announcing good-first-issue tags added on @tangled.sh (not affiliated with tangled!)
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}