1use anyhow::Result;
6use serde_json::{Value, json};
7
8use super::STACK_SECTION;
9use super::sections::{body_with_section, extract_section};
10use crate::providers::{ReviewProvider, ReviewRequest, ReviewState};
11
12const DATA_PREFIX: &str = "<!-- git-stk:data ";
13const COMMENT_END: &str = "-->";
14const TOOL_URL: &str = "https://github.com/lararosekelley/git-stk";
15const LOGO_URL: &str =
16 "https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
23struct NoteEntry {
24 id: String,
25 url: String,
26 title: String,
27 state: String,
28}
29
30impl NoteEntry {
31 fn from_review(review: &ReviewRequest) -> Self {
32 Self {
33 id: review.id.clone(),
34 url: review.url.clone(),
35 title: review.title.clone(),
36 state: review.state.to_string(),
37 }
38 }
39
40 fn to_review(&self) -> ReviewRequest {
43 let state = match self.state.as_str() {
44 "open" => ReviewState::Open,
45 "merged" => ReviewState::Merged,
46 "closed" => ReviewState::Closed,
47 other => ReviewState::Unknown(other.to_owned()),
48 };
49 ReviewRequest {
50 id: self.id.clone(),
51 branch: String::new(),
52 base: String::new(),
53 state,
54 url: self.url.clone(),
55 title: self.title.clone(),
56 }
57 }
58
59 fn matches(&self, other: &Self) -> bool {
62 (!self.id.is_empty() && self.id == other.id)
63 || (!self.url.is_empty() && self.url == other.url)
64 }
65}
66
67pub fn update_stack_notes(
74 review_provider: &dyn ReviewProvider,
75 branch_parents: &[(String, String)],
76 dry_run: bool,
77) -> Result<()> {
78 let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
80 return Ok(());
81 };
82
83 let mut live = Vec::new();
84 for (branch, _) in branch_parents {
85 match review_provider.review_for_branch_including_closed(branch)? {
89 Some(review) if review.branch == *branch => live.push(review),
90 _ => {
91 if !dry_run {
94 println!("skipped stack notes: no review found for {branch}");
95 }
96 return Ok(());
97 }
98 }
99 }
100
101 if dry_run {
102 for review in &live {
103 println!("would update stack note in {}", review.id);
104 }
105 return Ok(());
106 }
107
108 let mut bodies = Vec::new();
112 for review in &live {
113 bodies.push(review_provider.review_body(review)?);
114 }
115
116 let live_entries: Vec<NoteEntry> = live.iter().map(NoteEntry::from_review).collect();
117 let mut historical: Vec<NoteEntry> = Vec::new();
118 for body in &bodies {
119 let Some(section) = extract_section(body, STACK_SECTION) else {
120 continue;
121 };
122 for entry in parse_ledger(section) {
123 let known = live_entries.iter().chain(historical.iter());
124 if !known
125 .into_iter()
126 .any(|entry_known| entry_known.matches(&entry))
127 {
128 historical.push(entry);
129 }
130 }
131 }
132
133 let mut entries = historical.clone();
136 entries.extend(live_entries);
137
138 for (offset, review) in live.iter().enumerate() {
139 let note = build_stack_note(&entries, historical.len() + offset, &trunk);
140 let updated = body_with_section(&bodies[offset], STACK_SECTION, ¬e);
141 if updated == bodies[offset] {
142 continue;
143 }
144
145 review_provider.update_review_body(review, &updated)?;
146 println!("updated stack note in {}", review.id);
147 }
148
149 for (index, entry) in historical.iter().enumerate() {
153 if entry.id.is_empty() {
154 continue;
155 }
156 let review = entry.to_review();
157 let Ok(body) = review_provider.review_body(&review) else {
158 println!("skipped stack note in {}: could not read body", review.id);
159 continue;
160 };
161
162 let note = build_stack_note(&entries, index, &trunk);
163 let updated = body_with_section(&body, STACK_SECTION, ¬e);
164 if updated == body {
165 continue;
166 }
167
168 if review_provider
169 .update_review_body(&review, &updated)
170 .is_err()
171 {
172 println!("skipped stack note in {}: could not update body", review.id);
173 continue;
174 }
175 println!("updated stack note in {}", review.id);
176 }
177
178 Ok(())
179}
180
181fn build_stack_note(entries: &[NoteEntry], current: usize, trunk: &str) -> String {
186 let mut lines = vec![data_line(entries)];
187 for (index, entry) in entries.iter().enumerate().rev() {
188 lines.push(render_entry(entry, index == current));
189 }
190 lines.push(format!("- `{trunk}`"));
191
192 format!(
193 "{}\n\n---\n\nStack managed by \
194 <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
195 [git-stk]({TOOL_URL})",
196 lines.join("\n")
197 )
198}
199
200fn render_entry(entry: &NoteEntry, current: bool) -> String {
203 let label = crate::providers::label(&entry.title, &entry.id);
204 let link = format!("[{label}]({})", entry.url);
205
206 let mut line = match entry.state.as_str() {
207 "merged" => format!("- \u{1F7E3} ~~{link}~~ (merged)"),
208 "closed" => format!("- \u{1F534} ~~{link}~~ (closed)"),
209 _ => format!("- \u{1F7E2} {link}"),
210 };
211 if current {
212 line.push_str(" \u{1F448}");
213 }
214 line
215}
216
217fn data_line(entries: &[NoteEntry]) -> String {
220 let data = Value::Array(
221 entries
222 .iter()
223 .map(|entry| {
224 json!({
225 "id": entry.id,
226 "url": entry.url,
227 "title": entry.title,
228 "state": entry.state,
229 })
230 })
231 .collect(),
232 );
233
234 let encoded = data.to_string().replace('>', "\\u003e");
237 format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
238}
239
240fn parse_ledger(section: &str) -> Vec<NoteEntry> {
244 for line in section.lines() {
245 if let Some(rest) = line.trim().strip_prefix(DATA_PREFIX)
246 && let Some(encoded) = rest.trim_end().strip_suffix(COMMENT_END)
247 && let Some(entries) = parse_data_json(encoded.trim())
248 {
249 return entries;
250 }
251 }
252
253 section.lines().filter_map(parse_entry_line).collect()
254}
255
256fn parse_data_json(encoded: &str) -> Option<Vec<NoteEntry>> {
257 let value: Value = serde_json::from_str(encoded).ok()?;
258 let mut entries = Vec::new();
259 for item in value.as_array()? {
260 entries.push(NoteEntry {
261 id: item.get("id")?.as_str()?.to_owned(),
262 url: item.get("url")?.as_str()?.to_owned(),
263 title: item
264 .get("title")
265 .and_then(Value::as_str)
266 .unwrap_or_default()
267 .to_owned(),
268 state: item
269 .get("state")
270 .and_then(Value::as_str)
271 .unwrap_or("open")
272 .to_owned(),
273 });
274 }
275 Some(entries)
276}
277
278fn parse_entry_line(line: &str) -> Option<NoteEntry> {
282 let rest = line.trim().strip_prefix("- ")?;
283 if rest.starts_with('`') {
284 return None;
285 }
286
287 let open = rest.find('[')?;
288 let split = rest[open..].find("](")? + open;
289 let close = rest[split + 2..].find(')')? + split + 2;
290 let label = &rest[open + 1..split];
291 let url = &rest[split + 2..close];
292 let tail = &rest[close + 1..];
293
294 let state = if tail.contains("(merged)") {
295 "merged"
296 } else if tail.contains("(closed)") {
297 "closed"
298 } else {
299 "open"
300 };
301
302 let (title, id) = match rest[open + 1..split].rfind(" (") {
304 Some(position) if label.ends_with(')') => {
305 let id = &label[position + 2..label.len() - 1];
306 if id.starts_with('#') || id.starts_with('!') {
307 (label[..position].to_owned(), id.to_owned())
308 } else {
309 (label.to_owned(), String::new())
310 }
311 }
312 _ if label.starts_with('#') || label.starts_with('!') => (String::new(), label.to_owned()),
313 _ => (label.to_owned(), String::new()),
314 };
315
316 Some(NoteEntry {
317 id,
318 url: url.to_owned(),
319 title,
320 state: state.to_owned(),
321 })
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn entry(id: &str, title: &str, url: &str, state: &str) -> NoteEntry {
329 NoteEntry {
330 id: id.to_owned(),
331 url: url.to_owned(),
332 title: title.to_owned(),
333 state: state.to_owned(),
334 }
335 }
336
337 #[test]
338 fn build_stack_note_lists_ledger_leaf_first_with_pointer_and_trunk() {
339 let entries = vec![
340 entry("#12", "Bottom change", "https://example.com/12", "open"),
341 entry("#13", "Top change", "https://example.com/13", "open"),
342 ];
343
344 let note = build_stack_note(&entries, 0, "main");
345 let lines: Vec<&str> = note.lines().collect();
346 assert!(
347 lines[0].starts_with(DATA_PREFIX),
348 "missing data line: {note}"
349 );
350 assert_eq!(
351 lines[1],
352 "- \u{1F7E2} [Top change (#13)](https://example.com/13)"
353 );
354 assert_eq!(
355 lines[2],
356 "- \u{1F7E2} [Bottom change (#12)](https://example.com/12) \u{1F448}"
357 );
358 assert_eq!(lines[3], "- `main`");
359 assert!(note.ends_with(
360 "Stack managed by \
361 <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
362 width=\"12\" height=\"12\" alt=\"\" /> \
363 [git-stk](https://github.com/lararosekelley/git-stk)"
364 ));
365 }
366
367 #[test]
368 fn build_stack_note_styles_merged_and_closed_entries() {
369 let entries = vec![
370 entry("#11", "Landed", "https://example.com/11", "merged"),
371 entry("#12", "Abandoned", "https://example.com/12", "closed"),
372 entry("#13", "Live", "https://example.com/13", "open"),
373 ];
374
375 let note = build_stack_note(&entries, 2, "main");
376 assert!(note.contains("- \u{1F7E2} [Live (#13)](https://example.com/13) \u{1F448}"));
377 assert!(
378 note.contains("- \u{1F534} ~~[Abandoned (#12)](https://example.com/12)~~ (closed)")
379 );
380 assert!(note.contains("- \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)"));
381 }
382
383 #[test]
384 fn build_stack_note_falls_back_to_id_without_title() {
385 let entries = vec![entry("#12", "", "https://example.com/12", "open")];
386 let note = build_stack_note(&entries, 0, "main");
387 assert!(note.contains("- \u{1F7E2} [#12](https://example.com/12) \u{1F448}"));
388 }
389
390 #[test]
391 fn parse_ledger_round_trips_the_data_line() {
392 let entries = vec![
393 entry("#11", "Landed", "https://example.com/11", "merged"),
394 entry("#13", "Top -> change", "https://example.com/13", "open"),
395 ];
396
397 let note = build_stack_note(&entries, 1, "main");
398 assert_eq!(parse_ledger(¬e), entries);
399 }
400
401 #[test]
402 fn data_line_survives_a_title_containing_a_comment_terminator() {
403 let entries = vec![entry(
404 "#12",
405 "weird --> title",
406 "https://example.com/12",
407 "open",
408 )];
409 let line = data_line(&entries);
410 assert!(!line[DATA_PREFIX.len()..line.len() - COMMENT_END.len()].contains("-->"));
411 assert_eq!(parse_ledger(&line), entries);
412 }
413
414 #[test]
415 fn parse_ledger_recovers_entries_from_bullets_when_data_line_is_gone() {
416 let entries = vec![
417 entry("#11", "Landed", "https://example.com/11", "merged"),
418 entry("#12", "", "https://example.com/12", "closed"),
419 entry("#13", "Live", "https://example.com/13", "open"),
420 ];
421
422 let note = build_stack_note(&entries, 2, "main");
423 let without_data: String = note
424 .lines()
425 .filter(|line| !line.trim().starts_with(DATA_PREFIX))
426 .collect::<Vec<_>>()
427 .join("\n");
428
429 let mut recovered = parse_ledger(&without_data);
432 recovered.reverse();
433 assert_eq!(recovered, entries);
434 }
435
436 #[test]
437 fn parse_ledger_falls_back_to_bullets_when_data_line_is_corrupt() {
438 let section = "<!-- git-stk:data [{\"id\": -->\n\
439 - \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)\n\
440 - `main`";
441 assert_eq!(
442 parse_ledger(section),
443 vec![entry("#11", "Landed", "https://example.com/11", "merged")]
444 );
445 }
446
447 #[test]
448 fn parse_ledger_reads_the_legacy_unstyled_format() {
449 let section = "- [Top change (#13)](https://example.com/13)\n\
450 - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
451 - `main`\n\n---\n\nfooter";
452 assert_eq!(
453 parse_ledger(section),
454 vec![
455 entry("#13", "Top change", "https://example.com/13", "open"),
456 entry("#12", "Bottom change", "https://example.com/12", "open"),
457 ]
458 );
459 }
460
461 #[test]
462 fn note_entry_round_trips_through_review() {
463 let landed = entry("#11", "Landed", "https://example.com/11", "merged");
464 let review = landed.to_review();
465 assert_eq!(review.state, ReviewState::Merged);
466 assert_eq!(NoteEntry::from_review(&review), landed);
467 }
468
469 #[test]
470 fn note_entry_matches_by_id_or_url() {
471 let by_id = entry("#11", "", "", "open");
472 let by_url = entry("", "", "https://example.com/11", "open");
473 assert!(by_id.matches(&entry("#11", "x", "y", "merged")));
474 assert!(by_url.matches(&entry("#12", "", "https://example.com/11", "open")));
475 assert!(!by_url.matches(&by_id));
476 }
477}