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), a branch with no
138/// `--desc` wraps the template in the managed description block as its opening
139/// prose. The `--desc` branch (named by `desc_branch`) ignores the template
140/// entirely - the description replaces it - so all that happens there is
141/// `create_review`'s subject echo (seeded when the commit had no body) is
142/// dropped, leaving the description clear of a redundant copy of the title. A
143/// branch with neither a template nor a description keeps its subject
144/// placeholder untouched. The template's source is always the commit body,
145/// never the subject echo.
146pub 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        // A branch with no template to seed and no description that would
165        // strand the subject echo needs no change - leave create_review's body,
166        // and make no provider call for it.
167        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            // The description replaces the template on this branch, so ignore it
188            // and only drop create_review's subject echo, keeping a real commit
189            // body if the commit had one.
190            (prose.to_owned(), false)
191        } else {
192            match &template {
193                // No description: wrap the template in the managed block.
194                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
213/// Wrap the template (and any commit body) in the managed description block,
214/// for a branch that gets no `--desc`. Keeping it inside the block - rather than
215/// freeform above a seam - lets it read as the opening prose and keeps git-stk's
216/// managed sections contiguous.
217fn 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
226/// The issue number a branch name refers to, if any. A path segment that
227/// starts with the number (`123-fix-thing`, `fix/123-thing`, bare `123`) or
228/// prefixes it with issue/issues (`issue-123`, `fix/issues-123-thing`)
229/// counts; trailing numbers do not, to keep version-ish names from
230/// closing unrelated issues.
231fn 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
257/// Splice the closes note in, keeping it above the stack overview so the
258/// closing keyword reads as part of the description rather than the footer.
259fn body_with_closes_note(body: &str, note: &str) -> String {
260    body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
261}
262
263/// Splice the user's description in, above every managed section so it
264/// reads as the opening of the body.
265fn 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}