use anyhow::{anyhow, Context, Result};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::io::Write;
use std::process::{Command, Output, Stdio};
pub const API_VERSION: &str = "2026-03-10";
fn base_command() -> Command {
let mut cmd = Command::new("gh");
cmd.env("GH_PROMPT", "never")
.env("GH_PAGER", "cat")
.env("GH_NO_UPDATE_NOTIFIER", "1");
cmd
}
pub fn run(args: &[&str]) -> Result<String> {
retrying(args, None)
}
fn retrying(args: &[&str], stdin: Option<&[u8]>) -> Result<String> {
const RETRIES: u32 = 5;
let base: u64 = std::env::var("GBD_BACKOFF_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60_000);
let composite = args.first() == Some(&"issue") && args.get(1) == Some(&"create");
let mut wait = base;
let mut primary_waits = 0u32;
for attempt in 1..=RETRIES {
let output = spawn(args, stdin)?;
if composite || output.status.success() {
return finish(args, &output);
}
if primary_limited(&output) {
if primary_waits >= 2 {
return finish(args, &output);
}
primary_waits += 1;
match exhausted(args) {
Ok((pool, reset)) => wait_for_reset(pool, reset),
Err(err) => {
eprintln!(
"gh: primary rate limit on {}; could not read the reset time ({err:#}), waiting {}s",
pool_of(args),
wait.div_ceil(1000)
);
std::thread::sleep(std::time::Duration::from_millis(wait));
}
}
continue;
}
if !rate_limited(&output) {
return finish(args, &output);
}
let ms = retry_after_ms(&output).unwrap_or(wait);
eprintln!(
"gh: rate limited; retrying in {}s ({attempt} of {RETRIES})",
ms.div_ceil(1000)
);
std::thread::sleep(std::time::Duration::from_millis(ms));
wait = wait.saturating_mul(2);
}
finish(args, &spawn(args, stdin)?)
}
fn failure_text(output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout)
} else {
stderr
};
text.to_ascii_lowercase()
}
fn rate_limited(output: &Output) -> bool {
let msg = failure_text(output);
let status = msg.contains("http 429") || msg.contains("http 403");
msg.contains("secondary rate limit") || (status && msg.contains("abuse"))
}
fn primary_limited(output: &Output) -> bool {
let msg = failure_text(output);
msg.contains("rate limit") && msg.contains("exceeded") && !msg.contains("secondary")
}
fn pool_of(args: &[&str]) -> &'static str {
match args {
["api", "graphql", ..] | ["issue" | "project" | "repo", ..] => "graphql",
_ => "core",
}
}
fn rate_limits() -> Result<Value> {
let args = ["api", "rate_limit"];
let text = finish(&args, &spawn(&args, None)?)?;
serde_json::from_str(&text).context("reading /rate_limit")
}
fn pool_budget(limits: &Value, pool: &str) -> Result<(u64, u64)> {
let pool = limits
.pointer(&format!("/resources/{pool}"))
.with_context(|| format!("/rate_limit has no {pool} pool"))?;
let field = |k: &str| {
pool.get(k)
.and_then(Value::as_u64)
.with_context(|| format!("/rate_limit: no {k}"))
};
Ok((field("remaining")?, field("reset")?))
}
pub fn budget(pool: &str) -> Result<(u64, u64)> {
pool_budget(&rate_limits()?, pool)
}
fn exhausted(args: &[&str]) -> Result<(&'static str, u64)> {
let limits = rate_limits()?;
let guess = pool_of(args);
let other = if guess == "graphql" {
"core"
} else {
"graphql"
};
for pool in [guess, other] {
let (remaining, reset) = pool_budget(&limits, pool)?;
if remaining == 0 {
return Ok((pool, reset));
}
}
Ok((guess, 0))
}
pub fn wait_for_reset(pool: &str, reset: u64) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let margin_ms: u64 = std::env::var("GBD_BACKOFF_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map_or(5_000, |ms| ms.min(5_000));
let secs = reset.saturating_sub(now).min(3_700);
eprintln!(
"gh: primary rate limit on {pool}; waiting {}m{:02}s for the reset",
secs / 60,
secs % 60
);
std::thread::sleep(std::time::Duration::from_millis(secs * 1000 + margin_ms));
}
fn retry_after_ms(output: &Output) -> Option<u64> {
let msg = failure_text(output);
let idx = msg.find("retry-after:")?;
let secs: String = msg[idx + "retry-after:".len()..]
.trim_start()
.chars()
.take_while(char::is_ascii_digit)
.collect();
secs.parse::<u64>().ok().map(|s| s.saturating_mul(1000))
}
fn finish(args: &[&str], output: &Output) -> Result<String> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let msg = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
return Err(anyhow!("gh {} failed: {msg}", args.join(" ")));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn run_stdin(args: &[&str], stdin: &[u8]) -> Result<String> {
retrying(args, Some(stdin))
}
pub fn run_json<T: DeserializeOwned>(args: &[&str]) -> Result<T> {
let stdout = run(args)?;
decode(&stdout, || format!("gh {}", args.join(" ")))
}
fn decode<T: DeserializeOwned>(stdout: &str, what: impl Fn() -> String) -> Result<T> {
serde_json::from_str(stdout).with_context(|| {
format!(
"decoding JSON from `{}` (got {} bytes)",
what(),
stdout.len()
)
})
}
pub fn graphql(query: &str, vars: &[(&str, &str)]) -> Result<Value> {
let mut args: Vec<String> = vec![
"api".into(),
"graphql".into(),
"-H".into(),
"GraphQL-Features: issue_fields".into(),
"-f".into(),
format!("query={query}"),
];
for (k, v) in vars {
args.push("-F".into());
args.push(format!("{k}={v}"));
}
let str_args: Vec<&str> = args.iter().map(String::as_str).collect();
let stdout = run(&str_args)?;
decode(&stdout, || "gh api graphql".to_string())
}
pub fn api(method: &str, path: &str, body: Option<&Value>) -> Result<String> {
let mut args: Vec<String> = vec![
"api".into(),
"--method".into(),
method.into(),
"-H".into(),
"Accept: application/vnd.github+json".into(),
"-H".into(),
format!("X-GitHub-Api-Version: {API_VERSION}"),
path.trim_start_matches('/').to_string(),
];
let stdin = match body {
Some(v) => Some(serde_json::to_vec(v).context("encoding JSON body")?),
None => None,
};
if stdin.is_some() {
args.push("--input".into());
args.push("-".into());
}
let str_args: Vec<&str> = args.iter().map(String::as_str).collect();
retrying(&str_args, stdin.as_deref())
}
pub fn api_json<T: DeserializeOwned>(method: &str, path: &str, body: Option<&Value>) -> Result<T> {
let stdout = api(method, path, body)?;
decode(&stdout, || format!("gh api {method} {path}"))
}
pub const MIN_GH_VERSION: (u32, u32, u32) = (2, 94, 0);
pub fn version() -> Option<(u32, u32, u32)> {
let out = Command::new("gh")
.arg("--version")
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())?;
parse_version(&String::from_utf8_lossy(&out.stdout))
}
pub fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
let token = text.lines().next()?.split_whitespace().nth(2)?;
let core = token.split(['-', '+']).next()?;
let mut parts = core.split('.').map(str::parse::<u32>);
Some((
parts.next()?.ok()?,
parts.next()?.ok()?,
parts.next().unwrap_or(Ok(0)).ok()?,
))
}
pub fn version_string(v: (u32, u32, u32)) -> String {
format!("{}.{}.{}", v.0, v.1, v.2)
}
pub fn gh_on_path() -> bool {
version().is_some()
}
pub fn auth_logged_in() -> bool {
Command::new("gh")
.args(["auth", "status"])
.env("GH_PROMPT", "never")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn spawn(args: &[&str], stdin: Option<&[u8]>) -> Result<Output> {
let mut cmd = base_command();
cmd.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = cmd.spawn().with_context(|| {
format!(
"failed to execute `gh {}` — is gh on PATH? brew install gh",
args.join(" ")
)
})?;
if let Some(data) = stdin {
let mut pipe = child.stdin.take().context("opening gh stdin")?;
pipe.write_all(data).context("writing JSON to gh stdin")?;
}
child.wait_with_output().context("waiting for gh")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gh_on_path_does_not_panic() {
let _ = gh_on_path();
}
#[test]
fn parses_gh_version_line() {
assert_eq!(
parse_version("gh version 2.100.0 (2026-09-03)\nhttps://…"),
Some((2, 100, 0))
);
assert_eq!(parse_version("gh version 2.94.0"), Some((2, 94, 0)));
assert_eq!(
parse_version("gh version 2.95.0-rc.1 (…)"),
Some((2, 95, 0))
);
assert_eq!(parse_version("gh version 0.0.0-fake"), Some((0, 0, 0)));
assert_eq!(parse_version("something else"), None);
assert!((2, 94, 0) >= MIN_GH_VERSION);
assert!((2, 93, 9) < MIN_GH_VERSION);
}
}