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    pub hints: Vec<String>,
14}
15
16pub struct ApplyOutput {
17    pub closed: Vec<String>,
18    pub failed: Vec<(String, String)>,
19    pub messages: Vec<String>,
20}
21
22pub fn detect(root: &Path, config: &Config) -> Result<Candidates> {
23    let branches = git::ticket_branches(root)?;
24    let merged = git::merged_into_main(root, &config.project.default_branch)?;
25    let mut merged_set: HashSet<String> = merged.into_iter().collect();
26
27    let terminal = config.terminal_state_ids();
28    let impl_states = config.implementation_state_ids();
29    let eligible = |t: &Ticket| -> bool {
30        impl_states.contains(t.frontmatter.state.as_str())
31            || crate::ticket_fmt::history_target_states(&t.body)
32                .iter().any(|s| impl_states.contains(s.as_str()))
33    };
34
35    let branch_set: HashSet<&str> = branches.iter().map(|s| s.as_str()).collect();
36
37    let default_branch = &config.project.default_branch;
38    let tickets_dir = config.tickets.dir.to_string_lossy().to_string();
39
40    // Mirrors `merged_into_main`'s own preference: prefer origin/<default> when available.
41    let remote_ref = format!("refs/remotes/origin/{default_branch}");
42    let main_ref = if git::run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
43        format!("origin/{default_branch}")
44    } else {
45        default_branch.clone()
46    };
47
48    let mut close = Vec::new();
49    let mut hints = Vec::new();
50
51    // Case 1: non-terminal tickets on merged branches.
52    for branch in &branches {
53        if !merged_set.contains(branch.as_str()) { continue; }
54        let suffix = branch.trim_start_matches("ticket/");
55        let rel_path = format!("{tickets_dir}/{suffix}.md");
56        let content = match git::read_from_branch(root, branch, &rel_path) {
57            Ok(c) => c,
58            Err(_) => continue,
59        };
60        let t = match Ticket::parse(&root.join(&rel_path), &content) {
61            Ok(t) => t,
62            Err(_) => continue,
63        };
64        let state = t.frontmatter.state.as_str();
65        if terminal.contains(state) || !eligible(&t) { continue; }
66        close.push(CloseCandidate { ticket: t, reason: "branch merged" });
67    }
68
69    // Case 3: tickets whose branch tip has only state-transition commits after the merge.
70    // Walk from the tip skipping ticket-file-only commits; squash-check the last real commit.
71    for branch in &branches {
72        if merged_set.contains(branch.as_str()) { continue; }
73        if git::content_merged_into_main(root, &main_ref, branch, &tickets_dir)? {
74            let suffix = branch.trim_start_matches("ticket/");
75            let rel_path = format!("{tickets_dir}/{suffix}.md");
76            let content = match git::read_from_branch(root, branch, &rel_path) {
77                Ok(c) => c,
78                Err(_) => {
79                    merged_set.insert(branch.clone());
80                    continue;
81                }
82            };
83            let t = match Ticket::parse(&root.join(&rel_path), &content) {
84                Ok(t) => t,
85                Err(_) => {
86                    merged_set.insert(branch.clone());
87                    continue;
88                }
89            };
90            merged_set.insert(branch.clone());
91            let state = t.frontmatter.state.as_str();
92            if !terminal.contains(state) && eligible(&t) {
93                close.push(CloseCandidate { ticket: t, reason: "branch content merged" });
94            }
95        }
96    }
97
98    // Case 2: tickets on main in `implemented` state with no surviving branch.
99    let ticket_files = git::list_files_on_branch(root, default_branch, &tickets_dir).unwrap_or_default();
100    for rel_path in ticket_files {
101        if !rel_path.ends_with(".md") { continue; }
102        let content = match git::read_from_branch(root, default_branch, &rel_path) {
103            Ok(c) => c,
104            Err(_) => continue,
105        };
106        let t = match Ticket::parse(&root.join(&rel_path), &content) {
107            Ok(t) => t,
108            Err(_) => continue,
109        };
110        let state = t.frontmatter.state.as_str();
111        if eligible(&t) && !terminal.contains(state) {
112            let branch = t.frontmatter.branch.as_deref().unwrap_or("");
113            if !branch.is_empty() && !branch_set.contains(branch) {
114                close.push(CloseCandidate { ticket: t, reason: "implemented, branch gone" });
115            }
116        }
117    }
118
119    // Case 4: implemented tickets merged into their target_branch.
120    for branch in &branches {
121        if merged_set.contains(branch.as_str()) { continue; }
122        let suffix = branch.trim_start_matches("ticket/");
123        let rel_path = format!("{tickets_dir}/{suffix}.md");
124        let content = match git::read_from_branch(root, branch, &rel_path) {
125            Ok(c) => c,
126            Err(_) => continue,
127        };
128        let t = match Ticket::parse(&root.join(&rel_path), &content) {
129            Ok(t) => t,
130            Err(_) => continue,
131        };
132        let state = t.frontmatter.state.as_str();
133        if !eligible(&t) || terminal.contains(state) { continue; }
134        let target = match t.frontmatter.target_branch.as_deref() {
135            Some(tb) if !tb.is_empty() => tb.to_string(),
136            _ => continue,
137        };
138        if git::is_branch_merged_into(root, branch, &target)? {
139            merged_set.insert(branch.clone());
140            close.push(CloseCandidate { ticket: t, reason: "branch merged into target" });
141        }
142    }
143
144    // Hint generation: implemented tickets whose branch was not detected by any pass.
145    for branch in &branches {
146        if merged_set.contains(branch.as_str()) { continue; }
147        let suffix = branch.trim_start_matches("ticket/");
148        let rel_path = format!("{tickets_dir}/{suffix}.md");
149        let content = match git::read_from_branch(root, branch, &rel_path) {
150            Ok(c) => c,
151            Err(_) => continue,
152        };
153        let t = match Ticket::parse(&root.join(&rel_path), &content) {
154            Ok(t) => t,
155            Err(_) => continue,
156        };
157        let state = t.frontmatter.state.as_str();
158        if eligible(&t) && !terminal.contains(state) {
159            let id = &t.frontmatter.id;
160            hints.push(format!(
161                "ticket #{id} is in `implemented` state but its branch was not detected as merged into \
162                 main. If it was already merged, close it manually: apm state {id} closed"
163            ));
164        }
165    }
166
167    Ok(Candidates { close, hints })
168}
169
170pub fn apply(root: &Path, config: &Config, candidates: &Candidates, author: &str, aggressive: bool) -> Result<ApplyOutput> {
171    let mut closed = Vec::new();
172    let mut failed = Vec::new();
173    let mut messages = Vec::new();
174    for c in &candidates.close {
175        let id = c.ticket.frontmatter.id.clone();
176        match crate::ticket::close(root, config, &id, None, author, aggressive) {
177            Ok(msgs) => {
178                closed.push(id);
179                messages.extend(msgs);
180            }
181            Err(e) => {
182                failed.push((id, format!("{e:#}")));
183            }
184        }
185    }
186    Ok(ApplyOutput { closed, failed, messages })
187}