Skip to main content

git_workflow/commands/
await_pr.rs

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