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                anstream::println!("would link issue #{issue} in the review for {branch}");
39            } else {
40                anstream::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            anstream::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        anstream::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            anstream::println!("would {verb} the description on the review for {branch}");
85        } else {
86            anstream::println!("skipped description: no review found for {branch}");
87        }
88        return Ok(());
89    };
90    if review.branch != *branch {
91        anstream::println!(
92            "skipped description: review {} belongs to {}",
93            review.id,
94            review.branch
95        );
96        return Ok(());
97    }
98
99    if dry_run {
100        anstream::println!("would {verb} the description in {}", review.id);
101        return Ok(());
102    }
103
104    let body = review_provider.review_body(&review)?;
105    let updated = if description.is_empty() {
106        if !body.contains(&marker_start(DESCRIPTION_SECTION)) {
107            return Ok(());
108        }
109        strip_sections(&body, DESCRIPTION_SECTION)
110            .trim_end()
111            .to_owned()
112    } else {
113        body_with_description_note(&body, description)
114    };
115    if updated == body {
116        return Ok(());
117    }
118
119    review_provider.update_review_body(&review, &updated)?;
120    anstream::println!(
121        "{} description in {}",
122        if description.is_empty() {
123            "cleared"
124        } else {
125            "set"
126        },
127        review.id
128    );
129    Ok(())
130}
131
132/// The issue number a branch name refers to, if any. A path segment that
133/// starts with the number (`123-fix-thing`, `fix/123-thing`, bare `123`) or
134/// prefixes it with issue/issues (`issue-123`, `fix/issues-123-thing`)
135/// counts; trailing numbers do not, to keep version-ish names from
136/// closing unrelated issues.
137fn issue_number_from_branch(branch: &str) -> Option<u64> {
138    for segment in branch.split('/') {
139        let lowered = segment.to_ascii_lowercase();
140        let candidate = lowered
141            .strip_prefix("issue-")
142            .or_else(|| lowered.strip_prefix("issues-"))
143            .unwrap_or(&lowered);
144
145        let end = candidate
146            .find(|character: char| !character.is_ascii_digit())
147            .unwrap_or(candidate.len());
148        let (digits, rest) = candidate.split_at(end);
149        if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
150            continue;
151        }
152
153        if let Ok(number) = digits.parse::<u64>()
154            && number > 0
155        {
156            return Some(number);
157        }
158    }
159
160    None
161}
162
163/// Splice the closes note in, keeping it above the stack overview so the
164/// closing keyword reads as part of the description rather than the footer.
165fn body_with_closes_note(body: &str, note: &str) -> String {
166    body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
167}
168
169/// Splice the user's description in, above every managed section so it
170/// reads as the opening of the body.
171fn body_with_description_note(body: &str, description: &str) -> String {
172    body_with_section_before(
173        body,
174        DESCRIPTION_SECTION,
175        description,
176        &[CLOSES_SECTION, STACK_SECTION],
177    )
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn issue_number_from_branch_reads_supported_shapes() {
186        assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
187        assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
188        assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
189        assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
190        assert_eq!(issue_number_from_branch("42"), Some(42));
191    }
192
193    #[test]
194    fn issue_number_from_branch_rejects_lookalikes() {
195        assert_eq!(issue_number_from_branch("feature/b"), None);
196        assert_eq!(issue_number_from_branch("fix-thing-123"), None);
197        assert_eq!(issue_number_from_branch("v2-migration"), None);
198        assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
199        assert_eq!(issue_number_from_branch("0-zero"), None);
200        assert_eq!(issue_number_from_branch("upgrade-issue"), None);
201    }
202
203    #[test]
204    fn body_with_closes_note_appends_without_a_stack_section() {
205        let updated = body_with_closes_note("Description.", "Closes #5");
206        assert_eq!(
207            updated,
208            "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
209        );
210    }
211
212    #[test]
213    fn body_with_closes_note_lands_above_the_stack_section() {
214        let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
215        let updated = body_with_closes_note(body, "Closes #5");
216        assert_eq!(
217            updated,
218            "Description.\n\n\
219             <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
220             <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
221        );
222    }
223
224    #[test]
225    fn body_with_closes_note_replaces_a_stale_note_in_place() {
226        let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
227                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
228        let updated = body_with_closes_note(body, "Closes #5");
229        assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
230        assert!(updated.contains("Closes #5"));
231        assert!(!updated.contains("Closes #4"));
232        let closes = updated.find("Closes #5").expect("closes note");
233        let stack = updated.find("stack list").expect("stack note");
234        assert!(
235            closes < stack,
236            "closes note should sit above the stack note"
237        );
238    }
239
240    #[test]
241    fn body_with_description_note_lands_above_every_managed_section() {
242        let body = "Intro.\n\n\
243                    <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
244                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
245        let updated = body_with_description_note(body, "Summary.");
246
247        let intro = updated.find("Intro.").expect("intro");
248        let description = updated.find("Summary.").expect("description");
249        let closes = updated.find("Closes #5").expect("closes");
250        let stack = updated.find("stack list").expect("stack");
251        assert!(intro < description && description < closes && closes < stack);
252        assert!(
253            updated
254                .contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
255        );
256    }
257
258    #[test]
259    fn body_with_description_note_replaces_in_place() {
260        let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
261                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
262        let updated = body_with_description_note(body, "New.");
263        assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
264        assert!(updated.contains("New."));
265        assert!(!updated.contains("Old."));
266        let description = updated.find("New.").expect("description");
267        let stack = updated.find("stack list").expect("stack");
268        assert!(description < stack);
269    }
270}