From 4a2767b4a2714843eb5e0fb0d29b262a523a37fc Mon Sep 17 00:00:00 2001 From: phil Date: Mon, 6 Oct 2025 10:49:25 -0400 Subject: [PATCH] use serde derives to simplify parsing --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 137 ++++++++++++++++++++++++++-------------------------- 3 files changed, 71 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2aa8084..c0c871a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -973,6 +973,7 @@ dependencies = [ "jacquard", "jetstream", "reqwest", + "serde", "serde_json", "tokio", "url", diff --git a/Cargo.toml b/Cargo.toml index 04838a5..5fb58a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ env_logger = "0.11.8" jacquard = "0.1.0" jetstream = { path = "../links/jetstream" } reqwest = { version = "0.12.23", features = ["json"] } +serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" tokio = { version = "1.47.1", features = ["full"] } url = "2.5.7" diff --git a/src/main.rs b/src/main.rs index 8b413f4..4b0ca85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ use jacquard::{ }, }; use std::time::Duration; +use serde::Deserialize; type Result = std::result::Result>; @@ -51,6 +52,9 @@ struct Args { /// warning: setting this can lead to rapid bot posting #[arg(long)] jetstream_cursor: Option, + /// don't actually post + #[arg(long, action)] + dry_run: bool, } async fn post( @@ -61,7 +65,7 @@ async fn post( title: &str, repo_issues_url: &str, ) -> Result<()> { - let message = format!(r#"good-first-issue tagged for {repo_name}: + let message = format!(r#"good-first-issue added for {repo_name}: > {title}"#); @@ -72,7 +76,7 @@ async fn post( let repo_facet = Facet { features: vec![Data::from_json(&repo_feature)?], index: ByteSlice { - byte_start: 28, + byte_start: 27, byte_end: 29 + repo_name.len() as i64, extra_data: Default::default(), }, @@ -125,45 +129,71 @@ async fn post( Ok(()) } -async fn get_record(client: &reqwest::Client, at_uri: &str) -> Result> { +/// com.bad-example.identity.resolveMiniDoc bit we care about +#[derive(Deserialize)] +struct MiniDocResponse { + handle: String, +} + +/// com.atproto.repo.getRecord wraps the record in a `value` key +#[derive(Deserialize)] +struct GetRecordResonse { + value: T, +} + +/// part of CreateLabelRecord: key is the label reference (ie for "good-first-issue") +#[derive(Deserialize)] +struct AddLabel { + key: String, +} + +/// tangled's record for adding labels to an issue +#[derive(Deserialize)] +struct CreateLabelRecord { + add: Vec, + subject: String, +} + +/// tangled issue record +#[derive(Deserialize)] +struct IssueRecord { + title: String, + repo: String, +} + +/// tangled repo record +#[derive(Deserialize)] +struct RepoRecord { + name: String, +} + +/// get some atproto record content (from slingshot) +async fn get_record Deserialize<'a>>(client: &reqwest::Client, at_uri: &str) -> Result { let mut url: Url = "https://slingshot.microcosm.blue".parse()?; url.set_path("/xrpc/com.bad-example.repo.getUriRecord"); url.query_pairs_mut().append_pair("at_uri", at_uri); - let record_map = client + let GetRecordResonse { value } = client .get(url) .send() .await? .error_for_status()? - .json::() - .await? - .as_object() - .ok_or("get_record response was not a json object")? - .get("value") - .ok_or("get_record response obj did not have 'value' key")? - .as_object() - .ok_or("get_record response.value was not an object")? - .clone(); - Ok(record_map) + .json() + .await?; + Ok(value) } +/// try to resolve a bidirectionally verified handle from an identifier (via slingshot) async fn get_handle(client: &reqwest::Client, identifier: &str) -> Result> { let mut url: Url = "https://slingshot.microcosm.blue".parse()?; url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); url.query_pairs_mut().append_pair("identifier", identifier); - let handle = client + let MiniDocResponse { handle } = client .get(url) .send() .await? .error_for_status()? - .json::() - .await? - .as_object() - .ok_or("minidoc response was not a json object")? - .get("handle") - .ok_or("minidoc response obj did not have 'handle' key")? - .as_str() - .ok_or("minidoc handle was not a string")? - .to_string(); + .json() + .await?; if handle == "handle.invalid" { Ok(None) } else { @@ -233,78 +263,46 @@ async fn main() -> Result<()> { eprintln!("consumer: commit update/delete missing record, ignoring"); continue; }; - let jv: serde_json::Value = match record.get().parse() { + let CreateLabelRecord { add, subject } = match serde_json::from_str(record.get()) { Ok(v) => v, Err(e) => { eprintln!("consumer: record failed to parse, ignoring: {e}"); continue; } }; - let serde_json::Value::Object(o) = jv else { - eprintln!("record was not an object, ignoring"); - continue; - }; - let Some(serde_json::Value::Array(adds)) = o.get("add") else { - eprintln!("op did not have label added or was not an array"); - continue; - }; let mut added_good_first_issue = false; - for added in adds { - let serde_json::Value::Object(a) = added else { - eprintln!("added item was not an obj"); - continue; - }; - let Some(serde_json::Value::String(key)) = a.get("key") else { - eprintln!("added was missing key prop"); - continue; - }; - if key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" { + for added in add { + if added.key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" { println!("found a good first issue label!! {:?}", event.cursor); added_good_first_issue = true; - break; + break; // inner } eprintln!("found a label but it wasn't good-first-issue, ignoring..."); } if !added_good_first_issue { continue; } - let Some(serde_json::Value::String(subject)) = o.get("subject") else { - eprintln!("could not find `subject` string for the good-first-issue label"); - continue; - }; - let issue_record = match get_record(&req_client, subject).await { + let IssueRecord { title, repo } = match get_record(&req_client, &subject).await { Ok(m) => m, Err(e) => { eprintln!("failed to get issue record: {e} for {subject}"); continue; } }; - let Some(serde_json::Value::String(title)) = issue_record.get("title") else { - eprintln!("failed to get title from issue for {subject}"); - continue; - }; - let Some(serde_json::Value::String(repo)) = issue_record.get("repo") else { - eprintln!("failed to get repo from issue for {subject}"); - continue; - }; - let Ok(repo_uri) = AtUri::new(repo) else { + let Ok(repo_uri) = AtUri::new(&repo) else { eprintln!("failed to parse repo to aturi for {subject}"); continue; }; - let repo_record = match get_record(&req_client, repo).await { + let RepoRecord { name: repo_name } = match get_record(&req_client, &repo).await { Ok(m) => m, Err(e) => { eprintln!("failed to get repo record: {e} for {subject}"); continue; } }; - let Some(serde_json::Value::String(repo_name)) = repo_record.get("name") else { - eprintln!("failed to get name for repo for {subject}"); - continue; - }; let nice_tangled_repo_id = match repo_uri.authority() { AtIdentifier::Handle(h) => format!("@{h}"), @@ -323,12 +321,19 @@ async fn main() -> Result<()> { let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues"); + if args.dry_run { + println!("--dry-run, but would have posted:"); + println!("good-first-issue label added for {repo_full_name} ({repo_url}):"); + println!("> {title} ({issues_url})\n"); + continue; + } + if let Err(e) = post( &client, &bot_id, &repo_full_name, &repo_url, - title, + &title, &issues_url, ).await { eprintln!("failed to post for {subject}: {e}"); @@ -337,9 +342,5 @@ async fn main() -> Result<()> { break; } - // let u2: Url = "https://bad-example.com".parse()?; - // let bot_id = AtIdentifier::new(&args.identifier)?; - // post_link(&client, &bot_id, "link test 2: ", u2).await?; - Ok(()) } -- 2.51.2