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.
138pub fn seed_template_notes(
139    review_provider: &dyn ReviewProvider,
140    kind: ProviderKind,
141    created: &[String],
142    dry_run: bool,
143) -> Result<()> {
144    if created.is_empty() || !settings::use_pr_template()? {
145        return Ok(());
146    }
147    let Some(template) = template::discover(kind)? else {
148        return Ok(());
149    };
150
151    for branch in created {
152        if dry_run {
153            anstream::println!("would seed the PR template into the review for {branch}");
154            continue;
155        }
156
157        let Some(review) = review_provider.review_for_branch(branch)? else {
158            anstream::println!("skipped PR template: no review found for {branch}");
159            continue;
160        };
161        if review.branch != *branch {
162            continue;
163        }
164
165        let body = review_provider.review_body(&review)?;
166        let updated = body_with_template(&body, &template);
167        if updated == body {
168            continue;
169        }
170
171        review_provider.update_review_body(&review, &updated)?;
172        anstream::println!("seeded the PR template into {}", review.id);
173    }
174
175    Ok(())
176}
177
178/// Place the template at the top of the body. An empty body becomes the
179/// template alone; a body that already carries it is left untouched (so a
180/// re-seed is a no-op); otherwise the template is prepended above the
181/// `--fill` content, with the managed sections appending below it later.
182fn body_with_template(body: &str, template: &str) -> String {
183    if body.trim().is_empty() {
184        return template.to_owned();
185    }
186    if body.contains(template) {
187        return body.to_owned();
188    }
189    format!("{template}\n\n{}", body.trim_start())
190}
191
192/// The issue number a branch name refers to, if any. A path segment that
193/// starts with the number (`123-fix-thing`, `fix/123-thing`, bare `123`) or
194/// prefixes it with issue/issues (`issue-123`, `fix/issues-123-thing`)
195/// counts; trailing numbers do not, to keep version-ish names from
196/// closing unrelated issues.
197fn issue_number_from_branch(branch: &str) -> Option<u64> {
198    for segment in branch.split('/') {
199        let lowered = segment.to_ascii_lowercase();
200        let candidate = lowered
201            .strip_prefix("issue-")
202            .or_else(|| lowered.strip_prefix("issues-"))
203            .unwrap_or(&lowered);
204
205        let end = candidate
206            .find(|character: char| !character.is_ascii_digit())
207            .unwrap_or(candidate.len());
208        let (digits, rest) = candidate.split_at(end);
209        if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
210            continue;
211        }
212
213        if let Ok(number) = digits.parse::<u64>()
214            && number > 0
215        {
216            return Some(number);
217        }
218    }
219
220    None
221}
222
223/// Splice the closes note in, keeping it above the stack overview so the
224/// closing keyword reads as part of the description rather than the footer.
225fn body_with_closes_note(body: &str, note: &str) -> String {
226    body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
227}
228
229/// Splice the user's description in, above every managed section so it
230/// reads as the opening of the body.
231fn body_with_description_note(body: &str, description: &str) -> String {
232    body_with_section_before(
233        body,
234        DESCRIPTION_SECTION,
235        description,
236        &[CLOSES_SECTION, STACK_SECTION],
237    )
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn issue_number_from_branch_reads_supported_shapes() {
246        assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
247        assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
248        assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
249        assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
250        assert_eq!(issue_number_from_branch("42"), Some(42));
251    }
252
253    #[test]
254    fn issue_number_from_branch_rejects_lookalikes() {
255        assert_eq!(issue_number_from_branch("feature/b"), None);
256        assert_eq!(issue_number_from_branch("fix-thing-123"), None);
257        assert_eq!(issue_number_from_branch("v2-migration"), None);
258        assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
259        assert_eq!(issue_number_from_branch("0-zero"), None);
260        assert_eq!(issue_number_from_branch("upgrade-issue"), None);
261    }
262
263    #[test]
264    fn body_with_template_fills_an_empty_body() {
265        assert_eq!(body_with_template("", "## Summary"), "## Summary");
266        assert_eq!(body_with_template("   \n", "## Summary"), "## Summary");
267    }
268
269    #[test]
270    fn body_with_template_prepends_above_fill_content() {
271        assert_eq!(
272            body_with_template("Commit body.", "## Summary"),
273            "## Summary\n\nCommit body."
274        );
275    }
276
277    #[test]
278    fn body_with_template_is_idempotent_when_already_present() {
279        let seeded = "## Summary\n\nCommit body.";
280        assert_eq!(body_with_template(seeded, "## Summary"), seeded);
281    }
282
283    #[test]
284    fn body_with_closes_note_appends_without_a_stack_section() {
285        let updated = body_with_closes_note("Description.", "Closes #5");
286        assert_eq!(
287            updated,
288            "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
289        );
290    }
291
292    #[test]
293    fn body_with_closes_note_lands_above_the_stack_section() {
294        let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
295        let updated = body_with_closes_note(body, "Closes #5");
296        assert_eq!(
297            updated,
298            "Description.\n\n\
299             <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
300             <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
301        );
302    }
303
304    #[test]
305    fn body_with_closes_note_replaces_a_stale_note_in_place() {
306        let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
307                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
308        let updated = body_with_closes_note(body, "Closes #5");
309        assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
310        assert!(updated.contains("Closes #5"));
311        assert!(!updated.contains("Closes #4"));
312        let closes = updated.find("Closes #5").expect("closes note");
313        let stack = updated.find("stack list").expect("stack note");
314        assert!(
315            closes < stack,
316            "closes note should sit above the stack note"
317        );
318    }
319
320    #[test]
321    fn body_with_description_note_lands_above_every_managed_section() {
322        let body = "Intro.\n\n\
323                    <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
324                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
325        let updated = body_with_description_note(body, "Summary.");
326
327        let intro = updated.find("Intro.").expect("intro");
328        let description = updated.find("Summary.").expect("description");
329        let closes = updated.find("Closes #5").expect("closes");
330        let stack = updated.find("stack list").expect("stack");
331        assert!(intro < description && description < closes && closes < stack);
332        assert!(
333            updated
334                .contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
335        );
336    }
337
338    #[test]
339    fn body_with_description_note_replaces_in_place() {
340        let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
341                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
342        let updated = body_with_description_note(body, "New.");
343        assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
344        assert!(updated.contains("New."));
345        assert!(!updated.contains("Old."));
346        let description = updated.find("New.").expect("description");
347        let stack = updated.find("stack list").expect("stack");
348        assert!(description < stack);
349    }
350}