Skip to main content

git_workflow/commands/
await_pr.rs

1//! `gw await` command - Watch a specific PR to completion, then clean up.
2//!
3//! `await` is the closing bookend of the workflow. Its irreducible job is to
4//! *watch the PR until it reaches a terminal state* (merged or closed); the CI
5//! wait and the post-merge cleanup are adjacent steps composed on top, each
6//! toggleable by a flag.
7//!
8//! The PR is identified by **number**, not by the current branch. This keeps a
9//! background watcher bound to one PR even if you switch branches (e.g. while
10//! working a stacked PR), and lets the post-merge cleanup target the PR's own
11//! head branch rather than whatever happens to be checked out.
12//!
13//! State machine:
14//!
15//! - Wait for CI: poll until the checks reach a verdict (skip with `--no-wait`).
16//!   "Pending" just *keeps waiting*; pass/fail are terminal. A branch with no CI
17//!   at all ("no checks reported", confirmed across a few polls) isn't waited on
18//!   — there's nothing to wait for, so we proceed. Watching the PR is the
19//!   irreducible job, so this step also bails the moment the PR itself merges or
20//!   closes.
21//! - On CI pass (or no CI): open the PR in the browser (with `--open`), then
22//!   watch for merge.
23//! - On CI fail: report and stop, so you can fix → push → rerun `await`.
24//! - Watch for merge: poll the PR state every `--interval` seconds.
25//! - On merge: notify, then `gw cleanup <head branch>` (skip with `--no-cleanup`).
26//!
27//! Environment-specific behavior stays in the user's dotfiles, not the CLI:
28//! - `--open` opens the URL via `GW_OPEN_URL_CMD`/`OPEN_URL_CMD` (see `gw open`)
29//! - the merge notification is delegated to `GW_NOTIFY_CMD` (called as
30//!   `$GW_NOTIFY_CMD "<message>"`), e.g. a script wrapping macOS `osascript`.
31
32use std::process::Command;
33use std::thread;
34use std::time::Duration;
35
36use crate::commands::{cleanup, open};
37use crate::error::{GwError, Result};
38use crate::git;
39use crate::github::{self, PrState};
40use crate::output;
41
42/// Execute the `await` command for a specific PR number
43pub fn run(
44    pr_number: u64,
45    open_browser: bool,
46    no_wait: bool,
47    no_cleanup: bool,
48    ignore_ci_failure: bool,
49    interval: u64,
50    verbose: bool,
51) -> Result<()> {
52    if !git::is_git_repo() {
53        return Err(GwError::NotAGitRepository);
54    }
55
56    println!();
57
58    if !github::is_gh_available() {
59        return Err(GwError::Other(
60            "GitHub CLI (gh) is not available. Install it from https://cli.github.com/".into(),
61        ));
62    }
63
64    // Resolve the PR by number so the watcher is independent of the current branch.
65    let pr = match github::get_pr_for_branch(&pr_number.to_string())? {
66        Some(pr) => pr,
67        None => {
68            output::warn(&format!("No PR #{} found.", pr_number));
69            return Ok(());
70        }
71    };
72
73    // The branch to clean up after merge is the PR's head branch, resolved from
74    // GitHub — never the current checkout (which may differ for stacked work).
75    let head_branch = pr.head_branch.clone();
76
77    output::info(&format!("PR: #{} {}", pr.number, pr.title));
78
79    // If the PR is already terminal, handle it without watching.
80    match &pr.state {
81        PrState::Merged { .. } => {
82            output::success(&format!("PR #{} is already merged", pr.number));
83            return finish_merged(&head_branch, no_cleanup, verbose);
84        }
85        PrState::Closed => {
86            output::warn(&format!(
87                "PR #{} is already closed without merging",
88                pr.number
89            ));
90            return Ok(());
91        }
92        PrState::Open => {}
93    }
94
95    // Phase 1: wait for CI. A CI failure stops here (the PR can't merge until
96    // it's fixed) unless the user opted into watching anyway. If the PR merges
97    // or closes while we wait, CI is moot — react to that directly.
98    if !no_wait {
99        match wait_for_ci(pr.number, ignore_ci_failure, interval, verbose)? {
100            CiWait::Proceed => {}
101            CiWait::Terminal(state) => {
102                return react_to_terminal(state, pr.number, &head_branch, no_cleanup, verbose);
103            }
104        }
105    }
106
107    // Phase 2: CI is green (or skipped) — surface the PR for review/merge.
108    if open_browser {
109        match open::open_url(&pr.url, verbose) {
110            Ok(()) => output::success(&format!("Opened {}", pr.url)),
111            Err(e) => output::warn(&format!("Could not open browser: {}", e)),
112        }
113    }
114
115    // Phase 3: poll until the PR reaches a terminal state.
116    let poll_secs = interval.max(1);
117    output::info(&format!(
118        "Watching PR #{} for merge (every {}s)...",
119        pr.number, poll_secs
120    ));
121    let terminal = poll_until_terminal(pr.number, poll_secs);
122
123    // Phase 4: react to the terminal state.
124    react_to_terminal(terminal, pr.number, &head_branch, no_cleanup, verbose)
125}
126
127/// React to a PR's terminal state: clean up on merge, report on close.
128fn react_to_terminal(
129    terminal: PrState,
130    pr_number: u64,
131    head_branch: &str,
132    no_cleanup: bool,
133    verbose: bool,
134) -> Result<()> {
135    match terminal {
136        PrState::Merged { .. } => {
137            output::success(&format!("PR #{} merged!", pr_number));
138            notify(&format!("PR #{} merged", pr_number), verbose);
139            finish_merged(head_branch, no_cleanup, verbose)
140        }
141        PrState::Closed => {
142            output::warn(&format!("PR #{} was closed without merging", pr_number));
143            notify(
144                &format!("PR #{} closed without merging", pr_number),
145                verbose,
146            );
147            Ok(())
148        }
149        PrState::Open => unreachable!("react_to_terminal is only called with terminal states"),
150    }
151}
152
153/// Handle a merged PR: clean up the PR's head branch unless suppressed.
154fn finish_merged(head_branch: &str, no_cleanup: bool, verbose: bool) -> Result<()> {
155    if head_branch.is_empty() {
156        // No head branch resolved (e.g. unexpected GitHub output). Don't risk
157        // deleting the wrong branch — leave cleanup to the user.
158        output::warn("Could not determine the PR's branch; skipping cleanup.");
159        output::hints(&["gw cleanup <branch>  # Delete the merged branch manually"]);
160        return Ok(());
161    }
162    if no_cleanup {
163        output::ready("Merged", head_branch);
164        let hint = format!(
165            "gw cleanup {}  # Delete the merged branch when ready",
166            head_branch
167        );
168        output::hints(&[&hint]);
169        return Ok(());
170    }
171    println!();
172    output::info(&format!("Cleaning up merged branch '{}'...", head_branch));
173    cleanup::run(Some(head_branch.to_string()), verbose)
174}
175
176/// How many consecutive "no checks reported" polls confirm a branch simply has
177/// no CI, rather than CI that hasn't registered yet. A few quick confirmations
178/// absorb the brief window after a push before GitHub Actions attaches its
179/// checks, without making no-CI repos wait long.
180const NO_CI_CONFIRMATIONS: u32 = 3;
181
182/// Delay between the quick "is there really no CI?" confirmation polls. Short
183/// and independent of `--interval` (the merge-watch cadence) so detecting a
184/// no-CI branch takes seconds, not a full merge-poll interval.
185const NO_CI_PROBE_DELAY: Duration = Duration::from_secs(2);
186
187/// Where a PR's CI checks are in their lifecycle.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189enum CiState {
190    /// No checks reported. Either CI hasn't registered yet (keep waiting a few
191    /// polls) or the branch has no CI at all (proceed without waiting).
192    NotStarted,
193    /// Checks exist but haven't all finished. Keep waiting.
194    Pending,
195    /// All checks passed. Terminal.
196    Passed,
197    /// At least one check failed. Terminal.
198    Failed,
199}
200
201/// Outcome of the CI wait: either CI settled and we should keep watching for
202/// merge, or the PR reached a terminal state while we waited.
203enum CiWait {
204    /// CI passed (or a failure was ignored) — proceed to watch for merge.
205    Proceed,
206    /// The PR merged or closed before CI settled — react to it directly.
207    Terminal(PrState),
208}
209
210/// Wait for CI to reach a verdict, then report pass/fail.
211///
212/// This is a plain state machine: poll the PR's checks until they settle.
213/// `Pending` (checks exist but haven't finished) just *keeps waiting*; only
214/// `Passed`/`Failed` end the loop on a verdict. There is deliberately **no**
215/// timeout once real checks exist — "wait for CI" means wait for CI.
216///
217/// `NotStarted` ("no checks reported") is the ambiguous case: CI that hasn't
218/// registered yet vs. a branch with no CI at all. We confirm it across a few
219/// quick polls ([`NO_CI_CONFIRMATIONS`]); if checks still never appear, there's
220/// nothing to wait for, so we proceed (which lets `--open` open the PR and the
221/// merge watch begin) instead of hanging forever. The user shouldn't have to
222/// reach for `--no-wait` just because a repo has no CI.
223///
224/// Each poll also checks whether the PR itself went terminal: a merged PR can't
225/// be gated on checks. When that happens we return [`CiWait::Terminal`] so the
226/// caller reacts (cleanup on merge) instead of waiting. (`--no-wait` still skips
227/// this phase entirely.)
228///
229/// On `Failed`, the PR can't merge until it's fixed, so we stop and report it
230/// by default; `ignore_ci_failure` downgrades that to a warning and continues.
231fn wait_for_ci(
232    pr_number: u64,
233    ignore_ci_failure: bool,
234    interval: u64,
235    verbose: bool,
236) -> Result<CiWait> {
237    let num = pr_number.to_string();
238    let delay = Duration::from_secs(interval.max(1));
239    output::info(&format!("Waiting for CI checks on PR #{num}..."));
240    let mut no_checks_streak = 0u32;
241    loop {
242        // Watching the PR is the irreducible job. If it already merged or
243        // closed, stop waiting on CI regardless of where the checks stand.
244        if let Some(state) = terminal_state(&num) {
245            return Ok(CiWait::Terminal(state));
246        }
247        match query_ci_state(&num, verbose)? {
248            CiState::NotStarted => {
249                // No checks yet. Confirm a few times before concluding the
250                // branch simply has no CI — then proceed rather than hang.
251                no_checks_streak += 1;
252                if no_checks_streak >= NO_CI_CONFIRMATIONS {
253                    output::info(&format!(
254                        "No CI checks for PR #{num} — proceeding without waiting"
255                    ));
256                    return Ok(CiWait::Proceed);
257                }
258                thread::sleep(NO_CI_PROBE_DELAY);
259            }
260            // Checks appeared, so CI does exist — reset the no-CI counter and
261            // wait for them on the normal cadence.
262            CiState::Pending => {
263                no_checks_streak = 0;
264                thread::sleep(delay);
265            }
266            CiState::Passed => {
267                output::success("CI checks passed");
268                return Ok(CiWait::Proceed);
269            }
270            CiState::Failed if ignore_ci_failure => {
271                output::warn(
272                    "CI checks did not all pass — continuing anyway (--ignore-ci-failure)",
273                );
274                return Ok(CiWait::Proceed);
275            }
276            CiState::Failed => {
277                return Err(GwError::Other(format!(
278                    "CI checks for PR #{pr_number} did not all pass. Fix the PR, push again, then \
279                     rerun `gw await {pr_number} --open` (or pass --ignore-ci-failure to watch \
280                     regardless)."
281                )));
282            }
283        }
284    }
285}
286
287/// Return the PR's terminal state if it has merged or closed, else `None`.
288///
289/// Open PRs and transient lookup failures both yield `None`, so a caller polling
290/// this keeps waiting rather than aborting on a hiccup.
291fn terminal_state(selector: &str) -> Option<PrState> {
292    match github::get_pr_for_branch(selector) {
293        Ok(Some(pr)) => match pr.state {
294            PrState::Open => None,
295            terminal => Some(terminal),
296        },
297        _ => None,
298    }
299}
300
301/// Query the PR's current CI state via `gh pr checks` (no `--watch`).
302fn query_ci_state(pr: &str, verbose: bool) -> Result<CiState> {
303    let args = ["pr", "checks", pr];
304    if verbose {
305        output::action(&format!("gh {}", args.join(" ")));
306    }
307    let output = Command::new("gh")
308        .args(args)
309        .output()
310        .map_err(|e| GwError::Other(format!("Could not query CI checks for PR #{pr}: {e}")))?;
311    let stderr = String::from_utf8_lossy(&output.stderr);
312    Ok(classify_ci_state(
313        output.status.success(),
314        output.status.code(),
315        &stderr,
316    ))
317}
318
319/// Classify a `gh pr checks` (no `--watch`) result into a [`CiState`].
320///
321/// `gh` exit codes: 0 = all passed, 8 = some pending, 1 = failed *or* no checks
322/// reported (disambiguated by stderr). Anything else is treated as still
323/// pending so a transient `gh` hiccup retries rather than aborting the wait.
324fn classify_ci_state(success: bool, code: Option<i32>, stderr: &str) -> CiState {
325    if success {
326        return CiState::Passed;
327    }
328    if code == Some(8) {
329        return CiState::Pending;
330    }
331    if stderr.contains("no checks reported") {
332        return CiState::NotStarted;
333    }
334    if code == Some(1) {
335        return CiState::Failed;
336    }
337    CiState::Pending
338}
339
340/// Poll the PR state until it is merged or closed.
341///
342/// Polls *by PR number* so it keeps working after the remote branch is deleted
343/// on merge. Transient lookup failures are retried rather than aborting.
344fn poll_until_terminal(pr_number: u64, interval_secs: u64) -> PrState {
345    let selector = pr_number.to_string();
346    let delay = Duration::from_secs(interval_secs);
347    loop {
348        match github::get_pr_for_branch(&selector) {
349            Ok(Some(pr)) => match pr.state {
350                PrState::Open => {}
351                terminal => return terminal,
352            },
353            Ok(None) => output::warn("PR not found while polling — retrying"),
354            Err(e) => output::warn(&format!("Could not fetch PR status: {} — retrying", e)),
355        }
356        thread::sleep(delay);
357    }
358}
359
360/// Send a desktop notification via `GW_NOTIFY_CMD`, if configured.
361///
362/// The command is invoked as `$GW_NOTIFY_CMD "<message>"`. This keeps
363/// platform-specific notification logic (e.g. macOS `osascript`) in the user's
364/// dotfiles rather than the CLI. No-op when the variable is unset or empty.
365fn notify(message: &str, verbose: bool) {
366    let cmd = match std::env::var("GW_NOTIFY_CMD") {
367        Ok(cmd) if !cmd.is_empty() => cmd,
368        _ => return,
369    };
370    if verbose {
371        output::action(&format!("{} {}", cmd, message));
372    }
373    if let Err(e) = Command::new(&cmd).arg(message).status() {
374        output::warn(&format!("Notify command '{}' failed: {}", cmd, e));
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn exit_zero_means_passed() {
384        assert_eq!(classify_ci_state(true, Some(0), ""), CiState::Passed);
385    }
386
387    #[test]
388    fn exit_eight_means_pending() {
389        // `gh pr checks` exits 8 while checks are still running.
390        assert_eq!(classify_ci_state(false, Some(8), ""), CiState::Pending);
391    }
392
393    #[test]
394    fn no_checks_reported_means_not_started() {
395        // The window right after PR creation: CI hasn't registered any checks.
396        // This must keep waiting, not be mistaken for a failure.
397        assert_eq!(
398            classify_ci_state(
399                false,
400                Some(1),
401                "no checks reported on the 'feature/x' branch"
402            ),
403            CiState::NotStarted
404        );
405    }
406
407    #[test]
408    fn exit_one_with_checks_means_failed() {
409        assert_eq!(
410            classify_ci_state(false, Some(1), "1 failing check"),
411            CiState::Failed
412        );
413    }
414
415    #[test]
416    fn unknown_failure_retries_as_pending() {
417        // A transient gh hiccup (e.g. network) shouldn't abort the wait.
418        assert_eq!(
419            classify_ci_state(false, None, "could not connect"),
420            CiState::Pending
421        );
422    }
423}