tangled
alpha
login
or
join now
bad-example.com
/
hacktober-bot
6
fork
atom
announcing good-first-issue tags added on @tangled.sh (not affiliated with tangled!)
6
fork
atom
overview
issues
1
pulls
pipelines
use serde derives to simplify parsing
bad-example.com
5 months ago
4a2767b4
766ba4cd
+71
-68
3 changed files
expand all
collapse all
unified
split
Cargo.lock
Cargo.toml
src
main.rs
+1
Cargo.lock
···
973
973
"jacquard",
974
974
"jetstream",
975
975
"reqwest",
976
976
+
"serde",
976
977
"serde_json",
977
978
"tokio",
978
979
"url",
+1
Cargo.toml
···
9
9
jacquard = "0.1.0"
10
10
jetstream = { path = "../links/jetstream" }
11
11
reqwest = { version = "0.12.23", features = ["json"] }
12
12
+
serde = { version = "1.0.228", features = ["derive"] }
12
13
serde_json = "1.0.145"
13
14
tokio = { version = "1.47.1", features = ["full"] }
14
15
url = "2.5.7"
+69
-68
src/main.rs
···
25
25
},
26
26
};
27
27
use std::time::Duration;
28
28
+
use serde::Deserialize;
28
29
29
30
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
30
31
···
51
52
/// warning: setting this can lead to rapid bot posting
52
53
#[arg(long)]
53
54
jetstream_cursor: Option<u64>,
55
55
+
/// don't actually post
56
56
+
#[arg(long, action)]
57
57
+
dry_run: bool,
54
58
}
55
59
56
60
async fn post<C: HttpClient>(
···
61
65
title: &str,
62
66
repo_issues_url: &str,
63
67
) -> Result<()> {
64
64
-
let message = format!(r#"good-first-issue tagged for {repo_name}:
68
68
+
let message = format!(r#"good-first-issue added for {repo_name}:
65
69
66
70
> {title}"#);
67
71
···
72
76
let repo_facet = Facet {
73
77
features: vec![Data::from_json(&repo_feature)?],
74
78
index: ByteSlice {
75
75
-
byte_start: 28,
79
79
+
byte_start: 27,
76
80
byte_end: 29 + repo_name.len() as i64,
77
81
extra_data: Default::default(),
78
82
},
···
125
129
Ok(())
126
130
}
127
131
128
128
-
async fn get_record(client: &reqwest::Client, at_uri: &str) -> Result<serde_json::Map<String, serde_json::Value>> {
132
132
+
/// com.bad-example.identity.resolveMiniDoc bit we care about
133
133
+
#[derive(Deserialize)]
134
134
+
struct MiniDocResponse {
135
135
+
handle: String,
136
136
+
}
137
137
+
138
138
+
/// com.atproto.repo.getRecord wraps the record in a `value` key
139
139
+
#[derive(Deserialize)]
140
140
+
struct GetRecordResonse<T> {
141
141
+
value: T,
142
142
+
}
143
143
+
144
144
+
/// part of CreateLabelRecord: key is the label reference (ie for "good-first-issue")
145
145
+
#[derive(Deserialize)]
146
146
+
struct AddLabel {
147
147
+
key: String,
148
148
+
}
149
149
+
150
150
+
/// tangled's record for adding labels to an issue
151
151
+
#[derive(Deserialize)]
152
152
+
struct CreateLabelRecord {
153
153
+
add: Vec<AddLabel>,
154
154
+
subject: String,
155
155
+
}
156
156
+
157
157
+
/// tangled issue record
158
158
+
#[derive(Deserialize)]
159
159
+
struct IssueRecord {
160
160
+
title: String,
161
161
+
repo: String,
162
162
+
}
163
163
+
164
164
+
/// tangled repo record
165
165
+
#[derive(Deserialize)]
166
166
+
struct RepoRecord {
167
167
+
name: String,
168
168
+
}
169
169
+
170
170
+
/// get some atproto record content (from slingshot)
171
171
+
async fn get_record<T: for<'a> Deserialize<'a>>(client: &reqwest::Client, at_uri: &str) -> Result<T> {
129
172
let mut url: Url = "https://slingshot.microcosm.blue".parse()?;
130
173
url.set_path("/xrpc/com.bad-example.repo.getUriRecord");
131
174
url.query_pairs_mut().append_pair("at_uri", at_uri);
132
132
-
let record_map = client
175
175
+
let GetRecordResonse { value } = client
133
176
.get(url)
134
177
.send()
135
178
.await?
136
179
.error_for_status()?
137
137
-
.json::<serde_json::Value>()
138
138
-
.await?
139
139
-
.as_object()
140
140
-
.ok_or("get_record response was not a json object")?
141
141
-
.get("value")
142
142
-
.ok_or("get_record response obj did not have 'value' key")?
143
143
-
.as_object()
144
144
-
.ok_or("get_record response.value was not an object")?
145
145
-
.clone();
146
146
-
Ok(record_map)
180
180
+
.json()
181
181
+
.await?;
182
182
+
Ok(value)
147
183
}
148
184
185
185
+
/// try to resolve a bidirectionally verified handle from an identifier (via slingshot)
149
186
async fn get_handle(client: &reqwest::Client, identifier: &str) -> Result<Option<String>> {
150
187
let mut url: Url = "https://slingshot.microcosm.blue".parse()?;
151
188
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
152
189
url.query_pairs_mut().append_pair("identifier", identifier);
153
153
-
let handle = client
190
190
+
let MiniDocResponse { handle } = client
154
191
.get(url)
155
192
.send()
156
193
.await?
157
194
.error_for_status()?
158
158
-
.json::<serde_json::Value>()
159
159
-
.await?
160
160
-
.as_object()
161
161
-
.ok_or("minidoc response was not a json object")?
162
162
-
.get("handle")
163
163
-
.ok_or("minidoc response obj did not have 'handle' key")?
164
164
-
.as_str()
165
165
-
.ok_or("minidoc handle was not a string")?
166
166
-
.to_string();
195
195
+
.json()
196
196
+
.await?;
167
197
if handle == "handle.invalid" {
168
198
Ok(None)
169
199
} else {
···
233
263
eprintln!("consumer: commit update/delete missing record, ignoring");
234
264
continue;
235
265
};
236
236
-
let jv: serde_json::Value = match record.get().parse() {
266
266
+
let CreateLabelRecord { add, subject } = match serde_json::from_str(record.get()) {
237
267
Ok(v) => v,
238
268
Err(e) => {
239
269
eprintln!("consumer: record failed to parse, ignoring: {e}");
240
270
continue;
241
271
}
242
272
};
243
243
-
let serde_json::Value::Object(o) = jv else {
244
244
-
eprintln!("record was not an object, ignoring");
245
245
-
continue;
246
246
-
};
247
247
-
let Some(serde_json::Value::Array(adds)) = o.get("add") else {
248
248
-
eprintln!("op did not have label added or was not an array");
249
249
-
continue;
250
250
-
};
251
273
let mut added_good_first_issue = false;
252
252
-
for added in adds {
253
253
-
let serde_json::Value::Object(a) = added else {
254
254
-
eprintln!("added item was not an obj");
255
255
-
continue;
256
256
-
};
257
257
-
let Some(serde_json::Value::String(key)) = a.get("key") else {
258
258
-
eprintln!("added was missing key prop");
259
259
-
continue;
260
260
-
};
261
261
-
if key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" {
274
274
+
for added in add {
275
275
+
if added.key == "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" {
262
276
println!("found a good first issue label!! {:?}", event.cursor);
263
277
added_good_first_issue = true;
264
264
-
break;
278
278
+
break; // inner
265
279
}
266
280
eprintln!("found a label but it wasn't good-first-issue, ignoring...");
267
281
}
268
282
if !added_good_first_issue {
269
283
continue;
270
284
}
271
271
-
let Some(serde_json::Value::String(subject)) = o.get("subject") else {
272
272
-
eprintln!("could not find `subject` string for the good-first-issue label");
273
273
-
continue;
274
274
-
};
275
285
276
276
-
let issue_record = match get_record(&req_client, subject).await {
286
286
+
let IssueRecord { title, repo } = match get_record(&req_client, &subject).await {
277
287
Ok(m) => m,
278
288
Err(e) => {
279
289
eprintln!("failed to get issue record: {e} for {subject}");
280
290
continue;
281
291
}
282
292
};
283
283
-
let Some(serde_json::Value::String(title)) = issue_record.get("title") else {
284
284
-
eprintln!("failed to get title from issue for {subject}");
285
285
-
continue;
286
286
-
};
287
287
-
let Some(serde_json::Value::String(repo)) = issue_record.get("repo") else {
288
288
-
eprintln!("failed to get repo from issue for {subject}");
289
289
-
continue;
290
290
-
};
291
293
292
292
-
let Ok(repo_uri) = AtUri::new(repo) else {
294
294
+
let Ok(repo_uri) = AtUri::new(&repo) else {
293
295
eprintln!("failed to parse repo to aturi for {subject}");
294
296
continue;
295
297
};
296
298
297
297
-
let repo_record = match get_record(&req_client, repo).await {
299
299
+
let RepoRecord { name: repo_name } = match get_record(&req_client, &repo).await {
298
300
Ok(m) => m,
299
301
Err(e) => {
300
302
eprintln!("failed to get repo record: {e} for {subject}");
301
303
continue;
302
304
}
303
305
};
304
304
-
let Some(serde_json::Value::String(repo_name)) = repo_record.get("name") else {
305
305
-
eprintln!("failed to get name for repo for {subject}");
306
306
-
continue;
307
307
-
};
308
306
309
307
let nice_tangled_repo_id = match repo_uri.authority() {
310
308
AtIdentifier::Handle(h) => format!("@{h}"),
···
323
321
324
322
let issues_url = format!("https://tangled.org/{nice_tangled_repo_id}/{repo_name}/issues");
325
323
324
324
+
if args.dry_run {
325
325
+
println!("--dry-run, but would have posted:");
326
326
+
println!("good-first-issue label added for {repo_full_name} ({repo_url}):");
327
327
+
println!("> {title} ({issues_url})\n");
328
328
+
continue;
329
329
+
}
330
330
+
326
331
if let Err(e) = post(
327
332
&client,
328
333
&bot_id,
329
334
&repo_full_name,
330
335
&repo_url,
331
331
-
title,
336
336
+
&title,
332
337
&issues_url,
333
338
).await {
334
339
eprintln!("failed to post for {subject}: {e}");
···
336
341
337
342
break;
338
343
}
339
339
-
340
340
-
// let u2: Url = "https://bad-example.com".parse()?;
341
341
-
// let bot_id = AtIdentifier::new(&args.identifier)?;
342
342
-
// post_link(&client, &bot_id, "link test 2: ", u2).await?;
343
344
344
345
Ok(())
345
346
}