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 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 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 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 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 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" { continue; }
124 let target = match t.frontmatter.target_branch.as_deref() {
125 Some(tb) if !tb.is_empty() => tb.to_string(),
126 _ => continue,
127 };
128 if git::is_branch_merged_into(root, branch, &target)? {
129 merged_set.insert(branch.clone());
130 close.push(CloseCandidate { ticket: t, reason: "branch merged into target" });
131 }
132 }
133
134 for branch in &branches {
136 if merged_set.contains(branch.as_str()) { continue; }
137 let suffix = branch.trim_start_matches("ticket/");
138 let rel_path = format!("{tickets_dir}/{suffix}.md");
139 let content = match git::read_from_branch(root, branch, &rel_path) {
140 Ok(c) => c,
141 Err(_) => continue,
142 };
143 let t = match Ticket::parse(&root.join(&rel_path), &content) {
144 Ok(t) => t,
145 Err(_) => continue,
146 };
147 if t.frontmatter.state == "implemented" {
148 let id = &t.frontmatter.id;
149 hints.push(format!(
150 "ticket #{id} is in `implemented` state but its branch was not detected as merged into \
151 main. If it was already merged, close it manually: apm state {id} closed"
152 ));
153 }
154 }
155
156 Ok(Candidates { close, hints })
157}
158
159pub fn apply(root: &Path, config: &Config, candidates: &Candidates, author: &str, aggressive: bool) -> Result<ApplyOutput> {
160 let mut closed = Vec::new();
161 let mut failed = Vec::new();
162 let mut messages = Vec::new();
163 for c in &candidates.close {
164 let id = c.ticket.frontmatter.id.clone();
165 match crate::ticket::close(root, config, &id, None, author, aggressive) {
166 Ok(msgs) => {
167 closed.push(id);
168 messages.extend(msgs);
169 }
170 Err(e) => {
171 failed.push((id, format!("{e:#}")));
172 }
173 }
174 }
175 Ok(ApplyOutput { closed, failed, messages })
176}