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
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}