announcing good-first-issue tags added on @tangled.sh (not affiliated with tangled!)
at 1841bda54f2f9fc790acb83ab9ddb3222e856462 385 lines 12 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 55struct IssueDetails { 56 repo_full_name: String, 57 repo_url: String, 58 title: String, 59 issues_url: String, 60} 61 62/// com.bad-example.identity.resolveMiniDoc bit we care about 63#[derive(Deserialize)] 64struct MiniDocResponse { 65 handle: String, 66} 67 68/// com.atproto.repo.getRecord wraps the record in a `value` key 69#[derive(Deserialize)] 70struct GetRecordResonse<T> { 71 value: T, 72} 73 74/// part of CreateLabelRecord: key is the label reference (ie for "good-first-issue") 75#[derive(Deserialize)] 76struct AddLabel { 77 key: String, 78} 79 80/// tangled's record for adding labels to an issue 81#[derive(Deserialize)] 82struct CreateLabelRecord { 83 add: Vec<AddLabel>, 84 subject: String, 85} 86 87/// tangled issue record 88#[derive(Deserialize)] 89struct IssueRecord { 90 title: String, 91 repo: String, 92} 93 94/// tangled repo record 95#[derive(Deserialize)] 96struct RepoRecord { 97 name: String, 98} 99 100/// get some atproto record content (from slingshot) 101async fn get_record<T: for<'a> Deserialize<'a>>( 102 client: &reqwest::Client, 103 at_uri: &str, 104) -> Result<T> { 105 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 106 url.set_path("/xrpc/com.bad-example.repo.getUriRecord"); 107 url.query_pairs_mut().append_pair("at_uri", at_uri); 108 let GetRecordResonse { value } = client 109 .get(url) 110 .send() 111 .await? 112 .error_for_status()? 113 .json() 114 .await?; 115 Ok(value) 116} 117 118/// try to resolve a bidirectionally verified handle from an identifier (via slingshot) 119async fn get_handle(client: &reqwest::Client, identifier: &str) -> Result<Option<String>> { 120 let mut url: Url = "https://slingshot.microcosm.blue".parse()?; 121 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 122 url.query_pairs_mut().append_pair("identifier", identifier); 123 let MiniDocResponse { handle } = client 124 .get(url) 125 .send() 126 .await? 127 .error_for_status()? 128 .json() 129 .await?; 130 if handle == "handle.invalid" { 131 Ok(None) 132 } else { 133 Ok(Some(handle)) 134 } 135} 136 137fn event_to_create_label<T: for<'a> Deserialize<'a>>(event: JetstreamEvent) -> Result<T> { 138 if event.kind != EventKind::Commit { 139 return Err("not a commit".into()); 140 } 141 let commit = event.commit.ok_or("commit event missing commit data")?; 142 if commit.operation != CommitOp::Create { 143 return Err("not a create event".into()); 144 } 145 146 let raw = commit.record.ok_or("commit missing record")?; 147 148 // todo: delete post if label is removed 149 // delete sample: at://did:plc:hdhoaan3xa3jiuq4fg4mefid/sh.tangled.label.op/3m2jvx4c6wf22 150 // tldr: has a "delete" array just like "add" on the same op collection 151 Ok(serde_json::from_str(raw.get())?) 152} 153 154async fn extract_issue_info( 155 client: &reqwest::Client, 156 adds: Vec<AddLabel>, 157 subject: String, 158) -> Result<IssueDetails> { 159 let mut added_good_first_issue = false; 160 for added in adds { 161 if added.key 162 == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" 163 { 164 log::info!("found a good first issue label!!"); 165 added_good_first_issue = true; 166 break; // inner 167 } 168 log::debug!("found a label but it wasn't good-first-issue, ignoring..."); 169 } 170 if !added_good_first_issue { 171 return Err("good-first-issue label not found in added labels".into()); 172 } 173 174 let IssueRecord { title, repo } = match get_record(client, &subject).await { 175 Ok(m) => m, 176 Err(e) => return Err(format!("failed to get issue record: {e} for {subject}").into()), 177 }; 178 179 let Ok(repo_uri) = AtUri::new(&repo) else { 180 return Err("failed to parse repo to aturi for {subject}".into()); 181 }; 182 183 let RepoRecord { name: repo_name } = match get_record(client, &repo).await { 184 Ok(m) => m, 185 Err(e) => return Err(format!("failed to get repo record: {e} for {subject}").into()), 186 }; 187 188 let nice_tangled_repo_id = match repo_uri.authority() { 189 AtIdentifier::Handle(h) => format!("@{h}"), 190 AtIdentifier::Did(did) => match get_handle(client, did.as_str()).await { 191 Err(e) => { 192 return Err(format!( 193 "failed to get mini doc from repo identifier: {e} for {subject}" 194 ) 195 .into()); 196 } 197 Ok(None) => did.to_string(), 198 Ok(Some(h)) => format!("@{h}"), 199 }, 200 }; 201 202 let repo_full_name = format!("{nice_tangled_repo_id}/{repo_name}"); 203 let repo_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}"); 204 205 let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues"); 206 207 Ok(IssueDetails { 208 repo_full_name, 209 repo_url, 210 title, 211 issues_url, 212 }) 213} 214 215async fn post( 216 client: &BasicClient, 217 identifier: &AtIdentifier<'_>, 218 IssueDetails { 219 repo_full_name, 220 repo_url, 221 title, 222 issues_url, 223 }: &IssueDetails, 224) -> Result<()> { 225 let message = format!( 226 r#"good-first-issue added for {repo_full_name}: 227 228> {title}"# 229 ); 230 231 let repo_feature = serde_json::json!({ 232 "$type": "app.bsky.richtext.facet#link", 233 "uri": repo_url, 234 }); 235 let repo_facet = Facet { 236 features: vec![Data::from_json(&repo_feature)?], 237 index: ByteSlice { 238 byte_start: 27, 239 byte_end: 29 + repo_full_name.len() as i64, 240 extra_data: Default::default(), 241 }, 242 extra_data: Default::default(), 243 }; 244 245 let title_starts_at = (29 + repo_full_name.len() + 5) as i64; 246 247 let repo_issues_feature = serde_json::json!({ 248 "$type": "app.bsky.richtext.facet#link", 249 "uri": issues_url, 250 }); 251 let issues_facet = Facet { 252 features: vec![Data::from_json(&repo_issues_feature)?], 253 index: ByteSlice { 254 byte_start: title_starts_at, 255 byte_end: title_starts_at + title.len() as i64, 256 extra_data: Default::default(), 257 }, 258 extra_data: Default::default(), 259 }; 260 261 // Make a post 262 let post = Post { 263 created_at: Datetime::now(), 264 langs: Some(vec![Language::new("en")?]), 265 text: message.into(), 266 facets: Some(vec![repo_facet, issues_facet]), 267 embed: Default::default(), 268 entities: Default::default(), 269 labels: Default::default(), 270 reply: Default::default(), 271 tags: Default::default(), 272 extra_data: Default::default(), 273 }; 274 275 let json = serde_json::to_value(post)?; 276 let data = Data::from_json(&json)?; 277 278 log::info!("\nposting..."); 279 client 280 .send( 281 CreateRecord::new() 282 .repo(identifier.clone()) 283 .collection(Post::nsid()) 284 .record(data) 285 .build(), 286 ) 287 .await? 288 .into_output()?; 289 290 Ok(()) 291} 292 293#[tokio::main] 294async fn main() -> Result<()> { 295 env_logger::init(); 296 let args = Args::parse(); 297 298 // Create HTTP client and session 299 let client = BasicClient::new(args.pds); 300 let bot_id = AtIdentifier::new(&args.identifier)?; 301 let create_session = CreateSession::new() 302 .identifier(bot_id.to_string()) 303 .password(&args.app_password) 304 .build(); 305 let session = Session::from(client.send(create_session.clone()).await?.into_output()?); 306 log::debug!("logged in as {} ({})", session.handle, session.did); 307 client.set_session(session).await?; 308 309 let slingshot_client = reqwest::Client::builder() 310 .user_agent("hacktober_bot") 311 .timeout(Duration::from_secs(9)) 312 .build()?; 313 314 let jetstream_config: JetstreamConfig = JetstreamConfig { 315 endpoint: args.jetstream_url.to_string(), 316 wanted_collections: vec![Nsid::new("sh.tangled.label.op".to_string())?], 317 user_agent: Some("hacktober_bot".to_string()), 318 compression: JetstreamCompression::Zstd, 319 replay_on_reconnect: true, 320 channel_size: 1024, // buffer up to ~1s of jetstream events 321 ..Default::default() 322 }; 323 let mut receiver = JetstreamConnector::new(jetstream_config)? 324 .connect_cursor(args.jetstream_cursor.map(Cursor::from_raw_u64)) 325 .await?; 326 327 log::info!("receiving jetstream messages..."); 328 loop { 329 let Some(event) = receiver.recv().await else { 330 log::error!("consumer: could not receive event, bailing"); 331 break; 332 }; 333 let cursor = event.cursor; 334 335 let CreateLabelRecord { add: adds, subject } = match event_to_create_label(event) { 336 Ok(clr) => clr, 337 Err(e) => { 338 log::debug!("ignoring unparseable event (at {cursor:?}): {e}"); 339 continue; 340 } 341 }; 342 343 let issue_details = match extract_issue_info(&slingshot_client, adds, subject.clone()).await 344 { 345 Ok(deets) => deets, 346 Err(e) => { 347 log::warn!("failed to extract issue details (at {cursor:?}): {e}"); 348 continue; 349 } 350 }; 351 352 if args.dry_run { 353 let IssueDetails { 354 repo_full_name, 355 repo_url, 356 title, 357 issues_url, 358 } = issue_details; 359 log::info!( 360 r#"--dry-run, but would have posted: 361 362good-first-issue label added for {repo_full_name} ({repo_url}): 363 364> {title} ({issues_url})"# 365 ); 366 continue; 367 } 368 369 if let Err(e) = post(&client, &bot_id, &issue_details).await { 370 log::warn!("failed to post for {subject}: {e}, refreshing session for one retry..."); 371 let session = Session::from(client.send(create_session.clone()).await?.into_output()?); 372 log::debug!("logged in as {} ({})", session.handle, session.did); 373 client.set_session(session).await?; 374 375 if let Err(e) = post(&client, &bot_id, &issue_details).await { 376 log::error!( 377 "failed to post after a session refresh: {e:?}, something is wrong. bye." 378 ); 379 break; 380 } 381 }; 382 } 383 384 Ok(()) 385}