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 let mut entries = historical.clone();
202 entries.extend(live_entries);
203
204 for (offset, review) in live.iter().enumerate() {
205 let note = build_stack_note(&entries, historical.len() + offset, &trunk);
206 let updated = body_with_section(&bodies[offset], STACK_SECTION, ¬e);
207 if updated == bodies[offset] {
208 continue;
209 }
210
211 review_provider.update_review_body(review, &updated)?;
212 anstream::println!("updated stack note in {}", review.id);
213 }
214
215 for (index, entry) in historical.iter().enumerate() {
219 if entry.id.is_empty() {
220 continue;
221 }
222 let review = entry.to_review();
223 let Ok(body) = review_provider.review_body(&review) else {
224 anstream::println!("skipped stack note in {}: could not read body", review.id);
225 continue;
226 };
227
228 let note = build_stack_note(&entries, index, &trunk);
229 let updated = body_with_section(&body, STACK_SECTION, ¬e);
230 if updated == body {
231 continue;
232 }
233
234 if review_provider
235 .update_review_body(&review, &updated)
236 .is_err()
237 {
238 anstream::println!("skipped stack note in {}: could not update body", review.id);
239 continue;
240 }
241 anstream::println!("updated stack note in {}", review.id);
242 }
243
244 Ok(())
245}
246
247fn build_stack_note(entries: &[NoteEntry], current: usize, trunk: &str) -> String {
252 let mut lines = vec![data_line(entries)];
253 for (index, entry) in entries.iter().enumerate().rev() {
254 lines.push(render_entry(entry, index == current));
255 }
256 lines.push(format!("- `{trunk}`"));
257
258 format!(
259 "{}\n\n---\n\nStack managed by \
260 <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
261 [git-stk]({TOOL_URL})",
262 lines.join("\n")
263 )
264}
265
266fn render_entry(entry: &NoteEntry, current: bool) -> String {
269 let label = crate::providers::label(&entry.title, &entry.id);
270 let link = format!("[{label}]({})", entry.url);
271
272 let mut line = match entry.state.as_str() {
273 "merged" => format!("- \u{1F7E3} ~~{link}~~ (merged)"),
274 "closed" => format!("- \u{1F534} ~~{link}~~ (closed)"),
275 _ => format!("- \u{1F7E2} {link}"),
276 };
277 if current {
278 line.push_str(" \u{1F448}");
279 }
280 line
281}
282
283fn data_line(entries: &[NoteEntry]) -> String {
286 let data = Value::Array(
287 entries
288 .iter()
289 .map(|entry| {
290 json!({
291 "id": entry.id,
292 "url": entry.url,
293 "title": entry.title,
294 "state": entry.state,
295 })
296 })
297 .collect(),
298 );
299
300 let encoded = data.to_string().replace('>', "\\u003e");
303 format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
304}
305
306fn parse_ledger(section: &str) -> Vec<NoteEntry> {
310 for line in section.lines() {
311 if let Some(rest) = line.trim().strip_prefix(DATA_PREFIX)
312 && let Some(encoded) = rest.trim_end().strip_suffix(COMMENT_END)
313 && let Some(entries) = parse_data_json(encoded.trim())
314 {
315 return entries;
316 }
317 }
318
319 section.lines().filter_map(parse_entry_line).collect()
320}
321
322fn parse_data_json(encoded: &str) -> Option<Vec<NoteEntry>> {
323 let value: Value = serde_json::from_str(encoded).ok()?;
324 let mut entries = Vec::new();
325 for item in value.as_array()? {
326 entries.push(NoteEntry {
327 id: item.get("id")?.as_str()?.to_owned(),
328 url: item.get("url")?.as_str()?.to_owned(),
329 title: item
330 .get("title")
331 .and_then(Value::as_str)
332 .unwrap_or_default()
333 .to_owned(),
334 state: item
335 .get("state")
336 .and_then(Value::as_str)
337 .unwrap_or("open")
338 .to_owned(),
339 });
340 }
341 Some(entries)
342}
343
344fn parse_entry_line(line: &str) -> Option<NoteEntry> {
348 let rest = line.trim().strip_prefix("- ")?;
349 if rest.starts_with('`') {
350 return None;
351 }
352
353 let open = rest.find('[')?;
354 let split = rest[open..].find("](")? + open;
355 let close = rest[split + 2..].find(')')? + split + 2;
356 let label = &rest[open + 1..split];
357 let url = &rest[split + 2..close];
358 let tail = &rest[close + 1..];
359
360 let state = if tail.contains("(merged)") {
361 "merged"
362 } else if tail.contains("(closed)") {
363 "closed"
364 } else {
365 "open"
366 };
367
368 let (title, id) = match rest[open + 1..split].rfind(" (") {
370 Some(position) if label.ends_with(')') => {
371 let id = &label[position + 2..label.len() - 1];
372 if id.starts_with('#') || id.starts_with('!') {
373 (label[..position].to_owned(), id.to_owned())
374 } else {
375 (label.to_owned(), String::new())
376 }
377 }
378 _ if label.starts_with('#') || label.starts_with('!') => (String::new(), label.to_owned()),
379 _ => (label.to_owned(), String::new()),
380 };
381
382 Some(NoteEntry {
383 id,
384 url: url.to_owned(),
385 title,
386 state: state.to_owned(),
387 })
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn entry(id: &str, title: &str, url: &str, state: &str) -> NoteEntry {
395 NoteEntry {
396 id: id.to_owned(),
397 url: url.to_owned(),
398 title: title.to_owned(),
399 state: state.to_owned(),
400 }
401 }
402
403 #[test]
404 fn build_stack_note_lists_ledger_leaf_first_with_pointer_and_trunk() {
405 let entries = vec![
406 entry("#12", "Bottom change", "https://example.com/12", "open"),
407 entry("#13", "Top change", "https://example.com/13", "open"),
408 ];
409
410 let note = build_stack_note(&entries, 0, "main");
411 let lines: Vec<&str> = note.lines().collect();
412 assert!(
413 lines[0].starts_with(DATA_PREFIX),
414 "missing data line: {note}"
415 );
416 assert_eq!(
417 lines[1],
418 "- \u{1F7E2} [Top change (#13)](https://example.com/13)"
419 );
420 assert_eq!(
421 lines[2],
422 "- \u{1F7E2} [Bottom change (#12)](https://example.com/12) \u{1F448}"
423 );
424 assert_eq!(lines[3], "- `main`");
425 assert!(note.ends_with(
426 "Stack managed by \
427 <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
428 width=\"12\" height=\"12\" alt=\"\" /> \
429 [git-stk](https://github.com/lararosekelley/git-stk)"
430 ));
431 }
432
433 #[test]
434 fn build_stack_note_styles_merged_and_closed_entries() {
435 let entries = vec![
436 entry("#11", "Landed", "https://example.com/11", "merged"),
437 entry("#12", "Abandoned", "https://example.com/12", "closed"),
438 entry("#13", "Live", "https://example.com/13", "open"),
439 ];
440
441 let note = build_stack_note(&entries, 2, "main");
442 assert!(note.contains("- \u{1F7E2} [Live (#13)](https://example.com/13) \u{1F448}"));
443 assert!(
444 note.contains("- \u{1F534} ~~[Abandoned (#12)](https://example.com/12)~~ (closed)")
445 );
446 assert!(note.contains("- \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)"));
447 }
448
449 #[test]
450 fn build_stack_note_falls_back_to_id_without_title() {
451 let entries = vec![entry("#12", "", "https://example.com/12", "open")];
452 let note = build_stack_note(&entries, 0, "main");
453 assert!(note.contains("- \u{1F7E2} [#12](https://example.com/12) \u{1F448}"));
454 }
455
456 #[test]
457 fn parse_ledger_round_trips_the_data_line() {
458 let entries = vec![
459 entry("#11", "Landed", "https://example.com/11", "merged"),
460 entry("#13", "Top -> change", "https://example.com/13", "open"),
461 ];
462
463 let note = build_stack_note(&entries, 1, "main");
464 assert_eq!(parse_ledger(¬e), entries);
465 }
466
467 #[test]
468 fn data_line_survives_a_title_containing_a_comment_terminator() {
469 let entries = vec![entry(
470 "#12",
471 "weird --> title",
472 "https://example.com/12",
473 "open",
474 )];
475 let line = data_line(&entries);
476 assert!(!line[DATA_PREFIX.len()..line.len() - COMMENT_END.len()].contains("-->"));
477 assert_eq!(parse_ledger(&line), entries);
478 }
479
480 #[test]
481 fn parse_ledger_recovers_entries_from_bullets_when_data_line_is_gone() {
482 let entries = vec![
483 entry("#11", "Landed", "https://example.com/11", "merged"),
484 entry("#12", "", "https://example.com/12", "closed"),
485 entry("#13", "Live", "https://example.com/13", "open"),
486 ];
487
488 let note = build_stack_note(&entries, 2, "main");
489 let without_data: String = note
490 .lines()
491 .filter(|line| !line.trim().starts_with(DATA_PREFIX))
492 .collect::<Vec<_>>()
493 .join("\n");
494
495 let mut recovered = parse_ledger(&without_data);
498 recovered.reverse();
499 assert_eq!(recovered, entries);
500 }
501
502 #[test]
503 fn parse_ledger_falls_back_to_bullets_when_data_line_is_corrupt() {
504 let section = "<!-- git-stk:data [{\"id\": -->\n\
505 - \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)\n\
506 - `main`";
507 assert_eq!(
508 parse_ledger(section),
509 vec![entry("#11", "Landed", "https://example.com/11", "merged")]
510 );
511 }
512
513 #[test]
514 fn parse_ledger_reads_the_legacy_unstyled_format() {
515 let section = "- [Top change (#13)](https://example.com/13)\n\
516 - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
517 - `main`\n\n---\n\nfooter";
518 assert_eq!(
519 parse_ledger(section),
520 vec![
521 entry("#13", "Top change", "https://example.com/13", "open"),
522 entry("#12", "Bottom change", "https://example.com/12", "open"),
523 ]
524 );
525 }
526
527 #[test]
528 fn note_entry_round_trips_through_review() {
529 let landed = entry("#11", "Landed", "https://example.com/11", "merged");
530 let review = landed.to_review();
531 assert_eq!(review.state, ReviewState::Merged);
532 assert_eq!(NoteEntry::from_review(&review), landed);
533 }
534
535 #[test]
536 fn note_entry_matches_by_id_or_url() {
537 let by_id = entry("#11", "", "", "open");
538 let by_url = entry("", "", "https://example.com/11", "open");
539 assert!(by_id.matches(&entry("#11", "x", "y", "merged")));
540 assert!(by_url.matches(&entry("#12", "", "https://example.com/11", "open")));
541 assert!(!by_url.matches(&by_id));
542 }
543}