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

Compare changes

Choose any two refs to compare.

Changed files
+248 -181
docs
src
docs/october-dolly.png

This is a binary file and will not be displayed.

+7
license
··· 1 + Copyright (c) 2025 @bad-example.com 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 + 5 + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+32
readme.md
··· 1 + # ๐ŸŽƒ Happy hacktober! ๐Ÿง™๐Ÿผโ€โ™€๏ธ 2 + 3 + ![tangle's dolly as a witchy pumpkin](./docs/october-dolly.png) 4 + 5 + [This bot](https://bsky.app/profile/hacktober.tngl.sh) listens to the [jetstream](github.com/bluesky-social/jetstream) firehose, filters for labels added to issues on [tangled.org](https://tangled.org/), checks if they are the official [`good-first-issue`](https://tangled.org/goodfirstissues) label, and then [posts](https://bsky.app/profile/hacktober.tngl.sh/post/3m2oflabdmc2u) about it! 6 + 7 + 8 + ### It's made with: 9 + 10 + - [jacquard](https://docs.rs/jacquard/latest/jacquard/): auth and posting 11 + - [microcosm slingshot](https://slingshot.microcosm.blue/): identity resolution and record fetching 12 + - [microcosm jetstream](https://tangled.org/@microcosm.blue/microcosm-rs/tree/main/jetstream): firehose listener 13 + - [tangled's](https://tangled.org/) PDS hosts the bot's account! 14 + 15 + ### It's made by: 16 + 17 + - [@bad-example.com](https://bsky.app/profile/bad-example.com): [ko-fi](https://ko-fi.com/bad_example), [github sponsors](https://github.com/sponsors/uniphil/) 18 + 19 + 20 + ### It would be nice if this bot would: 21 + 22 + - [ ] pull [OG repo images](https://bsky.app/profile/oppi.li/post/3m2orohxal22j) so it can post with an external link embed 23 + 24 + ### It would be nice if this bot *could*: 25 + 26 + - [ ] link directly to the issue, instead of the repo's all-issues page. i don't think it's possible right now because the issue page URL needs the issue id number, which is only kept in tangled's appview at the moment. 27 + - [ ] reply to its posts when issues are closed as complete! again currently the open/closed state for tangled is only in the appview, so this is not currently possible to detect. 28 + 29 + 30 + ### Things to watch out for if you hack on it 31 + 32 + - [ ] The microcosm jetstream package isn't published, so this currently uses a horrible local path reference for it, and that reference uses a very old folder name that you won't get by default from cloning [microcosm-rs](https://tangled.org/@microcosm.blue/microcosm-rs). If you rename microcosm-rs's folder name to `links`, it should work! or ping me and i'll fix it.
+209 -181
src/main.rs
··· 50 50 /// don't actually post 51 51 #[arg(long, action)] 52 52 dry_run: bool, 53 + /// send a checkin to this url every 5 mins 54 + #[arg(long)] 55 + healthcheck_ping: Option<Url>, 53 56 } 54 57 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)) 58 + struct IssueDetails { 59 + repo_full_name: String, 60 + repo_url: String, 61 + title: String, 62 + issues_url: String, 147 63 } 148 64 149 65 /// com.bad-example.identity.resolveMiniDoc bit we care about ··· 221 137 } 222 138 } 223 139 140 + fn event_to_create_label<T: for<'a> Deserialize<'a>>(event: JetstreamEvent) -> Result<T> { 141 + if event.kind != EventKind::Commit { 142 + return Err("not a commit".into()); 143 + } 144 + let commit = event.commit.ok_or("commit event missing commit data")?; 145 + if commit.operation != CommitOp::Create { 146 + return Err("not a create event".into()); 147 + } 148 + 149 + let raw = commit.record.ok_or("commit missing record")?; 150 + 151 + // todo: delete post if label is removed 152 + // delete sample: at://did:plc:hdhoaan3xa3jiuq4fg4mefid/sh.tangled.label.op/3m2jvx4c6wf22 153 + // tldr: has a "delete" array just like "add" on the same op collection 154 + Ok(serde_json::from_str(raw.get())?) 155 + } 156 + 157 + async fn extract_issue_info( 158 + client: &reqwest::Client, 159 + adds: Vec<AddLabel>, 160 + subject: String, 161 + ) -> Result<IssueDetails> { 162 + let mut added_good_first_issue = false; 163 + for added in adds { 164 + if added.key 165 + == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" 166 + { 167 + log::info!("found a good first issue label!!"); 168 + added_good_first_issue = true; 169 + break; // inner 170 + } 171 + log::debug!("found a label but it wasn't good-first-issue, ignoring..."); 172 + } 173 + if !added_good_first_issue { 174 + return Err("good-first-issue label not found in added labels".into()); 175 + } 176 + 177 + let IssueRecord { title, repo } = match get_record(client, &subject).await { 178 + Ok(m) => m, 179 + Err(e) => return Err(format!("failed to get issue record: {e} for {subject}").into()), 180 + }; 181 + 182 + let Ok(repo_uri) = AtUri::new(&repo) else { 183 + return Err("failed to parse repo to aturi for {subject}".into()); 184 + }; 185 + 186 + let RepoRecord { name: repo_name } = match get_record(client, &repo).await { 187 + Ok(m) => m, 188 + Err(e) => return Err(format!("failed to get repo record: {e} for {subject}").into()), 189 + }; 190 + 191 + let nice_tangled_repo_id = match repo_uri.authority() { 192 + AtIdentifier::Handle(h) => format!("@{h}"), 193 + AtIdentifier::Did(did) => match get_handle(client, did.as_str()).await { 194 + Err(e) => { 195 + return Err(format!( 196 + "failed to get mini doc from repo identifier: {e} for {subject}" 197 + ) 198 + .into()); 199 + } 200 + Ok(None) => did.to_string(), 201 + Ok(Some(h)) => format!("@{h}"), 202 + }, 203 + }; 204 + 205 + let repo_full_name = format!("{nice_tangled_repo_id}/{repo_name}"); 206 + let repo_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}"); 207 + 208 + let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues"); 209 + 210 + Ok(IssueDetails { 211 + repo_full_name, 212 + repo_url, 213 + title, 214 + issues_url, 215 + }) 216 + } 217 + 218 + async fn post( 219 + client: &BasicClient, 220 + identifier: &AtIdentifier<'_>, 221 + IssueDetails { 222 + repo_full_name, 223 + repo_url, 224 + title, 225 + issues_url, 226 + }: &IssueDetails, 227 + ) -> Result<()> { 228 + let message = format!( 229 + r#"New from {repo_full_name}: 230 + 231 + > {title}"# 232 + ); 233 + 234 + let pre_len = 9; 235 + 236 + let repo_feature = serde_json::json!({ 237 + "$type": "app.bsky.richtext.facet#link", 238 + "uri": repo_url, 239 + }); 240 + let repo_facet = Facet { 241 + features: vec![Data::from_json(&repo_feature)?], 242 + index: ByteSlice { 243 + byte_start: pre_len, 244 + byte_end: pre_len + repo_full_name.len() as i64, 245 + extra_data: Default::default(), 246 + }, 247 + extra_data: Default::default(), 248 + }; 249 + 250 + let title_starts_at = pre_len + (repo_full_name.len() + 5) as i64; 251 + 252 + let repo_issues_feature = serde_json::json!({ 253 + "$type": "app.bsky.richtext.facet#link", 254 + "uri": issues_url, 255 + }); 256 + let issues_facet = Facet { 257 + features: vec![Data::from_json(&repo_issues_feature)?], 258 + index: ByteSlice { 259 + byte_start: title_starts_at, 260 + byte_end: title_starts_at + title.len() as i64, 261 + extra_data: Default::default(), 262 + }, 263 + extra_data: Default::default(), 264 + }; 265 + 266 + // Make a post 267 + let post = Post { 268 + created_at: Datetime::now(), 269 + langs: Some(vec![Language::new("en")?]), 270 + text: message.into(), 271 + facets: Some(vec![repo_facet, issues_facet]), 272 + embed: Default::default(), 273 + entities: Default::default(), 274 + labels: Default::default(), 275 + reply: Default::default(), 276 + tags: Default::default(), 277 + extra_data: Default::default(), 278 + }; 279 + 280 + let json = serde_json::to_value(post)?; 281 + let data = Data::from_json(&json)?; 282 + 283 + log::info!("\nposting..."); 284 + client 285 + .send( 286 + CreateRecord::new() 287 + .repo(identifier.clone()) 288 + .collection(Post::nsid()) 289 + .record(data) 290 + .build(), 291 + ) 292 + .await? 293 + .into_output()?; 294 + 295 + Ok(()) 296 + } 297 + 298 + async fn hc_ping(url: Url, client: reqwest::Client) { 299 + let mut interval = tokio::time::interval(Duration::from_secs(5 * 60)); 300 + loop { 301 + interval.tick().await; 302 + log::trace!("sending healthcheck ping..."); 303 + if let Err(e) = client 304 + .get(url.clone()) 305 + .send() 306 + .await 307 + .and_then(reqwest::Response::error_for_status) 308 + { 309 + log::warn!("error sending healthcheck ping: {e}"); 310 + } 311 + } 312 + } 313 + 224 314 #[tokio::main] 225 315 async fn main() -> Result<()> { 226 316 env_logger::init(); ··· 229 319 // Create HTTP client and session 230 320 let client = BasicClient::new(args.pds); 231 321 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 - ); 322 + let create_session = CreateSession::new() 323 + .identifier(bot_id.to_string()) 324 + .password(&args.app_password) 325 + .build(); 326 + let session = Session::from(client.send(create_session.clone()).await?.into_output()?); 243 327 log::debug!("logged in as {} ({})", session.handle, session.did); 244 328 client.set_session(session).await?; 245 329 ··· 261 345 .connect_cursor(args.jetstream_cursor.map(Cursor::from_raw_u64)) 262 346 .await?; 263 347 348 + if let Some(hc) = args.healthcheck_ping { 349 + log::info!("starting healthcheck ping task..."); 350 + tokio::task::spawn(hc_ping(hc.clone(), slingshot_client.clone())); 351 + } 352 + 264 353 log::info!("receiving jetstream messages..."); 265 354 loop { 266 355 let Some(event) = receiver.recv().await else { 267 356 log::error!("consumer: could not receive event, bailing"); 268 357 break; 269 358 }; 359 + let cursor = event.cursor; 270 360 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, 361 + let CreateLabelRecord { add: adds, subject } = match event_to_create_label(event) { 362 + Ok(clr) => clr, 292 363 Err(e) => { 293 - log::warn!("failed to get issue record: {e} for {subject}"); 364 + log::debug!("ignoring unparseable event (at {cursor:?}): {e}"); 294 365 continue; 295 366 } 296 367 }; 297 368 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, 369 + let issue_details = match extract_issue_info(&slingshot_client, adds, subject.clone()).await 370 + { 371 + Ok(deets) => deets, 305 372 Err(e) => { 306 - log::warn!("failed to get repo record: {e} for {subject}"); 373 + log::warn!("failed to extract issue details (at {cursor:?}): {e}"); 307 374 continue; 308 375 } 309 376 }; 310 377 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 378 if args.dry_run { 379 + let IssueDetails { 380 + repo_full_name, 381 + repo_url, 382 + title, 383 + issues_url, 384 + } = issue_details; 329 385 log::info!( 330 386 r#"--dry-run, but would have posted: 331 387 ··· 336 392 continue; 337 393 } 338 394 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 - { 395 + if let Err(e) = post(&client, &bot_id, &issue_details).await { 349 396 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 - ); 397 + let session = Session::from(client.send(create_session.clone()).await?.into_output()?); 361 398 log::debug!("logged in as {} ({})", session.handle, session.did); 362 399 client.set_session(session).await?; 363 400 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 - { 401 + if let Err(e) = post(&client, &bot_id, &issue_details).await { 374 402 log::error!( 375 403 "failed to post after a session refresh: {e:?}, something is wrong. bye." 376 404 );