Skip to main content

git_stk/notes/
mod.rs

1//! The managed blocks in review descriptions: the user's description, the
2//! issue-closing link, and the stack-overview ledger ([`ledger`]), all
3//! built on marker-delimited [`sections`].
4
5use 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
22/// Add a `Closes #N` line to each branch's review when the branch name
23/// references an issue (e.g. `123-fix-thing`, `fix/issue-123`), so the
24/// platform closes the issue when the review merges. Branches without an
25/// issue reference are passed over silently.
26pub 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            // On a dry run the review was likely never created; for real the
38            // submit just failed to produce one, which deserves a mention.
39            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
69/// Write (or, with an empty string, clear) the description block in the
70/// branch's review body. Unlike the stack overview the block is sticky:
71/// submits without `--desc` never touch it.
72pub 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
134/// Seed each freshly created review's body with the repo's PR/MR template, so
135/// the managed sections below augment it instead of `--fill` replacing it.
136/// Create-only - existing reviews keep whatever body they have - and skipped
137/// when `stk.usePrTemplate` is off or the repo has no single template.
138/// Each entry is a freshly created branch and whether git-stk will write a
139/// managed section (description, `Closes #N`, or stack overview) into its
140/// review below the template - which decides whether a seam rule is seeded.
141pub 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
181/// Whether a branch name references an issue git-stk would link with a
182/// `Closes #N` note. Lets the caller predict whether a seeded template will be
183/// followed by managed content (and so needs a seam rule).
184pub fn branch_references_issue(branch: &str) -> bool {
185    issue_number_from_branch(branch).is_some()
186}
187
188/// Place the template at the top of the body. An empty body becomes the
189/// template alone; a body that already carries it is left untouched (so a
190/// re-seed is a no-op); otherwise the template is prepended above the `--fill`
191/// content. With `seam`, a horizontal rule is appended below the freeform
192/// region so the managed sections that follow read as a distinct block rather
193/// than blurring into the template - only when those sections will actually be
194/// written, so the rule never dangles under nothing.
195fn 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
211/// The issue number a branch name refers to, if any. A path segment that
212/// starts with the number (`123-fix-thing`, `fix/123-thing`, bare `123`) or
213/// prefixes it with issue/issues (`issue-123`, `fix/issues-123-thing`)
214/// counts; trailing numbers do not, to keep version-ish names from
215/// closing unrelated issues.
216fn 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
242/// Splice the closes note in, keeping it above the stack overview so the
243/// closing keyword reads as part of the description rather than the footer.
244fn body_with_closes_note(body: &str, note: &str) -> String {
245    body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
246}
247
248/// Splice the user's description in, above every managed section so it
249/// reads as the opening of the body.
250fn 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        // Even with the seam requested, a body that already carries the
304        // template is left exactly as is - no second rule.
305        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        // Seed with a seam, then let the managed sections append below it -
323        // they must land under the rule, not above or onto it.
324        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        // Exactly one rule - the seam - not one per managed section.
338        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}