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/// Prepare each freshly created review's body before the managed sections go
135/// in. Create-only - existing reviews keep whatever body they have.
136///
137/// With a repo PR/MR template (and `stk.usePrTemplate` on), the body is seeded
138/// from it: the `--desc` branch (named by `desc_branch`) keeps the template
139/// freeform above a seam so the description reads as a distinct block below,
140/// while every other branch wraps the template in the managed description block
141/// as the opening prose. Without a template the only cleanup is on the `--desc`
142/// branch, whose body `create_review` seeded with the commit subject when the
143/// commit had no body: that echo is dropped so the description does not sit
144/// beneath a redundant copy of the title. A branch with neither a template nor
145/// a description keeps its subject placeholder untouched. The template's source
146/// is always the commit body, never the subject echo.
147pub 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        // A branch with no template to seed and no description that would
166        // strand the subject echo needs no change - leave create_review's body,
167        // and make no provider call for it.
168        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            // Template + description: template stays freeform above the seam.
189            (Some(template), true) => (body_with_template(prose, template), true),
190            // Template, no description: wrap it in the managed block.
191            (Some(template), false) => (body_template_as_description(template, prose), true),
192            // No template, description coming: drop create_review's subject echo,
193            // keeping a real commit body if the commit had one.
194            (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
212/// Wrap the template (and any commit body) in the managed description block,
213/// for a branch that gets no `--desc`. Keeping it inside the block - rather than
214/// freeform above a seam - lets it read as the opening prose and keeps git-stk's
215/// managed sections contiguous.
216fn 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
225/// Seed the `--desc` branch's body: the template, with the commit body beneath
226/// it when the commit has one, above a horizontal-rule seam that separates it
227/// from the managed description block `--desc` writes below. Always seamed - a
228/// description always follows - and the caller's `updated == body` check is what
229/// makes a re-seed a no-op, not any guard here.
230fn 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
239/// The issue number a branch name refers to, if any. A path segment that
240/// starts with the number (`123-fix-thing`, `fix/123-thing`, bare `123`) or
241/// prefixes it with issue/issues (`issue-123`, `fix/issues-123-thing`)
242/// counts; trailing numbers do not, to keep version-ish names from
243/// closing unrelated issues.
244fn 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
270/// Splice the closes note in, keeping it above the stack overview so the
271/// closing keyword reads as part of the description rather than the footer.
272fn body_with_closes_note(body: &str, note: &str) -> String {
273    body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
274}
275
276/// Splice the user's description in, above every managed section so it
277/// reads as the opening of the body.
278fn 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        // A commit body that opens with the template text must not suppress the
330        // seam: the `--desc` block always needs a rule above it.
331        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        // Seed with a seam, then let the managed sections append below it -
354        // they must land under the rule, not above or onto it.
355        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        // Exactly one rule - the seam - not one per managed section.
369        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}