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(
148 review_provider: &dyn ReviewProvider,
149 kind: ProviderKind,
150 created: &[String],
151 desc_branch: Option<&str>,
152 dry_run: bool,
153) -> Result<()> {
154 if created.is_empty() {
155 return Ok(());
156 }
157 let template = if settings::use_pr_template()? {
158 template::discover(kind)?
159 } else {
160 None
161 };
162
163 for branch in created {
164 let is_desc = desc_branch == Some(branch.as_str());
165 if template.is_none() && !is_desc {
169 continue;
170 }
171 if dry_run {
172 anstream::println!("would seed the review body for {branch}");
173 continue;
174 }
175
176 let Some(review) = review_provider.review_for_branch(branch)? else {
177 anstream::println!("skipped body seed: no review found for {branch}");
178 continue;
179 };
180 if review.branch != *branch {
181 continue;
182 }
183
184 let body = review_provider.review_body(&review)?;
185 let commit_body = crate::git::commit_body(branch)?;
186 let prose = commit_body.trim();
187 let (updated, seeded_template) = match (&template, is_desc) {
188 (Some(template), true) => (body_with_template(prose, template), true),
190 (Some(template), false) => (body_template_as_description(template, prose), true),
192 (None, true) => (prose.to_owned(), false),
195 (None, false) => continue,
196 };
197 if updated == body {
198 continue;
199 }
200
201 review_provider.update_review_body(&review, &updated)?;
202 if seeded_template {
203 anstream::println!("seeded the PR template into {}", review.id);
204 } else {
205 anstream::println!("dropped the commit subject from {}", review.id);
206 }
207 }
208
209 Ok(())
210}
211
212fn body_template_as_description(template: &str, prose: &str) -> String {
217 let content = if prose.is_empty() {
218 template.to_owned()
219 } else {
220 format!("{template}\n\n{prose}")
221 };
222 body_with_description_note("", &content)
223}
224
225fn body_with_template(prose: &str, template: &str) -> String {
231 let freeform = if prose.trim().is_empty() {
232 template.to_owned()
233 } else {
234 format!("{template}\n\n{}", prose.trim_start())
235 };
236 format!("{freeform}\n\n---")
237}
238
239fn issue_number_from_branch(branch: &str) -> Option<u64> {
245 for segment in branch.split('/') {
246 let lowered = segment.to_ascii_lowercase();
247 let candidate = lowered
248 .strip_prefix("issue-")
249 .or_else(|| lowered.strip_prefix("issues-"))
250 .unwrap_or(&lowered);
251
252 let end = candidate
253 .find(|character: char| !character.is_ascii_digit())
254 .unwrap_or(candidate.len());
255 let (digits, rest) = candidate.split_at(end);
256 if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
257 continue;
258 }
259
260 if let Ok(number) = digits.parse::<u64>()
261 && number > 0
262 {
263 return Some(number);
264 }
265 }
266
267 None
268}
269
270fn body_with_closes_note(body: &str, note: &str) -> String {
273 body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
274}
275
276fn body_with_description_note(body: &str, description: &str) -> String {
279 body_with_section_before(
280 body,
281 DESCRIPTION_SECTION,
282 description,
283 &[CLOSES_SECTION, STACK_SECTION],
284 )
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn issue_number_from_branch_reads_supported_shapes() {
293 assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
294 assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
295 assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
296 assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
297 assert_eq!(issue_number_from_branch("42"), Some(42));
298 }
299
300 #[test]
301 fn issue_number_from_branch_rejects_lookalikes() {
302 assert_eq!(issue_number_from_branch("feature/b"), None);
303 assert_eq!(issue_number_from_branch("fix-thing-123"), None);
304 assert_eq!(issue_number_from_branch("v2-migration"), None);
305 assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
306 assert_eq!(issue_number_from_branch("0-zero"), None);
307 assert_eq!(issue_number_from_branch("upgrade-issue"), None);
308 }
309
310 #[test]
311 fn body_with_template_fills_an_empty_body() {
312 assert_eq!(body_with_template("", "## Summary"), "## Summary\n\n---");
313 assert_eq!(
314 body_with_template(" \n", "## Summary"),
315 "## Summary\n\n---"
316 );
317 }
318
319 #[test]
320 fn body_with_template_prepends_above_the_commit_body() {
321 assert_eq!(
322 body_with_template("Commit body.", "## Summary"),
323 "## Summary\n\nCommit body.\n\n---"
324 );
325 }
326
327 #[test]
328 fn body_with_template_always_seams_even_when_the_commit_body_matches() {
329 let seeded = body_with_template("## Summary\n\ndetails", "## Summary");
332 assert_eq!(seeded, "## Summary\n\n## Summary\n\ndetails\n\n---");
333 }
334
335 #[test]
336 fn body_template_as_description_wraps_the_template() {
337 assert_eq!(
338 body_template_as_description("## Summary", ""),
339 "<!-- git-stk:description -->\n## Summary\n<!-- /git-stk:description -->"
340 );
341 }
342
343 #[test]
344 fn body_template_as_description_keeps_the_commit_body_below_the_template() {
345 assert_eq!(
346 body_template_as_description("## Summary", "Commit body."),
347 "<!-- git-stk:description -->\n## Summary\n\nCommit body.\n<!-- /git-stk:description -->"
348 );
349 }
350
351 #[test]
352 fn seam_separates_the_template_from_the_managed_sections() {
353 let seeded = body_with_template("", "## Summary\n\n- [ ] Tests");
356 let with_desc = body_with_description_note(&seeded, "What and why.");
357 let body = body_with_closes_note(&with_desc, "Closes #5");
358
359 let template = body.find("- [ ] Tests").expect("template present");
360 let rule = body.find("\n\n---\n\n").expect("seam rule present");
361 let description = body.find("What and why.").expect("description below seam");
362 let closes = body.find("Closes #5").expect("closes below seam");
363 assert!(template < rule, "template sits above the seam");
364 assert!(
365 rule < description && rule < closes,
366 "managed sections sit below the seam"
367 );
368 assert_eq!(body.matches("\n\n---\n\n").count(), 1, "{body}");
370 }
371
372 #[test]
373 fn body_with_closes_note_appends_without_a_stack_section() {
374 let updated = body_with_closes_note("Description.", "Closes #5");
375 assert_eq!(
376 updated,
377 "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
378 );
379 }
380
381 #[test]
382 fn body_with_closes_note_lands_above_the_stack_section() {
383 let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
384 let updated = body_with_closes_note(body, "Closes #5");
385 assert_eq!(
386 updated,
387 "Description.\n\n\
388 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
389 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
390 );
391 }
392
393 #[test]
394 fn body_with_closes_note_replaces_a_stale_note_in_place() {
395 let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
396 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
397 let updated = body_with_closes_note(body, "Closes #5");
398 assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
399 assert!(updated.contains("Closes #5"));
400 assert!(!updated.contains("Closes #4"));
401 let closes = updated.find("Closes #5").expect("closes note");
402 let stack = updated.find("stack list").expect("stack note");
403 assert!(
404 closes < stack,
405 "closes note should sit above the stack note"
406 );
407 }
408
409 #[test]
410 fn body_with_description_note_lands_above_every_managed_section() {
411 let body = "Intro.\n\n\
412 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
413 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
414 let updated = body_with_description_note(body, "Summary.");
415
416 let intro = updated.find("Intro.").expect("intro");
417 let description = updated.find("Summary.").expect("description");
418 let closes = updated.find("Closes #5").expect("closes");
419 let stack = updated.find("stack list").expect("stack");
420 assert!(intro < description && description < closes && closes < stack);
421 assert!(
422 updated
423 .contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
424 );
425 }
426
427 #[test]
428 fn body_with_description_note_replaces_in_place() {
429 let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
430 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
431 let updated = body_with_description_note(body, "New.");
432 assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
433 assert!(updated.contains("New."));
434 assert!(!updated.contains("Old."));
435 let description = updated.find("New.").expect("description");
436 let stack = updated.find("stack list").expect("stack");
437 assert!(description < stack);
438 }
439}