Skip to main content

pma_core/
sync.rs

1//! Hybrid sync between `Critical` items in TODO.md and GitHub Issues.
2//!
3//! TODO.md wins on text and priority; GitHub wins on closed state. Rules are
4//! in `docs/dev/design.md`. Write-backs are left uncommitted in the clone.
5
6use 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    /// Open an issue for a `Critical` item and write `gh:N` into its line.
25    Create {
26        line: usize,
27        title: String,
28        body: String,
29    },
30    /// Write `gh:N` for an open, labelled, unlinked issue with the item's
31    /// title: one created by a sync that stopped before writing the line.
32    Link {
33        line: usize,
34        number: u64,
35        /// The item's text, checked again before `gh:N` is written.
36        title: String,
37    },
38    /// The issue is closed: mark the item done.
39    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    /// `gh:N` names no issue in the repo.
54    Missing {
55        line: usize,
56        number: u64,
57    },
58    /// An open issue by someone else, linked to no item.
59    Untriaged {
60        number: u64,
61        title: String,
62        author: String,
63    },
64}
65
66impl Action {
67    /// Whether applying it changes GitHub or TODO.md.
68    pub fn is_change(&self) -> bool {
69        !matches!(self, Action::Missing { .. } | Action::Untriaged { .. })
70    }
71
72    /// The TODO.md line the action concerns, if any.
73    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
103/// Compares a parsed TODO.md with the repo's issues. `me` is the GitHub login
104/// whose issues are not untriaged.
105pub 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
195/// Runs `gh`, returning stdout or an error with its stderr.
196pub 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
265/// Applies the changes in `actions` to GitHub and to `todo_path`, in plan
266/// order, stopping at the first failure. Each write-back is saved before the
267/// next call, so a stopped sync is resumed by `Link` rather than duplicated.
268/// Returns the number of changes applied.
269pub 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    // The planned line must still hold the planned item, unlinked. A line
278    // added above it while `gh issue create` ran would put `gh:N` on another
279    // item, and the next sync would retitle that issue and open a second one.
280    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    /// A line added above the item between plan and write moves it. The write
465    /// is refused rather than landing `gh:N` on the item now at that line.
466    #[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}