use anyhow::Result;
use crate::providers::{ProviderKind, ReviewProvider, ReviewState};
use crate::settings;
mod ledger;
mod sections;
mod template;
pub use ledger::update_stack_notes;
use sections::{body_with_section_before, marker_start, strip_sections};
const STACK_SECTION: &str = "stack";
const CLOSES_SECTION: &str = "closes";
const DESCRIPTION_SECTION: &str = "description";
pub fn update_closes_notes(
review_provider: &dyn ReviewProvider,
branches: &[String],
dry_run: bool,
) -> Result<()> {
for branch in branches {
let Some(issue) = issue_number_from_branch(branch) else {
continue;
};
let Some(review) = review_provider.review_for_branch(branch)? else {
if dry_run {
anstream::println!("would link issue #{issue} in the review for {branch}");
} else {
anstream::println!("skipped issue link: no review found for {branch}");
}
continue;
};
if review.branch != *branch || review.state == ReviewState::Merged {
continue;
}
if dry_run {
anstream::println!("would link issue #{issue} in {}", review.id);
continue;
}
let body = review_provider.review_body(&review)?;
let updated = body_with_closes_note(&body, &format!("Closes #{issue}"));
if updated == body {
continue;
}
review_provider.update_review_body(&review, &updated)?;
anstream::println!("linked issue #{issue} in {}", review.id);
}
Ok(())
}
pub fn update_description_note(
review_provider: &dyn ReviewProvider,
branch: &str,
description: &str,
dry_run: bool,
) -> Result<()> {
let verb = if description.is_empty() {
"clear"
} else {
"set"
};
let Some(review) = review_provider.review_for_branch(branch)? else {
if dry_run {
anstream::println!("would {verb} the description on the review for {branch}");
} else {
anstream::println!("skipped description: no review found for {branch}");
}
return Ok(());
};
if review.branch != *branch {
anstream::println!(
"skipped description: review {} belongs to {}",
review.id,
review.branch
);
return Ok(());
}
if dry_run {
anstream::println!("would {verb} the description in {}", review.id);
return Ok(());
}
let body = review_provider.review_body(&review)?;
let updated = if description.is_empty() {
if !body.contains(&marker_start(DESCRIPTION_SECTION)) {
return Ok(());
}
strip_sections(&body, DESCRIPTION_SECTION)
.trim_end()
.to_owned()
} else {
body_with_description_note(&body, description)
};
if updated == body {
return Ok(());
}
review_provider.update_review_body(&review, &updated)?;
anstream::println!(
"{} description in {}",
if description.is_empty() {
"cleared"
} else {
"set"
},
review.id
);
Ok(())
}
pub fn seed_template_notes(
review_provider: &dyn ReviewProvider,
kind: ProviderKind,
created: &[String],
desc_branch: Option<&str>,
dry_run: bool,
) -> Result<()> {
if created.is_empty() {
return Ok(());
}
let template = if settings::use_pr_template()? {
template::discover(kind)?
} else {
None
};
for branch in created {
let is_desc = desc_branch == Some(branch.as_str());
if template.is_none() && !is_desc {
continue;
}
if dry_run {
anstream::println!("would seed the review body for {branch}");
continue;
}
let Some(review) = review_provider.review_for_branch(branch)? else {
anstream::println!("skipped body seed: no review found for {branch}");
continue;
};
if review.branch != *branch {
continue;
}
let body = review_provider.review_body(&review)?;
let commit_body = crate::git::commit_body(branch)?;
let prose = commit_body.trim();
let (updated, seeded_template) = if is_desc {
(prose.to_owned(), false)
} else {
match &template {
Some(template) => (body_template_as_description(template, prose), true),
None => continue,
}
};
if updated == body {
continue;
}
review_provider.update_review_body(&review, &updated)?;
if seeded_template {
anstream::println!("seeded the PR template into {}", review.id);
} else {
anstream::println!("dropped the commit subject from {}", review.id);
}
}
Ok(())
}
fn body_template_as_description(template: &str, prose: &str) -> String {
let content = if prose.is_empty() {
template.to_owned()
} else {
format!("{template}\n\n{prose}")
};
body_with_description_note("", &content)
}
fn issue_number_from_branch(branch: &str) -> Option<u64> {
for segment in branch.split('/') {
let lowered = segment.to_ascii_lowercase();
let candidate = lowered
.strip_prefix("issue-")
.or_else(|| lowered.strip_prefix("issues-"))
.unwrap_or(&lowered);
let end = candidate
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(candidate.len());
let (digits, rest) = candidate.split_at(end);
if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
continue;
}
if let Ok(number) = digits.parse::<u64>()
&& number > 0
{
return Some(number);
}
}
None
}
fn body_with_closes_note(body: &str, note: &str) -> String {
body_with_section_before(body, CLOSES_SECTION, note, &[STACK_SECTION])
}
fn body_with_description_note(body: &str, description: &str) -> String {
body_with_section_before(
body,
DESCRIPTION_SECTION,
description,
&[CLOSES_SECTION, STACK_SECTION],
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_number_from_branch_reads_supported_shapes() {
assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
assert_eq!(issue_number_from_branch("42"), Some(42));
}
#[test]
fn issue_number_from_branch_rejects_lookalikes() {
assert_eq!(issue_number_from_branch("feature/b"), None);
assert_eq!(issue_number_from_branch("fix-thing-123"), None);
assert_eq!(issue_number_from_branch("v2-migration"), None);
assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
assert_eq!(issue_number_from_branch("0-zero"), None);
assert_eq!(issue_number_from_branch("upgrade-issue"), None);
}
#[test]
fn body_template_as_description_wraps_the_template() {
assert_eq!(
body_template_as_description("## Summary", ""),
"<!-- git-stk:description -->\n## Summary\n<!-- /git-stk:description -->"
);
}
#[test]
fn body_template_as_description_keeps_the_commit_body_below_the_template() {
assert_eq!(
body_template_as_description("## Summary", "Commit body."),
"<!-- git-stk:description -->\n## Summary\n\nCommit body.\n<!-- /git-stk:description -->"
);
}
#[test]
fn body_with_closes_note_appends_without_a_stack_section() {
let updated = body_with_closes_note("Description.", "Closes #5");
assert_eq!(
updated,
"Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
);
}
#[test]
fn body_with_closes_note_lands_above_the_stack_section() {
let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
let updated = body_with_closes_note(body, "Closes #5");
assert_eq!(
updated,
"Description.\n\n\
<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
);
}
#[test]
fn body_with_closes_note_replaces_a_stale_note_in_place() {
let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
let updated = body_with_closes_note(body, "Closes #5");
assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
assert!(updated.contains("Closes #5"));
assert!(!updated.contains("Closes #4"));
let closes = updated.find("Closes #5").expect("closes note");
let stack = updated.find("stack list").expect("stack note");
assert!(
closes < stack,
"closes note should sit above the stack note"
);
}
#[test]
fn body_with_description_note_lands_above_every_managed_section() {
let body = "Intro.\n\n\
<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
let updated = body_with_description_note(body, "Summary.");
let intro = updated.find("Intro.").expect("intro");
let description = updated.find("Summary.").expect("description");
let closes = updated.find("Closes #5").expect("closes");
let stack = updated.find("stack list").expect("stack");
assert!(intro < description && description < closes && closes < stack);
assert!(
updated
.contains("<!-- git-stk:description -->\nSummary.\n<!-- /git-stk:description -->")
);
}
#[test]
fn body_with_description_note_replaces_in_place() {
let body = "<!-- git-stk:description -->\nOld.\n<!-- /git-stk:description -->\n\n\
<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
let updated = body_with_description_note(body, "New.");
assert_eq!(updated.matches("<!-- git-stk:description -->").count(), 1);
assert!(updated.contains("New."));
assert!(!updated.contains("Old."));
let description = updated.find("New.").expect("description");
let stack = updated.find("stack list").expect("stack");
assert!(description < stack);
}
}