announcing good-first-issue tags added on @tangled.sh (not affiliated with tangled!)
at bb300a448f32900a607ecd6567f16931b14a35b5 345 lines 11 kB view raw
1use clap::Parser; 2use url::Url; 3use jetstream::{ 4 JetstreamCompression, JetstreamConfig, JetstreamConnector, 5 events::{CommitOp, Cursor, EventKind, JetstreamEvent}, 6 exports::Nsid, 7}; 8use jacquard::{ 9 api::{ 10 app_bsky::feed::post::Post, 11 app_bsky::richtext::facet::{Facet, ByteSlice}, 12 com_atproto::server::create_session::CreateSession, 13 com_atproto::repo::create_record::CreateRecord, 14 }, 15 client::{BasicClient, Session}, 16 types::{ 17 datetime::Datetime, 18 ident::AtIdentifier, 19 language::Language, 20 collection::Collection, 21 value::Data, 22 string::AtUri, 23 }, 24}; 25 26use std::time::Duration; 27use serde::Deserialize; 28 29type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; 30 31#[derive(Debug, Parser)] 32#[command(version, about, long_about = None)] 33struct Args { 34 /// pds of the bot user 35 #[arg(short, long, env = "BOT_PDS")] 36 pds: Url, 37 /// handle or did of the bot user 38 #[arg(short, long, env = "BOT_HANDLE")] 39 identifier: String, 40 /// app password for the bot user 41 #[arg(short, long, env = "BOT_APP_PASSWORD")] 42 app_password: String, 43 /// lightweight firehose 44 #[arg(short, long, env = "BOT_JETSTREAM_URL")] 45 #[clap(default_value = "wss://jetstream1.us-east.fire.hose.cam/subscribe")] 46 jetstream_url: Url, 47 /// optional: we can pick up from a past jetstream cursor 48 /// 49 /// the default is to just live-tail 50 /// 51 /// warning: setting this can lead to rapid bot posting 52 #[arg(long)] 53 jetstream_cursor: Option<u64>, 54 /// don't actually post 55 #[arg(long, action)] 56 dry_run: bool, 57} 58 59async fn post( 60 client: &BasicClient, 61 identifier: &AtIdentifier<'_>, 62 repo_name: &str, 63 repo_url: &str, 64 title: &str, 65 repo_issues_url: &str, 66) -> Result<()> { 67 let message = format!(r#"good-first-issue added for {repo_name}: 68 69> {title}"#); 70 71 let repo_feature = serde_json::json!({ 72 "$type": "app.bsky.richtext.facet#link", 73 "uri": repo_url, 74 }); 75 let repo_facet = Facet { 76 features: vec![Data::from_json(&repo_feature)?], 77 index: ByteSlice { 78 byte_start: 27, 79 byte_end: 29 + repo_name.len() as i64, 80 extra_data: Default::default(), 81 }, 82 extra_data: Default::default(), 83 }; 84 85 let title_starts_at = (29 + repo_name.len() + 5) as i64; 86 87 let repo_issues_feature = serde_json::json!({ 88 "$type": "app.bsky.richtext.facet#link", 89 "uri": repo_issues_url, 90 }); 91 let issues_facet = Facet { 92 features: vec![Data::from_json(&repo_issues_feature)?], 93 index: ByteSlice { 94 byte_start: title_starts_at, 95 byte_end: title_starts_at + title.len() as i64, 96 extra_data: Default::default(), 97 }, 98 extra_data: Default::default(), 99 }; 100 101 // Make a post 102 let post = Post { 103 created_at: Datetime::now(), 104 langs: Some(vec![Language::new("en")?]), 105 text: message.into(), 106 facets: Some(vec![repo_facet, issues_facet]), 107 embed: Default::default(), 108 entities: Default::default(), 109 labels: Default::default(), 110 reply: Default::default(), 111 tags: Default::default(), 112 extra_data: Default::default(), 113 }; 114 115 let json = serde_json::to_value(post)?; 116 let data = Data::from_json(&json)?; 117 118 println!("\nposting..."); 119 client 120 .send(CreateRecord::new() 121 .repo(identifier.clone()) 122 .collection(Post::nsid()) 123 .record(data) 124 .build()) 125 .await? 126 .into_output()?; 127 128 Ok(()) 129} 130 131fn event_to_create_label<T: for <'a> Deserialize<'a>>(event: JetstreamEvent) -> Result<(T, Cursor)> { 132 if event.kind != EventKind::Commit { 133 return Err("not a commit".into()); 134 } 135 let commit = event.commit.ok_or("commit event missing commit data")?; 136 if commit.operation != CommitOp::Create { 137 return Err("not a create event".into()); 138 } 139 140 let raw = commit.record.ok_or("commit missing record")?; 141 142 // todo: delete post if label is removed 143 // delete sample: at://did:plc:hdhoaan3xa3jiuq4fg4mefid/sh.tangled.label.op/3m2jvx4c6wf22 144 // tldr: has a "delete" array just like "add" on the same op collection 145 let t = serde_json::from_str(raw.get())?; 146 Ok((t, event.cursor)) 147} 148 149/// com.bad-example.identity.resolveMiniDoc bit we care about 150#[derive(Deserialize)] 151struct MiniDocResponse { 152 handle: String, 153} 154 155/// com.atproto.repo.getRecord wraps the record in a `value` key 156#[derive(Deserialize)] 157struct GetRecordResonse<T> { 158 value: T, 159} 160 161/// part of CreateLabelRecord: key is the label reference (ie for "good-first-issue") 162#[derive(Deserialize)] 163struct AddLabel { 164 key: String, 165} 166 167/// tangled's record for adding labels to an issue 168#[derive(Deserialize)] 169struct CreateLabelRecord { 170 add: Vec<AddLabel>, 171 subject: String, 172} 173 174/// tangled issue record 175#[derive(Deserialize)] 176struct IssueRecord { 177 title: String, 178 repo: String, 179} 180 181/// tangled repo record 182#[derive(Deserialize)] 183struct RepoRecord { 184 name: String, 185} 186 187/// get some atproto record content (from slingshot) 188async fn get_record<T: for<'a> Deserialize<'a>>(client: &reqwest::Client, at_uri: &str) -> Result<T> { 189 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 190 url.set_path("/xrpc/com.bad-example.repo.getUriRecord"); 191 url.query_pairs_mut().append_pair("at_uri", at_uri); 192 let GetRecordResonse { value } = client 193 .get(url) 194 .send() 195 .await? 196 .error_for_status()? 197 .json() 198 .await?; 199 Ok(value) 200} 201 202/// try to resolve a bidirectionally verified handle from an identifier (via slingshot) 203async fn get_handle(client: &reqwest::Client, identifier: &str) -> Result<Option<String>> { 204 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 205 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 206 url.query_pairs_mut().append_pair("identifier", identifier); 207 let MiniDocResponse { handle } = client 208 .get(url) 209 .send() 210 .await? 211 .error_for_status()? 212 .json() 213 .await?; 214 if handle == "handle.invalid" { 215 Ok(None) 216 } else { 217 Ok(Some(handle)) 218 } 219} 220 221#[tokio::main] 222async fn main() -> Result<()> { 223 env_logger::init(); 224 let args = Args::parse(); 225 226 // Create HTTP client and session 227 let client = BasicClient::new(args.pds); 228 let bot_id = AtIdentifier::new(&args.identifier)?; 229 let session = Session::from( 230 client 231 .send( 232 CreateSession::new() 233 .identifier(&bot_id.to_string()) 234 .password(args.app_password) 235 .build(), 236 ) 237 .await? 238 .into_output()?, 239 ); 240 println!("logged in as {} ({})", session.handle, session.did); 241 client.set_session(session).await?; 242 243 let slingshot_client = reqwest::Client::builder() 244 .user_agent("hacktober_bot") 245 .timeout(Duration::from_secs(9)) 246 .build()?; 247 248 let jetstream_config: JetstreamConfig = JetstreamConfig { 249 endpoint: args.jetstream_url.to_string(), 250 wanted_collections: vec![Nsid::new("sh.tangled.label.op".to_string())?], 251 user_agent: Some("hacktober_bot".to_string()), 252 compression: JetstreamCompression::Zstd, 253 replay_on_reconnect: true, 254 channel_size: 1024, // buffer up to ~1s of jetstream events 255 ..Default::default() 256 }; 257 let mut receiver = JetstreamConnector::new(jetstream_config)? 258 .connect_cursor(args.jetstream_cursor.map(Cursor::from_raw_u64)) 259 .await?; 260 261 println!("receiving jetstream messages..."); 262 loop { 263 let Some(event) = receiver.recv().await else { 264 eprintln!("consumer: could not receive event, bailing"); 265 break; 266 }; 267 268 let Ok((CreateLabelRecord { add, subject }, cursor)) = event_to_create_label(event) else { 269 continue; 270 }; 271 272 let mut added_good_first_issue = false; 273 for added in add { 274 if added.key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" { 275 println!("found a good first issue label!! {:?}", cursor); 276 added_good_first_issue = true; 277 break; // inner 278 } 279 eprintln!("found a label but it wasn't good-first-issue, ignoring..."); 280 } 281 if !added_good_first_issue { 282 continue; 283 } 284 285 let IssueRecord { title, repo } = match get_record(&slingshot_client, &subject).await { 286 Ok(m) => m, 287 Err(e) => { 288 eprintln!("failed to get issue record: {e} for {subject}"); 289 continue; 290 } 291 }; 292 293 let Ok(repo_uri) = AtUri::new(&repo) else { 294 eprintln!("failed to parse repo to aturi for {subject}"); 295 continue; 296 }; 297 298 let RepoRecord { name: repo_name } = match get_record(&slingshot_client, &repo).await { 299 Ok(m) => m, 300 Err(e) => { 301 eprintln!("failed to get repo record: {e} for {subject}"); 302 continue; 303 } 304 }; 305 306 let nice_tangled_repo_id = match repo_uri.authority() { 307 AtIdentifier::Handle(h) => format!("@{h}"), 308 AtIdentifier::Did(did) => match get_handle(&slingshot_client, did.as_str()).await { 309 Err(e) => { 310 eprintln!("failed to get mini doc from repo identifier: {e} for {subject}"); 311 continue; 312 } 313 Ok(None) => did.to_string(), 314 Ok(Some(h)) => format!("@{h}"), 315 } 316 }; 317 318 let repo_full_name = format!("{nice_tangled_repo_id}/{repo_name}"); 319 let repo_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}"); 320 321 let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues"); 322 323 if args.dry_run { 324 println!("--dry-run, but would have posted:"); 325 println!("good-first-issue label added for {repo_full_name} ({repo_url}):"); 326 println!("> {title} ({issues_url})\n"); 327 continue; 328 } 329 330 if let Err(e) = post( 331 &client, 332 &bot_id, 333 &repo_full_name, 334 &repo_url, 335 &title, 336 &issues_url, 337 ).await { 338 eprintln!("failed to post for {subject}: {e}"); 339 }; 340 341 break; 342 } 343 344 Ok(()) 345}