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 stdout = String::from_utf8_lossy(&output.stdout);
312 let stderr = String::from_utf8_lossy(&output.stderr);
313 let state = classify_ci_state(
314 output.status.success(),
315 output.status.code(),
316 &stdout,
317 &stderr,
318 );
319 // Surface transient query failures (network drop, API error) so a long
320 // retry streak is visible in the watcher log instead of silent.
321 if state == CiState::Pending && !output.status.success() && output.status.code() != Some(8) {
322 output::warn(&format!(
323 "Could not query CI checks: {} — retrying",
324 stderr.trim()
325 ));
326 }
327 Ok(state)
328}
329
330/// Classify a `gh pr checks` (no `--watch`) result into a [`CiState`].
331///
332/// `gh` exit codes: 0 = all passed, 8 = some pending, 1 = failed, no checks
333/// reported, *or any other `gh` error* — network drops, API outages, and
334/// expired tokens all exit 1 too. What disambiguates a real CI verdict from a
335/// failed query is **stdout**: `gh pr checks` prints the check table to stdout
336/// when checks actually ran, and prints nothing there when `gh` itself failed.
337/// So exit 1 counts as `Failed` only with check output present; otherwise it's
338/// treated as still pending so the hiccup retries rather than aborting the
339/// wait with a bogus "CI failed".
340fn classify_ci_state(success: bool, code: Option<i32>, stdout: &str, stderr: &str) -> CiState {
341 if success {
342 return CiState::Passed;
343 }
344 if code == Some(8) {
345 return CiState::Pending;
346 }
347 if stderr.contains("no checks reported") {
348 return CiState::NotStarted;
349 }
350 if code == Some(1) && !stdout.trim().is_empty() {
351 return CiState::Failed;
352 }
353 CiState::Pending
354}
355
356/// Poll the PR state until it is merged or closed.
357///
358/// Polls *by PR number* so it keeps working after the remote branch is deleted
359/// on merge. Transient lookup failures are retried rather than aborting.
360fn poll_until_terminal(pr_number: u64, interval_secs: u64) -> PrState {
361 let selector = pr_number.to_string();
362 let delay = Duration::from_secs(interval_secs);
363 loop {
364 match github::get_pr_for_branch(&selector) {
365 Ok(Some(pr)) => match pr.state {
366 PrState::Open => {}
367 terminal => return terminal,
368 },
369 Ok(None) => output::warn("PR not found while polling — retrying"),
370 Err(e) => output::warn(&format!("Could not fetch PR status: {} — retrying", e)),
371 }
372 thread::sleep(delay);
373 }
374}
375
376/// Send a desktop notification via `GW_NOTIFY_CMD`, if configured.
377///
378/// The command is invoked as `$GW_NOTIFY_CMD "<message>"`. This keeps
379/// platform-specific notification logic (e.g. macOS `osascript`) in the user's
380/// dotfiles rather than the CLI. No-op when the variable is unset or empty.
381fn notify(message: &str, verbose: bool) {
382 let cmd = match std::env::var("GW_NOTIFY_CMD") {
383 Ok(cmd) if !cmd.is_empty() => cmd,
384 _ => return,
385 };
386 if verbose {
387 output::action(&format!("{} {}", cmd, message));
388 }
389 if let Err(e) = Command::new(&cmd).arg(message).status() {
390 output::warn(&format!("Notify command '{}' failed: {}", cmd, e));
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 /// A realistic non-TTY `gh pr checks` stdout: the tab-separated check table
399 /// that only exists when checks actually ran.
400 const CHECK_TABLE: &str = "build\tfail\t1m2s\thttps://github.com/o/r/actions/runs/1";
401
402 #[test]
403 fn exit_zero_means_passed() {
404 assert_eq!(
405 classify_ci_state(true, Some(0), CHECK_TABLE, ""),
406 CiState::Passed
407 );
408 }
409
410 #[test]
411 fn exit_eight_means_pending() {
412 // `gh pr checks` exits 8 while checks are still running.
413 assert_eq!(
414 classify_ci_state(false, Some(8), CHECK_TABLE, ""),
415 CiState::Pending
416 );
417 }
418
419 #[test]
420 fn no_checks_reported_means_not_started() {
421 // The window right after PR creation: CI hasn't registered any checks.
422 // This must keep waiting, not be mistaken for a failure.
423 assert_eq!(
424 classify_ci_state(
425 false,
426 Some(1),
427 "",
428 "no checks reported on the 'feature/x' branch"
429 ),
430 CiState::NotStarted
431 );
432 }
433
434 #[test]
435 fn exit_one_with_check_output_means_failed() {
436 assert_eq!(
437 classify_ci_state(false, Some(1), CHECK_TABLE, "1 failing check"),
438 CiState::Failed
439 );
440 }
441
442 #[test]
443 fn exit_one_without_check_output_retries_as_pending() {
444 // gh exits 1 for its own errors too (network drop, API outage, expired
445 // token). No check table on stdout means no CI verdict was reached —
446 // retry instead of aborting the wait with a bogus "CI failed".
447 assert_eq!(
448 classify_ci_state(
449 false,
450 Some(1),
451 "",
452 "error connecting to api.github.com\ncheck your internet connection"
453 ),
454 CiState::Pending
455 );
456 }
457
458 #[test]
459 fn unknown_failure_retries_as_pending() {
460 // A gh hiccup with no usable exit code shouldn't abort the wait either.
461 assert_eq!(
462 classify_ci_state(false, None, "", "could not connect"),
463 CiState::Pending
464 );
465 }
466}