use anyhow::Result;
use crate::providers::{ReviewProvider, ReviewRequest};
const STACK_NOTE_START: &str = "<!-- git-stk:stack -->";
const STACK_NOTE_END: &str = "<!-- /git-stk:stack -->";
const TOOL_URL: &str = "https://github.com/lararosekelley/git-stk";
const LOGO_URL: &str =
"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg";
pub fn update_stack_notes(
review_provider: &dyn ReviewProvider,
branch_parents: &[(String, String)],
dry_run: bool,
) -> Result<()> {
let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
return Ok(());
};
let mut entries = Vec::new();
for (branch, _) in branch_parents {
match review_provider.review_for_branch(branch)? {
Some(review) if review.branch == *branch => entries.push(review),
_ => {
if !dry_run {
println!("skipped stack notes: no review found for {branch}");
}
return Ok(());
}
}
}
for index in 0..entries.len() {
let note = build_stack_note(&entries, index, &trunk);
let review = &entries[index];
if dry_run {
println!("would update stack note in {}", review.id);
continue;
}
let body = review_provider.review_body(review)?;
let updated = body_with_stack_note(&body, ¬e);
if updated == body {
continue;
}
review_provider.update_review_body(review, &updated)?;
println!("updated stack note in {}", review.id);
}
Ok(())
}
fn build_stack_note(entries: &[ReviewRequest], current: usize, trunk: &str) -> String {
let mut lines = Vec::new();
for (index, entry) in entries.iter().enumerate().rev() {
let label = if entry.title.is_empty() {
entry.id.clone()
} else {
format!("{} ({})", entry.title, entry.id)
};
let mut line = format!("- [{label}]({})", entry.url);
if index == current {
line.push_str(" \u{1F448}");
}
lines.push(line);
}
lines.push(format!("- `{trunk}`"));
format!(
"{}\n\n---\n\nStack managed by \
<img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
[git-stk]({TOOL_URL})",
lines.join("\n")
)
}
fn body_with_stack_note(body: &str, note: &str) -> String {
let section = format!("{STACK_NOTE_START}\n{note}\n{STACK_NOTE_END}");
let cleaned = strip_stack_notes(body);
if cleaned.trim().is_empty() {
section
} else {
format!("{}\n\n{section}", cleaned.trim_end())
}
}
fn strip_stack_notes(body: &str) -> String {
let mut result = body.to_owned();
while let Some(start) = result.find(STACK_NOTE_START) {
match result[start..].find(STACK_NOTE_END) {
Some(end_offset) => {
let end = start + end_offset + STACK_NOTE_END.len();
result.replace_range(start..end, "");
}
None => result.replace_range(start..start + STACK_NOTE_START.len(), ""),
}
}
while let Some(start) = result.find(STACK_NOTE_END) {
result.replace_range(start..start + STACK_NOTE_END.len(), "");
}
while result.contains("\n\n\n") {
result = result.replace("\n\n\n", "\n\n");
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::ReviewState;
fn review(id: &str, title: &str, url: &str) -> ReviewRequest {
ReviewRequest {
id: id.to_owned(),
branch: String::new(),
base: String::new(),
state: ReviewState::Open,
url: url.to_owned(),
title: title.to_owned(),
}
}
#[test]
fn build_stack_note_lists_stack_leaf_first_with_pointer_and_trunk() {
let entries = vec![
review("#12", "Bottom change", "https://example.com/12"),
review("#13", "Top change", "https://example.com/13"),
];
let note = build_stack_note(&entries, 0, "main");
assert_eq!(
note,
"- [Top change (#13)](https://example.com/13)\n\
- [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
- `main`\n\n\
---\n\n\
Stack managed by \
<img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
width=\"12\" height=\"12\" alt=\"\" /> \
[git-stk](https://github.com/lararosekelley/git-stk)"
);
}
#[test]
fn build_stack_note_falls_back_to_id_without_title() {
let entries = vec![review("#12", "", "https://example.com/12")];
let note = build_stack_note(&entries, 0, "main");
assert!(note.contains("- [#12](https://example.com/12) \u{1F448}"));
}
#[test]
fn body_with_stack_note_appends_to_existing_body() {
let updated = body_with_stack_note("Some PR description.\n", "stack list");
assert_eq!(
updated,
"Some PR description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
);
}
#[test]
fn body_with_stack_note_fills_empty_body() {
let updated = body_with_stack_note("", "stack list");
assert_eq!(
updated,
"<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
);
}
#[test]
fn body_with_stack_note_replaces_existing_note() {
let body = "Intro.\n\n<!-- git-stk:stack -->\nold list\n<!-- /git-stk:stack -->\n\nOutro.";
let updated = body_with_stack_note(body, "new list");
assert_eq!(
updated,
"Intro.\n\nOutro.\n\n<!-- git-stk:stack -->\nnew list\n<!-- /git-stk:stack -->"
);
}
#[test]
fn body_with_stack_note_is_idempotent() {
let body = body_with_stack_note("Description.", "stack list");
assert_eq!(body_with_stack_note(&body, "stack list"), body);
}
#[test]
fn body_with_stack_note_repairs_orphaned_start_marker() {
let body = "Intro.\n\n<!-- git-stk:stack -->\nleftover text";
let updated = body_with_stack_note(body, "fresh list");
assert_eq!(
updated,
"Intro.\n\nleftover text\n\n<!-- git-stk:stack -->\nfresh list\n<!-- /git-stk:stack -->"
);
}
#[test]
fn body_with_stack_note_repairs_orphaned_end_marker() {
let body = "Intro.\nstray\n<!-- /git-stk:stack -->\nOutro.";
let updated = body_with_stack_note(body, "fresh list");
assert!(updated.matches("<!-- git-stk:stack -->").count() == 1);
assert!(updated.matches("<!-- /git-stk:stack -->").count() == 1);
assert!(updated.contains("Intro.\nstray"));
assert!(updated.ends_with("<!-- /git-stk:stack -->"));
}
#[test]
fn body_with_stack_note_repairs_reversed_and_duplicate_markers() {
let body = "<!-- /git-stk:stack -->\nA\n<!-- git-stk:stack -->\nB\n\
<!-- git-stk:stack -->\nC\n<!-- /git-stk:stack -->\nD";
let updated = body_with_stack_note(body, "fresh list");
assert_eq!(updated.matches("<!-- git-stk:stack -->").count(), 1);
assert_eq!(updated.matches("<!-- /git-stk:stack -->").count(), 1);
assert!(updated.contains("fresh list"));
assert!(updated.ends_with("<!-- /git-stk:stack -->"));
}
}