use std::process::Command;
use std::thread;
use std::time::Duration;
use crate::commands::{cleanup, open};
use crate::error::{GwError, Result};
use crate::git;
use crate::github::{self, PrState};
use crate::output;
pub fn run(
pr_number: u64,
open_browser: bool,
no_wait: bool,
no_cleanup: bool,
ignore_ci_failure: bool,
interval: u64,
verbose: bool,
) -> Result<()> {
if !git::is_git_repo() {
return Err(GwError::NotAGitRepository);
}
println!();
if !github::is_gh_available() {
return Err(GwError::Other(
"GitHub CLI (gh) is not available. Install it from https://cli.github.com/".into(),
));
}
let pr = match github::get_pr_for_branch(&pr_number.to_string())? {
Some(pr) => pr,
None => {
output::warn(&format!("No PR #{} found.", pr_number));
return Ok(());
}
};
let head_branch = pr.head_branch.clone();
output::info(&format!("PR: #{} {}", pr.number, pr.title));
match &pr.state {
PrState::Merged { .. } => {
output::success(&format!("PR #{} is already merged", pr.number));
return finish_merged(&head_branch, no_cleanup, verbose);
}
PrState::Closed => {
output::warn(&format!(
"PR #{} is already closed without merging",
pr.number
));
return Ok(());
}
PrState::Open => {}
}
if !no_wait {
match wait_for_ci(pr.number, ignore_ci_failure, interval, verbose)? {
CiWait::Proceed => {}
CiWait::Terminal(state) => {
return react_to_terminal(state, pr.number, &head_branch, no_cleanup, verbose);
}
}
}
if open_browser {
match open::open_url(&pr.url, verbose) {
Ok(()) => output::success(&format!("Opened {}", pr.url)),
Err(e) => output::warn(&format!("Could not open browser: {}", e)),
}
}
let poll_secs = interval.max(1);
output::info(&format!(
"Watching PR #{} for merge (every {}s)...",
pr.number, poll_secs
));
let terminal = poll_until_terminal(pr.number, poll_secs);
react_to_terminal(terminal, pr.number, &head_branch, no_cleanup, verbose)
}
fn react_to_terminal(
terminal: PrState,
pr_number: u64,
head_branch: &str,
no_cleanup: bool,
verbose: bool,
) -> Result<()> {
match terminal {
PrState::Merged { .. } => {
output::success(&format!("PR #{} merged!", pr_number));
notify(&format!("PR #{} merged", pr_number), verbose);
finish_merged(head_branch, no_cleanup, verbose)
}
PrState::Closed => {
output::warn(&format!("PR #{} was closed without merging", pr_number));
notify(
&format!("PR #{} closed without merging", pr_number),
verbose,
);
Ok(())
}
PrState::Open => unreachable!("react_to_terminal is only called with terminal states"),
}
}
fn finish_merged(head_branch: &str, no_cleanup: bool, verbose: bool) -> Result<()> {
if head_branch.is_empty() {
output::warn("Could not determine the PR's branch; skipping cleanup.");
output::hints(&["gw cleanup <branch> # Delete the merged branch manually"]);
return Ok(());
}
if no_cleanup {
output::ready("Merged", head_branch);
let hint = format!(
"gw cleanup {} # Delete the merged branch when ready",
head_branch
);
output::hints(&[&hint]);
return Ok(());
}
println!();
output::info(&format!("Cleaning up merged branch '{}'...", head_branch));
cleanup::run(Some(head_branch.to_string()), verbose)
}
const NO_CI_CONFIRMATIONS: u32 = 3;
const NO_CI_PROBE_DELAY: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CiState {
NotStarted,
Pending,
Passed,
Failed,
}
enum CiWait {
Proceed,
Terminal(PrState),
}
fn wait_for_ci(
pr_number: u64,
ignore_ci_failure: bool,
interval: u64,
verbose: bool,
) -> Result<CiWait> {
let num = pr_number.to_string();
let delay = Duration::from_secs(interval.max(1));
output::info(&format!("Waiting for CI checks on PR #{num}..."));
let mut no_checks_streak = 0u32;
loop {
if let Some(state) = terminal_state(&num) {
return Ok(CiWait::Terminal(state));
}
match query_ci_state(&num, verbose)? {
CiState::NotStarted => {
no_checks_streak += 1;
if no_checks_streak >= NO_CI_CONFIRMATIONS {
output::info(&format!(
"No CI checks for PR #{num} — proceeding without waiting"
));
return Ok(CiWait::Proceed);
}
thread::sleep(NO_CI_PROBE_DELAY);
}
CiState::Pending => {
no_checks_streak = 0;
thread::sleep(delay);
}
CiState::Passed => {
output::success("CI checks passed");
return Ok(CiWait::Proceed);
}
CiState::Failed if ignore_ci_failure => {
output::warn(
"CI checks did not all pass — continuing anyway (--ignore-ci-failure)",
);
return Ok(CiWait::Proceed);
}
CiState::Failed => {
return Err(GwError::Other(format!(
"CI checks for PR #{pr_number} did not all pass. Fix the PR, push again, then \
rerun `gw await {pr_number} --open` (or pass --ignore-ci-failure to watch \
regardless)."
)));
}
}
}
}
fn terminal_state(selector: &str) -> Option<PrState> {
match github::get_pr_for_branch(selector) {
Ok(Some(pr)) => match pr.state {
PrState::Open => None,
terminal => Some(terminal),
},
_ => None,
}
}
fn query_ci_state(pr: &str, verbose: bool) -> Result<CiState> {
let args = ["pr", "checks", pr];
if verbose {
output::action(&format!("gh {}", args.join(" ")));
}
let output = Command::new("gh")
.args(args)
.output()
.map_err(|e| GwError::Other(format!("Could not query CI checks for PR #{pr}: {e}")))?;
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(classify_ci_state(
output.status.success(),
output.status.code(),
&stderr,
))
}
fn classify_ci_state(success: bool, code: Option<i32>, stderr: &str) -> CiState {
if success {
return CiState::Passed;
}
if code == Some(8) {
return CiState::Pending;
}
if stderr.contains("no checks reported") {
return CiState::NotStarted;
}
if code == Some(1) {
return CiState::Failed;
}
CiState::Pending
}
fn poll_until_terminal(pr_number: u64, interval_secs: u64) -> PrState {
let selector = pr_number.to_string();
let delay = Duration::from_secs(interval_secs);
loop {
match github::get_pr_for_branch(&selector) {
Ok(Some(pr)) => match pr.state {
PrState::Open => {}
terminal => return terminal,
},
Ok(None) => output::warn("PR not found while polling — retrying"),
Err(e) => output::warn(&format!("Could not fetch PR status: {} — retrying", e)),
}
thread::sleep(delay);
}
}
fn notify(message: &str, verbose: bool) {
let cmd = match std::env::var("GW_NOTIFY_CMD") {
Ok(cmd) if !cmd.is_empty() => cmd,
_ => return,
};
if verbose {
output::action(&format!("{} {}", cmd, message));
}
if let Err(e) = Command::new(&cmd).arg(message).status() {
output::warn(&format!("Notify command '{}' failed: {}", cmd, e));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exit_zero_means_passed() {
assert_eq!(classify_ci_state(true, Some(0), ""), CiState::Passed);
}
#[test]
fn exit_eight_means_pending() {
assert_eq!(classify_ci_state(false, Some(8), ""), CiState::Pending);
}
#[test]
fn no_checks_reported_means_not_started() {
assert_eq!(
classify_ci_state(
false,
Some(1),
"no checks reported on the 'feature/x' branch"
),
CiState::NotStarted
);
}
#[test]
fn exit_one_with_checks_means_failed() {
assert_eq!(
classify_ci_state(false, Some(1), "1 failing check"),
CiState::Failed
);
}
#[test]
fn unknown_failure_retries_as_pending() {
assert_eq!(
classify_ci_state(false, None, "could not connect"),
CiState::Pending
);
}
}