announcing good-first-issue tags added on @tangled.sh (not affiliated with tangled!)

extact more stuff

Changed files
+183 -181
src
+183 -181
src/main.rs
··· 52 dry_run: bool, 53 } 54 55 - async 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 - log::info!("\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 - 131 - fn 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 ··· 221 } 222 } 223 224 #[tokio::main] 225 async fn main() -> Result<()> { 226 env_logger::init(); ··· 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 log::debug!("logged in as {} ({})", session.handle, session.did); 244 client.set_session(session).await?; 245 ··· 267 log::error!("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 - log::info!("found a good first issue label!! {:?}", cursor); 281 - added_good_first_issue = true; 282 - break; // inner 283 - } 284 - log::debug!("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 - log::warn!("failed to get issue record: {e} for {subject}"); 294 continue; 295 } 296 }; 297 298 - let Ok(repo_uri) = AtUri::new(&repo) else { 299 - log::warn!("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 - log::warn!("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 - log::warn!("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 log::info!( 330 r#"--dry-run, but would have posted: 331 ··· 336 continue; 337 } 338 339 - if let Err(e) = post( 340 - &client, 341 - &bot_id, 342 - &repo_full_name, 343 - &repo_url, 344 - &title, 345 - &issues_url, 346 - ) 347 - .await 348 - { 349 log::warn!("failed to post for {subject}: {e}, refreshing session for one retry..."); 350 - let session = Session::from( 351 - client 352 - .send( 353 - CreateSession::new() 354 - .identifier(bot_id.to_string()) 355 - .password(&args.app_password) 356 - .build(), 357 - ) 358 - .await? 359 - .into_output()?, 360 - ); 361 log::debug!("logged in as {} ({})", session.handle, session.did); 362 client.set_session(session).await?; 363 364 - if let Err(e) = post( 365 - &client, 366 - &bot_id, 367 - &repo_full_name, 368 - &repo_url, 369 - &title, 370 - &issues_url, 371 - ) 372 - .await 373 - { 374 log::error!( 375 "failed to post after a session refresh: {e:?}, something is wrong. bye." 376 );
··· 52 dry_run: bool, 53 } 54 55 + struct 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 ··· 134 } 135 } 136 137 + fn 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 + 154 + async 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 + 215 + async 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] 294 async fn main() -> Result<()> { 295 env_logger::init(); ··· 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 ··· 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 ··· 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 );