Skip to main content

git_stk/
notes.rs

1//! The stack overview maintained in every review description: build,
2//! splice, and self-repair of the marker-delimited section.
3
4use anyhow::Result;
5
6use crate::providers::{ReviewProvider, ReviewRequest};
7
8const STACK_NOTE_START: &str = "<!-- git-stk:stack -->";
9const STACK_NOTE_END: &str = "<!-- /git-stk:stack -->";
10const TOOL_URL: &str = "https://github.com/lararosekelley/git-stk";
11const LOGO_URL: &str =
12    "https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg";
13
14/// Maintain a stack overview in every review body: the full PR list
15/// leaf-first, the trunk at the bottom, and a pointing emoji marking the
16/// review being viewed. Lives between marker comments so resubmits replace
17/// it in place, and self-repairs if the markers were hand-edited away.
18pub fn update_stack_notes(
19    review_provider: &dyn ReviewProvider,
20    branch_parents: &[(String, String)],
21    dry_run: bool,
22) -> Result<()> {
23    // The bottom branch's parent is the base the whole stack sits on.
24    let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
25        return Ok(());
26    };
27
28    let mut entries = Vec::new();
29    for (branch, _) in branch_parents {
30        match review_provider.review_for_branch(branch)? {
31            Some(review) if review.branch == *branch => entries.push(review),
32            _ => {
33                // Without every review the overview would be wrong for all of
34                // them (dry runs never created the missing ones).
35                if !dry_run {
36                    println!("skipped stack notes: no review found for {branch}");
37                }
38                return Ok(());
39            }
40        }
41    }
42
43    for index in 0..entries.len() {
44        let note = build_stack_note(&entries, index, &trunk);
45        let review = &entries[index];
46
47        if dry_run {
48            println!("would update stack note in {}", review.id);
49            continue;
50        }
51
52        let body = review_provider.review_body(review)?;
53        let updated = body_with_stack_note(&body, &note);
54        if updated == body {
55            continue;
56        }
57
58        review_provider.update_review_body(review, &updated)?;
59        println!("updated stack note in {}", review.id);
60    }
61
62    Ok(())
63}
64
65/// Render the overview for one review: every PR in the stack leaf-first as a
66/// linked bullet, a pointer on the review being viewed, the trunk in
67/// backticks at the bottom, and a footer crediting the tool.
68fn build_stack_note(entries: &[ReviewRequest], current: usize, trunk: &str) -> String {
69    let mut lines = Vec::new();
70    for (index, entry) in entries.iter().enumerate().rev() {
71        let label = if entry.title.is_empty() {
72            entry.id.clone()
73        } else {
74            format!("{} ({})", entry.title, entry.id)
75        };
76        let mut line = format!("- [{label}]({})", entry.url);
77        if index == current {
78            line.push_str(" \u{1F448}");
79        }
80        lines.push(line);
81    }
82    lines.push(format!("- `{trunk}`"));
83
84    format!(
85        "{}\n\n---\n\nStack managed by \
86         <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
87         [git-stk]({TOOL_URL})",
88        lines.join("\n")
89    )
90}
91
92/// Replace the marker-delimited stack note in a review body, appending it at
93/// the end. Damaged markup (orphaned or reordered markers, duplicates) is
94/// stripped first, so the section self-repairs on the next submit.
95fn body_with_stack_note(body: &str, note: &str) -> String {
96    let section = format!("{STACK_NOTE_START}\n{note}\n{STACK_NOTE_END}");
97    let cleaned = strip_stack_notes(body);
98
99    if cleaned.trim().is_empty() {
100        section
101    } else {
102        format!("{}\n\n{section}", cleaned.trim_end())
103    }
104}
105
106/// Remove every well-formed marker section and any orphaned markers.
107fn strip_stack_notes(body: &str) -> String {
108    let mut result = body.to_owned();
109
110    while let Some(start) = result.find(STACK_NOTE_START) {
111        match result[start..].find(STACK_NOTE_END) {
112            Some(end_offset) => {
113                let end = start + end_offset + STACK_NOTE_END.len();
114                result.replace_range(start..end, "");
115            }
116            None => result.replace_range(start..start + STACK_NOTE_START.len(), ""),
117        }
118    }
119    while let Some(start) = result.find(STACK_NOTE_END) {
120        result.replace_range(start..start + STACK_NOTE_END.len(), "");
121    }
122
123    // Collapse the blank-line craters left behind by removed sections.
124    while result.contains("\n\n\n") {
125        result = result.replace("\n\n\n", "\n\n");
126    }
127    result
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::providers::ReviewState;
134
135    fn review(id: &str, title: &str, url: &str) -> ReviewRequest {
136        ReviewRequest {
137            id: id.to_owned(),
138            branch: String::new(),
139            base: String::new(),
140            state: ReviewState::Open,
141            url: url.to_owned(),
142            title: title.to_owned(),
143        }
144    }
145
146    #[test]
147    fn build_stack_note_lists_stack_leaf_first_with_pointer_and_trunk() {
148        let entries = vec![
149            review("#12", "Bottom change", "https://example.com/12"),
150            review("#13", "Top change", "https://example.com/13"),
151        ];
152
153        let note = build_stack_note(&entries, 0, "main");
154        assert_eq!(
155            note,
156            "- [Top change (#13)](https://example.com/13)\n\
157             - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
158             - `main`\n\n\
159             ---\n\n\
160             Stack managed by \
161             <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
162             width=\"12\" height=\"12\" alt=\"\" /> \
163             [git-stk](https://github.com/lararosekelley/git-stk)"
164        );
165    }
166
167    #[test]
168    fn build_stack_note_falls_back_to_id_without_title() {
169        let entries = vec![review("#12", "", "https://example.com/12")];
170        let note = build_stack_note(&entries, 0, "main");
171        assert!(note.contains("- [#12](https://example.com/12) \u{1F448}"));
172    }
173
174    #[test]
175    fn body_with_stack_note_appends_to_existing_body() {
176        let updated = body_with_stack_note("Some PR description.\n", "stack list");
177        assert_eq!(
178            updated,
179            "Some PR description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
180        );
181    }
182
183    #[test]
184    fn body_with_stack_note_fills_empty_body() {
185        let updated = body_with_stack_note("", "stack list");
186        assert_eq!(
187            updated,
188            "<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
189        );
190    }
191
192    #[test]
193    fn body_with_stack_note_replaces_existing_note() {
194        let body = "Intro.\n\n<!-- git-stk:stack -->\nold list\n<!-- /git-stk:stack -->\n\nOutro.";
195        let updated = body_with_stack_note(body, "new list");
196        assert_eq!(
197            updated,
198            "Intro.\n\nOutro.\n\n<!-- git-stk:stack -->\nnew list\n<!-- /git-stk:stack -->"
199        );
200    }
201
202    #[test]
203    fn body_with_stack_note_is_idempotent() {
204        let body = body_with_stack_note("Description.", "stack list");
205        assert_eq!(body_with_stack_note(&body, "stack list"), body);
206    }
207
208    #[test]
209    fn body_with_stack_note_repairs_orphaned_start_marker() {
210        let body = "Intro.\n\n<!-- git-stk:stack -->\nleftover text";
211        let updated = body_with_stack_note(body, "fresh list");
212        assert_eq!(
213            updated,
214            "Intro.\n\nleftover text\n\n<!-- git-stk:stack -->\nfresh list\n<!-- /git-stk:stack -->"
215        );
216    }
217
218    #[test]
219    fn body_with_stack_note_repairs_orphaned_end_marker() {
220        let body = "Intro.\nstray\n<!-- /git-stk:stack -->\nOutro.";
221        let updated = body_with_stack_note(body, "fresh list");
222        assert!(updated.matches("<!-- git-stk:stack -->").count() == 1);
223        assert!(updated.matches("<!-- /git-stk:stack -->").count() == 1);
224        assert!(updated.contains("Intro.\nstray"));
225        assert!(updated.ends_with("<!-- /git-stk:stack -->"));
226    }
227
228    #[test]
229    fn body_with_stack_note_repairs_reversed_and_duplicate_markers() {
230        let body = "<!-- /git-stk:stack -->\nA\n<!-- git-stk:stack -->\nB\n\
231                    <!-- git-stk:stack -->\nC\n<!-- /git-stk:stack -->\nD";
232        let updated = body_with_stack_note(body, "fresh list");
233        assert_eq!(updated.matches("<!-- git-stk:stack -->").count(), 1);
234        assert_eq!(updated.matches("<!-- /git-stk:stack -->").count(), 1);
235        assert!(updated.contains("fresh list"));
236        assert!(updated.ends_with("<!-- /git-stk:stack -->"));
237    }
238}