1use 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
14pub fn update_stack_notes(
19 review_provider: &dyn ReviewProvider,
20 branch_parents: &[(String, String)],
21 dry_run: bool,
22) -> Result<()> {
23 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 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, ¬e);
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
65fn 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
92fn 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
106fn 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 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}