Skip to main content

git_stk/notes/
ledger.rs

1//! The stack-overview ledger kept in every review body: build, parse, and
2//! refresh the marker-delimited overview whose merged and closed entries
3//! outlive their local branches.
4
5use 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/// One row of the stack-overview ledger. Live rows come from the provider;
21/// merged and closed rows outlive their local branches and are carried
22/// forward from the previous note, so the ledger is append-only history
23/// rather than a snapshot of the live stack.
24#[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    /// A review the ledger only knows by its row: enough identity to fetch
43    /// and update the body, nothing more.
44    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    /// Rows recovered from a hand-edited note may be missing the id, so the
63    /// URL doubles as identity.
64    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
70/// Maintain a stack overview in every review body, one independent stack at a
71/// time: a PR's overview must list only its own stack, never sibling stacks
72/// that merely share the trunk. `branch_parents` can span several such stacks
73/// (e.g. a `sync` run from the trunk), so group by each branch's child-of-trunk
74/// root and refresh each group's notes on its own.
75pub 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        // The line base (the branch's child-of-trunk ancestor) identifies the
84        // independent stack it belongs to; a broken lookup keeps the branch on
85        // its own rather than folding it into another stack.
86        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
98/// Refresh the overview across one independent stack. `branch_parents` here is
99/// a single stack, bottom-first.
100fn update_one_stack(
101    review_provider: &dyn ReviewProvider,
102    branch_parents: &[(String, String)],
103    dry_run: bool,
104    rebuild: bool,
105) -> Result<()> {
106    // The bottom branch's parent is the base the whole stack sits on.
107    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        // The closed-inclusive lookup is deliberate: a review closed on the
114        // platform should show up red in the ledger, even though every flow
115        // that acts on a review treats it as gone.
116        match review_provider.review_for_branch_including_closed(branch)? {
117            Some(review) if review.branch == *branch => live.push(review),
118            _ => {
119                // Without every review the overview would be wrong for all of
120                // them (dry runs never created the missing ones).
121                if !dry_run {
122                    anstream::println!("skipped stack notes: no review found for {branch}");
123                }
124                return Ok(());
125            }
126        }
127    }
128
129    // A normal dry run stays cheap (no body fetches); --rebuild-overview reads
130    // the bodies so it can report which drifted rows it would drop.
131    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    // Fetch every live body up front: each carries its own copy of the
139    // ledger, and the union keeps history alive even in bodies that have
140    // never seen it (e.g. a review created after earlier entries merged).
141    let mut bodies = Vec::new();
142    for review in &live {
143        bodies.push(review_provider.review_body(review)?);
144    }
145
146    // Reviews left behind by a branch rename: a fresh replacement is among the
147    // live entries, so the stale row is dropped rather than carried forward.
148    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            // --rebuild-overview keeps only genuinely landed history; closed or
176            // orphaned rows that drifted in are dropped rather than carried on.
177            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    // Refresh carried-forward rows still recorded as open: their branch is gone
200    // from the local stack, so nothing else re-queries them, and one may have
201    // merged or closed since it was last written. Terminal (merged/closed) rows
202    // never change, so leave them be. Best-effort: a lookup that fails keeps the
203    // recorded state rather than dropping the row.
204    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    // Bottom-first, like the stack itself: already-landed history below,
214    // the live stack on top of it.
215    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, &note);
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    // Historical reviews get the refreshed ledger too, so a just-merged
230    // review stops presenting the stack as it was. Failures are non-fatal:
231    // an old review may have become unreachable.
232    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, &note);
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
261/// Render the overview for one review: a hidden data line carrying the
262/// ledger, every entry leaf-first as a status-styled bullet, a pointer on
263/// the review being viewed, the trunk in backticks at the bottom, and a
264/// footer crediting the tool.
265fn 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
280/// A status emoji as the bullet, strikethrough plus a suffix for entries
281/// that have left the stack, and the pointer on the current review.
282fn 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
297/// One hidden machine-readable line so the ledger survives restyling: the
298/// rendered bullets are presentation, this is the data.
299fn 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    // '>' only ever appears inside JSON strings, so escaping it globally
315    // keeps a title containing "-->" from terminating the comment early.
316    let encoded = data.to_string().replace('>', "\\u003e");
317    format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
318}
319
320/// Read the ledger out of a stack section: the embedded data line when it
321/// is intact, otherwise whatever the rendered bullets still reveal (the
322/// hidden line may have been edited or deleted along with everything else).
323fn 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
358/// Best-effort recovery of one rendered bullet: `[label](url)` plus the
359/// state suffix. The trunk line (backticks, no link) and the footer fall
360/// through to None.
361fn 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    // "Title (#12)" carries both; a bare "#12" label is just the id.
383    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(&note), 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        // Bullets render leaf-first, so recovery reverses back to
510        // bottom-first ledger order.
511        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}