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::{ReviewProvider, ReviewState};
8
9mod ledger;
10mod sections;
11
12pub use ledger::update_stack_notes;
13
14use sections::{body_with_section_before, marker_start, strip_sections};
15
16const STACK_SECTION: &str = "stack";
17const CLOSES_SECTION: &str = "closes";
18const DESCRIPTION_SECTION: &str = "description";
19
20/// Add a `Closes #N` line to each branch's review when the branch name
21/// references an issue (e.g. `123-fix-thing`, `fix/issue-123`), so the
22/// platform closes the issue when the review merges. Branches without an
23/// issue reference are passed over silently.
24pub fn update_closes_notes(
25    review_provider: &dyn ReviewProvider,
26    branches: &[String],
27    dry_run: bool,
28) -> Result<()> {
29    for branch in branches {
30        let Some(issue) = issue_number_from_branch(branch) else {
31            continue;
32        };
33
34        let Some(review) = review_provider.review_for_branch(branch)? else {
35            // On a dry run the review was likely never created; for real the
36            // submit just failed to produce one, which deserves a mention.
37            if dry_run {
38                println!("would link issue #{issue} in the review for {branch}");
39            } else {
40                println!("skipped issue link: no review found for {branch}");
41            }
42            continue;
43        };
44
45        if review.branch != *branch || review.state == ReviewState::Merged {
46            continue;
47        }
48
49        if dry_run {
50            println!("would link issue #{issue} in {}", review.id);
51            continue;
52        }
53
54        let body = review_provider.review_body(&review)?;
55        let updated = body_with_closes_note(&body, &format!("Closes #{issue}"));
56        if updated == body {
57            continue;
58        }
59
60        review_provider.update_review_body(&review, &updated)?;
61        println!("linked issue #{issue} in {}", review.id);
62    }
63
64    Ok(())
65}
66
67/// Write (or, with an empty string, clear) the description block in the
68/// branch's review body. Unlike the stack overview the block is sticky:
69/// submits without `--desc` never touch it.
70pub fn update_description_note(
71    review_provider: &dyn ReviewProvider,
72    branch: &str,
73    description: &str,
74    dry_run: bool,
75) -> Result<()> {
76    let verb = if description.is_empty() {
77        "clear"
78    } else {
79        "set"
80    };
81
82    let Some(review) = review_provider.review_for_branch(branch)? else {
83        if dry_run {
84            println!("would {verb} the description on the review for {branch}");
85        } else {
86            println!("skipped description: no review found for {branch}");
87        }
88        return Ok(());
89    };
90    if review.branch != *branch {
91        println!(
92            "skipped description: review {} belongs to {}",
93            review.id, review.branch
94        );
95        return Ok(());
96    }
97
98    if dry_run {
99        println!("would {verb} the description in {}", review.id);
100        return Ok(());
101    }
102
103    let body = review_provider.review_body(&review)?;
104    let updated = if description.is_empty() {
105        if !body.contains(&marker_start(DESCRIPTION_SECTION)) {
106            return Ok(());
107        }
108        strip_sections(&body, DESCRIPTION_SECTION)
109            .trim_end()
110            .to_owned()
111    } else {
112        body_with_description_note(&body, description)
113    };
114    if updated == body {
115        return Ok(());
116    }
117
118    review_provider.update_review_body(&review, &updated)?;
119    println!(
120        "{} description in {}",
121        if description.is_empty() {
122            "cleared"
123        } else {
124            "set"
125        },
126        review.id
127    );
128    Ok(())
129}
130
131/// The issue number a branch name refers to, if any. A path segment that
132/// starts with the number (`123-fix-thing`, `fix/123-thing`, bare `123`) or
133/// prefixes it with issue/issues (`issue-123`, `fix/issues-123-thing`)
134/// counts; trailing numbers do not, to keep version-ish names from
135/// closing unrelated issues.
136fn issue_number_from_branch(branch: &str) -> Option<u64> {
137    for segment in branch.split('/') {
138        let lowered = segment.to_ascii_lowercase();
139        let candidate = lowered
140            .strip_prefix("issue-")
141            .or_else(|| lowered.strip_prefix("issues-"))
142            .unwrap_or(&lowered);
143
144        let end = candidate
145            .find(|character: char| !character.is_ascii_digit())
146            .unwrap_or(candidate.len());
147        let (digits, rest) = candidate.split_at(end);
148        if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
149            continue;
150        }
151
152        if let Ok(number) = digits.parse::<u64>()
153            && number > 0
154        {
155            return Some(number);
156        }
157    }
158
159    None
160}
161
162/// Splice the closes note in, keeping it above the stack overview so the
163/// closing keyword reads as part of the description rather than the footer.
164fn body_with_closes_note(body: &str, note: &str) -> String {
165    body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
166}
167
168/// Splice the user's description in, above every managed section so it
169/// reads as the opening of the body.
170fn body_with_description_note(body: &str, description: &str) -> String {
171    body_with_section_before(
172        body,
173        DESCRIPTION_SECTION,
174        description,
175        &[CLOSES_SECTION, STACK_SECTION],
176    )
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn issue_number_from_branch_reads_supported_shapes() {
185        assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
186        assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
187        assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
188        assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
189        assert_eq!(issue_number_from_branch("42"), Some(42));
190    }
191
192    #[test]
193    fn issue_number_from_branch_rejects_lookalikes() {
194        assert_eq!(issue_number_from_branch("feature/b"), None);
195        assert_eq!(issue_number_from_branch("fix-thing-123"), None);
196        assert_eq!(issue_number_from_branch("v2-migration"), None);
197        assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
198        assert_eq!(issue_number_from_branch("0-zero"), None);
199        assert_eq!(issue_number_from_branch("upgrade-issue"), None);
200    }
201
202    #[test]
203    fn body_with_closes_note_appends_without_a_stack_section() {
204        let updated = body_with_closes_note("Description.", "Closes #5");
205        assert_eq!(
206            updated,
207            "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
208        );
209    }
210
211    #[test]
212    fn body_with_closes_note_lands_above_the_stack_section() {
213        let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
214        let updated = body_with_closes_note(body, "Closes #5");
215        assert_eq!(
216            updated,
217            "Description.\n\n\
218             <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
219             <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
220        );
221    }
222
223    #[test]
224    fn body_with_closes_note_replaces_a_stale_note_in_place() {
225        let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
226                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
227        let updated = body_with_closes_note(body, "Closes #5");
228        assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
229        assert!(updated.contains("Closes #5"));
230        assert!(!updated.contains("Closes #4"));
231        let closes = updated.find("Closes #5").expect("closes note");
232        let stack = updated.find("stack list").expect("stack note");
233        assert!(
234            closes < stack,
235            "closes note should sit above the stack note"
236        );
237    }
238
239    #[test]
240    fn body_with_description_note_lands_above_every_managed_section() {
241        let body = "Intro.\n\n\
242                    <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
243                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
244        let updated = body_with_description_note(body, "Summary.");
245
246        let intro = updated.find("Intro.").expect("intro");
247        let description = updated.find("Summary.").expect("description");
248        let closes = updated.find("Closes #5").expect("closes");
249        let stack = updated.find("stack list").expect("stack");
250        assert!(intro < description && description < closes && closes < stack);
251        assert!(
252            updated
253                .contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
254        );
255    }
256
257    #[test]
258    fn body_with_description_note_replaces_in_place() {
259        let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
260                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
261        let updated = body_with_description_note(body, "New.");
262        assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
263        assert!(updated.contains("New."));
264        assert!(!updated.contains("Old."));
265        let description = updated.find("New.").expect("description");
266        let stack = updated.find("stack list").expect("stack");
267        assert!(description < stack);
268    }
269}