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 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/// One row of the stack-overview ledger. Live rows come from the provider;
19/// merged and closed rows outlive their local branches and are carried
20/// forward from the previous note, so the ledger is append-only history
21/// rather than a snapshot of the live stack.
22#[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    /// A review the ledger only knows by its row: enough identity to fetch
41    /// and update the body, nothing more.
42    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            draft: false,
57        }
58    }
59
60    /// Rows recovered from a hand-edited note may be missing the id, so the
61    /// URL doubles as identity.
62    fn matches(&self, other: &Self) -> bool {
63        (!self.id.is_empty() && self.id == other.id)
64            || (!self.url.is_empty() && self.url == other.url)
65    }
66}
67
68/// Maintain a stack overview in every review body: the full ledger
69/// leaf-first, the trunk at the bottom, and a pointing emoji marking the
70/// review being viewed. Lives between marker comments so refreshes replace
71/// it in place, and self-repairs if the markers were hand-edited away.
72/// Merged and closed entries are preserved from the previous note and
73/// restyled instead of dropped.
74pub fn update_stack_notes(
75    review_provider: &dyn ReviewProvider,
76    branch_parents: &[(String, String)],
77    dry_run: bool,
78    rebuild: bool,
79) -> Result<()> {
80    // The bottom branch's parent is the base the whole stack sits on.
81    let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
82        return Ok(());
83    };
84
85    let mut live = Vec::new();
86    for (branch, _) in branch_parents {
87        // The closed-inclusive lookup is deliberate: a review closed on the
88        // platform should show up red in the ledger, even though every flow
89        // that acts on a review treats it as gone.
90        match review_provider.review_for_branch_including_closed(branch)? {
91            Some(review) if review.branch == *branch => live.push(review),
92            _ => {
93                // Without every review the overview would be wrong for all of
94                // them (dry runs never created the missing ones).
95                if !dry_run {
96                    println!("skipped stack notes: no review found for {branch}");
97                }
98                return Ok(());
99            }
100        }
101    }
102
103    // A normal dry run stays cheap (no body fetches); --rebuild-overview reads
104    // the bodies so it can report which drifted rows it would drop.
105    if dry_run && !rebuild {
106        for review in &live {
107            println!("would update stack note in {}", review.id);
108        }
109        return Ok(());
110    }
111
112    // Fetch every live body up front: each carries its own copy of the
113    // ledger, and the union keeps history alive even in bodies that have
114    // never seen it (e.g. a review created after earlier entries merged).
115    let mut bodies = Vec::new();
116    for review in &live {
117        bodies.push(review_provider.review_body(review)?);
118    }
119
120    // Reviews left behind by a branch rename: a fresh replacement is among the
121    // live entries, so the stale row is dropped rather than carried forward.
122    let mut superseded: Vec<NoteEntry> = Vec::new();
123    for (branch, _) in branch_parents {
124        if let Some(old) = crate::stack::renamed_from(branch)?
125            && let Some(review) = review_provider.review_for_branch_including_closed(&old)?
126        {
127            superseded.push(NoteEntry::from_review(&review));
128        }
129    }
130
131    let live_entries: Vec<NoteEntry> = live.iter().map(NoteEntry::from_review).collect();
132    let mut historical: Vec<NoteEntry> = Vec::new();
133    let mut dropped: Vec<NoteEntry> = Vec::new();
134    for body in &bodies {
135        let Some(section) = extract_section(body, STACK_SECTION) else {
136            continue;
137        };
138        for entry in parse_ledger(section) {
139            if superseded.iter().any(|stale| stale.matches(&entry)) {
140                continue;
141            }
142            let known = live_entries
143                .iter()
144                .chain(historical.iter())
145                .chain(dropped.iter());
146            if known.into_iter().any(|seen| seen.matches(&entry)) {
147                continue;
148            }
149            // --rebuild-overview keeps only genuinely landed history; closed or
150            // orphaned rows that drifted in are dropped rather than carried on.
151            if rebuild && entry.state != "merged" {
152                dropped.push(entry);
153            } else {
154                historical.push(entry);
155            }
156        }
157    }
158
159    if dry_run {
160        for review in &live {
161            println!("would update stack note in {}", review.id);
162        }
163        for entry in &dropped {
164            println!(
165                "would drop drifted entry {} ({})",
166                if entry.id.is_empty() { "?" } else { &entry.id },
167                entry.state
168            );
169        }
170        return Ok(());
171    }
172
173    // Bottom-first, like the stack itself: already-landed history below,
174    // the live stack on top of it.
175    let mut entries = historical.clone();
176    entries.extend(live_entries);
177
178    for (offset, review) in live.iter().enumerate() {
179        let note = build_stack_note(&entries, historical.len() + offset, &trunk);
180        let updated = body_with_section(&bodies[offset], STACK_SECTION, &note);
181        if updated == bodies[offset] {
182            continue;
183        }
184
185        review_provider.update_review_body(review, &updated)?;
186        println!("updated stack note in {}", review.id);
187    }
188
189    // Historical reviews get the refreshed ledger too, so a just-merged
190    // review stops presenting the stack as it was. Failures are non-fatal:
191    // an old review may have become unreachable.
192    for (index, entry) in historical.iter().enumerate() {
193        if entry.id.is_empty() {
194            continue;
195        }
196        let review = entry.to_review();
197        let Ok(body) = review_provider.review_body(&review) else {
198            println!("skipped stack note in {}: could not read body", review.id);
199            continue;
200        };
201
202        let note = build_stack_note(&entries, index, &trunk);
203        let updated = body_with_section(&body, STACK_SECTION, &note);
204        if updated == body {
205            continue;
206        }
207
208        if review_provider
209            .update_review_body(&review, &updated)
210            .is_err()
211        {
212            println!("skipped stack note in {}: could not update body", review.id);
213            continue;
214        }
215        println!("updated stack note in {}", review.id);
216    }
217
218    Ok(())
219}
220
221/// Render the overview for one review: a hidden data line carrying the
222/// ledger, every entry leaf-first as a status-styled bullet, a pointer on
223/// the review being viewed, the trunk in backticks at the bottom, and a
224/// footer crediting the tool.
225fn build_stack_note(entries: &[NoteEntry], current: usize, trunk: &str) -> String {
226    let mut lines = vec![data_line(entries)];
227    for (index, entry) in entries.iter().enumerate().rev() {
228        lines.push(render_entry(entry, index == current));
229    }
230    lines.push(format!("- `{trunk}`"));
231
232    format!(
233        "{}\n\n---\n\nStack managed by \
234         <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
235         [git-stk]({TOOL_URL})",
236        lines.join("\n")
237    )
238}
239
240/// A status emoji as the bullet, strikethrough plus a suffix for entries
241/// that have left the stack, and the pointer on the current review.
242fn render_entry(entry: &NoteEntry, current: bool) -> String {
243    let label = crate::providers::label(&entry.title, &entry.id);
244    let link = format!("[{label}]({})", entry.url);
245
246    let mut line = match entry.state.as_str() {
247        "merged" => format!("- \u{1F7E3} ~~{link}~~ (merged)"),
248        "closed" => format!("- \u{1F534} ~~{link}~~ (closed)"),
249        _ => format!("- \u{1F7E2} {link}"),
250    };
251    if current {
252        line.push_str(" \u{1F448}");
253    }
254    line
255}
256
257/// One hidden machine-readable line so the ledger survives restyling: the
258/// rendered bullets are presentation, this is the data.
259fn data_line(entries: &[NoteEntry]) -> String {
260    let data = Value::Array(
261        entries
262            .iter()
263            .map(|entry| {
264                json!({
265                    "id": entry.id,
266                    "url": entry.url,
267                    "title": entry.title,
268                    "state": entry.state,
269                })
270            })
271            .collect(),
272    );
273
274    // '>' only ever appears inside JSON strings, so escaping it globally
275    // keeps a title containing "-->" from terminating the comment early.
276    let encoded = data.to_string().replace('>', "\\u003e");
277    format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
278}
279
280/// Read the ledger out of a stack section: the embedded data line when it
281/// is intact, otherwise whatever the rendered bullets still reveal (the
282/// hidden line may have been edited or deleted along with everything else).
283fn parse_ledger(section: &str) -> Vec<NoteEntry> {
284    for line in section.lines() {
285        if let Some(rest) = line.trim().strip_prefix(DATA_PREFIX)
286            && let Some(encoded) = rest.trim_end().strip_suffix(COMMENT_END)
287            && let Some(entries) = parse_data_json(encoded.trim())
288        {
289            return entries;
290        }
291    }
292
293    section.lines().filter_map(parse_entry_line).collect()
294}
295
296fn parse_data_json(encoded: &str) -> Option<Vec<NoteEntry>> {
297    let value: Value = serde_json::from_str(encoded).ok()?;
298    let mut entries = Vec::new();
299    for item in value.as_array()? {
300        entries.push(NoteEntry {
301            id: item.get("id")?.as_str()?.to_owned(),
302            url: item.get("url")?.as_str()?.to_owned(),
303            title: item
304                .get("title")
305                .and_then(Value::as_str)
306                .unwrap_or_default()
307                .to_owned(),
308            state: item
309                .get("state")
310                .and_then(Value::as_str)
311                .unwrap_or("open")
312                .to_owned(),
313        });
314    }
315    Some(entries)
316}
317
318/// Best-effort recovery of one rendered bullet: `[label](url)` plus the
319/// state suffix. The trunk line (backticks, no link) and the footer fall
320/// through to None.
321fn parse_entry_line(line: &str) -> Option<NoteEntry> {
322    let rest = line.trim().strip_prefix("- ")?;
323    if rest.starts_with('`') {
324        return None;
325    }
326
327    let open = rest.find('[')?;
328    let split = rest[open..].find("](")? + open;
329    let close = rest[split + 2..].find(')')? + split + 2;
330    let label = &rest[open + 1..split];
331    let url = &rest[split + 2..close];
332    let tail = &rest[close + 1..];
333
334    let state = if tail.contains("(merged)") {
335        "merged"
336    } else if tail.contains("(closed)") {
337        "closed"
338    } else {
339        "open"
340    };
341
342    // "Title (#12)" carries both; a bare "#12" label is just the id.
343    let (title, id) = match rest[open + 1..split].rfind(" (") {
344        Some(position) if label.ends_with(')') => {
345            let id = &label[position + 2..label.len() - 1];
346            if id.starts_with('#') || id.starts_with('!') {
347                (label[..position].to_owned(), id.to_owned())
348            } else {
349                (label.to_owned(), String::new())
350            }
351        }
352        _ if label.starts_with('#') || label.starts_with('!') => (String::new(), label.to_owned()),
353        _ => (label.to_owned(), String::new()),
354    };
355
356    Some(NoteEntry {
357        id,
358        url: url.to_owned(),
359        title,
360        state: state.to_owned(),
361    })
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn entry(id: &str, title: &str, url: &str, state: &str) -> NoteEntry {
369        NoteEntry {
370            id: id.to_owned(),
371            url: url.to_owned(),
372            title: title.to_owned(),
373            state: state.to_owned(),
374        }
375    }
376
377    #[test]
378    fn build_stack_note_lists_ledger_leaf_first_with_pointer_and_trunk() {
379        let entries = vec![
380            entry("#12", "Bottom change", "https://example.com/12", "open"),
381            entry("#13", "Top change", "https://example.com/13", "open"),
382        ];
383
384        let note = build_stack_note(&entries, 0, "main");
385        let lines: Vec<&str> = note.lines().collect();
386        assert!(
387            lines[0].starts_with(DATA_PREFIX),
388            "missing data line: {note}"
389        );
390        assert_eq!(
391            lines[1],
392            "- \u{1F7E2} [Top change (#13)](https://example.com/13)"
393        );
394        assert_eq!(
395            lines[2],
396            "- \u{1F7E2} [Bottom change (#12)](https://example.com/12) \u{1F448}"
397        );
398        assert_eq!(lines[3], "- `main`");
399        assert!(note.ends_with(
400            "Stack managed by \
401             <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
402             width=\"12\" height=\"12\" alt=\"\" /> \
403             [git-stk](https://github.com/lararosekelley/git-stk)"
404        ));
405    }
406
407    #[test]
408    fn build_stack_note_styles_merged_and_closed_entries() {
409        let entries = vec![
410            entry("#11", "Landed", "https://example.com/11", "merged"),
411            entry("#12", "Abandoned", "https://example.com/12", "closed"),
412            entry("#13", "Live", "https://example.com/13", "open"),
413        ];
414
415        let note = build_stack_note(&entries, 2, "main");
416        assert!(note.contains("- \u{1F7E2} [Live (#13)](https://example.com/13) \u{1F448}"));
417        assert!(
418            note.contains("- \u{1F534} ~~[Abandoned (#12)](https://example.com/12)~~ (closed)")
419        );
420        assert!(note.contains("- \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)"));
421    }
422
423    #[test]
424    fn build_stack_note_falls_back_to_id_without_title() {
425        let entries = vec![entry("#12", "", "https://example.com/12", "open")];
426        let note = build_stack_note(&entries, 0, "main");
427        assert!(note.contains("- \u{1F7E2} [#12](https://example.com/12) \u{1F448}"));
428    }
429
430    #[test]
431    fn parse_ledger_round_trips_the_data_line() {
432        let entries = vec![
433            entry("#11", "Landed", "https://example.com/11", "merged"),
434            entry("#13", "Top -> change", "https://example.com/13", "open"),
435        ];
436
437        let note = build_stack_note(&entries, 1, "main");
438        assert_eq!(parse_ledger(&note), entries);
439    }
440
441    #[test]
442    fn data_line_survives_a_title_containing_a_comment_terminator() {
443        let entries = vec![entry(
444            "#12",
445            "weird --> title",
446            "https://example.com/12",
447            "open",
448        )];
449        let line = data_line(&entries);
450        assert!(!line[DATA_PREFIX.len()..line.len() - COMMENT_END.len()].contains("-->"));
451        assert_eq!(parse_ledger(&line), entries);
452    }
453
454    #[test]
455    fn parse_ledger_recovers_entries_from_bullets_when_data_line_is_gone() {
456        let entries = vec![
457            entry("#11", "Landed", "https://example.com/11", "merged"),
458            entry("#12", "", "https://example.com/12", "closed"),
459            entry("#13", "Live", "https://example.com/13", "open"),
460        ];
461
462        let note = build_stack_note(&entries, 2, "main");
463        let without_data: String = note
464            .lines()
465            .filter(|line| !line.trim().starts_with(DATA_PREFIX))
466            .collect::<Vec<_>>()
467            .join("\n");
468
469        // Bullets render leaf-first, so recovery reverses back to
470        // bottom-first ledger order.
471        let mut recovered = parse_ledger(&without_data);
472        recovered.reverse();
473        assert_eq!(recovered, entries);
474    }
475
476    #[test]
477    fn parse_ledger_falls_back_to_bullets_when_data_line_is_corrupt() {
478        let section = "<!-- git-stk:data [{\"id\": -->\n\
479                       - \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)\n\
480                       - `main`";
481        assert_eq!(
482            parse_ledger(section),
483            vec![entry("#11", "Landed", "https://example.com/11", "merged")]
484        );
485    }
486
487    #[test]
488    fn parse_ledger_reads_the_legacy_unstyled_format() {
489        let section = "- [Top change (#13)](https://example.com/13)\n\
490                       - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
491                       - `main`\n\n---\n\nfooter";
492        assert_eq!(
493            parse_ledger(section),
494            vec![
495                entry("#13", "Top change", "https://example.com/13", "open"),
496                entry("#12", "Bottom change", "https://example.com/12", "open"),
497            ]
498        );
499    }
500
501    #[test]
502    fn note_entry_round_trips_through_review() {
503        let landed = entry("#11", "Landed", "https://example.com/11", "merged");
504        let review = landed.to_review();
505        assert_eq!(review.state, ReviewState::Merged);
506        assert_eq!(NoteEntry::from_review(&review), landed);
507    }
508
509    #[test]
510    fn note_entry_matches_by_id_or_url() {
511        let by_id = entry("#11", "", "", "open");
512        let by_url = entry("", "", "https://example.com/11", "open");
513        assert!(by_id.matches(&entry("#11", "x", "y", "merged")));
514        assert!(by_url.matches(&entry("#12", "", "https://example.com/11", "open")));
515        assert!(!by_url.matches(&by_id));
516    }
517}