1use anyhow::Result;
6
7use crate::providers::{ReviewProvider, ReviewState};
8
9mod ledger;
10mod sections;
11
12pub use ledger::update_stack_notes;
13
14use sections::{body_with_section_before, marker_start, strip_sections};
15
16const STACK_SECTION: &str = "stack";
17const CLOSES_SECTION: &str = "closes";
18const DESCRIPTION_SECTION: &str = "description";
19
20pub fn update_closes_notes(
25 review_provider: &dyn ReviewProvider,
26 branches: &[String],
27 dry_run: bool,
28) -> Result<()> {
29 for branch in branches {
30 let Some(issue) = issue_number_from_branch(branch) else {
31 continue;
32 };
33
34 let Some(review) = review_provider.review_for_branch(branch)? else {
35 if dry_run {
38 anstream::println!("would link issue #{issue} in the review for {branch}");
39 } else {
40 anstream::println!("skipped issue link: no review found for {branch}");
41 }
42 continue;
43 };
44
45 if review.branch != *branch || review.state == ReviewState::Merged {
46 continue;
47 }
48
49 if dry_run {
50 anstream::println!("would link issue #{issue} in {}", review.id);
51 continue;
52 }
53
54 let body = review_provider.review_body(&review)?;
55 let updated = body_with_closes_note(&body, &format!("Closes #{issue}"));
56 if updated == body {
57 continue;
58 }
59
60 review_provider.update_review_body(&review, &updated)?;
61 anstream::println!("linked issue #{issue} in {}", review.id);
62 }
63
64 Ok(())
65}
66
67pub fn update_description_note(
71 review_provider: &dyn ReviewProvider,
72 branch: &str,
73 description: &str,
74 dry_run: bool,
75) -> Result<()> {
76 let verb = if description.is_empty() {
77 "clear"
78 } else {
79 "set"
80 };
81
82 let Some(review) = review_provider.review_for_branch(branch)? else {
83 if dry_run {
84 anstream::println!("would {verb} the description on the review for {branch}");
85 } else {
86 anstream::println!("skipped description: no review found for {branch}");
87 }
88 return Ok(());
89 };
90 if review.branch != *branch {
91 anstream::println!(
92 "skipped description: review {} belongs to {}",
93 review.id,
94 review.branch
95 );
96 return Ok(());
97 }
98
99 if dry_run {
100 anstream::println!("would {verb} the description in {}", review.id);
101 return Ok(());
102 }
103
104 let body = review_provider.review_body(&review)?;
105 let updated = if description.is_empty() {
106 if !body.contains(&marker_start(DESCRIPTION_SECTION)) {
107 return Ok(());
108 }
109 strip_sections(&body, DESCRIPTION_SECTION)
110 .trim_end()
111 .to_owned()
112 } else {
113 body_with_description_note(&body, description)
114 };
115 if updated == body {
116 return Ok(());
117 }
118
119 review_provider.update_review_body(&review, &updated)?;
120 anstream::println!(
121 "{} description in {}",
122 if description.is_empty() {
123 "cleared"
124 } else {
125 "set"
126 },
127 review.id
128 );
129 Ok(())
130}
131
132fn issue_number_from_branch(branch: &str) -> Option<u64> {
138 for segment in branch.split('/') {
139 let lowered = segment.to_ascii_lowercase();
140 let candidate = lowered
141 .strip_prefix("issue-")
142 .or_else(|| lowered.strip_prefix("issues-"))
143 .unwrap_or(&lowered);
144
145 let end = candidate
146 .find(|character: char| !character.is_ascii_digit())
147 .unwrap_or(candidate.len());
148 let (digits, rest) = candidate.split_at(end);
149 if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
150 continue;
151 }
152
153 if let Ok(number) = digits.parse::<u64>()
154 && number > 0
155 {
156 return Some(number);
157 }
158 }
159
160 None
161}
162
163fn body_with_closes_note(body: &str, note: &str) -> String {
166 body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
167}
168
169fn body_with_description_note(body: &str, description: &str) -> String {
172 body_with_section_before(
173 body,
174 DESCRIPTION_SECTION,
175 description,
176 &[CLOSES_SECTION, STACK_SECTION],
177 )
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn issue_number_from_branch_reads_supported_shapes() {
186 assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
187 assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
188 assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
189 assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
190 assert_eq!(issue_number_from_branch("42"), Some(42));
191 }
192
193 #[test]
194 fn issue_number_from_branch_rejects_lookalikes() {
195 assert_eq!(issue_number_from_branch("feature/b"), None);
196 assert_eq!(issue_number_from_branch("fix-thing-123"), None);
197 assert_eq!(issue_number_from_branch("v2-migration"), None);
198 assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
199 assert_eq!(issue_number_from_branch("0-zero"), None);
200 assert_eq!(issue_number_from_branch("upgrade-issue"), None);
201 }
202
203 #[test]
204 fn body_with_closes_note_appends_without_a_stack_section() {
205 let updated = body_with_closes_note("Description.", "Closes #5");
206 assert_eq!(
207 updated,
208 "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
209 );
210 }
211
212 #[test]
213 fn body_with_closes_note_lands_above_the_stack_section() {
214 let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
215 let updated = body_with_closes_note(body, "Closes #5");
216 assert_eq!(
217 updated,
218 "Description.\n\n\
219 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
220 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
221 );
222 }
223
224 #[test]
225 fn body_with_closes_note_replaces_a_stale_note_in_place() {
226 let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
227 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
228 let updated = body_with_closes_note(body, "Closes #5");
229 assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
230 assert!(updated.contains("Closes #5"));
231 assert!(!updated.contains("Closes #4"));
232 let closes = updated.find("Closes #5").expect("closes note");
233 let stack = updated.find("stack list").expect("stack note");
234 assert!(
235 closes < stack,
236 "closes note should sit above the stack note"
237 );
238 }
239
240 #[test]
241 fn body_with_description_note_lands_above_every_managed_section() {
242 let body = "Intro.\n\n\
243 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
244 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
245 let updated = body_with_description_note(body, "Summary.");
246
247 let intro = updated.find("Intro.").expect("intro");
248 let description = updated.find("Summary.").expect("description");
249 let closes = updated.find("Closes #5").expect("closes");
250 let stack = updated.find("stack list").expect("stack");
251 assert!(intro < description && description < closes && closes < stack);
252 assert!(
253 updated
254 .contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
255 );
256 }
257
258 #[test]
259 fn body_with_description_note_replaces_in_place() {
260 let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
261 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
262 let updated = body_with_description_note(body, "New.");
263 assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
264 assert!(updated.contains("New."));
265 assert!(!updated.contains("Old."));
266 let description = updated.find("New.").expect("description");
267 let stack = updated.find("stack list").expect("stack");
268 assert!(description < stack);
269 }
270}