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//! Phases:
14//! 1. Wait for CI       — `gh pr checks --watch` (skip with `--no-wait`)
15//! 2. Watch for merge   — poll the PR state every `--interval` seconds
16//! 3. On merge          — notify, then `gw cleanup <head branch>` (skip with `--no-cleanup`)
17//!
18//! Environment-specific behavior stays in the user's dotfiles, not the CLI:
19//! - `--open` opens the URL via `GW_OPEN_URL_CMD`/`OPEN_URL_CMD` (see `gw open`)
20//! - the merge notification is delegated to `GW_NOTIFY_CMD` (called as
21//!   `$GW_NOTIFY_CMD "<message>"`), e.g. a script wrapping macOS `osascript`.
22
23use std::process::Command;
24use std::thread;
25use std::time::Duration;
26
27use crate::commands::{cleanup, open};
28use crate::error::{GwError, Result};
29use crate::git;
30use crate::github::{self, PrState};
31use crate::output;
32
33/// Execute the `await` command for a specific PR number
34pub fn run(
35    pr_number: u64,
36    open_browser: bool,
37    no_wait: bool,
38    no_cleanup: bool,
39    interval: u64,
40    verbose: bool,
41) -> Result<()> {
42    if !git::is_git_repo() {
43        return Err(GwError::NotAGitRepository);
44    }
45
46    println!();
47
48    if !github::is_gh_available() {
49        return Err(GwError::Other(
50            "GitHub CLI (gh) is not available. Install it from https://cli.github.com/".into(),
51        ));
52    }
53
54    // Resolve the PR by number so the watcher is independent of the current branch.
55    let pr = match github::get_pr_for_branch(&pr_number.to_string())? {
56        Some(pr) => pr,
57        None => {
58            output::warn(&format!("No PR #{} found.", pr_number));
59            return Ok(());
60        }
61    };
62
63    // The branch to clean up after merge is the PR's head branch, resolved from
64    // GitHub — never the current checkout (which may differ for stacked work).
65    let head_branch = pr.head_branch.clone();
66
67    output::info(&format!("PR: #{} {}", pr.number, pr.title));
68
69    // Optionally open in the browser up front.
70    if open_browser {
71        match open::open_url(&pr.url, verbose) {
72            Ok(()) => output::success(&format!("Opened {}", pr.url)),
73            Err(e) => output::warn(&format!("Could not open browser: {}", e)),
74        }
75    }
76
77    // If the PR is already terminal, handle it without watching.
78    match &pr.state {
79        PrState::Merged { .. } => {
80            output::success(&format!("PR #{} is already merged", pr.number));
81            return finish_merged(&head_branch, no_cleanup, verbose);
82        }
83        PrState::Closed => {
84            output::warn(&format!(
85                "PR #{} is already closed without merging",
86                pr.number
87            ));
88            return Ok(());
89        }
90        PrState::Open => {}
91    }
92
93    // Phase 1: wait for CI.
94    if !no_wait {
95        output::info(&format!("Waiting for CI checks on PR #{}...", pr.number));
96        wait_for_ci(pr.number, verbose);
97    }
98
99    // Phase 2: poll until the PR reaches a terminal state.
100    let poll_secs = interval.max(1);
101    output::info(&format!(
102        "Watching PR #{} for merge (every {}s)...",
103        pr.number, poll_secs
104    ));
105    let terminal = poll_until_terminal(pr.number, poll_secs);
106
107    // Phase 3: react to the terminal state.
108    match terminal {
109        PrState::Merged { .. } => {
110            output::success(&format!("PR #{} merged!", pr.number));
111            notify(&format!("PR #{} merged", pr.number), verbose);
112            finish_merged(&head_branch, no_cleanup, verbose)
113        }
114        PrState::Closed => {
115            output::warn(&format!("PR #{} was closed without merging", pr.number));
116            notify(
117                &format!("PR #{} closed without merging", pr.number),
118                verbose,
119            );
120            Ok(())
121        }
122        PrState::Open => unreachable!("poll_until_terminal only returns terminal states"),
123    }
124}
125
126/// Handle a merged PR: clean up the PR's head branch unless suppressed.
127fn finish_merged(head_branch: &str, no_cleanup: bool, verbose: bool) -> Result<()> {
128    if head_branch.is_empty() {
129        // No head branch resolved (e.g. unexpected GitHub output). Don't risk
130        // deleting the wrong branch — leave cleanup to the user.
131        output::warn("Could not determine the PR's branch; skipping cleanup.");
132        output::hints(&["gw cleanup <branch>  # Delete the merged branch manually"]);
133        return Ok(());
134    }
135    if no_cleanup {
136        output::ready("Merged", head_branch);
137        let hint = format!(
138            "gw cleanup {}  # Delete the merged branch when ready",
139            head_branch
140        );
141        output::hints(&[&hint]);
142        return Ok(());
143    }
144    println!();
145    output::info(&format!("Cleaning up merged branch '{}'...", head_branch));
146    cleanup::run(Some(head_branch.to_string()), verbose)
147}
148
149/// Wait for CI checks to finish by delegating to `gh pr checks --watch`.
150///
151/// A non-zero exit (failed/missing checks) is not fatal: the PR may still be
152/// mergeable, so we warn and continue to the merge watch.
153fn wait_for_ci(pr_number: u64, verbose: bool) {
154    let num = pr_number.to_string();
155    let args = ["pr", "checks", &num, "--watch"];
156    if verbose {
157        output::action(&format!("gh {}", args.join(" ")));
158    }
159    match Command::new("gh").args(args).status() {
160        Ok(status) if status.success() => output::success("CI checks passed"),
161        Ok(_) => output::warn("CI checks did not all pass — continuing anyway"),
162        Err(e) => output::warn(&format!("Could not watch CI checks: {} — continuing", e)),
163    }
164}
165
166/// Poll the PR state until it is merged or closed.
167///
168/// Polls *by PR number* so it keeps working after the remote branch is deleted
169/// on merge. Transient lookup failures are retried rather than aborting.
170fn poll_until_terminal(pr_number: u64, interval_secs: u64) -> PrState {
171    let selector = pr_number.to_string();
172    let delay = Duration::from_secs(interval_secs);
173    loop {
174        match github::get_pr_for_branch(&selector) {
175            Ok(Some(pr)) => match pr.state {
176                PrState::Open => {}
177                terminal => return terminal,
178            },
179            Ok(None) => output::warn("PR not found while polling — retrying"),
180            Err(e) => output::warn(&format!("Could not fetch PR status: {} — retrying", e)),
181        }
182        thread::sleep(delay);
183    }
184}
185
186/// Send a desktop notification via `GW_NOTIFY_CMD`, if configured.
187///
188/// The command is invoked as `$GW_NOTIFY_CMD "<message>"`. This keeps
189/// platform-specific notification logic (e.g. macOS `osascript`) in the user's
190/// dotfiles rather than the CLI. No-op when the variable is unset or empty.
191fn notify(message: &str, verbose: bool) {
192    let cmd = match std::env::var("GW_NOTIFY_CMD") {
193        Ok(cmd) if !cmd.is_empty() => cmd,
194        _ => return,
195    };
196    if verbose {
197        output::action(&format!("{} {}", cmd, message));
198    }
199    if let Err(e) = Command::new(&cmd).arg(message).status() {
200        output::warn(&format!("Notify command '{}' failed: {}", cmd, e));
201    }
202}