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