use super::token;
use super::types::{
GitHubAnnotation, GitHubCheckRun, GitHubComment, GitHubIssue, GitHubPullRequest, GitHubReview,
GitHubReviewComment,
};
use crate::engine::cooldown as engine_cooldown;
use anyhow::Context;
use reqwest::{header, Client, Response, StatusCode};
use serde::Serialize;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex, OnceLock,
};
use std::time::{Duration, Instant};
use urlencoding;
#[cfg(not(test))]
const GITHUB_API: &str = "https://api.github.com";
#[cfg(test)]
const GITHUB_API: &str = "http://127.0.0.1:9";
#[derive(Debug, serde::Deserialize)]
struct BlockedByPayload {
#[serde(default)]
id: i64,
number: i64,
#[serde(default)]
state: String,
#[serde(default)]
state_reason: Option<String>,
#[serde(default)]
repository: Option<BlockedByRepoPayload>,
}
#[derive(Debug, serde::Deserialize)]
struct BlockedByRepoPayload {
full_name: String,
}
fn parse_positive_issue_number(number: &str) -> anyhow::Result<i64> {
let parsed = number
.parse::<i64>()
.with_context(|| format!("invalid issue number: {number}"))?;
if parsed <= 0 {
anyhow::bail!("invalid issue number: {number}");
}
Ok(parsed)
}
struct RateLimit {
remaining: Option<u32>,
reset_at: Option<u64>,
backoff_until: Option<Instant>,
backoff_delay: Duration,
backoff_base: Duration,
backoff_max: Duration,
throttle_threshold: u32,
}
impl RateLimit {
fn new() -> Self {
let base = crate::config::get("gh.backoff.base_seconds")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(30);
let max = crate::config::get("gh.backoff.max_seconds")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(900);
let threshold = crate::config::get("gh.rate_limit.throttle_threshold")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(200);
Self {
remaining: None,
reset_at: None,
backoff_until: None,
backoff_delay: Duration::ZERO,
backoff_base: Duration::from_secs(base),
backoff_max: Duration::from_secs(max),
throttle_threshold: threshold,
}
}
fn update_from_headers(&mut self, headers: &header::HeaderMap) {
if let Some(v) = headers.get("x-ratelimit-remaining") {
self.remaining = v.to_str().ok().and_then(|s| s.parse().ok());
}
if let Some(v) = headers.get("x-ratelimit-reset") {
self.reset_at = v.to_str().ok().and_then(|s| s.parse().ok());
}
}
fn record_success(&mut self) {
if self.backoff_delay > Duration::ZERO {
tracing::info!("GitHub backoff cleared after successful API call");
}
self.backoff_delay = Duration::ZERO;
self.backoff_until = None;
}
fn record_rate_limit(&mut self) {
METRIC_RATE_LIMIT_HITS.fetch_add(1, Ordering::Relaxed);
if let Some(reset_epoch) = self.reset_at {
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(since_epoch) => {
let now_epoch = since_epoch.as_secs();
if reset_epoch > now_epoch {
let wait_secs = reset_epoch - now_epoch + 1;
self.backoff_delay = Duration::from_secs(wait_secs);
self.backoff_until = Some(Instant::now() + self.backoff_delay);
tracing::warn!(
wait_secs,
"GitHub rate limit hit, waiting until reset time"
);
return;
}
}
Err(e) => {
tracing::error!(
error = %e,
"System clock is before UNIX epoch; ignoring reset_at and falling back to exponential backoff"
);
}
}
}
self.backoff_delay = if self.backoff_delay.is_zero() {
self.backoff_base
} else {
(self.backoff_delay * 2).min(self.backoff_max)
};
self.backoff_until = Some(Instant::now() + self.backoff_delay);
tracing::warn!(
delay_secs = self.backoff_delay.as_secs(),
"GitHub rate limit hit, backing off (no reset header)"
);
}
fn backoff_remaining(&self) -> Option<Duration> {
if let Some(until) = self.backoff_until {
let now = Instant::now();
if now < until {
return Some(until - now);
}
}
None
}
fn proactive_wait_duration(&self) -> Option<Duration> {
if self.backoff_remaining().is_some() {
return None;
}
if let Some(remaining) = self.remaining {
if remaining < self.throttle_threshold {
if let Some(reset_epoch) = self.reset_at {
let now_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now_epoch < reset_epoch {
return Some(Duration::from_secs(reset_epoch - now_epoch + 1));
}
}
}
}
None
}
fn is_active(&self) -> Option<Duration> {
self.backoff_remaining()
.or_else(|| self.proactive_wait_duration())
}
}
static REST_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
static GRAPHQL_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
static METRIC_RATE_LIMIT_HITS: AtomicU64 = AtomicU64::new(0);
static METRIC_PROACTIVE_THROTTLES: AtomicU64 = AtomicU64::new(0);
static METRIC_5XX_HITS: AtomicU64 = AtomicU64::new(0);
fn check_rate_limit_backoff(cell: &Mutex<RateLimit>, api: &str) -> anyhow::Result<()> {
let Some(remaining) = cell.lock().ok().and_then(|rl| rl.is_active()) else {
return Ok(());
};
METRIC_PROACTIVE_THROTTLES.fetch_add(1, Ordering::Relaxed);
anyhow::bail!(
"GitHub {} API rate-limited, skipping call for {}s",
api,
remaining.as_secs()
);
}
static CALL_COUNTS: std::sync::LazyLock<Mutex<std::collections::HashMap<(String, String), u64>>> =
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
fn count_api_call(url: &str) {
let path = url.split('?').next().unwrap_or(url);
let key = match path.split_once("/repos/") {
Some((_, rest)) => {
let mut parts = rest.split('/');
let project = format!(
"{}/{}",
parts.next().unwrap_or(""),
parts.next().unwrap_or("")
);
(project, parts.next().unwrap_or("repo").to_string())
}
None if path.ends_with("/graphql") => ("-".to_string(), "graphql".to_string()),
None => ("-".to_string(), "other".to_string()),
};
if let Ok(mut counts) = CALL_COUNTS.lock() {
*counts.entry(key).or_insert(0) += 1;
}
}
pub fn drain_call_counts() -> Vec<(String, String, u64)> {
let mut rows: Vec<_> = CALL_COUNTS
.lock()
.map(|mut c| c.drain().map(|((p, k), n)| (p, k, n)).collect())
.unwrap_or_default();
rows.sort_by_key(|r| std::cmp::Reverse(r.2));
rows
}
struct CachedPage {
etag: String,
body: String,
next: Option<String>,
}
static PAGE_CACHE: std::sync::LazyLock<Mutex<std::collections::HashMap<String, CachedPage>>> =
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
const PAGE_CACHE_MAX: usize = 64;
fn page_cache_key<T>(url: &str, query: Option<&[(&str, &str)]>) -> Option<String> {
let mut key = String::from(std::any::type_name::<T>());
key.push(' ');
key.push_str(url);
if let Some(q) = query {
for (i, (k, v)) in q.iter().enumerate() {
key.push(if i == 0 { '?' } else { '&' });
key.push_str(k);
key.push('=');
key.push_str(v);
}
}
if key.contains("since=") {
return None;
}
Some(key)
}
fn page_cache_etag(key: &str) -> Option<String> {
PAGE_CACHE.lock().ok()?.get(key).map(|c| c.etag.clone())
}
fn page_cache_hit(key: &str) -> Option<(String, Option<String>)> {
let cache = PAGE_CACHE.lock().ok()?;
let entry = cache.get(key)?;
Some((entry.body.clone(), entry.next.clone()))
}
fn page_cache_store(key: String, etag: String, body: String, next: Option<String>) {
let Ok(mut cache) = PAGE_CACHE.lock() else {
return;
};
if cache.len() >= PAGE_CACHE_MAX && !cache.contains_key(&key) {
cache.clear();
}
cache.insert(key, CachedPage { etag, body, next });
}
fn maybe_record_body_rate_limit(cell: &Mutex<RateLimit>, status: StatusCode, body: &str) {
if status == StatusCode::FORBIDDEN {
let lower = body.to_lowercase();
if lower.contains("rate limit")
|| lower.contains("abuse detection")
|| lower.contains("secondary rate")
{
if let Ok(mut rl) = cell.lock() {
rl.record_rate_limit();
}
}
}
}
fn record_response_for_limiter(cell: &Mutex<RateLimit>, resp: &Response) {
if let Ok(mut rl) = cell.lock() {
rl.update_from_headers(resp.headers());
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
rl.record_rate_limit();
} else if resp.status().is_success() || resp.status() == StatusCode::NOT_MODIFIED {
rl.record_success();
}
}
let status = resp.status();
if status.is_server_error() {
METRIC_5XX_HITS.fetch_add(1, Ordering::Relaxed);
if tokio::runtime::Handle::try_current().is_ok() {
tokio::spawn(async move {
crate::engine::cooldown::record_github_5xx().await;
});
}
}
}
#[derive(Clone)]
pub struct GhHttp {
client: Client,
token_resolver: Arc<token::TokenResolver>,
cached_username: OnceLock<String>,
}
impl GhHttp {
pub fn new() -> anyhow::Result<Self> {
let client = Client::builder()
.user_agent("orch/0.1 (reqwest)")
.pool_max_idle_per_host(4)
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| anyhow::anyhow!("failed to build HTTP client (TLS init): {e}"))?;
Ok(Self {
client,
token_resolver: token::shared(),
cached_username: OnceLock::new(),
})
}
pub fn is_rate_limited() -> Option<Duration> {
REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.is_active())
}
fn check_backoff(url: &str) -> anyhow::Result<()> {
count_api_call(url);
check_rate_limit_backoff(&REST_RATE_LIMIT, "REST")
}
fn check_graphql_backoff(url: &str) -> anyhow::Result<()> {
count_api_call(url);
check_rate_limit_backoff(&GRAPHQL_RATE_LIMIT, "GraphQL")
}
fn record_response(resp: &Response) {
record_response_for_limiter(&REST_RATE_LIMIT, resp);
}
fn record_graphql_response(resp: &Response) {
record_response_for_limiter(&GRAPHQL_RATE_LIMIT, resp);
}
fn maybe_record_rate_limit_from_body(status: StatusCode, body: &str) {
maybe_record_body_rate_limit(&REST_RATE_LIMIT, status, body);
}
fn maybe_record_graphql_rate_limit_from_body(status: StatusCode, body: &str) {
maybe_record_body_rate_limit(&GRAPHQL_RATE_LIMIT, status, body);
}
fn record_success() {
if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
rl.record_success();
}
}
async fn auth_header(&self) -> anyhow::Result<String> {
match self.token_resolver.get_token().await? {
Some(token) if !token.is_empty() => Ok(format!("Bearer {token}")),
_ => anyhow::bail!("No GitHub token available — run `gh auth login` or set GH_TOKEN"),
}
}
async fn send_with_retries<F>(
&self,
mut make_req: F,
is_graphql: bool,
) -> anyhow::Result<Response>
where
F: FnMut() -> anyhow::Result<reqwest::RequestBuilder>,
{
if engine_cooldown::is_agent_in_cooldown("github:5xx") {
if let Some(until) = engine_cooldown::cooldown_until("github:5xx") {
let now = chrono::Utc::now().timestamp();
let remaining = if until > now { until - now } else { 0 };
anyhow::bail!(
"GitHub API transient 5xx circuit-breaker active for {}s",
remaining
);
} else {
anyhow::bail!("GitHub API transient 5xx circuit-breaker active");
}
}
let attempts = crate::config::get("gh.5xx.retry_attempts")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(3);
let base_secs = crate::config::get("gh.5xx.base_backoff_seconds")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1);
let max_secs = crate::config::get("gh.5xx.max_backoff_seconds")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(30);
let circuit_cooldown_secs = crate::config::get("gh.5xx.circuit_breaker_cooldown_seconds")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(300);
let mut last_err: Option<anyhow::Error> = None;
let mut auth_override: Option<String> = None;
let mut auth_retried = false;
for attempt in 0..attempts {
let mut req = make_req()?;
if let Some(auth) = &auth_override {
req = req.header(header::AUTHORIZATION, auth.clone());
}
let resp_result = req.send().await;
match resp_result {
Ok(resp) => {
if is_graphql {
Self::record_graphql_response(&resp);
} else {
Self::record_response(&resp);
}
if resp.status() == StatusCode::UNAUTHORIZED {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if body.contains("Bad credentials") && !auth_retried {
tracing::warn!(
"GitHub API 401 Bad credentials — clearing cached token and re-resolving"
);
self.token_resolver.clear_cache().await;
match self.auth_header().await {
Ok(new_auth) => {
auth_retried = true;
auth_override = Some(new_auth);
}
Err(e) => {
last_err = Some(
e.context("GitHub token re-resolution failed after 401"),
);
break;
}
}
continue;
}
if auth_retried {
self.token_resolver.clear_cache().await;
anyhow::bail!(
"GitHub authentication failed ({status}): token re-resolved after cache clear but still rejected"
);
}
anyhow::bail!("GitHub API request failed ({status}): {body}");
}
if resp.status().is_server_error() {
let status = resp.status();
let headers = resp.headers().clone();
let body = resp.text().await.unwrap_or_default();
let req_id = headers
.get("x-github-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("<none>");
let snippet: String = body.chars().take(400).collect();
tracing::warn!(
status = %status,
request_id = %req_id,
attempt,
"GitHub server error (5xx) — attempt {attempt}: {snippet}"
);
last_err = Some(anyhow::anyhow!(
"GitHub API server error ({status}) request_id={}: {}",
req_id,
snippet
));
if attempt + 1 < attempts {
let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
let mut backoff = base_secs.saturating_mul(exp);
if backoff > max_secs {
backoff = max_secs;
}
let jitter_ms = attempt as u64 * 100;
let sleep_dur = Duration::from_secs(backoff)
+ Duration::from_millis(jitter_ms.min(1000));
tokio::time::sleep(sleep_dur).await;
continue;
}
tracing::warn!(
"GitHub API 5xx persisted after {} attempts — setting circuit-breaker for {}s",
attempts,
circuit_cooldown_secs
);
engine_cooldown::set_agent_cooldown("github:5xx", circuit_cooldown_secs)
.await;
break;
}
return Ok(resp);
}
Err(e) => {
tracing::warn!(err = %e, attempt, "HTTP request failed to send (transport error, GitHub not reached), will retry if attempts remain");
last_err = Some(anyhow::anyhow!(
"HTTP request failed to send (transport error): {}",
e
));
if attempt + 1 < attempts {
let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
let backoff = base_secs.saturating_mul(exp);
let sleep_dur = Duration::from_secs(backoff)
+ Duration::from_millis((attempt as u64 * 100).min(1000));
tokio::time::sleep(sleep_dur).await;
continue;
}
tracing::warn!(
"HTTP request failed to send after {} attempts (transport error, GitHub not reached) — not tripping the github:5xx circuit-breaker",
attempts
);
break;
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unexpected send_with_retries failure")))
}
async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> anyhow::Result<T> {
Self::check_backoff(url)?;
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.get(url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
}
let body = resp.text().await?;
serde_json::from_str(&body)
.with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
}
async fn get_bytes(&self, url: &str) -> anyhow::Result<Vec<u8>> {
Self::check_backoff(url)?;
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.get(url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
}
Ok(resp.bytes().await?.to_vec())
}
async fn get_with_query<T: serde::de::DeserializeOwned>(
&self,
url: &str,
query: &[(&str, &str)],
) -> anyhow::Result<T> {
Self::check_backoff(url)?;
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.get(url)
.query(query)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
}
let body = resp.text().await?;
serde_json::from_str(&body)
.with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
}
async fn post_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
Self::check_backoff(url)?;
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.post(url)
.json(body)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
let text_res = resp.text().await;
if !status.is_success() {
let text = text_res.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &text);
anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
}
let text = text_res?;
Ok(text)
}
async fn post_json<T: serde::de::DeserializeOwned>(
&self,
url: &str,
body: &serde_json::Value,
) -> anyhow::Result<T> {
let text = self.post_json_raw(url, body).await?;
Ok(serde_json::from_str(&text)?)
}
async fn patch_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
Self::check_backoff(url)?;
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.patch(url)
.json(body)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
let text_res = resp.text().await;
if !status.is_success() {
let text = text_res.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &text);
anyhow::bail!("GitHub API PATCH {url} failed ({status}): {text}");
}
let text = text_res?;
Ok(text)
}
async fn delete(&self, url: &str) -> anyhow::Result<StatusCode> {
Self::check_backoff(url)?;
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.delete(url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
if !status.is_success() && status != StatusCode::NOT_FOUND {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!("GitHub API DELETE {url} failed ({status}): {body}");
}
Ok(status)
}
async fn get_all_pages<T: serde::de::DeserializeOwned>(
&self,
url: &str,
query: &[(&str, &str)],
) -> anyhow::Result<Vec<T>> {
Self::check_backoff(url)?;
let mut all: Vec<T> = Vec::new();
let mut next_url: Option<String> = None;
let mut is_first = true;
const MAX_JSON_DECODE_RETRIES: u32 = 2;
const JSON_DECODE_RETRY_BACKOFF_MS: u64 = 200;
loop {
let (request_url, request_query) = if is_first {
is_first = false;
(url, Some(query))
} else {
let Some(u) = next_url.as_ref() else { break };
(u.as_str(), None)
};
let cache_key = page_cache_key::<T>(request_url, request_query);
let mut cached_etag = cache_key.as_deref().and_then(page_cache_etag);
let mut decode_attempt = 0;
let (page, next) = loop {
let auth = self.auth_header().await?;
let mut req = self
.client
.get(request_url)
.header(header::AUTHORIZATION, auth)
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28");
if let Some(q) = request_query {
req = req.query(q);
}
if let Some(etag) = &cached_etag {
req = req.header(header::IF_NONE_MATCH, etag.clone());
}
let make_req = || {
req.try_clone()
.ok_or_else(|| anyhow::anyhow!("request clone failed"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
let next_from_headers = parse_link_next(resp.headers());
let etag = resp
.headers()
.get(header::ETAG)
.and_then(|v| v.to_str().ok())
.map(|v| v.to_string());
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| v.to_string())
.unwrap_or_else(|| "<missing>".to_string());
if status == StatusCode::NOT_MODIFIED {
match cache_key.as_deref().and_then(page_cache_hit) {
Some((body, next)) => break (serde_json::from_str::<Vec<T>>(&body)?, next),
None => {
cached_etag = None;
continue;
}
}
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!("GitHub API GET (paginated) failed ({status}): {body}");
}
let text = resp.text().await?;
match serde_json::from_str::<Vec<T>>(&text) {
Ok(page) => {
if let (Some(key), Some(etag)) = (cache_key.clone(), etag) {
page_cache_store(key, etag, text, next_from_headers.clone());
}
break (page, next_from_headers);
}
Err(err) => {
let snippet: String = text.chars().take(200).collect();
let decode_error = anyhow::anyhow!(
"GitHub API GET (paginated) JSON decode failed ({status}, content-type={content_type}): {snippet}"
)
.context(err);
if decode_attempt < MAX_JSON_DECODE_RETRIES {
decode_attempt += 1;
tracing::warn!(
attempt = decode_attempt,
max_attempts = MAX_JSON_DECODE_RETRIES,
status = %status,
content_type,
err = %decode_error,
"json decode failed; retrying paginated GET"
);
tokio::time::sleep(Duration::from_millis(
JSON_DECODE_RETRY_BACKOFF_MS * u64::from(decode_attempt),
))
.await;
continue;
}
return Err(decode_error);
}
}
};
next_url = next;
all.extend(page);
if next_url.is_none() {
break;
}
}
Ok(all)
}
async fn get_all_pages_from_wrapper<T: serde::de::DeserializeOwned>(
&self,
url: &str,
array_field: &str,
) -> anyhow::Result<Vec<T>> {
Self::check_backoff(url)?;
let mut all: Vec<T> = Vec::new();
let mut current_url: String = url.to_string();
loop {
let auth = self.auth_header().await?;
let make_req = || {
self.client
.get(¤t_url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.try_clone()
.ok_or_else(|| anyhow::anyhow!("request clone failed"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
let next = parse_link_next(resp.headers());
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!(
"GitHub API GET (wrapped paginated) {current_url} failed ({status}): {body}"
);
}
let body = resp.text().await?;
let wrapper: serde_json::Value = serde_json::from_str(&body).with_context(|| {
format!("failed to parse wrapper response from {current_url}: {body}")
})?;
let items = wrapper
.get(array_field)
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for item in items {
match serde_json::from_value::<T>(item) {
Ok(v) => all.push(v),
Err(e) => {
tracing::warn!(
field = array_field,
err = %e,
"skipping item that failed to deserialize in wrapped pagination"
);
}
}
}
match next {
Some(next_url) => current_url = next_url,
None => break,
}
}
Ok(all)
}
async fn graphql_request(
&self,
query: &str,
extra_headers: &[(&str, &str)],
variables: Option<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
self.graphql_request_to_url(
&format!("{GITHUB_API}/graphql"),
query,
extra_headers,
variables,
)
.await
}
async fn graphql_request_to_url(
&self,
url: &str,
query: &str,
extra_headers: &[(&str, &str)],
variables: Option<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
Self::check_graphql_backoff(url)?;
let body = if let Some(vars) = variables {
serde_json::json!({ "query": query, "variables": vars })
} else {
serde_json::json!({ "query": query })
};
let auth = self.auth_header().await?;
let mut req = self
.client
.post(url)
.json(&body)
.header(header::AUTHORIZATION, auth)
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28");
for (k, v) in extra_headers {
req = req.header(*k, *v);
}
let make_req = || {
req.try_clone()
.ok_or_else(|| anyhow::anyhow!("graphql request clone failed"))
};
let resp = self.send_with_retries(make_req, true).await?;
let status = resp.status();
let text_res = resp.text().await;
if !status.is_success() {
let text = text_res.unwrap_or_default();
Self::maybe_record_graphql_rate_limit_from_body(status, &text);
anyhow::bail!("GitHub GraphQL failed ({status}): {text}");
}
let text = text_res?;
let json: serde_json::Value = serde_json::from_str(&text)?;
if let Some(errors) = json.get("errors").and_then(|e| e.as_array()) {
if !errors.is_empty() {
let messages: Vec<&str> = errors
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect();
if json.get("data").is_none() || json["data"].is_null() {
anyhow::bail!("GitHub GraphQL errors: {}", messages.join("; "));
}
tracing::warn!(errors = ?messages, "GitHub GraphQL returned partial errors");
}
}
Ok(json)
}
pub async fn graphql_with_vars(
&self,
query: &str,
variables: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
self.graphql_request(query, &[], Some(variables)).await
}
pub async fn graphql_with_headers(
&self,
query: &str,
headers: &[&str],
) -> anyhow::Result<serde_json::Value> {
let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
self.graphql_request(query, &pairs, None).await
}
pub async fn graphql_with_headers_and_vars(
&self,
query: &str,
headers: &[&str],
variables: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
self.graphql_request(query, &pairs, Some(variables)).await
}
pub async fn auth_status(&self) -> anyhow::Result<()> {
match self
.get_json::<serde_json::Value>(&format!("{GITHUB_API}/user"))
.await
{
Ok(_) => Ok(()),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Bad credentials") {
anyhow::bail!(
"GitHub authentication failed: Invalid or expired token. \
Check your GH_TOKEN/GITHUB_TOKEN environment variables, \
or gh.auth configuration in ~/.orch/config.yml. \
Run `orch auth check` to verify your setup."
);
}
if err_str.contains("403") && err_str.contains("rate limit") {
anyhow::bail!(
"GitHub API rate limited. Wait before retrying, \
or check your token's rate limit quota."
);
}
Err(e)
}
}
}
pub async fn create_issue(
&self,
repo: &str,
title: &str,
body: &str,
labels: &[String],
) -> anyhow::Result<GitHubIssue> {
let url = format!("{GITHUB_API}/repos/{repo}/issues");
let payload = serde_json::json!({
"title": title,
"body": body,
"labels": labels,
});
self.post_json(&url, &payload).await
}
pub async fn update_issue(
&self,
repo: &str,
number: u64,
title: &str,
body: &str,
labels: &[String],
) -> anyhow::Result<GitHubIssue> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
let payload = serde_json::json!({
"title": title,
"body": body,
"labels": labels,
});
let text = self.patch_json_raw(&url, &payload).await?;
serde_json::from_str(&text)
.with_context(|| format!("failed to parse issue update response: {text}"))
}
pub async fn list_blocked_by(
&self,
repo: &str,
issue_number: u64,
) -> anyhow::Result<crate::backends::BlockedByFetch> {
use crate::backends::{BlockedByFetch, BlockedByIssue};
let mut next = Some(format!(
"{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by?per_page=100"
));
let mut found = Vec::new();
while let Some(url) = next.take() {
let resp = match self
.dependency_request(&url, reqwest::Method::GET, None)
.await
{
Ok(resp) => resp,
Err(err) => return Ok(BlockedByFetch::Failed(err.to_string())),
};
let status = resp.status();
if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
let body = resp.text().await.unwrap_or_default();
if status == StatusCode::FORBIDDEN
&& body.to_ascii_lowercase().contains("rate limit")
{
Self::maybe_record_rate_limit_from_body(status, &body);
return Ok(BlockedByFetch::Failed(body));
}
return Ok(BlockedByFetch::Unavailable);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
return Ok(BlockedByFetch::Failed(format!("{status}: {body}")));
}
Self::record_success();
let page_next = parse_link_next(resp.headers());
let body = resp.text().await.unwrap_or_default();
let page: Vec<BlockedByPayload> = match serde_json::from_str(&body) {
Ok(page) => page,
Err(err) => {
return Ok(BlockedByFetch::Failed(format!(
"blocked_by parse failed: {err}"
)));
}
};
for item in page {
if item.number <= 0 {
continue;
}
let item_repo = item
.repository
.as_ref()
.map(|r| r.full_name.clone())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| repo.to_string());
found.push(BlockedByIssue {
github_id: item.id,
repo: item_repo,
number: item.number,
state: item.state,
state_reason: item.state_reason,
});
}
next = page_next;
}
Ok(BlockedByFetch::Native(found))
}
pub async fn add_blocked_by(
&self,
repo: &str,
issue_number: u64,
blocking_issue_id: i64,
) -> anyhow::Result<()> {
let url =
format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by");
let resp = self
.dependency_request(
&url,
reqwest::Method::POST,
Some(serde_json::json!({ "issue_id": blocking_issue_id })),
)
.await?;
let status = resp.status();
if status.is_success() {
Self::record_success();
return Ok(());
}
let body = resp.text().await.unwrap_or_default();
if body.to_ascii_lowercase().contains("already") {
return Ok(());
}
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!("add blocked_by failed ({status}): {body}")
}
pub async fn remove_blocked_by(
&self,
repo: &str,
number: u64,
blocking_issue_id: u64,
) -> anyhow::Result<()> {
let url = format!(
"{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
);
self.delete(&url).await?;
Ok(())
}
async fn dependency_request(
&self,
url: &str,
method: reqwest::Method,
body: Option<serde_json::Value>,
) -> anyhow::Result<Response> {
Self::check_backoff(url)?;
let auth = self.auth_header().await?;
let make_req = || {
let mut req = self
.client
.request(method.clone(), url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2026-03-10");
if let Some(ref payload) = body {
req = req.json(payload);
}
Ok(req)
};
self.send_with_retries(make_req, false).await
}
pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
self.get_json(&format!("{GITHUB_API}/repos/{repo}/issues/{number}"))
.await
}
pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
self.get_issue(repo, number)
.await
.map(|i| i.pull_request.is_some())
.unwrap_or(false)
}
#[allow(dead_code)]
pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
self.list_issues_with_state(repo, label, "open").await
}
pub async fn list_issues_with_state(
&self,
repo: &str,
label: &str,
state: &str,
) -> anyhow::Result<Vec<GitHubIssue>> {
let url = format!("{GITHUB_API}/repos/{repo}/issues");
let mut query = vec![("state", state), ("per_page", "100")];
if !label.is_empty() {
query.push(("labels", label));
}
let all: Vec<GitHubIssue> = self.get_all_pages(&url, &query).await?;
Ok(all
.into_iter()
.filter(|i| i.pull_request.is_none())
.collect())
}
#[allow(dead_code)]
pub async fn list_issues_closed_since(
&self,
repo: &str,
label: &str,
since: &str,
) -> anyhow::Result<Vec<GitHubIssue>> {
let url = format!("{GITHUB_API}/repos/{repo}/issues");
let all: Vec<GitHubIssue> = self
.get_all_pages(
&url,
&[
("labels", label),
("state", "closed"),
("since", since),
("per_page", "100"),
],
)
.await?;
Ok(all
.into_iter()
.filter(|i| i.pull_request.is_none())
.collect())
}
pub async fn list_closed_issues_since(
&self,
repo: &str,
since: &str,
) -> anyhow::Result<Vec<GitHubIssue>> {
let url = format!("{GITHUB_API}/repos/{repo}/issues");
let all: Vec<GitHubIssue> = self
.get_all_pages(
&url,
&[("state", "closed"), ("since", since), ("per_page", "100")],
)
.await?;
Ok(all
.into_iter()
.filter(|i| i.pull_request.is_none())
.collect())
}
pub async fn list_all_open_issues(
&self,
repo: &str,
since: Option<&str>,
) -> anyhow::Result<Vec<GitHubIssue>> {
let url = format!("{GITHUB_API}/repos/{repo}/issues");
let params = match since {
Some(ts) => vec![("state", "open"), ("since", ts), ("per_page", "100")],
None => vec![("state", "open"), ("per_page", "100")],
};
let all: Vec<GitHubIssue> = self.get_all_pages(&url, ¶ms).await?;
Ok(all
.into_iter()
.filter(|i| i.pull_request.is_none())
.collect())
}
pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
let url = format!("{GITHUB_API}/repos/{repo}/pulls");
let all: Vec<serde_json::Value> = self
.get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
.await?;
Ok(all)
}
pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
let url = format!("{GITHUB_API}/repos/{repo}/issues");
let all: Vec<GitHubIssue> = self
.get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
.await?;
Ok(all
.into_iter()
.filter(|i| i.pull_request.is_none())
.collect())
}
pub async fn add_labels(
&self,
repo: &str,
number: &str,
labels: &[String],
) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
let payload = serde_json::json!({ "labels": labels });
self.post_json_raw(&url, &payload).await?;
Ok(())
}
pub async fn ensure_label(
&self,
repo: &str,
name: &str,
color: &str,
description: &str,
) -> anyhow::Result<()> {
let encoded = urlencoding::encode(name);
let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
match self.get_bytes(&get_url).await {
Ok(_) => return Ok(()),
Err(e) if e.to_string().contains("404") => {
tracing::debug!(repo, name, "ensure_label: label not found, creating");
}
Err(e) => {
tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
}
}
let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
let payload = serde_json::json!({
"name": name,
"color": color,
"description": description,
});
match self.post_json_raw(&create_url, &payload).await {
Ok(_) => {
Self::record_success();
}
Err(e) => {
let msg = e.to_string();
if msg.contains("422")
|| msg.contains("already_exists")
|| msg.contains("Unprocessable")
{
tracing::debug!(repo, name, "ensure_label: label already exists (race)");
return Ok(());
}
tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
}
}
Ok(())
}
pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
let encoded = urlencoding::encode(label);
let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
match self.delete(&url).await {
Ok(_) => Ok(()),
Err(e) if e.to_string().contains("404") => {
tracing::debug!(repo, number, label, "label already removed (404)");
Ok(())
}
Err(e) => Err(e),
}
}
pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
let payload = serde_json::json!({ "body": body });
self.post_json_raw(&url, &payload).await?;
Ok(())
}
pub async fn post_commit_status(
&self,
repo: &str,
sha: &str,
state: &str,
context: &str,
description: &str,
) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
let payload = serde_json::json!({
"state": state,
"context": context,
"description": description,
});
self.post_json_raw(&url, &payload).await?;
Ok(())
}
pub async fn add_reaction(
&self,
repo: &str,
number: &str,
content: &str,
) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
let payload = serde_json::json!({ "content": content });
self.post_json_raw(&url, &payload).await?;
Ok(())
}
pub async fn add_comment_reaction(
&self,
repo: &str,
comment_id: &str,
content: &str,
) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
let payload = serde_json::json!({ "content": content });
self.post_json_raw(&url, &payload).await?;
Ok(())
}
pub async fn list_comments(
&self,
repo: &str,
number: &str,
) -> anyhow::Result<Vec<GitHubComment>> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
self.get_all_pages(&url, &[("per_page", "100")]).await
}
pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
let owner = repo
.split('/')
.next()
.ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
let head = format!("{}:{}", owner, branch);
let url = format!("{GITHUB_API}/repos/{repo}/pulls");
let prs: Vec<serde_json::Value> = self
.get_with_query(
&url,
&[("head", &head), ("state", "closed"), ("per_page", "1")],
)
.await?;
if prs.is_empty() {
return Ok(false);
}
let merged_at = prs[0].get("merged_at");
Ok(merged_at.map(|v| !v.is_null()).unwrap_or(false))
}
pub async fn get_closed_pr_state(
&self,
repo: &str,
branch: &str,
) -> anyhow::Result<Option<(u64, bool, String)>> {
let owner = repo
.split('/')
.next()
.ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
let head = format!("{}:{}", owner, branch);
let url = format!("{GITHUB_API}/repos/{repo}/pulls");
let prs: Vec<serde_json::Value> = self
.get_with_query(
&url,
&[("head", &head), ("state", "closed"), ("per_page", "5")],
)
.await?;
if prs.is_empty() {
return Ok(None);
}
for pr in prs {
let pr_branch = pr
.get("head")
.and_then(|h| h.get("ref"))
.and_then(|r| r.as_str())
.unwrap_or("");
if pr_branch != branch {
tracing::debug!(
repo,
branch,
pr_branch,
"skipping PR with mismatched head ref"
);
continue;
}
let pr_number = pr
.get("number")
.and_then(|n| n.as_u64())
.ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
let merged_at = pr.get("merged_at");
let merged = merged_at.map(|v| !v.is_null()).unwrap_or(false);
let state = pr
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("closed")
.to_string();
return Ok(Some((pr_number, merged, state)));
}
Ok(None)
}
pub async fn batch_is_pr_merged_by_branch(
&self,
repo: &str,
branches: &[String],
) -> anyhow::Result<std::collections::HashMap<String, bool>> {
if branches.is_empty() {
return Ok(std::collections::HashMap::new());
}
let (owner, name) = repo
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
let mut var_defs = String::new();
let mut aliases = String::new();
let mut vars = serde_json::json!({ "owner": owner, "name": name });
for (i, branch) in branches.iter().enumerate() {
var_defs.push_str(&format!(", $b{i}: String!"));
aliases.push_str(&format!(
r#"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "#
));
vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
}
let query = format!(
r#"query($owner: String!, $name: String!{var_defs}) {{
repository(owner: $owner, name: $name) {{ {aliases} }}
}}"#
);
let resp = self.graphql_with_vars(&query, vars).await?;
if let Some(errors) = resp.get("errors").and_then(|e| e.as_array()) {
if !errors.is_empty() {
let messages: Vec<&str> = errors
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect();
tracing::warn!(
repo,
errors = ?messages,
"GraphQL returned errors in batch PR merge check"
);
}
}
Self::parse_batch_pr_merged_response(&resp, branches)
}
fn parse_batch_pr_merged_response(
resp: &serde_json::Value,
branches: &[String],
) -> anyhow::Result<std::collections::HashMap<String, bool>> {
let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
let error_msg = resp
.get("errors")
.and_then(|e| e.as_array())
.and_then(|arr| arr.first())
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("no error details");
anyhow::anyhow!(
"missing /data/repository in GraphQL response: {}",
error_msg
)
})?;
let mut result = std::collections::HashMap::new();
for (i, branch) in branches.iter().enumerate() {
let alias = format!("b{i}");
let merged = repo_data
.get(&alias)
.and_then(|v| v.get("nodes"))
.and_then(|nodes| nodes.as_array())
.and_then(|arr| arr.first())
.and_then(|node| node.get("merged"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
result.insert(branch.clone(), merged);
}
Ok(result)
}
pub async fn get_mentions(
&self,
repo: &str,
since: &str,
) -> anyhow::Result<Vec<GitHubComment>> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/comments");
self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
.await
}
pub async fn get_issue_comments(
&self,
repo: &str,
issue_number: u64,
) -> anyhow::Result<Vec<GitHubComment>> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/comments");
self.get_all_pages(&url, &[("per_page", "100")]).await
}
pub async fn get_whoami(&self) -> anyhow::Result<String> {
if let Some(cached) = self.cached_username.get() {
return Ok(cached.clone());
}
let user: serde_json::Value = self.get_json(&format!("{GITHUB_API}/user")).await?;
let username = user
.get("login")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
let _ = self.cached_username.set(username.clone());
Ok(username)
}
pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
let owner = repo
.split('/')
.next()
.ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
let head = format!("{}:{}", owner, branch);
let url = format!("{GITHUB_API}/repos/{repo}/pulls");
let prs: Vec<serde_json::Value> = self
.get_with_query(
&url,
&[("head", &head), ("state", "open"), ("per_page", "1")],
)
.await?;
if prs.is_empty() {
return Ok(None);
}
prs[0]
.get("number")
.and_then(|n| n.as_u64())
.ok_or_else(|| anyhow::anyhow!("PR missing number field"))
.map(Some)
}
pub async fn create_pr(
&self,
repo: &str,
title: &str,
body: &str,
head: &str,
base: &str,
) -> anyhow::Result<String> {
use reqwest::header;
let url = format!("{GITHUB_API}/repos/{repo}/pulls");
Self::check_backoff(&url)?;
#[derive(Serialize)]
struct CreatePullRequest<'a> {
title: &'a str,
body: &'a str,
head: &'a str,
base: &'a str,
}
let payload = CreatePullRequest {
title,
body,
head,
base,
};
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.post(&url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header(header::USER_AGENT, "orch")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&payload))
};
let response = self.send_with_retries(make_req, false).await?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
Self::maybe_record_rate_limit_from_body(status, &text);
anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
}
let pr: serde_json::Value = serde_json::from_str(&text)?;
pr.get("html_url")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {:?}", pr))
}
pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
let url = format!("{GITHUB_API}/repos/{repo}");
let resp: serde_json::Value = self.get_json(&url).await?;
resp.get("default_branch")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
}
pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
self.get_json(&url).await
}
pub async fn batch_get_pr_states(
&self,
repo: &str,
pr_numbers: &[u64],
) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
if pr_numbers.is_empty() {
return Ok(std::collections::HashMap::new());
}
let (owner, name) = repo
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
const CHUNK_SIZE: usize = 50;
let mut result = std::collections::HashMap::new();
for chunk in pr_numbers.chunks(CHUNK_SIZE) {
let aliases: String = chunk
.iter()
.map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
.collect::<Vec<_>>()
.join("\n ");
let query = format!(
r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
);
let resp = self
.graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
.await?;
let repo_data = resp
.pointer("/data/repository")
.ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
for n in chunk {
if let Some(pr) = repo_data.get(format!("pr{n}")) {
let merged = pr.get("merged").and_then(|v| v.as_bool()).unwrap_or(false);
let state = pr
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN")
.to_lowercase();
result.insert(*n, (merged, state));
}
}
}
Ok(result)
}
pub async fn batch_get_issue_states(
&self,
repo: &str,
issue_numbers: &[u64],
caller_context: &str,
) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
if issue_numbers.is_empty() {
return Ok(std::collections::HashMap::new());
}
let (valid_issue_numbers, invalid_issue_numbers) =
sanitize_github_issue_numbers(issue_numbers);
if !invalid_issue_numbers.is_empty() {
tracing::warn!(
repo,
caller_context,
invalid_issue_numbers = ?invalid_issue_numbers,
total_supplied = issue_numbers.len(),
"dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
);
}
if valid_issue_numbers.is_empty() {
return Ok(std::collections::HashMap::new());
}
let (owner, name) = repo
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
const CHUNK_SIZE: usize = 50;
let mut result = std::collections::HashMap::new();
for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
let aliases: String = chunk
.iter()
.map(|n| {
format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
})
.collect::<Vec<_>>()
.join(" ");
let query = format!(
r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
);
let resp = self
.graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
.await?;
let repo_data = resp
.pointer("/data/repository")
.ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
for n in chunk {
if let Some(issue) = repo_data.get(format!("issue{n}")) {
let state = issue
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN")
.to_lowercase();
let labels: Vec<String> = issue
.pointer("/labels/nodes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|l| {
l.get("name").and_then(|nm| nm.as_str()).map(String::from)
})
.collect()
})
.unwrap_or_default();
result.insert(n.to_string(), (state, labels));
}
}
}
Ok(result)
}
pub async fn get_required_status_check_contexts(
&self,
repo: &str,
branch: &str,
) -> anyhow::Result<Vec<String>> {
let branch = urlencoding::encode(branch);
let url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
let resp = self.get_json::<serde_json::Value>(&url).await;
match resp {
Ok(value) => Ok(value
.pointer("/required_status_checks/contexts")
.and_then(|v| v.as_array())
.map(|contexts| {
contexts
.iter()
.filter_map(|context| context.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()),
Err(e) => {
let err = e.to_string();
if err.contains("404") {
Ok(vec![])
} else {
Err(e)
}
}
}
}
pub async fn get_commit_status_contexts(
&self,
repo: &str,
sha: &str,
) -> anyhow::Result<Vec<(String, String)>> {
let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
let statuses: Vec<serde_json::Value> =
self.get_all_pages_from_wrapper(&url, "statuses").await?;
Ok(statuses
.into_iter()
.filter_map(|status| {
let context = status.get("context").and_then(|v| v.as_str())?;
let state = status.get("state").and_then(|v| v.as_str())?;
Some((context.to_string(), state.to_string()))
})
.collect())
}
fn combined_status_state(
total: u64,
failing: u64,
pending: u64,
has_workflows: bool,
mergeable_state: Option<&str>,
) -> String {
if failing > 0 {
"failure".to_string()
} else if pending > 0 {
"pending".to_string()
} else if total == 0 && has_workflows {
if mergeable_state == Some("clean") {
"success".to_string()
} else {
"pending".to_string()
}
} else {
"success".to_string()
}
}
pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
let resp = self.get_json::<serde_json::Value>(&url).await;
match resp {
Ok(value) => Ok(value
.get("workflows")
.and_then(|v| v.as_array())
.map(|workflows| !workflows.is_empty())
.unwrap_or(false)),
Err(e) => {
let err = e.to_string();
if err.contains("404") {
Ok(false)
} else {
Err(e)
}
}
}
}
pub async fn get_check_runs(
&self,
repo: &str,
sha: &str,
) -> anyhow::Result<Vec<GitHubCheckRun>> {
let url = format!(
"{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
);
self.get_all_pages_from_wrapper(&url, "check_runs").await
}
pub async fn get_check_run_annotations(
&self,
repo: &str,
check_run_id: u64,
) -> anyhow::Result<Vec<GitHubAnnotation>> {
let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
self.get_all_pages(&url, &[("per_page", "100")]).await
}
pub async fn get_pr_reviews(
&self,
repo: &str,
pr_number: u64,
) -> anyhow::Result<Vec<GitHubReview>> {
let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
self.get_all_pages(&url, &[("per_page", "100")]).await
}
pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
let value: serde_json::Value = self.get_json(&url).await?;
value
.pointer("/commit/committer/date")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
}
pub async fn update_pr_body(
&self,
repo: &str,
pr_number: u64,
body: &str,
) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
let payload = serde_json::json!({ "body": body });
self.patch_json_raw(&url, &payload).await?;
Ok(())
}
pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
let payload = serde_json::json!({ "state": "closed" });
self.patch_json_raw(&url, &payload).await?;
Ok(())
}
pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
let payload = serde_json::json!({ "state": "open" });
self.patch_json_raw(&url, &payload).await?;
Ok(())
}
pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
Self::check_backoff(&url)?;
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.get(&url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
if status == StatusCode::NO_CONTENT {
Ok(true)
} else if status == StatusCode::NOT_FOUND {
Ok(false)
} else {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
}
}
pub async fn add_sub_issue(
&self,
parent_node_id: &str,
child_node_id: &str,
) -> anyhow::Result<()> {
let query = format!(
r#"mutation {{
addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
issue {{ number }}
subIssue {{ number }}
}}
}}"#,
parent_node_id, child_node_id
);
self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
.await?;
Ok(())
}
pub async fn link_issue_to_branch(
&self,
repo: &str,
issue_number: u64,
branch: &str,
) -> anyhow::Result<String> {
let parts: Vec<&str> = repo.split('/').collect();
if parts.len() != 2 {
anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
}
let (owner, repo_name) = (parts[0], parts[1]);
let query = r#"query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
id
issue(number: $number) {
id
}
}
}"#;
let vars = serde_json::json!({
"owner": owner,
"name": repo_name,
"number": issue_number,
});
let result = self.graphql_with_vars(query, vars).await?;
let repo_id = result
.pointer("/data/repository/id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
let issue_id = result
.pointer("/data/repository/issue/id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
repository(owner: $owner, name: $name) {
ref(qualifiedName: $ref) {
target {
oid
}
}
}
}"#;
let branch_vars = serde_json::json!({
"owner": owner,
"name": repo_name,
"ref": format!("refs/heads/{}", branch),
});
let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
let branch_oid = branch_result
.pointer("/data/repository/ref/target/oid")
.and_then(|v| v.as_str());
let branch_oid = match branch_oid {
Some(oid) => oid.to_string(),
None => {
let default_query = r#"query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
defaultBranchRef {
target {
oid
}
}
}
}"#;
let default_vars = serde_json::json!({
"owner": owner,
"name": repo_name,
});
let default_result = self.graphql_with_vars(default_query, default_vars).await?;
match default_result
.pointer("/data/repository/defaultBranchRef/target/oid")
.and_then(|v| v.as_str())
{
Some(oid) => {
tracing::debug!(
repo,
issue_number,
branch,
"branch not found, using default branch OID to create and link"
);
oid.to_string()
}
None => {
return Ok(format!("unlinked:{}", branch));
}
}
}
};
let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
createLinkedBranch(input: {
issueId: $issueId
repositoryId: $repositoryId
name: $name
oid: $oid
}) {
linkedBranch {
id
ref {
name
}
}
}
}"#;
let mutation_vars = serde_json::json!({
"issueId": issue_id,
"repositoryId": repo_id,
"name": branch,
"oid": branch_oid,
});
let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
match link_result {
Ok(result) => {
let linked_branch_id = result
.pointer("/data/createLinkedBranch/linkedBranch/id")
.and_then(|v| v.as_str())
.map(String::from);
if let Some(id) = linked_branch_id {
tracing::info!(
repo,
issue_number,
branch,
"linked issue to branch via GraphQL API"
);
Ok(id)
} else {
let errors = result
.get("errors")
.and_then(|e| e.as_array())
.cloned()
.unwrap_or_default();
if errors.iter().any(|e| {
e.get("message")
.and_then(|m| m.as_str())
.map(|m| m.contains("already"))
.unwrap_or(false)
}) {
tracing::debug!(
repo,
issue_number,
branch,
"issue already linked to branch"
);
Ok(format!("already_linked:{}", branch))
} else {
anyhow::bail!("failed to link issue to branch: {:?}", result)
}
}
}
Err(e) => {
let err_str = format!("{}", e);
if err_str.contains("already") || err_str.contains("existing") {
tracing::debug!(
repo,
issue_number,
branch,
"issue already linked to branch (error check)"
);
Ok(format!("already_linked:{}", branch))
} else {
Err(e)
}
}
}
}
pub async fn merge_pr(
&self,
repo: &str,
pr_number: u64,
delete_branch: bool,
) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
Self::check_backoff(&url)?;
let payload = serde_json::json!({ "merge_method": "squash" });
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.put(&url)
.json(&payload)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("GitHub PR merge failed ({status}): {body}");
}
Self::record_success();
if delete_branch {
let pr: serde_json::Value = self
.get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
.await?;
if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
if let Err(e) = self.delete(&del_url).await {
let err_str = e.to_string();
if err_str.contains("Reference does not exist") {
tracing::debug!(
ref_name,
"branch already gone (GitHub auto-deleted on merge)"
);
} else {
tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
}
}
}
}
Ok(())
}
pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
Self::check_backoff(&url)?;
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.put(&url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
}
Self::record_success();
Ok(())
}
pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
let parts: Vec<&str> = repo.split('/').collect();
if parts.len() != 2 {
anyhow::bail!("invalid repo format: {repo}");
}
let (owner, repo_name) = (parts[0], parts[1]);
let pr_query = r#"query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) { id }
}
}"#;
let body = self
.graphql_with_vars(
pr_query,
serde_json::json!({
"owner": owner,
"name": repo_name,
"number": pr_number,
}),
)
.await?;
let pr_id = body
.pointer("/data/repository/pullRequest/id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
.to_string();
let mutation = r#"mutation($prId: ID!) {
enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
pullRequest { autoMergeRequest { enabledAt } }
}
}"#;
let body = self
.graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
.await?;
if let Some(errors) = body.get("errors") {
anyhow::bail!("enable auto-merge GraphQL error: {errors}");
}
tracing::info!(repo, pr_number, "auto-merge enabled on PR");
Ok(())
}
pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
let parts: Vec<&str> = repo.split('/').collect();
if parts.len() != 2 {
anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
}
let (owner, repo_name) = (parts[0], parts[1]);
let number = match parse_positive_issue_number(number) {
Ok(number) => number,
Err(e) => {
tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
return Ok(Vec::new());
}
};
let mut all_numbers: Vec<u64> = Vec::new();
let mut cursor: Option<String> = None;
let page_size = 100;
let max_pages = 50;
let mut page_count = 0;
loop {
page_count += 1;
if page_count > max_pages {
tracing::warn!(
repo,
number,
"get_sub_issues hit max page limit ({max_pages})"
);
break;
}
let (query, vars) = if let Some(ref c) = cursor {
(
r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
subIssues(first: $first, after: $after) {
nodes { number }
pageInfo { hasNextPage endCursor }
}
}
}
}"#,
serde_json::json!({
"owner": owner,
"name": repo_name,
"number": number,
"first": page_size,
"after": c,
}),
)
} else {
(
r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
subIssues(first: $first) {
nodes { number }
pageInfo { hasNextPage endCursor }
}
}
}
}"#,
serde_json::json!({
"owner": owner,
"name": repo_name,
"number": number,
"first": page_size,
}),
)
};
let result = self
.graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
.await?;
let sub_issues_data = result
.get("data")
.and_then(|d| d.get("repository"))
.and_then(|r| r.get("issue"))
.and_then(|i| i.get("subIssues"));
let nodes = sub_issues_data
.and_then(|s| s.get("nodes"))
.and_then(|n| n.as_array());
match nodes {
Some(nodes) => {
let numbers: Vec<u64> = nodes
.iter()
.filter_map(|n| n.get("number").and_then(|num| num.as_u64()))
.collect();
all_numbers.extend(numbers);
}
None => break,
}
let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
let has_next = page_info
.and_then(|p| p.get("hasNextPage"))
.and_then(|h| h.as_bool())
.unwrap_or(false);
if !has_next {
break;
}
cursor = page_info
.and_then(|p| p.get("endCursor"))
.and_then(|c| c.as_str())
.map(|s| s.to_string());
}
Ok(all_numbers)
}
pub async fn get_combined_status(
&self,
repo: &str,
git_ref: &str,
has_workflows: bool,
mergeable_state: Option<&str>,
) -> anyhow::Result<(String, u64, u64, u64, u64)> {
let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
let resp: serde_json::Value = self.get_json(&url).await?;
let runs = resp
.get("check_runs")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut passing = 0u64;
let mut failing = 0u64;
let mut pending = 0u64;
for run in &runs {
let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
let status = run
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("queued");
match status {
"completed" => {
if conclusion.is_empty() {
pending += 1;
} else if matches!(conclusion, "success" | "neutral" | "skipped") {
passing += 1;
} else {
failing += 1;
}
}
_ => pending += 1,
}
}
let total = runs.len() as u64;
let state =
Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
Ok((state, total, passing, failing, pending))
}
pub async fn rerun_failed_workflow(
&self,
repo: &str,
workflow_name: &str,
branch: &str,
) -> anyhow::Result<()> {
let url = format!(
"{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
);
let resp: serde_json::Value = self.get_json(&url).await?;
let runs = resp["workflow_runs"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
let run_id = runs
.iter()
.find(|r| {
r["name"].as_str() == Some(workflow_name)
&& matches!(
r["conclusion"].as_str(),
Some("failure") | Some("cancelled")
)
})
.and_then(|r| r["id"].as_u64())
.ok_or_else(|| {
anyhow::anyhow!(
"no failed run found for workflow '{}' on branch '{}'",
workflow_name,
branch
)
})?;
let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
let auth = self.auth_header().await?;
let make_req = || {
Ok(self
.client
.post(&rerun_url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28"))
};
let resp = self.send_with_retries(make_req, false).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
Self::maybe_record_rate_limit_from_body(status, &body);
anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
}
Ok(())
}
pub async fn batch_fetch_pr_review_data(
&self,
repo: &str,
pr_numbers: &[u64],
) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
if pr_numbers.is_empty() {
return Ok(std::collections::HashMap::new());
}
let (owner, name) = repo
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
let aliases: String = pr_numbers
.iter()
.map(|&n| build_pr_review_alias(n))
.collect::<Vec<_>>()
.join("\n");
let query = format!(
r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
);
let resp = self
.graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
.await?;
let repo_data = resp
.pointer("/data/repository")
.ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
let mut result = std::collections::HashMap::new();
for &n in pr_numbers {
let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
continue;
};
let merged = pr_data
.get("merged")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
Some("MERGEABLE") => Some(true),
Some("CONFLICTING") => Some(false),
_ => None, };
let mut reviews: Vec<GitHubReview> = pr_data
.pointer("/reviews/nodes")
.and_then(|v| v.as_array())
.map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
.unwrap_or_default();
if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
tracing::warn!(
pr_number = n,
fetched = reviews.len(),
"batch review fetch truncated reviews connection at first page"
);
}
reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
let mut review_comments: Vec<GitHubReviewComment> = pr_data
.pointer("/reviewThreads/nodes")
.and_then(|v| v.as_array())
.map(|threads| {
threads
.iter()
.filter_map(|thread| {
thread
.pointer("/comments/nodes")
.and_then(|v| v.as_array())
.map(|nodes| {
nodes
.iter()
.filter_map(parse_graphql_review_comment)
.collect::<Vec<_>>()
})
})
.flatten()
.collect()
})
.unwrap_or_default();
if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
tracing::warn!(
pr_number = n,
fetched_threads = pr_data
.pointer("/reviewThreads/nodes")
.and_then(|v| v.as_array())
.map_or(0, |threads| threads.len()),
"batch review fetch truncated reviewThreads connection at first page"
);
}
if pr_data
.pointer("/reviewThreads/nodes")
.and_then(|v| v.as_array())
.is_some_and(|threads| {
threads.iter().any(|thread| {
connection_has_next_page(thread.pointer("/comments/pageInfo"))
})
})
{
tracing::warn!(
pr_number = n,
"batch review fetch truncated one or more review thread comment connections"
);
}
review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
let mut issue_comments: Vec<GitHubComment> = pr_data
.pointer("/comments/nodes")
.and_then(|v| v.as_array())
.map(|nodes| {
nodes
.iter()
.filter_map(parse_graphql_issue_comment)
.collect()
})
.unwrap_or_default();
if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
issue_comments.extend(
self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
.await?,
);
}
issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
result.insert(
n,
PrReviewBatchData {
merged,
mergeable,
reviews,
review_comments,
issue_comments,
},
);
}
Ok(result)
}
async fn fetch_pr_issue_comments_after(
&self,
owner: &str,
name: &str,
pr_number: u64,
mut cursor: Option<String>,
) -> anyhow::Result<Vec<GitHubComment>> {
let mut comments = Vec::new();
let mut page_count = 0;
let max_pages = 50;
while let Some(current_cursor) = cursor.take() {
page_count += 1;
if page_count > max_pages {
tracing::warn!(
pr_number,
fetched = comments.len(),
"stopping PR issue comment pagination after max page limit"
);
break;
}
let (query, vars) =
build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
let resp = self.graphql_with_vars(&query, vars).await?;
let comments_data = resp
.pointer("/data/repository/pullRequest/comments")
.ok_or_else(|| {
anyhow::anyhow!(
"missing /data/repository/pullRequest/comments in GraphQL response"
)
})?;
comments.extend(
comments_data
.get("nodes")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(parse_graphql_issue_comment),
);
cursor = connection_end_cursor(comments_data.get("pageInfo"));
}
Ok(comments)
}
pub async fn get_automated_review_status(
&self,
repo: &str,
pr_number: u64,
) -> anyhow::Result<Option<String>> {
let comments = self.list_comments(repo, &pr_number.to_string()).await?;
let mut newest: Option<&crate::github::types::GitHubComment> = None;
for c in &comments {
if !c.body.starts_with("## Automated Review") {
continue;
}
match self.is_collaborator(repo, &c.user.login).await {
Ok(true) => match newest {
None => newest = Some(c),
Some(prev) => {
if c.created_at > prev.created_at {
newest = Some(c);
}
}
},
Ok(false) => {
tracing::warn!(
user = %c.user.login,
pr_number,
"ignoring automated review comment from non-collaborator"
);
continue;
}
Err(e) => {
tracing::debug!(
user = %c.user.login,
error = %e,
"failed to check collaborator status, skipping comment"
);
continue;
}
}
}
match newest {
Some(c) => {
let first_line = c.body.lines().next().unwrap_or("");
if first_line.contains("Automated Review \u{2014} Approve") {
Ok(Some("approve".to_string()))
} else if first_line.contains("Automated Review \u{2014} Changes Requested") {
Ok(Some("changes_requested".to_string()))
} else {
Ok(None)
}
}
None => Ok(None),
}
}
}
fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
let mut valid = Vec::with_capacity(issue_numbers.len());
let mut invalid = Vec::new();
for &issue_number in issue_numbers {
if issue_number == 0 {
invalid.push(issue_number);
} else {
valid.push(issue_number);
}
}
(valid, invalid)
}
#[derive(Debug, Clone)]
pub struct RateLimitMetrics {
pub hits: u64,
pub skipped_calls: u64,
pub rest_remaining: Option<u32>,
pub rest_reset_at: Option<u64>,
pub graphql_remaining: Option<u32>,
pub five_xx_hits: u64,
}
pub fn rate_limit_metrics() -> RateLimitMetrics {
let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
.lock()
.ok()
.map(|rl| (rl.remaining, rl.reset_at))
.unwrap_or((None, None));
let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
RateLimitMetrics {
hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
rest_remaining,
rest_reset_at,
graphql_remaining,
five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
}
}
fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
let link = headers.get("link")?.to_str().ok()?;
for part in link.split(',') {
let part = part.trim();
if part.contains("rel=\"next\"") {
let start = part.find('<')? + 1;
let end = part.find('>')?;
if start >= end {
return None;
}
return Some(part[start..end].to_string());
}
}
None
}
pub fn status_label_color(label: &str) -> &'static str {
match label {
"status:new" => "0e8a16",
"status:routed" => "1d76db",
"status:in_progress" => "fbca04",
"status:done" => "6f42c1",
"status:blocked" => "d73a4a",
"status:in_review" => "0075ca",
"status:needs_review" => "e4e669",
"status:waiting" => "5319e7",
_ => "c5def5",
}
}
#[derive(Debug, Clone, Default)]
pub struct PrReviewBatchData {
pub merged: bool,
pub mergeable: Option<bool>,
pub reviews: Vec<GitHubReview>,
pub review_comments: Vec<GitHubReviewComment>,
pub issue_comments: Vec<GitHubComment>,
}
fn build_pr_review_alias(n: u64) -> String {
format!(
r#"pr{n}: pullRequest(number: {n}) {{
merged
mergeable
reviews(first: 50) {{
nodes {{
databaseId
author {{ login }}
body
state
url
submittedAt
commit {{ oid }}
}}
pageInfo {{ hasNextPage }}
}}
reviewThreads(first: 50) {{
nodes {{
comments(first: 20) {{
nodes {{
databaseId
author {{ login }}
body
path
line
originalLine
commit {{ oid }}
originalCommit {{ oid }}
url
createdAt
updatedAt
replyTo {{ databaseId }}
diffHunk
}}
pageInfo {{ hasNextPage }}
}}
}}
pageInfo {{ hasNextPage }}
}}
comments(first: 50) {{
nodes {{
databaseId
author {{ login }}
body
createdAt
updatedAt
url
}}
pageInfo {{ hasNextPage endCursor }}
}}
}}"#
)
}
fn build_pr_issue_comments_page_query(
owner: &str,
name: &str,
pr_number: u64,
after_cursor: &str,
) -> (String, serde_json::Value) {
let query = r#"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
comments(first: 100, after: $after) {
nodes {
databaseId
author { login }
body
createdAt
updatedAt
url
}
pageInfo { hasNextPage endCursor }
}
}
}
}"#
.to_string();
let vars = serde_json::json!({
"owner": owner,
"name": name,
"number": pr_number,
"after": after_cursor,
});
(query, vars)
}
fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
page_info
.and_then(|v| v.get("hasNextPage"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
if !connection_has_next_page(page_info) {
return None;
}
page_info
.and_then(|v| v.get("endCursor"))
.and_then(|v| v.as_str())
.map(str::to_owned)
}
fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
use super::types::GitHubUser;
Some(GitHubReview {
id: node.get("databaseId").and_then(|v| v.as_u64())?,
user: GitHubUser {
login: node
.pointer("/author/login")
.and_then(|v| v.as_str())?
.to_string(),
},
body: node.get("body").and_then(|v| v.as_str()).map(String::from),
state: node.get("state").and_then(|v| v.as_str())?.to_string(),
html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
submitted_at: node
.get("submittedAt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
commit_id: node
.pointer("/commit/oid")
.and_then(|v| v.as_str())
.map(String::from),
})
}
fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
use super::types::GitHubUser;
Some(GitHubReviewComment {
id: node.get("databaseId").and_then(|v| v.as_u64())?,
user: GitHubUser {
login: node
.pointer("/author/login")
.and_then(|v| v.as_str())?
.to_string(),
},
body: node.get("body").and_then(|v| v.as_str())?.to_string(),
path: node
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
line: node.get("line").and_then(|v| v.as_u64()).map(|v| v as u32),
original_line: node
.get("originalLine")
.and_then(|v| v.as_u64())
.map(|v| v as u32),
commit_id: node
.pointer("/commit/oid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
original_commit_id: node
.pointer("/originalCommit/oid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
html_url: node
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
created_at: node
.get("createdAt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
updated_at: node
.get("updatedAt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
in_reply_to_id: node.pointer("/replyTo/databaseId").and_then(|v| v.as_u64()),
diff_hunk: node
.get("diffHunk")
.and_then(|v| v.as_str())
.map(String::from),
})
}
fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
use super::types::GitHubUser;
Some(GitHubComment {
id: node.get("databaseId").and_then(|v| v.as_u64())?,
body: node.get("body").and_then(|v| v.as_str())?.to_string(),
user: GitHubUser {
login: node
.pointer("/author/login")
.and_then(|v| v.as_str())?
.to_string(),
},
created_at: node
.get("createdAt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
updated_at: node
.get("updatedAt")
.and_then(|v| v.as_str())
.map(String::from),
html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
issue_url: None,
author_association: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf).await;
let declared_len = body_prefix.len() + 32;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.shutdown().await.unwrap();
});
format!("http://{addr}")
}
fn test_client() -> GhHttp {
GhHttp {
client: reqwest::Client::new(),
token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
cached_username: OnceLock::new(),
}
}
#[test]
fn gh_http_new_returns_ok() {
let result = GhHttp::new();
assert!(
result.is_ok(),
"GhHttp::new() must not panic and must return Ok; got: {:?}",
result.err()
);
}
#[test]
fn batch_get_issue_states_query_includes_labels_pagination() {
let issue_numbers = [1u64, 2, 3];
let aliases: String = issue_numbers
.iter()
.map(|n| {
format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
})
.collect::<Vec<_>>()
.join(" ");
let query = format!(
r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
);
assert!(query.contains("$owner"));
assert!(query.contains("$name"));
assert!(query.contains("issue1: issue(number: 1)"));
assert!(query.contains("issue3: issue(number: 3)"));
assert!(query.contains("labels(first: 100)"));
}
#[test]
fn parse_positive_issue_number_accepts_positive_values() {
assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
}
#[test]
fn parse_positive_issue_number_rejects_zero() {
let err = parse_positive_issue_number("0").unwrap_err().to_string();
assert!(err.contains("invalid issue number: 0"));
}
#[test]
fn parse_positive_issue_number_rejects_internal_ids() {
let err = parse_positive_issue_number("internal:5")
.unwrap_err()
.to_string();
assert!(err.contains("invalid issue number: internal:5"));
}
#[test]
fn pr_review_batch_query_includes_page_info_for_nested_connections() {
let query = build_pr_review_alias(42);
assert!(query.contains("reviews(first: 50)"));
assert!(query.contains("reviews(first: 50) {\n nodes"));
assert!(query.contains("pageInfo { hasNextPage }"));
assert!(query.contains("reviewThreads(first: 50)"));
assert!(query.contains("comments(first: 20)"));
assert!(query.contains("pageInfo { hasNextPage endCursor }"));
assert!(query.contains("updatedAt"));
}
#[test]
fn pr_issue_comments_page_query_uses_cursor_pagination() {
let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
assert!(query.contains("pullRequest(number: $number)"));
assert!(query.contains("comments(first: 100, after: $after)"));
assert!(query.contains("pageInfo { hasNextPage endCursor }"));
assert!(query.contains("updatedAt"));
assert_eq!(vars["owner"], "owner");
assert_eq!(vars["name"], "repo");
assert_eq!(vars["number"], 42);
assert_eq!(vars["after"], "cursor123");
}
#[test]
fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
let page_info = serde_json::json!({
"hasNextPage": true,
"endCursor": "cursor123"
});
assert_eq!(
connection_end_cursor(Some(&page_info)),
Some("cursor123".to_string())
);
let final_page = serde_json::json!({
"hasNextPage": false,
"endCursor": "cursor456"
});
assert_eq!(connection_end_cursor(Some(&final_page)), None);
}
#[test]
fn status_label_colors_match_bash_palette() {
assert_eq!(status_label_color("status:new"), "0e8a16");
assert_eq!(status_label_color("status:routed"), "1d76db");
assert_eq!(status_label_color("status:in_progress"), "fbca04");
assert_eq!(status_label_color("status:done"), "6f42c1");
assert_eq!(status_label_color("status:blocked"), "d73a4a");
assert_eq!(status_label_color("status:in_review"), "0075ca");
assert_eq!(status_label_color("status:needs_review"), "e4e669");
}
#[test]
fn unknown_label_gets_default_color() {
assert_eq!(status_label_color("agent:claude"), "c5def5");
assert_eq!(status_label_color("enhancement"), "c5def5");
assert_eq!(status_label_color(""), "c5def5");
}
#[test]
fn parse_link_next_finds_next_url() {
let mut headers = header::HeaderMap::new();
headers.insert(
"link",
"<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
.parse()
.unwrap(),
);
assert_eq!(
parse_link_next(&headers),
Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
);
}
#[test]
fn parse_link_next_none_when_no_next() {
let mut headers = header::HeaderMap::new();
headers.insert(
"link",
"<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
.parse()
.unwrap(),
);
assert_eq!(parse_link_next(&headers), None);
}
#[test]
fn parse_link_next_none_when_missing() {
let headers = header::HeaderMap::new();
assert_eq!(parse_link_next(&headers), None);
}
#[test]
fn parse_link_next_none_when_malformed() {
let mut headers = header::HeaderMap::new();
headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
assert_eq!(parse_link_next(&headers), None);
}
#[test]
fn parse_link_next_none_when_gt_before_lt() {
let mut headers = header::HeaderMap::new();
headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
assert_eq!(parse_link_next(&headers), None); }
#[test]
fn parse_link_next_none_when_empty_url() {
let mut headers = header::HeaderMap::new();
headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
assert_eq!(parse_link_next(&headers), None);
}
fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
RateLimit {
remaining: None,
reset_at: None,
backoff_until: None,
backoff_delay: Duration::ZERO,
backoff_base: Duration::from_secs(base_secs),
backoff_max: Duration::from_secs(max_secs),
throttle_threshold: 10,
}
}
#[test]
fn rate_limit_inactive_by_default() {
let rl = make_rl(30, 900);
assert!(rl.is_active().is_none());
}
#[test]
fn rate_limit_backoff_activates() {
let mut rl = make_rl(5, 60);
rl.record_rate_limit();
assert!(rl.is_active().is_some());
assert_eq!(rl.backoff_delay, Duration::from_secs(5));
}
#[test]
fn rate_limit_backoff_doubles() {
let mut rl = make_rl(5, 60);
rl.record_rate_limit();
assert_eq!(rl.backoff_delay, Duration::from_secs(5));
rl.record_rate_limit();
assert_eq!(rl.backoff_delay, Duration::from_secs(10));
rl.record_rate_limit();
assert_eq!(rl.backoff_delay, Duration::from_secs(20));
}
#[test]
fn rate_limit_backoff_caps_at_max() {
let mut rl = make_rl(30, 60);
rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
}
#[test]
fn rate_limit_success_resets() {
let mut rl = make_rl(5, 60);
rl.record_rate_limit();
assert!(rl.is_active().is_some());
rl.record_success();
assert!(rl.is_active().is_none());
assert_eq!(rl.backoff_delay, Duration::ZERO);
}
#[test]
fn page_cache_key_separates_types_and_queries() {
let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
assert_ne!(a, b);
assert_ne!(a, c);
assert_eq!(
a,
page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
);
}
#[test]
fn page_cache_key_skips_since_filtered_requests() {
assert!(page_cache_key::<GitHubIssue>(
"/issues",
Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
)
.is_none());
assert!(
page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
.is_none()
);
}
#[test]
fn low_quota_fails_fast_instead_of_waiting_for_reset() {
let mut rl = make_rl(30, 900);
rl.remaining = Some(0);
rl.reset_at = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600,
);
let cell = Mutex::new(rl);
let start = Instant::now();
let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
assert!(
start.elapsed() < Duration::from_secs(1),
"check blocked for {:?}",
start.elapsed()
);
assert!(err.to_string().contains("rate-limited"), "{err}");
}
#[test]
fn healthy_quota_passes_the_check() {
let mut rl = make_rl(30, 900);
rl.remaining = Some(4000);
rl.reset_at = Some(u64::MAX / 2);
let cell = Mutex::new(rl);
assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
}
#[test]
fn rate_limit_proactive_pause() {
let mut rl = make_rl(30, 900);
rl.remaining = Some(0);
let future_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 60;
rl.reset_at = Some(future_epoch);
let pause = rl.is_active();
assert!(pause.is_some());
assert!(pause.unwrap().as_secs() <= 62);
}
#[test]
fn rate_limit_proactive_pause_threshold() {
let mut rl = make_rl(30, 900);
rl.remaining = Some(15);
let future_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 60;
rl.reset_at = Some(future_epoch);
assert!(rl.is_active().is_none());
rl.remaining = Some(5);
assert!(rl.is_active().is_some());
}
#[test]
fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
assert_eq!(
GhHttp::combined_status_state(0, 0, 0, true, None),
"pending"
);
assert_eq!(
GhHttp::combined_status_state(0, 0, 0, false, None),
"success"
);
assert_eq!(
GhHttp::combined_status_state(3, 1, 0, true, None),
"failure"
);
assert_eq!(
GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
"success"
);
assert_eq!(
GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
"pending"
);
}
#[test]
fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
let resp = serde_json::json!({
"data": {},
"errors": [{ "message": "Something went wrong" }],
});
let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
.unwrap_err()
.to_string();
assert!(err_text.contains("missing /data/repository in GraphQL response"));
assert!(err_text.contains("Something went wrong"));
}
#[test]
fn rate_limit_uses_reset_at_for_backoff() {
let mut rl = make_rl(30, 900);
let future_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 45;
rl.reset_at = Some(future_epoch);
rl.record_rate_limit();
assert!(rl.backoff_delay.as_secs() >= 45);
assert!(rl.backoff_delay.as_secs() <= 47);
}
#[tokio::test]
async fn send_with_retries_propagates_make_req_error() {
let guard = std::env::var_os("GH_TOKEN");
std::env::set_var("GH_TOKEN", "test_token");
let gh = test_client();
let result = gh
.send_with_retries(
|| Err(anyhow::anyhow!("simulated request clone failure")),
false,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert_eq!(err, "simulated request clone failure");
match guard {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
}
#[tokio::test]
async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
let guard = std::env::var_os("GH_TOKEN");
std::env::set_var("GH_TOKEN", "test_token");
let addr = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
listener.local_addr().unwrap()
};
let url = format!("http://{addr}/");
let gh = test_client();
let make_req = || Ok(gh.client.get(&url));
let result = gh.send_with_retries(make_req, false).await;
assert!(result.is_err());
assert!(
!crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
"transport error must not trip the github:5xx circuit breaker"
);
assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
match guard {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
}
#[tokio::test]
async fn post_json_raw_propagates_body_read_errors_on_success() {
let guard = std::env::var_os("GH_TOKEN");
std::env::set_var("GH_TOKEN", "test_token");
let server_url = spawn_truncated_body_server("{").await;
let gh = test_client();
let result = gh
.post_json_raw(
&format!("{server_url}/post"),
&serde_json::json!({"ok": true}),
)
.await;
let err = result.unwrap_err().to_string();
assert!(
!err.contains("EOF while parsing a value"),
"should surface the body read error, got: {err}"
);
assert!(
err.contains("error decoding response body")
|| err.contains("request or response body error")
|| err.contains("end of file before message length reached"),
"expected a transport/body read error, got: {err}"
);
match guard {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
}
#[tokio::test]
async fn patch_json_raw_propagates_body_read_errors_on_success() {
let guard = std::env::var_os("GH_TOKEN");
std::env::set_var("GH_TOKEN", "test_token");
let server_url = spawn_truncated_body_server("{").await;
let gh = test_client();
let result = gh
.patch_json_raw(
&format!("{server_url}/patch"),
&serde_json::json!({"ok": true}),
)
.await;
let err = result.unwrap_err().to_string();
assert!(
!err.contains("EOF while parsing a value"),
"should surface the body read error, got: {err}"
);
assert!(
err.contains("error decoding response body")
|| err.contains("request or response body error")
|| err.contains("end of file before message length reached"),
"expected a transport/body read error, got: {err}"
);
match guard {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
}
#[tokio::test]
async fn graphql_request_propagates_body_read_errors_on_success() {
let guard = std::env::var_os("GH_TOKEN");
std::env::set_var("GH_TOKEN", "test_token");
let server_url = spawn_truncated_body_server("{").await;
let gh = test_client();
let query = "query { viewer { login } }";
let result = gh
.graphql_request_to_url(
&format!("{server_url}/graphql"),
query,
&[],
Some(serde_json::json!({"k": "v"})),
)
.await;
let err = result.unwrap_err().to_string();
assert!(
!err.contains("EOF while parsing a value"),
"should surface the body read error, got: {err}"
);
assert!(
err.contains("error decoding response body")
|| err.contains("request or response body error")
|| err.contains("end of file before message length reached"),
"expected a transport/body read error, got: {err}"
);
match guard {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
}
#[cfg(test)]
mod pagination {
use super::super::*;
use std::ffi::OsString;
use std::sync::Arc;
use wiremock::matchers::{method, path};
use wiremock::{matchers, Match, Mock, MockServer, Request, ResponseTemplate};
struct NotMatcher<M>(M);
impl<M: Match> Match for NotMatcher<M> {
fn matches(&self, request: &Request) -> bool {
!self.0.matches(request)
}
}
struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
fn make_client() -> (GhHttp, EnvVarGuard) {
let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
let client = GhHttp {
client: reqwest::Client::new(),
token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
cached_username: OnceLock::new(),
};
(client, guard)
}
#[tokio::test]
async fn get_all_pages_single_page() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
)
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/items", server.uri());
let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0]["id"], 1);
assert_eq!(result[1]["id"], 2);
}
#[tokio::test]
async fn get_all_pages_follows_link_next() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id": 1}]))
.insert_header(
"Link",
format!("<{}/items/page2>; rel=\"next\"", server.uri()),
),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/items/page2"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
)
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/items", server.uri());
let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0]["id"], 1);
assert_eq!(result[1]["id"], 2);
assert_eq!(result[2]["id"], 3);
}
#[tokio::test]
async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id": 1}]))
.insert_header(
"Link",
format!("<{}/items/page2>; rel=\"next\"", server.uri()),
),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/items/page2"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
)
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/items", server.uri());
let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
assert_eq!(result.len(), 2);
}
#[tokio::test]
async fn get_all_pages_stops_on_malformed_link_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id": 1}]))
.insert_header("Link", "garbage; rel=\"next\""),
)
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/items", server.uri());
let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
assert_eq!(result.len(), 1);
}
#[tokio::test]
async fn get_all_pages_returns_error_on_malformed_json_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
.expect(3)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/items", server.uri());
let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("JSON decode failed") || err.contains("expected ident"),
"error should mention JSON decode or serde error: {}",
err
);
}
struct GlobalStateGuard;
impl Drop for GlobalStateGuard {
fn drop(&mut self) {
if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
*rl = RateLimit::new();
}
if let Ok(mut cache) = PAGE_CACHE.lock() {
cache.clear();
}
}
}
#[tokio::test]
async fn get_all_pages_replays_body_on_304() {
let _reset = GlobalStateGuard;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/issues"))
.and(matchers::header_exists("authorization"))
.and(NotMatcher(matchers::header_exists("if-none-match")))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"number": 1}]))
.insert_header("ETag", "\"abc123\""),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/issues"))
.and(matchers::header("if-none-match", "\"abc123\""))
.respond_with(ResponseTemplate::new(304))
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/issues", server.uri());
let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
assert_eq!(first, second);
assert_eq!(second.len(), 1);
assert_eq!(second[0]["number"], 1);
}
#[tokio::test]
async fn exhausted_quota_skips_further_calls_until_reset() {
let _reset = GlobalStateGuard;
let server = MockServer::start().await;
let reset_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 600;
Mock::given(method("GET"))
.and(path("/issues"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"number": 1}]))
.insert_header("x-ratelimit-remaining", "0")
.insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
)
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/issues", server.uri());
let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
assert_eq!(first.len(), 1);
let start = Instant::now();
let err = gh
.get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
.await
.unwrap_err();
assert!(
start.elapsed() < Duration::from_secs(1),
"call blocked for {:?} instead of failing fast",
start.elapsed()
);
assert!(err.to_string().contains("rate-limited"), "{err}");
let m = rate_limit_metrics();
assert_eq!(m.rest_remaining, Some(0));
assert_eq!(m.rest_reset_at, Some(reset_at));
assert!(GhHttp::is_rate_limited().is_some());
}
#[tokio::test]
async fn get_all_pages_from_wrapper_single_page() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/check-runs"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"total_count": 2,
"check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
})))
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/check-runs", server.uri());
let result: Vec<serde_json::Value> = gh
.get_all_pages_from_wrapper(&url, "check_runs")
.await
.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0]["id"], 1);
assert_eq!(result[1]["id"], 2);
}
#[tokio::test]
async fn get_all_pages_from_wrapper_follows_link_next() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/check-runs"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({
"total_count": 3,
"check_runs": [{"id": 1, "name": "build"}]
}))
.insert_header(
"Link",
format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/check-runs/page2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"total_count": 3,
"check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
})))
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/check-runs", server.uri());
let result: Vec<serde_json::Value> = gh
.get_all_pages_from_wrapper(&url, "check_runs")
.await
.unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0]["id"], 1);
assert_eq!(result[1]["id"], 2);
assert_eq!(result[2]["id"], 3);
}
#[tokio::test]
async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/check-runs"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"total_count": 0,
"check_runs": []
})))
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/check-runs", server.uri());
let result: Vec<serde_json::Value> = gh
.get_all_pages_from_wrapper(&url, "check_runs")
.await
.unwrap();
assert_eq!(result.len(), 0);
}
#[tokio::test]
async fn get_all_pages_from_wrapper_error_on_non_success_status() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/check-runs"))
.respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
.expect(1)
.mount(&server)
.await;
let (gh, _guard) = make_client();
let url = format!("{}/check-runs", server.uri());
let result = gh
.get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("422") || err.contains("failed"),
"error should indicate failure: {err}"
);
}
}
#[cfg(test)]
mod auth_retry {
use super::test_client;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
fn bad_credentials() -> ResponseTemplate {
ResponseTemplate::new(401).set_body_json(serde_json::json!({
"message": "Bad credentials",
"status": 401
}))
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn retries_once_with_fresh_token_after_401() {
let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
let prev = std::env::var_os("GH_TOKEN");
std::env::set_var("GH_TOKEN", "bad_token");
let server = MockServer::start().await;
let hits = Arc::new(AtomicUsize::new(0));
let hits_in_handler = hits.clone();
Mock::given(method("GET"))
.respond_with(move |_req: &Request| {
if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
std::env::set_var("GH_TOKEN", "good_token");
bad_credentials()
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
}
})
.mount(&server)
.await;
let gh = test_client();
let result: anyhow::Result<serde_json::Value> =
gh.get_json(&format!("{}/x", server.uri())).await;
match prev {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
assert!(
result.is_ok(),
"retry with re-resolved token should succeed: {:?}",
result.err()
);
assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn double_401_bails_with_distinct_auth_error() {
let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
let prev = std::env::var_os("GH_TOKEN");
std::env::set_var("GH_TOKEN", "bad_token");
let server = MockServer::start().await;
let hits = Arc::new(AtomicUsize::new(0));
let hits_in_handler = hits.clone();
Mock::given(method("GET"))
.respond_with(move |_req: &Request| {
hits_in_handler.fetch_add(1, Ordering::SeqCst);
bad_credentials()
})
.mount(&server)
.await;
let gh = test_client();
let result: anyhow::Result<serde_json::Value> =
gh.get_json(&format!("{}/x", server.uri())).await;
match prev {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
let err = result.expect_err("persisting 401 must fail").to_string();
assert!(
err.contains("token re-resolved after cache clear but still rejected"),
"expected distinct auth-failure error, got: {err}"
);
assert_eq!(
hits.load(Ordering::SeqCst),
2,
"must retry exactly once, not loop"
);
}
}
}