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