1use std::path::Path;
7use std::process::Command;
8
9use crate::todo::{self, Parsed, Priority, normal_text};
10
11pub const LABEL: &str = "pma:critical";
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct Issue {
15 pub number: u64,
16 pub title: String,
17 pub open: bool,
18 pub author: String,
19 pub labels: Vec<String>,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23pub enum Action {
24 Create {
26 line: usize,
27 title: String,
28 body: String,
29 },
30 Link {
33 line: usize,
34 number: u64,
35 title: String,
37 },
38 Close {
40 line: usize,
41 number: u64,
42 },
43 Retitle {
44 number: u64,
45 title: String,
46 },
47 Label {
48 number: u64,
49 },
50 Unlabel {
51 number: u64,
52 },
53 Missing {
55 line: usize,
56 number: u64,
57 },
58 Untriaged {
60 number: u64,
61 title: String,
62 author: String,
63 },
64}
65
66impl Action {
67 pub fn is_change(&self) -> bool {
69 !matches!(self, Action::Missing { .. } | Action::Untriaged { .. })
70 }
71
72 pub fn line(&self) -> Option<usize> {
74 match self {
75 Action::Create { line, .. }
76 | Action::Link { line, .. }
77 | Action::Close { line, .. }
78 | Action::Missing { line, .. } => Some(*line),
79 _ => None,
80 }
81 }
82
83 pub fn describe(&self) -> String {
84 match self {
85 Action::Create { title, .. } => format!("open an issue for `{title}`"),
86 Action::Link { number, .. } => format!("link #{number}, already open"),
87 Action::Close { number, .. } => format!("mark done, #{number} is closed"),
88 Action::Retitle { number, title } => format!("retitle #{number} to `{title}`"),
89 Action::Label { number } => format!("label #{number} {LABEL}"),
90 Action::Unlabel { number } => format!("unlabel #{number}, no longer Critical"),
91 Action::Missing { number, .. } => {
92 format!("warning: #{number} is not an issue in this repo")
93 }
94 Action::Untriaged {
95 number,
96 title,
97 author,
98 } => format!("untriaged #{number} by {author}: {title}"),
99 }
100 }
101}
102
103pub fn plan(parsed: &Parsed, issues: &[Issue], me: &str) -> Vec<Action> {
106 let linked: Vec<u64> = parsed.items.iter().filter_map(|i| i.gh).collect();
107 let mut claimed: Vec<u64> = Vec::new();
108 let mut actions = Vec::new();
109
110 for item in parsed.items.iter().filter(|i| !i.done) {
111 let critical = item.priority == Priority::Critical;
112 let Some(number) = item.gh else {
113 if !critical {
114 continue;
115 }
116 let orphan = issues.iter().find(|i| {
117 i.open
118 && i.labels.iter().any(|l| l == LABEL)
119 && !linked.contains(&i.number)
120 && !claimed.contains(&i.number)
121 && normal_text(&i.title) == normal_text(&item.text)
122 });
123 actions.push(match orphan {
124 Some(issue) => {
125 claimed.push(issue.number);
126 Action::Link {
127 line: item.line,
128 number: issue.number,
129 title: item.text.clone(),
130 }
131 }
132 None => Action::Create {
133 line: item.line,
134 title: item.text.clone(),
135 body: body(&item.description),
136 },
137 });
138 continue;
139 };
140 let Some(issue) = issues.iter().find(|i| i.number == number) else {
141 actions.push(Action::Missing {
142 line: item.line,
143 number,
144 });
145 continue;
146 };
147 if !issue.open {
148 actions.push(Action::Close {
149 line: item.line,
150 number,
151 });
152 continue;
153 }
154 if issue.title != item.text {
155 actions.push(Action::Retitle {
156 number,
157 title: item.text.clone(),
158 });
159 }
160 let labelled = issue.labels.iter().any(|l| l == LABEL);
161 if critical && !labelled {
162 actions.push(Action::Label { number });
163 } else if !critical && labelled {
164 actions.push(Action::Unlabel { number });
165 }
166 }
167
168 for issue in issues {
169 if issue.open
170 && issue.author != me
171 && !linked.contains(&issue.number)
172 && !claimed.contains(&issue.number)
173 {
174 actions.push(Action::Untriaged {
175 number: issue.number,
176 title: issue.title.clone(),
177 author: issue.author.clone(),
178 });
179 }
180 }
181 actions
182}
183
184fn body(description: &[String]) -> String {
185 let text: Vec<&str> = description.iter().map(|l| l.trim()).collect();
186 let text = text.join("\n").trim().to_string();
187 let note = "Opened by pma from a `## Critical` item in TODO.md.";
188 if text.is_empty() {
189 note.into()
190 } else {
191 format!("{text}\n\n{note}")
192 }
193}
194
195pub fn gh(args: &[&str]) -> Result<String, String> {
197 let out = Command::new("gh")
198 .args(args)
199 .output()
200 .map_err(|e| match e.kind() {
201 std::io::ErrorKind::NotFound => {
202 "`gh` is not on PATH; install and authenticate it".into()
203 }
204 _ => format!("gh: {e}"),
205 })?;
206 if !out.status.success() {
207 return Err(format!(
208 "gh {}: {}",
209 args.iter().take(2).copied().collect::<Vec<_>>().join(" "),
210 String::from_utf8_lossy(&out.stderr).trim()
211 ));
212 }
213 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
214}
215
216pub fn login() -> Result<String, String> {
217 Ok(gh(&["api", "user", "--jq", ".login"])?.trim().to_string())
218}
219
220pub fn issues(repo: &str) -> Result<Vec<Issue>, String> {
221 let out = gh(&[
222 "issue",
223 "list",
224 "-R",
225 repo,
226 "--state",
227 "all",
228 "--limit",
229 "5000",
230 "--json",
231 "number,title,state,author,labels",
232 ])?;
233 parse_issues(&out)
234}
235
236fn parse_issues(json: &str) -> Result<Vec<Issue>, String> {
237 let value: serde_json::Value =
238 serde_json::from_str(json).map_err(|e| format!("gh issue list: {e}"))?;
239 let rows = value.as_array().ok_or("gh issue list: expected an array")?;
240 rows.iter()
241 .map(|r| {
242 Ok(Issue {
243 number: r["number"]
244 .as_u64()
245 .ok_or("gh issue list: issue without a number")?,
246 title: r["title"].as_str().unwrap_or_default().to_string(),
247 open: r["state"].as_str() == Some("OPEN"),
248 author: r["author"]["login"]
249 .as_str()
250 .unwrap_or_default()
251 .to_string(),
252 labels: r["labels"]
253 .as_array()
254 .map(|ls| {
255 ls.iter()
256 .filter_map(|l| l["name"].as_str().map(String::from))
257 .collect()
258 })
259 .unwrap_or_default(),
260 })
261 })
262 .collect()
263}
264
265pub fn apply(repo: &str, todo_path: &Path, actions: &[Action]) -> Result<usize, String> {
270 let io = |e: std::io::Error| format!("{}: {e}", todo_path.display());
271 let edit = |f: &dyn Fn(&str) -> Option<String>| -> Result<(), String> {
272 let text = std::fs::read_to_string(todo_path).map_err(io)?;
273 let edited = f(&text)
274 .ok_or_else(|| format!("{} changed during sync; run it again", todo_path.display()))?;
275 todo::save(todo_path, &edited).map_err(io)
276 };
277 let link = |line: usize, number: u64, title: &str| {
281 edit(&|t| {
282 let unlinked = todo::parse(t).items.iter().any(|i| {
283 i.line == line && i.gh.is_none() && normal_text(&i.text) == normal_text(title)
284 });
285 unlinked
286 .then(|| todo::add_token(t, line, &format!("gh:{number}")))
287 .flatten()
288 })
289 };
290 let mut label_ready = false;
291 let ensure_label = |label_ready: &mut bool| -> Result<(), String> {
292 if !*label_ready {
293 gh(&[
294 "label",
295 "create",
296 LABEL,
297 "-R",
298 repo,
299 "--force",
300 "--color",
301 "B60205",
302 "--description",
303 "Critical item in TODO.md, synced by pma",
304 ])?;
305 *label_ready = true;
306 }
307 Ok(())
308 };
309
310 let mut applied = 0;
311 for action in actions.iter().filter(|a| a.is_change()) {
312 match action {
313 Action::Create { line, title, body } => {
314 ensure_label(&mut label_ready)?;
315 let url = gh(&[
316 "issue", "create", "-R", repo, "--title", title, "--body", body, "--label",
317 LABEL,
318 ])?;
319 let number = url
320 .trim()
321 .rsplit('/')
322 .next()
323 .and_then(|n| n.parse::<u64>().ok())
324 .ok_or_else(|| {
325 format!("gh issue create: unexpected output `{}`", url.trim())
326 })?;
327 link(*line, number, title)?;
328 }
329 Action::Link {
330 line,
331 number,
332 title,
333 } => link(*line, *number, title)?,
334 Action::Close { number, .. } => {
335 edit(&|t| todo::mark_done(t, &format!("gh:{number}"), ""))?;
336 }
337 Action::Retitle { number, title } => {
338 gh(&[
339 "issue",
340 "edit",
341 &number.to_string(),
342 "-R",
343 repo,
344 "--title",
345 title,
346 ])?;
347 }
348 Action::Label { number } => {
349 ensure_label(&mut label_ready)?;
350 gh(&[
351 "issue",
352 "edit",
353 &number.to_string(),
354 "-R",
355 repo,
356 "--add-label",
357 LABEL,
358 ])?;
359 }
360 Action::Unlabel { number } => {
361 gh(&[
362 "issue",
363 "edit",
364 &number.to_string(),
365 "-R",
366 repo,
367 "--remove-label",
368 LABEL,
369 ])?;
370 }
371 Action::Missing { .. } | Action::Untriaged { .. } => {}
372 }
373 applied += 1;
374 }
375 Ok(applied)
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 fn issue(number: u64, title: &str, open: bool, author: &str, labels: &[&str]) -> Issue {
383 Issue {
384 number,
385 title: title.into(),
386 open,
387 author: author.into(),
388 labels: labels.iter().map(|l| l.to_string()).collect(),
389 }
390 }
391
392 #[test]
393 fn plan_covers_every_rule() {
394 let text = "# TODO
395
396## Critical
397
398- [ ] new crash
399 on empty input
400- [ ] half synced
401- [ ] linked gh:1
402- [ ] renamed text gh:2
403- [ ] closed upstream gh:3
404- [ ] gone gh:9
405
406## High
407
408- [ ] demoted gh:4
409- [ ] not critical
410- [x] old gh:5
411";
412 let parsed = todo::parse(text);
413 assert!(!parsed.has_errors(), "{:?}", parsed.diagnostics);
414 let issues = [
415 issue(1, "linked", true, "me", &[LABEL]),
416 issue(2, "old text", true, "me", &[]),
417 issue(3, "closed upstream", false, "me", &[LABEL]),
418 issue(4, "demoted", true, "me", &[LABEL]),
419 issue(5, "old", true, "alice", &[]),
420 issue(6, "Half Synced", true, "me", &[LABEL]),
421 issue(7, "feature request", true, "alice", &[]),
422 issue(8, "mine, unlinked", true, "me", &[]),
423 issue(10, "stale report", false, "bob", &[]),
424 ];
425 let actions = plan(&parsed, &issues, "me");
426 assert_eq!(
427 actions,
428 [
429 Action::Create {
430 line: 5,
431 title: "new crash".into(),
432 body: "on empty input\n\nOpened by pma from a `## Critical` item in TODO.md."
433 .into()
434 },
435 Action::Link {
436 line: 7,
437 number: 6,
438 title: "half synced".into()
439 },
440 Action::Retitle {
441 number: 2,
442 title: "renamed text".into()
443 },
444 Action::Label { number: 2 },
445 Action::Close {
446 line: 10,
447 number: 3
448 },
449 Action::Missing {
450 line: 11,
451 number: 9
452 },
453 Action::Unlabel { number: 4 },
454 Action::Untriaged {
455 number: 7,
456 title: "feature request".into(),
457 author: "alice".into()
458 },
459 ]
460 );
461 assert_eq!(actions.iter().filter(|a| a.is_change()).count(), 6);
462 }
463
464 #[test]
467 fn a_link_is_refused_when_the_planned_line_holds_another_item() {
468 let dir = std::env::temp_dir().join(format!("pma-sync-link-{}", std::process::id()));
469 let _ = std::fs::remove_dir_all(&dir);
470 std::fs::create_dir_all(&dir).unwrap();
471 let path = dir.join("TODO.md");
472 let planned = "# TODO\n\n## Critical\n\n- [ ] crash\n";
473 let actions = plan(
474 &todo::parse(planned),
475 &[issue(6, "crash", true, "me", &[LABEL])],
476 "me",
477 );
478 assert_eq!(
479 actions,
480 [Action::Link {
481 line: 5,
482 number: 6,
483 title: "crash".into()
484 }]
485 );
486
487 let moved = "# TODO\n\n## Critical\n\n- [ ] added meanwhile\n- [ ] crash\n";
488 std::fs::write(&path, moved).unwrap();
489 let err = apply("me/repo", &path, &actions).unwrap_err();
490 assert!(err.contains("changed during sync"), "{err}");
491 assert_eq!(std::fs::read_to_string(&path).unwrap(), moved);
492
493 std::fs::write(&path, planned).unwrap();
494 assert_eq!(apply("me/repo", &path, &actions), Ok(1));
495 assert!(
496 std::fs::read_to_string(&path)
497 .unwrap()
498 .contains("- [ ] crash gh:6")
499 );
500 let _ = std::fs::remove_dir_all(dir);
501 }
502
503 #[test]
504 fn gh_issue_json_is_read() {
505 let json = r#"[{"number":12,"title":"crash","state":"OPEN","author":{"login":"me"},"labels":[{"name":"pma:critical"},{"name":"bug"}]},
506 {"number":3,"title":"old","state":"CLOSED","author":{"login":"bob"},"labels":[]}]"#;
507 assert_eq!(
508 parse_issues(json).unwrap(),
509 [
510 issue(12, "crash", true, "me", &[LABEL, "bug"]),
511 issue(3, "old", false, "bob", &[]),
512 ]
513 );
514 assert!(parse_issues("{}").is_err());
515 }
516}