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}