use std::collections::HashMap;
use anyhow::{Context, Result};
use crate::forge::types::{PullRequest, RepoInfo};
use crate::forge::{Forge, ForgeKind};
use crate::jj::types::{Bookmark, NarrowedSegment};
#[derive(Debug)]
pub struct BookmarkNeedingPr {
pub bookmark: Bookmark,
pub base_branch: String,
pub title: String,
pub body: String,
}
#[derive(Debug)]
pub struct BookmarkNeedingBaseUpdate {
pub bookmark: Bookmark,
pub pr: PullRequest,
pub expected_base: String,
}
#[derive(Debug)]
pub struct BookmarkNeedingBodyUpdate {
pub bookmark: Bookmark,
pub pr_number: u64,
pub new_body: String,
}
#[derive(Debug)]
pub struct BookmarkNeedingReady {
pub bookmark: Bookmark,
pub pr_number: u64,
}
#[derive(Debug)]
pub struct TitleDrift {
pub bookmark: Bookmark,
pub pr_number: u64,
pub current_title: String,
pub expected_title: String,
}
#[derive(Debug)]
pub struct MergedBookmark {
pub bookmark: Bookmark,
pub pr_number: u64,
pub html_url: String,
}
#[derive(Debug)]
pub struct SubmissionPlan {
pub bookmarks_needing_push: Vec<Bookmark>,
pub bookmarks_needing_pr: Vec<BookmarkNeedingPr>,
pub bookmarks_needing_base_update: Vec<BookmarkNeedingBaseUpdate>,
pub bookmarks_needing_body_update: Vec<BookmarkNeedingBodyUpdate>,
pub bookmarks_needing_ready: Vec<BookmarkNeedingReady>,
pub bookmarks_needing_reviewers: Vec<(Bookmark, u64)>,
pub bookmarks_with_title_drift: Vec<TitleDrift>,
pub bookmarks_already_merged: Vec<MergedBookmark>,
pub existing_prs: HashMap<String, PullRequest>,
pub remote_name: String,
pub repo_info: RepoInfo,
pub forge_kind: ForgeKind,
pub all_bookmarks: Vec<Bookmark>,
pub default_branch: String,
pub draft: bool,
}
impl SubmissionPlan {
pub fn has_actions(&self) -> bool {
!self.bookmarks_needing_push.is_empty()
|| !self.bookmarks_needing_pr.is_empty()
|| !self.bookmarks_needing_base_update.is_empty()
|| !self.bookmarks_needing_body_update.is_empty()
|| !self.bookmarks_needing_ready.is_empty()
|| !self.bookmarks_needing_reviewers.is_empty()
}
}
const DESCRIPTION_START: &str = "<!-- jjpr:description -->";
const DESCRIPTION_END: &str = "<!-- /jjpr:description -->";
fn derive_pr_title_body(segment: &NarrowedSegment) -> (String, String) {
if let Some(change) = segment.changes.first() {
let title = change.description_first_line.clone();
let mut body = change
.description
.strip_prefix(&title)
.unwrap_or("")
.trim()
.to_string();
if !segment.merge_source_names.is_empty() {
let note = generate_merge_note(&segment.merge_source_names);
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(¬e);
}
(title, body)
} else {
(segment.bookmark.name.clone(), String::new())
}
}
fn generate_merge_note(source_names: &[String]) -> String {
let formatted: Vec<String> = source_names.iter().map(|n| format!("`{n}`")).collect();
let sources_text = match formatted.len() {
1 => formatted[0].clone(),
2 => format!("{} and {}", formatted[0], formatted[1]),
_ => {
let (last, rest) = formatted.split_last().unwrap();
format!("{}, and {last}", rest.join(", "))
}
};
let plural = if source_names.len() == 1 {
"that PR is"
} else {
"those PRs are"
};
format!(
"**Merge note:** This change also merges {sources_text} in jj. \
The diff may include changes from {sources_text} until {plural} merged."
)
}
pub fn wrap_managed_body(commit_body: &str) -> String {
format!("{DESCRIPTION_START}\n{commit_body}\n{DESCRIPTION_END}")
}
pub fn extract_managed_body(pr_body: &str) -> Option<&str> {
let start_idx = pr_body.find(DESCRIPTION_START)?;
let content_start = start_idx + DESCRIPTION_START.len();
let end_idx = pr_body[content_start..].find(DESCRIPTION_END)? + content_start;
Some(pr_body[content_start..end_idx].trim())
}
fn replace_managed_body(pr_body: &str, new_commit_body: &str) -> String {
let Some(start_idx) = pr_body.find(DESCRIPTION_START) else {
return pr_body.to_string();
};
let Some(end_tag_start) = pr_body[start_idx..].find(DESCRIPTION_END) else {
return pr_body.to_string();
};
let end_idx = start_idx + end_tag_start + DESCRIPTION_END.len();
let before = &pr_body[..start_idx];
let after = &pr_body[end_idx..];
format!("{before}{DESCRIPTION_START}\n{new_commit_body}\n{DESCRIPTION_END}{after}")
}
pub fn create_submission_plan(
github: &dyn Forge,
segments: &[NarrowedSegment],
remote_name: &str,
repo_info: &RepoInfo,
forge_kind: ForgeKind,
default_branch: &str,
draft: bool,
ready: bool,
reviewers: &[String],
stack_base: Option<&str>,
) -> Result<SubmissionPlan> {
let all_open_prs = github
.list_open_prs(&repo_info.owner, &repo_info.repo)
.context("failed to list open PRs — check `jjpr auth test`")?;
let pr_map = crate::forge::build_pr_map(all_open_prs, &repo_info.owner);
let mut bookmarks_needing_push = Vec::new();
let mut bookmarks_needing_pr = Vec::new();
let mut bookmarks_needing_base_update = Vec::new();
let mut bookmarks_needing_body_update = Vec::new();
let mut bookmarks_needing_ready = Vec::new();
let mut bookmarks_needing_reviewers = Vec::new();
let mut bookmarks_with_title_drift = Vec::new();
let mut bookmarks_already_merged = Vec::new();
let mut existing_prs: HashMap<String, PullRequest> = HashMap::new();
let mut all_bookmarks = Vec::new();
for (i, segment) in segments.iter().enumerate() {
let bookmark = &segment.bookmark;
all_bookmarks.push(bookmark.clone());
let base_branch = if i == 0 {
stack_base.unwrap_or(default_branch).to_string()
} else {
segments[i - 1].bookmark.name.clone()
};
let existing_pr = pr_map.get(&bookmark.name).cloned();
if existing_pr.is_none() {
match github.find_merged_pr(&repo_info.owner, &repo_info.repo, &bookmark.name) {
Ok(Some(merged_pr)) => {
bookmarks_already_merged.push(MergedBookmark {
bookmark: bookmark.clone(),
pr_number: merged_pr.number,
html_url: merged_pr.html_url,
});
continue;
}
Err(e) => {
eprintln!(
" Warning: could not check merged status for '{}': {e}",
bookmark.name
);
}
Ok(None) => {}
}
}
if !bookmark.is_synced {
bookmarks_needing_push.push(bookmark.clone());
}
if let Some(pr) = existing_pr {
if pr.base.ref_name != base_branch {
bookmarks_needing_base_update.push(BookmarkNeedingBaseUpdate {
bookmark: bookmark.clone(),
pr: pr.clone(),
expected_base: base_branch,
});
}
let (expected_title, expected_body) = derive_pr_title_body(segment);
let current_body = pr.body.as_deref().unwrap_or("");
if let Some(current_managed) = extract_managed_body(current_body)
&& current_managed != expected_body
{
let new_full_body = replace_managed_body(current_body, &expected_body);
bookmarks_needing_body_update.push(BookmarkNeedingBodyUpdate {
bookmark: bookmark.clone(),
pr_number: pr.number,
new_body: new_full_body,
});
}
if segment.changes.len() == 1 && pr.title != expected_title {
bookmarks_with_title_drift.push(TitleDrift {
bookmark: bookmark.clone(),
pr_number: pr.number,
current_title: pr.title.clone(),
expected_title,
});
}
if ready && pr.draft {
bookmarks_needing_ready.push(BookmarkNeedingReady {
bookmark: bookmark.clone(),
pr_number: pr.number,
});
}
if !reviewers.is_empty() {
bookmarks_needing_reviewers.push((bookmark.clone(), pr.number));
}
existing_prs.insert(bookmark.name.clone(), pr);
} else {
let (title, body) = derive_pr_title_body(segment);
bookmarks_needing_pr.push(BookmarkNeedingPr {
bookmark: bookmark.clone(),
base_branch,
title,
body: wrap_managed_body(&body),
});
}
}
Ok(SubmissionPlan {
bookmarks_needing_push,
bookmarks_needing_pr,
bookmarks_needing_base_update,
bookmarks_needing_body_update,
bookmarks_needing_ready,
bookmarks_needing_reviewers,
bookmarks_with_title_drift,
bookmarks_already_merged,
existing_prs,
remote_name: remote_name.to_string(),
repo_info: repo_info.clone(),
forge_kind,
all_bookmarks,
default_branch: default_branch.to_string(),
draft,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::forge::types::{ChecksStatus, IssueComment, MergeMethod, PrMergeability, PrState, PullRequestRef, ReviewSummary};
use crate::jj::types::LogEntry;
struct StubGitHub {
prs: HashMap<String, PullRequest>,
}
impl Forge for StubGitHub {
fn list_open_prs(
&self,
_owner: &str,
_repo: &str,
) -> Result<Vec<PullRequest>> {
Ok(self.prs.values().cloned().collect())
}
fn create_pr(
&self, _o: &str, _r: &str, _t: &str, _b: &str,
_h: &str, _ba: &str, _draft: bool,
) -> Result<PullRequest> {
unimplemented!()
}
fn update_pr_base(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> {
unimplemented!()
}
fn request_reviewers(
&self, _o: &str, _r: &str, _n: u64, _revs: &[String],
) -> Result<()> {
unimplemented!()
}
fn list_comments(&self, _o: &str, _r: &str, _i: u64) -> Result<Vec<IssueComment>> {
unimplemented!()
}
fn create_comment(
&self, _o: &str, _r: &str, _i: u64, _b: &str,
) -> Result<IssueComment> {
unimplemented!()
}
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> {
unimplemented!()
}
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> {
unimplemented!()
}
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> {
unimplemented!()
}
fn get_authenticated_user(&self) -> Result<String> {
Ok("testuser".to_string())
}
fn find_merged_pr(
&self, _o: &str, _r: &str, _h: &str,
) -> Result<Option<PullRequest>> {
Ok(None)
}
fn merge_pr(&self, _o: &str, _r: &str, _n: u64, _m: MergeMethod) -> Result<()> { unimplemented!() }
fn get_pr_checks_status(&self, _o: &str, _r: &str, _h: &str) -> Result<ChecksStatus> { unimplemented!() }
fn get_pr_reviews(&self, _o: &str, _r: &str, _n: u64) -> Result<ReviewSummary> { unimplemented!() }
fn get_pr_mergeability(&self, _o: &str, _r: &str, _n: u64) -> Result<PrMergeability> { unimplemented!() }
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<PrState> {
Ok(PrState { merged: false, state: "open".to_string() })
}
}
fn make_segment(name: &str, synced: bool) -> NarrowedSegment {
NarrowedSegment {
bookmark: Bookmark {
name: name.to_string(),
commit_id: format!("c_{name}"),
change_id: format!("ch_{name}"),
has_remote: synced,
is_synced: synced,
},
changes: vec![LogEntry {
commit_id: format!("c_{name}"),
change_id: format!("ch_{name}"),
author_name: "Test".to_string(),
author_email: "test@test.com".to_string(),
description: format!("Add {name}\n\nDetailed description"),
description_first_line: format!("Add {name}"),
parents: vec![],
local_bookmarks: vec![name.to_string()],
remote_bookmarks: vec![],
is_working_copy: false,
conflict: false,
}],
merge_source_names: vec![],
}
}
fn make_pr(name: &str, base: &str) -> PullRequest {
PullRequest {
number: 1,
html_url: "https://github.com/o/r/pull/1".to_string(),
title: format!("Add {name}"),
body: Some("Detailed description".to_string()),
base: PullRequestRef { ref_name: base.to_string(), label: String::new(), sha: String::new() },
head: PullRequestRef { ref_name: name.to_string(), label: String::new(), sha: String::new() },
draft: false,
node_id: String::new(),
merged_at: None,
requested_reviewers: vec![],
}
}
#[test]
fn test_plan_new_pr_needed() {
let gh = StubGitHub {
prs: HashMap::new(),
};
let segments = vec![make_segment("feature", false)];
let repo = RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
};
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert_eq!(plan.bookmarks_needing_push.len(), 1);
assert_eq!(plan.bookmarks_needing_pr.len(), 1);
assert_eq!(plan.bookmarks_needing_pr[0].base_branch, "main");
assert_eq!(plan.bookmarks_needing_pr[0].title, "Add feature");
assert_eq!(
plan.bookmarks_needing_pr[0].body,
wrap_managed_body("Detailed description")
);
}
#[test]
fn test_plan_existing_pr_correct_base() {
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), make_pr("feature", "main"))]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
};
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(plan.bookmarks_needing_push.is_empty());
assert!(plan.bookmarks_needing_pr.is_empty());
assert!(plan.bookmarks_needing_base_update.is_empty());
assert_eq!(plan.existing_prs.len(), 1);
}
#[test]
fn test_plan_existing_pr_wrong_base() {
let gh = StubGitHub {
prs: HashMap::from([("profile".to_string(), make_pr("profile", "main"))]),
};
let segments = vec![
make_segment("auth", true),
make_segment("profile", true),
];
let repo = RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
};
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert_eq!(plan.bookmarks_needing_base_update.len(), 1);
assert_eq!(
plan.bookmarks_needing_base_update[0].expected_base,
"auth"
);
}
#[test]
fn test_plan_stacked_base_branches() {
let gh = StubGitHub {
prs: HashMap::new(),
};
let segments = vec![
make_segment("auth", false),
make_segment("profile", false),
make_segment("settings", false),
];
let repo = RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
};
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert_eq!(plan.bookmarks_needing_pr[0].base_branch, "main");
assert_eq!(plan.bookmarks_needing_pr[1].base_branch, "auth");
assert_eq!(plan.bookmarks_needing_pr[2].base_branch, "profile");
}
#[test]
fn test_plan_stale_title_does_not_trigger_body_update() {
let mut pr = make_pr("feature", "main");
pr.title = "Old title".to_string();
pr.body = Some(wrap_managed_body("Detailed description"));
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(plan.bookmarks_needing_body_update.is_empty());
}
#[test]
fn test_plan_detects_title_drift() {
let mut pr = make_pr("feature", "main");
pr.title = "Old title".to_string();
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert_eq!(plan.bookmarks_with_title_drift.len(), 1);
assert_eq!(plan.bookmarks_with_title_drift[0].current_title, "Old title");
assert_eq!(plan.bookmarks_with_title_drift[0].expected_title, "Add feature");
}
#[test]
fn test_plan_tracks_reviewers_for_existing_prs() {
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), make_pr("feature", "main"))]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let reviewers = ["alice".to_string()];
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &reviewers, None).unwrap();
assert_eq!(plan.bookmarks_needing_reviewers.len(), 1);
assert_eq!(plan.bookmarks_needing_reviewers[0].1, 1); }
#[test]
fn test_plan_detects_stale_managed_body() {
let mut pr = make_pr("feature", "main");
pr.body = Some(wrap_managed_body("Old body text"));
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert_eq!(plan.bookmarks_needing_body_update.len(), 1);
assert!(extract_managed_body(&plan.bookmarks_needing_body_update[0].new_body)
.is_some_and(|m| m == "Detailed description"));
}
#[test]
fn test_plan_no_update_when_managed_body_matches() {
let mut pr = make_pr("feature", "main");
pr.body = Some(wrap_managed_body("Detailed description"));
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(plan.bookmarks_needing_body_update.is_empty());
}
#[test]
fn test_plan_preserves_user_content_around_sentinels() {
let mut pr = make_pr("feature", "main");
let body_with_extras = format!(
"User notes above\n\n{}\n\n## Screenshots\nSome screenshot",
wrap_managed_body("Old body")
);
pr.body = Some(body_with_extras);
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert_eq!(plan.bookmarks_needing_body_update.len(), 1);
let new_body = &plan.bookmarks_needing_body_update[0].new_body;
assert!(new_body.starts_with("User notes above"));
assert!(new_body.contains("## Screenshots\nSome screenshot"));
assert!(extract_managed_body(new_body).is_some_and(|m| m == "Detailed description"));
}
#[test]
fn test_plan_no_update_when_sentinels_removed() {
let mut pr = make_pr("feature", "main");
pr.body = Some("Completely rewritten body with no sentinels".to_string());
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(plan.bookmarks_needing_body_update.is_empty());
}
#[test]
fn test_wrap_managed_body() {
let wrapped = wrap_managed_body("hello world");
assert_eq!(
wrapped,
"<!-- jjpr:description -->\nhello world\n<!-- /jjpr:description -->"
);
}
#[test]
fn test_extract_managed_body() {
let body = "<!-- jjpr:description -->\nhello world\n<!-- /jjpr:description -->";
assert_eq!(extract_managed_body(body), Some("hello world"));
}
#[test]
fn test_extract_managed_body_with_surrounding_content() {
let body = "User text\n\n<!-- jjpr:description -->\nmanaged\n<!-- /jjpr:description -->\n\nMore user text";
assert_eq!(extract_managed_body(body), Some("managed"));
}
#[test]
fn test_extract_managed_body_no_markers() {
assert_eq!(extract_managed_body("plain text"), None);
}
#[test]
fn test_extract_managed_body_only_start_marker() {
let body = "text\n<!-- jjpr:description -->\nsome content but no end marker";
assert_eq!(extract_managed_body(body), None);
}
#[test]
fn test_replace_managed_body_preserves_surroundings() {
let body = "Before\n<!-- jjpr:description -->\nold\n<!-- /jjpr:description -->\nAfter";
let result = replace_managed_body(body, "new content");
assert_eq!(
result,
"Before\n<!-- jjpr:description -->\nnew content\n<!-- /jjpr:description -->\nAfter"
);
assert_eq!(extract_managed_body(&result), Some("new content"));
}
#[test]
fn test_replace_managed_body_no_markers() {
let body = "no markers here";
assert_eq!(replace_managed_body(body, "new"), body);
}
#[test]
fn test_plan_skips_merged_prs() {
struct GitHubWithMergedPr;
impl Forge for GitHubWithMergedPr {
fn list_open_prs(&self, _o: &str, _r: &str) -> Result<Vec<PullRequest>> {
Ok(vec![])
}
fn find_merged_pr(&self, _o: &str, _r: &str, head: &str) -> Result<Option<PullRequest>> {
if head == "auth" {
Ok(Some(PullRequest {
number: 99,
html_url: "https://github.com/o/r/pull/99".to_string(),
title: "Add auth".to_string(),
body: None,
base: PullRequestRef { ref_name: "main".to_string(), label: String::new(), sha: String::new() },
head: PullRequestRef { ref_name: "auth".to_string(), label: String::new(), sha: String::new() },
draft: false,
node_id: String::new(),
merged_at: Some("2024-01-01T00:00:00Z".to_string()),
requested_reviewers: vec![],
}))
} else {
Ok(None)
}
}
fn create_pr(&self, _o: &str, _r: &str, _t: &str, _b: &str, _h: &str, _ba: &str, _d: bool) -> Result<PullRequest> { unimplemented!() }
fn update_pr_base(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn request_reviewers(&self, _o: &str, _r: &str, _n: u64, _r2: &[String]) -> Result<()> { unimplemented!() }
fn list_comments(&self, _o: &str, _r: &str, _i: u64) -> Result<Vec<IssueComment>> { unimplemented!() }
fn create_comment(&self, _o: &str, _r: &str, _i: u64, _b: &str) -> Result<IssueComment> { unimplemented!() }
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { unimplemented!() }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { unimplemented!() }
fn get_authenticated_user(&self) -> Result<String> { Ok("test".to_string()) }
fn merge_pr(&self, _o: &str, _r: &str, _n: u64, _m: MergeMethod) -> Result<()> { unimplemented!() }
fn get_pr_checks_status(&self, _o: &str, _r: &str, _h: &str) -> Result<ChecksStatus> { unimplemented!() }
fn get_pr_reviews(&self, _o: &str, _r: &str, _n: u64) -> Result<ReviewSummary> { unimplemented!() }
fn get_pr_mergeability(&self, _o: &str, _r: &str, _n: u64) -> Result<PrMergeability> { unimplemented!() }
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<PrState> {
Ok(PrState { merged: false, state: "open".to_string() })
}
}
let segments = vec![
make_segment("auth", true),
make_segment("profile", false),
];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(
&GitHubWithMergedPr, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None,
).unwrap();
assert_eq!(plan.bookmarks_already_merged.len(), 1);
assert_eq!(plan.bookmarks_already_merged[0].bookmark.name, "auth");
assert_eq!(plan.bookmarks_already_merged[0].pr_number, 99);
assert_eq!(plan.bookmarks_needing_pr.len(), 1);
assert_eq!(plan.bookmarks_needing_pr[0].bookmark.name, "profile");
}
#[test]
fn test_plan_does_not_skip_closed_but_unmerged_prs() {
struct GitHubWithClosedPr;
impl Forge for GitHubWithClosedPr {
fn list_open_prs(&self, _o: &str, _r: &str) -> Result<Vec<PullRequest>> {
Ok(vec![])
}
fn find_merged_pr(&self, _o: &str, _r: &str, _head: &str) -> Result<Option<PullRequest>> {
Ok(None)
}
fn create_pr(&self, _o: &str, _r: &str, _t: &str, _b: &str, _h: &str, _ba: &str, _d: bool) -> Result<PullRequest> { unimplemented!() }
fn update_pr_base(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn request_reviewers(&self, _o: &str, _r: &str, _n: u64, _r2: &[String]) -> Result<()> { unimplemented!() }
fn list_comments(&self, _o: &str, _r: &str, _i: u64) -> Result<Vec<IssueComment>> { unimplemented!() }
fn create_comment(&self, _o: &str, _r: &str, _i: u64, _b: &str) -> Result<IssueComment> { unimplemented!() }
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { unimplemented!() }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { unimplemented!() }
fn get_authenticated_user(&self) -> Result<String> { Ok("test".to_string()) }
fn merge_pr(&self, _o: &str, _r: &str, _n: u64, _m: MergeMethod) -> Result<()> { unimplemented!() }
fn get_pr_checks_status(&self, _o: &str, _r: &str, _h: &str) -> Result<ChecksStatus> { unimplemented!() }
fn get_pr_reviews(&self, _o: &str, _r: &str, _n: u64) -> Result<ReviewSummary> { unimplemented!() }
fn get_pr_mergeability(&self, _o: &str, _r: &str, _n: u64) -> Result<PrMergeability> { unimplemented!() }
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<PrState> {
Ok(PrState { merged: false, state: "open".to_string() })
}
}
let segments = vec![make_segment("feature", false)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(
&GitHubWithClosedPr, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None,
).unwrap();
assert!(plan.bookmarks_already_merged.is_empty());
assert_eq!(plan.bookmarks_needing_pr.len(), 1, "should create a new PR");
}
#[test]
fn test_plan_merged_bookmark_not_pushed() {
struct GitHubWithMergedPr;
impl Forge for GitHubWithMergedPr {
fn list_open_prs(&self, _o: &str, _r: &str) -> Result<Vec<PullRequest>> {
Ok(vec![])
}
fn find_merged_pr(&self, _o: &str, _r: &str, head: &str) -> Result<Option<PullRequest>> {
if head == "auth" {
Ok(Some(PullRequest {
number: 99,
html_url: "https://github.com/o/r/pull/99".to_string(),
title: "Add auth".to_string(),
body: None,
base: PullRequestRef { ref_name: "main".to_string(), label: String::new(), sha: String::new() },
head: PullRequestRef { ref_name: "auth".to_string(), label: String::new(), sha: String::new() },
draft: false,
node_id: String::new(),
merged_at: Some("2024-01-01T00:00:00Z".to_string()),
requested_reviewers: vec![],
}))
} else {
Ok(None)
}
}
fn create_pr(&self, _o: &str, _r: &str, _t: &str, _b: &str, _h: &str, _ba: &str, _d: bool) -> Result<PullRequest> { unimplemented!() }
fn update_pr_base(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn request_reviewers(&self, _o: &str, _r: &str, _n: u64, _r2: &[String]) -> Result<()> { unimplemented!() }
fn list_comments(&self, _o: &str, _r: &str, _i: u64) -> Result<Vec<IssueComment>> { unimplemented!() }
fn create_comment(&self, _o: &str, _r: &str, _i: u64, _b: &str) -> Result<IssueComment> { unimplemented!() }
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { unimplemented!() }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { unimplemented!() }
fn get_authenticated_user(&self) -> Result<String> { Ok("test".to_string()) }
fn merge_pr(&self, _o: &str, _r: &str, _n: u64, _m: MergeMethod) -> Result<()> { unimplemented!() }
fn get_pr_checks_status(&self, _o: &str, _r: &str, _h: &str) -> Result<ChecksStatus> { unimplemented!() }
fn get_pr_reviews(&self, _o: &str, _r: &str, _n: u64) -> Result<ReviewSummary> { unimplemented!() }
fn get_pr_mergeability(&self, _o: &str, _r: &str, _n: u64) -> Result<PrMergeability> { unimplemented!() }
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<PrState> {
Ok(PrState { merged: false, state: "open".to_string() })
}
}
let segments = vec![make_segment("auth", false)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(
&GitHubWithMergedPr, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None,
).unwrap();
assert_eq!(plan.bookmarks_already_merged.len(), 1);
assert!(
plan.bookmarks_needing_push.is_empty(),
"merged bookmarks should not be pushed: {:?}",
plan.bookmarks_needing_push.iter().map(|b| &b.name).collect::<Vec<_>>()
);
}
#[test]
fn test_plan_no_title_drift_when_title_matches() {
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), make_pr("feature", "main"))]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(plan.bookmarks_with_title_drift.is_empty());
}
#[test]
fn test_plan_no_title_drift_for_multi_commit_segment() {
let mut pr = make_pr("feature", "main");
pr.title = "Manually curated title".to_string();
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let mut segment = make_segment("feature", true);
segment.changes.push(LogEntry {
commit_id: "c_extra".to_string(),
change_id: "ch_extra".to_string(),
author_name: "Test".to_string(),
author_email: "test@test.com".to_string(),
description: "Earlier commit".to_string(),
description_first_line: "Earlier commit".to_string(),
parents: vec![],
local_bookmarks: vec![],
remote_bookmarks: vec![],
is_working_copy: false,
conflict: false,
});
let segments = vec![segment];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(
plan.bookmarks_with_title_drift.is_empty(),
"multi-commit segments should not report title drift"
);
}
#[test]
fn test_plan_no_reviewers_tracked_when_empty() {
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), make_pr("feature", "main"))]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(plan.bookmarks_needing_reviewers.is_empty());
}
#[test]
fn test_plan_identifies_draft_prs_for_ready() {
let mut pr = make_pr("feature", "main");
pr.draft = true;
pr.node_id = "PR_kwDOxyz".to_string();
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(plan.bookmarks_needing_ready.is_empty());
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, true, &[], None).unwrap();
assert_eq!(plan.bookmarks_needing_ready.len(), 1);
assert_eq!(plan.bookmarks_needing_ready[0].pr_number, 1);
}
#[test]
fn test_plan_filters_fork_prs() {
let mut fork_pr = make_pr("feature", "main");
fork_pr.head.label = "someone-else:feature".to_string();
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), fork_pr)]),
};
let segments = vec![make_segment("feature", false)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert_eq!(plan.bookmarks_needing_pr.len(), 1);
assert!(plan.existing_prs.is_empty());
}
#[test]
fn test_plan_accepts_prs_with_empty_label() {
let mut pr = make_pr("feature", "main");
pr.head.label = String::new();
let gh = StubGitHub {
prs: HashMap::from([("feature".to_string(), pr)]),
};
let segments = vec![make_segment("feature", true)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
assert!(plan.bookmarks_needing_pr.is_empty());
assert_eq!(plan.existing_prs.len(), 1);
}
#[test]
fn test_plan_error_context_on_list_failure() {
struct FailingGitHub;
impl Forge for FailingGitHub {
fn list_open_prs(&self, _o: &str, _r: &str) -> Result<Vec<PullRequest>> {
anyhow::bail!("HTTP 401 Unauthorized")
}
fn create_pr(&self, _o: &str, _r: &str, _t: &str, _b: &str, _h: &str, _ba: &str, _d: bool) -> Result<PullRequest> { unimplemented!() }
fn update_pr_base(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn request_reviewers(&self, _o: &str, _r: &str, _n: u64, _r2: &[String]) -> Result<()> { unimplemented!() }
fn list_comments(&self, _o: &str, _r: &str, _i: u64) -> Result<Vec<IssueComment>> { unimplemented!() }
fn create_comment(&self, _o: &str, _r: &str, _i: u64, _b: &str) -> Result<IssueComment> { unimplemented!() }
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { unimplemented!() }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { unimplemented!() }
fn get_authenticated_user(&self) -> Result<String> { unimplemented!() }
fn find_merged_pr(&self, _o: &str, _r: &str, _h: &str) -> Result<Option<PullRequest>> { unimplemented!() }
fn merge_pr(&self, _o: &str, _r: &str, _n: u64, _m: MergeMethod) -> Result<()> { unimplemented!() }
fn get_pr_checks_status(&self, _o: &str, _r: &str, _h: &str) -> Result<ChecksStatus> { unimplemented!() }
fn get_pr_reviews(&self, _o: &str, _r: &str, _n: u64) -> Result<ReviewSummary> { unimplemented!() }
fn get_pr_mergeability(&self, _o: &str, _r: &str, _n: u64) -> Result<PrMergeability> { unimplemented!() }
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<PrState> {
Ok(PrState { merged: false, state: "open".to_string() })
}
}
let segments = vec![make_segment("feature", false)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let err = create_submission_plan(&FailingGitHub, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None)
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("jjpr auth test"), "error should hint at auth: {msg}");
}
#[test]
fn test_plan_warns_on_merged_check_failure() {
struct MergedCheckFailsGitHub;
impl Forge for MergedCheckFailsGitHub {
fn list_open_prs(&self, _o: &str, _r: &str) -> Result<Vec<PullRequest>> {
Ok(vec![])
}
fn create_pr(&self, _o: &str, _r: &str, _t: &str, _b: &str, _h: &str, _ba: &str, _d: bool) -> Result<PullRequest> { unimplemented!() }
fn update_pr_base(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn request_reviewers(&self, _o: &str, _r: &str, _n: u64, _r2: &[String]) -> Result<()> { unimplemented!() }
fn list_comments(&self, _o: &str, _r: &str, _i: u64) -> Result<Vec<IssueComment>> { unimplemented!() }
fn create_comment(&self, _o: &str, _r: &str, _i: u64, _b: &str) -> Result<IssueComment> { unimplemented!() }
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { unimplemented!() }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { unimplemented!() }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { unimplemented!() }
fn get_authenticated_user(&self) -> Result<String> { unimplemented!() }
fn find_merged_pr(&self, _o: &str, _r: &str, _h: &str) -> Result<Option<PullRequest>> {
anyhow::bail!("network timeout")
}
fn merge_pr(&self, _o: &str, _r: &str, _n: u64, _m: MergeMethod) -> Result<()> { unimplemented!() }
fn get_pr_checks_status(&self, _o: &str, _r: &str, _h: &str) -> Result<ChecksStatus> { unimplemented!() }
fn get_pr_reviews(&self, _o: &str, _r: &str, _n: u64) -> Result<ReviewSummary> { unimplemented!() }
fn get_pr_mergeability(&self, _o: &str, _r: &str, _n: u64) -> Result<PrMergeability> { unimplemented!() }
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<PrState> {
Ok(PrState { merged: false, state: "open".to_string() })
}
}
let segments = vec![make_segment("feature", false)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(
&MergedCheckFailsGitHub, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None,
).unwrap();
assert_eq!(plan.bookmarks_needing_pr.len(), 1);
assert!(plan.bookmarks_already_merged.is_empty());
}
#[test]
fn test_plan_uses_stack_base_for_first_pr() {
let gh = StubGitHub {
prs: HashMap::new(),
};
let segments = vec![
make_segment("auth", false),
make_segment("profile", false),
];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(
&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[],
Some("coworker-feat"),
).unwrap();
assert_eq!(plan.bookmarks_needing_pr[0].base_branch, "coworker-feat");
assert_eq!(plan.bookmarks_needing_pr[1].base_branch, "auth");
}
#[test]
fn test_plan_merge_note_in_pr_body() {
let gh = StubGitHub {
prs: HashMap::new(),
};
let mut segment = make_segment("merge-feat", false);
segment.merge_source_names = vec!["feat-d".to_string()];
let segments = vec![segment];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
let body = &plan.bookmarks_needing_pr[0].body;
assert!(body.contains("**Merge note:**"), "body should contain merge note: {body}");
assert!(body.contains("`feat-d`"), "body should reference the merge source: {body}");
}
#[test]
fn test_plan_no_merge_note_for_linear() {
let gh = StubGitHub {
prs: HashMap::new(),
};
let segments = vec![make_segment("feature", false)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None).unwrap();
let body = &plan.bookmarks_needing_pr[0].body;
assert!(!body.contains("Merge note"), "linear segment should have no merge note: {body}");
}
#[test]
fn test_plan_merge_note_three_parents() {
let note = generate_merge_note(&[
"feat-b".to_string(),
"feat-c".to_string(),
"feat-d".to_string(),
]);
assert!(note.contains("`feat-b`, `feat-c`, and `feat-d`"), "should format 3 sources: {note}");
assert!(note.contains("those PRs are"), "should use plural: {note}");
}
#[test]
fn test_generate_merge_note_single() {
let note = generate_merge_note(&["feat-x".to_string()]);
assert!(note.contains("`feat-x`"));
assert!(note.contains("that PR is"));
}
#[test]
fn test_generate_merge_note_two() {
let note = generate_merge_note(&["feat-a".to_string(), "feat-b".to_string()]);
assert!(note.contains("`feat-a` and `feat-b`"));
assert!(note.contains("those PRs are"));
}
#[test]
fn test_plan_falls_back_to_default_branch() {
let gh = StubGitHub {
prs: HashMap::new(),
};
let segments = vec![make_segment("feature", false)];
let repo = RepoInfo { owner: "o".to_string(), repo: "r".to_string() };
let plan = create_submission_plan(
&gh, &segments, "origin", &repo, ForgeKind::GitHub, "main", false, false, &[], None,
).unwrap();
assert_eq!(plan.bookmarks_needing_pr[0].base_branch, "main");
}
}