+183
-181
src/main.rs
+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
);