Skip to main content

orch/github/
http.rs

1//! Native `reqwest` HTTP client for the GitHub API — replaces `gh` CLI subprocesses.
2//!
3//! Uses a shared `reqwest::Client` with connection pooling, reads rate-limit
4//! headers proactively, and supports concurrent requests via `tokio::join!`.
5//!
6//! Auth: uses the centralized [`TokenResolver`] which supports:
7//! - `GH_TOKEN` / `GITHUB_TOKEN` environment variables
8//! - `gh.auth.token` config value
9//! - `gh auth token` CLI fallback (default — just run `gh auth login`)
10
11use super::token;
12use super::types::GitHubUser;
13use super::types::{
14    GitHubAnnotation, GitHubCheckRun, GitHubComment, GitHubIssue, GitHubPullRequest, GitHubReview,
15    GitHubReviewComment, RequiredStatusCheck,
16};
17use crate::backends::BlockedByFetch;
18use crate::backends::BlockedByIssue;
19use crate::config;
20use crate::engine::cooldown as engine_cooldown;
21use crate::engine::cooldown;
22use crate::github::types;
23use crate::security;
24use anyhow::Context;
25use reqwest::{Client, Response, StatusCode, header};
26use serde::Serialize;
27use serde::de::DeserializeOwned;
28use std::any;
29use std::cmp::Reverse;
30use std::collections::HashMap;
31use std::collections::HashSet;
32use std::fmt;
33use std::fmt::Debug;
34use std::fmt::Formatter;
35use std::fmt::Write as _;
36use std::sync::LazyLock;
37use std::sync::{
38    Arc, Mutex, OnceLock,
39    atomic::{AtomicU64, Ordering},
40};
41use std::time as std_time;
42use std::time::SystemTime;
43use std::time::{Duration, Instant};
44use tokio::runtime::Handle;
45use tokio::time;
46use urlencoding;
47
48#[cfg(not(test))]
49const GITHUB_API: &str = "https://api.github.com";
50// Tests that reach a real GhHttp must fail fast here, never send the operator's token to GitHub
51#[cfg(test)]
52const GITHUB_API: &str = "http://127.0.0.1:9";
53
54#[derive(Debug, serde::Deserialize)]
55struct BlockedByPayload {
56    #[serde(default)]
57    id: i64,
58    number: i64,
59    #[serde(default)]
60    state: String,
61    #[serde(default)]
62    state_reason: Option<String>,
63    #[serde(default)]
64    repository: Option<BlockedByRepoPayload>,
65}
66
67#[derive(Debug, serde::Deserialize)]
68struct BlockedByRepoPayload {
69    full_name: String,
70}
71
72pub(crate) fn is_full_commit_sha(sha: &str) -> bool {
73    let len = sha.len();
74    (len == 40 || len == 64) && sha.bytes().all(|b| b.is_ascii_hexdigit())
75}
76
77// Shared gate for every GitHub-posted body so a new call site cannot skip the scan
78/// `owner/repo` from a REST URL such as `https://api.github.com/repos/owner/repo/pulls`
79fn repo_from_url(url: &str) -> Option<String> {
80    let rest = url.split_once("/repos/")?.1;
81    let mut parts = rest.split(['/', '?']);
82    let owner = parts.next().filter(|s| !s.is_empty())?;
83    let name = parts.next().filter(|s| !s.is_empty())?;
84    Some(format!("{owner}/{name}"))
85}
86
87pub(crate) fn redact_posted_body(body: &str) -> String {
88    if !security::has_leaks(body) {
89        return body.to_string();
90    }
91    let leaks = security::scan(body);
92    let rules: Vec<&str> = leaks.iter().map(|leak| leak.rule).collect();
93    let warning = format!(
94        "\n\n> ⚠️ **Security Notice**: {} potential secret(s) detected and redacted: {}",
95        leaks.len(),
96        rules.join(", ")
97    );
98    format!("{}{warning}", security::redact(body))
99}
100
101fn merge_pr_body(head_sha: &str) -> serde_json::Value {
102    serde_json::json!({
103        "merge_method": "squash",
104        "sha": head_sha,
105    })
106}
107
108fn parse_positive_issue_number(number: &str) -> anyhow::Result<i64> {
109    let parsed = number
110        .parse::<i64>()
111        .with_context(|| format!("invalid issue number: {number}"))?;
112    if parsed <= 0 {
113        anyhow::bail!("invalid issue number: {number}");
114    }
115    Ok(parsed)
116}
117
118// ── Rate-limit state ─────────────────────────────────────────────────
119
120/// Proactive rate-limit state derived from `X-RateLimit-*` response headers.
121struct RateLimit {
122    /// Remaining requests in the current window.
123    remaining: Option<u32>,
124    /// UTC epoch second when the window resets.
125    reset_at: Option<u64>,
126    /// Hard backoff after a 403/429 (fallback when reset time is absent).
127    backoff_until: Option<Instant>,
128    backoff_delay: Duration,
129    backoff_base: Duration,
130    backoff_max: Duration,
131    /// Stop making calls when remaining drops below this threshold (default 200). The
132    /// reserve is for the dispatched agents, which run `gh` against the same token.
133    throttle_threshold: u32,
134}
135
136impl RateLimit {
137    fn new() -> Self {
138        let base = config::get("gh.backoff.base_seconds")
139            .ok()
140            .and_then(|v| v.parse::<u64>().ok())
141            .unwrap_or(30);
142        let max = config::get("gh.backoff.max_seconds")
143            .ok()
144            .and_then(|v| v.parse::<u64>().ok())
145            .unwrap_or(900);
146        let threshold = config::get("gh.rate_limit.throttle_threshold")
147            .ok()
148            .and_then(|v| v.parse::<u32>().ok())
149            .unwrap_or(200);
150
151        Self {
152            remaining: None,
153            reset_at: None,
154            backoff_until: None,
155            backoff_delay: Duration::ZERO,
156            backoff_base: Duration::from_secs(base),
157            backoff_max: Duration::from_secs(max),
158            throttle_threshold: threshold,
159        }
160    }
161
162    /// Update state from response headers.
163    fn update_from_headers(&mut self, headers: &header::HeaderMap) {
164        if let Some(v) = headers.get("x-ratelimit-remaining") {
165            self.remaining = v.to_str().ok().and_then(|s| s.parse().ok());
166        }
167        if let Some(v) = headers.get("x-ratelimit-reset") {
168            self.reset_at = v.to_str().ok().and_then(|s| s.parse().ok());
169        }
170    }
171
172    /// Record a successful call — reset hard backoff.
173    fn record_success(&mut self) {
174        if self.backoff_delay > Duration::ZERO {
175            tracing::info!("GitHub backoff cleared after successful API call");
176        }
177        self.backoff_delay = Duration::ZERO;
178        self.backoff_until = None;
179    }
180
181    /// Record a 403/429 — use reset_at timestamp if available, else exponential backoff.
182    fn record_rate_limit(&mut self) {
183        METRIC_RATE_LIMIT_HITS.fetch_add(1, Ordering::Relaxed);
184
185        // Prefer reset_at when available — avoids over-waiting with exponential backoff
186        if let Some(reset_epoch) = self.reset_at {
187            match SystemTime::now().duration_since(std_time::UNIX_EPOCH) {
188                Ok(since_epoch) => {
189                    let now_epoch = since_epoch.as_secs();
190                    if reset_epoch > now_epoch {
191                        let wait_secs = reset_epoch - now_epoch + 1;
192                        self.backoff_delay = Duration::from_secs(wait_secs);
193                        self.backoff_until = Some(Instant::now() + self.backoff_delay);
194                        tracing::warn!(
195                            wait_secs,
196                            "GitHub rate limit hit, waiting until reset time"
197                        );
198                        return;
199                    }
200                }
201                Err(e) => {
202                    tracing::error!(
203                        error = %e,
204                        "System clock is before UNIX epoch; ignoring reset_at and falling back to exponential backoff"
205                    );
206                }
207            }
208        }
209
210        // Fall back to exponential backoff when reset time is unknown
211        self.backoff_delay = if self.backoff_delay.is_zero() {
212            self.backoff_base
213        } else {
214            (self.backoff_delay * 2).min(self.backoff_max)
215        };
216        self.backoff_until = Some(Instant::now() + self.backoff_delay);
217        tracing::warn!(
218            delay_secs = self.backoff_delay.as_secs(),
219            "GitHub rate limit hit, backing off (no reset header)"
220        );
221    }
222
223    /// Returns remaining hard-backoff duration (post-403/429), or None.
224    fn backoff_remaining(&self) -> Option<Duration> {
225        if let Some(until) = self.backoff_until {
226            let now = Instant::now();
227            if now < until {
228                return Some(until - now);
229            }
230        }
231        None
232    }
233
234    /// Returns how long until the window resets when the quota is under the reserve.
235    ///
236    /// Returns Some only when:
237    /// - not already in hard backoff,
238    /// - `remaining` is known and below `throttle_threshold`, and
239    /// - `reset_at` is known and in the future.
240    fn proactive_wait_duration(&self) -> Option<Duration> {
241        // Don't double-throttle when already in hard backoff
242        if self.backoff_remaining().is_some() {
243            return None;
244        }
245        if let Some(remaining) = self.remaining
246            && remaining < self.throttle_threshold
247            && let Some(reset_epoch) = self.reset_at
248        {
249            let now_epoch = SystemTime::now()
250                .duration_since(std_time::UNIX_EPOCH)
251                .unwrap_or_default()
252                .as_secs();
253            if now_epoch < reset_epoch {
254                return Some(Duration::from_secs(reset_epoch - now_epoch + 1));
255            }
256        }
257        None
258    }
259
260    /// Combined check: backoff or proactive throttle. Used by engine's is_rate_limited().
261    fn is_active(&self) -> Option<Duration> {
262        self.backoff_remaining()
263            .or_else(|| self.proactive_wait_duration())
264    }
265}
266
267// ── Global state ─────────────────────────────────────────────────────
268
269static REST_RATE_LIMIT: LazyLock<Mutex<RateLimit>> = LazyLock::new(|| Mutex::new(RateLimit::new()));
270
271/// Separate rate-limit tracker for the GraphQL endpoint (distinct quota from REST).
272static GRAPHQL_RATE_LIMIT: LazyLock<Mutex<RateLimit>> =
273    LazyLock::new(|| Mutex::new(RateLimit::new()));
274
275// ── Rate-limit metrics ────────────────────────────────────────────────
276
277/// Total 403/429 rate-limit responses received (REST + GraphQL).
278static METRIC_RATE_LIMIT_HITS: AtomicU64 = AtomicU64::new(0);
279/// Calls skipped because the quota was under the reserve threshold or a backoff was active.
280static METRIC_PROACTIVE_THROTTLES: AtomicU64 = AtomicU64::new(0);
281/// Total 5xx server error responses received (REST + GraphQL).
282static METRIC_5XX_HITS: AtomicU64 = AtomicU64::new(0);
283
284// ── Rate-limit generic helpers ────────────────────────────────────────
285
286/**
287 * Fail fast when the limiter is in hard backoff (post-403/429) or the remaining quota is
288 * under the reserve threshold. This must never sleep: the engine runs every tick phase
289 * through these calls, so waiting out a reset here froze the whole tick loop for up to an
290 * hour and tripped the watchdog. Returning an error lets the phase give up and the tick
291 * finish, and the next tick retries once the window resets
292 **/
293fn check_rate_limit_backoff(cell: &Mutex<RateLimit>, api: &str) -> anyhow::Result<()> {
294    let Some(remaining) = cell.lock().ok().and_then(|rl| rl.is_active()) else {
295        return Ok(());
296    };
297    METRIC_PROACTIVE_THROTTLES.fetch_add(1, Ordering::Relaxed);
298    anyhow::bail!(
299        "GitHub {} API rate-limited, skipping call for {}s",
300        api,
301        remaining.as_secs()
302    );
303}
304
305/// API calls since the last drain, keyed by (project, call type), for the per-tick debug report.
306static CALL_COUNTS: LazyLock<Mutex<HashMap<(String, String), u64>>> =
307    LazyLock::new(|| Mutex::new(HashMap::new()));
308
309/// Project is `owner/repo` from a `/repos/` URL and call type is the path segment after it
310/// (`pulls`, `issues`, `check-runs`), so non-repo endpoints land under `-`.
311fn count_api_call(url: &str) {
312    let path = url.split('?').next().unwrap_or(url);
313    let key = match path.split_once("/repos/") {
314        Some((_, rest)) => {
315            let mut parts = rest.split('/');
316            let project = format!(
317                "{}/{}",
318                parts.next().unwrap_or(""),
319                parts.next().unwrap_or("")
320            );
321            (project, parts.next().unwrap_or("repo").to_string())
322        }
323        None if path.ends_with("/graphql") => ("-".to_string(), "graphql".to_string()),
324        None => ("-".to_string(), "other".to_string()),
325    };
326    if let Ok(mut counts) = CALL_COUNTS.lock() {
327        *counts.entry(key).or_insert(0) += 1;
328    }
329}
330
331/// Take the call counts accumulated since the last drain, largest first.
332pub fn drain_call_counts() -> Vec<(String, String, u64)> {
333    let mut rows: Vec<_> = CALL_COUNTS
334        .lock()
335        .map(|mut c| c.drain().map(|((p, k), n)| (p, k, n)).collect())
336        .unwrap_or_default();
337    rows.sort_by_key(|r| Reverse(r.2));
338    rows
339}
340
341// ── Conditional-request cache ─────────────────────────────────────────
342
343/// A stored 200 response, replayed when GitHub answers the next conditional GET with 304.
344struct CachedPage {
345    etag: String,
346    body: String,
347    /// `Link: rel="next"` from the cached response; a 304 does not repeat it.
348    next: Option<String>,
349}
350
351/**
352 * Keyed by the full request URL plus the decoded type, since the body is replayed through
353 * `serde_json` and two call sites can share a URL while wanting different shapes. GitHub
354 * answers a conditional GET with 304 and no body when nothing changed, and that response
355 * does not count against the core quota, so the listing endpoints orch polls every tick
356 * cost one request per hour of quiet instead of one per tick
357 **/
358static PAGE_CACHE: LazyLock<Mutex<HashMap<String, CachedPage>>> =
359    LazyLock::new(|| Mutex::new(HashMap::new()));
360
361/// Distinct cacheable list URLs are few (per repo, per query), so the cap only guards a
362/// leak. Each entry holds a full page body, so keep it small.
363const PAGE_CACHE_MAX: usize = 64;
364
365/// `None` when the request cannot produce a hit, so it is neither sent conditionally nor
366/// stored. A `since` filter is computed from the clock on every call, so its key never
367/// recurs and caching it would only grow the map with page-sized bodies.
368fn page_cache_key<T>(url: &str, query: Option<&[(&str, &str)]>) -> Option<String> {
369    let mut key = String::from(any::type_name::<T>());
370    key.push(' ');
371    key.push_str(url);
372    if let Some(q) = query {
373        for (i, (k, v)) in q.iter().enumerate() {
374            key.push(if i == 0 { '?' } else { '&' });
375            key.push_str(k);
376            key.push('=');
377            key.push_str(v);
378        }
379    }
380    if key.contains("since=") {
381        return None;
382    }
383    Some(key)
384}
385
386fn page_cache_etag(key: &str) -> Option<String> {
387    PAGE_CACHE.lock().ok()?.get(key).map(|c| c.etag.clone())
388}
389
390/// Returns the stored body and `next` link for a 304 reply.
391fn page_cache_hit(key: &str) -> Option<(String, Option<String>)> {
392    let cache = PAGE_CACHE.lock().ok()?;
393    let entry = cache.get(key)?;
394    Some((entry.body.clone(), entry.next.clone()))
395}
396
397fn page_cache_store(key: String, etag: String, body: String, next: Option<String>) {
398    let Ok(mut cache) = PAGE_CACHE.lock() else {
399        return;
400    };
401    // ponytail: clears everything at the cap instead of evicting one entry, switch to an
402    // LRU if the hit rate ever matters more than the bookkeeping
403    if cache.len() >= PAGE_CACHE_MAX && !cache.contains_key(&key) {
404        cache.clear();
405    }
406    cache.insert(key, CachedPage { etag, body, next });
407}
408
409/// Check response body for rate-limit signals on 403 responses.
410/// Not all 403s are rate limits — some are permission errors.
411fn maybe_record_body_rate_limit(cell: &Mutex<RateLimit>, status: StatusCode, body: &str) {
412    if status == StatusCode::FORBIDDEN {
413        let lower = body.to_lowercase();
414        if (lower.contains("rate limit")
415            || lower.contains("abuse detection")
416            || lower.contains("secondary rate"))
417            && let Ok(mut rl) = cell.lock()
418        {
419            rl.record_rate_limit();
420        }
421    }
422}
423
424/// Update rate-limit state from response headers/status and detect 5xx errors.
425fn record_response_for_limiter(cell: &Mutex<RateLimit>, resp: &Response) {
426    if let Ok(mut rl) = cell.lock() {
427        rl.update_from_headers(resp.headers());
428        // Only backoff on 429 (always rate limit) — 403 requires body inspection
429        // which is handled in maybe_record_body_rate_limit().
430        if resp.status() == StatusCode::TOO_MANY_REQUESTS {
431            rl.record_rate_limit();
432        } else if resp.status().is_success() || resp.status() == StatusCode::NOT_MODIFIED {
433            rl.record_success();
434        }
435    }
436    // Detect 5xx server errors for the global circuit breaker.
437    let status = resp.status();
438    if status.is_server_error() {
439        METRIC_5XX_HITS.fetch_add(1, Ordering::Relaxed);
440        if Handle::try_current().is_ok() {
441            tokio::spawn(async move {
442                cooldown::record_github_5xx().await;
443            });
444        }
445    }
446}
447
448// ── Shared token resolver ────────────────────────────────────────────
449
450// ── GhHttp client ────────────────────────────────────────────────────
451
452/// Native HTTP client for the GitHub API with connection pooling and
453/// proactive rate-limit avoidance.
454#[derive(Clone)]
455pub struct GhHttp {
456    client: Client,
457    token_resolver: Arc<token::TokenResolver>,
458    // An App only tests set, production calls always use the gh token
459    app: Option<Arc<super::app::AppAuth>>,
460    // Calls the App cannot make, such as `GET /user` and Projects V2 on a personal account
461    owner_only: bool,
462    cached_username: OnceLock<String>,
463    #[cfg(test)]
464    api_base: Option<String>,
465}
466
467impl Debug for GhHttp {
468    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
469        f.debug_struct("GhHttp")
470            .field("owner_only", &self.owner_only)
471            .finish_non_exhaustive()
472    }
473}
474
475impl GhHttp {
476    /// Create a new client backed by the process-wide shared [`TokenResolver`].
477    ///
478    /// Token is resolved lazily on first request:
479    ///   GH_TOKEN → GITHUB_TOKEN → gh.auth.token config → gh auth token CLI
480    /// The result is cached in the shared resolver for the life of the process,
481    /// so `gh auth token` is only called once regardless of how many instances
482    /// are created.
483    pub fn new() -> anyhow::Result<Self> {
484        let client = Client::builder()
485            .user_agent("orch/0.1 (reqwest)")
486            .pool_max_idle_per_host(4)
487            .timeout(Duration::from_secs(30))
488            .build()
489            .map_err(|e| anyhow::anyhow!("failed to build HTTP client (TLS init): {e}"))?;
490
491        Ok(Self {
492            client,
493            token_resolver: token::shared(),
494            app: None,
495            owner_only: false,
496            cached_username: OnceLock::new(),
497            #[cfg(test)]
498            api_base: None,
499        })
500    }
501
502    /// A client that always uses the owner token, for calls the App cannot make, such as
503    /// `GET /user` and Projects V2 on a personal account
504    pub fn owner() -> anyhow::Result<Self> {
505        let mut gh = Self::new()?;
506        gh.owner_only = true;
507        Ok(gh)
508    }
509
510    #[cfg(test)]
511    pub(crate) fn for_test(api_base: String, app: Option<Arc<super::app::AppAuth>>) -> Self {
512        Self {
513            client: Client::new(),
514            token_resolver: Arc::new(token::TokenResolver::default_env()),
515            app,
516            owner_only: false,
517            cached_username: OnceLock::new(),
518            api_base: Some(api_base),
519        }
520    }
521
522    #[cfg_attr(
523        not(test),
524        expect(
525            clippy::unused_self,
526            reason = "only the test build reads the api base override"
527        )
528    )]
529    fn api_root(&self) -> &str {
530        #[cfg(test)]
531        if let Some(base) = &self.api_base {
532            return base;
533        }
534        GITHUB_API
535    }
536
537    // ── Rate-limit helpers ────────────────────────────────────────
538
539    /// Check if the REST GitHub API is currently rate-limited (backoff or approaching limit).
540    /// Used by the engine to decide whether to skip a tick.
541    pub fn is_rate_limited() -> Option<Duration> {
542        REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.is_active())
543    }
544
545    /// Fail fast if the REST API is in hard backoff (post-403/429).
546    fn check_backoff(url: &str) -> anyhow::Result<()> {
547        count_api_call(url);
548        check_rate_limit_backoff(&REST_RATE_LIMIT, "REST")
549    }
550
551    /// Fail fast if the GraphQL API is in hard backoff (post-403/429).
552    fn check_graphql_backoff(url: &str) -> anyhow::Result<()> {
553        count_api_call(url);
554        check_rate_limit_backoff(&GRAPHQL_RATE_LIMIT, "GraphQL")
555    }
556
557    fn record_response(resp: &Response) {
558        record_response_for_limiter(&REST_RATE_LIMIT, resp);
559    }
560
561    fn record_graphql_response(resp: &Response) {
562        record_response_for_limiter(&GRAPHQL_RATE_LIMIT, resp);
563    }
564
565    /// Check response body for rate-limit signals on 403 responses (REST).
566    fn maybe_record_rate_limit_from_body(status: StatusCode, body: &str) {
567        maybe_record_body_rate_limit(&REST_RATE_LIMIT, status, body);
568    }
569
570    /// Check response body for rate-limit signals on 403 responses (GraphQL).
571    fn maybe_record_graphql_rate_limit_from_body(status: StatusCode, body: &str) {
572        maybe_record_body_rate_limit(&GRAPHQL_RATE_LIMIT, status, body);
573    }
574
575    fn record_success() {
576        if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
577            rl.record_success();
578        }
579    }
580
581    // ── Low-level HTTP helpers ────────────────────────────────────
582
583    fn app(&self) -> Option<Arc<super::app::AppAuth>> {
584        if self.owner_only {
585            return None;
586        }
587
588        // orch's own calls use the gh token, the App only reviews (src/engine/review.rs). The App
589        // must not open PRs, GitHub refuses a review from a PR's author
590        self.app.clone()
591    }
592
593    /// The App's token for a repository it is installed on, the owner token for anything else,
594    /// including GraphQL, whose URL names no repository
595    async fn auth_header(&self, url: &str) -> anyhow::Result<String> {
596        if let (Some(app), Some(repo)) = (self.app(), repo_from_url(url)) {
597            match app.token_for_repo(&repo).await {
598                Ok(Some(token)) => return Ok(format!("Bearer {token}")),
599                Ok(None) => {}
600                Err(e) => {
601                    tracing::warn!(error = %e, repo, "GitHub App token unavailable, using the owner token");
602                }
603            }
604        }
605        match self.token_resolver.get_token().await? {
606            Some(token) if !token.is_empty() => Ok(format!("Bearer {token}")),
607            _ => anyhow::bail!("No GitHub token available — run `gh auth login` or set GH_TOKEN"),
608        }
609    }
610
611    /// Generic send wrapper that retries on 5xx server errors with exponential
612    /// backoff and sets a short-lived circuit-breaker in KV when retries fail.
613    async fn send_with_retries<F>(
614        &self,
615        mut make_req: F,
616        is_graphql: bool,
617    ) -> anyhow::Result<Response>
618    where
619        F: FnMut() -> anyhow::Result<reqwest::RequestBuilder>,
620    {
621        // Circuit-breaker: if set, fail fast to avoid wasting work.
622        if engine_cooldown::is_agent_in_cooldown("github:5xx") {
623            if let Some(until) = engine_cooldown::cooldown_until("github:5xx") {
624                let now = chrono::Utc::now().timestamp();
625                let remaining = if until > now { until - now } else { 0 };
626                anyhow::bail!("GitHub API transient 5xx circuit-breaker active for {remaining}s");
627            }
628            anyhow::bail!("GitHub API transient 5xx circuit-breaker active");
629        }
630
631        // Configurable retry params (defaults chosen conservatively)
632        let attempts = config::get("gh.5xx.retry_attempts")
633            .ok()
634            .and_then(|v| v.parse::<u32>().ok())
635            .unwrap_or(3);
636        let base_secs = config::get("gh.5xx.base_backoff_seconds")
637            .ok()
638            .and_then(|v| v.parse::<u64>().ok())
639            .unwrap_or(1);
640        let max_secs = config::get("gh.5xx.max_backoff_seconds")
641            .ok()
642            .and_then(|v| v.parse::<u64>().ok())
643            .unwrap_or(30);
644        let circuit_cooldown_secs = config::get("gh.5xx.circuit_breaker_cooldown_seconds")
645            .ok()
646            .and_then(|v| v.parse::<u64>().ok())
647            .unwrap_or(300);
648
649        let mut last_err: Option<anyhow::Error> = None;
650        // A cached token can go bad mid-process (revoked, rotated, stale keychain read).
651        // On the first 401 Bad credentials: evict the cache, re-resolve once, resend
652        let mut auth_override: Option<String> = None;
653        let mut auth_retried = false;
654        for attempt in 0..attempts {
655            let mut req = make_req()?;
656            if let Some(auth) = &auth_override {
657                req = req.header(header::AUTHORIZATION, auth.clone());
658            }
659            let resp_result = req.send().await;
660
661            match resp_result {
662                Ok(resp) => {
663                    // Record rate-limit state depending on REST vs GraphQL
664                    if is_graphql {
665                        Self::record_graphql_response(&resp);
666                    } else {
667                        Self::record_response(&resp);
668                    }
669
670                    if resp.status() == StatusCode::UNAUTHORIZED {
671                        let resp_url = resp.url().to_string();
672                        let status = resp.status();
673                        let body = resp.text().await.unwrap_or_default();
674                        if body.contains("Bad credentials") && !auth_retried {
675                            tracing::warn!(
676                                "GitHub API 401 Bad credentials — clearing cached token and re-resolving"
677                            );
678                            self.token_resolver.clear_cache().await;
679                            if let Some(app) = self.app() {
680                                app.invalidate();
681                            }
682                            match self.auth_header(&resp_url).await {
683                                Ok(new_auth) => {
684                                    auth_retried = true;
685                                    auth_override = Some(new_auth);
686                                }
687                                Err(e) => {
688                                    last_err = Some(
689                                        e.context("GitHub token re-resolution failed after 401"),
690                                    );
691                                    break;
692                                }
693                            }
694                            // ponytail: consumes one 5xx-retry slot, attempts default is 3
695                            continue;
696                        }
697                        if auth_retried {
698                            // Fresh token also rejected; leave the cache empty so the next
699                            // call re-resolves and picks up env/config fixes immediately
700                            self.token_resolver.clear_cache().await;
701                            anyhow::bail!(
702                                "GitHub authentication failed ({status}): token re-resolved after cache clear but still rejected"
703                            );
704                        }
705                        anyhow::bail!("GitHub API request failed ({status}): {body}");
706                    }
707
708                    // If server error, treat as transient and retry
709                    if resp.status().is_server_error() {
710                        let status = resp.status();
711                        let headers = resp.headers().clone();
712                        let body = resp.text().await.unwrap_or_default();
713                        let req_id = headers
714                            .get("x-github-request-id")
715                            .and_then(|v| v.to_str().ok())
716                            .unwrap_or("<none>");
717                        let snippet: String = body.chars().take(400).collect();
718                        tracing::warn!(
719                            status = %status,
720                            request_id = %req_id,
721                            attempt,
722                            "GitHub server error (5xx) — attempt {attempt}: {snippet}"
723                        );
724
725                        last_err = Some(anyhow::anyhow!(
726                            "GitHub API server error ({status}) request_id={req_id}: {snippet}"
727                        ));
728
729                        if attempt + 1 < attempts {
730                            // Exponential backoff (2^attempt * base), capped
731                            // Cap exponent at 63 (max valid shift for u64) to prevent overflow
732                            let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
733                            let mut backoff = base_secs.saturating_mul(exp);
734                            if backoff > max_secs {
735                                backoff = max_secs;
736                            }
737                            // Add small linear jitter to avoid synchronized retries
738                            let jitter_ms = u64::from(attempt) * 100;
739                            let sleep_dur = Duration::from_secs(backoff)
740                                + Duration::from_millis(jitter_ms.min(1000));
741                            time::sleep(sleep_dur).await;
742                            continue;
743                        }
744                        // Exhausted attempts — set circuit-breaker and break so last_err is read
745                        tracing::warn!(
746                            "GitHub API 5xx persisted after {} attempts — setting circuit-breaker for {}s",
747                            attempts,
748                            circuit_cooldown_secs
749                        );
750                        engine_cooldown::set_agent_cooldown("github:5xx", circuit_cooldown_secs)
751                            .await;
752                        break;
753                    }
754
755                    // Non-5xx response — return it for caller to handle success/failure
756                    return Ok(resp);
757                }
758                Err(e) => {
759                    // Transport error — the request never reached GitHub (DNS failure,
760                    // connection refused, no route to host, TLS failure, etc). This is a
761                    // local/network condition, not evidence that GitHub itself is unhealthy,
762                    // so unlike the 5xx arm above it must NOT trip the shared "github:5xx"
763                    // circuit breaker: other subsystems (routing-phase skip, cooldown
764                    // throttling) key off that breaker as a signal about GitHub's health.
765                    tracing::warn!(err = %e, attempt, "HTTP request failed to send (transport error, GitHub not reached), will retry if attempts remain");
766                    last_err = Some(anyhow::anyhow!(
767                        "HTTP request failed to send (transport error): {e}"
768                    ));
769                    if attempt + 1 < attempts {
770                        // Cap exponent at 63 (max valid shift for u64) to prevent overflow
771                        let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
772                        let backoff = base_secs.saturating_mul(exp);
773                        let sleep_dur = Duration::from_secs(backoff)
774                            + Duration::from_millis((u64::from(attempt) * 100).min(1000));
775                        time::sleep(sleep_dur).await;
776                        continue;
777                    }
778                    // Exhausted attempts — break so last_err is read. Deliberately does not
779                    // call set_agent_cooldown("github:5xx", ...): GitHub was never reached,
780                    // so its health is unknown and the shared breaker must stay untouched.
781                    tracing::warn!(
782                        "HTTP request failed to send after {} attempts (transport error, GitHub not reached) — not tripping the github:5xx circuit-breaker",
783                        attempts
784                    );
785                    break;
786                }
787            }
788        }
789
790        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unexpected send_with_retries failure")))
791    }
792
793    /// GET request, returns deserialized JSON.
794    async fn get_json<T: DeserializeOwned>(&self, url: &str) -> anyhow::Result<T> {
795        Self::check_backoff(url)?;
796        let auth = self.auth_header(url).await?;
797        // Use send_with_retries to handle transient 5xx/network failures
798        let make_req = || {
799            Ok(self
800                .client
801                .get(url)
802                .header(header::AUTHORIZATION, auth.clone())
803                .header(header::ACCEPT, "application/vnd.github+json")
804                .header("X-GitHub-Api-Version", "2022-11-28"))
805        };
806        let resp = self.send_with_retries(make_req, false).await?;
807        let status = resp.status();
808        if !status.is_success() {
809            let body = resp.text().await.unwrap_or_default();
810            Self::maybe_record_rate_limit_from_body(status, &body);
811            anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
812        }
813        let body = resp.text().await?;
814        serde_json::from_str(&body)
815            .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
816    }
817
818    /// GET raw bytes (for endpoints that return non-JSON or we parse manually).
819    async fn get_bytes(&self, url: &str) -> anyhow::Result<Vec<u8>> {
820        Self::check_backoff(url)?;
821        let auth = self.auth_header(url).await?;
822        let make_req = || {
823            Ok(self
824                .client
825                .get(url)
826                .header(header::AUTHORIZATION, auth.clone())
827                .header(header::ACCEPT, "application/vnd.github+json")
828                .header("X-GitHub-Api-Version", "2022-11-28"))
829        };
830        let resp = self.send_with_retries(make_req, false).await?;
831        let status = resp.status();
832        if !status.is_success() {
833            let body = resp.text().await.unwrap_or_default();
834            Self::maybe_record_rate_limit_from_body(status, &body);
835            anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
836        }
837        Ok(resp.bytes().await?.to_vec())
838    }
839
840    /// GET with query params, returns deserialized JSON.
841    async fn get_with_query<T: DeserializeOwned>(
842        &self,
843        url: &str,
844        query: &[(&str, &str)],
845    ) -> anyhow::Result<T> {
846        Self::check_backoff(url)?;
847        let auth = self.auth_header(url).await?;
848        let make_req = || {
849            Ok(self
850                .client
851                .get(url)
852                .query(query)
853                .header(header::AUTHORIZATION, auth.clone())
854                .header(header::ACCEPT, "application/vnd.github+json")
855                .header("X-GitHub-Api-Version", "2022-11-28"))
856        };
857        let resp = self.send_with_retries(make_req, false).await?;
858        let status = resp.status();
859        if !status.is_success() {
860            let body = resp.text().await.unwrap_or_default();
861            Self::maybe_record_rate_limit_from_body(status, &body);
862            anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
863        }
864        let body = resp.text().await?;
865        serde_json::from_str(&body)
866            .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
867    }
868
869    /// POST with JSON body, returns raw response text.
870    async fn post_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
871        Self::check_backoff(url)?;
872        let auth = self.auth_header(url).await?;
873        let make_req = || {
874            Ok(self
875                .client
876                .post(url)
877                .json(body)
878                .header(header::AUTHORIZATION, auth.clone())
879                .header(header::ACCEPT, "application/vnd.github+json")
880                .header("X-GitHub-Api-Version", "2022-11-28"))
881        };
882        let resp = self.send_with_retries(make_req, false).await?;
883        let status = resp.status();
884        let text_res = resp.text().await;
885        if !status.is_success() {
886            let text = text_res.unwrap_or_default();
887            Self::maybe_record_rate_limit_from_body(status, &text);
888            anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
889        }
890        let text = text_res?;
891        Ok(text)
892    }
893
894    /// POST with JSON body, returns deserialized JSON.
895    async fn post_json<T: DeserializeOwned>(
896        &self,
897        url: &str,
898        body: &serde_json::Value,
899    ) -> anyhow::Result<T> {
900        let text = self.post_json_raw(url, body).await?;
901        Ok(serde_json::from_str(&text)?)
902    }
903
904    /// PATCH with JSON body, returns raw response.
905    async fn patch_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
906        Self::check_backoff(url)?;
907        let auth = self.auth_header(url).await?;
908        let make_req = || {
909            Ok(self
910                .client
911                .patch(url)
912                .json(body)
913                .header(header::AUTHORIZATION, auth.clone())
914                .header(header::ACCEPT, "application/vnd.github+json")
915                .header("X-GitHub-Api-Version", "2022-11-28"))
916        };
917        let resp = self.send_with_retries(make_req, false).await?;
918        let status = resp.status();
919        let text_res = resp.text().await;
920        if !status.is_success() {
921            let text = text_res.unwrap_or_default();
922            Self::maybe_record_rate_limit_from_body(status, &text);
923            anyhow::bail!("GitHub API PATCH {url} failed ({status}): {text}");
924        }
925        let text = text_res?;
926        Ok(text)
927    }
928
929    /// DELETE request.
930    async fn delete(&self, url: &str) -> anyhow::Result<StatusCode> {
931        Self::check_backoff(url)?;
932        let auth = self.auth_header(url).await?;
933        let make_req = || {
934            Ok(self
935                .client
936                .delete(url)
937                .header(header::AUTHORIZATION, auth.clone())
938                .header(header::ACCEPT, "application/vnd.github+json")
939                .header("X-GitHub-Api-Version", "2022-11-28"))
940        };
941        let resp = self.send_with_retries(make_req, false).await?;
942        let status = resp.status();
943        if !status.is_success() && status != StatusCode::NOT_FOUND {
944            let body = resp.text().await.unwrap_or_default();
945            Self::maybe_record_rate_limit_from_body(status, &body);
946            anyhow::bail!("GitHub API DELETE {url} failed ({status}): {body}");
947        }
948        Ok(status)
949    }
950
951    /// Paginated GET — follows Link: <next> headers, with conditional requests.
952    ///
953    /// Each page is sent with `If-None-Match` when a prior ETag is known; GitHub's 304
954    /// reply is free of quota and the stored body is replayed.
955    async fn get_all_pages<T: DeserializeOwned>(
956        &self,
957        url: &str,
958        query: &[(&str, &str)],
959    ) -> anyhow::Result<Vec<T>> {
960        const MAX_JSON_DECODE_RETRIES: u32 = 2;
961        const JSON_DECODE_RETRY_BACKOFF_MS: u64 = 200;
962        Self::check_backoff(url)?;
963        let mut all: Vec<T> = Vec::new();
964        let mut next_url: Option<String> = None;
965        let mut is_first = true;
966
967        loop {
968            let (request_url, request_query) = if is_first {
969                is_first = false;
970                (url, Some(query))
971            } else {
972                let Some(u) = next_url.as_ref() else { break };
973                (u.as_str(), None)
974            };
975
976            let cache_key = page_cache_key::<T>(request_url, request_query);
977            let mut cached_etag = cache_key.as_deref().and_then(page_cache_etag);
978            let mut decode_attempt = 0;
979            let (page, next) = loop {
980                let auth = self.auth_header(request_url).await?;
981                let mut req = self
982                    .client
983                    .get(request_url)
984                    .header(header::AUTHORIZATION, auth)
985                    .header(header::ACCEPT, "application/vnd.github+json")
986                    .header("X-GitHub-Api-Version", "2022-11-28");
987                if let Some(q) = request_query {
988                    req = req.query(q);
989                }
990                if let Some(etag) = &cached_etag {
991                    req = req.header(header::IF_NONE_MATCH, etag.clone());
992                }
993
994                let make_req = || {
995                    req.try_clone()
996                        .ok_or_else(|| anyhow::anyhow!("request clone failed"))
997                };
998                let resp = self.send_with_retries(make_req, false).await?;
999                let status = resp.status();
1000                let next_from_headers = parse_link_next(resp.headers());
1001                let etag = resp
1002                    .headers()
1003                    .get(header::ETAG)
1004                    .and_then(|v| v.to_str().ok())
1005                    .map(ToString::to_string);
1006                let content_type = resp
1007                    .headers()
1008                    .get(header::CONTENT_TYPE)
1009                    .and_then(|v| v.to_str().ok())
1010                    .map_or_else(|| "<missing>".to_string(), ToString::to_string);
1011
1012                if status == StatusCode::NOT_MODIFIED {
1013                    if let Some((body, next)) = cache_key.as_deref().and_then(page_cache_hit) {
1014                        break (serde_json::from_str::<Vec<T>>(&body)?, next);
1015                    }
1016                    // The entry is normally there, since If-None-Match is only sent
1017                    // when it is, but the size cap can clear the map in between.
1018                    // Dropping the ETag makes the retry unconditional, so the loop
1019                    // cannot see a second 304
1020                    cached_etag = None;
1021                    continue;
1022                }
1023
1024                if !status.is_success() {
1025                    let body = resp.text().await.unwrap_or_default();
1026                    Self::maybe_record_rate_limit_from_body(status, &body);
1027                    anyhow::bail!("GitHub API GET (paginated) failed ({status}): {body}");
1028                }
1029
1030                let text = resp.text().await?;
1031                match serde_json::from_str::<Vec<T>>(&text) {
1032                    Ok(page) => {
1033                        if let (Some(key), Some(etag)) = (cache_key.clone(), etag) {
1034                            page_cache_store(key, etag, text, next_from_headers.clone());
1035                        }
1036                        break (page, next_from_headers);
1037                    }
1038                    Err(err) => {
1039                        let snippet: String = text.chars().take(200).collect();
1040                        let decode_error = anyhow::anyhow!(
1041                            "GitHub API GET (paginated) JSON decode failed ({status}, content-type={content_type}): {snippet}"
1042                        )
1043                        .context(err);
1044                        if decode_attempt < MAX_JSON_DECODE_RETRIES {
1045                            decode_attempt += 1;
1046                            tracing::warn!(
1047                                attempt = decode_attempt,
1048                                max_attempts = MAX_JSON_DECODE_RETRIES,
1049                                status = %status,
1050                                content_type,
1051                                err = %decode_error,
1052                                "json decode failed; retrying paginated GET"
1053                            );
1054                            time::sleep(Duration::from_millis(
1055                                JSON_DECODE_RETRY_BACKOFF_MS * u64::from(decode_attempt),
1056                            ))
1057                            .await;
1058                            continue;
1059                        }
1060                        return Err(decode_error);
1061                    }
1062                }
1063            };
1064
1065            next_url = next;
1066            all.extend(page);
1067
1068            if next_url.is_none() {
1069                break;
1070            }
1071        }
1072
1073        Ok(all)
1074    }
1075
1076    /// Paginated GET for GitHub endpoints that return a wrapper object instead of a raw
1077    /// array.  Each page is expected to look like `{ "<array_field>": [...], ... }`.
1078    /// The function extracts `array_field` from every page and appends the elements
1079    /// to the accumulated result, following `Link: <next>` headers until exhausted.
1080    ///
1081    /// This is necessary for endpoints such as `/commits/{sha}/check-runs` which
1082    /// return `{ "total_count": N, "check_runs": [...] }` rather than a bare `[...]`
1083    /// array.  Using `get_all_pages` on those endpoints would fail at the
1084    /// `serde_json::from_str::<Vec<T>>` step because the body is an object, not an
1085    /// array.
1086    async fn get_all_pages_from_wrapper<T: DeserializeOwned>(
1087        &self,
1088        url: &str,
1089        array_field: &str,
1090    ) -> anyhow::Result<Vec<T>> {
1091        Self::check_backoff(url)?;
1092        let mut all: Vec<T> = Vec::new();
1093        let mut current_url: String = url.to_string();
1094
1095        loop {
1096            let auth = self.auth_header(&current_url).await?;
1097            let make_req = || {
1098                self.client
1099                    .get(&current_url)
1100                    .header(header::AUTHORIZATION, auth.clone())
1101                    .header(header::ACCEPT, "application/vnd.github+json")
1102                    .header("X-GitHub-Api-Version", "2022-11-28")
1103                    .try_clone()
1104                    .ok_or_else(|| anyhow::anyhow!("request clone failed"))
1105            };
1106            let resp = self.send_with_retries(make_req, false).await?;
1107            let status = resp.status();
1108            let next = parse_link_next(resp.headers());
1109
1110            if !status.is_success() {
1111                let body = resp.text().await.unwrap_or_default();
1112                Self::maybe_record_rate_limit_from_body(status, &body);
1113                anyhow::bail!(
1114                    "GitHub API GET (wrapped paginated) {current_url} failed ({status}): {body}"
1115                );
1116            }
1117
1118            let body = resp.text().await?;
1119            let wrapper: serde_json::Value = serde_json::from_str(&body).with_context(|| {
1120                format!("failed to parse wrapper response from {current_url}: {body}")
1121            })?;
1122            let items = wrapper
1123                .get(array_field)
1124                .and_then(|v| v.as_array())
1125                .cloned()
1126                .unwrap_or_default();
1127
1128            for item in items {
1129                match serde_json::from_value::<T>(item) {
1130                    Ok(v) => all.push(v),
1131                    Err(e) => {
1132                        tracing::warn!(
1133                            field = array_field,
1134                            err = %e,
1135                            "skipping item that failed to deserialize in wrapped pagination"
1136                        );
1137                    }
1138                }
1139            }
1140
1141            match next {
1142                Some(next_url) => current_url = next_url,
1143                None => break,
1144            }
1145        }
1146
1147        Ok(all)
1148    }
1149
1150    /// POST to the GraphQL endpoint. Uses separate rate-limit tracking from REST.
1151    async fn graphql_request(
1152        &self,
1153        query: &str,
1154        extra_headers: &[(&str, &str)],
1155        variables: Option<serde_json::Value>,
1156    ) -> anyhow::Result<serde_json::Value> {
1157        self.graphql_request_to_url(
1158            &format!("{GITHUB_API}/graphql"),
1159            query,
1160            extra_headers,
1161            variables,
1162        )
1163        .await
1164    }
1165
1166    async fn graphql_request_to_url(
1167        &self,
1168        url: &str,
1169        query: &str,
1170        extra_headers: &[(&str, &str)],
1171        variables: Option<serde_json::Value>,
1172    ) -> anyhow::Result<serde_json::Value> {
1173        Self::check_graphql_backoff(url)?;
1174        let body = if let Some(vars) = variables {
1175            serde_json::json!({ "query": query, "variables": vars })
1176        } else {
1177            serde_json::json!({ "query": query })
1178        };
1179        let auth = self.auth_header(url).await?;
1180        let mut req = self
1181            .client
1182            .post(url)
1183            .json(&body)
1184            .header(header::AUTHORIZATION, auth)
1185            .header(header::ACCEPT, "application/vnd.github+json")
1186            .header("X-GitHub-Api-Version", "2022-11-28");
1187
1188        for (k, v) in extra_headers {
1189            req = req.header(*k, *v);
1190        }
1191
1192        // Use send_with_retries wrapper for GraphQL (is_graphql = true)
1193        let make_req = || {
1194            req.try_clone()
1195                .ok_or_else(|| anyhow::anyhow!("graphql request clone failed"))
1196        };
1197        let resp = self.send_with_retries(make_req, true).await?;
1198        let status = resp.status();
1199        let text_res = resp.text().await;
1200        if !status.is_success() {
1201            let text = text_res.unwrap_or_default();
1202            Self::maybe_record_graphql_rate_limit_from_body(status, &text);
1203            anyhow::bail!("GitHub GraphQL failed ({status}): {text}");
1204        }
1205        let text = text_res?;
1206        let json: serde_json::Value = serde_json::from_str(&text)?;
1207        // GraphQL always returns 200 even for errors; check the errors field explicitly.
1208        if let Some(errors) = json.get("errors").and_then(|e| e.as_array())
1209            && !errors.is_empty()
1210        {
1211            let messages: Vec<&str> = errors
1212                .iter()
1213                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1214                .collect();
1215            if json.get("data").is_none() || json["data"].is_null() {
1216                // No data at all — surface the errors as a proper error.
1217                anyhow::bail!("GitHub GraphQL errors: {}", messages.join("; "));
1218            }
1219            // Partial data alongside errors — log and continue.
1220            tracing::warn!(errors = ?messages, "GitHub GraphQL returned partial errors");
1221        }
1222        Ok(json)
1223    }
1224
1225    // ── Public API (mirrors GhCli) ───────────────────────────────
1226
1227    pub async fn graphql_with_vars(
1228        &self,
1229        query: &str,
1230        variables: serde_json::Value,
1231    ) -> anyhow::Result<serde_json::Value> {
1232        self.graphql_request(query, &[], Some(variables)).await
1233    }
1234
1235    pub async fn graphql_with_headers(
1236        &self,
1237        query: &str,
1238        headers: &[&str],
1239    ) -> anyhow::Result<serde_json::Value> {
1240        // Convert "Key:Value" strings to (&str, &str) tuples.
1241        let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1242        self.graphql_request(query, &pairs, None).await
1243    }
1244
1245    pub async fn graphql_with_headers_and_vars(
1246        &self,
1247        query: &str,
1248        headers: &[&str],
1249        variables: serde_json::Value,
1250    ) -> anyhow::Result<serde_json::Value> {
1251        let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1252        self.graphql_request(query, &pairs, Some(variables)).await
1253    }
1254
1255    /// Verify authentication by fetching the current user.
1256    ///
1257    /// Returns detailed error messages for common auth failures to help
1258    /// users diagnose configuration issues at startup.
1259    pub async fn auth_status(&self) -> anyhow::Result<()> {
1260        match self
1261            .get_json::<serde_json::Value>(&format!("{GITHUB_API}/user"))
1262            .await
1263        {
1264            Ok(_) => Ok(()),
1265            Err(e) => {
1266                let err_str = e.to_string();
1267
1268                // Provide helpful error messages for common auth failures
1269                if err_str.contains("401") || err_str.contains("Bad credentials") {
1270                    anyhow::bail!(
1271                        "GitHub authentication failed: Invalid or expired token. \
1272                         Check your GH_TOKEN/GITHUB_TOKEN environment variables, \
1273                         or gh.auth configuration in ~/.orch/config.yml. \
1274                         Run `orch auth check` to verify your setup."
1275                    );
1276                }
1277
1278                if err_str.contains("403") && err_str.contains("rate limit") {
1279                    anyhow::bail!(
1280                        "GitHub API rate limited. Wait before retrying, \
1281                         or check your token's rate limit quota."
1282                    );
1283                }
1284
1285                Err(e)
1286            }
1287        }
1288    }
1289
1290    /// Create a GitHub issue.
1291    pub async fn create_issue(
1292        &self,
1293        repo: &str,
1294        title: &str,
1295        body: &str,
1296        labels: &[String],
1297    ) -> anyhow::Result<GitHubIssue> {
1298        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1299        let payload = serde_json::json!({
1300            "title": title,
1301            "body": body,
1302            "labels": labels,
1303        });
1304        self.post_json(&url, &payload).await
1305    }
1306
1307    /// Replace an issue's title, body and labels.
1308    ///
1309    /// Labels replace the full set. Callers pass labels they want to keep
1310    pub async fn update_issue(
1311        &self,
1312        repo: &str,
1313        number: u64,
1314        title: &str,
1315        body: &str,
1316        labels: &[String],
1317    ) -> anyhow::Result<GitHubIssue> {
1318        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
1319        let payload = serde_json::json!({
1320            "title": title,
1321            "body": body,
1322            "labels": labels,
1323        });
1324        let text = self.patch_json_raw(&url, &payload).await?;
1325        serde_json::from_str(&text)
1326            .with_context(|| format!("failed to parse issue update response: {text}"))
1327    }
1328
1329    /// List issues that block `issue_number`.
1330    ///
1331    /// `GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by`.
1332    /// GA since 2025-08-21, no preview media type. This call sends
1333    /// `X-GitHub-Api-Version: 2026-03-10` and `Accept: application/vnd.github+json`.
1334    /// 404 and 403 (except rate limit) are `Unavailable`. Transport errors and
1335    /// 5xx are `Failed`, so the caller does not drop stored links
1336    pub async fn list_blocked_by(
1337        &self,
1338        repo: &str,
1339        issue_number: u64,
1340    ) -> anyhow::Result<BlockedByFetch> {
1341        let mut next = Some(format!(
1342            "{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by?per_page=100"
1343        ));
1344        let mut found = Vec::new();
1345        while let Some(url) = next.take() {
1346            let resp = match self
1347                .dependency_request(&url, reqwest::Method::GET, None)
1348                .await
1349            {
1350                Ok(resp) => resp,
1351                Err(err) => return Ok(BlockedByFetch::Failed(err.to_string())),
1352            };
1353            let status = resp.status();
1354            if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
1355                let body = resp.text().await.unwrap_or_default();
1356                if status == StatusCode::FORBIDDEN
1357                    && body.to_ascii_lowercase().contains("rate limit")
1358                {
1359                    Self::maybe_record_rate_limit_from_body(status, &body);
1360                    return Ok(BlockedByFetch::Failed(body));
1361                }
1362                return Ok(BlockedByFetch::Unavailable);
1363            }
1364            if !status.is_success() {
1365                let body = resp.text().await.unwrap_or_default();
1366                Self::maybe_record_rate_limit_from_body(status, &body);
1367                return Ok(BlockedByFetch::Failed(format!("{status}: {body}")));
1368            }
1369            Self::record_success();
1370            let page_next = parse_link_next(resp.headers());
1371            let body = resp.text().await.unwrap_or_default();
1372            let page: Vec<BlockedByPayload> = match serde_json::from_str(&body) {
1373                Ok(page) => page,
1374                Err(err) => {
1375                    return Ok(BlockedByFetch::Failed(format!(
1376                        "blocked_by parse failed: {err}"
1377                    )));
1378                }
1379            };
1380            for item in page {
1381                if item.number <= 0 {
1382                    continue;
1383                }
1384                let item_repo = item
1385                    .repository
1386                    .as_ref()
1387                    .map(|r| r.full_name.clone())
1388                    .filter(|name| !name.is_empty())
1389                    .unwrap_or_else(|| repo.to_string());
1390                found.push(BlockedByIssue {
1391                    github_id: item.id,
1392                    repo: item_repo,
1393                    number: item.number,
1394                    state: item.state,
1395                    state_reason: item.state_reason,
1396                });
1397            }
1398            next = page_next;
1399        }
1400        Ok(BlockedByFetch::Native(found))
1401    }
1402
1403    /// `POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by`
1404    /// with `{"issue_id": blocking_issue_id}`. Same API version as `list_blocked_by`.
1405    /// 201 is success. A body that says the link already exists is also success
1406    pub async fn add_blocked_by(
1407        &self,
1408        repo: &str,
1409        issue_number: u64,
1410        blocking_issue_id: i64,
1411    ) -> anyhow::Result<()> {
1412        let url =
1413            format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by");
1414        let resp = self
1415            .dependency_request(
1416                &url,
1417                reqwest::Method::POST,
1418                Some(serde_json::json!({ "issue_id": blocking_issue_id })),
1419            )
1420            .await?;
1421        let status = resp.status();
1422        if status.is_success() {
1423            Self::record_success();
1424            return Ok(());
1425        }
1426        let body = resp.text().await.unwrap_or_default();
1427        if body.to_ascii_lowercase().contains("already") {
1428            return Ok(());
1429        }
1430        Self::maybe_record_rate_limit_from_body(status, &body);
1431        anyhow::bail!("add blocked_by failed ({status}): {body}")
1432    }
1433
1434    /// Remove a blocked-by link. `issue_id` in the path is the blocking issue's database id
1435    pub async fn remove_blocked_by(
1436        &self,
1437        repo: &str,
1438        number: u64,
1439        blocking_issue_id: u64,
1440    ) -> anyhow::Result<()> {
1441        let url = format!(
1442            "{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
1443        );
1444        self.delete(&url).await?;
1445        Ok(())
1446    }
1447
1448    /// GET or POST an issue-dependency URL with API version 2026-03-10
1449    async fn dependency_request(
1450        &self,
1451        url: &str,
1452        method: reqwest::Method,
1453        body: Option<serde_json::Value>,
1454    ) -> anyhow::Result<Response> {
1455        Self::check_backoff(url)?;
1456        let auth = self.auth_header(url).await?;
1457        let make_req = || {
1458            let mut req = self
1459                .client
1460                .request(method.clone(), url)
1461                .header(header::AUTHORIZATION, auth.clone())
1462                .header(header::ACCEPT, "application/vnd.github+json")
1463                .header("X-GitHub-Api-Version", "2026-03-10");
1464            if let Some(ref payload) = body {
1465                req = req.json(payload);
1466            }
1467            Ok(req)
1468        };
1469        self.send_with_retries(make_req, false).await
1470    }
1471
1472    /// Get a single issue.
1473    pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1474        self.get_json(&format!("{}/repos/{repo}/issues/{number}", self.api_root()))
1475            .await
1476    }
1477
1478    /// Check whether an issue number refers to a pull request.
1479    pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1480        self.get_issue(repo, number)
1481            .await
1482            .is_ok_and(|i| i.pull_request.is_some())
1483    }
1484
1485    /// List issues filtered by a label (paginated).
1486    pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1487        self.list_issues_with_state(repo, label, "open").await
1488    }
1489
1490    /// List issues filtered by a label and issue state (paginated).
1491    ///
1492    /// `state` is passed directly to the GitHub API: `"open"`, `"closed"`, or `"all"`.
1493    pub async fn list_issues_with_state(
1494        &self,
1495        repo: &str,
1496        label: &str,
1497        state: &str,
1498    ) -> anyhow::Result<Vec<GitHubIssue>> {
1499        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1500        // An empty label lists every issue
1501        let mut query = vec![("state", state), ("per_page", "100")];
1502        if !label.is_empty() {
1503            query.push(("labels", label));
1504        }
1505        let all: Vec<GitHubIssue> = self.get_all_pages(&url, &query).await?;
1506        // GitHub /issues API returns PRs too — filter them out
1507        Ok(all
1508            .into_iter()
1509            .filter(|i| i.pull_request.is_none())
1510            .collect())
1511    }
1512
1513    /// List closed issues updated since `since` (ISO 8601), filtered by label.
1514    ///
1515    /// Used for dedup checks — prevents re-creating issues that were recently closed.
1516    pub async fn list_issues_closed_since(
1517        &self,
1518        repo: &str,
1519        label: &str,
1520        since: &str,
1521    ) -> anyhow::Result<Vec<GitHubIssue>> {
1522        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1523        let all: Vec<GitHubIssue> = self
1524            .get_all_pages(
1525                &url,
1526                &[
1527                    ("labels", label),
1528                    ("state", "closed"),
1529                    ("since", since),
1530                    ("per_page", "100"),
1531                ],
1532            )
1533            .await?;
1534        Ok(all
1535            .into_iter()
1536            .filter(|i| i.pull_request.is_none())
1537            .collect())
1538    }
1539
1540    /// List closed issues updated since `since` (ISO 8601), no label filter.
1541    pub async fn list_closed_issues_since(
1542        &self,
1543        repo: &str,
1544        since: &str,
1545    ) -> anyhow::Result<Vec<GitHubIssue>> {
1546        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1547        let all: Vec<GitHubIssue> = self
1548            .get_all_pages(
1549                &url,
1550                &[("state", "closed"), ("since", since), ("per_page", "100")],
1551            )
1552            .await?;
1553        Ok(all
1554            .into_iter()
1555            .filter(|i| i.pull_request.is_none())
1556            .collect())
1557    }
1558
1559    /// List all open issues (no label filter, paginated).
1560    ///
1561    /// The URL carries no `since`, so it repeats across calls and `get_all_pages` can send
1562    /// it conditionally. GitHub answers 304 while nothing changed, which is free quota
1563    pub async fn list_all_open_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1564        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1565        let params = [("state", "open"), ("per_page", "100")];
1566        let all: Vec<GitHubIssue> = self.get_all_pages(&url, &params).await?;
1567        // GitHub /issues API returns PRs too — filter them out
1568        Ok(all
1569            .into_iter()
1570            .filter(|i| i.pull_request.is_none())
1571            .collect())
1572    }
1573
1574    /// List open pull requests (paginated) using the REST pulls endpoint.
1575    /// Returns raw JSON values for flexibility in callers.
1576    pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1577        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1578        let all: Vec<serde_json::Value> = self
1579            .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1580            .await?;
1581        Ok(all)
1582    }
1583
1584    /// List all issues (open and closed, no label filter, paginated).
1585    ///
1586    /// This is more efficient than making separate calls for open and closed.
1587    pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1588        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1589        let all: Vec<GitHubIssue> = self
1590            .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1591            .await?;
1592        // GitHub /issues API returns PRs too — filter them out
1593        Ok(all
1594            .into_iter()
1595            .filter(|i| i.pull_request.is_none())
1596            .collect())
1597    }
1598
1599    /// Add labels to an issue.
1600    pub async fn add_labels(
1601        &self,
1602        repo: &str,
1603        number: &str,
1604        labels: &[String],
1605    ) -> anyhow::Result<()> {
1606        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1607        let payload = serde_json::json!({ "labels": labels });
1608        self.post_json_raw(&url, &payload).await?;
1609        Ok(())
1610    }
1611
1612    /// Ensure a label exists on the repo, creating it if needed.
1613    pub async fn ensure_label(
1614        &self,
1615        repo: &str,
1616        name: &str,
1617        color: &str,
1618        description: &str,
1619    ) -> anyhow::Result<()> {
1620        let encoded = urlencoding::encode(name);
1621        let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1622
1623        // Check if label exists
1624        match self.get_bytes(&get_url).await {
1625            Ok(_) => return Ok(()),
1626            Err(e) if e.to_string().contains("404") => {
1627                tracing::debug!(repo, name, "ensure_label: label not found, creating");
1628            }
1629            Err(e) => {
1630                tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1631            }
1632        }
1633
1634        // Create the label
1635        let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1636        let payload = serde_json::json!({
1637            "name": name,
1638            "color": color,
1639            "description": description,
1640        });
1641
1642        match self.post_json_raw(&create_url, &payload).await {
1643            Ok(_) => {
1644                Self::record_success();
1645            }
1646            Err(e) => {
1647                let msg = e.to_string();
1648                if msg.contains("422")
1649                    || msg.contains("already_exists")
1650                    || msg.contains("Unprocessable")
1651                {
1652                    tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1653                    return Ok(());
1654                }
1655                // Tolerate creation failures (matches old behavior).
1656                tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1657            }
1658        }
1659        Ok(())
1660    }
1661
1662    /// Remove a label from an issue.
1663    pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1664        let encoded = urlencoding::encode(label);
1665        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1666        match self.delete(&url).await {
1667            Ok(_) => Ok(()),
1668            Err(e) if e.to_string().contains("404") => {
1669                tracing::debug!(repo, number, label, "label already removed (404)");
1670                Ok(())
1671            }
1672            Err(e) => Err(e),
1673        }
1674    }
1675
1676    /// Add a comment to an issue.
1677    pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1678        let body = redact_posted_body(body);
1679        let url = format!("{}/repos/{repo}/issues/{number}/comments", self.api_root());
1680        let payload = serde_json::json!({ "body": body });
1681        self.post_json_raw(&url, &payload).await?;
1682        Ok(())
1683    }
1684
1685    /// Post a commit status. `state` is one of pending, success, failure, error.
1686    pub async fn post_commit_status(
1687        &self,
1688        repo: &str,
1689        sha: &str,
1690        state: &str,
1691        context: &str,
1692        description: &str,
1693    ) -> anyhow::Result<()> {
1694        let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1695        let payload = serde_json::json!({
1696            "state": state,
1697            "context": context,
1698            "description": description,
1699        });
1700        self.post_json_raw(&url, &payload).await?;
1701        Ok(())
1702    }
1703
1704    /// Add a reaction to an issue.
1705    ///
1706    /// Supported content values: "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"
1707    pub async fn add_reaction(
1708        &self,
1709        repo: &str,
1710        number: &str,
1711        content: &str,
1712    ) -> anyhow::Result<()> {
1713        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1714        let payload = serde_json::json!({ "content": content });
1715        self.post_json_raw(&url, &payload).await?;
1716        Ok(())
1717    }
1718
1719    /// Add a reaction to an issue comment.
1720    ///
1721    /// Supported content values: "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"
1722    pub async fn add_comment_reaction(
1723        &self,
1724        repo: &str,
1725        comment_id: &str,
1726        content: &str,
1727    ) -> anyhow::Result<()> {
1728        let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1729        let payload = serde_json::json!({ "content": content });
1730        self.post_json_raw(&url, &payload).await?;
1731        Ok(())
1732    }
1733
1734    /// List comments on an issue.
1735    pub async fn list_comments(
1736        &self,
1737        repo: &str,
1738        number: &str,
1739    ) -> anyhow::Result<Vec<GitHubComment>> {
1740        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1741        self.get_all_pages(&url, &[("per_page", "100")]).await
1742    }
1743
1744    /// Check if a PR with the given branch was merged.
1745    pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1746        let owner = repo
1747            .split('/')
1748            .next()
1749            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1750        let head = format!("{owner}:{branch}");
1751
1752        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1753        let prs: Vec<serde_json::Value> = self
1754            .get_with_query(
1755                &url,
1756                &[("head", &head), ("state", "closed"), ("per_page", "1")],
1757            )
1758            .await?;
1759
1760        if prs.is_empty() {
1761            return Ok(false);
1762        }
1763        let merged_at = prs[0].get("merged_at");
1764        Ok(merged_at.is_some_and(|v| !v.is_null()))
1765    }
1766
1767    /// Get the state of a closed PR for the given branch.
1768    ///
1769    /// Returns `Some((merged, state))` if a closed PR exists for the branch,
1770    /// where `merged` indicates if it was merged and `state` is the PR state ("closed" or "merged").
1771    /// Returns `None` if no closed PR exists for the branch.
1772    /// Get the state of a closed PR for a given branch.
1773    ///
1774    /// Returns `Some((pr_number, merged, state))` if a closed PR is found that
1775    /// matches the expected branch. Returns `None` if no matching closed PR is found.
1776    ///
1777    /// Validates that the returned PR's head ref matches the expected branch to
1778    /// prevent incorrect merge decisions when multiple PRs exist for the same branch.
1779    pub async fn get_closed_pr_state(
1780        &self,
1781        repo: &str,
1782        branch: &str,
1783    ) -> anyhow::Result<Option<(u64, bool, String)>> {
1784        let owner = repo
1785            .split('/')
1786            .next()
1787            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1788        let head = format!("{owner}:{branch}");
1789
1790        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1791        let prs: Vec<serde_json::Value> = self
1792            .get_with_query(
1793                &url,
1794                &[("head", &head), ("state", "closed"), ("per_page", "5")],
1795            )
1796            .await?;
1797
1798        if prs.is_empty() {
1799            return Ok(None);
1800        }
1801
1802        // Find the first PR where head ref actually matches our branch
1803        // This validates that we're working with the correct PR
1804        for pr in prs {
1805            let pr_branch = pr
1806                .get("head")
1807                .and_then(|h| h.get("ref"))
1808                .and_then(|r| r.as_str())
1809                .unwrap_or("");
1810
1811            if pr_branch != branch {
1812                tracing::debug!(
1813                    repo,
1814                    branch,
1815                    pr_branch,
1816                    "skipping PR with mismatched head ref"
1817                );
1818                continue;
1819            }
1820
1821            let pr_number = pr
1822                .get("number")
1823                .and_then(serde_json::Value::as_u64)
1824                .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1825
1826            let merged_at = pr.get("merged_at");
1827            let merged = merged_at.is_some_and(|v| !v.is_null());
1828            let state = pr
1829                .get("state")
1830                .and_then(|v| v.as_str())
1831                .unwrap_or("closed")
1832                .to_string();
1833
1834            return Ok(Some((pr_number, merged, state)));
1835        }
1836
1837        // No PR with matching head ref found
1838        Ok(None)
1839    }
1840
1841    /// Batch-check whether the PR for each branch was merged via a single GraphQL query.
1842    ///
1843    /// Returns a map of branch → merged. Branches with no matching PR are omitted
1844    /// (callers should treat missing entries as not merged).
1845    pub async fn batch_is_pr_merged_by_branch(
1846        &self,
1847        repo: &str,
1848        branches: &[String],
1849    ) -> anyhow::Result<HashMap<String, bool>> {
1850        if branches.is_empty() {
1851            return Ok(HashMap::new());
1852        }
1853
1854        let (owner, name) = repo
1855            .split_once('/')
1856            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1857
1858        // Build one alias per branch using positional index to avoid GraphQL
1859        // alias restrictions on branch names that contain non-identifier chars.
1860        // Branch names are passed via variables ($b0, $b1, …) to avoid injection.
1861        let mut var_defs = String::new();
1862        let mut aliases = String::new();
1863        let mut vars = serde_json::json!({ "owner": owner, "name": name });
1864        for (i, branch) in branches.iter().enumerate() {
1865            let _ = write!(var_defs, ", $b{i}: String!");
1866            let _ = write!(
1867                aliases,
1868                r"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "
1869            );
1870            vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1871        }
1872
1873        let query = format!(
1874            r"query($owner: String!, $name: String!{var_defs}) {{
1875                repository(owner: $owner, name: $name) {{ {aliases} }}
1876            }}"
1877        );
1878
1879        let resp = self.graphql_with_vars(&query, vars).await?;
1880
1881        // Surface GraphQL partial errors before checking data structure
1882        if let Some(errors) = resp.get("errors").and_then(|e| e.as_array())
1883            && !errors.is_empty()
1884        {
1885            let messages: Vec<&str> = errors
1886                .iter()
1887                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1888                .collect();
1889            tracing::warn!(
1890                repo,
1891                errors = ?messages,
1892                "GraphQL returned errors in batch PR merge check"
1893            );
1894        }
1895
1896        Self::parse_batch_pr_merged_response(&resp, branches)
1897    }
1898
1899    fn parse_batch_pr_merged_response(
1900        resp: &serde_json::Value,
1901        branches: &[String],
1902    ) -> anyhow::Result<HashMap<String, bool>> {
1903        let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1904            // Check for errors array to provide a more helpful error message
1905            let error_msg = resp
1906                .get("errors")
1907                .and_then(|e| e.as_array())
1908                .and_then(|arr| arr.first())
1909                .and_then(|e| e.get("message"))
1910                .and_then(|m| m.as_str())
1911                .unwrap_or("no error details");
1912            anyhow::anyhow!("missing /data/repository in GraphQL response: {error_msg}")
1913        })?;
1914
1915        let mut result = HashMap::new();
1916        for (i, branch) in branches.iter().enumerate() {
1917            let alias = format!("b{i}");
1918            let merged = repo_data
1919                .get(&alias)
1920                .and_then(|v| v.get("nodes"))
1921                .and_then(|nodes| nodes.as_array())
1922                .and_then(|arr| arr.first())
1923                .and_then(|node| node.get("merged"))
1924                .and_then(serde_json::Value::as_bool)
1925                .unwrap_or(false);
1926            result.insert(branch.clone(), merged);
1927        }
1928
1929        Ok(result)
1930    }
1931
1932    /// Get issue/PR comments since a given timestamp (paginated).
1933    pub async fn get_mentions(
1934        &self,
1935        repo: &str,
1936        since: &str,
1937    ) -> anyhow::Result<Vec<GitHubComment>> {
1938        let url = format!("{}/repos/{repo}/issues/comments", self.api_root());
1939        self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1940            .await
1941    }
1942
1943    /// Get all comments for a specific issue number.
1944    pub async fn get_issue_comments(
1945        &self,
1946        repo: &str,
1947        issue_number: u64,
1948    ) -> anyhow::Result<Vec<GitHubComment>> {
1949        let url = format!(
1950            "{}/repos/{repo}/issues/{issue_number}/comments",
1951            self.api_root()
1952        );
1953        self.get_all_pages(&url, &[("per_page", "100")]).await
1954    }
1955
1956    /// Get the current authenticated username (cached after first call).
1957    pub async fn get_whoami(&self) -> anyhow::Result<String> {
1958        if let Some(cached) = self.cached_username.get() {
1959            return Ok(cached.clone());
1960        }
1961
1962        // An installation token cannot read `GET /user`, and the owner is the identity mentions name
1963        let mut owner = self.clone();
1964        owner.owner_only = true;
1965        let user: serde_json::Value = owner.get_json(&format!("{GITHUB_API}/user")).await?;
1966        let username = user
1967            .get("login")
1968            .and_then(|v| v.as_str())
1969            .map(String::from)
1970            .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1971
1972        let _ = self.cached_username.set(username.clone());
1973        Ok(username)
1974    }
1975
1976    /// Get PR number by branch name.
1977    pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1978        let owner = repo
1979            .split('/')
1980            .next()
1981            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1982        let head = format!("{owner}:{branch}");
1983
1984        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1985        let prs: Vec<serde_json::Value> = self
1986            .get_with_query(
1987                &url,
1988                &[("head", &head), ("state", "open"), ("per_page", "1")],
1989            )
1990            .await?;
1991
1992        if prs.is_empty() {
1993            return Ok(None);
1994        }
1995        prs[0]
1996            .get("number")
1997            .and_then(serde_json::Value::as_u64)
1998            .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1999            .map(Some)
2000    }
2001
2002    /// Create a new pull request.
2003    ///
2004    /// # Arguments
2005    /// * `repo` - Repository in "owner/repo" format
2006    /// * `title` - PR title
2007    /// * `body` - PR body/description
2008    /// * `head` - Branch name to create PR from
2009    /// * `base` - Base branch to merge into (e.g., "main")
2010    ///
2011    /// Returns the created PR's URL on success.
2012    pub async fn create_pr(
2013        &self,
2014        repo: &str,
2015        title: &str,
2016        body: &str,
2017        head: &str,
2018        base: &str,
2019    ) -> anyhow::Result<String> {
2020        #[derive(Serialize)]
2021        struct CreatePullRequest<'a> {
2022            title: &'a str,
2023            body: &'a str,
2024            head: &'a str,
2025            base: &'a str,
2026        }
2027
2028        let body = redact_posted_body(body);
2029        let url = format!("{}/repos/{repo}/pulls", self.api_root());
2030        Self::check_backoff(&url)?;
2031
2032        let payload = CreatePullRequest {
2033            title,
2034            body: &body,
2035            head,
2036            base,
2037        };
2038
2039        let auth = self.auth_header(&url).await?;
2040        let make_req = || {
2041            Ok(self
2042                .client
2043                .post(&url)
2044                .header(header::AUTHORIZATION, auth.clone())
2045                .header(header::ACCEPT, "application/vnd.github+json")
2046                .header(header::USER_AGENT, "orch")
2047                .header("X-GitHub-Api-Version", "2022-11-28")
2048                .json(&payload))
2049        };
2050        let response = self.send_with_retries(make_req, false).await?;
2051        let status = response.status();
2052        let text = response.text().await.unwrap_or_default();
2053        if !status.is_success() {
2054            Self::maybe_record_rate_limit_from_body(status, &text);
2055            anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
2056        }
2057
2058        let pr: serde_json::Value = serde_json::from_str(&text)?;
2059        pr.get("html_url")
2060            .and_then(|v| v.as_str())
2061            .map(String::from)
2062            .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {pr:?}"))
2063    }
2064
2065    /// Get the repository's default branch from the GitHub API.
2066    ///
2067    /// Used as a fallback when local git detection yields an invalid base
2068    /// branch for PR creation.
2069    pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
2070        let url = format!("{GITHUB_API}/repos/{repo}");
2071        let resp: serde_json::Value = self.get_json(&url).await?;
2072        resp.get("default_branch")
2073            .and_then(|v| v.as_str())
2074            .map(String::from)
2075            .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
2076    }
2077
2078    /// Get PR details by PR number.
2079    /// Returns the full PR object including the `mergeable` field.
2080    pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
2081        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2082        self.get_json(&url).await
2083    }
2084
2085    /// Fetch the merged/state for multiple PRs in a single GraphQL query.
2086    ///
2087    /// Returns a map of pr_number → (merged, state).
2088    /// PRs that fail to fetch are silently omitted from the result.
2089    pub async fn batch_get_pr_states(
2090        &self,
2091        repo: &str,
2092        pr_numbers: &[u64],
2093    ) -> anyhow::Result<HashMap<u64, (bool, String)>> {
2094        // GitHub GraphQL complexity limit: chunk at 50 to avoid slow/rejected queries.
2095        const CHUNK_SIZE: usize = 50;
2096
2097        if pr_numbers.is_empty() {
2098            return Ok(HashMap::new());
2099        }
2100
2101        let (owner, name) = repo
2102            .split_once('/')
2103            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2104
2105        let mut result = HashMap::new();
2106
2107        for chunk in pr_numbers.chunks(CHUNK_SIZE) {
2108            let aliases: String = chunk
2109                .iter()
2110                .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
2111                .collect::<Vec<_>>()
2112                .join("\n  ");
2113
2114            let query = format!(
2115                r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2116            );
2117
2118            let resp = self
2119                .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2120                .await?;
2121            let repo_data = resp
2122                .pointer("/data/repository")
2123                .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2124
2125            for n in chunk {
2126                if let Some(pr) = repo_data.get(format!("pr{n}")) {
2127                    let merged = pr
2128                        .get("merged")
2129                        .and_then(serde_json::Value::as_bool)
2130                        .unwrap_or(false);
2131                    let state = pr
2132                        .get("state")
2133                        .and_then(|v| v.as_str())
2134                        .unwrap_or("UNKNOWN")
2135                        .to_lowercase();
2136                    result.insert(*n, (merged, state));
2137                }
2138            }
2139        }
2140
2141        Ok(result)
2142    }
2143
2144    /// Batch-fetch state and labels for multiple GitHub issues in a single GraphQL request.
2145    ///
2146    /// Returns a map of issue number (as string) → `(state, labels)` where state is
2147    /// lowercase ("open" / "closed") to match the REST API convention used elsewhere.
2148    pub async fn batch_get_issue_states(
2149        &self,
2150        repo: &str,
2151        issue_numbers: &[u64],
2152        caller_context: &str,
2153    ) -> anyhow::Result<HashMap<String, (String, Vec<String>)>> {
2154        // GitHub GraphQL complexity limit: chunk at 50 to avoid slow/rejected queries.
2155        const CHUNK_SIZE: usize = 50;
2156
2157        if issue_numbers.is_empty() {
2158            return Ok(HashMap::new());
2159        }
2160
2161        let (valid_issue_numbers, invalid_issue_numbers) =
2162            sanitize_github_issue_numbers(issue_numbers);
2163        if !invalid_issue_numbers.is_empty() {
2164            tracing::warn!(
2165                repo,
2166                caller_context,
2167                invalid_issue_numbers = ?invalid_issue_numbers,
2168                total_supplied = issue_numbers.len(),
2169                "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
2170            );
2171        }
2172        if valid_issue_numbers.is_empty() {
2173            return Ok(HashMap::new());
2174        }
2175
2176        let (owner, name) = repo
2177            .split_once('/')
2178            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2179
2180        let mut result = HashMap::new();
2181
2182        for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
2183            let aliases: String = chunk
2184                .iter()
2185                .map(|n| {
2186                    format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
2187                })
2188                .collect::<Vec<_>>()
2189                .join(" ");
2190
2191            let query = format!(
2192                r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2193            );
2194
2195            let resp = self
2196                .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2197                .await?;
2198            let repo_data = resp
2199                .pointer("/data/repository")
2200                .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2201
2202            for n in chunk {
2203                if let Some(issue) = repo_data.get(format!("issue{n}")) {
2204                    let state = issue
2205                        .get("state")
2206                        .and_then(|v| v.as_str())
2207                        .unwrap_or("UNKNOWN")
2208                        .to_lowercase();
2209                    let labels: Vec<String> = issue
2210                        .pointer("/labels/nodes")
2211                        .and_then(|v| v.as_array())
2212                        .map(|arr| {
2213                            arr.iter()
2214                                .filter_map(|l| {
2215                                    l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2216                                })
2217                                .collect()
2218                        })
2219                        .unwrap_or_default();
2220                    result.insert(n.to_string(), (state, labels));
2221                }
2222            }
2223        }
2224        Ok(result)
2225    }
2226
2227    /// Required status checks for a branch from classic protection and from rulesets
2228    pub async fn get_required_status_check_contexts(
2229        &self,
2230        repo: &str,
2231        branch: &str,
2232    ) -> anyhow::Result<Vec<RequiredStatusCheck>> {
2233        let branch = urlencoding::encode(branch);
2234        let classic_url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2235        let rules_url = format!("{GITHUB_API}/repos/{repo}/rules/branches/{branch}");
2236
2237        let classic_fut = self.get_json::<serde_json::Value>(&classic_url);
2238        let rules_fut = self.get_all_pages::<serde_json::Value>(&rules_url, &[("per_page", "100")]);
2239        let (classic_resp, rules_resp) = tokio::join!(classic_fut, rules_fut);
2240
2241        let classic = match classic_resp {
2242            Ok(value) => parse_classic_required_status_checks(&value),
2243            Err(e) => {
2244                if is_no_branch_protection_error(&e.to_string()) {
2245                    Vec::new()
2246                } else {
2247                    return Err(e);
2248                }
2249            }
2250        };
2251
2252        let ruleset = match rules_resp {
2253            Ok(rules) => parse_ruleset_required_status_checks(&rules),
2254            Err(e) => {
2255                if e.to_string().contains("404") {
2256                    Vec::new()
2257                } else {
2258                    return Err(e);
2259                }
2260            }
2261        };
2262
2263        Ok(merge_required_status_checks(classic, ruleset))
2264    }
2265
2266    /// Get commit status contexts for a SHA.
2267    pub async fn get_commit_status_contexts(
2268        &self,
2269        repo: &str,
2270        sha: &str,
2271    ) -> anyhow::Result<Vec<(String, String)>> {
2272        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2273        // The /commits/{sha}/status endpoint returns a wrapper object:
2274        // { "state": "pending", "total_count": N, "statuses": [...] }
2275        // Use get_all_pages_from_wrapper to follow pagination and extract the
2276        // `statuses` array across pages so we don't silently truncate at 100.
2277        let statuses: Vec<serde_json::Value> =
2278            self.get_all_pages_from_wrapper(&url, "statuses").await?;
2279
2280        Ok(statuses
2281            .into_iter()
2282            .filter_map(|status| {
2283                let context = status.get("context").and_then(|v| v.as_str())?;
2284                let state = status.get("state").and_then(|v| v.as_str())?;
2285                Some((context.to_string(), state.to_string()))
2286            })
2287            .collect())
2288    }
2289
2290    fn combined_status_state(
2291        total: u64,
2292        failing: u64,
2293        pending: u64,
2294        has_workflows: bool,
2295        mergeable_state: Option<&str>,
2296    ) -> String {
2297        if failing > 0 {
2298            "failure".to_string()
2299        } else if pending > 0 {
2300            "pending".to_string()
2301        } else if total == 0 && has_workflows {
2302            // No check runs yet. If GitHub already reports mergeable_state="clean"
2303            // the PR is ready (e.g. all workflows use paths-ignore and skipped this
2304            // commit). Treating it as pending would stall auto-merge forever.
2305            if mergeable_state == Some("clean") {
2306                "success".to_string()
2307            } else {
2308                "pending".to_string()
2309            }
2310        } else {
2311            "success".to_string()
2312        }
2313    }
2314
2315    /// Return `true` when the repository has at least one GitHub Actions workflow.
2316    ///
2317    /// This is used to distinguish repos that genuinely have no CI configured from
2318    /// repos where CI exists but GitHub has not surfaced any runs yet.
2319    pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2320        let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2321        let resp = self.get_json::<serde_json::Value>(&url).await;
2322        match resp {
2323            Ok(value) => Ok(value
2324                .get("workflows")
2325                .and_then(|v| v.as_array())
2326                .is_some_and(|workflows| !workflows.is_empty())),
2327            Err(e) => {
2328                let err = e.to_string();
2329                if err.contains("404") {
2330                    Ok(false)
2331                } else {
2332                    Err(e)
2333                }
2334            }
2335        }
2336    }
2337
2338    /// Get check runs for a PR head SHA.
2339    pub async fn get_check_runs(
2340        &self,
2341        repo: &str,
2342        sha: &str,
2343    ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2344        // Use `filter=latest` so GitHub returns only the most recent check run
2345        // per check name. Without this, the API returns all runs (oldest
2346        // first) and callers that pick the first match may observe a stale
2347        // failed run when a newer run with the same name has succeeded.
2348        //
2349        // Use `per_page=100` and manual pagination via `get_all_pages_from_wrapper`
2350        // because this endpoint returns a *wrapper object* — `{ "total_count": N,
2351        // "check_runs": [...] }` — rather than a raw JSON array.  `get_all_pages`
2352        // cannot be used here because it calls `serde_json::from_str::<Vec<T>>`
2353        // which would fail on an object body.
2354        let url = format!(
2355            "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2356        );
2357        self.get_all_pages_from_wrapper(&url, "check_runs").await
2358    }
2359
2360    /// Get annotations for a specific check run.
2361    ///
2362    /// Returns the list of annotations attached to the check run, including
2363    /// billing failure messages ("The job was not started because recent account
2364    /// payments have failed …") that GitHub surfaces as annotations rather than
2365    /// as a distinct CI conclusion value.
2366    pub async fn get_check_run_annotations(
2367        &self,
2368        repo: &str,
2369        check_run_id: u64,
2370    ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2371        let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2372        self.get_all_pages(&url, &[("per_page", "100")]).await
2373    }
2374
2375    /// Get reviews for a PR.
2376    pub async fn get_pr_reviews(
2377        &self,
2378        repo: &str,
2379        pr_number: u64,
2380    ) -> anyhow::Result<Vec<GitHubReview>> {
2381        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2382        self.get_all_pages(&url, &[("per_page", "100")]).await
2383    }
2384
2385    /// Get the committer timestamp for a specific commit SHA.
2386    ///
2387    /// Returns an ISO-8601 string, e.g. `"2024-01-15T12:00:00Z"`.
2388    /// Used to determine whether the branch has been updated since a review was submitted.
2389    pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2390        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2391        let value: serde_json::Value = self.get_json(&url).await?;
2392        value
2393            .pointer("/commit/committer/date")
2394            .and_then(|v| v.as_str())
2395            .map(ToString::to_string)
2396            .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2397    }
2398
2399    /// Update the body of a pull request.
2400    pub async fn update_pr_body(
2401        &self,
2402        repo: &str,
2403        pr_number: u64,
2404        body: &str,
2405    ) -> anyhow::Result<()> {
2406        let body = redact_posted_body(body);
2407        let url = format!("{}/repos/{repo}/pulls/{pr_number}", self.api_root());
2408        let payload = serde_json::json!({ "body": body });
2409        self.patch_json_raw(&url, &payload).await?;
2410        Ok(())
2411    }
2412
2413    /// Close a pull request without merging it.
2414    pub async fn close_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2415        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2416        let payload = serde_json::json!({ "state": "closed" });
2417        self.patch_json_raw(&url, &payload).await?;
2418        Ok(())
2419    }
2420
2421    /// How many commits the head is missing from `base` (GitHub's `behind_by`).
2422    ///
2423    /// Zero means the head already contains the current base tip, so its checks
2424    /// ran against the code the merge would produce.
2425    pub async fn compare_behind_by(
2426        &self,
2427        repo: &str,
2428        base: &str,
2429        head_sha: &str,
2430    ) -> anyhow::Result<u64> {
2431        let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2432        let body: serde_json::Value = self.get_json(&url).await?;
2433        parse_behind_by(&body)
2434    }
2435
2436    /// Parents, author and committer of a commit
2437    pub async fn commit_meta(&self, repo: &str, sha: &str) -> anyhow::Result<CommitMeta> {
2438        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2439        let value: serde_json::Value = self.get_json(&url).await?;
2440        Ok(parse_commit_meta(&value))
2441    }
2442
2443    /// True when `branch` already contains the commit `sha`
2444    pub async fn branch_contains(
2445        &self,
2446        repo: &str,
2447        branch: &str,
2448        sha: &str,
2449    ) -> anyhow::Result<bool> {
2450        let url = format!("{GITHUB_API}/repos/{repo}/compare/{branch}...{sha}");
2451        let body: serde_json::Value = self.get_json(&url).await?;
2452        body.get("ahead_by")
2453            .and_then(serde_json::Value::as_u64)
2454            .map(|ahead| ahead == 0)
2455            .ok_or_else(|| anyhow::anyhow!("compare response has no numeric ahead_by field"))
2456    }
2457
2458    /// Close a GitHub issue.
2459    pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2460        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2461        let payload = serde_json::json!({ "state": "closed" });
2462        self.patch_json_raw(&url, &payload).await?;
2463        Ok(())
2464    }
2465
2466    /// Reopen a closed GitHub issue.
2467    pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2468        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2469        let payload = serde_json::json!({ "state": "open" });
2470        self.patch_json_raw(&url, &payload).await?;
2471        Ok(())
2472    }
2473
2474    /// Check if a user is a collaborator.
2475    pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2476        let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2477        Self::check_backoff(&url)?;
2478        let auth = self.auth_header(&url).await?;
2479        let make_req = || {
2480            Ok(self
2481                .client
2482                .get(&url)
2483                .header(header::AUTHORIZATION, auth.clone())
2484                .header(header::ACCEPT, "application/vnd.github+json")
2485                .header("X-GitHub-Api-Version", "2022-11-28"))
2486        };
2487        let resp = self.send_with_retries(make_req, false).await?;
2488        let status = resp.status();
2489        if status == StatusCode::NO_CONTENT {
2490            Ok(true)
2491        } else if status == StatusCode::NOT_FOUND {
2492            Ok(false)
2493        } else {
2494            let body = resp.text().await.unwrap_or_default();
2495            anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2496        }
2497    }
2498
2499    /// Add a sub-issue relationship via GraphQL.
2500    pub async fn add_sub_issue(
2501        &self,
2502        parent_node_id: &str,
2503        child_node_id: &str,
2504    ) -> anyhow::Result<()> {
2505        let query = format!(
2506            r#"mutation {{
2507                addSubIssue(input: {{issueId: "{parent_node_id}", subIssueId: "{child_node_id}"}}) {{
2508                    issue {{ number }}
2509                    subIssue {{ number }}
2510                }}
2511            }}"#
2512        );
2513        self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2514            .await?;
2515        Ok(())
2516    }
2517
2518    /// Link an issue to a branch using GraphQL.
2519    ///
2520    /// Creates a "Development" sidebar link in GitHub that connects the issue
2521    /// to the branch, similar to `gh issue develop`.
2522    ///
2523    /// # Arguments
2524    /// * `repo` - Repository in "owner/repo" format
2525    /// * `issue_number` - The issue number to link
2526    /// * `branch` - The branch name to link the issue to
2527    ///
2528    /// Returns the linked branch ID on success.
2529    pub async fn link_issue_to_branch(
2530        &self,
2531        repo: &str,
2532        issue_number: u64,
2533        branch: &str,
2534    ) -> anyhow::Result<String> {
2535        let parts: Vec<&str> = repo.split('/').collect();
2536        if parts.len() != 2 {
2537            anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2538        }
2539        let (owner, repo_name) = (parts[0], parts[1]);
2540
2541        // First, get the repository and issue node IDs
2542        let query = r"query($owner: String!, $name: String!, $number: Int!) {
2543                repository(owner: $owner, name: $name) {
2544                    id
2545                    issue(number: $number) {
2546                        id
2547                    }
2548                }
2549            }";
2550        let vars = serde_json::json!({
2551            "owner": owner,
2552            "name": repo_name,
2553            "number": issue_number,
2554        });
2555
2556        let result = self.graphql_with_vars(query, vars).await?;
2557
2558        let repo_id = result
2559            .pointer("/data/repository/id")
2560            .and_then(|v| v.as_str())
2561            .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2562
2563        let issue_id = result
2564            .pointer("/data/repository/issue/id")
2565            .and_then(|v| v.as_str())
2566            .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2567
2568        // Fetch branch ref to get the commit OID required by createLinkedBranch
2569        let branch_query = r"query($owner: String!, $name: String!, $ref: String!) {
2570                repository(owner: $owner, name: $name) {
2571                    ref(qualifiedName: $ref) {
2572                        target {
2573                            oid
2574                        }
2575                    }
2576                }
2577            }";
2578        let branch_vars = serde_json::json!({
2579            "owner": owner,
2580            "name": repo_name,
2581            "ref": format!("refs/heads/{}", branch),
2582        });
2583
2584        let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2585        let branch_oid = branch_result
2586            .pointer("/data/repository/ref/target/oid")
2587            .and_then(|v| v.as_str());
2588
2589        // If branch doesn't exist yet, use the default branch OID.
2590        // createLinkedBranch will create the branch at that OID AND link it.
2591        let branch_oid = if let Some(oid) = branch_oid {
2592            oid.to_string()
2593        } else {
2594            // Get default branch OID as the base for the new branch
2595            let default_query = r"query($owner: String!, $name: String!) {
2596                    repository(owner: $owner, name: $name) {
2597                        defaultBranchRef {
2598                            target {
2599                                oid
2600                            }
2601                        }
2602                    }
2603                }";
2604            let default_vars = serde_json::json!({
2605                "owner": owner,
2606                "name": repo_name,
2607            });
2608            let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2609            match default_result
2610                .pointer("/data/repository/defaultBranchRef/target/oid")
2611                .and_then(|v| v.as_str())
2612            {
2613                Some(oid) => {
2614                    tracing::debug!(
2615                        repo,
2616                        issue_number,
2617                        branch,
2618                        "branch not found, using default branch OID to create and link"
2619                    );
2620                    oid.to_string()
2621                }
2622                None => {
2623                    return Ok(format!("unlinked:{branch}"));
2624                }
2625            }
2626        };
2627
2628        // Use createLinkedBranch mutation to link the issue to the existing branch.
2629        // Required fields: issueId, repositoryId, name (branch name), oid (commit SHA).
2630        let mutation = r"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2631                createLinkedBranch(input: {
2632                    issueId: $issueId
2633                    repositoryId: $repositoryId
2634                    name: $name
2635                    oid: $oid
2636                }) {
2637                    linkedBranch {
2638                        id
2639                        ref {
2640                            name
2641                        }
2642                    }
2643                }
2644            }";
2645        let mutation_vars = serde_json::json!({
2646            "issueId": issue_id,
2647            "repositoryId": repo_id,
2648            "name": branch,
2649            "oid": branch_oid,
2650        });
2651
2652        let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2653
2654        match link_result {
2655            Ok(result) => {
2656                let linked_branch_id = result
2657                    .pointer("/data/createLinkedBranch/linkedBranch/id")
2658                    .and_then(|v| v.as_str())
2659                    .map(String::from);
2660
2661                if let Some(id) = linked_branch_id {
2662                    tracing::info!(
2663                        repo,
2664                        issue_number,
2665                        branch,
2666                        "linked issue to branch via GraphQL API"
2667                    );
2668                    Ok(id)
2669                } else {
2670                    // Branch may already be linked - check for errors
2671                    let errors = result
2672                        .get("errors")
2673                        .and_then(|e| e.as_array())
2674                        .cloned()
2675                        .unwrap_or_default();
2676
2677                    if errors.iter().any(|e| {
2678                        e.get("message")
2679                            .and_then(|m| m.as_str())
2680                            .is_some_and(|m| m.contains("already"))
2681                    }) {
2682                        tracing::debug!(
2683                            repo,
2684                            issue_number,
2685                            branch,
2686                            "issue already linked to branch"
2687                        );
2688                        Ok(format!("already_linked:{branch}"))
2689                    } else {
2690                        anyhow::bail!("failed to link issue to branch: {result:?}")
2691                    }
2692                }
2693            }
2694            Err(e) => {
2695                // Check if it's a "already exists" type error
2696                let err_str = format!("{e}");
2697                if err_str.contains("already") || err_str.contains("existing") {
2698                    tracing::debug!(
2699                        repo,
2700                        issue_number,
2701                        branch,
2702                        "issue already linked to branch (error check)"
2703                    );
2704                    Ok(format!("already_linked:{branch}"))
2705                } else {
2706                    Err(e)
2707                }
2708            }
2709        }
2710    }
2711
2712    /// Merge a PR using the REST API (squash merge).
2713    /// GitHub rejects it when the PR head is no longer `head_sha`, the commit CI was checked against
2714    pub async fn merge_pr(
2715        &self,
2716        repo: &str,
2717        pr_number: u64,
2718        delete_branch: bool,
2719        head_sha: &str,
2720    ) -> anyhow::Result<()> {
2721        if !is_full_commit_sha(head_sha) {
2722            anyhow::bail!("merge_pr requires the full head sha, got {head_sha:?}");
2723        }
2724        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2725        Self::check_backoff(&url)?;
2726        let payload = merge_pr_body(head_sha);
2727        let auth = self.auth_header(&url).await?;
2728        let make_req = || {
2729            Ok(self
2730                .client
2731                .put(&url)
2732                .json(&payload)
2733                .header(header::AUTHORIZATION, auth.clone())
2734                .header(header::ACCEPT, "application/vnd.github+json")
2735                .header("X-GitHub-Api-Version", "2022-11-28"))
2736        };
2737        let resp = self.send_with_retries(make_req, false).await?;
2738        let status = resp.status();
2739        if !status.is_success() {
2740            let body = resp.text().await.unwrap_or_default();
2741            anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2742        }
2743        Self::record_success();
2744
2745        // Delete branch if requested
2746        if delete_branch {
2747            // Get the head ref (branch name) from the PR
2748            let pr: serde_json::Value = self
2749                .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2750                .await?;
2751            if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2752                let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2753                if let Err(e) = self.delete(&del_url).await {
2754                    let err_str = e.to_string();
2755                    if err_str.contains("Reference does not exist") {
2756                        tracing::debug!(
2757                            ref_name,
2758                            "branch already gone (GitHub auto-deleted on merge)"
2759                        );
2760                    } else {
2761                        tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2762                    }
2763                }
2764            }
2765        }
2766        Ok(())
2767    }
2768
2769    /// Update a PR's branch by merging the latest base branch into it
2770    /// (equivalent of the "Update branch" button on GitHub, or `gh pr
2771    /// update-branch`). Produces a new merge commit on the head branch,
2772    /// which re-triggers CI — used to give a PR a chance to pick up a fix
2773    /// that landed on the base branch after the PR's last CI run failed.
2774    pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2775        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2776        Self::check_backoff(&url)?;
2777        let auth = self.auth_header(&url).await?;
2778        let make_req = || {
2779            Ok(self
2780                .client
2781                .put(&url)
2782                .header(header::AUTHORIZATION, auth.clone())
2783                .header(header::ACCEPT, "application/vnd.github+json")
2784                .header("X-GitHub-Api-Version", "2022-11-28"))
2785        };
2786        let resp = self.send_with_retries(make_req, false).await?;
2787        let status = resp.status();
2788        if !status.is_success() {
2789            let body = resp.text().await.unwrap_or_default();
2790            Self::maybe_record_rate_limit_from_body(status, &body);
2791            anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2792        }
2793        Self::record_success();
2794        Ok(())
2795    }
2796
2797    /// Enable auto-merge on a PR via GraphQL.
2798    ///
2799    /// GitHub will automatically merge the PR once all required checks pass.
2800    pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2801        let parts: Vec<&str> = repo.split('/').collect();
2802        if parts.len() != 2 {
2803            anyhow::bail!("invalid repo format: {repo}");
2804        }
2805        let (owner, repo_name) = (parts[0], parts[1]);
2806
2807        // First get the PR node ID
2808        let pr_query = r"query($owner: String!, $name: String!, $number: Int!) {
2809            repository(owner: $owner, name: $name) {
2810                pullRequest(number: $number) { id }
2811            }
2812        }";
2813        let body = self
2814            .graphql_with_vars(
2815                pr_query,
2816                serde_json::json!({
2817                    "owner": owner,
2818                    "name": repo_name,
2819                    "number": pr_number,
2820                }),
2821            )
2822            .await?;
2823        let pr_id = body
2824            .pointer("/data/repository/pullRequest/id")
2825            .and_then(|v| v.as_str())
2826            .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2827            .to_string();
2828
2829        // Enable auto-merge with squash
2830        let mutation = r"mutation($prId: ID!) {
2831            enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2832                pullRequest { autoMergeRequest { enabledAt } }
2833            }
2834        }";
2835        let body = self
2836            .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2837            .await?;
2838        if let Some(errors) = body.get("errors") {
2839            anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2840        }
2841
2842        tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2843        Ok(())
2844    }
2845
2846    /// Get sub-issues via GraphQL (paginated).
2847    pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2848        let parts: Vec<&str> = repo.split('/').collect();
2849        if parts.len() != 2 {
2850            anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2851        }
2852        let (owner, repo_name) = (parts[0], parts[1]);
2853        let number = match parse_positive_issue_number(number) {
2854            Ok(number) => number,
2855            Err(e) => {
2856                tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2857                return Ok(Vec::new());
2858            }
2859        };
2860
2861        let mut all_numbers: Vec<u64> = Vec::new();
2862        let mut cursor: Option<String> = None;
2863        let page_size = 100;
2864        let max_pages = 50;
2865        let mut page_count = 0;
2866
2867        loop {
2868            page_count += 1;
2869            if page_count > max_pages {
2870                tracing::warn!(
2871                    repo,
2872                    number,
2873                    "get_sub_issues hit max page limit ({max_pages})"
2874                );
2875                break;
2876            }
2877
2878            let (query, vars) = if let Some(ref c) = cursor {
2879                (
2880                    r"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2881                    repository(owner: $owner, name: $name) {
2882                        issue(number: $number) {
2883                            subIssues(first: $first, after: $after) {
2884                                nodes { number }
2885                                pageInfo { hasNextPage endCursor }
2886                            }
2887                        }
2888                    }
2889                }",
2890                    serde_json::json!({
2891                        "owner": owner,
2892                        "name": repo_name,
2893                        "number": number,
2894                        "first": page_size,
2895                        "after": c,
2896                    }),
2897                )
2898            } else {
2899                (
2900                    r"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2901                    repository(owner: $owner, name: $name) {
2902                        issue(number: $number) {
2903                            subIssues(first: $first) {
2904                                nodes { number }
2905                                pageInfo { hasNextPage endCursor }
2906                            }
2907                        }
2908                    }
2909                }",
2910                    serde_json::json!({
2911                        "owner": owner,
2912                        "name": repo_name,
2913                        "number": number,
2914                        "first": page_size,
2915                    }),
2916                )
2917            };
2918
2919            let result = self
2920                .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2921                .await?;
2922
2923            let sub_issues_data = result
2924                .get("data")
2925                .and_then(|d| d.get("repository"))
2926                .and_then(|r| r.get("issue"))
2927                .and_then(|i| i.get("subIssues"));
2928
2929            let nodes = sub_issues_data
2930                .and_then(|s| s.get("nodes"))
2931                .and_then(|n| n.as_array());
2932
2933            match nodes {
2934                Some(nodes) => {
2935                    let numbers: Vec<u64> = nodes
2936                        .iter()
2937                        .filter_map(|n| n.get("number").and_then(serde_json::Value::as_u64))
2938                        .collect();
2939                    all_numbers.extend(numbers);
2940                }
2941                None => break,
2942            }
2943
2944            let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2945            let has_next = page_info
2946                .and_then(|p| p.get("hasNextPage"))
2947                .and_then(serde_json::Value::as_bool)
2948                .unwrap_or(false);
2949
2950            if !has_next {
2951                break;
2952            }
2953
2954            cursor = page_info
2955                .and_then(|p| p.get("endCursor"))
2956                .and_then(|c| c.as_str())
2957                .map(ToString::to_string);
2958        }
2959
2960        Ok(all_numbers)
2961    }
2962
2963    /// Get combined CI check status for a git ref.
2964    pub async fn get_combined_status(
2965        &self,
2966        repo: &str,
2967        git_ref: &str,
2968        has_workflows: bool,
2969        mergeable_state: Option<&str>,
2970    ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2971        let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2972        let resp: serde_json::Value = self.get_json(&url).await?;
2973
2974        let runs = resp
2975            .get("check_runs")
2976            .and_then(|v| v.as_array())
2977            .cloned()
2978            .unwrap_or_default();
2979
2980        let mut passing = 0u64;
2981        let mut failing = 0u64;
2982        let mut pending = 0u64;
2983
2984        for run in &runs {
2985            let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2986            let status = run
2987                .get("status")
2988                .and_then(|v| v.as_str())
2989                .unwrap_or("queued");
2990
2991            match status {
2992                "completed" => {
2993                    if conclusion.is_empty() {
2994                        pending += 1;
2995                    } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2996                        passing += 1;
2997                    } else {
2998                        failing += 1;
2999                    }
3000                }
3001                _ => pending += 1,
3002            }
3003        }
3004
3005        let total = runs.len() as u64;
3006        let state =
3007            Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
3008
3009        Ok((state, total, passing, failing, pending))
3010    }
3011
3012    /// Rerun the latest failed workflow run for a given workflow name and branch.
3013    ///
3014    /// Finds the most recent failed (or cancelled) run of `workflow_name` on
3015    /// `branch` triggered by a `push` event, then calls the GitHub Actions
3016    /// rerun endpoint so the **same check run** on the commit flips green.
3017    pub async fn rerun_failed_workflow(
3018        &self,
3019        repo: &str,
3020        workflow_name: &str,
3021        branch: &str,
3022    ) -> anyhow::Result<()> {
3023        // List recent runs for this branch, filtered to push events
3024        let url = format!(
3025            "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
3026        );
3027        let resp: serde_json::Value = self.get_json(&url).await?;
3028        let runs = resp["workflow_runs"]
3029            .as_array()
3030            .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
3031
3032        // Find the latest failed/cancelled run matching the workflow name
3033        let run_id = runs
3034            .iter()
3035            .find(|r| {
3036                r["name"].as_str() == Some(workflow_name)
3037                    && matches!(r["conclusion"].as_str(), Some("failure" | "cancelled"))
3038            })
3039            .and_then(|r| r["id"].as_u64())
3040            .ok_or_else(|| {
3041                anyhow::anyhow!(
3042                    "no failed run found for workflow '{workflow_name}' on branch '{branch}'"
3043                )
3044            })?;
3045
3046        // POST rerun — no body needed
3047        let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
3048        let auth = self.auth_header(&rerun_url).await?;
3049        let make_req = || {
3050            Ok(self
3051                .client
3052                .post(&rerun_url)
3053                .header(header::AUTHORIZATION, auth.clone())
3054                .header(header::ACCEPT, "application/vnd.github+json")
3055                .header("X-GitHub-Api-Version", "2022-11-28"))
3056        };
3057        let resp = self.send_with_retries(make_req, false).await?;
3058        let status = resp.status();
3059        if !status.is_success() {
3060            let body = resp.text().await.unwrap_or_default();
3061            Self::maybe_record_rate_limit_from_body(status, &body);
3062            anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
3063        }
3064
3065        Ok(())
3066    }
3067
3068    /// Fetch reviews, review comments, and issue comments for multiple PRs in a
3069    /// single GraphQL query instead of 4–5 REST calls per PR.
3070    ///
3071    /// Returns a map of `pr_number → PrReviewBatchData`.
3072    /// PRs missing from the response are omitted from the result.
3073    pub async fn batch_fetch_pr_review_data(
3074        &self,
3075        repo: &str,
3076        pr_numbers: &[u64],
3077    ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
3078        if pr_numbers.is_empty() {
3079            return Ok(HashMap::new());
3080        }
3081
3082        let (owner, name) = repo
3083            .split_once('/')
3084            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
3085
3086        // Build one GraphQL alias per PR number.
3087        let aliases: String = pr_numbers
3088            .iter()
3089            .map(|&n| build_pr_review_alias(n))
3090            .collect::<Vec<_>>()
3091            .join("\n");
3092
3093        let query = format!(
3094            r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
3095        );
3096
3097        let resp = self
3098            .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
3099            .await?;
3100        let repo_data = resp
3101            .pointer("/data/repository")
3102            .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
3103
3104        let mut result = HashMap::new();
3105        for &n in pr_numbers {
3106            let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
3107                continue;
3108            };
3109
3110            let merged = pr_data
3111                .get("merged")
3112                .and_then(serde_json::Value::as_bool)
3113                .unwrap_or(false);
3114            let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
3115                Some("MERGEABLE") => Some(true),
3116                Some("CONFLICTING") => Some(false),
3117                _ => None, // UNKNOWN or missing
3118            };
3119
3120            let mut reviews: Vec<GitHubReview> = pr_data
3121                .pointer("/reviews/nodes")
3122                .and_then(|v| v.as_array())
3123                .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
3124                .unwrap_or_default();
3125
3126            if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
3127                tracing::warn!(
3128                    pr_number = n,
3129                    fetched = reviews.len(),
3130                    "batch review fetch truncated reviews connection at first page"
3131                );
3132            }
3133
3134            // Ensure deterministic ordering by submitted timestamp (oldest -> newest).
3135            // GraphQL connections do not guarantee a stable sort unless explicitly
3136            // requested. Sort here so consumers can rely on chronological order.
3137            reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
3138
3139            let mut review_comments: Vec<GitHubReviewComment> = pr_data
3140                .pointer("/reviewThreads/nodes")
3141                .and_then(|v| v.as_array())
3142                .map(|threads| {
3143                    threads
3144                        .iter()
3145                        .filter_map(|thread| {
3146                            thread
3147                                .pointer("/comments/nodes")
3148                                .and_then(|v| v.as_array())
3149                                .map(|nodes| {
3150                                    nodes
3151                                        .iter()
3152                                        .filter_map(parse_graphql_review_comment)
3153                                        .collect::<Vec<_>>()
3154                                })
3155                        })
3156                        .flatten()
3157                        .collect()
3158                })
3159                .unwrap_or_default();
3160
3161            if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3162                tracing::warn!(
3163                    pr_number = n,
3164                    fetched_threads = pr_data
3165                        .pointer("/reviewThreads/nodes")
3166                        .and_then(|v| v.as_array())
3167                        .map_or(0, Vec::len),
3168                    "batch review fetch truncated reviewThreads connection at first page"
3169                );
3170            }
3171            if pr_data
3172                .pointer("/reviewThreads/nodes")
3173                .and_then(|v| v.as_array())
3174                .is_some_and(|threads| {
3175                    threads.iter().any(|thread| {
3176                        connection_has_next_page(thread.pointer("/comments/pageInfo"))
3177                    })
3178                })
3179            {
3180                tracing::warn!(
3181                    pr_number = n,
3182                    "batch review fetch truncated one or more review thread comment connections"
3183                );
3184            }
3185
3186            // Sort inline review comments by created_at (oldest -> newest) so
3187            // callers can process them deterministically.
3188            review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3189
3190            let mut issue_comments: Vec<GitHubComment> = pr_data
3191                .pointer("/comments/nodes")
3192                .and_then(|v| v.as_array())
3193                .map(|nodes| {
3194                    nodes
3195                        .iter()
3196                        .filter_map(parse_graphql_issue_comment)
3197                        .collect()
3198                })
3199                .unwrap_or_default();
3200
3201            if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3202                issue_comments.extend(
3203                    self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3204                        .await?,
3205                );
3206            }
3207
3208            // Sort issue-level comments by created_at (oldest -> newest). The
3209            // review poll logic expects chronological ordering and may iterate
3210            // in reverse to find the latest comments — so ensure a consistent
3211            // ordering here regardless of GraphQL's default.
3212            issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3213
3214            result.insert(
3215                n,
3216                PrReviewBatchData {
3217                    merged,
3218                    head_sha: pr_data
3219                        .get("headRefOid")
3220                        .and_then(|v| v.as_str())
3221                        .unwrap_or_default()
3222                        .to_string(),
3223                    mergeable,
3224                    reviews,
3225                    review_comments,
3226                    issue_comments,
3227                },
3228            );
3229        }
3230
3231        Ok(result)
3232    }
3233
3234    async fn fetch_pr_issue_comments_after(
3235        &self,
3236        owner: &str,
3237        name: &str,
3238        pr_number: u64,
3239        mut cursor: Option<String>,
3240    ) -> anyhow::Result<Vec<GitHubComment>> {
3241        let mut comments = Vec::new();
3242        let mut page_count = 0;
3243        let max_pages = 50;
3244
3245        while let Some(current_cursor) = cursor.take() {
3246            page_count += 1;
3247            if page_count > max_pages {
3248                tracing::warn!(
3249                    pr_number,
3250                    fetched = comments.len(),
3251                    "stopping PR issue comment pagination after max page limit"
3252                );
3253                break;
3254            }
3255
3256            let (query, vars) =
3257                build_pr_issue_comments_page_query(owner, name, pr_number, &current_cursor);
3258            let resp = self.graphql_with_vars(&query, vars).await?;
3259            let comments_data = resp
3260                .pointer("/data/repository/pullRequest/comments")
3261                .ok_or_else(|| {
3262                    anyhow::anyhow!(
3263                        "missing /data/repository/pullRequest/comments in GraphQL response"
3264                    )
3265                })?;
3266
3267            comments.extend(
3268                comments_data
3269                    .get("nodes")
3270                    .and_then(|v| v.as_array())
3271                    .into_iter()
3272                    .flatten()
3273                    .filter_map(parse_graphql_issue_comment),
3274            );
3275
3276            cursor = connection_end_cursor(comments_data.get("pageInfo"));
3277        }
3278
3279        Ok(comments)
3280    }
3281
3282    /// Check the latest automated review comment on a PR.
3283    pub async fn get_automated_review_status(
3284        &self,
3285        repo: &str,
3286        pr_number: u64,
3287    ) -> anyhow::Result<Option<String>> {
3288        let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3289
3290        // Find the most recent automated review comment authored by a collaborator or the App
3291        let app_login = super::app::bot_login().await;
3292        let mut newest: Option<&types::GitHubComment> = None;
3293        for c in &comments {
3294            if !c.body.starts_with("## Automated Review") {
3295                continue;
3296            }
3297            let trusted = match app_login.as_deref() {
3298                Some(app) if super::app::same_login(&c.user.login, app) => Ok(true),
3299                _ => self.is_collaborator(repo, &c.user.login).await,
3300            };
3301            match trusted {
3302                Ok(true) => match newest {
3303                    None => newest = Some(c),
3304                    Some(prev) => {
3305                        if c.created_at > prev.created_at {
3306                            newest = Some(c);
3307                        }
3308                    }
3309                },
3310                Ok(false) => {
3311                    tracing::warn!(
3312                        user = %c.user.login,
3313                        pr_number,
3314                        "ignoring automated review comment from non-collaborator"
3315                    );
3316                }
3317                Err(e) => {
3318                    tracing::debug!(
3319                        user = %c.user.login,
3320                        error = %e,
3321                        "failed to check collaborator status, skipping comment"
3322                    );
3323                }
3324            }
3325        }
3326
3327        match newest {
3328            Some(c) => {
3329                let first_line = c.body.lines().next().unwrap_or("");
3330                if first_line.contains("Automated Review \u{2014} Approve") {
3331                    Ok(Some("approve".to_string()))
3332                } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3333                    Ok(Some("changes_requested".to_string()))
3334                } else {
3335                    Ok(None)
3336                }
3337            }
3338            None => Ok(None),
3339        }
3340    }
3341}
3342
3343/// Read `behind_by` out of a compare response.
3344///
3345/// A missing field is an error, not zero: treating an unreadable response as
3346/// "head is up to date" would let a stale PR merge
3347/// A commit's parents, first parent first, and the GitHub accounts behind it
3348#[derive(Debug, Default, PartialEq)]
3349pub struct CommitMeta {
3350    pub parents: Vec<String>,
3351    pub author_login: Option<String>,
3352    pub committer_login: Option<String>,
3353}
3354
3355fn parse_commit_meta(commit: &serde_json::Value) -> CommitMeta {
3356    let login = |field: &str| {
3357        commit
3358            .pointer(&format!("/{field}/login"))
3359            .and_then(|v| v.as_str())
3360            .map(String::from)
3361    };
3362    CommitMeta {
3363        parents: commit
3364            .get("parents")
3365            .and_then(|p| p.as_array())
3366            .map(|parents| {
3367                parents
3368                    .iter()
3369                    .filter_map(|p| p.get("sha").and_then(|s| s.as_str()).map(String::from))
3370                    .collect()
3371            })
3372            .unwrap_or_default(),
3373        author_login: login("author"),
3374        committer_login: login("committer"),
3375    }
3376}
3377
3378fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3379    body.get("behind_by")
3380        .and_then(serde_json::Value::as_u64)
3381        .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3382}
3383
3384/// A branch protection read that means "no protection": 404 "Branch not protected", or the 403
3385/// GitHub returns for private repos on the Free plan
3386fn is_no_branch_protection_error(err: &str) -> bool {
3387    err.contains("404")
3388        || (err.contains("403")
3389            && err.contains("Upgrade to GitHub Pro or make this repository public"))
3390}
3391
3392/// Classic protection prefers `checks` (includes `app_id`) and falls back to `contexts`
3393fn parse_classic_required_status_checks(value: &serde_json::Value) -> Vec<RequiredStatusCheck> {
3394    if let Some(checks) = value
3395        .pointer("/required_status_checks/checks")
3396        .and_then(|v| v.as_array())
3397    {
3398        let parsed: Vec<RequiredStatusCheck> =
3399            checks.iter().filter_map(parse_classic_check).collect();
3400        if !parsed.is_empty() {
3401            return parsed;
3402        }
3403    }
3404
3405    value
3406        .pointer("/required_status_checks/contexts")
3407        .and_then(|v| v.as_array())
3408        .map(|contexts| {
3409            contexts
3410                .iter()
3411                .filter_map(|context| {
3412                    context.as_str().map(|s| RequiredStatusCheck {
3413                        context: s.to_string(),
3414                        integration_id: None,
3415                    })
3416                })
3417                .collect()
3418        })
3419        .unwrap_or_default()
3420}
3421
3422fn parse_classic_check(check: &serde_json::Value) -> Option<RequiredStatusCheck> {
3423    let context = check.get("context")?.as_str()?.to_string();
3424    let integration_id = check
3425        .get("app_id")
3426        .and_then(serde_json::Value::as_i64)
3427        .filter(|&id| id > 0);
3428    Some(RequiredStatusCheck {
3429        context,
3430        integration_id,
3431    })
3432}
3433
3434/// Collect `required_status_checks` entries from every active `required_status_checks` rule
3435fn parse_ruleset_required_status_checks(rules: &[serde_json::Value]) -> Vec<RequiredStatusCheck> {
3436    let mut out = Vec::new();
3437    for rule in rules {
3438        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3439            continue;
3440        }
3441        let Some(checks) = rule
3442            .pointer("/parameters/required_status_checks")
3443            .or_else(|| rule.get("required_status_checks"))
3444            .and_then(|v| v.as_array())
3445        else {
3446            continue;
3447        };
3448        for check in checks {
3449            let Some(context) = check.get("context").and_then(|v| v.as_str()) else {
3450                continue;
3451            };
3452            let integration_id = check
3453                .get("integration_id")
3454                .and_then(serde_json::Value::as_i64)
3455                .filter(|&id| id > 0);
3456            out.push(RequiredStatusCheck {
3457                context: context.to_string(),
3458                integration_id,
3459            });
3460        }
3461    }
3462    out
3463}
3464
3465fn merge_required_status_checks(
3466    mut checks: Vec<RequiredStatusCheck>,
3467    extra: Vec<RequiredStatusCheck>,
3468) -> Vec<RequiredStatusCheck> {
3469    checks.extend(extra);
3470    let mut seen = HashSet::new();
3471    checks.retain(|c| seen.insert((c.context.clone(), c.integration_id)));
3472    checks
3473}
3474
3475fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3476    let mut valid = Vec::with_capacity(issue_numbers.len());
3477    let mut invalid = Vec::new();
3478    for &issue_number in issue_numbers {
3479        if issue_number == 0 {
3480            invalid.push(issue_number);
3481        } else {
3482            valid.push(issue_number);
3483        }
3484    }
3485    (valid, invalid)
3486}
3487
3488// ── Public metrics ───────────────────────────────────────────────────
3489
3490/// Snapshot of GitHub API rate-limit metrics.
3491#[derive(Debug, Clone)]
3492pub struct RateLimitMetrics {
3493    /// Total 403/429 rate-limit responses received (REST + GraphQL combined).
3494    pub hits: u64,
3495    /// Calls skipped because the quota was low or a backoff was active.
3496    pub skipped_calls: u64,
3497    /// Current REST remaining quota (None if not yet observed).
3498    pub rest_remaining: Option<u32>,
3499    /// UTC epoch second when the REST window resets (None if not yet observed).
3500    pub rest_reset_at: Option<u64>,
3501    /// Current GraphQL remaining quota (None if not yet observed).
3502    pub graphql_remaining: Option<u32>,
3503    /// Total 5xx server error responses received (REST + GraphQL combined).
3504    pub five_xx_hits: u64,
3505}
3506
3507/// Return a snapshot of GitHub API rate-limit metrics.
3508pub fn rate_limit_metrics() -> RateLimitMetrics {
3509    let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
3510        .lock()
3511        .ok()
3512        .map_or((None, None), |rl| (rl.remaining, rl.reset_at));
3513    let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3514    RateLimitMetrics {
3515        hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3516        skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3517        rest_remaining,
3518        rest_reset_at,
3519        graphql_remaining,
3520        five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3521    }
3522}
3523
3524// ── Link header parser ───────────────────────────────────────────────
3525
3526/// Parse the `Link` header to find the `rel="next"` URL.
3527fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3528    let link = headers.get("link")?.to_str().ok()?;
3529    for part in link.split(',') {
3530        let part = part.trim();
3531        if part.contains("rel=\"next\"") {
3532            // Extract URL between < and >
3533            let start = part.find('<')? + 1;
3534            let end = part.find('>')?;
3535            if start >= end {
3536                return None;
3537            }
3538            return Some(part[start..end].to_string());
3539        }
3540    }
3541    None
3542}
3543
3544/// Return the hex color string for a given `status:*` label.
3545pub fn status_label_color(label: &str) -> &'static str {
3546    match label {
3547        "status:new" => "0e8a16",
3548        "status:routed" => "1d76db",
3549        "status:in_progress" => "fbca04",
3550        "status:done" => "6f42c1",
3551        "status:blocked" => "d73a4a",
3552        "status:in_review" => "0075ca",
3553        "status:approved" => "2ea44f",
3554        "status:needs_review" => "e4e669",
3555        "status:waiting" => "5319e7",
3556        _ => "c5def5",
3557    }
3558}
3559
3560// ── Batch GraphQL helpers ─────────────────────────────────────────────
3561
3562/// Data fetched for one PR by [`GhHttp::batch_fetch_pr_review_data`].
3563#[derive(Debug, Clone, Default)]
3564pub struct PrReviewBatchData {
3565    /// True when the pull request was merged.
3566    pub merged: bool,
3567    /// Head commit of the pull request, empty when GitHub did not return it.
3568    pub head_sha: String,
3569    /// `Some(true)` = MERGEABLE, `Some(false)` = CONFLICTING, `None` = UNKNOWN.
3570    pub mergeable: Option<bool>,
3571    /// All reviews submitted on the PR.
3572    pub reviews: Vec<GitHubReview>,
3573    /// All inline review comments (from all review threads).
3574    pub review_comments: Vec<GitHubReviewComment>,
3575    /// All issue-level comments on the PR.
3576    pub issue_comments: Vec<GitHubComment>,
3577}
3578
3579/// Build a GraphQL alias fragment for one pull request, fetching all data
3580/// needed by the review poll loop in a single batch request.
3581fn build_pr_review_alias(n: u64) -> String {
3582    format!(
3583        r"pr{n}: pullRequest(number: {n}) {{
3584  merged
3585  mergeable
3586  headRefOid
3587  reviews(first: 50) {{
3588    nodes {{
3589      databaseId
3590      author {{ login }}
3591      body
3592      state
3593      url
3594      submittedAt
3595      commit {{ oid }}
3596    }}
3597    pageInfo {{ hasNextPage }}
3598  }}
3599  reviewThreads(first: 50) {{
3600    nodes {{
3601      comments(first: 20) {{
3602        nodes {{
3603          databaseId
3604          author {{ login }}
3605          body
3606          path
3607          line
3608          originalLine
3609          commit {{ oid }}
3610          originalCommit {{ oid }}
3611          url
3612          createdAt
3613          updatedAt
3614          replyTo {{ databaseId }}
3615          diffHunk
3616        }}
3617        pageInfo {{ hasNextPage }}
3618      }}
3619    }}
3620    pageInfo {{ hasNextPage }}
3621  }}
3622  comments(first: 50) {{
3623    nodes {{
3624      databaseId
3625      author {{ login }}
3626      body
3627      createdAt
3628      updatedAt
3629      url
3630    }}
3631    pageInfo {{ hasNextPage endCursor }}
3632  }}
3633}}"
3634    )
3635}
3636
3637fn build_pr_issue_comments_page_query(
3638    owner: &str,
3639    name: &str,
3640    pr_number: u64,
3641    after_cursor: &str,
3642) -> (String, serde_json::Value) {
3643    let query = r"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3644  repository(owner: $owner, name: $name) {
3645    pullRequest(number: $number) {
3646      comments(first: 100, after: $after) {
3647        nodes {
3648          databaseId
3649          author { login }
3650          body
3651          createdAt
3652          updatedAt
3653          url
3654        }
3655        pageInfo { hasNextPage endCursor }
3656      }
3657    }
3658  }
3659}"
3660    .to_string();
3661    let vars = serde_json::json!({
3662        "owner": owner,
3663        "name": name,
3664        "number": pr_number,
3665        "after": after_cursor,
3666    });
3667    (query, vars)
3668}
3669
3670fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3671    page_info
3672        .and_then(|v| v.get("hasNextPage"))
3673        .and_then(serde_json::Value::as_bool)
3674        .unwrap_or(false)
3675}
3676
3677fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3678    if !connection_has_next_page(page_info) {
3679        return None;
3680    }
3681
3682    page_info
3683        .and_then(|v| v.get("endCursor"))
3684        .and_then(|v| v.as_str())
3685        .map(str::to_owned)
3686}
3687
3688fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3689    Some(GitHubReview {
3690        id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3691        user: GitHubUser {
3692            login: node
3693                .pointer("/author/login")
3694                .and_then(|v| v.as_str())?
3695                .to_string(),
3696        },
3697        body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3698        state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3699        html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3700        submitted_at: node
3701            .get("submittedAt")
3702            .and_then(|v| v.as_str())
3703            .unwrap_or("")
3704            .to_string(),
3705        commit_id: node
3706            .pointer("/commit/oid")
3707            .and_then(|v| v.as_str())
3708            .map(String::from),
3709    })
3710}
3711
3712fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3713    Some(GitHubReviewComment {
3714        id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3715        user: GitHubUser {
3716            login: node
3717                .pointer("/author/login")
3718                .and_then(|v| v.as_str())?
3719                .to_string(),
3720        },
3721        body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3722        path: node
3723            .get("path")
3724            .and_then(|v| v.as_str())
3725            .unwrap_or("")
3726            .to_string(),
3727        line: node
3728            .get("line")
3729            .and_then(serde_json::Value::as_u64)
3730            .and_then(|v| u32::try_from(v).ok()),
3731        original_line: node
3732            .get("originalLine")
3733            .and_then(serde_json::Value::as_u64)
3734            .and_then(|v| u32::try_from(v).ok()),
3735        commit_id: node
3736            .pointer("/commit/oid")
3737            .and_then(|v| v.as_str())
3738            .unwrap_or("")
3739            .to_string(),
3740        original_commit_id: node
3741            .pointer("/originalCommit/oid")
3742            .and_then(|v| v.as_str())
3743            .unwrap_or("")
3744            .to_string(),
3745        html_url: node
3746            .get("url")
3747            .and_then(|v| v.as_str())
3748            .unwrap_or("")
3749            .to_string(),
3750        created_at: node
3751            .get("createdAt")
3752            .and_then(|v| v.as_str())
3753            .unwrap_or("")
3754            .to_string(),
3755        updated_at: node
3756            .get("updatedAt")
3757            .and_then(|v| v.as_str())
3758            .unwrap_or("")
3759            .to_string(),
3760        in_reply_to_id: node
3761            .pointer("/replyTo/databaseId")
3762            .and_then(serde_json::Value::as_u64),
3763        diff_hunk: node
3764            .get("diffHunk")
3765            .and_then(|v| v.as_str())
3766            .map(String::from),
3767    })
3768}
3769
3770fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3771    Some(GitHubComment {
3772        id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3773        body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3774        user: GitHubUser {
3775            login: node
3776                .pointer("/author/login")
3777                .and_then(|v| v.as_str())?
3778                .to_string(),
3779        },
3780        created_at: node
3781            .get("createdAt")
3782            .and_then(|v| v.as_str())
3783            .unwrap_or("")
3784            .to_string(),
3785        updated_at: node
3786            .get("updatedAt")
3787            .and_then(|v| v.as_str())
3788            .map(String::from),
3789        html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3790        issue_url: None,
3791        author_association: None,
3792    })
3793}
3794
3795#[cfg(test)]
3796mod tests {
3797    // Secret redaction on posted bodies (wiremock)
3798    mod leak_redaction;
3799
3800    use std::env;
3801
3802    use super::*;
3803    use crate::github::token::TokenResolver;
3804    use tokio::io::{AsyncReadExt, AsyncWriteExt};
3805    use tokio::net::TcpListener;
3806
3807    #[test]
3808    fn repo_from_url_names_the_repository_or_nothing() {
3809        let api = "https://api.github.com";
3810        assert_eq!(
3811            repo_from_url(&format!("{api}/repos/me/a/pulls/9")).as_deref(),
3812            Some("me/a")
3813        );
3814        assert_eq!(
3815            repo_from_url(&format!("{api}/repos/org/b?per_page=1")).as_deref(),
3816            Some("org/b")
3817        );
3818        assert_eq!(
3819            repo_from_url(&format!("{api}/repos/org/b")).as_deref(),
3820            Some("org/b")
3821        );
3822        assert_eq!(repo_from_url(&format!("{api}/graphql")), None);
3823        assert_eq!(repo_from_url(&format!("{api}/user")), None);
3824        assert_eq!(repo_from_url(&format!("{api}/repos/only-owner")), None);
3825    }
3826
3827    #[test]
3828    fn no_branch_protection_errors() {
3829        let free_plan = "GitHub API GET https://api.github.com/repos/o/r/branches/main/protection failed (403 Forbidden): {\"message\":\"Upgrade to GitHub Pro or make this repository public to enable this feature.\"}";
3830        assert!(is_no_branch_protection_error(free_plan));
3831        assert!(is_no_branch_protection_error(
3832            "failed (404 Not Found): {\"message\":\"Branch not protected\"}"
3833        ));
3834        assert!(!is_no_branch_protection_error(
3835            "failed (403 Forbidden): {\"message\":\"Resource not accessible\"}"
3836        ));
3837    }
3838
3839    #[test]
3840    fn parse_classic_required_status_checks_reads_checks_app_id() {
3841        let value = serde_json::json!({
3842            "required_status_checks": {
3843                "contexts": ["ci/test"],
3844                "checks": [{ "context": "ci/test", "app_id": 42 }]
3845            }
3846        });
3847        assert_eq!(
3848            parse_classic_required_status_checks(&value),
3849            vec![RequiredStatusCheck {
3850                context: "ci/test".to_string(),
3851                integration_id: Some(42),
3852            }]
3853        );
3854    }
3855
3856    #[test]
3857    fn parse_classic_required_status_checks_falls_back_to_contexts() {
3858        let value = serde_json::json!({
3859            "required_status_checks": {
3860                "contexts": ["ci/test", "lint"]
3861            }
3862        });
3863        assert_eq!(
3864            parse_classic_required_status_checks(&value),
3865            vec![
3866                RequiredStatusCheck {
3867                    context: "ci/test".to_string(),
3868                    integration_id: None,
3869                },
3870                RequiredStatusCheck {
3871                    context: "lint".to_string(),
3872                    integration_id: None,
3873                }
3874            ]
3875        );
3876    }
3877
3878    #[test]
3879    fn parse_classic_required_status_checks_empty_when_unprotected() {
3880        let checks = parse_classic_required_status_checks(&serde_json::json!({}));
3881        assert!(checks.is_empty(), "{checks:?}");
3882    }
3883
3884    #[test]
3885    fn parse_ruleset_required_status_checks_reads_integration_id() {
3886        let rules = vec![
3887            serde_json::json!({ "type": "pull_request" }),
3888            serde_json::json!({
3889                "type": "required_status_checks",
3890                "parameters": {
3891                    "required_status_checks": [
3892                        { "context": "ci/test", "integration_id": 42 },
3893                        { "context": "lint" }
3894                    ]
3895                }
3896            }),
3897        ];
3898        assert_eq!(
3899            parse_ruleset_required_status_checks(&rules),
3900            vec![
3901                RequiredStatusCheck {
3902                    context: "ci/test".to_string(),
3903                    integration_id: Some(42),
3904                },
3905                RequiredStatusCheck {
3906                    context: "lint".to_string(),
3907                    integration_id: None,
3908                }
3909            ]
3910        );
3911    }
3912
3913    #[test]
3914    fn parse_ruleset_required_status_checks_treats_zero_integration_id_as_any_app() {
3915        let rules = vec![serde_json::json!({
3916            "type": "required_status_checks",
3917            "parameters": {
3918                "required_status_checks": [{ "context": "ci/test", "integration_id": 0 }]
3919            }
3920        })];
3921        assert_eq!(
3922            parse_ruleset_required_status_checks(&rules),
3923            vec![RequiredStatusCheck {
3924                context: "ci/test".to_string(),
3925                integration_id: None,
3926            }]
3927        );
3928    }
3929
3930    #[test]
3931    fn merge_required_status_checks_dedupes_identical_pairs() {
3932        let classic = vec![RequiredStatusCheck {
3933            context: "ci/test".to_string(),
3934            integration_id: None,
3935        }];
3936        let ruleset = vec![
3937            RequiredStatusCheck {
3938                context: "ci/test".to_string(),
3939                integration_id: None,
3940            },
3941            RequiredStatusCheck {
3942                context: "ci/test".to_string(),
3943                integration_id: Some(42),
3944            },
3945        ];
3946        assert_eq!(
3947            merge_required_status_checks(classic, ruleset),
3948            vec![
3949                RequiredStatusCheck {
3950                    context: "ci/test".to_string(),
3951                    integration_id: None,
3952                },
3953                RequiredStatusCheck {
3954                    context: "ci/test".to_string(),
3955                    integration_id: Some(42),
3956                }
3957            ]
3958        );
3959    }
3960
3961    #[test]
3962    fn parse_commit_meta_reads_parents_in_order_and_logins() {
3963        let merge = serde_json::json!({
3964            "parents": [{"sha": "approved"}, {"sha": "base"}],
3965            "author": {"login": "owner"},
3966            "committer": {"login": "web-flow"},
3967        });
3968        let meta = parse_commit_meta(&merge);
3969        assert_eq!(meta.parents, vec!["approved", "base"]);
3970        assert_eq!(meta.author_login.as_deref(), Some("owner"));
3971        assert_eq!(meta.committer_login.as_deref(), Some("web-flow"));
3972        assert_eq!(
3973            parse_commit_meta(&serde_json::json!({})),
3974            CommitMeta::default()
3975        );
3976    }
3977
3978    #[test]
3979    fn parse_behind_by_reads_compare_status() {
3980        // A head behind the base must not merge: its checks ran against old code.
3981        let behind = serde_json::json!({"status": "behind", "behind_by": 3, "ahead_by": 0});
3982        assert_eq!(parse_behind_by(&behind).unwrap(), 3);
3983        let diverged = serde_json::json!({"status": "diverged", "behind_by": 1, "ahead_by": 2});
3984        assert_eq!(parse_behind_by(&diverged).unwrap(), 1);
3985        let ahead = serde_json::json!({"status": "ahead", "behind_by": 0, "ahead_by": 2});
3986        assert_eq!(parse_behind_by(&ahead).unwrap(), 0);
3987        // An unreadable response is an error, never a permissive zero.
3988        assert!(parse_behind_by(&serde_json::json!({"status": "ahead"})).is_err());
3989    }
3990
3991    async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3992        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3993        let addr = listener.local_addr().unwrap();
3994
3995        tokio::spawn(async move {
3996            let (mut socket, _) = listener.accept().await.unwrap();
3997            let mut buf = [0u8; 4096];
3998            let _ = socket.read(&mut buf).await;
3999
4000            let declared_len = body_prefix.len() + 32;
4001            let response = format!(
4002                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
4003            );
4004            socket.write_all(response.as_bytes()).await.unwrap();
4005            socket.shutdown().await.unwrap();
4006        });
4007
4008        format!("http://{addr}")
4009    }
4010
4011    #[test]
4012    fn merge_pr_body_pins_the_head_sha() {
4013        let sha = "0123456789abcdef0123456789abcdef01234567";
4014        let body = merge_pr_body(sha);
4015        assert_eq!(body["merge_method"], "squash");
4016        assert_eq!(body["sha"], sha);
4017        assert!(is_full_commit_sha(sha));
4018        assert!(!is_full_commit_sha("abc"));
4019        assert!(!is_full_commit_sha(""));
4020    }
4021
4022    fn test_client() -> GhHttp {
4023        GhHttp {
4024            client: reqwest::Client::new(),
4025            token_resolver: Arc::new(TokenResolver::default_env()),
4026            app: None,
4027            owner_only: false,
4028            cached_username: OnceLock::new(),
4029            api_base: None,
4030        }
4031    }
4032
4033    /// Verify `GhHttp::new()` returns `Ok` and does not panic on construction.
4034    ///
4035    /// This test guards against regressions where `GhHttp::new()` might panic
4036    /// (e.g., if TLS initialisation fails or the builder API changes).
4037    #[test]
4038    fn gh_http_new_returns_ok() {
4039        let result = GhHttp::new();
4040        assert!(
4041            result.is_ok(),
4042            "GhHttp::new() must not panic and must return Ok; got: {:?}",
4043            result.err()
4044        );
4045    }
4046
4047    #[test]
4048    fn batch_get_issue_states_query_includes_labels_pagination() {
4049        let issue_numbers = [1u64, 2, 3];
4050
4051        let aliases: String = issue_numbers
4052            .iter()
4053            .map(|n| {
4054                format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
4055            })
4056            .collect::<Vec<_>>()
4057            .join(" ");
4058
4059        let query = format!(
4060            r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
4061        );
4062        assert!(query.contains("$owner"));
4063        assert!(query.contains("$name"));
4064        assert!(query.contains("issue1: issue(number: 1)"));
4065        assert!(query.contains("issue3: issue(number: 3)"));
4066        assert!(query.contains("labels(first: 100)"));
4067    }
4068
4069    #[test]
4070    fn parse_positive_issue_number_accepts_positive_values() {
4071        assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
4072    }
4073
4074    #[test]
4075    fn parse_positive_issue_number_rejects_zero() {
4076        let err = parse_positive_issue_number("0").unwrap_err().to_string();
4077        assert!(err.contains("invalid issue number: 0"));
4078    }
4079
4080    #[test]
4081    fn parse_positive_issue_number_rejects_internal_ids() {
4082        let err = parse_positive_issue_number("internal:5")
4083            .unwrap_err()
4084            .to_string();
4085        assert!(err.contains("invalid issue number: internal:5"));
4086    }
4087
4088    #[test]
4089    fn pr_review_batch_query_includes_page_info_for_nested_connections() {
4090        let query = build_pr_review_alias(42);
4091
4092        assert!(query.contains("reviews(first: 50)"));
4093        assert!(query.contains("reviews(first: 50) {\n    nodes"));
4094        assert!(query.contains("pageInfo { hasNextPage }"));
4095        assert!(query.contains("reviewThreads(first: 50)"));
4096        assert!(query.contains("comments(first: 20)"));
4097        assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4098        assert!(query.contains("updatedAt"));
4099    }
4100
4101    #[test]
4102    fn pr_issue_comments_page_query_uses_cursor_pagination() {
4103        let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
4104
4105        assert!(query.contains("pullRequest(number: $number)"));
4106        assert!(query.contains("comments(first: 100, after: $after)"));
4107        assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4108        assert!(query.contains("updatedAt"));
4109        assert_eq!(vars["owner"], "owner");
4110        assert_eq!(vars["name"], "repo");
4111        assert_eq!(vars["number"], 42);
4112        assert_eq!(vars["after"], "cursor123");
4113    }
4114
4115    #[test]
4116    fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
4117        let page_info = serde_json::json!({
4118            "hasNextPage": true,
4119            "endCursor": "cursor123"
4120        });
4121        assert_eq!(
4122            connection_end_cursor(Some(&page_info)),
4123            Some("cursor123".to_string())
4124        );
4125
4126        let final_page = serde_json::json!({
4127            "hasNextPage": false,
4128            "endCursor": "cursor456"
4129        });
4130        assert_eq!(connection_end_cursor(Some(&final_page)), None);
4131    }
4132
4133    #[test]
4134    fn status_label_colors_match_bash_palette() {
4135        assert_eq!(status_label_color("status:new"), "0e8a16");
4136        assert_eq!(status_label_color("status:routed"), "1d76db");
4137        assert_eq!(status_label_color("status:in_progress"), "fbca04");
4138        assert_eq!(status_label_color("status:done"), "6f42c1");
4139        assert_eq!(status_label_color("status:blocked"), "d73a4a");
4140        assert_eq!(status_label_color("status:in_review"), "0075ca");
4141        assert_eq!(status_label_color("status:needs_review"), "e4e669");
4142    }
4143
4144    #[test]
4145    fn unknown_label_gets_default_color() {
4146        assert_eq!(status_label_color("agent:claude"), "c5def5");
4147        assert_eq!(status_label_color("enhancement"), "c5def5");
4148        assert_eq!(status_label_color(""), "c5def5");
4149    }
4150
4151    #[test]
4152    fn parse_link_next_finds_next_url() {
4153        let mut headers = header::HeaderMap::new();
4154        headers.insert(
4155            "link",
4156            "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
4157                .parse()
4158                .unwrap(),
4159        );
4160        assert_eq!(
4161            parse_link_next(&headers),
4162            Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
4163        );
4164    }
4165
4166    #[test]
4167    fn parse_link_next_none_when_no_next() {
4168        let mut headers = header::HeaderMap::new();
4169        headers.insert(
4170            "link",
4171            "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
4172                .parse()
4173                .unwrap(),
4174        );
4175        assert_eq!(parse_link_next(&headers), None);
4176    }
4177
4178    #[test]
4179    fn parse_link_next_none_when_missing() {
4180        let headers = header::HeaderMap::new();
4181        assert_eq!(parse_link_next(&headers), None);
4182    }
4183
4184    #[test]
4185    fn parse_link_next_none_when_malformed() {
4186        let mut headers = header::HeaderMap::new();
4187        // Malformed: no angle-bracket URL, just garbage
4188        headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
4189        assert_eq!(parse_link_next(&headers), None);
4190    }
4191
4192    #[test]
4193    fn parse_link_next_none_when_gt_before_lt() {
4194        let mut headers = header::HeaderMap::new();
4195        headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
4196        assert_eq!(parse_link_next(&headers), None); // should not panic
4197    }
4198
4199    #[test]
4200    fn parse_link_next_none_when_empty_url() {
4201        let mut headers = header::HeaderMap::new();
4202        headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
4203        assert_eq!(parse_link_next(&headers), None);
4204    }
4205
4206    fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
4207        RateLimit {
4208            remaining: None,
4209            reset_at: None,
4210            backoff_until: None,
4211            backoff_delay: Duration::ZERO,
4212            backoff_base: Duration::from_secs(base_secs),
4213            backoff_max: Duration::from_secs(max_secs),
4214            throttle_threshold: 10,
4215        }
4216    }
4217
4218    #[test]
4219    fn rate_limit_inactive_by_default() {
4220        let rl = make_rl(30, 900);
4221        assert!(rl.is_active().is_none());
4222    }
4223
4224    #[test]
4225    fn rate_limit_backoff_activates() {
4226        let mut rl = make_rl(5, 60);
4227        rl.record_rate_limit();
4228        assert!(rl.is_active().is_some());
4229        assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4230    }
4231
4232    #[test]
4233    fn rate_limit_backoff_doubles() {
4234        let mut rl = make_rl(5, 60);
4235        rl.record_rate_limit();
4236        assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4237        rl.record_rate_limit();
4238        assert_eq!(rl.backoff_delay, Duration::from_secs(10));
4239        rl.record_rate_limit();
4240        assert_eq!(rl.backoff_delay, Duration::from_secs(20));
4241    }
4242
4243    #[test]
4244    fn rate_limit_backoff_caps_at_max() {
4245        let mut rl = make_rl(30, 60);
4246        rl.record_rate_limit(); // 30
4247        rl.record_rate_limit(); // 60
4248        rl.record_rate_limit(); // capped at 60
4249        assert_eq!(rl.backoff_delay, Duration::from_secs(60));
4250    }
4251
4252    #[test]
4253    fn rate_limit_success_resets() {
4254        let mut rl = make_rl(5, 60);
4255        rl.record_rate_limit();
4256        assert!(rl.is_active().is_some());
4257        rl.record_success();
4258        assert!(rl.is_active().is_none());
4259        assert_eq!(rl.backoff_delay, Duration::ZERO);
4260    }
4261
4262    #[test]
4263    fn page_cache_key_separates_types_and_queries() {
4264        let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
4265        let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
4266        let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
4267        assert_ne!(a, b);
4268        assert_ne!(a, c);
4269        assert_eq!(
4270            a,
4271            page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
4272        );
4273    }
4274
4275    /// `since` is derived from the clock, so the key would never recur.
4276    #[test]
4277    fn page_cache_key_skips_since_filtered_requests() {
4278        assert!(
4279            page_cache_key::<GitHubIssue>(
4280                "/issues",
4281                Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
4282            )
4283            .is_none()
4284        );
4285        // Also when it arrives embedded in a Link: next URL.
4286        assert!(
4287            page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
4288                .is_none()
4289        );
4290    }
4291
4292    /// A limiter with no quota left and a reset an hour out: the check must return an
4293    /// error straight away. Sleeping here is what froze the tick loop for 504s.
4294    #[test]
4295    fn low_quota_fails_fast_instead_of_waiting_for_reset() {
4296        let mut rl = make_rl(30, 900);
4297        rl.remaining = Some(0);
4298        rl.reset_at = Some(
4299            SystemTime::now()
4300                .duration_since(std_time::UNIX_EPOCH)
4301                .unwrap()
4302                .as_secs()
4303                + 3600,
4304        );
4305        let cell = Mutex::new(rl);
4306
4307        let start = Instant::now();
4308        let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
4309        assert!(
4310            start.elapsed() < Duration::from_secs(1),
4311            "check blocked for {:?}",
4312            start.elapsed()
4313        );
4314        assert!(err.to_string().contains("rate-limited"), "{err}");
4315    }
4316
4317    /// Quota above the reserve is not a reason to skip the call.
4318    #[test]
4319    fn healthy_quota_passes_the_check() {
4320        let mut rl = make_rl(30, 900);
4321        rl.remaining = Some(4000);
4322        rl.reset_at = Some(u64::MAX / 2);
4323        let cell = Mutex::new(rl);
4324        assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
4325    }
4326
4327    #[test]
4328    fn rate_limit_proactive_pause() {
4329        let mut rl = make_rl(30, 900);
4330        // Simulate: 0 remaining (below default threshold of 10), reset 60s in the future
4331        rl.remaining = Some(0);
4332        let future_epoch = SystemTime::now()
4333            .duration_since(std_time::UNIX_EPOCH)
4334            .unwrap()
4335            .as_secs()
4336            + 60;
4337        rl.reset_at = Some(future_epoch);
4338        let pause = rl.is_active();
4339        assert!(pause.is_some());
4340        // Should be roughly 60 seconds (within tolerance, +1s buffer added)
4341        assert!(pause.unwrap().as_secs() <= 62);
4342    }
4343
4344    #[test]
4345    fn rate_limit_proactive_pause_threshold() {
4346        let mut rl = make_rl(30, 900);
4347        // remaining=15 is above default threshold of 10 — no proactive wait
4348        rl.remaining = Some(15);
4349        let future_epoch = SystemTime::now()
4350            .duration_since(std_time::UNIX_EPOCH)
4351            .unwrap()
4352            .as_secs()
4353            + 60;
4354        rl.reset_at = Some(future_epoch);
4355        assert!(rl.is_active().is_none());
4356
4357        // remaining=5 is below threshold — proactive wait activates
4358        rl.remaining = Some(5);
4359        assert!(rl.is_active().is_some());
4360    }
4361
4362    #[test]
4363    fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
4364        // No check runs yet and GitHub hasn't said it's clean → pending (wait for CI to queue)
4365        assert_eq!(
4366            GhHttp::combined_status_state(0, 0, 0, true, None),
4367            "pending"
4368        );
4369        // No workflows configured → no need to wait
4370        assert_eq!(
4371            GhHttp::combined_status_state(0, 0, 0, false, None),
4372            "success"
4373        );
4374        // Failing check → failure regardless
4375        assert_eq!(
4376            GhHttp::combined_status_state(3, 1, 0, true, None),
4377            "failure"
4378        );
4379        // paths-ignore filtered out all workflows: no check runs but GitHub says "clean"
4380        assert_eq!(
4381            GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
4382            "success"
4383        );
4384        // mergeable_state is dirty/blocked — still pending even with has_workflows
4385        assert_eq!(
4386            GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
4387            "pending"
4388        );
4389    }
4390
4391    #[test]
4392    fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
4393        let resp = serde_json::json!({
4394            "data": {},
4395            "errors": [{ "message": "Something went wrong" }],
4396        });
4397
4398        let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
4399            .unwrap_err()
4400            .to_string();
4401        assert!(err_text.contains("missing /data/repository in GraphQL response"));
4402        assert!(err_text.contains("Something went wrong"));
4403    }
4404
4405    #[test]
4406    fn rate_limit_uses_reset_at_for_backoff() {
4407        let mut rl = make_rl(30, 900);
4408        // Set reset_at to 45s in the future
4409        let future_epoch = SystemTime::now()
4410            .duration_since(std_time::UNIX_EPOCH)
4411            .unwrap()
4412            .as_secs()
4413            + 45;
4414        rl.reset_at = Some(future_epoch);
4415        rl.record_rate_limit();
4416        // Backoff should use reset_at (~46s) not the base (30s)
4417        assert!(rl.backoff_delay.as_secs() >= 45);
4418        assert!(rl.backoff_delay.as_secs() <= 47);
4419    }
4420
4421    #[tokio::test]
4422    async fn send_with_retries_propagates_make_req_error() {
4423        let guard = env::var_os("GH_TOKEN");
4424        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4425        unsafe { env::set_var("GH_TOKEN", "test_token") };
4426        let gh = test_client();
4427        let result = gh
4428            .send_with_retries(
4429                || Err(anyhow::anyhow!("simulated request clone failure")),
4430                false,
4431            )
4432            .await;
4433        assert!(result.is_err());
4434        let err = result.unwrap_err().to_string();
4435        assert_eq!(err, "simulated request clone failure");
4436        match guard {
4437            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4438            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4439            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4440            None => unsafe { env::remove_var("GH_TOKEN") },
4441        }
4442    }
4443
4444    /// A transport-level failure (connection refused — GitHub never reached)
4445    /// must exhaust retries without tripping the shared "github:5xx" circuit
4446    /// breaker: that breaker is read by the routing-phase skip and other
4447    /// subsystems as a signal specifically about GitHub's health, which a
4448    /// local connectivity blip says nothing about.
4449    #[tokio::test]
4450    async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
4451        let guard = env::var_os("GH_TOKEN");
4452        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4453        unsafe { env::set_var("GH_TOKEN", "test_token") };
4454
4455        // Bind then immediately drop a listener to get a port nothing is
4456        // listening on, so connecting reliably fails with "connection refused"
4457        // rather than depending on an external unreachable host.
4458        let addr = {
4459            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4460            listener.local_addr().unwrap()
4461        };
4462        let url = format!("http://{addr}/");
4463
4464        let gh = test_client();
4465        let make_req = || Ok(gh.client.get(&url));
4466        let result = gh.send_with_retries(make_req, false).await;
4467
4468        assert!(result.is_err());
4469        assert!(
4470            !cooldown::is_agent_in_cooldown("github:5xx"),
4471            "transport error must not trip the github:5xx circuit breaker"
4472        );
4473        assert!(cooldown::cooldown_until("github:5xx").is_none());
4474
4475        match guard {
4476            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4477            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4478            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4479            None => unsafe { env::remove_var("GH_TOKEN") },
4480        }
4481    }
4482
4483    #[tokio::test]
4484    async fn post_json_raw_propagates_body_read_errors_on_success() {
4485        let guard = env::var_os("GH_TOKEN");
4486        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4487        unsafe { env::set_var("GH_TOKEN", "test_token") };
4488        let server_url = spawn_truncated_body_server("{").await;
4489        let gh = test_client();
4490
4491        let result = gh
4492            .post_json_raw(
4493                &format!("{server_url}/post"),
4494                &serde_json::json!({"ok": true}),
4495            )
4496            .await;
4497
4498        let err = result.unwrap_err().to_string();
4499        assert!(
4500            !err.contains("EOF while parsing a value"),
4501            "should surface the body read error, got: {err}"
4502        );
4503        assert!(
4504            err.contains("error decoding response body")
4505                || err.contains("request or response body error")
4506                || err.contains("end of file before message length reached"),
4507            "expected a transport/body read error, got: {err}"
4508        );
4509
4510        match guard {
4511            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4512            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4513            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4514            None => unsafe { env::remove_var("GH_TOKEN") },
4515        }
4516    }
4517
4518    #[tokio::test]
4519    async fn patch_json_raw_propagates_body_read_errors_on_success() {
4520        let guard = env::var_os("GH_TOKEN");
4521        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4522        unsafe { env::set_var("GH_TOKEN", "test_token") };
4523        let server_url = spawn_truncated_body_server("{").await;
4524        let gh = test_client();
4525
4526        let result = gh
4527            .patch_json_raw(
4528                &format!("{server_url}/patch"),
4529                &serde_json::json!({"ok": true}),
4530            )
4531            .await;
4532
4533        let err = result.unwrap_err().to_string();
4534        assert!(
4535            !err.contains("EOF while parsing a value"),
4536            "should surface the body read error, got: {err}"
4537        );
4538        assert!(
4539            err.contains("error decoding response body")
4540                || err.contains("request or response body error")
4541                || err.contains("end of file before message length reached"),
4542            "expected a transport/body read error, got: {err}"
4543        );
4544
4545        match guard {
4546            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4547            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4548            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4549            None => unsafe { env::remove_var("GH_TOKEN") },
4550        }
4551    }
4552
4553    #[tokio::test]
4554    async fn graphql_request_propagates_body_read_errors_on_success() {
4555        let guard = env::var_os("GH_TOKEN");
4556        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4557        unsafe { env::set_var("GH_TOKEN", "test_token") };
4558        let server_url = spawn_truncated_body_server("{").await;
4559        let gh = test_client();
4560        let query = "query { viewer { login } }";
4561
4562        let result = gh
4563            .graphql_request_to_url(
4564                &format!("{server_url}/graphql"),
4565                query,
4566                &[],
4567                Some(serde_json::json!({"k": "v"})),
4568            )
4569            .await;
4570
4571        let err = result.unwrap_err().to_string();
4572        assert!(
4573            !err.contains("EOF while parsing a value"),
4574            "should surface the body read error, got: {err}"
4575        );
4576        assert!(
4577            err.contains("error decoding response body")
4578                || err.contains("request or response body error")
4579                || err.contains("end of file before message length reached"),
4580            "expected a transport/body read error, got: {err}"
4581        );
4582
4583        match guard {
4584            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4585            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4586            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4587            None => unsafe { env::remove_var("GH_TOKEN") },
4588        }
4589    }
4590
4591    // ── get_all_pages integration tests (wiremock) ────────────────────────
4592
4593    #[cfg(test)]
4594    mod pagination {
4595        use super::super::*;
4596        use crate::github::token::TokenResolver;
4597        use std::env;
4598        use std::ffi::OsString;
4599        use std::sync::Arc;
4600        use std::time;
4601        use std::time::SystemTime;
4602        use wiremock::matchers::{method, path};
4603        use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate, matchers};
4604
4605        /// wiremock has no negated matcher, and the first poll must be matched on the
4606        /// absence of `If-None-Match` to tell it apart from the conditional one.
4607        struct NotMatcher<M>(M);
4608
4609        impl<M: Match> Match for NotMatcher<M> {
4610            fn matches(&self, request: &Request) -> bool {
4611                !self.0.matches(request)
4612            }
4613        }
4614
4615        struct EnvVarGuard {
4616            key: &'static str,
4617            previous: Option<OsString>,
4618        }
4619
4620        impl EnvVarGuard {
4621            fn set(key: &'static str, value: &str) -> Self {
4622                let previous = env::var_os(key);
4623                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4624                unsafe { env::set_var(key, value) };
4625                Self { key, previous }
4626            }
4627        }
4628
4629        impl Drop for EnvVarGuard {
4630            fn drop(&mut self) {
4631                match self.previous.take() {
4632                    // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4633                    Some(value) => unsafe { env::set_var(self.key, value) },
4634                    // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4635                    None => unsafe { env::remove_var(self.key) },
4636                }
4637            }
4638        }
4639
4640        /// Build a minimal GhHttp pointing at a wiremock server.
4641        /// The token resolver reads GH_TOKEN; we set it to a dummy value.
4642        fn make_client() -> (GhHttp, EnvVarGuard) {
4643            // Ensure a token is available so auth_header() doesn't bail.
4644            let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4645            let client = GhHttp {
4646                client: reqwest::Client::new(),
4647                token_resolver: Arc::new(TokenResolver::default_env()),
4648                app: None,
4649                owner_only: false,
4650                cached_username: OnceLock::new(),
4651                api_base: None,
4652            };
4653            (client, guard)
4654        }
4655
4656        #[tokio::test]
4657        async fn get_all_pages_single_page() {
4658            let server = MockServer::start().await;
4659
4660            Mock::given(method("GET"))
4661                .and(path("/items"))
4662                .respond_with(
4663                    ResponseTemplate::new(200)
4664                        .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4665                )
4666                .expect(1)
4667                .mount(&server)
4668                .await;
4669
4670            let (gh, _guard) = make_client();
4671            let url = format!("{}/items", server.uri());
4672            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4673
4674            assert_eq!(result.len(), 2);
4675            assert_eq!(result[0]["id"], 1);
4676            assert_eq!(result[1]["id"], 2);
4677        }
4678
4679        #[tokio::test]
4680        async fn get_all_pages_follows_link_next() {
4681            let server = MockServer::start().await;
4682
4683            // Page 1 — includes Link header pointing to page 2
4684            Mock::given(method("GET"))
4685                .and(path("/items"))
4686                .respond_with(
4687                    ResponseTemplate::new(200)
4688                        .set_body_json(serde_json::json!([{"id": 1}]))
4689                        .insert_header(
4690                            "Link",
4691                            format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4692                        ),
4693                )
4694                .expect(1)
4695                .mount(&server)
4696                .await;
4697
4698            // Page 2 — no Link header (last page)
4699            Mock::given(method("GET"))
4700                .and(path("/items/page2"))
4701                .respond_with(
4702                    ResponseTemplate::new(200)
4703                        .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4704                )
4705                .expect(1)
4706                .mount(&server)
4707                .await;
4708
4709            let (gh, _guard) = make_client();
4710            let url = format!("{}/items", server.uri());
4711            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4712
4713            assert_eq!(result.len(), 3);
4714            assert_eq!(result[0]["id"], 1);
4715            assert_eq!(result[1]["id"], 2);
4716            assert_eq!(result[2]["id"], 3);
4717        }
4718
4719        #[tokio::test]
4720        async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4721            // Simulates a proxy/transient response that drops the Link header mid-pagination.
4722            // The previous `.unwrap()` would panic here; the fix breaks the loop instead.
4723            let server = MockServer::start().await;
4724
4725            Mock::given(method("GET"))
4726                .and(path("/items"))
4727                .respond_with(
4728                    ResponseTemplate::new(200)
4729                        .set_body_json(serde_json::json!([{"id": 1}]))
4730                        .insert_header(
4731                            "Link",
4732                            format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4733                        ),
4734                )
4735                .expect(1)
4736                .mount(&server)
4737                .await;
4738
4739            // Page 2 response has NO Link header — pagination should stop cleanly.
4740            Mock::given(method("GET"))
4741                .and(path("/items/page2"))
4742                .respond_with(
4743                    ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4744                )
4745                .expect(1)
4746                .mount(&server)
4747                .await;
4748
4749            let (gh, _guard) = make_client();
4750            let url = format!("{}/items", server.uri());
4751            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4752
4753            // Must not panic and must return both items collected so far.
4754            assert_eq!(result.len(), 2);
4755        }
4756
4757        #[tokio::test]
4758        async fn get_all_pages_stops_on_malformed_link_header() {
4759            // A malformed Link header (no angle-bracket URL) yields None from parse_link_next,
4760            // which should stop pagination rather than panic.
4761            let server = MockServer::start().await;
4762
4763            Mock::given(method("GET"))
4764                .and(path("/items"))
4765                .respond_with(
4766                    ResponseTemplate::new(200)
4767                        .set_body_json(serde_json::json!([{"id": 1}]))
4768                        .insert_header("Link", "garbage; rel=\"next\""),
4769                )
4770                .expect(1)
4771                .mount(&server)
4772                .await;
4773
4774            let (gh, _guard) = make_client();
4775            let url = format!("{}/items", server.uri());
4776            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4777
4778            // Malformed Link header → pagination stops after first page, no panic.
4779            assert_eq!(result.len(), 1);
4780        }
4781
4782        #[tokio::test]
4783        async fn get_all_pages_returns_error_on_malformed_json_body() {
4784            let server = MockServer::start().await;
4785
4786            Mock::given(method("GET"))
4787                .and(path("/items"))
4788                .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4789                .expect(3)
4790                .mount(&server)
4791                .await;
4792
4793            let (gh, _guard) = make_client();
4794            let url = format!("{}/items", server.uri());
4795            let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4796
4797            assert!(result.is_err());
4798            let err = result.unwrap_err().to_string();
4799            assert!(
4800                err.contains("JSON decode failed") || err.contains("expected ident"),
4801                "error should mention JSON decode or serde error: {err}"
4802            );
4803        }
4804
4805        // ── conditional request / rate-limit tests ───────────────────────────
4806
4807        /// Restores the global REST limiter and the page cache, both of which these two
4808        /// tests have to poison to exercise the real request path.
4809        struct GlobalStateGuard;
4810
4811        impl Drop for GlobalStateGuard {
4812            fn drop(&mut self) {
4813                if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4814                    *rl = RateLimit::new();
4815                }
4816                if let Ok(mut cache) = PAGE_CACHE.lock() {
4817                    cache.clear();
4818                }
4819            }
4820        }
4821
4822        #[tokio::test]
4823        async fn get_all_pages_replays_body_on_304() {
4824            let _reset = GlobalStateGuard;
4825            let server = MockServer::start().await;
4826
4827            // First poll: 200 with an ETag.
4828            Mock::given(method("GET"))
4829                .and(path("/issues"))
4830                .and(matchers::header_exists("authorization"))
4831                .and(NotMatcher(matchers::header_exists("if-none-match")))
4832                .respond_with(
4833                    ResponseTemplate::new(200)
4834                        .set_body_json(serde_json::json!([{"number": 1}]))
4835                        .insert_header("ETag", "\"abc123\""),
4836                )
4837                .expect(1)
4838                .mount(&server)
4839                .await;
4840
4841            // Second poll: orch sends If-None-Match, GitHub answers 304 with no body.
4842            Mock::given(method("GET"))
4843                .and(path("/issues"))
4844                .and(matchers::header("if-none-match", "\"abc123\""))
4845                .respond_with(ResponseTemplate::new(304))
4846                .expect(1)
4847                .mount(&server)
4848                .await;
4849
4850            let (gh, _guard) = make_client();
4851            let url = format!("{}/issues", server.uri());
4852
4853            let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4854            let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4855
4856            assert_eq!(first, second);
4857            assert_eq!(second.len(), 1);
4858            assert_eq!(second[0]["number"], 1);
4859        }
4860
4861        #[tokio::test]
4862        async fn exhausted_quota_skips_further_calls_until_reset() {
4863            let _reset = GlobalStateGuard;
4864            let server = MockServer::start().await;
4865
4866            let reset_at = SystemTime::now()
4867                .duration_since(time::UNIX_EPOCH)
4868                .unwrap()
4869                .as_secs()
4870                + 600;
4871
4872            // One response, and only one: it reports an exhausted core quota, so every
4873            // later call must be skipped locally rather than sent.
4874            Mock::given(method("GET"))
4875                .and(path("/issues"))
4876                .respond_with(
4877                    ResponseTemplate::new(200)
4878                        .set_body_json(serde_json::json!([{"number": 1}]))
4879                        .insert_header("x-ratelimit-remaining", "0")
4880                        .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4881                )
4882                .expect(1)
4883                .mount(&server)
4884                .await;
4885
4886            let (gh, _guard) = make_client();
4887            let url = format!("{}/issues", server.uri());
4888
4889            let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4890            assert_eq!(first.len(), 1);
4891
4892            // The next call returns immediately instead of waiting out the 600s window.
4893            let start = Instant::now();
4894            let err = gh
4895                .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4896                .await
4897                .unwrap_err();
4898            assert!(
4899                start.elapsed() < Duration::from_secs(1),
4900                "call blocked for {:?} instead of failing fast",
4901                start.elapsed()
4902            );
4903            assert!(err.to_string().contains("rate-limited"), "{err}");
4904
4905            // The engine reports the window from the same state the skip reads.
4906            let m = rate_limit_metrics();
4907            assert_eq!(m.rest_remaining, Some(0));
4908            assert_eq!(m.rest_reset_at, Some(reset_at));
4909            assert!(GhHttp::is_rate_limited().is_some());
4910
4911            // `.expect(1)` on the mock is verified on drop: no second request went out.
4912        }
4913
4914        // ── get_all_pages_from_wrapper tests ─────────────────────────────────
4915
4916        #[tokio::test]
4917        async fn get_all_pages_from_wrapper_single_page() {
4918            // Simulates GitHub's check-runs response: a wrapper object with the
4919            // array nested under a named field.
4920            let server = MockServer::start().await;
4921
4922            Mock::given(method("GET"))
4923                .and(path("/check-runs"))
4924                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4925                    "total_count": 2,
4926                    "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4927                })))
4928                .expect(1)
4929                .mount(&server)
4930                .await;
4931
4932            let (gh, _guard) = make_client();
4933            let url = format!("{}/check-runs", server.uri());
4934            let result: Vec<serde_json::Value> = gh
4935                .get_all_pages_from_wrapper(&url, "check_runs")
4936                .await
4937                .unwrap();
4938
4939            assert_eq!(result.len(), 2);
4940            assert_eq!(result[0]["id"], 1);
4941            assert_eq!(result[1]["id"], 2);
4942        }
4943
4944        #[tokio::test]
4945        async fn get_all_pages_from_wrapper_follows_link_next() {
4946            // Two-page check-runs response (common for repos with >100 distinct check names).
4947            let server = MockServer::start().await;
4948
4949            Mock::given(method("GET"))
4950                .and(path("/check-runs"))
4951                .respond_with(
4952                    ResponseTemplate::new(200)
4953                        .set_body_json(serde_json::json!({
4954                            "total_count": 3,
4955                            "check_runs": [{"id": 1, "name": "build"}]
4956                        }))
4957                        .insert_header(
4958                            "Link",
4959                            format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4960                        ),
4961                )
4962                .expect(1)
4963                .mount(&server)
4964                .await;
4965
4966            Mock::given(method("GET"))
4967                .and(path("/check-runs/page2"))
4968                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4969                    "total_count": 3,
4970                    "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4971                })))
4972                .expect(1)
4973                .mount(&server)
4974                .await;
4975
4976            let (gh, _guard) = make_client();
4977            let url = format!("{}/check-runs", server.uri());
4978            let result: Vec<serde_json::Value> = gh
4979                .get_all_pages_from_wrapper(&url, "check_runs")
4980                .await
4981                .unwrap();
4982
4983            assert_eq!(result.len(), 3);
4984            assert_eq!(result[0]["id"], 1);
4985            assert_eq!(result[1]["id"], 2);
4986            assert_eq!(result[2]["id"], 3);
4987        }
4988
4989        #[tokio::test]
4990        async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4991            // When the array field is missing or empty, the function should return
4992            // an empty vec rather than erroring.
4993            let server = MockServer::start().await;
4994
4995            Mock::given(method("GET"))
4996                .and(path("/check-runs"))
4997                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4998                    "total_count": 0,
4999                    "check_runs": []
5000                })))
5001                .expect(1)
5002                .mount(&server)
5003                .await;
5004
5005            let (gh, _guard) = make_client();
5006            let url = format!("{}/check-runs", server.uri());
5007            let result: Vec<serde_json::Value> = gh
5008                .get_all_pages_from_wrapper(&url, "check_runs")
5009                .await
5010                .unwrap();
5011
5012            assert_eq!(result.len(), 0);
5013        }
5014
5015        #[tokio::test]
5016        async fn get_all_pages_from_wrapper_error_on_non_success_status() {
5017            let server = MockServer::start().await;
5018
5019            Mock::given(method("GET"))
5020                .and(path("/check-runs"))
5021                .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
5022                .expect(1)
5023                .mount(&server)
5024                .await;
5025
5026            let (gh, _guard) = make_client();
5027            let url = format!("{}/check-runs", server.uri());
5028            let result = gh
5029                .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
5030                .await;
5031
5032            assert!(result.is_err());
5033            let err = result.unwrap_err().to_string();
5034            assert!(
5035                err.contains("422") || err.contains("failed"),
5036                "error should indicate failure: {err}"
5037            );
5038        }
5039    }
5040
5041    // ── 401 token-refresh tests (wiremock) ────────────────────────────────
5042
5043    #[cfg(test)]
5044    mod auth_retry {
5045        use super::test_client;
5046        use crate::github::token;
5047        use std::env;
5048        use std::sync::Arc;
5049        use std::sync::atomic::{AtomicUsize, Ordering};
5050        use wiremock::matchers::method;
5051        use wiremock::{Mock, MockServer, Request, ResponseTemplate};
5052
5053        fn bad_credentials() -> ResponseTemplate {
5054            ResponseTemplate::new(401).set_body_json(serde_json::json!({
5055                "message": "Bad credentials",
5056                "status": 401
5057            }))
5058        }
5059
5060        #[expect(
5061            clippy::await_holding_lock,
5062            reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5063        )]
5064        #[tokio::test]
5065        async fn retries_once_with_fresh_token_after_401() {
5066            let _env_guard = token::ENV_LOCK.lock().unwrap();
5067            let prev = env::var_os("GH_TOKEN");
5068            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5069            unsafe { env::set_var("GH_TOKEN", "bad_token") };
5070
5071            let server = MockServer::start().await;
5072            let hits = Arc::new(AtomicUsize::new(0));
5073            let hits_in_handler = Arc::clone(&hits);
5074            Mock::given(method("GET"))
5075                .respond_with(move |_req: &Request| {
5076                    if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
5077                        // The 401 handler clears the cache and re-resolves; simulate the
5078                        // operator having fixed the env var in the meantime
5079                        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5080                        unsafe { env::set_var("GH_TOKEN", "good_token") };
5081                        bad_credentials()
5082                    } else {
5083                        ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
5084                    }
5085                })
5086                .mount(&server)
5087                .await;
5088
5089            let gh = test_client();
5090            let result: anyhow::Result<serde_json::Value> =
5091                gh.get_json(&format!("{}/x", server.uri())).await;
5092
5093            match prev {
5094                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5095                Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
5096                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5097                None => unsafe { env::remove_var("GH_TOKEN") },
5098            }
5099
5100            assert!(
5101                result.is_ok(),
5102                "retry with re-resolved token should succeed: {:?}",
5103                result.err()
5104            );
5105            assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
5106        }
5107
5108        #[expect(
5109            clippy::await_holding_lock,
5110            reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5111        )]
5112        #[tokio::test]
5113        async fn double_401_bails_with_distinct_auth_error() {
5114            let _env_guard = token::ENV_LOCK.lock().unwrap();
5115            let prev = env::var_os("GH_TOKEN");
5116            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5117            unsafe { env::set_var("GH_TOKEN", "bad_token") };
5118
5119            let server = MockServer::start().await;
5120            let hits = Arc::new(AtomicUsize::new(0));
5121            let hits_in_handler = Arc::clone(&hits);
5122            Mock::given(method("GET"))
5123                .respond_with(move |_req: &Request| {
5124                    hits_in_handler.fetch_add(1, Ordering::SeqCst);
5125                    bad_credentials()
5126                })
5127                .mount(&server)
5128                .await;
5129
5130            let gh = test_client();
5131            let result: anyhow::Result<serde_json::Value> =
5132                gh.get_json(&format!("{}/x", server.uri())).await;
5133
5134            match prev {
5135                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5136                Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
5137                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5138                None => unsafe { env::remove_var("GH_TOKEN") },
5139            }
5140
5141            let err = result.expect_err("persisting 401 must fail").to_string();
5142            assert!(
5143                err.contains("token re-resolved after cache clear but still rejected"),
5144                "expected distinct auth-failure error, got: {err}"
5145            );
5146            assert_eq!(
5147                hits.load(Ordering::SeqCst),
5148                2,
5149                "must retry exactly once, not loop"
5150            );
5151        }
5152    }
5153}