announcing good-first-issue tags added on @tangled.sh (not affiliated with tangled!)
1use clap::Parser;
2use url::Url;
3use jetstream::{
4 JetstreamCompression, JetstreamConfig, JetstreamConnector,
5 events::{CommitOp, Cursor, EventKind, JetstreamEvent},
6 exports::Nsid,
7};
8use jacquard::{
9 api::{
10 app_bsky::feed::post::Post,
11 app_bsky::richtext::facet::{Facet, ByteSlice},
12 com_atproto::server::create_session::CreateSession,
13 com_atproto::repo::create_record::CreateRecord,
14 },
15 client::{BasicClient, Session},
16 types::{
17 datetime::Datetime,
18 ident::AtIdentifier,
19 language::Language,
20 collection::Collection,
21 value::Data,
22 string::AtUri,
23 },
24};
25
26use std::time::Duration;
27use serde::Deserialize;
28
29type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
30
31#[derive(Debug, Parser)]
32#[command(version, about, long_about = None)]
33struct Args {
34 /// pds of the bot user
35 #[arg(short, long, env = "BOT_PDS")]
36 pds: Url,
37 /// handle or did of the bot user
38 #[arg(short, long, env = "BOT_HANDLE")]
39 identifier: String,
40 /// app password for the bot user
41 #[arg(short, long, env = "BOT_APP_PASSWORD")]
42 app_password: String,
43 /// lightweight firehose
44 #[arg(short, long, env = "BOT_JETSTREAM_URL")]
45 #[clap(default_value = "wss://jetstream1.us-east.fire.hose.cam/subscribe")]
46 jetstream_url: Url,
47 /// optional: we can pick up from a past jetstream cursor
48 ///
49 /// the default is to just live-tail
50 ///
51 /// warning: setting this can lead to rapid bot posting
52 #[arg(long)]
53 jetstream_cursor: Option<u64>,
54 /// don't actually post
55 #[arg(long, action)]
56 dry_run: bool,
57}
58
59async fn post(
60 client: &BasicClient,
61 identifier: &AtIdentifier<'_>,
62 repo_name: &str,
63 repo_url: &str,
64 title: &str,
65 repo_issues_url: &str,
66) -> Result<()> {
67 let message = format!(r#"good-first-issue added for {repo_name}:
68
69> {title}"#);
70
71 let repo_feature = serde_json::json!({
72 "$type": "app.bsky.richtext.facet#link",
73 "uri": repo_url,
74 });
75 let repo_facet = Facet {
76 features: vec![Data::from_json(&repo_feature)?],
77 index: ByteSlice {
78 byte_start: 27,
79 byte_end: 29 + repo_name.len() as i64,
80 extra_data: Default::default(),
81 },
82 extra_data: Default::default(),
83 };
84
85 let title_starts_at = (29 + repo_name.len() + 5) as i64;
86
87 let repo_issues_feature = serde_json::json!({
88 "$type": "app.bsky.richtext.facet#link",
89 "uri": repo_issues_url,
90 });
91 let issues_facet = Facet {
92 features: vec![Data::from_json(&repo_issues_feature)?],
93 index: ByteSlice {
94 byte_start: title_starts_at,
95 byte_end: title_starts_at + title.len() as i64,
96 extra_data: Default::default(),
97 },
98 extra_data: Default::default(),
99 };
100
101 // Make a post
102 let post = Post {
103 created_at: Datetime::now(),
104 langs: Some(vec![Language::new("en")?]),
105 text: message.into(),
106 facets: Some(vec![repo_facet, issues_facet]),
107 embed: Default::default(),
108 entities: Default::default(),
109 labels: Default::default(),
110 reply: Default::default(),
111 tags: Default::default(),
112 extra_data: Default::default(),
113 };
114
115 let json = serde_json::to_value(post)?;
116 let data = Data::from_json(&json)?;
117
118 println!("\nposting...");
119 client
120 .send(CreateRecord::new()
121 .repo(identifier.clone())
122 .collection(Post::nsid())
123 .record(data)
124 .build())
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>>(client: &reqwest::Client, at_uri: &str) -> Result<T> {
189 let mut url: Url = "https://slingshot.microcosm.blue".parse()?;
190 url.set_path("/xrpc/com.bad-example.repo.getUriRecord");
191 url.query_pairs_mut().append_pair("at_uri", at_uri);
192 let GetRecordResonse { value } = client
193 .get(url)
194 .send()
195 .await?
196 .error_for_status()?
197 .json()
198 .await?;
199 Ok(value)
200}
201
202/// try to resolve a bidirectionally verified handle from an identifier (via slingshot)
203async fn get_handle(client: &reqwest::Client, identifier: &str) -> Result<Option<String>> {
204 let mut url: Url = "https://slingshot.microcosm.blue".parse()?;
205 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
206 url.query_pairs_mut().append_pair("identifier", identifier);
207 let MiniDocResponse { handle } = client
208 .get(url)
209 .send()
210 .await?
211 .error_for_status()?
212 .json()
213 .await?;
214 if handle == "handle.invalid" {
215 Ok(None)
216 } else {
217 Ok(Some(handle))
218 }
219}
220
221#[tokio::main]
222async fn main() -> Result<()> {
223 env_logger::init();
224 let args = Args::parse();
225
226 // Create HTTP client and session
227 let client = BasicClient::new(args.pds);
228 let bot_id = AtIdentifier::new(&args.identifier)?;
229 let session = Session::from(
230 client
231 .send(
232 CreateSession::new()
233 .identifier(&bot_id.to_string())
234 .password(args.app_password)
235 .build(),
236 )
237 .await?
238 .into_output()?,
239 );
240 println!("logged in as {} ({})", session.handle, session.did);
241 client.set_session(session).await?;
242
243 let slingshot_client = reqwest::Client::builder()
244 .user_agent("hacktober_bot")
245 .timeout(Duration::from_secs(9))
246 .build()?;
247
248 let jetstream_config: JetstreamConfig = JetstreamConfig {
249 endpoint: args.jetstream_url.to_string(),
250 wanted_collections: vec![Nsid::new("sh.tangled.label.op".to_string())?],
251 user_agent: Some("hacktober_bot".to_string()),
252 compression: JetstreamCompression::Zstd,
253 replay_on_reconnect: true,
254 channel_size: 1024, // buffer up to ~1s of jetstream events
255 ..Default::default()
256 };
257 let mut receiver = JetstreamConnector::new(jetstream_config)?
258 .connect_cursor(args.jetstream_cursor.map(Cursor::from_raw_u64))
259 .await?;
260
261 println!("receiving jetstream messages...");
262 loop {
263 let Some(event) = receiver.recv().await else {
264 eprintln!("consumer: could not receive event, bailing");
265 break;
266 };
267
268 let Ok((CreateLabelRecord { add, subject }, cursor)) = event_to_create_label(event) else {
269 continue;
270 };
271
272 let mut added_good_first_issue = false;
273 for added in add {
274 if added.key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" {
275 println!("found a good first issue label!! {:?}", cursor);
276 added_good_first_issue = true;
277 break; // inner
278 }
279 eprintln!("found a label but it wasn't good-first-issue, ignoring...");
280 }
281 if !added_good_first_issue {
282 continue;
283 }
284
285 let IssueRecord { title, repo } = match get_record(&slingshot_client, &subject).await {
286 Ok(m) => m,
287 Err(e) => {
288 eprintln!("failed to get issue record: {e} for {subject}");
289 continue;
290 }
291 };
292
293 let Ok(repo_uri) = AtUri::new(&repo) else {
294 eprintln!("failed to parse repo to aturi for {subject}");
295 continue;
296 };
297
298 let RepoRecord { name: repo_name } = match get_record(&slingshot_client, &repo).await {
299 Ok(m) => m,
300 Err(e) => {
301 eprintln!("failed to get repo record: {e} for {subject}");
302 continue;
303 }
304 };
305
306 let nice_tangled_repo_id = match repo_uri.authority() {
307 AtIdentifier::Handle(h) => format!("@{h}"),
308 AtIdentifier::Did(did) => match get_handle(&slingshot_client, did.as_str()).await {
309 Err(e) => {
310 eprintln!("failed to get mini doc from repo identifier: {e} for {subject}");
311 continue;
312 }
313 Ok(None) => did.to_string(),
314 Ok(Some(h)) => format!("@{h}"),
315 }
316 };
317
318 let repo_full_name = format!("{nice_tangled_repo_id}/{repo_name}");
319 let repo_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}");
320
321 let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues");
322
323 if args.dry_run {
324 println!("--dry-run, but would have posted:");
325 println!("good-first-issue label added for {repo_full_name} ({repo_url}):");
326 println!("> {title} ({issues_url})\n");
327 continue;
328 }
329
330 if let Err(e) = post(
331 &client,
332 &bot_id,
333 &repo_full_name,
334 &repo_url,
335 &title,
336 &issues_url,
337 ).await {
338 eprintln!("failed to post for {subject}: {e}");
339 };
340
341 break;
342 }
343
344 Ok(())
345}