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