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    // Bottom-first, like the stack itself: already-landed history below,
200    // the live stack on top of it.
201    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, &note);
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    // Historical reviews get the refreshed ledger too, so a just-merged
216    // review stops presenting the stack as it was. Failures are non-fatal:
217    // an old review may have become unreachable.
218    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, &note);
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
247/// Render the overview for one review: a hidden data line carrying the
248/// ledger, every entry leaf-first as a status-styled bullet, a pointer on
249/// the review being viewed, the trunk in backticks at the bottom, and a
250/// footer crediting the tool.
251fn 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
266/// A status emoji as the bullet, strikethrough plus a suffix for entries
267/// that have left the stack, and the pointer on the current review.
268fn 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
283/// One hidden machine-readable line so the ledger survives restyling: the
284/// rendered bullets are presentation, this is the data.
285fn 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    // '>' only ever appears inside JSON strings, so escaping it globally
301    // keeps a title containing "-->" from terminating the comment early.
302    let encoded = data.to_string().replace('>', "\\u003e");
303    format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
304}
305
306/// Read the ledger out of a stack section: the embedded data line when it
307/// is intact, otherwise whatever the rendered bullets still reveal (the
308/// hidden line may have been edited or deleted along with everything else).
309fn 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
344/// Best-effort recovery of one rendered bullet: `[label](url)` plus the
345/// state suffix. The trunk line (backticks, no link) and the footer fall
346/// through to None.
347fn 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    // "Title (#12)" carries both; a bare "#12" label is just the id.
369    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(&note), 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        // Bullets render leaf-first, so recovery reverses back to
496        // bottom-first ledger order.
497        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}