use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::time::Instant;
use anyhow::Result;
use crate::forge::types::{ChecksStatus, PullRequest, RepoInfo};
use crate::forge::{Forge, ForgeKind};
use crate::graph::change_graph;
use crate::jj::types::NarrowedSegment;
use crate::jj::Jj;
use crate::merge::execute::{
format_block_reason, merge_with_retry, reconcile_after_merge, BlockedPr,
LocalDivergenceWarning, MergeResult, MergedPr, SkippedMergedPr,
};
use crate::merge::plan::{evaluate_segment, BlockReason, MergeOptions, PrMergeStatus};
use crate::merge::watch::{
interruptible_sleep, local_time_hhmm, refresh_pr_map, report_status_changes,
WatchOptions, HEARTBEAT_INTERVAL, MAX_CONSECUTIVE_ERRORS,
};
use crate::submit::{analyze, plan, execute, resolve};
#[derive(Debug)]
pub struct CreatedPr {
pub bookmark_name: String,
pub pr_number: u64,
}
#[derive(Debug)]
pub struct PromotedPr {
pub bookmark_name: String,
pub pr_number: u64,
}
#[derive(Debug)]
pub struct WatchResult {
pub prs_created: Vec<CreatedPr>,
pub prs_promoted: Vec<PromotedPr>,
pub merge_result: MergeResult,
}
fn promote_ready_drafts(
forge: &dyn Forge,
segments: &[NarrowedSegment],
pr_map: &HashMap<String, PullRequest>,
repo_info: &RepoInfo,
fk: ForgeKind,
) -> Vec<PromotedPr> {
let mut promoted = Vec::new();
let owner = &repo_info.owner;
let repo = &repo_info.repo;
for seg in segments {
let Some(pr) = pr_map.get(&seg.bookmark.name) else {
continue;
};
if !pr.draft {
continue;
}
let checks_ref = if pr.head.sha.is_empty() {
&pr.head.ref_name
} else {
&pr.head.sha
};
let Ok(status) = forge.get_pr_checks_status(owner, repo, checks_ref) else {
continue;
};
if status == ChecksStatus::Pass {
if let Err(e) = forge.mark_pr_ready(owner, repo, pr.number) {
eprintln!(
" Warning: failed to mark {} as ready: {e}",
fk.format_ref(pr.number)
);
continue;
}
println!(
" Marked '{}' as ready (CI passing)",
seg.bookmark.name
);
promoted.push(PromotedPr {
bookmark_name: seg.bookmark.name.clone(),
pr_number: pr.number,
});
}
}
promoted
}
fn reviewer_hint(
pr: Option<&PullRequest>,
reasons: &[BlockReason],
bookmark_name: &str,
fk: ForgeKind,
) -> Option<String> {
let pr = pr?;
if !reasons.iter().any(|r| matches!(r, BlockReason::InsufficientApprovals { .. })) {
return None;
}
if !pr.requested_reviewers.is_empty() {
return None;
}
Some(format!(
"\n '{}' ({}): needs review approval but has no reviewers\n\
\x20 hint: run `jjpr submit --reviewer <username>` to request reviewers",
bookmark_name,
fk.format_ref(pr.number),
))
}
fn make_merge_plan(
repo_info: &RepoInfo,
forge_kind: ForgeKind,
default_branch: &str,
remote_name: &str,
options: &MergeOptions,
stack_base: Option<&str>,
) -> crate::merge::plan::MergePlan {
crate::merge::plan::MergePlan {
actions: vec![],
repo_info: repo_info.clone(),
forge_kind,
default_branch: default_branch.to_string(),
remote_name: remote_name.to_string(),
options: options.clone(),
stack_base: stack_base.map(|s| s.to_string()),
}
}
struct MergePhaseOutcome {
merged: Vec<MergedPr>,
skipped: Vec<SkippedMergedPr>,
blocked: Option<BlockedPr>,
all_done: bool,
}
#[allow(clippy::too_many_arguments)]
fn run_merge_phase(
jj: &dyn Jj,
forge: &dyn Forge,
segments: &[NarrowedSegment],
pr_map: &HashMap<String, PullRequest>,
merge_options: &MergeOptions,
merge_plan: &crate::merge::plan::MergePlan,
forge_kind: ForgeKind,
prev_reasons: &mut Option<Vec<BlockReason>>,
consecutive_errors: &mut u32,
last_heartbeat: &mut Instant,
local_degraded: &mut bool,
local_warnings: &mut Vec<LocalDivergenceWarning>,
) -> Result<MergePhaseOutcome> {
let owner = &merge_plan.repo_info.owner;
let repo = &merge_plan.repo_info.repo;
let mut pr_map = pr_map.clone();
let mut merged = Vec::new();
let mut skipped = Vec::new();
let mut seg_idx = 0;
let mut advanced = false;
while seg_idx < segments.len() {
let segment = &segments[seg_idx];
let status = match evaluate_segment(
forge,
&segment.bookmark.name,
&merge_plan.repo_info,
&pr_map,
merge_options,
) {
Ok(s) => s,
Err(e) => {
*consecutive_errors += 1;
let now = local_time_hhmm();
eprintln!(" [{now}] Eval error ({consecutive_errors}/{MAX_CONSECUTIVE_ERRORS}): {e}");
break;
}
};
*consecutive_errors = 0;
let prev_seg_idx = seg_idx;
match status {
PrMergeStatus::AlreadyMerged {
bookmark_name,
pr_number,
} => {
if prev_reasons.is_some() {
println!(
" {bookmark_name}: Merged externally ({}) \u{2014} moving on",
forge_kind.format_ref(pr_number)
);
} else {
println!(
" '{bookmark_name}' ({}) already merged",
forge_kind.format_ref(pr_number)
);
}
skipped.push(SkippedMergedPr {
bookmark_name,
pr_number,
});
*prev_reasons = None;
seg_idx += 1;
advanced = true;
}
PrMergeStatus::Mergeable { bookmark_name, pr } => {
if prev_reasons.is_some() {
println!(" {bookmark_name}: Ready to merge");
}
println!(
"\n Merging '{bookmark_name}' ({}, {})...",
forge_kind.format_ref(pr.number),
merge_options.merge_method
);
println!(" {}", pr.html_url);
merge_with_retry(
forge,
owner,
repo,
pr.number,
merge_options.merge_method,
forge_kind,
)?;
merged.push(MergedPr {
bookmark_name,
pr_number: pr.number,
html_url: pr.html_url.clone(),
});
*prev_reasons = None;
seg_idx += 1;
advanced = true;
}
PrMergeStatus::Blocked {
bookmark_name,
pr,
reasons,
} => {
if reasons.iter().any(|r| matches!(r, BlockReason::NoPr)) {
return Ok(MergePhaseOutcome {
merged,
skipped,
blocked: Some(BlockedPr {
bookmark_name,
pr_number: None,
reasons,
}),
all_done: false,
});
}
if prev_reasons.is_none()
&& let Some(hint) = reviewer_hint(pr.as_ref(), &reasons, &bookmark_name, forge_kind)
{
println!("{hint}");
}
let changed = report_status_changes(
&bookmark_name,
prev_reasons.as_deref(),
&reasons,
forge_kind,
);
if !changed && last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL {
let now = local_time_hhmm();
let first_reason = reasons
.first()
.map(|r| format_block_reason(r, forge_kind))
.unwrap_or_default();
println!(
" [{now}] Still waiting for {bookmark_name}: {first_reason}"
);
*last_heartbeat = Instant::now();
}
if changed {
*last_heartbeat = Instant::now();
}
*prev_reasons = Some(reasons);
break; }
}
if seg_idx > prev_seg_idx && seg_idx < segments.len() {
let fresh = reconcile_after_merge(
jj,
forge,
segments,
prev_seg_idx,
merge_plan,
forge_kind,
local_degraded,
local_warnings,
);
if let Some(fresh_map) = fresh {
pr_map = fresh_map;
}
}
}
Ok(MergePhaseOutcome {
merged,
skipped,
blocked: None,
all_done: seg_idx >= segments.len() && advanced,
})
}
pub fn run_watch_loop(
jj: &dyn Jj,
forge: &dyn Forge,
repo_info: &RepoInfo,
forge_kind: ForgeKind,
remote_name: &str,
default_branch: &str,
merge_options: &MergeOptions,
target_bookmark: &str,
stack_base: Option<&str>,
opts: WatchOptions,
) -> Result<WatchResult> {
let shutdown = opts.shutdown;
let timeout = opts.timeout;
let poll_interval = opts.poll_interval;
let owner = &repo_info.owner;
let repo = &repo_info.repo;
let mut all_created: Vec<CreatedPr> = Vec::new();
let mut all_promoted: Vec<PromotedPr> = Vec::new();
let mut merged: Vec<MergedPr> = Vec::new();
let mut blocked_at: Option<BlockedPr> = None;
let mut skipped_merged: Vec<SkippedMergedPr> = Vec::new();
let mut local_warnings: Vec<LocalDivergenceWarning> = Vec::new();
let mut local_degraded = false;
let mut prev_reasons: Option<Vec<BlockReason>> = None;
let mut consecutive_errors: u32 = 0;
let mut last_heartbeat = Instant::now();
let deadline = timeout.map(|d| Instant::now() + d);
let merge_plan = make_merge_plan(
repo_info, forge_kind, default_branch, remote_name, merge_options, stack_base,
);
if let Ok(initial_prs) = forge.list_open_prs(owner, repo) {
let pr_map = crate::forge::build_pr_map(initial_prs, owner);
let segments = rediscover_segments(jj, target_bookmark).unwrap_or_default();
let with_pr: Vec<_> = segments.iter()
.filter(|s| pr_map.contains_key(&s.bookmark.name))
.collect();
let without_pr: Vec<_> = segments.iter()
.filter(|s| !pr_map.contains_key(&s.bookmark.name))
.collect();
if !with_pr.is_empty() || !without_pr.is_empty() {
println!(" {} bookmark{} in stack{}",
segments.len(),
if segments.len() == 1 { "" } else { "s" },
if !with_pr.is_empty() {
format!(", {} with existing PR{}",
with_pr.len(),
if with_pr.len() == 1 { "" } else { "s" })
} else {
String::new()
},
);
if !without_pr.is_empty() {
let names: Vec<_> = without_pr.iter().map(|s| s.bookmark.name.as_str()).collect();
println!(" Will create draft PRs for: {}\n", names.join(", "));
} else {
println!();
}
}
}
if merge_options.required_approvals == 0 {
anyhow::bail!(
"jjpr watch requires at least 1 approval to merge (required_approvals is 0).\n\
\n\
With 0 required approvals, watch would auto-merge PRs the moment CI \n\
passes — no human review. Set required_approvals = 1 in your config \n\
or pass --required-approvals 1.\n\
\n\
If you need to merge without approvals, use `jjpr merge` instead."
);
}
loop {
if shutdown.load(Ordering::Relaxed) {
break;
}
if let Some(dl) = deadline
&& Instant::now() >= dl
{
println!("\nWatch timed out.");
break;
}
let segments = match rediscover_segments(jj, target_bookmark) {
Ok(segs) => segs,
Err(e) => {
consecutive_errors += 1;
let now = local_time_hhmm();
eprintln!(" [{now}] Graph scan error ({consecutive_errors}/{MAX_CONSECUTIVE_ERRORS}): {e}");
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
eprintln!(" Too many consecutive errors \u{2014} giving up.");
break;
}
if interruptible_sleep(poll_interval, &shutdown) {
break;
}
continue;
}
};
if segments.is_empty() {
break;
}
let has_conflicts = segments.iter().any(|seg|
seg.changes.iter().any(|c| c.conflict)
);
if has_conflicts {
if prev_reasons.is_none() {
let conflicted: Vec<_> = segments.iter()
.flat_map(|seg| seg.changes.iter().filter(|c| c.conflict)
.map(|c| (seg.bookmark.name.as_str(), c.change_id.as_str())))
.collect();
println!("\n Waiting for conflict resolution:");
for (bookmark, change_id) in &conflicted {
println!(" - {change_id} ({bookmark})");
}
println!(" hint: jj edit <change_id>, fix the conflicts, then jjpr watch will continue");
}
if interruptible_sleep(poll_interval, &shutdown) {
break;
}
continue;
}
let bookmarks_being_created = match run_submit_phase(jj, forge, &segments, remote_name, repo_info, forge_kind, default_branch, stack_base) {
Ok(names) => {
consecutive_errors = 0;
names
}
Err(e) => {
consecutive_errors += 1;
let now = local_time_hhmm();
eprintln!(" [{now}] Submit error ({consecutive_errors}/{MAX_CONSECUTIVE_ERRORS}): {e}");
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
break;
}
if interruptible_sleep(poll_interval, &shutdown) {
break;
}
continue;
}
};
let pr_map = match refresh_pr_map(forge, owner, repo) {
Ok(m) => {
consecutive_errors = 0;
m
}
Err(e) => {
consecutive_errors += 1;
let now = local_time_hhmm();
eprintln!(" [{now}] PR refresh error ({consecutive_errors}/{MAX_CONSECUTIVE_ERRORS}): {e}");
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
break;
}
if interruptible_sleep(poll_interval, &shutdown) {
break;
}
continue;
}
};
for name in &bookmarks_being_created {
if let Some(pr) = pr_map.get(name) {
println!(" {}", forge_kind.format_ref(pr.number));
all_created.push(CreatedPr {
bookmark_name: name.clone(),
pr_number: pr.number,
});
}
}
let promoted = promote_ready_drafts(forge, &segments, &pr_map, repo_info, forge_kind);
let pr_map = if !promoted.is_empty() {
refresh_pr_map(forge, owner, repo).unwrap_or(pr_map)
} else {
pr_map
};
all_promoted.extend(promoted);
let merge_outcome = run_merge_phase(
jj, forge, &segments, &pr_map, merge_options, &merge_plan,
forge_kind, &mut prev_reasons, &mut consecutive_errors,
&mut last_heartbeat, &mut local_degraded, &mut local_warnings,
)?;
merged.extend(merge_outcome.merged);
skipped_merged.extend(merge_outcome.skipped);
if let Some(blocked) = merge_outcome.blocked {
blocked_at = Some(blocked);
break;
}
if merge_outcome.all_done {
break;
}
if interruptible_sleep(poll_interval, &shutdown) {
break;
}
}
Ok(WatchResult {
prs_created: all_created,
prs_promoted: all_promoted,
merge_result: MergeResult {
merged,
blocked_at,
skipped_merged,
local_warnings,
},
})
}
fn rediscover_segments(
jj: &dyn Jj,
target_bookmark: &str,
) -> Result<Vec<NarrowedSegment>> {
let graph = change_graph::build_change_graph(jj)?;
let analysis = match analyze::analyze_submission_graph(&graph, target_bookmark) {
Ok(a) => a,
Err(_) => return Ok(vec![]),
};
resolve::resolve_bookmark_selections(&analysis.relevant_segments, false)
}
fn run_submit_phase(
jj: &dyn Jj,
forge: &dyn Forge,
segments: &[NarrowedSegment],
remote_name: &str,
repo_info: &RepoInfo,
forge_kind: ForgeKind,
default_branch: &str,
stack_base: Option<&str>,
) -> Result<Vec<String>> {
let submission_plan = plan::create_submission_plan(
forge,
segments,
remote_name,
repo_info,
forge_kind,
default_branch,
true, false, &[], stack_base,
)?;
if !submission_plan.has_actions() {
return Ok(vec![]);
}
let creating: Vec<String> = submission_plan
.bookmarks_needing_pr
.iter()
.map(|b| b.bookmark.name.clone())
.collect();
execute::execute_submission_plan(jj, forge, &submission_plan, &[], false)?;
Ok(creating)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Mutex;
use super::*;
use crate::forge::types::{
ChecksStatus, IssueComment, MergeMethod, PrMergeability, PrState, PullRequest,
PullRequestRef, ReviewSummary,
};
use crate::jj::types::Bookmark;
fn make_pr(name: &str, number: u64, draft: bool) -> PullRequest {
PullRequest {
number,
html_url: format!("https://github.com/o/r/pull/{number}"),
title: name.to_string(),
body: None,
base: PullRequestRef {
ref_name: "main".to_string(),
label: String::new(),
sha: String::new(),
},
head: PullRequestRef {
ref_name: name.to_string(),
label: String::new(),
sha: format!("sha_{name}"),
},
draft,
node_id: String::new(),
merged_at: None,
requested_reviewers: vec![],
}
}
fn make_segment(name: &str) -> NarrowedSegment {
NarrowedSegment {
bookmark: Bookmark {
name: name.to_string(),
commit_id: format!("commit_{name}"),
change_id: format!("change_{name}"),
has_remote: true,
is_synced: true,
},
changes: vec![],
merge_source_names: vec![],
}
}
fn repo_info() -> RepoInfo {
RepoInfo {
owner: "o".to_string(),
repo: "r".to_string(),
}
}
struct PromotionForge {
calls: Mutex<Vec<String>>,
prs: HashMap<String, PullRequest>,
checks: HashMap<String, ChecksStatus>,
}
impl PromotionForge {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
prs: HashMap::new(),
checks: HashMap::new(),
}
}
fn with_pr(mut self, pr: PullRequest, checks: ChecksStatus) -> Self {
let sha_key = if pr.head.sha.is_empty() {
pr.head.ref_name.clone()
} else {
pr.head.sha.clone()
};
self.checks.insert(sha_key, checks);
self.prs.insert(pr.head.ref_name.clone(), pr);
self
}
fn calls(&self) -> Vec<String> {
self.calls.lock().expect("poisoned").clone()
}
}
impl Forge for PromotionForge {
fn list_open_prs(&self, _o: &str, _r: &str) -> Result<Vec<PullRequest>> {
Ok(self.prs.values().cloned().collect())
}
fn get_pr_checks_status(&self, _o: &str, _r: &str, ref_name: &str) -> Result<ChecksStatus> {
self.checks.get(ref_name).cloned()
.ok_or_else(|| anyhow::anyhow!("no checks for {ref_name}"))
}
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 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<()> { Ok(()) }
fn update_pr_body(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<()> { Ok(()) }
fn request_reviewers(&self, _o: &str, _r: &str, _n: u64, _r2: &[String]) -> Result<()> { Ok(()) }
fn list_comments(&self, _o: &str, _r: &str, _n: u64) -> Result<Vec<IssueComment>> { Ok(vec![]) }
fn create_comment(&self, _o: &str, _r: &str, _n: u64, _b: &str) -> Result<IssueComment> { unimplemented!() }
fn update_comment(&self, _o: &str, _r: &str, _id: u64, _b: &str) -> Result<()> { Ok(()) }
fn get_authenticated_user(&self) -> Result<String> { Ok("user".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<()> { Ok(()) }
fn get_pr_reviews(&self, _o: &str, _r: &str, _n: u64) -> Result<ReviewSummary> {
Ok(ReviewSummary { approved_count: 0, changes_requested: false })
}
fn get_pr_mergeability(&self, _o: &str, _r: &str, _n: u64) -> Result<PrMergeability> {
Ok(PrMergeability { mergeable: Some(true), mergeable_state: "clean".to_string() })
}
fn get_pr_state(&self, _o: &str, _r: &str, _n: u64) -> Result<PrState> {
Ok(PrState { merged: false, state: "open".to_string() })
}
}
#[test]
fn test_reviewer_hint_shown_when_no_reviewers() {
let pr = make_pr("auth", 42, false);
let reasons = vec![BlockReason::InsufficientApprovals { have: 0, need: 1 }];
let hint = reviewer_hint(Some(&pr), &reasons, "auth", ForgeKind::GitHub);
assert!(hint.is_some(), "should show hint when no reviewers");
let text = hint.unwrap();
assert!(text.contains("no reviewers"), "hint text: {text}");
assert!(text.contains("jjpr submit --reviewer"), "hint text: {text}");
}
#[test]
fn test_reviewer_hint_not_shown_when_reviewers_present() {
let mut pr = make_pr("auth", 42, false);
pr.requested_reviewers = vec!["alice".to_string()];
let reasons = vec![BlockReason::InsufficientApprovals { have: 0, need: 1 }];
let hint = reviewer_hint(Some(&pr), &reasons, "auth", ForgeKind::GitHub);
assert!(hint.is_none(), "should not show hint when reviewers are present");
}
#[test]
fn test_reviewer_hint_not_shown_for_non_approval_blocks() {
let pr = make_pr("auth", 42, false);
let reasons = vec![BlockReason::ChecksPending];
let hint = reviewer_hint(Some(&pr), &reasons, "auth", ForgeKind::GitHub);
assert!(hint.is_none(), "should not show hint for non-approval blocks");
}
#[test]
fn test_reviewer_hint_not_shown_when_no_pr() {
let reasons = vec![BlockReason::NoPr];
let hint = reviewer_hint(None, &reasons, "auth", ForgeKind::GitHub);
assert!(hint.is_none(), "should not show hint when there's no PR");
}
#[test]
fn test_promote_draft_when_ci_passes() {
let forge = PromotionForge::new()
.with_pr(make_pr("auth", 1, true), ChecksStatus::Pass);
let segments = vec![make_segment("auth")];
let pr_map: HashMap<String, PullRequest> = forge.prs.clone();
let promoted = promote_ready_drafts(&forge, &segments, &pr_map, &repo_info(), ForgeKind::GitHub);
assert_eq!(promoted.len(), 1);
assert_eq!(promoted[0].pr_number, 1);
assert!(forge.calls().contains(&"mark_pr_ready:1".to_string()));
}
#[test]
fn test_no_promote_when_ci_pending() {
let forge = PromotionForge::new()
.with_pr(make_pr("auth", 1, true), ChecksStatus::Pending);
let segments = vec![make_segment("auth")];
let pr_map: HashMap<String, PullRequest> = forge.prs.clone();
let promoted = promote_ready_drafts(&forge, &segments, &pr_map, &repo_info(), ForgeKind::GitHub);
assert!(promoted.is_empty());
assert!(!forge.calls().iter().any(|c| c.starts_with("mark_pr_ready")));
}
#[test]
fn test_no_promote_when_ci_failing() {
let forge = PromotionForge::new()
.with_pr(make_pr("auth", 1, true), ChecksStatus::Fail);
let segments = vec![make_segment("auth")];
let pr_map: HashMap<String, PullRequest> = forge.prs.clone();
let promoted = promote_ready_drafts(&forge, &segments, &pr_map, &repo_info(), ForgeKind::GitHub);
assert!(promoted.is_empty());
}
#[test]
fn test_no_promote_when_not_draft() {
let forge = PromotionForge::new()
.with_pr(make_pr("auth", 1, false), ChecksStatus::Pass);
let segments = vec![make_segment("auth")];
let pr_map: HashMap<String, PullRequest> = forge.prs.clone();
let promoted = promote_ready_drafts(&forge, &segments, &pr_map, &repo_info(), ForgeKind::GitHub);
assert!(promoted.is_empty());
}
#[test]
fn test_no_promote_when_no_ci_checks() {
let forge = PromotionForge::new()
.with_pr(make_pr("auth", 1, true), ChecksStatus::None);
let segments = vec![make_segment("auth")];
let pr_map: HashMap<String, PullRequest> = forge.prs.clone();
let promoted = promote_ready_drafts(&forge, &segments, &pr_map, &repo_info(), ForgeKind::GitHub);
assert!(promoted.is_empty(), "should not promote when no CI checks exist");
}
#[test]
fn test_promote_multiple_drafts_in_stack() {
let forge = PromotionForge::new()
.with_pr(make_pr("auth", 1, true), ChecksStatus::Pass)
.with_pr(make_pr("profile", 2, true), ChecksStatus::Pass)
.with_pr(make_pr("settings", 3, true), ChecksStatus::Pass);
let segments = vec![
make_segment("auth"),
make_segment("profile"),
make_segment("settings"),
];
let pr_map: HashMap<String, PullRequest> = forge.prs.clone();
let promoted = promote_ready_drafts(&forge, &segments, &pr_map, &repo_info(), ForgeKind::GitHub);
assert_eq!(promoted.len(), 3);
let calls = forge.calls();
assert!(calls.contains(&"mark_pr_ready:1".to_string()));
assert!(calls.contains(&"mark_pr_ready:2".to_string()));
assert!(calls.contains(&"mark_pr_ready:3".to_string()));
}
}