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