1use anyhow::Result;
6
7use crate::providers::{ProviderKind, ReviewProvider, ReviewState};
8use crate::settings;
9
10mod ledger;
11mod sections;
12mod template;
13
14pub use ledger::update_stack_notes;
15
16use sections::{body_with_section_before, marker_start, strip_sections};
17
18const STACK_SECTION: &str = "stack";
19const CLOSES_SECTION: &str = "closes";
20const DESCRIPTION_SECTION: &str = "description";
21
22pub fn update_closes_notes(
27 review_provider: &dyn ReviewProvider,
28 branches: &[String],
29 dry_run: bool,
30) -> Result<()> {
31 for branch in branches {
32 let Some(issue) = issue_number_from_branch(branch) else {
33 continue;
34 };
35
36 let Some(review) = review_provider.review_for_branch(branch)? else {
37 if dry_run {
40 anstream::println!("would link issue #{issue} in the review for {branch}");
41 } else {
42 anstream::println!("skipped issue link: no review found for {branch}");
43 }
44 continue;
45 };
46
47 if review.branch != *branch || review.state == ReviewState::Merged {
48 continue;
49 }
50
51 if dry_run {
52 anstream::println!("would link issue #{issue} in {}", review.id);
53 continue;
54 }
55
56 let body = review_provider.review_body(&review)?;
57 let updated = body_with_closes_note(&body, &format!("Closes #{issue}"));
58 if updated == body {
59 continue;
60 }
61
62 review_provider.update_review_body(&review, &updated)?;
63 anstream::println!("linked issue #{issue} in {}", review.id);
64 }
65
66 Ok(())
67}
68
69pub fn update_description_note(
73 review_provider: &dyn ReviewProvider,
74 branch: &str,
75 description: &str,
76 dry_run: bool,
77) -> Result<()> {
78 let verb = if description.is_empty() {
79 "clear"
80 } else {
81 "set"
82 };
83
84 let Some(review) = review_provider.review_for_branch(branch)? else {
85 if dry_run {
86 anstream::println!("would {verb} the description on the review for {branch}");
87 } else {
88 anstream::println!("skipped description: no review found for {branch}");
89 }
90 return Ok(());
91 };
92 if review.branch != *branch {
93 anstream::println!(
94 "skipped description: review {} belongs to {}",
95 review.id,
96 review.branch
97 );
98 return Ok(());
99 }
100
101 if dry_run {
102 anstream::println!("would {verb} the description in {}", review.id);
103 return Ok(());
104 }
105
106 let body = review_provider.review_body(&review)?;
107 let updated = if description.is_empty() {
108 if !body.contains(&marker_start(DESCRIPTION_SECTION)) {
109 return Ok(());
110 }
111 strip_sections(&body, DESCRIPTION_SECTION)
112 .trim_end()
113 .to_owned()
114 } else {
115 body_with_description_note(&body, description)
116 };
117 if updated == body {
118 return Ok(());
119 }
120
121 review_provider.update_review_body(&review, &updated)?;
122 anstream::println!(
123 "{} description in {}",
124 if description.is_empty() {
125 "cleared"
126 } else {
127 "set"
128 },
129 review.id
130 );
131 Ok(())
132}
133
134pub fn seed_template_notes(
139 review_provider: &dyn ReviewProvider,
140 kind: ProviderKind,
141 created: &[String],
142 dry_run: bool,
143) -> Result<()> {
144 if created.is_empty() || !settings::use_pr_template()? {
145 return Ok(());
146 }
147 let Some(template) = template::discover(kind)? else {
148 return Ok(());
149 };
150
151 for branch in created {
152 if dry_run {
153 anstream::println!("would seed the PR template into the review for {branch}");
154 continue;
155 }
156
157 let Some(review) = review_provider.review_for_branch(branch)? else {
158 anstream::println!("skipped PR template: no review found for {branch}");
159 continue;
160 };
161 if review.branch != *branch {
162 continue;
163 }
164
165 let body = review_provider.review_body(&review)?;
166 let updated = body_with_template(&body, &template);
167 if updated == body {
168 continue;
169 }
170
171 review_provider.update_review_body(&review, &updated)?;
172 anstream::println!("seeded the PR template into {}", review.id);
173 }
174
175 Ok(())
176}
177
178fn body_with_template(body: &str, template: &str) -> String {
183 if body.trim().is_empty() {
184 return template.to_owned();
185 }
186 if body.contains(template) {
187 return body.to_owned();
188 }
189 format!("{template}\n\n{}", body.trim_start())
190}
191
192fn issue_number_from_branch(branch: &str) -> Option<u64> {
198 for segment in branch.split('/') {
199 let lowered = segment.to_ascii_lowercase();
200 let candidate = lowered
201 .strip_prefix("issue-")
202 .or_else(|| lowered.strip_prefix("issues-"))
203 .unwrap_or(&lowered);
204
205 let end = candidate
206 .find(|character: char| !character.is_ascii_digit())
207 .unwrap_or(candidate.len());
208 let (digits, rest) = candidate.split_at(end);
209 if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
210 continue;
211 }
212
213 if let Ok(number) = digits.parse::<u64>()
214 && number > 0
215 {
216 return Some(number);
217 }
218 }
219
220 None
221}
222
223fn body_with_closes_note(body: &str, note: &str) -> String {
226 body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
227}
228
229fn body_with_description_note(body: &str, description: &str) -> String {
232 body_with_section_before(
233 body,
234 DESCRIPTION_SECTION,
235 description,
236 &[CLOSES_SECTION, STACK_SECTION],
237 )
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn issue_number_from_branch_reads_supported_shapes() {
246 assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
247 assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
248 assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
249 assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
250 assert_eq!(issue_number_from_branch("42"), Some(42));
251 }
252
253 #[test]
254 fn issue_number_from_branch_rejects_lookalikes() {
255 assert_eq!(issue_number_from_branch("feature/b"), None);
256 assert_eq!(issue_number_from_branch("fix-thing-123"), None);
257 assert_eq!(issue_number_from_branch("v2-migration"), None);
258 assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
259 assert_eq!(issue_number_from_branch("0-zero"), None);
260 assert_eq!(issue_number_from_branch("upgrade-issue"), None);
261 }
262
263 #[test]
264 fn body_with_template_fills_an_empty_body() {
265 assert_eq!(body_with_template("", "## Summary"), "## Summary");
266 assert_eq!(body_with_template(" \n", "## Summary"), "## Summary");
267 }
268
269 #[test]
270 fn body_with_template_prepends_above_fill_content() {
271 assert_eq!(
272 body_with_template("Commit body.", "## Summary"),
273 "## Summary\n\nCommit body."
274 );
275 }
276
277 #[test]
278 fn body_with_template_is_idempotent_when_already_present() {
279 let seeded = "## Summary\n\nCommit body.";
280 assert_eq!(body_with_template(seeded, "## Summary"), seeded);
281 }
282
283 #[test]
284 fn body_with_closes_note_appends_without_a_stack_section() {
285 let updated = body_with_closes_note("Description.", "Closes #5");
286 assert_eq!(
287 updated,
288 "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
289 );
290 }
291
292 #[test]
293 fn body_with_closes_note_lands_above_the_stack_section() {
294 let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
295 let updated = body_with_closes_note(body, "Closes #5");
296 assert_eq!(
297 updated,
298 "Description.\n\n\
299 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
300 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
301 );
302 }
303
304 #[test]
305 fn body_with_closes_note_replaces_a_stale_note_in_place() {
306 let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
307 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
308 let updated = body_with_closes_note(body, "Closes #5");
309 assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
310 assert!(updated.contains("Closes #5"));
311 assert!(!updated.contains("Closes #4"));
312 let closes = updated.find("Closes #5").expect("closes note");
313 let stack = updated.find("stack list").expect("stack note");
314 assert!(
315 closes < stack,
316 "closes note should sit above the stack note"
317 );
318 }
319
320 #[test]
321 fn body_with_description_note_lands_above_every_managed_section() {
322 let body = "Intro.\n\n\
323 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
324 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
325 let updated = body_with_description_note(body, "Summary.");
326
327 let intro = updated.find("Intro.").expect("intro");
328 let description = updated.find("Summary.").expect("description");
329 let closes = updated.find("Closes #5").expect("closes");
330 let stack = updated.find("stack list").expect("stack");
331 assert!(intro < description && description < closes && closes < stack);
332 assert!(
333 updated
334 .contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
335 );
336 }
337
338 #[test]
339 fn body_with_description_note_replaces_in_place() {
340 let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
341 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
342 let updated = body_with_description_note(body, "New.");
343 assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
344 assert!(updated.contains("New."));
345 assert!(!updated.contains("Old."));
346 let description = updated.find("New.").expect("description");
347 let stack = updated.find("stack list").expect("stack");
348 assert!(description < stack);
349 }
350}