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 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 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 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 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 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 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 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}