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 println!("would link issue #{issue} in the review for {branch}");
39 } else {
40 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 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 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 println!("would {verb} the description on the review for {branch}");
85 } else {
86 println!("skipped description: no review found for {branch}");
87 }
88 return Ok(());
89 };
90 if review.branch != *branch {
91 println!(
92 "skipped description: review {} belongs to {}",
93 review.id, review.branch
94 );
95 return Ok(());
96 }
97
98 if dry_run {
99 println!("would {verb} the description in {}", review.id);
100 return Ok(());
101 }
102
103 let body = review_provider.review_body(&review)?;
104 let updated = if description.is_empty() {
105 if !body.contains(&marker_start(DESCRIPTION_SECTION)) {
106 return Ok(());
107 }
108 strip_sections(&body, DESCRIPTION_SECTION)
109 .trim_end()
110 .to_owned()
111 } else {
112 body_with_description_note(&body, description)
113 };
114 if updated == body {
115 return Ok(());
116 }
117
118 review_provider.update_review_body(&review, &updated)?;
119 println!(
120 "{} description in {}",
121 if description.is_empty() {
122 "cleared"
123 } else {
124 "set"
125 },
126 review.id
127 );
128 Ok(())
129}
130
131fn issue_number_from_branch(branch: &str) -> Option<u64> {
137 for segment in branch.split('/') {
138 let lowered = segment.to_ascii_lowercase();
139 let candidate = lowered
140 .strip_prefix("issue-")
141 .or_else(|| lowered.strip_prefix("issues-"))
142 .unwrap_or(&lowered);
143
144 let end = candidate
145 .find(|character: char| !character.is_ascii_digit())
146 .unwrap_or(candidate.len());
147 let (digits, rest) = candidate.split_at(end);
148 if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
149 continue;
150 }
151
152 if let Ok(number) = digits.parse::<u64>()
153 && number > 0
154 {
155 return Some(number);
156 }
157 }
158
159 None
160}
161
162fn body_with_closes_note(body: &str, note: &str) -> String {
165 body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
166}
167
168fn body_with_description_note(body: &str, description: &str) -> String {
171 body_with_section_before(
172 body,
173 DESCRIPTION_SECTION,
174 description,
175 &[CLOSES_SECTION, STACK_SECTION],
176 )
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn issue_number_from_branch_reads_supported_shapes() {
185 assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
186 assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
187 assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
188 assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
189 assert_eq!(issue_number_from_branch("42"), Some(42));
190 }
191
192 #[test]
193 fn issue_number_from_branch_rejects_lookalikes() {
194 assert_eq!(issue_number_from_branch("feature/b"), None);
195 assert_eq!(issue_number_from_branch("fix-thing-123"), None);
196 assert_eq!(issue_number_from_branch("v2-migration"), None);
197 assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
198 assert_eq!(issue_number_from_branch("0-zero"), None);
199 assert_eq!(issue_number_from_branch("upgrade-issue"), None);
200 }
201
202 #[test]
203 fn body_with_closes_note_appends_without_a_stack_section() {
204 let updated = body_with_closes_note("Description.", "Closes #5");
205 assert_eq!(
206 updated,
207 "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
208 );
209 }
210
211 #[test]
212 fn body_with_closes_note_lands_above_the_stack_section() {
213 let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
214 let updated = body_with_closes_note(body, "Closes #5");
215 assert_eq!(
216 updated,
217 "Description.\n\n\
218 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
219 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
220 );
221 }
222
223 #[test]
224 fn body_with_closes_note_replaces_a_stale_note_in_place() {
225 let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
226 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
227 let updated = body_with_closes_note(body, "Closes #5");
228 assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
229 assert!(updated.contains("Closes #5"));
230 assert!(!updated.contains("Closes #4"));
231 let closes = updated.find("Closes #5").expect("closes note");
232 let stack = updated.find("stack list").expect("stack note");
233 assert!(
234 closes < stack,
235 "closes note should sit above the stack note"
236 );
237 }
238
239 #[test]
240 fn body_with_description_note_lands_above_every_managed_section() {
241 let body = "Intro.\n\n\
242 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
243 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
244 let updated = body_with_description_note(body, "Summary.");
245
246 let intro = updated.find("Intro.").expect("intro");
247 let description = updated.find("Summary.").expect("description");
248 let closes = updated.find("Closes #5").expect("closes");
249 let stack = updated.find("stack list").expect("stack");
250 assert!(intro < description && description < closes && closes < stack);
251 assert!(
252 updated
253 .contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
254 );
255 }
256
257 #[test]
258 fn body_with_description_note_replaces_in_place() {
259 let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
260 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
261 let updated = body_with_description_note(body, "New.");
262 assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
263 assert!(updated.contains("New."));
264 assert!(!updated.contains("Old."));
265 let description = updated.find("New.").expect("description");
266 let stack = updated.find("stack list").expect("stack");
267 assert!(description < stack);
268 }
269}