Skip to main content

apm_core/
sync.rs

1use anyhow::Result;
2use std::collections::HashSet;
3use std::path::Path;
4use crate::{config::Config, git, ticket::Ticket};
5
6pub struct CloseCandidate {
7    pub ticket: Ticket,
8    pub reason: &'static str,
9}
10
11pub struct Candidates {
12    pub close: Vec<CloseCandidate>,
13}
14
15pub struct ApplyOutput {
16    pub closed: Vec<String>,
17    pub failed: Vec<(String, String)>,
18    pub messages: Vec<String>,
19}
20
21pub fn detect(root: &Path, config: &Config) -> Result<Candidates> {
22    let branches = git::ticket_branches(root)?;
23    let merged = git::merged_into_main(root, &config.project.default_branch)?;
24    let merged_set: HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
25
26    let terminal = config.terminal_state_ids();
27
28    let branch_set: HashSet<&str> = branches.iter().map(|s| s.as_str()).collect();
29
30    let mut close = Vec::new();
31
32    // Case 1: non-terminal tickets on merged branches.
33    for branch in &branches {
34        if !merged_set.contains(branch.as_str()) { continue; }
35        let suffix = branch.trim_start_matches("ticket/");
36        let rel_path = format!("{}/{suffix}.md", config.tickets.dir.to_string_lossy());
37        let content = match git::read_from_branch(root, branch, &rel_path) {
38            Ok(c) => c,
39            Err(_) => continue,
40        };
41        let t = match Ticket::parse(&root.join(&rel_path), &content) {
42            Ok(t) => t,
43            Err(_) => continue,
44        };
45        if terminal.contains(t.frontmatter.state.as_str()) { continue; }
46        close.push(CloseCandidate { ticket: t, reason: "branch merged" });
47    }
48
49    // Case 2: tickets on main in `implemented` state with no surviving branch.
50    let default_branch = &config.project.default_branch;
51    let ticket_files = git::list_files_on_branch(root, default_branch, &config.tickets.dir.to_string_lossy()).unwrap_or_default();
52    for rel_path in ticket_files {
53        if !rel_path.ends_with(".md") { continue; }
54        let content = match git::read_from_branch(root, default_branch, &rel_path) {
55            Ok(c) => c,
56            Err(_) => continue,
57        };
58        let t = match Ticket::parse(&root.join(&rel_path), &content) {
59            Ok(t) => t,
60            Err(_) => continue,
61        };
62        if t.frontmatter.state == "implemented" {
63            let branch = t.frontmatter.branch.as_deref().unwrap_or("");
64            if !branch.is_empty() && !branch_set.contains(branch) {
65                close.push(CloseCandidate { ticket: t, reason: "implemented, branch gone" });
66            }
67        }
68    }
69
70    Ok(Candidates { close })
71}
72
73pub fn apply(root: &Path, config: &Config, candidates: &Candidates, author: &str, aggressive: bool) -> Result<ApplyOutput> {
74    let mut closed = Vec::new();
75    let mut failed = Vec::new();
76    let mut messages = Vec::new();
77    for c in &candidates.close {
78        let id = c.ticket.frontmatter.id.clone();
79        match crate::ticket::close(root, config, &id, None, author, aggressive) {
80            Ok(msgs) => {
81                closed.push(id);
82                messages.extend(msgs);
83            }
84            Err(e) => {
85                failed.push((id, format!("{e:#}")));
86            }
87        }
88    }
89    Ok(ApplyOutput { closed, failed, messages })
90}