1use std::collections::BTreeMap;
6
7use anyhow::Result;
8use serde_json::{Value, json};
9
10use super::STACK_SECTION;
11use super::sections::{body_with_section, extract_section};
12use crate::providers::{ReviewProvider, ReviewRequest, ReviewState};
13
14const DATA_PREFIX: &str = "<!-- git-stk:data ";
15const COMMENT_END: &str = "-->";
16const TOOL_URL: &str = "https://github.com/lararosekelley/git-stk";
17const LOGO_URL: &str =
18 "https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg";
19
20#[derive(Debug, Clone, PartialEq, Eq)]
25struct NoteEntry {
26 id: String,
27 url: String,
28 title: String,
29 state: String,
30}
31
32impl NoteEntry {
33 fn from_review(review: &ReviewRequest) -> Self {
34 Self {
35 id: review.id.clone(),
36 url: review.url.clone(),
37 title: review.title.clone(),
38 state: review.state.to_string(),
39 }
40 }
41
42 fn to_review(&self) -> ReviewRequest {
45 let state = match self.state.as_str() {
46 "open" => ReviewState::Open,
47 "merged" => ReviewState::Merged,
48 "closed" => ReviewState::Closed,
49 other => ReviewState::Unknown(other.to_owned()),
50 };
51 ReviewRequest {
52 id: self.id.clone(),
53 branch: String::new(),
54 base: String::new(),
55 state,
56 url: self.url.clone(),
57 title: self.title.clone(),
58 draft: false,
59 }
60 }
61
62 fn matches(&self, other: &Self) -> bool {
65 (!self.id.is_empty() && self.id == other.id)
66 || (!self.url.is_empty() && self.url == other.url)
67 }
68}
69
70pub fn update_stack_notes(
76 review_provider: &dyn ReviewProvider,
77 branch_parents: &[(String, String)],
78 dry_run: bool,
79 rebuild: bool,
80) -> Result<()> {
81 let mut stacks: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
82 for (branch, parent) in branch_parents {
83 let key = crate::stack::line_base(branch).unwrap_or_else(|_| branch.clone());
87 stacks
88 .entry(key)
89 .or_default()
90 .push((branch.clone(), parent.clone()));
91 }
92 for stack in stacks.values() {
93 update_one_stack(review_provider, stack, dry_run, rebuild)?;
94 }
95 Ok(())
96}
97
98fn update_one_stack(
101 review_provider: &dyn ReviewProvider,
102 branch_parents: &[(String, String)],
103 dry_run: bool,
104 rebuild: bool,
105) -> Result<()> {
106 let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
108 return Ok(());
109 };
110
111 let mut live = Vec::new();
112 for (branch, _) in branch_parents {
113 match review_provider.review_for_branch_including_closed(branch)? {
117 Some(review) if review.branch == *branch => live.push(review),
118 _ => {
119 if !dry_run {
122 anstream::println!("skipped stack notes: no review found for {branch}");
123 }
124 return Ok(());
125 }
126 }
127 }
128
129 if dry_run && !rebuild {
132 for review in &live {
133 anstream::println!("would update stack note in {}", review.id);
134 }
135 return Ok(());
136 }
137
138 let mut bodies = Vec::new();
142 for review in &live {
143 bodies.push(review_provider.review_body(review)?);
144 }
145
146 let mut superseded: Vec<NoteEntry> = Vec::new();
149 for (branch, _) in branch_parents {
150 if let Some(old) = crate::stack::renamed_from(branch)?
151 && let Some(review) = review_provider.review_for_branch_including_closed(&old)?
152 {
153 superseded.push(NoteEntry::from_review(&review));
154 }
155 }
156
157 let live_entries: Vec<NoteEntry> = live.iter().map(NoteEntry::from_review).collect();
158 let mut historical: Vec<NoteEntry> = Vec::new();
159 let mut dropped: Vec<NoteEntry> = Vec::new();
160 for body in &bodies {
161 let Some(section) = extract_section(body, STACK_SECTION) else {
162 continue;
163 };
164 for entry in parse_ledger(section) {
165 if superseded.iter().any(|stale| stale.matches(&entry)) {
166 continue;
167 }
168 let known = live_entries
169 .iter()
170 .chain(historical.iter())
171 .chain(dropped.iter());
172 if known.into_iter().any(|seen| seen.matches(&entry)) {
173 continue;
174 }
175 if rebuild && entry.state != "merged" {
178 dropped.push(entry);
179 } else {
180 historical.push(entry);
181 }
182 }
183 }
184
185 if dry_run {
186 for review in &live {
187 anstream::println!("would update stack note in {}", review.id);
188 }
189 for entry in &dropped {
190 anstream::println!(
191 "would drop drifted entry {} ({})",
192 if entry.id.is_empty() { "?" } else { &entry.id },
193 entry.state
194 );
195 }
196 return Ok(());
197 }
198
199 for entry in &mut historical {
205 if entry.state != "open" || entry.id.is_empty() {
206 continue;
207 }
208 if let Ok(Some(state)) = review_provider.review_state(&entry.to_review()) {
209 entry.state = state.to_string();
210 }
211 }
212
213 let mut entries = historical.clone();
216 entries.extend(live_entries);
217
218 for (offset, review) in live.iter().enumerate() {
219 let note = build_stack_note(&entries, historical.len() + offset, &trunk);
220 let updated = body_with_section(&bodies[offset], STACK_SECTION, ¬e);
221 if updated == bodies[offset] {
222 continue;
223 }
224
225 review_provider.update_review_body(review, &updated)?;
226 anstream::println!("updated stack note in {}", review.id);
227 }
228
229 for (index, entry) in historical.iter().enumerate() {
233 if entry.id.is_empty() {
234 continue;
235 }
236 let review = entry.to_review();
237 let Ok(body) = review_provider.review_body(&review) else {
238 anstream::println!("skipped stack note in {}: could not read body", review.id);
239 continue;
240 };
241
242 let note = build_stack_note(&entries, index, &trunk);
243 let updated = body_with_section(&body, STACK_SECTION, ¬e);
244 if updated == body {
245 continue;
246 }
247
248 if review_provider
249 .update_review_body(&review, &updated)
250 .is_err()
251 {
252 anstream::println!("skipped stack note in {}: could not update body", review.id);
253 continue;
254 }
255 anstream::println!("updated stack note in {}", review.id);
256 }
257
258 Ok(())
259}
260
261fn build_stack_note(entries: &[NoteEntry], current: usize, trunk: &str) -> String {
266 let mut lines = vec![data_line(entries)];
267 for (index, entry) in entries.iter().enumerate().rev() {
268 lines.push(render_entry(entry, index == current));
269 }
270 lines.push(format!("- `{trunk}`"));
271
272 format!(
273 "{}\n\n---\n\nStack managed by \
274 <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
275 [git-stk]({TOOL_URL})",
276 lines.join("\n")
277 )
278}
279
280fn render_entry(entry: &NoteEntry, current: bool) -> String {
283 let label = crate::providers::label(&entry.title, &entry.id);
284 let link = format!("[{label}]({})", entry.url);
285
286 let mut line = match entry.state.as_str() {
287 "merged" => format!("- \u{1F7E3} ~~{link}~~ (merged)"),
288 "closed" => format!("- \u{1F534} ~~{link}~~ (closed)"),
289 _ => format!("- \u{1F7E2} {link}"),
290 };
291 if current {
292 line.push_str(" \u{1F448}");
293 }
294 line
295}
296
297fn data_line(entries: &[NoteEntry]) -> String {
300 let data = Value::Array(
301 entries
302 .iter()
303 .map(|entry| {
304 json!({
305 "id": entry.id,
306 "url": entry.url,
307 "title": entry.title,
308 "state": entry.state,
309 })
310 })
311 .collect(),
312 );
313
314 let encoded = data.to_string().replace('>', "\\u003e");
317 format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
318}
319
320fn parse_ledger(section: &str) -> Vec<NoteEntry> {
324 for line in section.lines() {
325 if let Some(rest) = line.trim().strip_prefix(DATA_PREFIX)
326 && let Some(encoded) = rest.trim_end().strip_suffix(COMMENT_END)
327 && let Some(entries) = parse_data_json(encoded.trim())
328 {
329 return entries;
330 }
331 }
332
333 section.lines().filter_map(parse_entry_line).collect()
334}
335
336fn parse_data_json(encoded: &str) -> Option<Vec<NoteEntry>> {
337 let value: Value = serde_json::from_str(encoded).ok()?;
338 let mut entries = Vec::new();
339 for item in value.as_array()? {
340 entries.push(NoteEntry {
341 id: item.get("id")?.as_str()?.to_owned(),
342 url: item.get("url")?.as_str()?.to_owned(),
343 title: item
344 .get("title")
345 .and_then(Value::as_str)
346 .unwrap_or_default()
347 .to_owned(),
348 state: item
349 .get("state")
350 .and_then(Value::as_str)
351 .unwrap_or("open")
352 .to_owned(),
353 });
354 }
355 Some(entries)
356}
357
358fn parse_entry_line(line: &str) -> Option<NoteEntry> {
362 let rest = line.trim().strip_prefix("- ")?;
363 if rest.starts_with('`') {
364 return None;
365 }
366
367 let open = rest.find('[')?;
368 let split = rest[open..].find("](")? + open;
369 let close = rest[split + 2..].find(')')? + split + 2;
370 let label = &rest[open + 1..split];
371 let url = &rest[split + 2..close];
372 let tail = &rest[close + 1..];
373
374 let state = if tail.contains("(merged)") {
375 "merged"
376 } else if tail.contains("(closed)") {
377 "closed"
378 } else {
379 "open"
380 };
381
382 let (title, id) = match rest[open + 1..split].rfind(" (") {
384 Some(position) if label.ends_with(')') => {
385 let id = &label[position + 2..label.len() - 1];
386 if id.starts_with('#') || id.starts_with('!') {
387 (label[..position].to_owned(), id.to_owned())
388 } else {
389 (label.to_owned(), String::new())
390 }
391 }
392 _ if label.starts_with('#') || label.starts_with('!') => (String::new(), label.to_owned()),
393 _ => (label.to_owned(), String::new()),
394 };
395
396 Some(NoteEntry {
397 id,
398 url: url.to_owned(),
399 title,
400 state: state.to_owned(),
401 })
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 fn entry(id: &str, title: &str, url: &str, state: &str) -> NoteEntry {
409 NoteEntry {
410 id: id.to_owned(),
411 url: url.to_owned(),
412 title: title.to_owned(),
413 state: state.to_owned(),
414 }
415 }
416
417 #[test]
418 fn build_stack_note_lists_ledger_leaf_first_with_pointer_and_trunk() {
419 let entries = vec![
420 entry("#12", "Bottom change", "https://example.com/12", "open"),
421 entry("#13", "Top change", "https://example.com/13", "open"),
422 ];
423
424 let note = build_stack_note(&entries, 0, "main");
425 let lines: Vec<&str> = note.lines().collect();
426 assert!(
427 lines[0].starts_with(DATA_PREFIX),
428 "missing data line: {note}"
429 );
430 assert_eq!(
431 lines[1],
432 "- \u{1F7E2} [Top change (#13)](https://example.com/13)"
433 );
434 assert_eq!(
435 lines[2],
436 "- \u{1F7E2} [Bottom change (#12)](https://example.com/12) \u{1F448}"
437 );
438 assert_eq!(lines[3], "- `main`");
439 assert!(note.ends_with(
440 "Stack managed by \
441 <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
442 width=\"12\" height=\"12\" alt=\"\" /> \
443 [git-stk](https://github.com/lararosekelley/git-stk)"
444 ));
445 }
446
447 #[test]
448 fn build_stack_note_styles_merged_and_closed_entries() {
449 let entries = vec![
450 entry("#11", "Landed", "https://example.com/11", "merged"),
451 entry("#12", "Abandoned", "https://example.com/12", "closed"),
452 entry("#13", "Live", "https://example.com/13", "open"),
453 ];
454
455 let note = build_stack_note(&entries, 2, "main");
456 assert!(note.contains("- \u{1F7E2} [Live (#13)](https://example.com/13) \u{1F448}"));
457 assert!(
458 note.contains("- \u{1F534} ~~[Abandoned (#12)](https://example.com/12)~~ (closed)")
459 );
460 assert!(note.contains("- \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)"));
461 }
462
463 #[test]
464 fn build_stack_note_falls_back_to_id_without_title() {
465 let entries = vec![entry("#12", "", "https://example.com/12", "open")];
466 let note = build_stack_note(&entries, 0, "main");
467 assert!(note.contains("- \u{1F7E2} [#12](https://example.com/12) \u{1F448}"));
468 }
469
470 #[test]
471 fn parse_ledger_round_trips_the_data_line() {
472 let entries = vec![
473 entry("#11", "Landed", "https://example.com/11", "merged"),
474 entry("#13", "Top -> change", "https://example.com/13", "open"),
475 ];
476
477 let note = build_stack_note(&entries, 1, "main");
478 assert_eq!(parse_ledger(¬e), entries);
479 }
480
481 #[test]
482 fn data_line_survives_a_title_containing_a_comment_terminator() {
483 let entries = vec![entry(
484 "#12",
485 "weird --> title",
486 "https://example.com/12",
487 "open",
488 )];
489 let line = data_line(&entries);
490 assert!(!line[DATA_PREFIX.len()..line.len() - COMMENT_END.len()].contains("-->"));
491 assert_eq!(parse_ledger(&line), entries);
492 }
493
494 #[test]
495 fn parse_ledger_recovers_entries_from_bullets_when_data_line_is_gone() {
496 let entries = vec![
497 entry("#11", "Landed", "https://example.com/11", "merged"),
498 entry("#12", "", "https://example.com/12", "closed"),
499 entry("#13", "Live", "https://example.com/13", "open"),
500 ];
501
502 let note = build_stack_note(&entries, 2, "main");
503 let without_data: String = note
504 .lines()
505 .filter(|line| !line.trim().starts_with(DATA_PREFIX))
506 .collect::<Vec<_>>()
507 .join("\n");
508
509 let mut recovered = parse_ledger(&without_data);
512 recovered.reverse();
513 assert_eq!(recovered, entries);
514 }
515
516 #[test]
517 fn parse_ledger_falls_back_to_bullets_when_data_line_is_corrupt() {
518 let section = "<!-- git-stk:data [{\"id\": -->\n\
519 - \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)\n\
520 - `main`";
521 assert_eq!(
522 parse_ledger(section),
523 vec![entry("#11", "Landed", "https://example.com/11", "merged")]
524 );
525 }
526
527 #[test]
528 fn parse_ledger_reads_the_legacy_unstyled_format() {
529 let section = "- [Top change (#13)](https://example.com/13)\n\
530 - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
531 - `main`\n\n---\n\nfooter";
532 assert_eq!(
533 parse_ledger(section),
534 vec![
535 entry("#13", "Top change", "https://example.com/13", "open"),
536 entry("#12", "Bottom change", "https://example.com/12", "open"),
537 ]
538 );
539 }
540
541 #[test]
542 fn note_entry_round_trips_through_review() {
543 let landed = entry("#11", "Landed", "https://example.com/11", "merged");
544 let review = landed.to_review();
545 assert_eq!(review.state, ReviewState::Merged);
546 assert_eq!(NoteEntry::from_review(&review), landed);
547 }
548
549 #[test]
550 fn note_entry_matches_by_id_or_url() {
551 let by_id = entry("#11", "", "", "open");
552 let by_url = entry("", "", "https://example.com/11", "open");
553 assert!(by_id.matches(&entry("#11", "x", "y", "merged")));
554 assert!(by_url.matches(&entry("#12", "", "https://example.com/11", "open")));
555 assert!(!by_url.matches(&by_id));
556 }
557}