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