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