announcing good-first-issue tags added on @tangled.sh (not affiliated with tangled!)

use serde derives to simplify parsing

+71 -68
+1
Cargo.lock
··· 973 973 "jacquard", 974 974 "jetstream", 975 975 "reqwest", 976 + "serde", 976 977 "serde_json", 977 978 "tokio", 978 979 "url",
+1
Cargo.toml
··· 9 9 jacquard = "0.1.0" 10 10 jetstream = { path = "../links/jetstream" } 11 11 reqwest = { version = "0.12.23", features = ["json"] } 12 + serde = { version = "1.0.228", features = ["derive"] } 12 13 serde_json = "1.0.145" 13 14 tokio = { version = "1.47.1", features = ["full"] } 14 15 url = "2.5.7"
+69 -68
src/main.rs
··· 25 25 }, 26 26 }; 27 27 use std::time::Duration; 28 + use serde::Deserialize; 28 29 29 30 type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; 30 31 ··· 51 52 /// warning: setting this can lead to rapid bot posting 52 53 #[arg(long)] 53 54 jetstream_cursor: Option<u64>, 55 + /// don't actually post 56 + #[arg(long, action)] 57 + dry_run: bool, 54 58 } 55 59 56 60 async fn post<C: HttpClient>( ··· 61 65 title: &str, 62 66 repo_issues_url: &str, 63 67 ) -> Result<()> { 64 - let message = format!(r#"good-first-issue tagged for {repo_name}: 68 + let message = format!(r#"good-first-issue added for {repo_name}: 65 69 66 70 > {title}"#); 67 71 ··· 72 76 let repo_facet = Facet { 73 77 features: vec![Data::from_json(&repo_feature)?], 74 78 index: ByteSlice { 75 - byte_start: 28, 79 + byte_start: 27, 76 80 byte_end: 29 + repo_name.len() as i64, 77 81 extra_data: Default::default(), 78 82 }, ··· 125 129 Ok(()) 126 130 } 127 131 128 - async fn get_record(client: &reqwest::Client, at_uri: &str) -> Result<serde_json::Map<String, serde_json::Value>> { 132 + /// com.bad-example.identity.resolveMiniDoc bit we care about 133 + #[derive(Deserialize)] 134 + struct MiniDocResponse { 135 + handle: String, 136 + } 137 + 138 + /// com.atproto.repo.getRecord wraps the record in a `value` key 139 + #[derive(Deserialize)] 140 + struct GetRecordResonse<T> { 141 + value: T, 142 + } 143 + 144 + /// part of CreateLabelRecord: key is the label reference (ie for "good-first-issue") 145 + #[derive(Deserialize)] 146 + struct AddLabel { 147 + key: String, 148 + } 149 + 150 + /// tangled's record for adding labels to an issue 151 + #[derive(Deserialize)] 152 + struct CreateLabelRecord { 153 + add: Vec<AddLabel>, 154 + subject: String, 155 + } 156 + 157 + /// tangled issue record 158 + #[derive(Deserialize)] 159 + struct IssueRecord { 160 + title: String, 161 + repo: String, 162 + } 163 + 164 + /// tangled repo record 165 + #[derive(Deserialize)] 166 + struct RepoRecord { 167 + name: String, 168 + } 169 + 170 + /// get some atproto record content (from slingshot) 171 + async fn get_record<T: for<'a> Deserialize<'a>>(client: &reqwest::Client, at_uri: &str) -> Result<T> { 129 172 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 130 173 url.set_path("/xrpc/com.bad-example.repo.getUriRecord"); 131 174 url.query_pairs_mut().append_pair("at_uri", at_uri); 132 - let record_map = client 175 + let GetRecordResonse { value } = client 133 176 .get(url) 134 177 .send() 135 178 .await? 136 179 .error_for_status()? 137 - .json::<serde_json::Value>() 138 - .await? 139 - .as_object() 140 - .ok_or("get_record response was not a json object")? 141 - .get("value") 142 - .ok_or("get_record response obj did not have 'value' key")? 143 - .as_object() 144 - .ok_or("get_record response.value was not an object")? 145 - .clone(); 146 - Ok(record_map) 180 + .json() 181 + .await?; 182 + Ok(value) 147 183 } 148 184 185 + /// try to resolve a bidirectionally verified handle from an identifier (via slingshot) 149 186 async fn get_handle(client: &reqwest::Client, identifier: &str) -> Result<Option<String>> { 150 187 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 151 188 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 152 189 url.query_pairs_mut().append_pair("identifier", identifier); 153 - let handle = client 190 + let MiniDocResponse { handle } = client 154 191 .get(url) 155 192 .send() 156 193 .await? 157 194 .error_for_status()? 158 - .json::<serde_json::Value>() 159 - .await? 160 - .as_object() 161 - .ok_or("minidoc response was not a json object")? 162 - .get("handle") 163 - .ok_or("minidoc response obj did not have 'handle' key")? 164 - .as_str() 165 - .ok_or("minidoc handle was not a string")? 166 - .to_string(); 195 + .json() 196 + .await?; 167 197 if handle == "handle.invalid" { 168 198 Ok(None) 169 199 } else { ··· 233 263 eprintln!("consumer: commit update/delete missing record, ignoring"); 234 264 continue; 235 265 }; 236 - let jv: serde_json::Value = match record.get().parse() { 266 + let CreateLabelRecord { add, subject } = match serde_json::from_str(record.get()) { 237 267 Ok(v) => v, 238 268 Err(e) => { 239 269 eprintln!("consumer: record failed to parse, ignoring: {e}"); 240 270 continue; 241 271 } 242 272 }; 243 - let serde_json::Value::Object(o) = jv else { 244 - eprintln!("record was not an object, ignoring"); 245 - continue; 246 - }; 247 - let Some(serde_json::Value::Array(adds)) = o.get("add") else { 248 - eprintln!("op did not have label added or was not an array"); 249 - continue; 250 - }; 251 273 let mut added_good_first_issue = false; 252 - for added in adds { 253 - let serde_json::Value::Object(a) = added else { 254 - eprintln!("added item was not an obj"); 255 - continue; 256 - }; 257 - let Some(serde_json::Value::String(key)) = a.get("key") else { 258 - eprintln!("added was missing key prop"); 259 - continue; 260 - }; 261 - if key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" { 274 + for added in add { 275 + if added.key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" { 262 276 println!("found a good first issue label!! {:?}", event.cursor); 263 277 added_good_first_issue = true; 264 - break; 278 + break; // inner 265 279 } 266 280 eprintln!("found a label but it wasn't good-first-issue, ignoring..."); 267 281 } 268 282 if !added_good_first_issue { 269 283 continue; 270 284 } 271 - let Some(serde_json::Value::String(subject)) = o.get("subject") else { 272 - eprintln!("could not find `subject` string for the good-first-issue label"); 273 - continue; 274 - }; 275 285 276 - let issue_record = match get_record(&req_client, subject).await { 286 + let IssueRecord { title, repo } = match get_record(&req_client, &subject).await { 277 287 Ok(m) => m, 278 288 Err(e) => { 279 289 eprintln!("failed to get issue record: {e} for {subject}"); 280 290 continue; 281 291 } 282 292 }; 283 - let Some(serde_json::Value::String(title)) = issue_record.get("title") else { 284 - eprintln!("failed to get title from issue for {subject}"); 285 - continue; 286 - }; 287 - let Some(serde_json::Value::String(repo)) = issue_record.get("repo") else { 288 - eprintln!("failed to get repo from issue for {subject}"); 289 - continue; 290 - }; 291 293 292 - let Ok(repo_uri) = AtUri::new(repo) else { 294 + let Ok(repo_uri) = AtUri::new(&repo) else { 293 295 eprintln!("failed to parse repo to aturi for {subject}"); 294 296 continue; 295 297 }; 296 298 297 - let repo_record = match get_record(&req_client, repo).await { 299 + let RepoRecord { name: repo_name } = match get_record(&req_client, &repo).await { 298 300 Ok(m) => m, 299 301 Err(e) => { 300 302 eprintln!("failed to get repo record: {e} for {subject}"); 301 303 continue; 302 304 } 303 305 }; 304 - let Some(serde_json::Value::String(repo_name)) = repo_record.get("name") else { 305 - eprintln!("failed to get name for repo for {subject}"); 306 - continue; 307 - }; 308 306 309 307 let nice_tangled_repo_id = match repo_uri.authority() { 310 308 AtIdentifier::Handle(h) => format!("@{h}"), ··· 323 321 324 322 let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues"); 325 323 324 + if args.dry_run { 325 + println!("--dry-run, but would have posted:"); 326 + println!("good-first-issue label added for {repo_full_name} ({repo_url}):"); 327 + println!("> {title} ({issues_url})\n"); 328 + continue; 329 + } 330 + 326 331 if let Err(e) = post( 327 332 &client, 328 333 &bot_id, 329 334 &repo_full_name, 330 335 &repo_url, 331 - title, 336 + &title, 332 337 &issues_url, 333 338 ).await { 334 339 eprintln!("failed to post for {subject}: {e}"); ··· 336 341 337 342 break; 338 343 } 339 - 340 - // let u2: Url = "https://bad-example.com".parse()?; 341 - // let bot_id = AtIdentifier::new(&args.identifier)?; 342 - // post_link(&client, &bot_id, "link test 2: ", u2).await?; 343 344 344 345 Ok(()) 345 346 }