Skip to main content

git_stk/
notes.rs

1//! Marker-delimited sections maintained in every review description: the
2//! stack-overview ledger and issue-closing links. Build, splice, parse, and
3//! self-repair of `<!-- git-stk:NAME -->` sections.
4
5use anyhow::Result;
6use serde_json::{Value, json};
7
8use crate::providers::{ReviewProvider, ReviewRequest, ReviewState};
9
10const STACK_SECTION: &str = "stack";
11const CLOSES_SECTION: &str = "closes";
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
18fn marker_start(name: &str) -> String {
19    format!("<!-- git-stk:{name} -->")
20}
21
22fn marker_end(name: &str) -> String {
23    format!("<!-- /git-stk:{name} -->")
24}
25
26/// One row of the stack-overview ledger. Live rows come from the provider;
27/// merged and closed rows outlive their local branches and are carried
28/// forward from the previous note, so the ledger is append-only history
29/// rather than a snapshot of the live stack.
30#[derive(Debug, Clone, PartialEq, Eq)]
31struct NoteEntry {
32    id: String,
33    url: String,
34    title: String,
35    state: String,
36}
37
38impl NoteEntry {
39    fn from_review(review: &ReviewRequest) -> Self {
40        Self {
41            id: review.id.clone(),
42            url: review.url.clone(),
43            title: review.title.clone(),
44            state: review.state.to_string(),
45        }
46    }
47
48    /// A review the ledger only knows by its row: enough identity to fetch
49    /// and update the body, nothing more.
50    fn to_review(&self) -> ReviewRequest {
51        let state = match self.state.as_str() {
52            "open" => ReviewState::Open,
53            "merged" => ReviewState::Merged,
54            "closed" => ReviewState::Closed,
55            other => ReviewState::Unknown(other.to_owned()),
56        };
57        ReviewRequest {
58            id: self.id.clone(),
59            branch: String::new(),
60            base: String::new(),
61            state,
62            url: self.url.clone(),
63            title: self.title.clone(),
64        }
65    }
66
67    /// Rows recovered from a hand-edited note may be missing the id, so the
68    /// URL doubles as identity.
69    fn matches(&self, other: &Self) -> bool {
70        (!self.id.is_empty() && self.id == other.id)
71            || (!self.url.is_empty() && self.url == other.url)
72    }
73}
74
75/// Maintain a stack overview in every review body: the full ledger
76/// leaf-first, the trunk at the bottom, and a pointing emoji marking the
77/// review being viewed. Lives between marker comments so refreshes replace
78/// it in place, and self-repairs if the markers were hand-edited away.
79/// Merged and closed entries are preserved from the previous note and
80/// restyled instead of dropped.
81pub fn update_stack_notes(
82    review_provider: &dyn ReviewProvider,
83    branch_parents: &[(String, String)],
84    dry_run: bool,
85) -> Result<()> {
86    // The bottom branch's parent is the base the whole stack sits on.
87    let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
88        return Ok(());
89    };
90
91    let mut live = Vec::new();
92    for (branch, _) in branch_parents {
93        // The closed-inclusive lookup is deliberate: a review closed on the
94        // platform should show up red in the ledger, even though every flow
95        // that acts on a review treats it as gone.
96        match review_provider.review_for_branch_including_closed(branch)? {
97            Some(review) if review.branch == *branch => live.push(review),
98            _ => {
99                // Without every review the overview would be wrong for all of
100                // them (dry runs never created the missing ones).
101                if !dry_run {
102                    println!("skipped stack notes: no review found for {branch}");
103                }
104                return Ok(());
105            }
106        }
107    }
108
109    if dry_run {
110        for review in &live {
111            println!("would update stack note in {}", review.id);
112        }
113        return Ok(());
114    }
115
116    // Fetch every live body up front: each carries its own copy of the
117    // ledger, and the union keeps history alive even in bodies that have
118    // never seen it (e.g. a review created after earlier entries merged).
119    let mut bodies = Vec::new();
120    for review in &live {
121        bodies.push(review_provider.review_body(review)?);
122    }
123
124    let live_entries: Vec<NoteEntry> = live.iter().map(NoteEntry::from_review).collect();
125    let mut historical: Vec<NoteEntry> = Vec::new();
126    for body in &bodies {
127        let Some(section) = extract_section(body, STACK_SECTION) else {
128            continue;
129        };
130        for entry in parse_ledger(section) {
131            let known = live_entries.iter().chain(historical.iter());
132            if !known
133                .into_iter()
134                .any(|entry_known| entry_known.matches(&entry))
135            {
136                historical.push(entry);
137            }
138        }
139    }
140
141    // Bottom-first, like the stack itself: already-landed history below,
142    // the live stack on top of it.
143    let mut entries = historical.clone();
144    entries.extend(live_entries);
145
146    for (offset, review) in live.iter().enumerate() {
147        let note = build_stack_note(&entries, historical.len() + offset, &trunk);
148        let updated = body_with_section(&bodies[offset], STACK_SECTION, &note);
149        if updated == bodies[offset] {
150            continue;
151        }
152
153        review_provider.update_review_body(review, &updated)?;
154        println!("updated stack note in {}", review.id);
155    }
156
157    // Historical reviews get the refreshed ledger too, so a just-merged
158    // review stops presenting the stack as it was. Failures are non-fatal:
159    // an old review may have become unreachable.
160    for (index, entry) in historical.iter().enumerate() {
161        if entry.id.is_empty() {
162            continue;
163        }
164        let review = entry.to_review();
165        let Ok(body) = review_provider.review_body(&review) else {
166            println!("skipped stack note in {}: could not read body", review.id);
167            continue;
168        };
169
170        let note = build_stack_note(&entries, index, &trunk);
171        let updated = body_with_section(&body, STACK_SECTION, &note);
172        if updated == body {
173            continue;
174        }
175
176        if review_provider
177            .update_review_body(&review, &updated)
178            .is_err()
179        {
180            println!("skipped stack note in {}: could not update body", review.id);
181            continue;
182        }
183        println!("updated stack note in {}", review.id);
184    }
185
186    Ok(())
187}
188
189/// Add a `Closes #N` line to each branch's review when the branch name
190/// references an issue (e.g. `123-fix-thing`, `fix/issue-123`), so the
191/// platform closes the issue when the review merges. Branches without an
192/// issue reference are passed over silently.
193pub fn update_closes_notes(
194    review_provider: &dyn ReviewProvider,
195    branches: &[String],
196    dry_run: bool,
197) -> Result<()> {
198    for branch in branches {
199        let Some(issue) = issue_number_from_branch(branch) else {
200            continue;
201        };
202
203        let Some(review) = review_provider.review_for_branch(branch)? else {
204            // On a dry run the review was likely never created; for real the
205            // submit just failed to produce one, which deserves a mention.
206            if dry_run {
207                println!("would link issue #{issue} in the review for {branch}");
208            } else {
209                println!("skipped issue link: no review found for {branch}");
210            }
211            continue;
212        };
213
214        if review.branch != *branch || review.state == ReviewState::Merged {
215            continue;
216        }
217
218        if dry_run {
219            println!("would link issue #{issue} in {}", review.id);
220            continue;
221        }
222
223        let body = review_provider.review_body(&review)?;
224        let updated = body_with_closes_note(&body, &format!("Closes #{issue}"));
225        if updated == body {
226            continue;
227        }
228
229        review_provider.update_review_body(&review, &updated)?;
230        println!("linked issue #{issue} in {}", review.id);
231    }
232
233    Ok(())
234}
235
236/// The issue number a branch name refers to, if any. A path segment that
237/// starts with the number (`123-fix-thing`, `fix/123-thing`, bare `123`) or
238/// prefixes it with issue/issues (`issue-123`, `fix/issues-123-thing`)
239/// counts; trailing numbers do not, to keep version-ish names from
240/// closing unrelated issues.
241fn issue_number_from_branch(branch: &str) -> Option<u64> {
242    for segment in branch.split('/') {
243        let lowered = segment.to_ascii_lowercase();
244        let candidate = lowered
245            .strip_prefix("issue-")
246            .or_else(|| lowered.strip_prefix("issues-"))
247            .unwrap_or(&lowered);
248
249        let end = candidate
250            .find(|character: char| !character.is_ascii_digit())
251            .unwrap_or(candidate.len());
252        let (digits, rest) = candidate.split_at(end);
253        if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
254            continue;
255        }
256
257        if let Ok(number) = digits.parse::<u64>()
258            && number > 0
259        {
260            return Some(number);
261        }
262    }
263
264    None
265}
266
267/// Render the overview for one review: a hidden data line carrying the
268/// ledger, every entry leaf-first as a status-styled bullet, a pointer on
269/// the review being viewed, the trunk in backticks at the bottom, and a
270/// footer crediting the tool.
271fn build_stack_note(entries: &[NoteEntry], current: usize, trunk: &str) -> String {
272    let mut lines = vec![data_line(entries)];
273    for (index, entry) in entries.iter().enumerate().rev() {
274        lines.push(render_entry(entry, index == current));
275    }
276    lines.push(format!("- `{trunk}`"));
277
278    format!(
279        "{}\n\n---\n\nStack managed by \
280         <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
281         [git-stk]({TOOL_URL})",
282        lines.join("\n")
283    )
284}
285
286/// A status emoji as the bullet, strikethrough plus a suffix for entries
287/// that have left the stack, and the pointer on the current review.
288fn render_entry(entry: &NoteEntry, current: bool) -> String {
289    let label = if entry.title.is_empty() {
290        entry.id.clone()
291    } else {
292        format!("{} ({})", entry.title, entry.id)
293    };
294    let link = format!("[{label}]({})", entry.url);
295
296    let mut line = match entry.state.as_str() {
297        "merged" => format!("- \u{1F7E3} ~~{link}~~ (merged)"),
298        "closed" => format!("- \u{1F534} ~~{link}~~ (closed)"),
299        _ => format!("- \u{1F7E2} {link}"),
300    };
301    if current {
302        line.push_str(" \u{1F448}");
303    }
304    line
305}
306
307/// One hidden machine-readable line so the ledger survives restyling: the
308/// rendered bullets are presentation, this is the data.
309fn data_line(entries: &[NoteEntry]) -> String {
310    let data = Value::Array(
311        entries
312            .iter()
313            .map(|entry| {
314                json!({
315                    "id": entry.id,
316                    "url": entry.url,
317                    "title": entry.title,
318                    "state": entry.state,
319                })
320            })
321            .collect(),
322    );
323
324    // '>' only ever appears inside JSON strings, so escaping it globally
325    // keeps a title containing "-->" from terminating the comment early.
326    let encoded = data.to_string().replace('>', "\\u003e");
327    format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
328}
329
330/// Read the ledger out of a stack section: the embedded data line when it
331/// is intact, otherwise whatever the rendered bullets still reveal (the
332/// hidden line may have been edited or deleted along with everything else).
333fn parse_ledger(section: &str) -> Vec<NoteEntry> {
334    for line in section.lines() {
335        if let Some(rest) = line.trim().strip_prefix(DATA_PREFIX)
336            && let Some(encoded) = rest.trim_end().strip_suffix(COMMENT_END)
337            && let Some(entries) = parse_data_json(encoded.trim())
338        {
339            return entries;
340        }
341    }
342
343    section.lines().filter_map(parse_entry_line).collect()
344}
345
346fn parse_data_json(encoded: &str) -> Option<Vec<NoteEntry>> {
347    let value: Value = serde_json::from_str(encoded).ok()?;
348    let mut entries = Vec::new();
349    for item in value.as_array()? {
350        entries.push(NoteEntry {
351            id: item.get("id")?.as_str()?.to_owned(),
352            url: item.get("url")?.as_str()?.to_owned(),
353            title: item
354                .get("title")
355                .and_then(Value::as_str)
356                .unwrap_or_default()
357                .to_owned(),
358            state: item
359                .get("state")
360                .and_then(Value::as_str)
361                .unwrap_or("open")
362                .to_owned(),
363        });
364    }
365    Some(entries)
366}
367
368/// Best-effort recovery of one rendered bullet: `[label](url)` plus the
369/// state suffix. The trunk line (backticks, no link) and the footer fall
370/// through to None.
371fn parse_entry_line(line: &str) -> Option<NoteEntry> {
372    let rest = line.trim().strip_prefix("- ")?;
373    if rest.starts_with('`') {
374        return None;
375    }
376
377    let open = rest.find('[')?;
378    let split = rest[open..].find("](")? + open;
379    let close = rest[split + 2..].find(')')? + split + 2;
380    let label = &rest[open + 1..split];
381    let url = &rest[split + 2..close];
382    let tail = &rest[close + 1..];
383
384    let state = if tail.contains("(merged)") {
385        "merged"
386    } else if tail.contains("(closed)") {
387        "closed"
388    } else {
389        "open"
390    };
391
392    // "Title (#12)" carries both; a bare "#12" label is just the id.
393    let (title, id) = match rest[open + 1..split].rfind(" (") {
394        Some(position) if label.ends_with(')') => {
395            let id = &label[position + 2..label.len() - 1];
396            if id.starts_with('#') || id.starts_with('!') {
397                (label[..position].to_owned(), id.to_owned())
398            } else {
399                (label.to_owned(), String::new())
400            }
401        }
402        _ if label.starts_with('#') || label.starts_with('!') => (String::new(), label.to_owned()),
403        _ => (label.to_owned(), String::new()),
404    };
405
406    Some(NoteEntry {
407        id,
408        url: url.to_owned(),
409        title,
410        state: state.to_owned(),
411    })
412}
413
414/// The content of the first well-formed marker section, if any.
415fn extract_section<'body>(body: &'body str, name: &str) -> Option<&'body str> {
416    let start_marker = marker_start(name);
417    let end_marker = marker_end(name);
418    let start = body.find(&start_marker)? + start_marker.len();
419    let length = body[start..].find(&end_marker)?;
420    Some(&body[start..start + length])
421}
422
423/// Replace the marker-delimited section in a review body, appending it at
424/// the end. Damaged markup (orphaned or reordered markers, duplicates) is
425/// stripped first, so the section self-repairs on the next update.
426fn body_with_section(body: &str, name: &str, content: &str) -> String {
427    let section = format!("{}\n{content}\n{}", marker_start(name), marker_end(name));
428    let cleaned = strip_sections(body, name);
429
430    if cleaned.trim().is_empty() {
431        section
432    } else {
433        format!("{}\n\n{section}", cleaned.trim_end())
434    }
435}
436
437/// Splice the closes note in, keeping it above the stack overview so the
438/// closing keyword reads as part of the description rather than the footer.
439fn body_with_closes_note(body: &str, note: &str) -> String {
440    let section = format!(
441        "{}\n{note}\n{}",
442        marker_start(CLOSES_SECTION),
443        marker_end(CLOSES_SECTION)
444    );
445    let cleaned = strip_sections(body, CLOSES_SECTION);
446
447    if let Some(position) = cleaned.find(&marker_start(STACK_SECTION)) {
448        let head = cleaned[..position].trim_end();
449        let tail = &cleaned[position..];
450        if head.is_empty() {
451            format!("{section}\n\n{tail}")
452        } else {
453            format!("{head}\n\n{section}\n\n{tail}")
454        }
455    } else if cleaned.trim().is_empty() {
456        section
457    } else {
458        format!("{}\n\n{section}", cleaned.trim_end())
459    }
460}
461
462/// Remove every well-formed marker section and any orphaned markers.
463fn strip_sections(body: &str, name: &str) -> String {
464    let start_marker = marker_start(name);
465    let end_marker = marker_end(name);
466    let mut result = body.to_owned();
467
468    while let Some(start) = result.find(&start_marker) {
469        match result[start..].find(&end_marker) {
470            Some(end_offset) => {
471                let end = start + end_offset + end_marker.len();
472                result.replace_range(start..end, "");
473            }
474            None => result.replace_range(start..start + start_marker.len(), ""),
475        }
476    }
477    while let Some(start) = result.find(&end_marker) {
478        result.replace_range(start..start + end_marker.len(), "");
479    }
480
481    // Collapse the blank-line craters left behind by removed sections.
482    while result.contains("\n\n\n") {
483        result = result.replace("\n\n\n", "\n\n");
484    }
485    result
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use crate::providers::ReviewState;
492
493    fn entry(id: &str, title: &str, url: &str, state: &str) -> NoteEntry {
494        NoteEntry {
495            id: id.to_owned(),
496            url: url.to_owned(),
497            title: title.to_owned(),
498            state: state.to_owned(),
499        }
500    }
501
502    #[test]
503    fn build_stack_note_lists_ledger_leaf_first_with_pointer_and_trunk() {
504        let entries = vec![
505            entry("#12", "Bottom change", "https://example.com/12", "open"),
506            entry("#13", "Top change", "https://example.com/13", "open"),
507        ];
508
509        let note = build_stack_note(&entries, 0, "main");
510        let lines: Vec<&str> = note.lines().collect();
511        assert!(
512            lines[0].starts_with(DATA_PREFIX),
513            "missing data line: {note}"
514        );
515        assert_eq!(
516            lines[1],
517            "- \u{1F7E2} [Top change (#13)](https://example.com/13)"
518        );
519        assert_eq!(
520            lines[2],
521            "- \u{1F7E2} [Bottom change (#12)](https://example.com/12) \u{1F448}"
522        );
523        assert_eq!(lines[3], "- `main`");
524        assert!(note.ends_with(
525            "Stack managed by \
526             <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
527             width=\"12\" height=\"12\" alt=\"\" /> \
528             [git-stk](https://github.com/lararosekelley/git-stk)"
529        ));
530    }
531
532    #[test]
533    fn build_stack_note_styles_merged_and_closed_entries() {
534        let entries = vec![
535            entry("#11", "Landed", "https://example.com/11", "merged"),
536            entry("#12", "Abandoned", "https://example.com/12", "closed"),
537            entry("#13", "Live", "https://example.com/13", "open"),
538        ];
539
540        let note = build_stack_note(&entries, 2, "main");
541        assert!(note.contains("- \u{1F7E2} [Live (#13)](https://example.com/13) \u{1F448}"));
542        assert!(
543            note.contains("- \u{1F534} ~~[Abandoned (#12)](https://example.com/12)~~ (closed)")
544        );
545        assert!(note.contains("- \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)"));
546    }
547
548    #[test]
549    fn build_stack_note_falls_back_to_id_without_title() {
550        let entries = vec![entry("#12", "", "https://example.com/12", "open")];
551        let note = build_stack_note(&entries, 0, "main");
552        assert!(note.contains("- \u{1F7E2} [#12](https://example.com/12) \u{1F448}"));
553    }
554
555    #[test]
556    fn parse_ledger_round_trips_the_data_line() {
557        let entries = vec![
558            entry("#11", "Landed", "https://example.com/11", "merged"),
559            entry("#13", "Top -> change", "https://example.com/13", "open"),
560        ];
561
562        let note = build_stack_note(&entries, 1, "main");
563        assert_eq!(parse_ledger(&note), entries);
564    }
565
566    #[test]
567    fn data_line_survives_a_title_containing_a_comment_terminator() {
568        let entries = vec![entry(
569            "#12",
570            "weird --> title",
571            "https://example.com/12",
572            "open",
573        )];
574        let line = data_line(&entries);
575        assert!(!line[DATA_PREFIX.len()..line.len() - COMMENT_END.len()].contains("-->"));
576        assert_eq!(parse_ledger(&line), entries);
577    }
578
579    #[test]
580    fn parse_ledger_recovers_entries_from_bullets_when_data_line_is_gone() {
581        let entries = vec![
582            entry("#11", "Landed", "https://example.com/11", "merged"),
583            entry("#12", "", "https://example.com/12", "closed"),
584            entry("#13", "Live", "https://example.com/13", "open"),
585        ];
586
587        let note = build_stack_note(&entries, 2, "main");
588        let without_data: String = note
589            .lines()
590            .filter(|line| !line.trim().starts_with(DATA_PREFIX))
591            .collect::<Vec<_>>()
592            .join("\n");
593
594        // Bullets render leaf-first, so recovery reverses back to
595        // bottom-first ledger order.
596        let mut recovered = parse_ledger(&without_data);
597        recovered.reverse();
598        assert_eq!(recovered, entries);
599    }
600
601    #[test]
602    fn parse_ledger_falls_back_to_bullets_when_data_line_is_corrupt() {
603        let section = "<!-- git-stk:data [{\"id\": -->\n\
604                       - \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)\n\
605                       - `main`";
606        assert_eq!(
607            parse_ledger(section),
608            vec![entry("#11", "Landed", "https://example.com/11", "merged")]
609        );
610    }
611
612    #[test]
613    fn parse_ledger_reads_the_legacy_unstyled_format() {
614        let section = "- [Top change (#13)](https://example.com/13)\n\
615                       - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
616                       - `main`\n\n---\n\nfooter";
617        assert_eq!(
618            parse_ledger(section),
619            vec![
620                entry("#13", "Top change", "https://example.com/13", "open"),
621                entry("#12", "Bottom change", "https://example.com/12", "open"),
622            ]
623        );
624    }
625
626    #[test]
627    fn issue_number_from_branch_reads_supported_shapes() {
628        assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
629        assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
630        assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
631        assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
632        assert_eq!(issue_number_from_branch("42"), Some(42));
633    }
634
635    #[test]
636    fn issue_number_from_branch_rejects_lookalikes() {
637        assert_eq!(issue_number_from_branch("feature/b"), None);
638        assert_eq!(issue_number_from_branch("fix-thing-123"), None);
639        assert_eq!(issue_number_from_branch("v2-migration"), None);
640        assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
641        assert_eq!(issue_number_from_branch("0-zero"), None);
642        assert_eq!(issue_number_from_branch("upgrade-issue"), None);
643    }
644
645    #[test]
646    fn body_with_section_appends_to_existing_body() {
647        let updated = body_with_section("Some PR description.\n", STACK_SECTION, "stack list");
648        assert_eq!(
649            updated,
650            "Some PR description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
651        );
652    }
653
654    #[test]
655    fn body_with_section_fills_empty_body() {
656        let updated = body_with_section("", STACK_SECTION, "stack list");
657        assert_eq!(
658            updated,
659            "<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
660        );
661    }
662
663    #[test]
664    fn body_with_section_replaces_existing_section() {
665        let body = "Intro.\n\n<!-- git-stk:stack -->\nold list\n<!-- /git-stk:stack -->\n\nOutro.";
666        let updated = body_with_section(body, STACK_SECTION, "new list");
667        assert_eq!(
668            updated,
669            "Intro.\n\nOutro.\n\n<!-- git-stk:stack -->\nnew list\n<!-- /git-stk:stack -->"
670        );
671    }
672
673    #[test]
674    fn body_with_section_is_idempotent() {
675        let body = body_with_section("Description.", STACK_SECTION, "stack list");
676        assert_eq!(body_with_section(&body, STACK_SECTION, "stack list"), body);
677    }
678
679    #[test]
680    fn body_with_section_keeps_other_sections_intact() {
681        let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->";
682        let updated = body_with_section(body, STACK_SECTION, "stack list");
683        assert!(updated.contains("<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"));
684        assert!(updated.ends_with("<!-- /git-stk:stack -->"));
685    }
686
687    #[test]
688    fn body_with_section_repairs_orphaned_start_marker() {
689        let body = "Intro.\n\n<!-- git-stk:stack -->\nleftover text";
690        let updated = body_with_section(body, STACK_SECTION, "fresh list");
691        assert_eq!(
692            updated,
693            "Intro.\n\nleftover text\n\n<!-- git-stk:stack -->\nfresh list\n<!-- /git-stk:stack -->"
694        );
695    }
696
697    #[test]
698    fn body_with_section_repairs_orphaned_end_marker() {
699        let body = "Intro.\nstray\n<!-- /git-stk:stack -->\nOutro.";
700        let updated = body_with_section(body, STACK_SECTION, "fresh list");
701        assert!(updated.matches("<!-- git-stk:stack -->").count() == 1);
702        assert!(updated.matches("<!-- /git-stk:stack -->").count() == 1);
703        assert!(updated.contains("Intro.\nstray"));
704        assert!(updated.ends_with("<!-- /git-stk:stack -->"));
705    }
706
707    #[test]
708    fn body_with_section_repairs_reversed_and_duplicate_markers() {
709        let body = "<!-- /git-stk:stack -->\nA\n<!-- git-stk:stack -->\nB\n\
710                    <!-- git-stk:stack -->\nC\n<!-- /git-stk:stack -->\nD";
711        let updated = body_with_section(body, STACK_SECTION, "fresh list");
712        assert_eq!(updated.matches("<!-- git-stk:stack -->").count(), 1);
713        assert_eq!(updated.matches("<!-- /git-stk:stack -->").count(), 1);
714        assert!(updated.contains("fresh list"));
715        assert!(updated.ends_with("<!-- /git-stk:stack -->"));
716    }
717
718    #[test]
719    fn body_with_closes_note_appends_without_a_stack_section() {
720        let updated = body_with_closes_note("Description.", "Closes #5");
721        assert_eq!(
722            updated,
723            "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
724        );
725    }
726
727    #[test]
728    fn body_with_closes_note_lands_above_the_stack_section() {
729        let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
730        let updated = body_with_closes_note(body, "Closes #5");
731        assert_eq!(
732            updated,
733            "Description.\n\n\
734             <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
735             <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
736        );
737    }
738
739    #[test]
740    fn body_with_closes_note_replaces_a_stale_note_in_place() {
741        let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
742                    <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
743        let updated = body_with_closes_note(body, "Closes #5");
744        assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
745        assert!(updated.contains("Closes #5"));
746        assert!(!updated.contains("Closes #4"));
747        let closes = updated.find("Closes #5").expect("closes note");
748        let stack = updated.find("stack list").expect("stack note");
749        assert!(
750            closes < stack,
751            "closes note should sit above the stack note"
752        );
753    }
754
755    #[test]
756    fn note_entry_round_trips_through_review() {
757        let landed = entry("#11", "Landed", "https://example.com/11", "merged");
758        let review = landed.to_review();
759        assert_eq!(review.state, ReviewState::Merged);
760        assert_eq!(NoteEntry::from_review(&review), landed);
761    }
762
763    #[test]
764    fn note_entry_matches_by_id_or_url() {
765        let by_id = entry("#11", "", "", "open");
766        let by_url = entry("", "", "https://example.com/11", "open");
767        assert!(by_id.matches(&entry("#11", "x", "y", "merged")));
768        assert!(by_url.matches(&entry("#12", "", "https://example.com/11", "open")));
769        assert!(!by_url.matches(&by_id));
770    }
771}