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(
147 review_provider: &dyn ReviewProvider,
148 kind: ProviderKind,
149 created: &[String],
150 desc_branch: Option<&str>,
151 dry_run: bool,
152) -> Result<()> {
153 if created.is_empty() {
154 return Ok(());
155 }
156 let template = if settings::use_pr_template()? {
157 template::discover(kind)?
158 } else {
159 None
160 };
161
162 for branch in created {
163 let is_desc = desc_branch == Some(branch.as_str());
164 if template.is_none() && !is_desc {
168 continue;
169 }
170 if dry_run {
171 anstream::println!("would seed the review body for {branch}");
172 continue;
173 }
174
175 let Some(review) = review_provider.review_for_branch(branch)? else {
176 anstream::println!("skipped body seed: no review found for {branch}");
177 continue;
178 };
179 if review.branch != *branch {
180 continue;
181 }
182
183 let body = review_provider.review_body(&review)?;
184 let commit_body = crate::git::commit_body(branch)?;
185 let prose = commit_body.trim();
186 let (updated, seeded_template) = if is_desc {
187 (prose.to_owned(), false)
191 } else {
192 match &template {
193 Some(template) => (body_template_as_description(template, prose), true),
195 None => continue,
196 }
197 };
198 if updated == body {
199 continue;
200 }
201
202 review_provider.update_review_body(&review, &updated)?;
203 if seeded_template {
204 anstream::println!("seeded the PR template into {}", review.id);
205 } else {
206 anstream::println!("dropped the commit subject from {}", review.id);
207 }
208 }
209
210 Ok(())
211}
212
213fn body_template_as_description(template: &str, prose: &str) -> String {
218 let content = if prose.is_empty() {
219 template.to_owned()
220 } else {
221 format!("{template}\n\n{prose}")
222 };
223 body_with_description_note("", &content)
224}
225
226fn issue_number_from_branch(branch: &str) -> Option<u64> {
232 for segment in branch.split('/') {
233 let lowered = segment.to_ascii_lowercase();
234 let candidate = lowered
235 .strip_prefix("issue-")
236 .or_else(|| lowered.strip_prefix("issues-"))
237 .unwrap_or(&lowered);
238
239 let end = candidate
240 .find(|character: char| !character.is_ascii_digit())
241 .unwrap_or(candidate.len());
242 let (digits, rest) = candidate.split_at(end);
243 if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
244 continue;
245 }
246
247 if let Ok(number) = digits.parse::<u64>()
248 && number > 0
249 {
250 return Some(number);
251 }
252 }
253
254 None
255}
256
257fn body_with_closes_note(body: &str, note: &str) -> String {
260 body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
261}
262
263fn body_with_description_note(body: &str, description: &str) -> String {
266 body_with_section_before(
267 body,
268 DESCRIPTION_SECTION,
269 description,
270 &[CLOSES_SECTION, STACK_SECTION],
271 )
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn issue_number_from_branch_reads_supported_shapes() {
280 assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
281 assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
282 assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
283 assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
284 assert_eq!(issue_number_from_branch("42"), Some(42));
285 }
286
287 #[test]
288 fn issue_number_from_branch_rejects_lookalikes() {
289 assert_eq!(issue_number_from_branch("feature/b"), None);
290 assert_eq!(issue_number_from_branch("fix-thing-123"), None);
291 assert_eq!(issue_number_from_branch("v2-migration"), None);
292 assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
293 assert_eq!(issue_number_from_branch("0-zero"), None);
294 assert_eq!(issue_number_from_branch("upgrade-issue"), None);
295 }
296
297 #[test]
298 fn body_template_as_description_wraps_the_template() {
299 assert_eq!(
300 body_template_as_description("## Summary", ""),
301 "<!-- git-stk:description -->\n## Summary\n<!-- /git-stk:description -->"
302 );
303 }
304
305 #[test]
306 fn body_template_as_description_keeps_the_commit_body_below_the_template() {
307 assert_eq!(
308 body_template_as_description("## Summary", "Commit body."),
309 "<!-- git-stk:description -->\n## Summary\n\nCommit body.\n<!-- /git-stk:description -->"
310 );
311 }
312
313 #[test]
314 fn body_with_closes_note_appends_without_a_stack_section() {
315 let updated = body_with_closes_note("Description.", "Closes #5");
316 assert_eq!(
317 updated,
318 "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
319 );
320 }
321
322 #[test]
323 fn body_with_closes_note_lands_above_the_stack_section() {
324 let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
325 let updated = body_with_closes_note(body, "Closes #5");
326 assert_eq!(
327 updated,
328 "Description.\n\n\
329 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
330 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
331 );
332 }
333
334 #[test]
335 fn body_with_closes_note_replaces_a_stale_note_in_place() {
336 let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
337 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
338 let updated = body_with_closes_note(body, "Closes #5");
339 assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
340 assert!(updated.contains("Closes #5"));
341 assert!(!updated.contains("Closes #4"));
342 let closes = updated.find("Closes #5").expect("closes note");
343 let stack = updated.find("stack list").expect("stack note");
344 assert!(
345 closes < stack,
346 "closes note should sit above the stack note"
347 );
348 }
349
350 #[test]
351 fn body_with_description_note_lands_above_every_managed_section() {
352 let body = "Intro.\n\n\
353 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
354 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
355 let updated = body_with_description_note(body, "Summary.");
356
357 let intro = updated.find("Intro.").expect("intro");
358 let description = updated.find("Summary.").expect("description");
359 let closes = updated.find("Closes #5").expect("closes");
360 let stack = updated.find("stack list").expect("stack");
361 assert!(intro < description && description < closes && closes < stack);
362 assert!(
363 updated
364 .contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
365 );
366 }
367
368 #[test]
369 fn body_with_description_note_replaces_in_place() {
370 let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
371 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
372 let updated = body_with_description_note(body, "New.");
373 assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
374 assert!(updated.contains("New."));
375 assert!(!updated.contains("Old."));
376 let description = updated.find("New.").expect("description");
377 let stack = updated.find("stack list").expect("stack");
378 assert!(description < stack);
379 }
380}