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