use std::collections::HashMap;
use anyhow::Result;
use crate::forge::comment::{self, StackEntry};
use crate::forge::types::PullRequest;
use crate::forge::Forge;
use crate::jj::Jj;
use super::plan::SubmissionPlan;
pub fn execute_submission_plan(
jj: &dyn Jj,
github: &dyn Forge,
plan: &SubmissionPlan,
reviewers: &[String],
dry_run: bool,
) -> Result<()> {
let owner = &plan.repo_info.owner;
let repo = &plan.repo_info.repo;
let fk = plan.forge_kind;
let mut completed_actions: Vec<String> = Vec::new();
for item in &plan.bookmarks_already_merged {
println!(
" Skipping '{}' — {} already merged",
item.bookmark.name, fk.format_ref(item.pr_number)
);
}
for bookmark in &plan.bookmarks_needing_push {
if dry_run {
println!(" Would push bookmark '{}' to {}", bookmark.name, plan.remote_name);
continue;
}
println!(" Pushing '{}'...", bookmark.name);
if let Err(e) = jj.push_bookmark(&bookmark.name, &plan.remote_name) {
report_partial_failure(&completed_actions);
return Err(e);
}
completed_actions.push(format!("Pushed '{}'", bookmark.name));
if let Some(pr) = plan.existing_prs.get(&bookmark.name) {
println!(" {}", pr.html_url);
}
}
let mut bookmark_to_pr: HashMap<String, PullRequest> = plan.existing_prs.clone();
for item in &plan.bookmarks_needing_pr {
if dry_run {
println!(
" Would create {} for '{}' (base: {})",
fk.request_abbreviation(), item.bookmark.name, item.base_branch
);
continue;
}
let label = if plan.draft { " (draft)" } else { "" };
println!(" Creating {}{label} for '{}'...", fk.request_abbreviation(), item.bookmark.name);
let pr = match github.create_pr(
owner,
repo,
&item.title,
&item.body,
&item.bookmark.name,
&item.base_branch,
plan.draft,
) {
Ok(pr) => pr,
Err(e) => {
report_partial_failure(&completed_actions);
return Err(e);
}
};
println!(" {}", pr.html_url);
completed_actions.push(format!("Created {} for '{}'", fk.format_ref(pr.number), item.bookmark.name));
if !reviewers.is_empty()
&& let Err(e) = github.request_reviewers(owner, repo, pr.number, reviewers)
{
report_partial_failure(&completed_actions);
return Err(e);
}
bookmark_to_pr.insert(item.bookmark.name.clone(), pr);
}
for item in &plan.bookmarks_needing_base_update {
if dry_run {
println!(
" Would update {} base: {} -> {}",
fk.format_ref(item.pr.number), item.pr.base.ref_name, item.expected_base
);
continue;
}
println!(
" Updating {} base to '{}'...",
fk.format_ref(item.pr.number), item.expected_base
);
if let Err(e) = github.update_pr_base(owner, repo, item.pr.number, &item.expected_base) {
report_partial_failure(&completed_actions);
return Err(e);
}
completed_actions.push(format!("Updated {} base to '{}'", fk.format_ref(item.pr.number), item.expected_base));
}
for item in &plan.bookmarks_needing_body_update {
if dry_run {
println!(
" Would update {} body for '{}'",
fk.format_ref(item.pr_number), item.bookmark.name
);
continue;
}
println!(
" Updating {} body for '{}'...",
fk.format_ref(item.pr_number), item.bookmark.name
);
if let Err(e) = github.update_pr_body(owner, repo, item.pr_number, &item.new_body) {
report_partial_failure(&completed_actions);
return Err(e);
}
completed_actions.push(format!("Updated {} body", fk.format_ref(item.pr_number)));
}
for item in &plan.bookmarks_needing_ready {
if dry_run {
println!(
" Would mark {} as ready for review ('{}')",
fk.format_ref(item.pr_number), item.bookmark.name
);
continue;
}
println!(
" Marking {} as ready for review ('{}')...",
fk.format_ref(item.pr_number), item.bookmark.name
);
if let Err(e) = github.mark_pr_ready(owner, repo, item.pr_number) {
report_partial_failure(&completed_actions);
return Err(e);
}
completed_actions.push(format!("Marked {} as ready", fk.format_ref(item.pr_number)));
}
for (bookmark, pr_number) in &plan.bookmarks_needing_reviewers {
let already_requested: &[String] = plan
.existing_prs
.get(&bookmark.name)
.map(|pr| pr.requested_reviewers.as_slice())
.unwrap_or_default();
if reviewers
.iter()
.all(|r| already_requested.iter().any(|a| a.eq_ignore_ascii_case(r)))
{
continue;
}
if dry_run {
println!(
" Would request reviewers on {} ('{}')",
fk.format_ref(*pr_number), bookmark.name
);
continue;
}
println!(
" Requesting reviewers on {}...",
fk.format_ref(*pr_number)
);
let mut all_reviewers: Vec<String> = already_requested.to_vec();
for r in reviewers {
if !all_reviewers.iter().any(|a| a.eq_ignore_ascii_case(r)) {
all_reviewers.push(r.clone());
}
}
if let Err(e) = github.request_reviewers(owner, repo, *pr_number, &all_reviewers) {
report_partial_failure(&completed_actions);
return Err(e);
}
completed_actions.push(format!("Requested reviewers on {}", fk.format_ref(*pr_number)));
}
let comments_updated = if dry_run {
println!(" Would update stack comments");
0
} else {
match update_stack_comments(github, plan, &bookmark_to_pr) {
Ok(n) => {
if n > 0 {
println!(" Updated stack comments on {n} {}.", if n == 1 { "PR" } else { "PRs" });
}
n
}
Err(e) => {
eprintln!(" Warning: failed to update stack comments: {e}");
eprintln!(" (run `jjpr submit` again to retry)");
0
}
}
};
print_title_drift_warnings(&plan.bookmarks_with_title_drift, &plan.repo_info, fk);
if !plan.has_actions() && plan.bookmarks_already_merged.is_empty() && comments_updated == 0 {
println!(" Stack is up to date.");
}
Ok(())
}
fn print_title_drift_warnings(
drifts: &[super::plan::TitleDrift],
repo_info: &crate::forge::types::RepoInfo,
forge_kind: crate::forge::ForgeKind,
) {
for drift in drifts {
let escaped_title = drift.expected_title.replace('\'', "'\\''");
let fix_hint = match forge_kind {
crate::forge::ForgeKind::GitHub | crate::forge::ForgeKind::Forgejo => format!(
"gh pr edit {} --repo {}/{} --title '{}'",
drift.pr_number, repo_info.owner, repo_info.repo, escaped_title,
),
crate::forge::ForgeKind::GitLab => format!(
"glab mr update {} --title '{}'",
drift.pr_number, escaped_title,
),
};
println!(
" Note: {} title differs from commit description\n\
\x20 current: \"{}\"\n\
\x20 expected: \"{}\"\n\
\x20 fix with: {fix_hint}",
forge_kind.format_ref(drift.pr_number),
drift.current_title,
drift.expected_title,
);
}
}
fn report_partial_failure(completed: &[String]) {
if !completed.is_empty() {
eprintln!("\nThe following actions completed before the error:");
for action in completed {
eprintln!(" - {action}");
}
eprintln!();
}
}
struct EntryData {
name: String,
url: Option<String>,
number: Option<u64>,
is_merged: bool,
}
fn merge_with_previous_entries(
current: &[EntryData],
previous: &[comment::StackCommentItem],
) -> Vec<EntryData> {
use std::collections::HashSet;
let current_by_name: HashMap<&str, &EntryData> = current
.iter()
.map(|e| (e.name.as_str(), e))
.collect();
let mut seen: HashSet<&str> = HashSet::new();
let mut result = Vec::new();
for prev in previous {
seen.insert(&prev.bookmark_name);
if let Some(cur) = current_by_name.get(prev.bookmark_name.as_str()) {
result.push(EntryData {
name: cur.name.clone(),
url: cur.url.clone(),
number: cur.number,
is_merged: cur.is_merged,
});
} else {
result.push(EntryData {
name: prev.bookmark_name.clone(),
url: Some(prev.pr_url.clone()),
number: Some(prev.pr_number),
is_merged: true,
});
}
}
for cur in current {
if !seen.contains(cur.name.as_str()) {
result.push(EntryData {
name: cur.name.clone(),
url: cur.url.clone(),
number: cur.number,
is_merged: cur.is_merged,
});
}
}
result
}
pub(crate) fn update_stack_comments(
github: &dyn Forge,
plan: &SubmissionPlan,
bookmark_to_pr: &HashMap<String, PullRequest>,
) -> Result<usize> {
let owner = &plan.repo_info.owner;
let repo = &plan.repo_info.repo;
let mut updated = 0;
let stack_bookmark_count = plan
.all_bookmarks
.iter()
.filter(|b| b.name != plan.default_branch)
.count();
let merged_prs: HashMap<&str, &super::plan::MergedBookmark> = plan
.bookmarks_already_merged
.iter()
.map(|m| (m.bookmark.name.as_str(), m))
.collect();
let current_entries: Vec<EntryData> = plan
.all_bookmarks
.iter()
.filter(|b| b.name != plan.default_branch)
.map(|b| {
if let Some(pr) = bookmark_to_pr.get(&b.name) {
EntryData {
name: b.name.clone(),
url: Some(pr.html_url.clone()),
number: Some(pr.number),
is_merged: false,
}
} else if let Some(merged) = merged_prs.get(b.name.as_str()) {
EntryData {
name: b.name.clone(),
url: Some(merged.html_url.clone()),
number: Some(merged.pr_number),
is_merged: true,
}
} else {
EntryData {
name: b.name.clone(),
url: None,
number: None,
is_merged: false,
}
}
})
.collect();
for bookmark in plan.all_bookmarks.iter().filter(|b| b.name != plan.default_branch) {
let Some(pr) = bookmark_to_pr.get(&bookmark.name) else {
continue;
};
let comments = github.list_comments(owner, repo, pr.number)?;
let existing = comment::find_stack_comment(&comments);
if stack_bookmark_count <= 1 && existing.is_none() {
continue;
}
let previous_items: Vec<comment::StackCommentItem> = existing
.and_then(|c| c.body.as_deref())
.and_then(comment::parse_comment_data)
.map(|d| d.stack)
.unwrap_or_default();
let merged = merge_with_previous_entries(¤t_entries, &previous_items);
let entries: Vec<StackEntry> = merged
.iter()
.map(|e| StackEntry {
bookmark_name: e.name.clone(),
pr_url: e.url.clone(),
pr_number: e.number,
is_current: e.name == bookmark.name,
is_merged: e.is_merged,
})
.collect();
let body = comment::generate_comment_body(&entries);
if let Some(existing_comment) = existing {
if existing_comment.body.as_deref() != Some(&body) {
github.update_comment(owner, repo, existing_comment.id, &body)?;
updated += 1;
}
} else {
github.create_comment(owner, repo, pr.number, &body)?;
updated += 1;
}
}
Ok(updated)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::forge::ForgeKind;
use crate::forge::types::{ChecksStatus, IssueComment, MergeMethod, PrMergeability, PrState, PullRequestRef, RepoInfo, ReviewSummary};
use crate::jj::types::{Bookmark, GitRemote, LogEntry};
use crate::jj::Jj;
struct RecordingGitHub {
calls: Mutex<Vec<String>>,
}
impl RecordingGitHub {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
}
}
fn calls(&self) -> Vec<String> {
self.calls.lock().expect("poisoned").clone()
}
}
impl Forge for RecordingGitHub {
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,
head: &str,
base: &str,
draft: bool,
) -> Result<PullRequest> {
let label = if draft { "create_draft_pr" } else { "create_pr" };
self.calls
.lock().expect("poisoned")
.push(format!("{label}:{head}:{base}"));
Ok(PullRequest {
number: 42,
html_url: "https://github.com/o/r/pull/42".to_string(),
title: "test".to_string(),
body: None,
base: PullRequestRef {
ref_name: base.to_string(),
label: String::new(),
sha: String::new(),
},
head: PullRequestRef {
ref_name: head.to_string(),
label: String::new(),
sha: String::new(),
},
draft,
node_id: "PR_node123".to_string(),
merged_at: None,
requested_reviewers: vec![],
})
}
fn update_pr_base(&self, _o: &str, _r: &str, n: u64, base: &str) -> Result<()> {
self.calls
.lock().expect("poisoned")
.push(format!("update_base:#{n}:{base}"));
Ok(())
}
fn request_reviewers(
&self,
_o: &str,
_r: &str,
n: u64,
revs: &[String],
) -> Result<()> {
self.calls
.lock().expect("poisoned")
.push(format!("request_reviewers:#{n}:{}", revs.join(",")));
Ok(())
}
fn list_comments(&self, _o: &str, _r: &str, _i: u64) -> Result<Vec<IssueComment>> {
Ok(vec![])
}
fn create_comment(
&self,
_o: &str,
_r: &str,
number: u64,
_b: &str,
) -> Result<IssueComment> {
self.calls
.lock().expect("poisoned")
.push(format!("create_comment:#{number}"));
Ok(IssueComment {
id: 100,
body: Some("comment".to_string()),
})
}
fn update_comment(&self, _o: &str, _r: &str, id: u64, _b: &str) -> Result<()> {
self.calls
.lock().expect("poisoned")
.push(format!("update_comment:{id}"));
Ok(())
}
fn update_pr_body(&self, _o: &str, _r: &str, n: u64, _body: &str) -> Result<()> {
self.calls
.lock().expect("poisoned")
.push(format!("update_pr_body:#{n}"));
Ok(())
}
fn mark_pr_ready(&self, _o: &str, _r: &str, number: u64) -> Result<()> {
self.calls
.lock().expect("poisoned")
.push(format!("mark_pr_ready:#{number}"));
Ok(())
}
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() })
}
}
struct RecordingJj {
pushes: Mutex<Vec<String>>,
}
impl RecordingJj {
fn new() -> Self {
Self {
pushes: Mutex::new(Vec::new()),
}
}
fn pushes(&self) -> Vec<String> {
self.pushes.lock().expect("poisoned").clone()
}
}
impl Jj for RecordingJj {
fn git_fetch(&self) -> Result<()> {
Ok(())
}
fn get_my_bookmarks(&self) -> Result<Vec<Bookmark>> {
Ok(vec![])
}
fn get_changes_to_commit(&self, _to: &str) -> Result<Vec<LogEntry>> {
Ok(vec![])
}
fn get_git_remotes(&self) -> Result<Vec<GitRemote>> {
Ok(vec![])
}
fn get_default_branch(&self) -> Result<String> {
Ok("main".to_string())
}
fn push_bookmark(&self, name: &str, remote: &str) -> Result<()> {
self.pushes.lock().expect("poisoned").push(format!("{name}:{remote}"));
Ok(())
}
fn get_working_copy_commit_id(&self) -> Result<String> {
Ok("wc_commit".to_string())
}
fn rebase_onto(&self, _source: &str, _dest: &str) -> Result<()> { unimplemented!() }
fn merge_into(&self, _bookmark: &str, _dest: &str) -> Result<()> { unimplemented!() }
fn resolve_change_id(&self, _change_id: &str) -> Result<Vec<String>> {
Ok(vec!["dummy_commit_id".to_string()])
}
fn is_conflicted(&self, _revset: &str) -> Result<bool> { Ok(false) }
}
fn make_bookmark(name: &str) -> Bookmark {
Bookmark {
name: name.to_string(),
commit_id: format!("c_{name}"),
change_id: format!("ch_{name}"),
has_remote: false,
is_synced: false,
}
}
fn make_plan() -> SubmissionPlan {
SubmissionPlan {
bookmarks_needing_push: vec![make_bookmark("auth")],
bookmarks_needing_pr: vec![super::super::plan::BookmarkNeedingPr {
bookmark: make_bookmark("auth"),
base_branch: "main".to_string(),
title: "Add auth".to_string(),
body: "Auth body".to_string(),
}],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
},
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
}
}
#[test]
fn test_dry_run_produces_no_side_effects() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = make_plan();
execute_submission_plan(&jj, &github, &plan, &[], true).unwrap();
assert!(jj.pushes().is_empty(), "dry run should not push");
assert!(
github.calls().is_empty(),
"dry run should not call GitHub API"
);
}
#[test]
fn test_creates_pr_with_correct_base() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = make_plan();
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
assert_eq!(jj.pushes(), vec!["auth:origin"]);
assert!(github.calls().iter().any(|c| c == "create_pr:auth:main"));
}
#[test]
fn test_requests_reviewers_on_new_prs() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = make_plan();
let reviewers = vec!["alice".to_string(), "bob".to_string()];
execute_submission_plan(&jj, &github, &plan, &reviewers, false).unwrap();
assert!(github
.calls()
.iter()
.any(|c| c == "request_reviewers:#42:alice,bob"));
}
#[test]
fn test_no_reviewers_when_list_empty() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = make_plan();
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
assert!(
!github
.calls()
.iter()
.any(|c| c.starts_with("request_reviewers")),
"should not request reviewers when list is empty"
);
}
#[test]
fn test_single_pr_skips_stack_comment() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = make_plan();
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
assert!(
!github
.calls()
.iter()
.any(|c| c.starts_with("create_comment")),
"single-PR stack should not get a stack comment"
);
}
#[test]
fn test_two_prs_creates_stack_comments() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let mut plan = make_plan();
plan.all_bookmarks.push(make_bookmark("profile"));
plan.bookmarks_needing_pr.push(super::super::plan::BookmarkNeedingPr {
bookmark: make_bookmark("profile"),
base_branch: "auth".to_string(),
title: "Add profile".to_string(),
body: "Profile body".to_string(),
});
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
let comment_calls: Vec<_> = github
.calls()
.iter()
.filter(|c| c.starts_with("create_comment"))
.cloned()
.collect();
assert_eq!(
comment_calls.len(),
2,
"two-PR stack should get comments on both PRs: {comment_calls:?}"
);
}
#[test]
fn test_updates_existing_stack_comment() {
let jj = RecordingJj::new();
struct GitHubWithExistingComment {
calls: Mutex<Vec<String>>,
}
impl Forge for GitHubWithExistingComment {
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, _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>> {
Ok(vec![IssueComment {
id: 99,
body: Some("<!-- jjpr:stack-info -->\nold comment".to_string()),
}])
}
fn create_comment(
&self,
_o: &str,
_r: &str,
_i: u64,
_b: &str,
) -> Result<IssueComment> {
panic!("should update, not create");
}
fn update_comment(&self, _o: &str, _r: &str, id: u64, _b: &str) -> Result<()> {
self.calls
.lock().expect("poisoned")
.push(format!("update_comment:{id}"));
Ok(())
}
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> {
Ok(())
}
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> {
Ok(())
}
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() })
}
}
let github = GitHubWithExistingComment {
calls: Mutex::new(Vec::new()),
};
let existing_pr = PullRequest {
number: 10,
html_url: "https://github.com/o/r/pull/10".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: None,
requested_reviewers: vec![],
};
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::from([("auth".to_string(), existing_pr)]),
remote_name: "origin".to_string(),
repo_info: RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
},
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
let calls = github.calls.lock().expect("poisoned");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], "update_comment:99");
}
#[test]
fn test_updates_pr_base() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let existing_pr = PullRequest {
number: 5,
html_url: "https://github.com/o/r/pull/5".to_string(),
title: "profile".to_string(),
body: None,
base: PullRequestRef { ref_name: "main".to_string(), label: String::new(), sha: String::new() },
head: PullRequestRef { ref_name: "profile".to_string(), label: String::new(), sha: String::new() },
draft: false,
node_id: String::new(),
merged_at: None,
requested_reviewers: vec![],
};
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![super::super::plan::BookmarkNeedingBaseUpdate {
bookmark: make_bookmark("profile"),
pr: existing_pr.clone(),
expected_base: "auth".to_string(),
}],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::from([("profile".to_string(), existing_pr)]),
remote_name: "origin".to_string(),
repo_info: RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
},
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("profile")],
default_branch: "main".to_string(),
draft: false,
};
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
assert!(github.calls().iter().any(|c| c == "update_base:#5:auth"));
}
#[test]
fn test_execute_updates_pr_body() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![super::super::plan::BookmarkNeedingBodyUpdate {
bookmark: make_bookmark("auth"),
pr_number: 10,
new_body: "Updated body".to_string(),
}],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::from([(
"auth".to_string(),
PullRequest {
number: 10,
html_url: "https://github.com/o/r/pull/10".to_string(),
title: "Old title".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: None,
requested_reviewers: vec![],
},
)]),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
assert!(
github.calls().iter().any(|c| c == "update_pr_body:#10"),
"should call update_pr_body"
);
}
#[test]
fn test_dry_run_skips_body_update() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![super::super::plan::BookmarkNeedingBodyUpdate {
bookmark: make_bookmark("auth"),
pr_number: 10,
new_body: "Updated body".to_string(),
}],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
execute_submission_plan(&jj, &github, &plan, &[], true).unwrap();
assert!(
!github.calls().iter().any(|c| c.starts_with("update_pr_body")),
"dry run should not call update_pr_body"
);
}
#[test]
fn test_create_pr_as_draft() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let mut plan = make_plan();
plan.draft = true;
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
assert!(
github.calls().iter().any(|c| c.starts_with("create_draft_pr:")),
"should pass draft=true to create_pr: {:?}",
github.calls()
);
}
#[test]
fn test_ready_converts_draft_prs() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![super::super::plan::BookmarkNeedingReady {
bookmark: make_bookmark("auth"),
pr_number: 10,
}],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
assert!(
github.calls().iter().any(|c| c == "mark_pr_ready:#10"),
"should call mark_pr_ready: {:?}",
github.calls()
);
}
#[test]
fn test_requests_reviewers_on_existing_prs() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![(make_bookmark("auth"), 10)],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
let reviewers = vec!["alice".to_string()];
execute_submission_plan(&jj, &github, &plan, &reviewers, false).unwrap();
assert!(
github.calls().iter().any(|c| c == "request_reviewers:#10:alice"),
"should request reviewers on existing PRs: {:?}",
github.calls()
);
}
#[test]
fn test_skips_already_requested_reviewers() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let existing_pr = PullRequest {
number: 10,
html_url: "https://github.com/o/r/pull/10".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: None,
requested_reviewers: vec!["alice".to_string()],
};
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![(make_bookmark("auth"), 10)],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::from([("auth".to_string(), existing_pr)]),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
let reviewers = vec!["alice".to_string(), "bob".to_string()];
execute_submission_plan(&jj, &github, &plan, &reviewers, false).unwrap();
assert!(
github.calls().iter().any(|c| c == "request_reviewers:#10:alice,bob"),
"should pass full reviewer set (existing + new) to forge: {:?}",
github.calls()
);
}
#[test]
fn test_skips_reviewer_request_when_all_already_requested() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let existing_pr = PullRequest {
number: 10,
html_url: "https://github.com/o/r/pull/10".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: None,
requested_reviewers: vec!["alice".to_string(), "bob".to_string()],
};
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![(make_bookmark("auth"), 10)],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::from([("auth".to_string(), existing_pr)]),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
let reviewers = vec!["alice".to_string(), "bob".to_string()];
execute_submission_plan(&jj, &github, &plan, &reviewers, false).unwrap();
assert!(
!github.calls().iter().any(|c| c.starts_with("request_reviewers")),
"should not request reviewers when all already requested: {:?}",
github.calls()
);
}
#[test]
fn test_skips_reviewer_case_insensitive() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let existing_pr = PullRequest {
number: 10,
html_url: "https://github.com/o/r/pull/10".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: None,
requested_reviewers: vec!["Alice".to_string()],
};
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![(make_bookmark("auth"), 10)],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::from([("auth".to_string(), existing_pr)]),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
let reviewers = vec!["alice".to_string()];
execute_submission_plan(&jj, &github, &plan, &reviewers, false).unwrap();
assert!(
!github.calls().iter().any(|c| c.starts_with("request_reviewers")),
"should match reviewers case-insensitively: {:?}",
github.calls()
);
}
#[test]
fn test_partial_failure_reports_completed_actions() {
struct FailingJj;
impl Jj for FailingJj {
fn git_fetch(&self) -> Result<()> { Ok(()) }
fn get_my_bookmarks(&self) -> Result<Vec<Bookmark>> { Ok(vec![]) }
fn get_changes_to_commit(&self, _to: &str) -> Result<Vec<LogEntry>> { Ok(vec![]) }
fn get_git_remotes(&self) -> Result<Vec<GitRemote>> { Ok(vec![]) }
fn get_default_branch(&self) -> Result<String> { Ok("main".to_string()) }
fn push_bookmark(&self, name: &str, _remote: &str) -> Result<()> {
if name == "profile" {
anyhow::bail!("push failed for profile")
}
Ok(())
}
fn get_working_copy_commit_id(&self) -> Result<String> { Ok("wc".to_string()) }
fn rebase_onto(&self, _source: &str, _dest: &str) -> Result<()> { unimplemented!() }
fn merge_into(&self, _bookmark: &str, _dest: &str) -> Result<()> { unimplemented!() }
fn resolve_change_id(&self, _change_id: &str) -> Result<Vec<String>> {
Ok(vec!["dummy_commit_id".to_string()])
}
fn is_conflicted(&self, _revset: &str) -> Result<bool> { Ok(false) }
}
let github = RecordingGitHub::new();
let plan = SubmissionPlan {
bookmarks_needing_push: vec![make_bookmark("auth"), make_bookmark("profile")],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth"), make_bookmark("profile")],
default_branch: "main".to_string(),
draft: false,
};
let err = execute_submission_plan(&FailingJj, &github, &plan, &[], false).unwrap_err();
assert!(err.to_string().contains("push failed for profile"));
}
#[test]
fn test_dry_run_skips_reviewer_requests_on_existing() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![(make_bookmark("auth"), 10)],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
let reviewers = vec!["alice".to_string()];
execute_submission_plan(&jj, &github, &plan, &reviewers, true).unwrap();
assert!(
github.calls().is_empty(),
"dry run should not call any GitHub API: {:?}",
github.calls()
);
}
#[test]
fn test_noop_plan_succeeds_without_api_calls() {
let jj = RecordingJj::new();
let github = RecordingGitHub::new();
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
assert!(jj.pushes().is_empty());
assert!(github.calls().is_empty());
}
#[test]
fn test_has_actions_empty_plan() {
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![],
default_branch: "main".to_string(),
draft: false,
};
assert!(!plan.has_actions());
}
#[test]
fn test_has_actions_with_push() {
let plan = SubmissionPlan {
bookmarks_needing_push: vec![make_bookmark("auth")],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::new(),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![],
default_branch: "main".to_string(),
draft: false,
};
assert!(plan.has_actions());
}
#[test]
fn test_stack_comment_excludes_default_branch() {
let jj = RecordingJj::new();
struct CapturingGitHub {
calls: Mutex<Vec<String>>,
comment_bodies: Mutex<Vec<String>>,
}
impl Forge for CapturingGitHub {
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, _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>> { Ok(vec![]) }
fn create_comment(&self, _o: &str, _r: &str, number: u64, body: &str) -> Result<IssueComment> {
self.calls.lock().expect("poisoned").push(format!("create_comment:#{number}"));
self.comment_bodies.lock().expect("poisoned").push(body.to_string());
Ok(IssueComment { id: 100, body: Some(body.to_string()) })
}
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { Ok(()) }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { Ok(()) }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { Ok(()) }
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() })
}
}
let github = CapturingGitHub {
calls: Mutex::new(Vec::new()),
comment_bodies: Mutex::new(Vec::new()),
};
let auth_pr = PullRequest {
number: 1,
html_url: "https://github.com/o/r/pull/1".to_string(),
title: "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: None,
requested_reviewers: vec![],
};
let profile_pr = PullRequest {
number: 2,
html_url: "https://github.com/o/r/pull/2".to_string(),
title: "profile".to_string(),
body: None,
base: PullRequestRef { ref_name: "auth".to_string(), label: String::new(), sha: String::new() },
head: PullRequestRef { ref_name: "profile".to_string(), label: String::new(), sha: String::new() },
draft: false,
node_id: String::new(),
merged_at: None,
requested_reviewers: vec![],
};
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::from([
("auth".to_string(), auth_pr),
("profile".to_string(), profile_pr),
]),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("main"), make_bookmark("auth"), make_bookmark("profile")],
default_branch: "main".to_string(),
draft: false,
};
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
let calls = github.calls.lock().expect("poisoned");
assert_eq!(calls.len(), 2, "should create exactly two comments: {calls:?}");
assert!(calls.contains(&"create_comment:#1".to_string()));
assert!(calls.contains(&"create_comment:#2".to_string()));
let bodies = github.comment_bodies.lock().expect("poisoned");
for body in bodies.iter() {
assert!(!body.contains("`main`"), "comment should not contain main: {body}");
}
}
#[test]
fn test_merged_pr_links_preserved_in_stack_comments() {
let jj = RecordingJj::new();
struct CapturingGitHub {
comment_bodies: Mutex<Vec<String>>,
}
impl Forge for CapturingGitHub {
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, _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>> { Ok(vec![]) }
fn create_comment(&self, _o: &str, _r: &str, _number: u64, body: &str) -> Result<IssueComment> {
self.comment_bodies.lock().expect("poisoned").push(body.to_string());
Ok(IssueComment { id: 100, body: Some(body.to_string()) })
}
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { Ok(()) }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { Ok(()) }
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> { Ok(()) }
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() })
}
}
let github = CapturingGitHub {
comment_bodies: Mutex::new(Vec::new()),
};
let profile_pr = PullRequest {
number: 2,
html_url: "https://github.com/o/r/pull/2".to_string(),
title: "profile".to_string(),
body: None,
base: PullRequestRef { ref_name: "auth".to_string(), label: String::new(), sha: String::new() },
head: PullRequestRef { ref_name: "profile".to_string(), label: String::new(), sha: String::new() },
draft: false,
node_id: String::new(),
merged_at: None,
requested_reviewers: vec![],
};
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![super::super::plan::MergedBookmark {
bookmark: make_bookmark("auth"),
pr_number: 1,
html_url: "https://github.com/o/r/pull/1".to_string(),
}],
existing_prs: HashMap::from([("profile".to_string(), profile_pr)]),
remote_name: "origin".to_string(),
repo_info: RepoInfo { owner: "o".to_string(), repo: "r".to_string() },
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth"), make_bookmark("profile")],
default_branch: "main".to_string(),
draft: false,
};
execute_submission_plan(&jj, &github, &plan, &[], false).unwrap();
let bodies = github.comment_bodies.lock().expect("poisoned");
assert_eq!(bodies.len(), 1, "should create comment on profile PR");
assert!(
bodies[0].contains("pull/1"),
"comment should contain link to merged auth PR #1: {}",
bodies[0]
);
assert!(
bodies[0].contains("`auth`"),
"comment should mention auth bookmark: {}",
bodies[0]
);
}
#[test]
fn test_title_drift_escapes_single_quotes() {
let title = "Fix the user's login";
let escaped = title.replace('\'', "'\\''");
assert_eq!(escaped, "Fix the user'\\''s login");
}
#[test]
fn test_title_drift_shell_metacharacters() {
let title = "Fix $(echo pwned) `rm -rf` $HOME";
let escaped = title.replace('\'', "'\\''");
assert_eq!(escaped, title);
let hint = format!("gh pr edit 42 --title '{escaped}'");
assert!(hint.contains("'Fix $(echo pwned) `rm -rf` $HOME'"));
}
#[test]
fn test_comment_failure_does_not_abort() {
let jj = RecordingJj::new();
struct CommentFailsGitHub;
impl Forge for CommentFailsGitHub {
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, _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>> {
anyhow::bail!("GitHub API rate limited")
}
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<()> {
Ok(())
}
fn mark_pr_ready(&self, _o: &str, _r: &str, _n: u64) -> Result<()> {
Ok(())
}
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() })
}
}
let existing_pr = PullRequest {
number: 10,
html_url: "https://github.com/o/r/pull/10".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: None,
requested_reviewers: vec![],
};
let plan = SubmissionPlan {
bookmarks_needing_push: vec![],
bookmarks_needing_pr: vec![],
bookmarks_needing_base_update: vec![],
bookmarks_needing_body_update: vec![],
bookmarks_needing_ready: vec![],
bookmarks_needing_reviewers: vec![],
bookmarks_with_title_drift: vec![],
bookmarks_already_merged: vec![],
existing_prs: HashMap::from([("auth".to_string(), existing_pr)]),
remote_name: "origin".to_string(),
repo_info: RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
},
forge_kind: ForgeKind::GitHub,
all_bookmarks: vec![make_bookmark("auth")],
default_branch: "main".to_string(),
draft: false,
};
let result = execute_submission_plan(&jj, &CommentFailsGitHub, &plan, &[], false);
assert!(result.is_ok(), "comment failure should not abort: {result:?}");
}
#[test]
fn test_merge_previous_entries_preserves_removed() {
let current = vec![
EntryData { name: "B".into(), url: Some("url_b".into()), number: Some(2), is_merged: false },
EntryData { name: "C".into(), url: Some("url_c".into()), number: Some(3), is_merged: false },
];
let previous = vec![
comment::StackCommentItem { bookmark_name: "A".into(), pr_url: "url_a".into(), pr_number: 1, is_merged: false },
comment::StackCommentItem { bookmark_name: "B".into(), pr_url: "url_b".into(), pr_number: 2, is_merged: false },
comment::StackCommentItem { bookmark_name: "C".into(), pr_url: "url_c".into(), pr_number: 3, is_merged: false },
];
let result = merge_with_previous_entries(¤t, &previous);
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "A");
assert!(result[0].is_merged, "A should be marked merged");
assert_eq!(result[1].name, "B");
assert!(!result[1].is_merged);
assert_eq!(result[2].name, "C");
assert!(!result[2].is_merged);
}
#[test]
fn test_merge_previous_entries_appends_new() {
let current = vec![
EntryData { name: "B".into(), url: Some("url_b".into()), number: Some(2), is_merged: false },
EntryData { name: "C".into(), url: Some("url_c".into()), number: Some(3), is_merged: false },
];
let previous = vec![
comment::StackCommentItem { bookmark_name: "A".into(), pr_url: "url_a".into(), pr_number: 1, is_merged: false },
comment::StackCommentItem { bookmark_name: "B".into(), pr_url: "url_b".into(), pr_number: 2, is_merged: false },
];
let result = merge_with_previous_entries(¤t, &previous);
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "A");
assert!(result[0].is_merged);
assert_eq!(result[1].name, "B");
assert!(!result[1].is_merged);
assert_eq!(result[2].name, "C");
assert!(!result[2].is_merged);
}
#[test]
fn test_merge_previous_entries_empty_previous() {
let current = vec![
EntryData { name: "A".into(), url: Some("url_a".into()), number: Some(1), is_merged: false },
EntryData { name: "B".into(), url: Some("url_b".into()), number: Some(2), is_merged: false },
];
let result = merge_with_previous_entries(¤t, &[]);
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "A");
assert_eq!(result[1].name, "B");
}
#[test]
fn test_merge_previous_entries_current_takes_precedence() {
let current = vec![
EntryData { name: "B".into(), url: Some("new_url_b".into()), number: Some(22), is_merged: false },
];
let previous = vec![
comment::StackCommentItem { bookmark_name: "A".into(), pr_url: "url_a".into(), pr_number: 1, is_merged: false },
comment::StackCommentItem { bookmark_name: "B".into(), pr_url: "old_url_b".into(), pr_number: 2, is_merged: false },
];
let result = merge_with_previous_entries(¤t, &previous);
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "A");
assert!(result[0].is_merged);
assert_eq!(result[1].name, "B");
assert_eq!(result[1].url.as_deref(), Some("new_url_b"));
assert_eq!(result[1].number, Some(22));
assert!(!result[1].is_merged);
}
#[test]
fn test_merge_previous_entries_no_change() {
let current = vec![
EntryData { name: "A".into(), url: Some("url_a".into()), number: Some(1), is_merged: false },
EntryData { name: "B".into(), url: Some("url_b".into()), number: Some(2), is_merged: false },
];
let previous = vec![
comment::StackCommentItem { bookmark_name: "A".into(), pr_url: "url_a".into(), pr_number: 1, is_merged: false },
comment::StackCommentItem { bookmark_name: "B".into(), pr_url: "url_b".into(), pr_number: 2, is_merged: false },
];
let result = merge_with_previous_entries(¤t, &previous);
assert_eq!(result.len(), 2);
assert!(!result[0].is_merged);
assert!(!result[1].is_merged);
}
}