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