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 "jacquard", 974 "jetstream", 975 "reqwest", 976 "serde_json", 977 "tokio", 978 "url",
··· 973 "jacquard", 974 "jetstream", 975 "reqwest", 976 + "serde", 977 "serde_json", 978 "tokio", 979 "url",
+1
Cargo.toml
··· 9 jacquard = "0.1.0" 10 jetstream = { path = "../links/jetstream" } 11 reqwest = { version = "0.12.23", features = ["json"] } 12 serde_json = "1.0.145" 13 tokio = { version = "1.47.1", features = ["full"] } 14 url = "2.5.7"
··· 9 jacquard = "0.1.0" 10 jetstream = { path = "../links/jetstream" } 11 reqwest = { version = "0.12.23", features = ["json"] } 12 + serde = { version = "1.0.228", features = ["derive"] } 13 serde_json = "1.0.145" 14 tokio = { version = "1.47.1", features = ["full"] } 15 url = "2.5.7"
+69 -68
src/main.rs
··· 25 }, 26 }; 27 use std::time::Duration; 28 29 type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; 30 ··· 51 /// warning: setting this can lead to rapid bot posting 52 #[arg(long)] 53 jetstream_cursor: Option<u64>, 54 } 55 56 async fn post<C: HttpClient>( ··· 61 title: &str, 62 repo_issues_url: &str, 63 ) -> Result<()> { 64 - let message = format!(r#"good-first-issue tagged for {repo_name}: 65 66 > {title}"#); 67 ··· 72 let repo_facet = Facet { 73 features: vec![Data::from_json(&repo_feature)?], 74 index: ByteSlice { 75 - byte_start: 28, 76 byte_end: 29 + repo_name.len() as i64, 77 extra_data: Default::default(), 78 }, ··· 125 Ok(()) 126 } 127 128 - async fn get_record(client: &reqwest::Client, at_uri: &str) -> Result<serde_json::Map<String, serde_json::Value>> { 129 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 130 url.set_path("/xrpc/com.bad-example.repo.getUriRecord"); 131 url.query_pairs_mut().append_pair("at_uri", at_uri); 132 - let record_map = client 133 .get(url) 134 .send() 135 .await? 136 .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) 147 } 148 149 async fn get_handle(client: &reqwest::Client, identifier: &str) -> Result<Option<String>> { 150 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 151 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 152 url.query_pairs_mut().append_pair("identifier", identifier); 153 - let handle = client 154 .get(url) 155 .send() 156 .await? 157 .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(); 167 if handle == "handle.invalid" { 168 Ok(None) 169 } else { ··· 233 eprintln!("consumer: commit update/delete missing record, ignoring"); 234 continue; 235 }; 236 - let jv: serde_json::Value = match record.get().parse() { 237 Ok(v) => v, 238 Err(e) => { 239 eprintln!("consumer: record failed to parse, ignoring: {e}"); 240 continue; 241 } 242 }; 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 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" { 262 println!("found a good first issue label!! {:?}", event.cursor); 263 added_good_first_issue = true; 264 - break; 265 } 266 eprintln!("found a label but it wasn't good-first-issue, ignoring..."); 267 } 268 if !added_good_first_issue { 269 continue; 270 } 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 276 - let issue_record = match get_record(&req_client, subject).await { 277 Ok(m) => m, 278 Err(e) => { 279 eprintln!("failed to get issue record: {e} for {subject}"); 280 continue; 281 } 282 }; 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 292 - let Ok(repo_uri) = AtUri::new(repo) else { 293 eprintln!("failed to parse repo to aturi for {subject}"); 294 continue; 295 }; 296 297 - let repo_record = match get_record(&req_client, repo).await { 298 Ok(m) => m, 299 Err(e) => { 300 eprintln!("failed to get repo record: {e} for {subject}"); 301 continue; 302 } 303 }; 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 309 let nice_tangled_repo_id = match repo_uri.authority() { 310 AtIdentifier::Handle(h) => format!("@{h}"), ··· 323 324 let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues"); 325 326 if let Err(e) = post( 327 &client, 328 &bot_id, 329 &repo_full_name, 330 &repo_url, 331 - title, 332 &issues_url, 333 ).await { 334 eprintln!("failed to post for {subject}: {e}"); ··· 336 337 break; 338 } 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 Ok(()) 345 }
··· 25 }, 26 }; 27 use std::time::Duration; 28 + use serde::Deserialize; 29 30 type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; 31 ··· 52 /// warning: setting this can lead to rapid bot posting 53 #[arg(long)] 54 jetstream_cursor: Option<u64>, 55 + /// don't actually post 56 + #[arg(long, action)] 57 + dry_run: bool, 58 } 59 60 async fn post<C: HttpClient>( ··· 65 title: &str, 66 repo_issues_url: &str, 67 ) -> Result<()> { 68 + let message = format!(r#"good-first-issue added for {repo_name}: 69 70 > {title}"#); 71 ··· 76 let repo_facet = Facet { 77 features: vec![Data::from_json(&repo_feature)?], 78 index: ByteSlice { 79 + byte_start: 27, 80 byte_end: 29 + repo_name.len() as i64, 81 extra_data: Default::default(), 82 }, ··· 129 Ok(()) 130 } 131 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> { 172 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 173 url.set_path("/xrpc/com.bad-example.repo.getUriRecord"); 174 url.query_pairs_mut().append_pair("at_uri", at_uri); 175 + let GetRecordResonse { value } = client 176 .get(url) 177 .send() 178 .await? 179 .error_for_status()? 180 + .json() 181 + .await?; 182 + Ok(value) 183 } 184 185 + /// try to resolve a bidirectionally verified handle from an identifier (via slingshot) 186 async fn get_handle(client: &reqwest::Client, identifier: &str) -> Result<Option<String>> { 187 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 188 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 189 url.query_pairs_mut().append_pair("identifier", identifier); 190 + let MiniDocResponse { handle } = client 191 .get(url) 192 .send() 193 .await? 194 .error_for_status()? 195 + .json() 196 + .await?; 197 if handle == "handle.invalid" { 198 Ok(None) 199 } else { ··· 263 eprintln!("consumer: commit update/delete missing record, ignoring"); 264 continue; 265 }; 266 + let CreateLabelRecord { add, subject } = match serde_json::from_str(record.get()) { 267 Ok(v) => v, 268 Err(e) => { 269 eprintln!("consumer: record failed to parse, ignoring: {e}"); 270 continue; 271 } 272 }; 273 let mut added_good_first_issue = false; 274 + for added in add { 275 + if added.key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" { 276 println!("found a good first issue label!! {:?}", event.cursor); 277 added_good_first_issue = true; 278 + break; // inner 279 } 280 eprintln!("found a label but it wasn't good-first-issue, ignoring..."); 281 } 282 if !added_good_first_issue { 283 continue; 284 } 285 286 + let IssueRecord { title, repo } = match get_record(&req_client, &subject).await { 287 Ok(m) => m, 288 Err(e) => { 289 eprintln!("failed to get issue record: {e} for {subject}"); 290 continue; 291 } 292 }; 293 294 + let Ok(repo_uri) = AtUri::new(&repo) else { 295 eprintln!("failed to parse repo to aturi for {subject}"); 296 continue; 297 }; 298 299 + let RepoRecord { name: repo_name } = match get_record(&req_client, &repo).await { 300 Ok(m) => m, 301 Err(e) => { 302 eprintln!("failed to get repo record: {e} for {subject}"); 303 continue; 304 } 305 }; 306 307 let nice_tangled_repo_id = match repo_uri.authority() { 308 AtIdentifier::Handle(h) => format!("@{h}"), ··· 321 322 let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues"); 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 + 331 if let Err(e) = post( 332 &client, 333 &bot_id, 334 &repo_full_name, 335 &repo_url, 336 + &title, 337 &issues_url, 338 ).await { 339 eprintln!("failed to post for {subject}: {e}"); ··· 341 342 break; 343 } 344 345 Ok(()) 346 }