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