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) => {
1352                    let message = err.to_string();
1353                    // `send_with_retries` words a send failure with this marker
1354                    if message.contains("(transport error)") {
1355                        return Ok(BlockedByFetch::Unreachable(message));
1356                    }
1357                    return Ok(BlockedByFetch::Failed(message));
1358                }
1359            };
1360            let status = resp.status();
1361            if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
1362                let body = resp.text().await.unwrap_or_default();
1363                if status == StatusCode::FORBIDDEN
1364                    && body.to_ascii_lowercase().contains("rate limit")
1365                {
1366                    Self::maybe_record_rate_limit_from_body(status, &body);
1367                    return Ok(BlockedByFetch::Failed(body));
1368                }
1369                return Ok(BlockedByFetch::Unavailable);
1370            }
1371            if !status.is_success() {
1372                let body = resp.text().await.unwrap_or_default();
1373                Self::maybe_record_rate_limit_from_body(status, &body);
1374                return Ok(BlockedByFetch::Failed(format!("{status}: {body}")));
1375            }
1376            Self::record_success();
1377            let page_next = parse_link_next(resp.headers());
1378            let body = resp.text().await.unwrap_or_default();
1379            let page: Vec<BlockedByPayload> = match serde_json::from_str(&body) {
1380                Ok(page) => page,
1381                Err(err) => {
1382                    return Ok(BlockedByFetch::Failed(format!(
1383                        "blocked_by parse failed: {err}"
1384                    )));
1385                }
1386            };
1387            for item in page {
1388                if item.number <= 0 {
1389                    continue;
1390                }
1391                let item_repo = item
1392                    .repository
1393                    .as_ref()
1394                    .map(|r| r.full_name.clone())
1395                    .filter(|name| !name.is_empty())
1396                    .unwrap_or_else(|| repo.to_string());
1397                found.push(BlockedByIssue {
1398                    github_id: item.id,
1399                    repo: item_repo,
1400                    number: item.number,
1401                    state: item.state,
1402                    state_reason: item.state_reason,
1403                });
1404            }
1405            next = page_next;
1406        }
1407        Ok(BlockedByFetch::Native(found))
1408    }
1409
1410    /// `POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by`
1411    /// with `{"issue_id": blocking_issue_id}`. Same API version as `list_blocked_by`.
1412    /// 201 is success. A body that says the link already exists is also success
1413    pub async fn add_blocked_by(
1414        &self,
1415        repo: &str,
1416        issue_number: u64,
1417        blocking_issue_id: i64,
1418    ) -> anyhow::Result<()> {
1419        let url =
1420            format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by");
1421        let resp = self
1422            .dependency_request(
1423                &url,
1424                reqwest::Method::POST,
1425                Some(serde_json::json!({ "issue_id": blocking_issue_id })),
1426            )
1427            .await?;
1428        let status = resp.status();
1429        if status.is_success() {
1430            Self::record_success();
1431            return Ok(());
1432        }
1433        let body = resp.text().await.unwrap_or_default();
1434        if body.to_ascii_lowercase().contains("already") {
1435            return Ok(());
1436        }
1437        Self::maybe_record_rate_limit_from_body(status, &body);
1438        anyhow::bail!("add blocked_by failed ({status}): {body}")
1439    }
1440
1441    /// Remove a blocked-by link. `issue_id` in the path is the blocking issue's database id
1442    pub async fn remove_blocked_by(
1443        &self,
1444        repo: &str,
1445        number: u64,
1446        blocking_issue_id: u64,
1447    ) -> anyhow::Result<()> {
1448        let url = format!(
1449            "{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
1450        );
1451        self.delete(&url).await?;
1452        Ok(())
1453    }
1454
1455    /// GET or POST an issue-dependency URL with API version 2026-03-10
1456    async fn dependency_request(
1457        &self,
1458        url: &str,
1459        method: reqwest::Method,
1460        body: Option<serde_json::Value>,
1461    ) -> anyhow::Result<Response> {
1462        Self::check_backoff(url)?;
1463        let auth = self.auth_header(url).await?;
1464        let make_req = || {
1465            let mut req = self
1466                .client
1467                .request(method.clone(), url)
1468                .header(header::AUTHORIZATION, auth.clone())
1469                .header(header::ACCEPT, "application/vnd.github+json")
1470                .header("X-GitHub-Api-Version", "2026-03-10");
1471            if let Some(ref payload) = body {
1472                req = req.json(payload);
1473            }
1474            Ok(req)
1475        };
1476        self.send_with_retries(make_req, false).await
1477    }
1478
1479    /// Get a single issue.
1480    pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1481        self.get_json(&format!("{}/repos/{repo}/issues/{number}", self.api_root()))
1482            .await
1483    }
1484
1485    /// Check whether an issue number refers to a pull request.
1486    pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1487        self.get_issue(repo, number)
1488            .await
1489            .is_ok_and(|i| i.pull_request.is_some())
1490    }
1491
1492    /// List issues filtered by a label (paginated).
1493    pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1494        self.list_issues_with_state(repo, label, "open").await
1495    }
1496
1497    /// List issues filtered by a label and issue state (paginated).
1498    ///
1499    /// `state` is passed directly to the GitHub API: `"open"`, `"closed"`, or `"all"`.
1500    pub async fn list_issues_with_state(
1501        &self,
1502        repo: &str,
1503        label: &str,
1504        state: &str,
1505    ) -> anyhow::Result<Vec<GitHubIssue>> {
1506        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1507        // An empty label lists every issue
1508        let mut query = vec![("state", state), ("per_page", "100")];
1509        if !label.is_empty() {
1510            query.push(("labels", label));
1511        }
1512        let all: Vec<GitHubIssue> = self.get_all_pages(&url, &query).await?;
1513        // GitHub /issues API returns PRs too — filter them out
1514        Ok(all
1515            .into_iter()
1516            .filter(|i| i.pull_request.is_none())
1517            .collect())
1518    }
1519
1520    /// List closed issues updated since `since` (ISO 8601), filtered by label.
1521    ///
1522    /// Used for dedup checks — prevents re-creating issues that were recently closed.
1523    pub async fn list_issues_closed_since(
1524        &self,
1525        repo: &str,
1526        label: &str,
1527        since: &str,
1528    ) -> anyhow::Result<Vec<GitHubIssue>> {
1529        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1530        let all: Vec<GitHubIssue> = self
1531            .get_all_pages(
1532                &url,
1533                &[
1534                    ("labels", label),
1535                    ("state", "closed"),
1536                    ("since", since),
1537                    ("per_page", "100"),
1538                ],
1539            )
1540            .await?;
1541        Ok(all
1542            .into_iter()
1543            .filter(|i| i.pull_request.is_none())
1544            .collect())
1545    }
1546
1547    /// List closed issues updated since `since` (ISO 8601), no label filter.
1548    pub async fn list_closed_issues_since(
1549        &self,
1550        repo: &str,
1551        since: &str,
1552    ) -> anyhow::Result<Vec<GitHubIssue>> {
1553        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1554        let all: Vec<GitHubIssue> = self
1555            .get_all_pages(
1556                &url,
1557                &[("state", "closed"), ("since", since), ("per_page", "100")],
1558            )
1559            .await?;
1560        Ok(all
1561            .into_iter()
1562            .filter(|i| i.pull_request.is_none())
1563            .collect())
1564    }
1565
1566    /// List all open issues (no label filter, paginated).
1567    ///
1568    /// The URL carries no `since`, so it repeats across calls and `get_all_pages` can send
1569    /// it conditionally. GitHub answers 304 while nothing changed, which is free quota
1570    pub async fn list_all_open_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1571        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1572        let params = [("state", "open"), ("per_page", "100")];
1573        let all: Vec<GitHubIssue> = self.get_all_pages(&url, &params).await?;
1574        // GitHub /issues API returns PRs too — filter them out
1575        Ok(all
1576            .into_iter()
1577            .filter(|i| i.pull_request.is_none())
1578            .collect())
1579    }
1580
1581    /// List open pull requests (paginated) using the REST pulls endpoint.
1582    /// Returns raw JSON values for flexibility in callers.
1583    pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1584        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1585        let all: Vec<serde_json::Value> = self
1586            .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1587            .await?;
1588        Ok(all)
1589    }
1590
1591    /// List all issues (open and closed, no label filter, paginated).
1592    ///
1593    /// This is more efficient than making separate calls for open and closed.
1594    pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1595        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1596        let all: Vec<GitHubIssue> = self
1597            .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1598            .await?;
1599        // GitHub /issues API returns PRs too — filter them out
1600        Ok(all
1601            .into_iter()
1602            .filter(|i| i.pull_request.is_none())
1603            .collect())
1604    }
1605
1606    /// Add labels to an issue.
1607    pub async fn add_labels(
1608        &self,
1609        repo: &str,
1610        number: &str,
1611        labels: &[String],
1612    ) -> anyhow::Result<()> {
1613        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1614        let payload = serde_json::json!({ "labels": labels });
1615        self.post_json_raw(&url, &payload).await?;
1616        Ok(())
1617    }
1618
1619    /// Ensure a label exists on the repo, creating it if needed.
1620    pub async fn ensure_label(
1621        &self,
1622        repo: &str,
1623        name: &str,
1624        color: &str,
1625        description: &str,
1626    ) -> anyhow::Result<()> {
1627        let encoded = urlencoding::encode(name);
1628        let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1629
1630        // Check if label exists
1631        match self.get_bytes(&get_url).await {
1632            Ok(_) => return Ok(()),
1633            Err(e) if e.to_string().contains("404") => {
1634                tracing::debug!(repo, name, "ensure_label: label not found, creating");
1635            }
1636            Err(e) => {
1637                tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1638            }
1639        }
1640
1641        // Create the label
1642        let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1643        let payload = serde_json::json!({
1644            "name": name,
1645            "color": color,
1646            "description": description,
1647        });
1648
1649        match self.post_json_raw(&create_url, &payload).await {
1650            Ok(_) => {
1651                Self::record_success();
1652            }
1653            Err(e) => {
1654                let msg = e.to_string();
1655                if msg.contains("422")
1656                    || msg.contains("already_exists")
1657                    || msg.contains("Unprocessable")
1658                {
1659                    tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1660                    return Ok(());
1661                }
1662                // Tolerate creation failures (matches old behavior).
1663                tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1664            }
1665        }
1666        Ok(())
1667    }
1668
1669    /// Remove a label from an issue.
1670    pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1671        let encoded = urlencoding::encode(label);
1672        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1673        match self.delete(&url).await {
1674            Ok(_) => Ok(()),
1675            Err(e) if e.to_string().contains("404") => {
1676                tracing::debug!(repo, number, label, "label already removed (404)");
1677                Ok(())
1678            }
1679            Err(e) => Err(e),
1680        }
1681    }
1682
1683    /// Add a comment to an issue.
1684    pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1685        let body = redact_posted_body(body);
1686        let url = format!("{}/repos/{repo}/issues/{number}/comments", self.api_root());
1687        let payload = serde_json::json!({ "body": body });
1688        self.post_json_raw(&url, &payload).await?;
1689        Ok(())
1690    }
1691
1692    /// Post a commit status. `state` is one of pending, success, failure, error.
1693    pub async fn post_commit_status(
1694        &self,
1695        repo: &str,
1696        sha: &str,
1697        state: &str,
1698        context: &str,
1699        description: &str,
1700    ) -> anyhow::Result<()> {
1701        let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1702        let payload = serde_json::json!({
1703            "state": state,
1704            "context": context,
1705            "description": description,
1706        });
1707        self.post_json_raw(&url, &payload).await?;
1708        Ok(())
1709    }
1710
1711    /// Add a reaction to an issue.
1712    ///
1713    /// Supported content values: "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"
1714    pub async fn add_reaction(
1715        &self,
1716        repo: &str,
1717        number: &str,
1718        content: &str,
1719    ) -> anyhow::Result<()> {
1720        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1721        let payload = serde_json::json!({ "content": content });
1722        self.post_json_raw(&url, &payload).await?;
1723        Ok(())
1724    }
1725
1726    /// Add a reaction to an issue comment.
1727    ///
1728    /// Supported content values: "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"
1729    pub async fn add_comment_reaction(
1730        &self,
1731        repo: &str,
1732        comment_id: &str,
1733        content: &str,
1734    ) -> anyhow::Result<()> {
1735        let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1736        let payload = serde_json::json!({ "content": content });
1737        self.post_json_raw(&url, &payload).await?;
1738        Ok(())
1739    }
1740
1741    /// List comments on an issue.
1742    pub async fn list_comments(
1743        &self,
1744        repo: &str,
1745        number: &str,
1746    ) -> anyhow::Result<Vec<GitHubComment>> {
1747        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1748        self.get_all_pages(&url, &[("per_page", "100")]).await
1749    }
1750
1751    /// Check if a PR with the given branch was merged.
1752    pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1753        let owner = repo
1754            .split('/')
1755            .next()
1756            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1757        let head = format!("{owner}:{branch}");
1758
1759        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1760        let prs: Vec<serde_json::Value> = self
1761            .get_with_query(
1762                &url,
1763                &[("head", &head), ("state", "closed"), ("per_page", "1")],
1764            )
1765            .await?;
1766
1767        if prs.is_empty() {
1768            return Ok(false);
1769        }
1770        let merged_at = prs[0].get("merged_at");
1771        Ok(merged_at.is_some_and(|v| !v.is_null()))
1772    }
1773
1774    /// Get the state of a closed PR for the given branch.
1775    ///
1776    /// Returns `Some((merged, state))` if a closed PR exists for the branch,
1777    /// where `merged` indicates if it was merged and `state` is the PR state ("closed" or "merged").
1778    /// Returns `None` if no closed PR exists for the branch.
1779    /// Get the state of a closed PR for a given branch.
1780    ///
1781    /// Returns `Some((pr_number, merged, state))` if a closed PR is found that
1782    /// matches the expected branch. Returns `None` if no matching closed PR is found.
1783    ///
1784    /// Validates that the returned PR's head ref matches the expected branch to
1785    /// prevent incorrect merge decisions when multiple PRs exist for the same branch.
1786    pub async fn get_closed_pr_state(
1787        &self,
1788        repo: &str,
1789        branch: &str,
1790    ) -> anyhow::Result<Option<(u64, bool, String)>> {
1791        let owner = repo
1792            .split('/')
1793            .next()
1794            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1795        let head = format!("{owner}:{branch}");
1796
1797        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1798        let prs: Vec<serde_json::Value> = self
1799            .get_with_query(
1800                &url,
1801                &[("head", &head), ("state", "closed"), ("per_page", "5")],
1802            )
1803            .await?;
1804
1805        if prs.is_empty() {
1806            return Ok(None);
1807        }
1808
1809        // Find the first PR where head ref actually matches our branch
1810        // This validates that we're working with the correct PR
1811        for pr in prs {
1812            let pr_branch = pr
1813                .get("head")
1814                .and_then(|h| h.get("ref"))
1815                .and_then(|r| r.as_str())
1816                .unwrap_or("");
1817
1818            if pr_branch != branch {
1819                tracing::debug!(
1820                    repo,
1821                    branch,
1822                    pr_branch,
1823                    "skipping PR with mismatched head ref"
1824                );
1825                continue;
1826            }
1827
1828            let pr_number = pr
1829                .get("number")
1830                .and_then(serde_json::Value::as_u64)
1831                .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1832
1833            let merged_at = pr.get("merged_at");
1834            let merged = merged_at.is_some_and(|v| !v.is_null());
1835            let state = pr
1836                .get("state")
1837                .and_then(|v| v.as_str())
1838                .unwrap_or("closed")
1839                .to_string();
1840
1841            return Ok(Some((pr_number, merged, state)));
1842        }
1843
1844        // No PR with matching head ref found
1845        Ok(None)
1846    }
1847
1848    /// Batch-check whether the PR for each branch was merged via a single GraphQL query.
1849    ///
1850    /// Returns a map of branch → merged. Branches with no matching PR are omitted
1851    /// (callers should treat missing entries as not merged).
1852    pub async fn batch_is_pr_merged_by_branch(
1853        &self,
1854        repo: &str,
1855        branches: &[String],
1856    ) -> anyhow::Result<HashMap<String, bool>> {
1857        if branches.is_empty() {
1858            return Ok(HashMap::new());
1859        }
1860
1861        let (owner, name) = repo
1862            .split_once('/')
1863            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1864
1865        // Build one alias per branch using positional index to avoid GraphQL
1866        // alias restrictions on branch names that contain non-identifier chars.
1867        // Branch names are passed via variables ($b0, $b1, …) to avoid injection.
1868        let mut var_defs = String::new();
1869        let mut aliases = String::new();
1870        let mut vars = serde_json::json!({ "owner": owner, "name": name });
1871        for (i, branch) in branches.iter().enumerate() {
1872            let _ = write!(var_defs, ", $b{i}: String!");
1873            let _ = write!(
1874                aliases,
1875                r"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "
1876            );
1877            vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1878        }
1879
1880        let query = format!(
1881            r"query($owner: String!, $name: String!{var_defs}) {{
1882                repository(owner: $owner, name: $name) {{ {aliases} }}
1883            }}"
1884        );
1885
1886        let resp = self.graphql_with_vars(&query, vars).await?;
1887
1888        // Surface GraphQL partial errors before checking data structure
1889        if let Some(errors) = resp.get("errors").and_then(|e| e.as_array())
1890            && !errors.is_empty()
1891        {
1892            let messages: Vec<&str> = errors
1893                .iter()
1894                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1895                .collect();
1896            tracing::warn!(
1897                repo,
1898                errors = ?messages,
1899                "GraphQL returned errors in batch PR merge check"
1900            );
1901        }
1902
1903        Self::parse_batch_pr_merged_response(&resp, branches)
1904    }
1905
1906    fn parse_batch_pr_merged_response(
1907        resp: &serde_json::Value,
1908        branches: &[String],
1909    ) -> anyhow::Result<HashMap<String, bool>> {
1910        let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1911            // Check for errors array to provide a more helpful error message
1912            let error_msg = resp
1913                .get("errors")
1914                .and_then(|e| e.as_array())
1915                .and_then(|arr| arr.first())
1916                .and_then(|e| e.get("message"))
1917                .and_then(|m| m.as_str())
1918                .unwrap_or("no error details");
1919            anyhow::anyhow!("missing /data/repository in GraphQL response: {error_msg}")
1920        })?;
1921
1922        let mut result = HashMap::new();
1923        for (i, branch) in branches.iter().enumerate() {
1924            let alias = format!("b{i}");
1925            let merged = repo_data
1926                .get(&alias)
1927                .and_then(|v| v.get("nodes"))
1928                .and_then(|nodes| nodes.as_array())
1929                .and_then(|arr| arr.first())
1930                .and_then(|node| node.get("merged"))
1931                .and_then(serde_json::Value::as_bool)
1932                .unwrap_or(false);
1933            result.insert(branch.clone(), merged);
1934        }
1935
1936        Ok(result)
1937    }
1938
1939    /// Get issue/PR comments since a given timestamp (paginated).
1940    pub async fn get_mentions(
1941        &self,
1942        repo: &str,
1943        since: &str,
1944    ) -> anyhow::Result<Vec<GitHubComment>> {
1945        let url = format!("{}/repos/{repo}/issues/comments", self.api_root());
1946        self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1947            .await
1948    }
1949
1950    /// Get all comments for a specific issue number.
1951    pub async fn get_issue_comments(
1952        &self,
1953        repo: &str,
1954        issue_number: u64,
1955    ) -> anyhow::Result<Vec<GitHubComment>> {
1956        let url = format!(
1957            "{}/repos/{repo}/issues/{issue_number}/comments",
1958            self.api_root()
1959        );
1960        self.get_all_pages(&url, &[("per_page", "100")]).await
1961    }
1962
1963    /// Get the current authenticated username (cached after first call).
1964    pub async fn get_whoami(&self) -> anyhow::Result<String> {
1965        if let Some(cached) = self.cached_username.get() {
1966            return Ok(cached.clone());
1967        }
1968
1969        // An installation token cannot read `GET /user`, and the owner is the identity mentions name
1970        let mut owner = self.clone();
1971        owner.owner_only = true;
1972        let user: serde_json::Value = owner.get_json(&format!("{GITHUB_API}/user")).await?;
1973        let username = user
1974            .get("login")
1975            .and_then(|v| v.as_str())
1976            .map(String::from)
1977            .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1978
1979        let _ = self.cached_username.set(username.clone());
1980        Ok(username)
1981    }
1982
1983    /// Get PR number by branch name.
1984    pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1985        let owner = repo
1986            .split('/')
1987            .next()
1988            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1989        let head = format!("{owner}:{branch}");
1990
1991        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1992        let prs: Vec<serde_json::Value> = self
1993            .get_with_query(
1994                &url,
1995                &[("head", &head), ("state", "open"), ("per_page", "1")],
1996            )
1997            .await?;
1998
1999        if prs.is_empty() {
2000            return Ok(None);
2001        }
2002        prs[0]
2003            .get("number")
2004            .and_then(serde_json::Value::as_u64)
2005            .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
2006            .map(Some)
2007    }
2008
2009    /// Create a new pull request.
2010    ///
2011    /// # Arguments
2012    /// * `repo` - Repository in "owner/repo" format
2013    /// * `title` - PR title
2014    /// * `body` - PR body/description
2015    /// * `head` - Branch name to create PR from
2016    /// * `base` - Base branch to merge into (e.g., "main")
2017    ///
2018    /// Returns the created PR's URL on success.
2019    pub async fn create_pr(
2020        &self,
2021        repo: &str,
2022        title: &str,
2023        body: &str,
2024        head: &str,
2025        base: &str,
2026    ) -> anyhow::Result<String> {
2027        #[derive(Serialize)]
2028        struct CreatePullRequest<'a> {
2029            title: &'a str,
2030            body: &'a str,
2031            head: &'a str,
2032            base: &'a str,
2033        }
2034
2035        let body = redact_posted_body(body);
2036        let url = format!("{}/repos/{repo}/pulls", self.api_root());
2037        Self::check_backoff(&url)?;
2038
2039        let payload = CreatePullRequest {
2040            title,
2041            body: &body,
2042            head,
2043            base,
2044        };
2045
2046        let auth = self.auth_header(&url).await?;
2047        let make_req = || {
2048            Ok(self
2049                .client
2050                .post(&url)
2051                .header(header::AUTHORIZATION, auth.clone())
2052                .header(header::ACCEPT, "application/vnd.github+json")
2053                .header(header::USER_AGENT, "orch")
2054                .header("X-GitHub-Api-Version", "2022-11-28")
2055                .json(&payload))
2056        };
2057        let response = self.send_with_retries(make_req, false).await?;
2058        let status = response.status();
2059        let text = response.text().await.unwrap_or_default();
2060        if !status.is_success() {
2061            Self::maybe_record_rate_limit_from_body(status, &text);
2062            anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
2063        }
2064
2065        let pr: serde_json::Value = serde_json::from_str(&text)?;
2066        pr.get("html_url")
2067            .and_then(|v| v.as_str())
2068            .map(String::from)
2069            .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {pr:?}"))
2070    }
2071
2072    /// Get the repository's default branch from the GitHub API.
2073    ///
2074    /// Used as a fallback when local git detection yields an invalid base
2075    /// branch for PR creation.
2076    pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
2077        let url = format!("{GITHUB_API}/repos/{repo}");
2078        let resp: serde_json::Value = self.get_json(&url).await?;
2079        resp.get("default_branch")
2080            .and_then(|v| v.as_str())
2081            .map(String::from)
2082            .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
2083    }
2084
2085    /// Get PR details by PR number.
2086    /// Returns the full PR object including the `mergeable` field.
2087    pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
2088        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2089        self.get_json(&url).await
2090    }
2091
2092    /// Fetch the merged/state for multiple PRs in a single GraphQL query.
2093    ///
2094    /// Returns a map of pr_number → (merged, state).
2095    /// PRs that fail to fetch are silently omitted from the result.
2096    pub async fn batch_get_pr_states(
2097        &self,
2098        repo: &str,
2099        pr_numbers: &[u64],
2100    ) -> anyhow::Result<HashMap<u64, (bool, String)>> {
2101        // GitHub GraphQL complexity limit: chunk at 50 to avoid slow/rejected queries.
2102        const CHUNK_SIZE: usize = 50;
2103
2104        if pr_numbers.is_empty() {
2105            return Ok(HashMap::new());
2106        }
2107
2108        let (owner, name) = repo
2109            .split_once('/')
2110            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2111
2112        let mut result = HashMap::new();
2113
2114        for chunk in pr_numbers.chunks(CHUNK_SIZE) {
2115            let aliases: String = chunk
2116                .iter()
2117                .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
2118                .collect::<Vec<_>>()
2119                .join("\n  ");
2120
2121            let query = format!(
2122                r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2123            );
2124
2125            let resp = self
2126                .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2127                .await?;
2128            let repo_data = resp
2129                .pointer("/data/repository")
2130                .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2131
2132            for n in chunk {
2133                if let Some(pr) = repo_data.get(format!("pr{n}")) {
2134                    let merged = pr
2135                        .get("merged")
2136                        .and_then(serde_json::Value::as_bool)
2137                        .unwrap_or(false);
2138                    let state = pr
2139                        .get("state")
2140                        .and_then(|v| v.as_str())
2141                        .unwrap_or("UNKNOWN")
2142                        .to_lowercase();
2143                    result.insert(*n, (merged, state));
2144                }
2145            }
2146        }
2147
2148        Ok(result)
2149    }
2150
2151    /// Batch-fetch state and labels for multiple GitHub issues in a single GraphQL request.
2152    ///
2153    /// Returns a map of issue number (as string) → `(state, labels)` where state is
2154    /// lowercase ("open" / "closed") to match the REST API convention used elsewhere.
2155    pub async fn batch_get_issue_states(
2156        &self,
2157        repo: &str,
2158        issue_numbers: &[u64],
2159        caller_context: &str,
2160    ) -> anyhow::Result<HashMap<String, (String, Vec<String>)>> {
2161        // GitHub GraphQL complexity limit: chunk at 50 to avoid slow/rejected queries.
2162        const CHUNK_SIZE: usize = 50;
2163
2164        if issue_numbers.is_empty() {
2165            return Ok(HashMap::new());
2166        }
2167
2168        let (valid_issue_numbers, invalid_issue_numbers) =
2169            sanitize_github_issue_numbers(issue_numbers);
2170        if !invalid_issue_numbers.is_empty() {
2171            tracing::warn!(
2172                repo,
2173                caller_context,
2174                invalid_issue_numbers = ?invalid_issue_numbers,
2175                total_supplied = issue_numbers.len(),
2176                "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
2177            );
2178        }
2179        if valid_issue_numbers.is_empty() {
2180            return Ok(HashMap::new());
2181        }
2182
2183        let (owner, name) = repo
2184            .split_once('/')
2185            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2186
2187        let mut result = HashMap::new();
2188
2189        for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
2190            let aliases: String = chunk
2191                .iter()
2192                .map(|n| {
2193                    format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
2194                })
2195                .collect::<Vec<_>>()
2196                .join(" ");
2197
2198            let query = format!(
2199                r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2200            );
2201
2202            let resp = self
2203                .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2204                .await?;
2205            let repo_data = resp
2206                .pointer("/data/repository")
2207                .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2208
2209            for n in chunk {
2210                if let Some(issue) = repo_data.get(format!("issue{n}")) {
2211                    let state = issue
2212                        .get("state")
2213                        .and_then(|v| v.as_str())
2214                        .unwrap_or("UNKNOWN")
2215                        .to_lowercase();
2216                    let labels: Vec<String> = issue
2217                        .pointer("/labels/nodes")
2218                        .and_then(|v| v.as_array())
2219                        .map(|arr| {
2220                            arr.iter()
2221                                .filter_map(|l| {
2222                                    l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2223                                })
2224                                .collect()
2225                        })
2226                        .unwrap_or_default();
2227                    result.insert(n.to_string(), (state, labels));
2228                }
2229            }
2230        }
2231        Ok(result)
2232    }
2233
2234    /// Required status checks for a branch from classic protection and from rulesets
2235    pub async fn get_required_status_check_contexts(
2236        &self,
2237        repo: &str,
2238        branch: &str,
2239    ) -> anyhow::Result<Vec<RequiredStatusCheck>> {
2240        let branch = urlencoding::encode(branch);
2241        let classic_url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2242        let rules_url = format!("{GITHUB_API}/repos/{repo}/rules/branches/{branch}");
2243
2244        let classic_fut = self.get_json::<serde_json::Value>(&classic_url);
2245        let rules_fut = self.get_all_pages::<serde_json::Value>(&rules_url, &[("per_page", "100")]);
2246        let (classic_resp, rules_resp) = tokio::join!(classic_fut, rules_fut);
2247
2248        let classic = match classic_resp {
2249            Ok(value) => parse_classic_required_status_checks(&value),
2250            Err(e) => {
2251                if is_no_branch_protection_error(&e.to_string()) {
2252                    Vec::new()
2253                } else {
2254                    return Err(e);
2255                }
2256            }
2257        };
2258
2259        let ruleset = match rules_resp {
2260            Ok(rules) => parse_ruleset_required_status_checks(&rules),
2261            Err(e) => {
2262                if e.to_string().contains("404") {
2263                    Vec::new()
2264                } else {
2265                    return Err(e);
2266                }
2267            }
2268        };
2269
2270        Ok(merge_required_status_checks(classic, ruleset))
2271    }
2272
2273    /// Get commit status contexts for a SHA.
2274    pub async fn get_commit_status_contexts(
2275        &self,
2276        repo: &str,
2277        sha: &str,
2278    ) -> anyhow::Result<Vec<(String, String)>> {
2279        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2280        // The /commits/{sha}/status endpoint returns a wrapper object:
2281        // { "state": "pending", "total_count": N, "statuses": [...] }
2282        // Use get_all_pages_from_wrapper to follow pagination and extract the
2283        // `statuses` array across pages so we don't silently truncate at 100.
2284        let statuses: Vec<serde_json::Value> =
2285            self.get_all_pages_from_wrapper(&url, "statuses").await?;
2286
2287        Ok(statuses
2288            .into_iter()
2289            .filter_map(|status| {
2290                let context = status.get("context").and_then(|v| v.as_str())?;
2291                let state = status.get("state").and_then(|v| v.as_str())?;
2292                Some((context.to_string(), state.to_string()))
2293            })
2294            .collect())
2295    }
2296
2297    fn combined_status_state(
2298        total: u64,
2299        failing: u64,
2300        pending: u64,
2301        has_workflows: bool,
2302        mergeable_state: Option<&str>,
2303    ) -> String {
2304        if failing > 0 {
2305            "failure".to_string()
2306        } else if pending > 0 {
2307            "pending".to_string()
2308        } else if total == 0 && has_workflows {
2309            // No check runs yet. If GitHub already reports mergeable_state="clean"
2310            // the PR is ready (e.g. all workflows use paths-ignore and skipped this
2311            // commit). Treating it as pending would stall auto-merge forever.
2312            if mergeable_state == Some("clean") {
2313                "success".to_string()
2314            } else {
2315                "pending".to_string()
2316            }
2317        } else {
2318            "success".to_string()
2319        }
2320    }
2321
2322    /// Return `true` when the repository has at least one GitHub Actions workflow.
2323    ///
2324    /// This is used to distinguish repos that genuinely have no CI configured from
2325    /// repos where CI exists but GitHub has not surfaced any runs yet.
2326    pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2327        let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2328        let resp = self.get_json::<serde_json::Value>(&url).await;
2329        match resp {
2330            Ok(value) => Ok(value
2331                .get("workflows")
2332                .and_then(|v| v.as_array())
2333                .is_some_and(|workflows| !workflows.is_empty())),
2334            Err(e) => {
2335                let err = e.to_string();
2336                if err.contains("404") {
2337                    Ok(false)
2338                } else {
2339                    Err(e)
2340                }
2341            }
2342        }
2343    }
2344
2345    /// Get check runs for a PR head SHA.
2346    pub async fn get_check_runs(
2347        &self,
2348        repo: &str,
2349        sha: &str,
2350    ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2351        // Use `filter=latest` so GitHub returns only the most recent check run
2352        // per check name. Without this, the API returns all runs (oldest
2353        // first) and callers that pick the first match may observe a stale
2354        // failed run when a newer run with the same name has succeeded.
2355        //
2356        // Use `per_page=100` and manual pagination via `get_all_pages_from_wrapper`
2357        // because this endpoint returns a *wrapper object* — `{ "total_count": N,
2358        // "check_runs": [...] }` — rather than a raw JSON array.  `get_all_pages`
2359        // cannot be used here because it calls `serde_json::from_str::<Vec<T>>`
2360        // which would fail on an object body.
2361        let url = format!(
2362            "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2363        );
2364        self.get_all_pages_from_wrapper(&url, "check_runs").await
2365    }
2366
2367    /// Get annotations for a specific check run.
2368    ///
2369    /// Returns the list of annotations attached to the check run, including
2370    /// billing failure messages ("The job was not started because recent account
2371    /// payments have failed …") that GitHub surfaces as annotations rather than
2372    /// as a distinct CI conclusion value.
2373    pub async fn get_check_run_annotations(
2374        &self,
2375        repo: &str,
2376        check_run_id: u64,
2377    ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2378        let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2379        self.get_all_pages(&url, &[("per_page", "100")]).await
2380    }
2381
2382    /// Get reviews for a PR.
2383    pub async fn get_pr_reviews(
2384        &self,
2385        repo: &str,
2386        pr_number: u64,
2387    ) -> anyhow::Result<Vec<GitHubReview>> {
2388        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2389        self.get_all_pages(&url, &[("per_page", "100")]).await
2390    }
2391
2392    /// Get the committer timestamp for a specific commit SHA.
2393    ///
2394    /// Returns an ISO-8601 string, e.g. `"2024-01-15T12:00:00Z"`.
2395    /// Used to determine whether the branch has been updated since a review was submitted.
2396    pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2397        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2398        let value: serde_json::Value = self.get_json(&url).await?;
2399        value
2400            .pointer("/commit/committer/date")
2401            .and_then(|v| v.as_str())
2402            .map(ToString::to_string)
2403            .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2404    }
2405
2406    /// Update the body of a pull request.
2407    pub async fn update_pr_body(
2408        &self,
2409        repo: &str,
2410        pr_number: u64,
2411        body: &str,
2412    ) -> anyhow::Result<()> {
2413        let body = redact_posted_body(body);
2414        let url = format!("{}/repos/{repo}/pulls/{pr_number}", self.api_root());
2415        let payload = serde_json::json!({ "body": body });
2416        self.patch_json_raw(&url, &payload).await?;
2417        Ok(())
2418    }
2419
2420    /// Close a pull request without merging it.
2421    pub async fn close_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2422        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2423        let payload = serde_json::json!({ "state": "closed" });
2424        self.patch_json_raw(&url, &payload).await?;
2425        Ok(())
2426    }
2427
2428    /// How many commits the head is missing from `base` (GitHub's `behind_by`).
2429    ///
2430    /// Zero means the head already contains the current base tip, so its checks
2431    /// ran against the code the merge would produce.
2432    pub async fn compare_behind_by(
2433        &self,
2434        repo: &str,
2435        base: &str,
2436        head_sha: &str,
2437    ) -> anyhow::Result<u64> {
2438        let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2439        let body: serde_json::Value = self.get_json(&url).await?;
2440        parse_behind_by(&body)
2441    }
2442
2443    /// Parents, author and committer of a commit
2444    pub async fn commit_meta(&self, repo: &str, sha: &str) -> anyhow::Result<CommitMeta> {
2445        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2446        let value: serde_json::Value = self.get_json(&url).await?;
2447        Ok(parse_commit_meta(&value))
2448    }
2449
2450    /// True when `branch` already contains the commit `sha`
2451    pub async fn branch_contains(
2452        &self,
2453        repo: &str,
2454        branch: &str,
2455        sha: &str,
2456    ) -> anyhow::Result<bool> {
2457        let url = format!("{GITHUB_API}/repos/{repo}/compare/{branch}...{sha}");
2458        let body: serde_json::Value = self.get_json(&url).await?;
2459        body.get("ahead_by")
2460            .and_then(serde_json::Value::as_u64)
2461            .map(|ahead| ahead == 0)
2462            .ok_or_else(|| anyhow::anyhow!("compare response has no numeric ahead_by field"))
2463    }
2464
2465    /// Close a GitHub issue.
2466    pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2467        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2468        let payload = serde_json::json!({ "state": "closed" });
2469        self.patch_json_raw(&url, &payload).await?;
2470        Ok(())
2471    }
2472
2473    /// Reopen a closed GitHub issue.
2474    pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2475        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2476        let payload = serde_json::json!({ "state": "open" });
2477        self.patch_json_raw(&url, &payload).await?;
2478        Ok(())
2479    }
2480
2481    /// Check if a user is a collaborator.
2482    pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2483        let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2484        Self::check_backoff(&url)?;
2485        let auth = self.auth_header(&url).await?;
2486        let make_req = || {
2487            Ok(self
2488                .client
2489                .get(&url)
2490                .header(header::AUTHORIZATION, auth.clone())
2491                .header(header::ACCEPT, "application/vnd.github+json")
2492                .header("X-GitHub-Api-Version", "2022-11-28"))
2493        };
2494        let resp = self.send_with_retries(make_req, false).await?;
2495        let status = resp.status();
2496        if status == StatusCode::NO_CONTENT {
2497            Ok(true)
2498        } else if status == StatusCode::NOT_FOUND {
2499            Ok(false)
2500        } else {
2501            let body = resp.text().await.unwrap_or_default();
2502            anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2503        }
2504    }
2505
2506    /// Add a sub-issue relationship via GraphQL.
2507    pub async fn add_sub_issue(
2508        &self,
2509        parent_node_id: &str,
2510        child_node_id: &str,
2511    ) -> anyhow::Result<()> {
2512        let query = format!(
2513            r#"mutation {{
2514                addSubIssue(input: {{issueId: "{parent_node_id}", subIssueId: "{child_node_id}"}}) {{
2515                    issue {{ number }}
2516                    subIssue {{ number }}
2517                }}
2518            }}"#
2519        );
2520        self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2521            .await?;
2522        Ok(())
2523    }
2524
2525    /// Link an issue to a branch using GraphQL.
2526    ///
2527    /// Creates a "Development" sidebar link in GitHub that connects the issue
2528    /// to the branch, similar to `gh issue develop`.
2529    ///
2530    /// # Arguments
2531    /// * `repo` - Repository in "owner/repo" format
2532    /// * `issue_number` - The issue number to link
2533    /// * `branch` - The branch name to link the issue to
2534    ///
2535    /// Returns the linked branch ID on success.
2536    pub async fn link_issue_to_branch(
2537        &self,
2538        repo: &str,
2539        issue_number: u64,
2540        branch: &str,
2541    ) -> anyhow::Result<String> {
2542        let parts: Vec<&str> = repo.split('/').collect();
2543        if parts.len() != 2 {
2544            anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2545        }
2546        let (owner, repo_name) = (parts[0], parts[1]);
2547
2548        // First, get the repository and issue node IDs
2549        let query = r"query($owner: String!, $name: String!, $number: Int!) {
2550                repository(owner: $owner, name: $name) {
2551                    id
2552                    issue(number: $number) {
2553                        id
2554                    }
2555                }
2556            }";
2557        let vars = serde_json::json!({
2558            "owner": owner,
2559            "name": repo_name,
2560            "number": issue_number,
2561        });
2562
2563        let result = self.graphql_with_vars(query, vars).await?;
2564
2565        let repo_id = result
2566            .pointer("/data/repository/id")
2567            .and_then(|v| v.as_str())
2568            .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2569
2570        let issue_id = result
2571            .pointer("/data/repository/issue/id")
2572            .and_then(|v| v.as_str())
2573            .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2574
2575        // Fetch branch ref to get the commit OID required by createLinkedBranch
2576        let branch_query = r"query($owner: String!, $name: String!, $ref: String!) {
2577                repository(owner: $owner, name: $name) {
2578                    ref(qualifiedName: $ref) {
2579                        target {
2580                            oid
2581                        }
2582                    }
2583                }
2584            }";
2585        let branch_vars = serde_json::json!({
2586            "owner": owner,
2587            "name": repo_name,
2588            "ref": format!("refs/heads/{}", branch),
2589        });
2590
2591        let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2592        let branch_oid = branch_result
2593            .pointer("/data/repository/ref/target/oid")
2594            .and_then(|v| v.as_str());
2595
2596        // If branch doesn't exist yet, use the default branch OID.
2597        // createLinkedBranch will create the branch at that OID AND link it.
2598        let branch_oid = if let Some(oid) = branch_oid {
2599            oid.to_string()
2600        } else {
2601            // Get default branch OID as the base for the new branch
2602            let default_query = r"query($owner: String!, $name: String!) {
2603                    repository(owner: $owner, name: $name) {
2604                        defaultBranchRef {
2605                            target {
2606                                oid
2607                            }
2608                        }
2609                    }
2610                }";
2611            let default_vars = serde_json::json!({
2612                "owner": owner,
2613                "name": repo_name,
2614            });
2615            let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2616            match default_result
2617                .pointer("/data/repository/defaultBranchRef/target/oid")
2618                .and_then(|v| v.as_str())
2619            {
2620                Some(oid) => {
2621                    tracing::debug!(
2622                        repo,
2623                        issue_number,
2624                        branch,
2625                        "branch not found, using default branch OID to create and link"
2626                    );
2627                    oid.to_string()
2628                }
2629                None => {
2630                    return Ok(format!("unlinked:{branch}"));
2631                }
2632            }
2633        };
2634
2635        // Use createLinkedBranch mutation to link the issue to the existing branch.
2636        // Required fields: issueId, repositoryId, name (branch name), oid (commit SHA).
2637        let mutation = r"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2638                createLinkedBranch(input: {
2639                    issueId: $issueId
2640                    repositoryId: $repositoryId
2641                    name: $name
2642                    oid: $oid
2643                }) {
2644                    linkedBranch {
2645                        id
2646                        ref {
2647                            name
2648                        }
2649                    }
2650                }
2651            }";
2652        let mutation_vars = serde_json::json!({
2653            "issueId": issue_id,
2654            "repositoryId": repo_id,
2655            "name": branch,
2656            "oid": branch_oid,
2657        });
2658
2659        let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2660
2661        match link_result {
2662            Ok(result) => {
2663                let linked_branch_id = result
2664                    .pointer("/data/createLinkedBranch/linkedBranch/id")
2665                    .and_then(|v| v.as_str())
2666                    .map(String::from);
2667
2668                if let Some(id) = linked_branch_id {
2669                    tracing::info!(
2670                        repo,
2671                        issue_number,
2672                        branch,
2673                        "linked issue to branch via GraphQL API"
2674                    );
2675                    Ok(id)
2676                } else {
2677                    // Branch may already be linked - check for errors
2678                    let errors = result
2679                        .get("errors")
2680                        .and_then(|e| e.as_array())
2681                        .cloned()
2682                        .unwrap_or_default();
2683
2684                    if errors.iter().any(|e| {
2685                        e.get("message")
2686                            .and_then(|m| m.as_str())
2687                            .is_some_and(|m| m.contains("already"))
2688                    }) {
2689                        tracing::debug!(
2690                            repo,
2691                            issue_number,
2692                            branch,
2693                            "issue already linked to branch"
2694                        );
2695                        Ok(format!("already_linked:{branch}"))
2696                    } else {
2697                        anyhow::bail!("failed to link issue to branch: {result:?}")
2698                    }
2699                }
2700            }
2701            Err(e) => {
2702                // Check if it's a "already exists" type error
2703                let err_str = format!("{e}");
2704                if err_str.contains("already") || err_str.contains("existing") {
2705                    tracing::debug!(
2706                        repo,
2707                        issue_number,
2708                        branch,
2709                        "issue already linked to branch (error check)"
2710                    );
2711                    Ok(format!("already_linked:{branch}"))
2712                } else {
2713                    Err(e)
2714                }
2715            }
2716        }
2717    }
2718
2719    /// Merge a PR using the REST API (squash merge).
2720    /// GitHub rejects it when the PR head is no longer `head_sha`, the commit CI was checked against
2721    pub async fn merge_pr(
2722        &self,
2723        repo: &str,
2724        pr_number: u64,
2725        delete_branch: bool,
2726        head_sha: &str,
2727    ) -> anyhow::Result<()> {
2728        if !is_full_commit_sha(head_sha) {
2729            anyhow::bail!("merge_pr requires the full head sha, got {head_sha:?}");
2730        }
2731        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2732        Self::check_backoff(&url)?;
2733        let payload = merge_pr_body(head_sha);
2734        let auth = self.auth_header(&url).await?;
2735        let make_req = || {
2736            Ok(self
2737                .client
2738                .put(&url)
2739                .json(&payload)
2740                .header(header::AUTHORIZATION, auth.clone())
2741                .header(header::ACCEPT, "application/vnd.github+json")
2742                .header("X-GitHub-Api-Version", "2022-11-28"))
2743        };
2744        let resp = self.send_with_retries(make_req, false).await?;
2745        let status = resp.status();
2746        if !status.is_success() {
2747            let body = resp.text().await.unwrap_or_default();
2748            anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2749        }
2750        Self::record_success();
2751
2752        // Delete branch if requested
2753        if delete_branch {
2754            // Get the head ref (branch name) from the PR
2755            let pr: serde_json::Value = self
2756                .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2757                .await?;
2758            if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2759                let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2760                if let Err(e) = self.delete(&del_url).await {
2761                    let err_str = e.to_string();
2762                    if err_str.contains("Reference does not exist") {
2763                        tracing::debug!(
2764                            ref_name,
2765                            "branch already gone (GitHub auto-deleted on merge)"
2766                        );
2767                    } else {
2768                        tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2769                    }
2770                }
2771            }
2772        }
2773        Ok(())
2774    }
2775
2776    /// Update a PR's branch by merging the latest base branch into it
2777    /// (equivalent of the "Update branch" button on GitHub, or `gh pr
2778    /// update-branch`). Produces a new merge commit on the head branch,
2779    /// which re-triggers CI — used to give a PR a chance to pick up a fix
2780    /// that landed on the base branch after the PR's last CI run failed.
2781    pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2782        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2783        Self::check_backoff(&url)?;
2784        let auth = self.auth_header(&url).await?;
2785        let make_req = || {
2786            Ok(self
2787                .client
2788                .put(&url)
2789                .header(header::AUTHORIZATION, auth.clone())
2790                .header(header::ACCEPT, "application/vnd.github+json")
2791                .header("X-GitHub-Api-Version", "2022-11-28"))
2792        };
2793        let resp = self.send_with_retries(make_req, false).await?;
2794        let status = resp.status();
2795        if !status.is_success() {
2796            let body = resp.text().await.unwrap_or_default();
2797            Self::maybe_record_rate_limit_from_body(status, &body);
2798            anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2799        }
2800        Self::record_success();
2801        Ok(())
2802    }
2803
2804    /// Enable auto-merge on a PR via GraphQL.
2805    ///
2806    /// GitHub will automatically merge the PR once all required checks pass.
2807    pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2808        let parts: Vec<&str> = repo.split('/').collect();
2809        if parts.len() != 2 {
2810            anyhow::bail!("invalid repo format: {repo}");
2811        }
2812        let (owner, repo_name) = (parts[0], parts[1]);
2813
2814        // First get the PR node ID
2815        let pr_query = r"query($owner: String!, $name: String!, $number: Int!) {
2816            repository(owner: $owner, name: $name) {
2817                pullRequest(number: $number) { id }
2818            }
2819        }";
2820        let body = self
2821            .graphql_with_vars(
2822                pr_query,
2823                serde_json::json!({
2824                    "owner": owner,
2825                    "name": repo_name,
2826                    "number": pr_number,
2827                }),
2828            )
2829            .await?;
2830        let pr_id = body
2831            .pointer("/data/repository/pullRequest/id")
2832            .and_then(|v| v.as_str())
2833            .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2834            .to_string();
2835
2836        // Enable auto-merge with squash
2837        let mutation = r"mutation($prId: ID!) {
2838            enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2839                pullRequest { autoMergeRequest { enabledAt } }
2840            }
2841        }";
2842        let body = self
2843            .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2844            .await?;
2845        if let Some(errors) = body.get("errors") {
2846            anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2847        }
2848
2849        tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2850        Ok(())
2851    }
2852
2853    /// Get sub-issues via GraphQL (paginated).
2854    pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2855        let parts: Vec<&str> = repo.split('/').collect();
2856        if parts.len() != 2 {
2857            anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2858        }
2859        let (owner, repo_name) = (parts[0], parts[1]);
2860        let number = match parse_positive_issue_number(number) {
2861            Ok(number) => number,
2862            Err(e) => {
2863                tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2864                return Ok(Vec::new());
2865            }
2866        };
2867
2868        let mut all_numbers: Vec<u64> = Vec::new();
2869        let mut cursor: Option<String> = None;
2870        let page_size = 100;
2871        let max_pages = 50;
2872        let mut page_count = 0;
2873
2874        loop {
2875            page_count += 1;
2876            if page_count > max_pages {
2877                tracing::warn!(
2878                    repo,
2879                    number,
2880                    "get_sub_issues hit max page limit ({max_pages})"
2881                );
2882                break;
2883            }
2884
2885            let (query, vars) = if let Some(ref c) = cursor {
2886                (
2887                    r"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2888                    repository(owner: $owner, name: $name) {
2889                        issue(number: $number) {
2890                            subIssues(first: $first, after: $after) {
2891                                nodes { number }
2892                                pageInfo { hasNextPage endCursor }
2893                            }
2894                        }
2895                    }
2896                }",
2897                    serde_json::json!({
2898                        "owner": owner,
2899                        "name": repo_name,
2900                        "number": number,
2901                        "first": page_size,
2902                        "after": c,
2903                    }),
2904                )
2905            } else {
2906                (
2907                    r"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2908                    repository(owner: $owner, name: $name) {
2909                        issue(number: $number) {
2910                            subIssues(first: $first) {
2911                                nodes { number }
2912                                pageInfo { hasNextPage endCursor }
2913                            }
2914                        }
2915                    }
2916                }",
2917                    serde_json::json!({
2918                        "owner": owner,
2919                        "name": repo_name,
2920                        "number": number,
2921                        "first": page_size,
2922                    }),
2923                )
2924            };
2925
2926            let result = self
2927                .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2928                .await?;
2929
2930            let sub_issues_data = result
2931                .get("data")
2932                .and_then(|d| d.get("repository"))
2933                .and_then(|r| r.get("issue"))
2934                .and_then(|i| i.get("subIssues"));
2935
2936            let nodes = sub_issues_data
2937                .and_then(|s| s.get("nodes"))
2938                .and_then(|n| n.as_array());
2939
2940            match nodes {
2941                Some(nodes) => {
2942                    let numbers: Vec<u64> = nodes
2943                        .iter()
2944                        .filter_map(|n| n.get("number").and_then(serde_json::Value::as_u64))
2945                        .collect();
2946                    all_numbers.extend(numbers);
2947                }
2948                None => break,
2949            }
2950
2951            let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2952            let has_next = page_info
2953                .and_then(|p| p.get("hasNextPage"))
2954                .and_then(serde_json::Value::as_bool)
2955                .unwrap_or(false);
2956
2957            if !has_next {
2958                break;
2959            }
2960
2961            cursor = page_info
2962                .and_then(|p| p.get("endCursor"))
2963                .and_then(|c| c.as_str())
2964                .map(ToString::to_string);
2965        }
2966
2967        Ok(all_numbers)
2968    }
2969
2970    /// Get combined CI check status for a git ref.
2971    pub async fn get_combined_status(
2972        &self,
2973        repo: &str,
2974        git_ref: &str,
2975        has_workflows: bool,
2976        mergeable_state: Option<&str>,
2977    ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2978        let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2979        let resp: serde_json::Value = self.get_json(&url).await?;
2980
2981        let runs = resp
2982            .get("check_runs")
2983            .and_then(|v| v.as_array())
2984            .cloned()
2985            .unwrap_or_default();
2986
2987        let mut passing = 0u64;
2988        let mut failing = 0u64;
2989        let mut pending = 0u64;
2990
2991        for run in &runs {
2992            let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2993            let status = run
2994                .get("status")
2995                .and_then(|v| v.as_str())
2996                .unwrap_or("queued");
2997
2998            match status {
2999                "completed" => {
3000                    if conclusion.is_empty() {
3001                        pending += 1;
3002                    } else if matches!(conclusion, "success" | "neutral" | "skipped") {
3003                        passing += 1;
3004                    } else {
3005                        failing += 1;
3006                    }
3007                }
3008                _ => pending += 1,
3009            }
3010        }
3011
3012        let total = runs.len() as u64;
3013        let state =
3014            Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
3015
3016        Ok((state, total, passing, failing, pending))
3017    }
3018
3019    /// Rerun the latest failed workflow run for a given workflow name and branch.
3020    ///
3021    /// Finds the most recent failed (or cancelled) run of `workflow_name` on
3022    /// `branch` triggered by a `push` event, then calls the GitHub Actions
3023    /// rerun endpoint so the **same check run** on the commit flips green.
3024    pub async fn rerun_failed_workflow(
3025        &self,
3026        repo: &str,
3027        workflow_name: &str,
3028        branch: &str,
3029    ) -> anyhow::Result<()> {
3030        // List recent runs for this branch, filtered to push events
3031        let url = format!(
3032            "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
3033        );
3034        let resp: serde_json::Value = self.get_json(&url).await?;
3035        let runs = resp["workflow_runs"]
3036            .as_array()
3037            .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
3038
3039        // Find the latest failed/cancelled run matching the workflow name
3040        let run_id = runs
3041            .iter()
3042            .find(|r| {
3043                r["name"].as_str() == Some(workflow_name)
3044                    && matches!(r["conclusion"].as_str(), Some("failure" | "cancelled"))
3045            })
3046            .and_then(|r| r["id"].as_u64())
3047            .ok_or_else(|| {
3048                anyhow::anyhow!(
3049                    "no failed run found for workflow '{workflow_name}' on branch '{branch}'"
3050                )
3051            })?;
3052
3053        // POST rerun — no body needed
3054        let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
3055        let auth = self.auth_header(&rerun_url).await?;
3056        let make_req = || {
3057            Ok(self
3058                .client
3059                .post(&rerun_url)
3060                .header(header::AUTHORIZATION, auth.clone())
3061                .header(header::ACCEPT, "application/vnd.github+json")
3062                .header("X-GitHub-Api-Version", "2022-11-28"))
3063        };
3064        let resp = self.send_with_retries(make_req, false).await?;
3065        let status = resp.status();
3066        if !status.is_success() {
3067            let body = resp.text().await.unwrap_or_default();
3068            Self::maybe_record_rate_limit_from_body(status, &body);
3069            anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
3070        }
3071
3072        Ok(())
3073    }
3074
3075    /// Fetch reviews, review comments, and issue comments for multiple PRs in a
3076    /// single GraphQL query instead of 4–5 REST calls per PR.
3077    ///
3078    /// Returns a map of `pr_number → PrReviewBatchData`.
3079    /// PRs missing from the response are omitted from the result.
3080    pub async fn batch_fetch_pr_review_data(
3081        &self,
3082        repo: &str,
3083        pr_numbers: &[u64],
3084    ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
3085        if pr_numbers.is_empty() {
3086            return Ok(HashMap::new());
3087        }
3088
3089        let (owner, name) = repo
3090            .split_once('/')
3091            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
3092
3093        // Build one GraphQL alias per PR number.
3094        let aliases: String = pr_numbers
3095            .iter()
3096            .map(|&n| build_pr_review_alias(n))
3097            .collect::<Vec<_>>()
3098            .join("\n");
3099
3100        let query = format!(
3101            r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
3102        );
3103
3104        let resp = self
3105            .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
3106            .await?;
3107        let repo_data = resp
3108            .pointer("/data/repository")
3109            .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
3110
3111        let mut result = HashMap::new();
3112        for &n in pr_numbers {
3113            let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
3114                continue;
3115            };
3116
3117            let merged = pr_data
3118                .get("merged")
3119                .and_then(serde_json::Value::as_bool)
3120                .unwrap_or(false);
3121            let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
3122                Some("MERGEABLE") => Some(true),
3123                Some("CONFLICTING") => Some(false),
3124                _ => None, // UNKNOWN or missing
3125            };
3126
3127            let mut reviews: Vec<GitHubReview> = pr_data
3128                .pointer("/reviews/nodes")
3129                .and_then(|v| v.as_array())
3130                .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
3131                .unwrap_or_default();
3132
3133            if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
3134                tracing::warn!(
3135                    pr_number = n,
3136                    fetched = reviews.len(),
3137                    "batch review fetch truncated reviews connection at first page"
3138                );
3139            }
3140
3141            // Ensure deterministic ordering by submitted timestamp (oldest -> newest).
3142            // GraphQL connections do not guarantee a stable sort unless explicitly
3143            // requested. Sort here so consumers can rely on chronological order.
3144            reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
3145
3146            let mut review_comments: Vec<GitHubReviewComment> = pr_data
3147                .pointer("/reviewThreads/nodes")
3148                .and_then(|v| v.as_array())
3149                .map(|threads| {
3150                    threads
3151                        .iter()
3152                        .filter_map(|thread| {
3153                            thread
3154                                .pointer("/comments/nodes")
3155                                .and_then(|v| v.as_array())
3156                                .map(|nodes| {
3157                                    nodes
3158                                        .iter()
3159                                        .filter_map(parse_graphql_review_comment)
3160                                        .collect::<Vec<_>>()
3161                                })
3162                        })
3163                        .flatten()
3164                        .collect()
3165                })
3166                .unwrap_or_default();
3167
3168            if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3169                tracing::warn!(
3170                    pr_number = n,
3171                    fetched_threads = pr_data
3172                        .pointer("/reviewThreads/nodes")
3173                        .and_then(|v| v.as_array())
3174                        .map_or(0, Vec::len),
3175                    "batch review fetch truncated reviewThreads connection at first page"
3176                );
3177            }
3178            if pr_data
3179                .pointer("/reviewThreads/nodes")
3180                .and_then(|v| v.as_array())
3181                .is_some_and(|threads| {
3182                    threads.iter().any(|thread| {
3183                        connection_has_next_page(thread.pointer("/comments/pageInfo"))
3184                    })
3185                })
3186            {
3187                tracing::warn!(
3188                    pr_number = n,
3189                    "batch review fetch truncated one or more review thread comment connections"
3190                );
3191            }
3192
3193            // Sort inline review comments by created_at (oldest -> newest) so
3194            // callers can process them deterministically.
3195            review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3196
3197            let mut issue_comments: Vec<GitHubComment> = pr_data
3198                .pointer("/comments/nodes")
3199                .and_then(|v| v.as_array())
3200                .map(|nodes| {
3201                    nodes
3202                        .iter()
3203                        .filter_map(parse_graphql_issue_comment)
3204                        .collect()
3205                })
3206                .unwrap_or_default();
3207
3208            if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3209                issue_comments.extend(
3210                    self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3211                        .await?,
3212                );
3213            }
3214
3215            // Sort issue-level comments by created_at (oldest -> newest). The
3216            // review poll logic expects chronological ordering and may iterate
3217            // in reverse to find the latest comments — so ensure a consistent
3218            // ordering here regardless of GraphQL's default.
3219            issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3220
3221            result.insert(
3222                n,
3223                PrReviewBatchData {
3224                    merged,
3225                    head_sha: pr_data
3226                        .get("headRefOid")
3227                        .and_then(|v| v.as_str())
3228                        .unwrap_or_default()
3229                        .to_string(),
3230                    mergeable,
3231                    reviews,
3232                    review_comments,
3233                    issue_comments,
3234                },
3235            );
3236        }
3237
3238        Ok(result)
3239    }
3240
3241    async fn fetch_pr_issue_comments_after(
3242        &self,
3243        owner: &str,
3244        name: &str,
3245        pr_number: u64,
3246        mut cursor: Option<String>,
3247    ) -> anyhow::Result<Vec<GitHubComment>> {
3248        let mut comments = Vec::new();
3249        let mut page_count = 0;
3250        let max_pages = 50;
3251
3252        while let Some(current_cursor) = cursor.take() {
3253            page_count += 1;
3254            if page_count > max_pages {
3255                tracing::warn!(
3256                    pr_number,
3257                    fetched = comments.len(),
3258                    "stopping PR issue comment pagination after max page limit"
3259                );
3260                break;
3261            }
3262
3263            let (query, vars) =
3264                build_pr_issue_comments_page_query(owner, name, pr_number, &current_cursor);
3265            let resp = self.graphql_with_vars(&query, vars).await?;
3266            let comments_data = resp
3267                .pointer("/data/repository/pullRequest/comments")
3268                .ok_or_else(|| {
3269                    anyhow::anyhow!(
3270                        "missing /data/repository/pullRequest/comments in GraphQL response"
3271                    )
3272                })?;
3273
3274            comments.extend(
3275                comments_data
3276                    .get("nodes")
3277                    .and_then(|v| v.as_array())
3278                    .into_iter()
3279                    .flatten()
3280                    .filter_map(parse_graphql_issue_comment),
3281            );
3282
3283            cursor = connection_end_cursor(comments_data.get("pageInfo"));
3284        }
3285
3286        Ok(comments)
3287    }
3288
3289    /// Check the latest automated review comment on a PR.
3290    pub async fn get_automated_review_status(
3291        &self,
3292        repo: &str,
3293        pr_number: u64,
3294    ) -> anyhow::Result<Option<String>> {
3295        let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3296
3297        // Find the most recent automated review comment authored by a collaborator or the App
3298        let app_login = super::app::bot_login().await;
3299        let mut newest: Option<&types::GitHubComment> = None;
3300        for c in &comments {
3301            if !c.body.starts_with("## Automated Review") {
3302                continue;
3303            }
3304            let trusted = match app_login.as_deref() {
3305                Some(app) if super::app::same_login(&c.user.login, app) => Ok(true),
3306                _ => self.is_collaborator(repo, &c.user.login).await,
3307            };
3308            match trusted {
3309                Ok(true) => match newest {
3310                    None => newest = Some(c),
3311                    Some(prev) => {
3312                        if c.created_at > prev.created_at {
3313                            newest = Some(c);
3314                        }
3315                    }
3316                },
3317                Ok(false) => {
3318                    tracing::warn!(
3319                        user = %c.user.login,
3320                        pr_number,
3321                        "ignoring automated review comment from non-collaborator"
3322                    );
3323                }
3324                Err(e) => {
3325                    tracing::debug!(
3326                        user = %c.user.login,
3327                        error = %e,
3328                        "failed to check collaborator status, skipping comment"
3329                    );
3330                }
3331            }
3332        }
3333
3334        match newest {
3335            Some(c) => {
3336                let first_line = c.body.lines().next().unwrap_or("");
3337                if first_line.contains("Automated Review \u{2014} Approve") {
3338                    Ok(Some("approve".to_string()))
3339                } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3340                    Ok(Some("changes_requested".to_string()))
3341                } else {
3342                    Ok(None)
3343                }
3344            }
3345            None => Ok(None),
3346        }
3347    }
3348}
3349
3350/// Read `behind_by` out of a compare response.
3351///
3352/// A missing field is an error, not zero: treating an unreadable response as
3353/// "head is up to date" would let a stale PR merge
3354/// A commit's parents, first parent first, and the GitHub accounts behind it
3355#[derive(Debug, Default, PartialEq)]
3356pub struct CommitMeta {
3357    pub parents: Vec<String>,
3358    pub author_login: Option<String>,
3359    pub committer_login: Option<String>,
3360}
3361
3362fn parse_commit_meta(commit: &serde_json::Value) -> CommitMeta {
3363    let login = |field: &str| {
3364        commit
3365            .pointer(&format!("/{field}/login"))
3366            .and_then(|v| v.as_str())
3367            .map(String::from)
3368    };
3369    CommitMeta {
3370        parents: commit
3371            .get("parents")
3372            .and_then(|p| p.as_array())
3373            .map(|parents| {
3374                parents
3375                    .iter()
3376                    .filter_map(|p| p.get("sha").and_then(|s| s.as_str()).map(String::from))
3377                    .collect()
3378            })
3379            .unwrap_or_default(),
3380        author_login: login("author"),
3381        committer_login: login("committer"),
3382    }
3383}
3384
3385fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3386    body.get("behind_by")
3387        .and_then(serde_json::Value::as_u64)
3388        .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3389}
3390
3391/// A branch protection read that means "no protection": 404 "Branch not protected", or the 403
3392/// GitHub returns for private repos on the Free plan
3393fn is_no_branch_protection_error(err: &str) -> bool {
3394    err.contains("404")
3395        || (err.contains("403")
3396            && err.contains("Upgrade to GitHub Pro or make this repository public"))
3397}
3398
3399/// Classic protection prefers `checks` (includes `app_id`) and falls back to `contexts`
3400fn parse_classic_required_status_checks(value: &serde_json::Value) -> Vec<RequiredStatusCheck> {
3401    if let Some(checks) = value
3402        .pointer("/required_status_checks/checks")
3403        .and_then(|v| v.as_array())
3404    {
3405        let parsed: Vec<RequiredStatusCheck> =
3406            checks.iter().filter_map(parse_classic_check).collect();
3407        if !parsed.is_empty() {
3408            return parsed;
3409        }
3410    }
3411
3412    value
3413        .pointer("/required_status_checks/contexts")
3414        .and_then(|v| v.as_array())
3415        .map(|contexts| {
3416            contexts
3417                .iter()
3418                .filter_map(|context| {
3419                    context.as_str().map(|s| RequiredStatusCheck {
3420                        context: s.to_string(),
3421                        integration_id: None,
3422                    })
3423                })
3424                .collect()
3425        })
3426        .unwrap_or_default()
3427}
3428
3429fn parse_classic_check(check: &serde_json::Value) -> Option<RequiredStatusCheck> {
3430    let context = check.get("context")?.as_str()?.to_string();
3431    let integration_id = check
3432        .get("app_id")
3433        .and_then(serde_json::Value::as_i64)
3434        .filter(|&id| id > 0);
3435    Some(RequiredStatusCheck {
3436        context,
3437        integration_id,
3438    })
3439}
3440
3441/// Collect `required_status_checks` entries from every active `required_status_checks` rule
3442fn parse_ruleset_required_status_checks(rules: &[serde_json::Value]) -> Vec<RequiredStatusCheck> {
3443    let mut out = Vec::new();
3444    for rule in rules {
3445        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3446            continue;
3447        }
3448        let Some(checks) = rule
3449            .pointer("/parameters/required_status_checks")
3450            .or_else(|| rule.get("required_status_checks"))
3451            .and_then(|v| v.as_array())
3452        else {
3453            continue;
3454        };
3455        for check in checks {
3456            let Some(context) = check.get("context").and_then(|v| v.as_str()) else {
3457                continue;
3458            };
3459            let integration_id = check
3460                .get("integration_id")
3461                .and_then(serde_json::Value::as_i64)
3462                .filter(|&id| id > 0);
3463            out.push(RequiredStatusCheck {
3464                context: context.to_string(),
3465                integration_id,
3466            });
3467        }
3468    }
3469    out
3470}
3471
3472fn merge_required_status_checks(
3473    mut checks: Vec<RequiredStatusCheck>,
3474    extra: Vec<RequiredStatusCheck>,
3475) -> Vec<RequiredStatusCheck> {
3476    checks.extend(extra);
3477    let mut seen = HashSet::new();
3478    checks.retain(|c| seen.insert((c.context.clone(), c.integration_id)));
3479    checks
3480}
3481
3482fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3483    let mut valid = Vec::with_capacity(issue_numbers.len());
3484    let mut invalid = Vec::new();
3485    for &issue_number in issue_numbers {
3486        if issue_number == 0 {
3487            invalid.push(issue_number);
3488        } else {
3489            valid.push(issue_number);
3490        }
3491    }
3492    (valid, invalid)
3493}
3494
3495// ── Public metrics ───────────────────────────────────────────────────
3496
3497/// Snapshot of GitHub API rate-limit metrics.
3498#[derive(Debug, Clone)]
3499pub struct RateLimitMetrics {
3500    /// Total 403/429 rate-limit responses received (REST + GraphQL combined).
3501    pub hits: u64,
3502    /// Calls skipped because the quota was low or a backoff was active.
3503    pub skipped_calls: u64,
3504    /// Current REST remaining quota (None if not yet observed).
3505    pub rest_remaining: Option<u32>,
3506    /// UTC epoch second when the REST window resets (None if not yet observed).
3507    pub rest_reset_at: Option<u64>,
3508    /// Current GraphQL remaining quota (None if not yet observed).
3509    pub graphql_remaining: Option<u32>,
3510    /// Total 5xx server error responses received (REST + GraphQL combined).
3511    pub five_xx_hits: u64,
3512}
3513
3514/// Return a snapshot of GitHub API rate-limit metrics.
3515pub fn rate_limit_metrics() -> RateLimitMetrics {
3516    let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
3517        .lock()
3518        .ok()
3519        .map_or((None, None), |rl| (rl.remaining, rl.reset_at));
3520    let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3521    RateLimitMetrics {
3522        hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3523        skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3524        rest_remaining,
3525        rest_reset_at,
3526        graphql_remaining,
3527        five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3528    }
3529}
3530
3531// ── Link header parser ───────────────────────────────────────────────
3532
3533/// Parse the `Link` header to find the `rel="next"` URL.
3534fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3535    let link = headers.get("link")?.to_str().ok()?;
3536    for part in link.split(',') {
3537        let part = part.trim();
3538        if part.contains("rel=\"next\"") {
3539            // Extract URL between < and >
3540            let start = part.find('<')? + 1;
3541            let end = part.find('>')?;
3542            if start >= end {
3543                return None;
3544            }
3545            return Some(part[start..end].to_string());
3546        }
3547    }
3548    None
3549}
3550
3551/// Return the hex color string for a given `status:*` label.
3552pub fn status_label_color(label: &str) -> &'static str {
3553    match label {
3554        "status:new" => "0e8a16",
3555        "status:routed" => "1d76db",
3556        "status:in_progress" => "fbca04",
3557        "status:done" => "6f42c1",
3558        "status:blocked" => "d73a4a",
3559        "status:in_review" => "0075ca",
3560        "status:approved" => "2ea44f",
3561        "status:needs_review" => "e4e669",
3562        "status:waiting" => "5319e7",
3563        _ => "c5def5",
3564    }
3565}
3566
3567// ── Batch GraphQL helpers ─────────────────────────────────────────────
3568
3569/// Data fetched for one PR by [`GhHttp::batch_fetch_pr_review_data`].
3570#[derive(Debug, Clone, Default)]
3571pub struct PrReviewBatchData {
3572    /// True when the pull request was merged.
3573    pub merged: bool,
3574    /// Head commit of the pull request, empty when GitHub did not return it.
3575    pub head_sha: String,
3576    /// `Some(true)` = MERGEABLE, `Some(false)` = CONFLICTING, `None` = UNKNOWN.
3577    pub mergeable: Option<bool>,
3578    /// All reviews submitted on the PR.
3579    pub reviews: Vec<GitHubReview>,
3580    /// All inline review comments (from all review threads).
3581    pub review_comments: Vec<GitHubReviewComment>,
3582    /// All issue-level comments on the PR.
3583    pub issue_comments: Vec<GitHubComment>,
3584}
3585
3586/// Build a GraphQL alias fragment for one pull request, fetching all data
3587/// needed by the review poll loop in a single batch request.
3588fn build_pr_review_alias(n: u64) -> String {
3589    format!(
3590        r"pr{n}: pullRequest(number: {n}) {{
3591  merged
3592  mergeable
3593  headRefOid
3594  reviews(first: 50) {{
3595    nodes {{
3596      databaseId
3597      author {{ login }}
3598      body
3599      state
3600      url
3601      submittedAt
3602      commit {{ oid }}
3603    }}
3604    pageInfo {{ hasNextPage }}
3605  }}
3606  reviewThreads(first: 50) {{
3607    nodes {{
3608      comments(first: 20) {{
3609        nodes {{
3610          databaseId
3611          author {{ login }}
3612          body
3613          path
3614          line
3615          originalLine
3616          commit {{ oid }}
3617          originalCommit {{ oid }}
3618          url
3619          createdAt
3620          updatedAt
3621          replyTo {{ databaseId }}
3622          diffHunk
3623        }}
3624        pageInfo {{ hasNextPage }}
3625      }}
3626    }}
3627    pageInfo {{ hasNextPage }}
3628  }}
3629  comments(first: 50) {{
3630    nodes {{
3631      databaseId
3632      author {{ login }}
3633      body
3634      createdAt
3635      updatedAt
3636      url
3637    }}
3638    pageInfo {{ hasNextPage endCursor }}
3639  }}
3640}}"
3641    )
3642}
3643
3644fn build_pr_issue_comments_page_query(
3645    owner: &str,
3646    name: &str,
3647    pr_number: u64,
3648    after_cursor: &str,
3649) -> (String, serde_json::Value) {
3650    let query = r"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3651  repository(owner: $owner, name: $name) {
3652    pullRequest(number: $number) {
3653      comments(first: 100, after: $after) {
3654        nodes {
3655          databaseId
3656          author { login }
3657          body
3658          createdAt
3659          updatedAt
3660          url
3661        }
3662        pageInfo { hasNextPage endCursor }
3663      }
3664    }
3665  }
3666}"
3667    .to_string();
3668    let vars = serde_json::json!({
3669        "owner": owner,
3670        "name": name,
3671        "number": pr_number,
3672        "after": after_cursor,
3673    });
3674    (query, vars)
3675}
3676
3677fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3678    page_info
3679        .and_then(|v| v.get("hasNextPage"))
3680        .and_then(serde_json::Value::as_bool)
3681        .unwrap_or(false)
3682}
3683
3684fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3685    if !connection_has_next_page(page_info) {
3686        return None;
3687    }
3688
3689    page_info
3690        .and_then(|v| v.get("endCursor"))
3691        .and_then(|v| v.as_str())
3692        .map(str::to_owned)
3693}
3694
3695fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3696    Some(GitHubReview {
3697        id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3698        user: GitHubUser {
3699            login: node
3700                .pointer("/author/login")
3701                .and_then(|v| v.as_str())?
3702                .to_string(),
3703        },
3704        body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3705        state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3706        html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3707        submitted_at: node
3708            .get("submittedAt")
3709            .and_then(|v| v.as_str())
3710            .unwrap_or("")
3711            .to_string(),
3712        commit_id: node
3713            .pointer("/commit/oid")
3714            .and_then(|v| v.as_str())
3715            .map(String::from),
3716    })
3717}
3718
3719fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3720    Some(GitHubReviewComment {
3721        id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3722        user: GitHubUser {
3723            login: node
3724                .pointer("/author/login")
3725                .and_then(|v| v.as_str())?
3726                .to_string(),
3727        },
3728        body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3729        path: node
3730            .get("path")
3731            .and_then(|v| v.as_str())
3732            .unwrap_or("")
3733            .to_string(),
3734        line: node
3735            .get("line")
3736            .and_then(serde_json::Value::as_u64)
3737            .and_then(|v| u32::try_from(v).ok()),
3738        original_line: node
3739            .get("originalLine")
3740            .and_then(serde_json::Value::as_u64)
3741            .and_then(|v| u32::try_from(v).ok()),
3742        commit_id: node
3743            .pointer("/commit/oid")
3744            .and_then(|v| v.as_str())
3745            .unwrap_or("")
3746            .to_string(),
3747        original_commit_id: node
3748            .pointer("/originalCommit/oid")
3749            .and_then(|v| v.as_str())
3750            .unwrap_or("")
3751            .to_string(),
3752        html_url: node
3753            .get("url")
3754            .and_then(|v| v.as_str())
3755            .unwrap_or("")
3756            .to_string(),
3757        created_at: node
3758            .get("createdAt")
3759            .and_then(|v| v.as_str())
3760            .unwrap_or("")
3761            .to_string(),
3762        updated_at: node
3763            .get("updatedAt")
3764            .and_then(|v| v.as_str())
3765            .unwrap_or("")
3766            .to_string(),
3767        in_reply_to_id: node
3768            .pointer("/replyTo/databaseId")
3769            .and_then(serde_json::Value::as_u64),
3770        diff_hunk: node
3771            .get("diffHunk")
3772            .and_then(|v| v.as_str())
3773            .map(String::from),
3774    })
3775}
3776
3777fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3778    Some(GitHubComment {
3779        id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3780        body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3781        user: GitHubUser {
3782            login: node
3783                .pointer("/author/login")
3784                .and_then(|v| v.as_str())?
3785                .to_string(),
3786        },
3787        created_at: node
3788            .get("createdAt")
3789            .and_then(|v| v.as_str())
3790            .unwrap_or("")
3791            .to_string(),
3792        updated_at: node
3793            .get("updatedAt")
3794            .and_then(|v| v.as_str())
3795            .map(String::from),
3796        html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3797        issue_url: None,
3798        author_association: None,
3799    })
3800}
3801
3802#[cfg(test)]
3803mod tests {
3804    // Secret redaction on posted bodies (wiremock)
3805    mod leak_redaction;
3806
3807    use std::env;
3808
3809    use super::*;
3810    use crate::github::token::TokenResolver;
3811    use tokio::io::{AsyncReadExt, AsyncWriteExt};
3812    use tokio::net::TcpListener;
3813
3814    #[test]
3815    fn repo_from_url_names_the_repository_or_nothing() {
3816        let api = "https://api.github.com";
3817        assert_eq!(
3818            repo_from_url(&format!("{api}/repos/me/a/pulls/9")).as_deref(),
3819            Some("me/a")
3820        );
3821        assert_eq!(
3822            repo_from_url(&format!("{api}/repos/org/b?per_page=1")).as_deref(),
3823            Some("org/b")
3824        );
3825        assert_eq!(
3826            repo_from_url(&format!("{api}/repos/org/b")).as_deref(),
3827            Some("org/b")
3828        );
3829        assert_eq!(repo_from_url(&format!("{api}/graphql")), None);
3830        assert_eq!(repo_from_url(&format!("{api}/user")), None);
3831        assert_eq!(repo_from_url(&format!("{api}/repos/only-owner")), None);
3832    }
3833
3834    #[test]
3835    fn no_branch_protection_errors() {
3836        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.\"}";
3837        assert!(is_no_branch_protection_error(free_plan));
3838        assert!(is_no_branch_protection_error(
3839            "failed (404 Not Found): {\"message\":\"Branch not protected\"}"
3840        ));
3841        assert!(!is_no_branch_protection_error(
3842            "failed (403 Forbidden): {\"message\":\"Resource not accessible\"}"
3843        ));
3844    }
3845
3846    #[test]
3847    fn parse_classic_required_status_checks_reads_checks_app_id() {
3848        let value = serde_json::json!({
3849            "required_status_checks": {
3850                "contexts": ["ci/test"],
3851                "checks": [{ "context": "ci/test", "app_id": 42 }]
3852            }
3853        });
3854        assert_eq!(
3855            parse_classic_required_status_checks(&value),
3856            vec![RequiredStatusCheck {
3857                context: "ci/test".to_string(),
3858                integration_id: Some(42),
3859            }]
3860        );
3861    }
3862
3863    #[test]
3864    fn parse_classic_required_status_checks_falls_back_to_contexts() {
3865        let value = serde_json::json!({
3866            "required_status_checks": {
3867                "contexts": ["ci/test", "lint"]
3868            }
3869        });
3870        assert_eq!(
3871            parse_classic_required_status_checks(&value),
3872            vec![
3873                RequiredStatusCheck {
3874                    context: "ci/test".to_string(),
3875                    integration_id: None,
3876                },
3877                RequiredStatusCheck {
3878                    context: "lint".to_string(),
3879                    integration_id: None,
3880                }
3881            ]
3882        );
3883    }
3884
3885    #[test]
3886    fn parse_classic_required_status_checks_empty_when_unprotected() {
3887        let checks = parse_classic_required_status_checks(&serde_json::json!({}));
3888        assert!(checks.is_empty(), "{checks:?}");
3889    }
3890
3891    #[test]
3892    fn parse_ruleset_required_status_checks_reads_integration_id() {
3893        let rules = vec![
3894            serde_json::json!({ "type": "pull_request" }),
3895            serde_json::json!({
3896                "type": "required_status_checks",
3897                "parameters": {
3898                    "required_status_checks": [
3899                        { "context": "ci/test", "integration_id": 42 },
3900                        { "context": "lint" }
3901                    ]
3902                }
3903            }),
3904        ];
3905        assert_eq!(
3906            parse_ruleset_required_status_checks(&rules),
3907            vec![
3908                RequiredStatusCheck {
3909                    context: "ci/test".to_string(),
3910                    integration_id: Some(42),
3911                },
3912                RequiredStatusCheck {
3913                    context: "lint".to_string(),
3914                    integration_id: None,
3915                }
3916            ]
3917        );
3918    }
3919
3920    #[test]
3921    fn parse_ruleset_required_status_checks_treats_zero_integration_id_as_any_app() {
3922        let rules = vec![serde_json::json!({
3923            "type": "required_status_checks",
3924            "parameters": {
3925                "required_status_checks": [{ "context": "ci/test", "integration_id": 0 }]
3926            }
3927        })];
3928        assert_eq!(
3929            parse_ruleset_required_status_checks(&rules),
3930            vec![RequiredStatusCheck {
3931                context: "ci/test".to_string(),
3932                integration_id: None,
3933            }]
3934        );
3935    }
3936
3937    #[test]
3938    fn merge_required_status_checks_dedupes_identical_pairs() {
3939        let classic = vec![RequiredStatusCheck {
3940            context: "ci/test".to_string(),
3941            integration_id: None,
3942        }];
3943        let ruleset = vec![
3944            RequiredStatusCheck {
3945                context: "ci/test".to_string(),
3946                integration_id: None,
3947            },
3948            RequiredStatusCheck {
3949                context: "ci/test".to_string(),
3950                integration_id: Some(42),
3951            },
3952        ];
3953        assert_eq!(
3954            merge_required_status_checks(classic, ruleset),
3955            vec![
3956                RequiredStatusCheck {
3957                    context: "ci/test".to_string(),
3958                    integration_id: None,
3959                },
3960                RequiredStatusCheck {
3961                    context: "ci/test".to_string(),
3962                    integration_id: Some(42),
3963                }
3964            ]
3965        );
3966    }
3967
3968    #[test]
3969    fn parse_commit_meta_reads_parents_in_order_and_logins() {
3970        let merge = serde_json::json!({
3971            "parents": [{"sha": "approved"}, {"sha": "base"}],
3972            "author": {"login": "owner"},
3973            "committer": {"login": "web-flow"},
3974        });
3975        let meta = parse_commit_meta(&merge);
3976        assert_eq!(meta.parents, vec!["approved", "base"]);
3977        assert_eq!(meta.author_login.as_deref(), Some("owner"));
3978        assert_eq!(meta.committer_login.as_deref(), Some("web-flow"));
3979        assert_eq!(
3980            parse_commit_meta(&serde_json::json!({})),
3981            CommitMeta::default()
3982        );
3983    }
3984
3985    #[test]
3986    fn parse_behind_by_reads_compare_status() {
3987        // A head behind the base must not merge: its checks ran against old code.
3988        let behind = serde_json::json!({"status": "behind", "behind_by": 3, "ahead_by": 0});
3989        assert_eq!(parse_behind_by(&behind).unwrap(), 3);
3990        let diverged = serde_json::json!({"status": "diverged", "behind_by": 1, "ahead_by": 2});
3991        assert_eq!(parse_behind_by(&diverged).unwrap(), 1);
3992        let ahead = serde_json::json!({"status": "ahead", "behind_by": 0, "ahead_by": 2});
3993        assert_eq!(parse_behind_by(&ahead).unwrap(), 0);
3994        // An unreadable response is an error, never a permissive zero.
3995        assert!(parse_behind_by(&serde_json::json!({"status": "ahead"})).is_err());
3996    }
3997
3998    async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3999        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4000        let addr = listener.local_addr().unwrap();
4001
4002        tokio::spawn(async move {
4003            let (mut socket, _) = listener.accept().await.unwrap();
4004            let mut buf = [0u8; 4096];
4005            let _ = socket.read(&mut buf).await;
4006
4007            let declared_len = body_prefix.len() + 32;
4008            let response = format!(
4009                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
4010            );
4011            socket.write_all(response.as_bytes()).await.unwrap();
4012            socket.shutdown().await.unwrap();
4013        });
4014
4015        format!("http://{addr}")
4016    }
4017
4018    #[test]
4019    fn merge_pr_body_pins_the_head_sha() {
4020        let sha = "0123456789abcdef0123456789abcdef01234567";
4021        let body = merge_pr_body(sha);
4022        assert_eq!(body["merge_method"], "squash");
4023        assert_eq!(body["sha"], sha);
4024        assert!(is_full_commit_sha(sha));
4025        assert!(!is_full_commit_sha("abc"));
4026        assert!(!is_full_commit_sha(""));
4027    }
4028
4029    fn test_client() -> GhHttp {
4030        GhHttp {
4031            client: reqwest::Client::new(),
4032            token_resolver: Arc::new(TokenResolver::default_env()),
4033            app: None,
4034            owner_only: false,
4035            cached_username: OnceLock::new(),
4036            api_base: None,
4037        }
4038    }
4039
4040    /// Verify `GhHttp::new()` returns `Ok` and does not panic on construction.
4041    ///
4042    /// This test guards against regressions where `GhHttp::new()` might panic
4043    /// (e.g., if TLS initialisation fails or the builder API changes).
4044    #[test]
4045    fn gh_http_new_returns_ok() {
4046        let result = GhHttp::new();
4047        assert!(
4048            result.is_ok(),
4049            "GhHttp::new() must not panic and must return Ok; got: {:?}",
4050            result.err()
4051        );
4052    }
4053
4054    #[test]
4055    fn batch_get_issue_states_query_includes_labels_pagination() {
4056        let issue_numbers = [1u64, 2, 3];
4057
4058        let aliases: String = issue_numbers
4059            .iter()
4060            .map(|n| {
4061                format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
4062            })
4063            .collect::<Vec<_>>()
4064            .join(" ");
4065
4066        let query = format!(
4067            r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
4068        );
4069        assert!(query.contains("$owner"));
4070        assert!(query.contains("$name"));
4071        assert!(query.contains("issue1: issue(number: 1)"));
4072        assert!(query.contains("issue3: issue(number: 3)"));
4073        assert!(query.contains("labels(first: 100)"));
4074    }
4075
4076    #[test]
4077    fn parse_positive_issue_number_accepts_positive_values() {
4078        assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
4079    }
4080
4081    #[test]
4082    fn parse_positive_issue_number_rejects_zero() {
4083        let err = parse_positive_issue_number("0").unwrap_err().to_string();
4084        assert!(err.contains("invalid issue number: 0"));
4085    }
4086
4087    #[test]
4088    fn parse_positive_issue_number_rejects_internal_ids() {
4089        let err = parse_positive_issue_number("internal:5")
4090            .unwrap_err()
4091            .to_string();
4092        assert!(err.contains("invalid issue number: internal:5"));
4093    }
4094
4095    #[test]
4096    fn pr_review_batch_query_includes_page_info_for_nested_connections() {
4097        let query = build_pr_review_alias(42);
4098
4099        assert!(query.contains("reviews(first: 50)"));
4100        assert!(query.contains("reviews(first: 50) {\n    nodes"));
4101        assert!(query.contains("pageInfo { hasNextPage }"));
4102        assert!(query.contains("reviewThreads(first: 50)"));
4103        assert!(query.contains("comments(first: 20)"));
4104        assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4105        assert!(query.contains("updatedAt"));
4106    }
4107
4108    #[test]
4109    fn pr_issue_comments_page_query_uses_cursor_pagination() {
4110        let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
4111
4112        assert!(query.contains("pullRequest(number: $number)"));
4113        assert!(query.contains("comments(first: 100, after: $after)"));
4114        assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4115        assert!(query.contains("updatedAt"));
4116        assert_eq!(vars["owner"], "owner");
4117        assert_eq!(vars["name"], "repo");
4118        assert_eq!(vars["number"], 42);
4119        assert_eq!(vars["after"], "cursor123");
4120    }
4121
4122    #[test]
4123    fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
4124        let page_info = serde_json::json!({
4125            "hasNextPage": true,
4126            "endCursor": "cursor123"
4127        });
4128        assert_eq!(
4129            connection_end_cursor(Some(&page_info)),
4130            Some("cursor123".to_string())
4131        );
4132
4133        let final_page = serde_json::json!({
4134            "hasNextPage": false,
4135            "endCursor": "cursor456"
4136        });
4137        assert_eq!(connection_end_cursor(Some(&final_page)), None);
4138    }
4139
4140    #[test]
4141    fn status_label_colors_match_bash_palette() {
4142        assert_eq!(status_label_color("status:new"), "0e8a16");
4143        assert_eq!(status_label_color("status:routed"), "1d76db");
4144        assert_eq!(status_label_color("status:in_progress"), "fbca04");
4145        assert_eq!(status_label_color("status:done"), "6f42c1");
4146        assert_eq!(status_label_color("status:blocked"), "d73a4a");
4147        assert_eq!(status_label_color("status:in_review"), "0075ca");
4148        assert_eq!(status_label_color("status:needs_review"), "e4e669");
4149    }
4150
4151    #[test]
4152    fn unknown_label_gets_default_color() {
4153        assert_eq!(status_label_color("agent:claude"), "c5def5");
4154        assert_eq!(status_label_color("enhancement"), "c5def5");
4155        assert_eq!(status_label_color(""), "c5def5");
4156    }
4157
4158    #[test]
4159    fn parse_link_next_finds_next_url() {
4160        let mut headers = header::HeaderMap::new();
4161        headers.insert(
4162            "link",
4163            "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
4164                .parse()
4165                .unwrap(),
4166        );
4167        assert_eq!(
4168            parse_link_next(&headers),
4169            Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
4170        );
4171    }
4172
4173    #[test]
4174    fn parse_link_next_none_when_no_next() {
4175        let mut headers = header::HeaderMap::new();
4176        headers.insert(
4177            "link",
4178            "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
4179                .parse()
4180                .unwrap(),
4181        );
4182        assert_eq!(parse_link_next(&headers), None);
4183    }
4184
4185    #[test]
4186    fn parse_link_next_none_when_missing() {
4187        let headers = header::HeaderMap::new();
4188        assert_eq!(parse_link_next(&headers), None);
4189    }
4190
4191    #[test]
4192    fn parse_link_next_none_when_malformed() {
4193        let mut headers = header::HeaderMap::new();
4194        // Malformed: no angle-bracket URL, just garbage
4195        headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
4196        assert_eq!(parse_link_next(&headers), None);
4197    }
4198
4199    #[test]
4200    fn parse_link_next_none_when_gt_before_lt() {
4201        let mut headers = header::HeaderMap::new();
4202        headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
4203        assert_eq!(parse_link_next(&headers), None); // should not panic
4204    }
4205
4206    #[test]
4207    fn parse_link_next_none_when_empty_url() {
4208        let mut headers = header::HeaderMap::new();
4209        headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
4210        assert_eq!(parse_link_next(&headers), None);
4211    }
4212
4213    fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
4214        RateLimit {
4215            remaining: None,
4216            reset_at: None,
4217            backoff_until: None,
4218            backoff_delay: Duration::ZERO,
4219            backoff_base: Duration::from_secs(base_secs),
4220            backoff_max: Duration::from_secs(max_secs),
4221            throttle_threshold: 10,
4222        }
4223    }
4224
4225    #[test]
4226    fn rate_limit_inactive_by_default() {
4227        let rl = make_rl(30, 900);
4228        assert!(rl.is_active().is_none());
4229    }
4230
4231    #[test]
4232    fn rate_limit_backoff_activates() {
4233        let mut rl = make_rl(5, 60);
4234        rl.record_rate_limit();
4235        assert!(rl.is_active().is_some());
4236        assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4237    }
4238
4239    #[test]
4240    fn rate_limit_backoff_doubles() {
4241        let mut rl = make_rl(5, 60);
4242        rl.record_rate_limit();
4243        assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4244        rl.record_rate_limit();
4245        assert_eq!(rl.backoff_delay, Duration::from_secs(10));
4246        rl.record_rate_limit();
4247        assert_eq!(rl.backoff_delay, Duration::from_secs(20));
4248    }
4249
4250    #[test]
4251    fn rate_limit_backoff_caps_at_max() {
4252        let mut rl = make_rl(30, 60);
4253        rl.record_rate_limit(); // 30
4254        rl.record_rate_limit(); // 60
4255        rl.record_rate_limit(); // capped at 60
4256        assert_eq!(rl.backoff_delay, Duration::from_secs(60));
4257    }
4258
4259    #[test]
4260    fn rate_limit_success_resets() {
4261        let mut rl = make_rl(5, 60);
4262        rl.record_rate_limit();
4263        assert!(rl.is_active().is_some());
4264        rl.record_success();
4265        assert!(rl.is_active().is_none());
4266        assert_eq!(rl.backoff_delay, Duration::ZERO);
4267    }
4268
4269    #[test]
4270    fn page_cache_key_separates_types_and_queries() {
4271        let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
4272        let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
4273        let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
4274        assert_ne!(a, b);
4275        assert_ne!(a, c);
4276        assert_eq!(
4277            a,
4278            page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
4279        );
4280    }
4281
4282    /// `since` is derived from the clock, so the key would never recur.
4283    #[test]
4284    fn page_cache_key_skips_since_filtered_requests() {
4285        assert!(
4286            page_cache_key::<GitHubIssue>(
4287                "/issues",
4288                Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
4289            )
4290            .is_none()
4291        );
4292        // Also when it arrives embedded in a Link: next URL.
4293        assert!(
4294            page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
4295                .is_none()
4296        );
4297    }
4298
4299    /// A limiter with no quota left and a reset an hour out: the check must return an
4300    /// error straight away. Sleeping here is what froze the tick loop for 504s.
4301    #[test]
4302    fn low_quota_fails_fast_instead_of_waiting_for_reset() {
4303        let mut rl = make_rl(30, 900);
4304        rl.remaining = Some(0);
4305        rl.reset_at = Some(
4306            SystemTime::now()
4307                .duration_since(std_time::UNIX_EPOCH)
4308                .unwrap()
4309                .as_secs()
4310                + 3600,
4311        );
4312        let cell = Mutex::new(rl);
4313
4314        let start = Instant::now();
4315        let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
4316        assert!(
4317            start.elapsed() < Duration::from_secs(1),
4318            "check blocked for {:?}",
4319            start.elapsed()
4320        );
4321        assert!(err.to_string().contains("rate-limited"), "{err}");
4322    }
4323
4324    /// Quota above the reserve is not a reason to skip the call.
4325    #[test]
4326    fn healthy_quota_passes_the_check() {
4327        let mut rl = make_rl(30, 900);
4328        rl.remaining = Some(4000);
4329        rl.reset_at = Some(u64::MAX / 2);
4330        let cell = Mutex::new(rl);
4331        assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
4332    }
4333
4334    #[test]
4335    fn rate_limit_proactive_pause() {
4336        let mut rl = make_rl(30, 900);
4337        // Simulate: 0 remaining (below default threshold of 10), reset 60s in the future
4338        rl.remaining = Some(0);
4339        let future_epoch = SystemTime::now()
4340            .duration_since(std_time::UNIX_EPOCH)
4341            .unwrap()
4342            .as_secs()
4343            + 60;
4344        rl.reset_at = Some(future_epoch);
4345        let pause = rl.is_active();
4346        assert!(pause.is_some());
4347        // Should be roughly 60 seconds (within tolerance, +1s buffer added)
4348        assert!(pause.unwrap().as_secs() <= 62);
4349    }
4350
4351    #[test]
4352    fn rate_limit_proactive_pause_threshold() {
4353        let mut rl = make_rl(30, 900);
4354        // remaining=15 is above default threshold of 10 — no proactive wait
4355        rl.remaining = Some(15);
4356        let future_epoch = SystemTime::now()
4357            .duration_since(std_time::UNIX_EPOCH)
4358            .unwrap()
4359            .as_secs()
4360            + 60;
4361        rl.reset_at = Some(future_epoch);
4362        assert!(rl.is_active().is_none());
4363
4364        // remaining=5 is below threshold — proactive wait activates
4365        rl.remaining = Some(5);
4366        assert!(rl.is_active().is_some());
4367    }
4368
4369    #[test]
4370    fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
4371        // No check runs yet and GitHub hasn't said it's clean → pending (wait for CI to queue)
4372        assert_eq!(
4373            GhHttp::combined_status_state(0, 0, 0, true, None),
4374            "pending"
4375        );
4376        // No workflows configured → no need to wait
4377        assert_eq!(
4378            GhHttp::combined_status_state(0, 0, 0, false, None),
4379            "success"
4380        );
4381        // Failing check → failure regardless
4382        assert_eq!(
4383            GhHttp::combined_status_state(3, 1, 0, true, None),
4384            "failure"
4385        );
4386        // paths-ignore filtered out all workflows: no check runs but GitHub says "clean"
4387        assert_eq!(
4388            GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
4389            "success"
4390        );
4391        // mergeable_state is dirty/blocked — still pending even with has_workflows
4392        assert_eq!(
4393            GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
4394            "pending"
4395        );
4396    }
4397
4398    #[test]
4399    fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
4400        let resp = serde_json::json!({
4401            "data": {},
4402            "errors": [{ "message": "Something went wrong" }],
4403        });
4404
4405        let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
4406            .unwrap_err()
4407            .to_string();
4408        assert!(err_text.contains("missing /data/repository in GraphQL response"));
4409        assert!(err_text.contains("Something went wrong"));
4410    }
4411
4412    #[test]
4413    fn rate_limit_uses_reset_at_for_backoff() {
4414        let mut rl = make_rl(30, 900);
4415        // Set reset_at to 45s in the future
4416        let future_epoch = SystemTime::now()
4417            .duration_since(std_time::UNIX_EPOCH)
4418            .unwrap()
4419            .as_secs()
4420            + 45;
4421        rl.reset_at = Some(future_epoch);
4422        rl.record_rate_limit();
4423        // Backoff should use reset_at (~46s) not the base (30s)
4424        assert!(rl.backoff_delay.as_secs() >= 45);
4425        assert!(rl.backoff_delay.as_secs() <= 47);
4426    }
4427
4428    #[tokio::test]
4429    async fn send_with_retries_propagates_make_req_error() {
4430        let guard = env::var_os("GH_TOKEN");
4431        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4432        unsafe { env::set_var("GH_TOKEN", "test_token") };
4433        let gh = test_client();
4434        let result = gh
4435            .send_with_retries(
4436                || Err(anyhow::anyhow!("simulated request clone failure")),
4437                false,
4438            )
4439            .await;
4440        assert!(result.is_err());
4441        let err = result.unwrap_err().to_string();
4442        assert_eq!(err, "simulated request clone failure");
4443        match guard {
4444            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4445            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4446            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4447            None => unsafe { env::remove_var("GH_TOKEN") },
4448        }
4449    }
4450
4451    /// A transport-level failure (connection refused — GitHub never reached)
4452    /// must exhaust retries without tripping the shared "github:5xx" circuit
4453    /// breaker: that breaker is read by the routing-phase skip and other
4454    /// subsystems as a signal specifically about GitHub's health, which a
4455    /// local connectivity blip says nothing about.
4456    #[tokio::test]
4457    async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
4458        let guard = env::var_os("GH_TOKEN");
4459        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4460        unsafe { env::set_var("GH_TOKEN", "test_token") };
4461
4462        // Bind then immediately drop a listener to get a port nothing is
4463        // listening on, so connecting reliably fails with "connection refused"
4464        // rather than depending on an external unreachable host.
4465        let addr = {
4466            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4467            listener.local_addr().unwrap()
4468        };
4469        let url = format!("http://{addr}/");
4470
4471        let gh = test_client();
4472        let make_req = || Ok(gh.client.get(&url));
4473        let result = gh.send_with_retries(make_req, false).await;
4474
4475        assert!(result.is_err());
4476        assert!(
4477            !cooldown::is_agent_in_cooldown("github:5xx"),
4478            "transport error must not trip the github:5xx circuit breaker"
4479        );
4480        assert!(cooldown::cooldown_until("github:5xx").is_none());
4481
4482        match guard {
4483            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4484            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4485            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4486            None => unsafe { env::remove_var("GH_TOKEN") },
4487        }
4488    }
4489
4490    #[tokio::test]
4491    async fn post_json_raw_propagates_body_read_errors_on_success() {
4492        let guard = env::var_os("GH_TOKEN");
4493        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4494        unsafe { env::set_var("GH_TOKEN", "test_token") };
4495        let server_url = spawn_truncated_body_server("{").await;
4496        let gh = test_client();
4497
4498        let result = gh
4499            .post_json_raw(
4500                &format!("{server_url}/post"),
4501                &serde_json::json!({"ok": true}),
4502            )
4503            .await;
4504
4505        let err = result.unwrap_err().to_string();
4506        assert!(
4507            !err.contains("EOF while parsing a value"),
4508            "should surface the body read error, got: {err}"
4509        );
4510        assert!(
4511            err.contains("error decoding response body")
4512                || err.contains("request or response body error")
4513                || err.contains("end of file before message length reached"),
4514            "expected a transport/body read error, got: {err}"
4515        );
4516
4517        match guard {
4518            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4519            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4520            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4521            None => unsafe { env::remove_var("GH_TOKEN") },
4522        }
4523    }
4524
4525    #[tokio::test]
4526    async fn patch_json_raw_propagates_body_read_errors_on_success() {
4527        let guard = env::var_os("GH_TOKEN");
4528        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4529        unsafe { env::set_var("GH_TOKEN", "test_token") };
4530        let server_url = spawn_truncated_body_server("{").await;
4531        let gh = test_client();
4532
4533        let result = gh
4534            .patch_json_raw(
4535                &format!("{server_url}/patch"),
4536                &serde_json::json!({"ok": true}),
4537            )
4538            .await;
4539
4540        let err = result.unwrap_err().to_string();
4541        assert!(
4542            !err.contains("EOF while parsing a value"),
4543            "should surface the body read error, got: {err}"
4544        );
4545        assert!(
4546            err.contains("error decoding response body")
4547                || err.contains("request or response body error")
4548                || err.contains("end of file before message length reached"),
4549            "expected a transport/body read error, got: {err}"
4550        );
4551
4552        match guard {
4553            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4554            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4555            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4556            None => unsafe { env::remove_var("GH_TOKEN") },
4557        }
4558    }
4559
4560    #[tokio::test]
4561    async fn graphql_request_propagates_body_read_errors_on_success() {
4562        let guard = env::var_os("GH_TOKEN");
4563        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4564        unsafe { env::set_var("GH_TOKEN", "test_token") };
4565        let server_url = spawn_truncated_body_server("{").await;
4566        let gh = test_client();
4567        let query = "query { viewer { login } }";
4568
4569        let result = gh
4570            .graphql_request_to_url(
4571                &format!("{server_url}/graphql"),
4572                query,
4573                &[],
4574                Some(serde_json::json!({"k": "v"})),
4575            )
4576            .await;
4577
4578        let err = result.unwrap_err().to_string();
4579        assert!(
4580            !err.contains("EOF while parsing a value"),
4581            "should surface the body read error, got: {err}"
4582        );
4583        assert!(
4584            err.contains("error decoding response body")
4585                || err.contains("request or response body error")
4586                || err.contains("end of file before message length reached"),
4587            "expected a transport/body read error, got: {err}"
4588        );
4589
4590        match guard {
4591            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4592            Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4593            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4594            None => unsafe { env::remove_var("GH_TOKEN") },
4595        }
4596    }
4597
4598    // ── get_all_pages integration tests (wiremock) ────────────────────────
4599
4600    #[cfg(test)]
4601    mod pagination {
4602        use super::super::*;
4603        use crate::github::token::TokenResolver;
4604        use std::env;
4605        use std::ffi::OsString;
4606        use std::sync::Arc;
4607        use std::time;
4608        use std::time::SystemTime;
4609        use wiremock::matchers::{method, path};
4610        use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate, matchers};
4611
4612        /// wiremock has no negated matcher, and the first poll must be matched on the
4613        /// absence of `If-None-Match` to tell it apart from the conditional one.
4614        struct NotMatcher<M>(M);
4615
4616        impl<M: Match> Match for NotMatcher<M> {
4617            fn matches(&self, request: &Request) -> bool {
4618                !self.0.matches(request)
4619            }
4620        }
4621
4622        struct EnvVarGuard {
4623            key: &'static str,
4624            previous: Option<OsString>,
4625        }
4626
4627        impl EnvVarGuard {
4628            fn set(key: &'static str, value: &str) -> Self {
4629                let previous = env::var_os(key);
4630                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4631                unsafe { env::set_var(key, value) };
4632                Self { key, previous }
4633            }
4634        }
4635
4636        impl Drop for EnvVarGuard {
4637            fn drop(&mut self) {
4638                match self.previous.take() {
4639                    // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4640                    Some(value) => unsafe { env::set_var(self.key, value) },
4641                    // SAFETY: nextest runs each test in its own process, so no other test touches the environment
4642                    None => unsafe { env::remove_var(self.key) },
4643                }
4644            }
4645        }
4646
4647        /// Build a minimal GhHttp pointing at a wiremock server.
4648        /// The token resolver reads GH_TOKEN; we set it to a dummy value.
4649        fn make_client() -> (GhHttp, EnvVarGuard) {
4650            // Ensure a token is available so auth_header() doesn't bail.
4651            let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4652            let client = GhHttp {
4653                client: reqwest::Client::new(),
4654                token_resolver: Arc::new(TokenResolver::default_env()),
4655                app: None,
4656                owner_only: false,
4657                cached_username: OnceLock::new(),
4658                api_base: None,
4659            };
4660            (client, guard)
4661        }
4662
4663        #[tokio::test]
4664        async fn get_all_pages_single_page() {
4665            let server = MockServer::start().await;
4666
4667            Mock::given(method("GET"))
4668                .and(path("/items"))
4669                .respond_with(
4670                    ResponseTemplate::new(200)
4671                        .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4672                )
4673                .expect(1)
4674                .mount(&server)
4675                .await;
4676
4677            let (gh, _guard) = make_client();
4678            let url = format!("{}/items", server.uri());
4679            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4680
4681            assert_eq!(result.len(), 2);
4682            assert_eq!(result[0]["id"], 1);
4683            assert_eq!(result[1]["id"], 2);
4684        }
4685
4686        #[tokio::test]
4687        async fn get_all_pages_follows_link_next() {
4688            let server = MockServer::start().await;
4689
4690            // Page 1 — includes Link header pointing to page 2
4691            Mock::given(method("GET"))
4692                .and(path("/items"))
4693                .respond_with(
4694                    ResponseTemplate::new(200)
4695                        .set_body_json(serde_json::json!([{"id": 1}]))
4696                        .insert_header(
4697                            "Link",
4698                            format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4699                        ),
4700                )
4701                .expect(1)
4702                .mount(&server)
4703                .await;
4704
4705            // Page 2 — no Link header (last page)
4706            Mock::given(method("GET"))
4707                .and(path("/items/page2"))
4708                .respond_with(
4709                    ResponseTemplate::new(200)
4710                        .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4711                )
4712                .expect(1)
4713                .mount(&server)
4714                .await;
4715
4716            let (gh, _guard) = make_client();
4717            let url = format!("{}/items", server.uri());
4718            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4719
4720            assert_eq!(result.len(), 3);
4721            assert_eq!(result[0]["id"], 1);
4722            assert_eq!(result[1]["id"], 2);
4723            assert_eq!(result[2]["id"], 3);
4724        }
4725
4726        #[tokio::test]
4727        async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4728            // Simulates a proxy/transient response that drops the Link header mid-pagination.
4729            // The previous `.unwrap()` would panic here; the fix breaks the loop instead.
4730            let server = MockServer::start().await;
4731
4732            Mock::given(method("GET"))
4733                .and(path("/items"))
4734                .respond_with(
4735                    ResponseTemplate::new(200)
4736                        .set_body_json(serde_json::json!([{"id": 1}]))
4737                        .insert_header(
4738                            "Link",
4739                            format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4740                        ),
4741                )
4742                .expect(1)
4743                .mount(&server)
4744                .await;
4745
4746            // Page 2 response has NO Link header — pagination should stop cleanly.
4747            Mock::given(method("GET"))
4748                .and(path("/items/page2"))
4749                .respond_with(
4750                    ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4751                )
4752                .expect(1)
4753                .mount(&server)
4754                .await;
4755
4756            let (gh, _guard) = make_client();
4757            let url = format!("{}/items", server.uri());
4758            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4759
4760            // Must not panic and must return both items collected so far.
4761            assert_eq!(result.len(), 2);
4762        }
4763
4764        #[tokio::test]
4765        async fn get_all_pages_stops_on_malformed_link_header() {
4766            // A malformed Link header (no angle-bracket URL) yields None from parse_link_next,
4767            // which should stop pagination rather than panic.
4768            let server = MockServer::start().await;
4769
4770            Mock::given(method("GET"))
4771                .and(path("/items"))
4772                .respond_with(
4773                    ResponseTemplate::new(200)
4774                        .set_body_json(serde_json::json!([{"id": 1}]))
4775                        .insert_header("Link", "garbage; rel=\"next\""),
4776                )
4777                .expect(1)
4778                .mount(&server)
4779                .await;
4780
4781            let (gh, _guard) = make_client();
4782            let url = format!("{}/items", server.uri());
4783            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4784
4785            // Malformed Link header → pagination stops after first page, no panic.
4786            assert_eq!(result.len(), 1);
4787        }
4788
4789        #[tokio::test]
4790        async fn get_all_pages_returns_error_on_malformed_json_body() {
4791            let server = MockServer::start().await;
4792
4793            Mock::given(method("GET"))
4794                .and(path("/items"))
4795                .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4796                .expect(3)
4797                .mount(&server)
4798                .await;
4799
4800            let (gh, _guard) = make_client();
4801            let url = format!("{}/items", server.uri());
4802            let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4803
4804            assert!(result.is_err());
4805            let err = result.unwrap_err().to_string();
4806            assert!(
4807                err.contains("JSON decode failed") || err.contains("expected ident"),
4808                "error should mention JSON decode or serde error: {err}"
4809            );
4810        }
4811
4812        // ── conditional request / rate-limit tests ───────────────────────────
4813
4814        /// Restores the global REST limiter and the page cache, both of which these two
4815        /// tests have to poison to exercise the real request path.
4816        struct GlobalStateGuard;
4817
4818        impl Drop for GlobalStateGuard {
4819            fn drop(&mut self) {
4820                if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4821                    *rl = RateLimit::new();
4822                }
4823                if let Ok(mut cache) = PAGE_CACHE.lock() {
4824                    cache.clear();
4825                }
4826            }
4827        }
4828
4829        #[tokio::test]
4830        async fn get_all_pages_replays_body_on_304() {
4831            let _reset = GlobalStateGuard;
4832            let server = MockServer::start().await;
4833
4834            // First poll: 200 with an ETag.
4835            Mock::given(method("GET"))
4836                .and(path("/issues"))
4837                .and(matchers::header_exists("authorization"))
4838                .and(NotMatcher(matchers::header_exists("if-none-match")))
4839                .respond_with(
4840                    ResponseTemplate::new(200)
4841                        .set_body_json(serde_json::json!([{"number": 1}]))
4842                        .insert_header("ETag", "\"abc123\""),
4843                )
4844                .expect(1)
4845                .mount(&server)
4846                .await;
4847
4848            // Second poll: orch sends If-None-Match, GitHub answers 304 with no body.
4849            Mock::given(method("GET"))
4850                .and(path("/issues"))
4851                .and(matchers::header("if-none-match", "\"abc123\""))
4852                .respond_with(ResponseTemplate::new(304))
4853                .expect(1)
4854                .mount(&server)
4855                .await;
4856
4857            let (gh, _guard) = make_client();
4858            let url = format!("{}/issues", server.uri());
4859
4860            let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4861            let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4862
4863            assert_eq!(first, second);
4864            assert_eq!(second.len(), 1);
4865            assert_eq!(second[0]["number"], 1);
4866        }
4867
4868        #[tokio::test]
4869        async fn exhausted_quota_skips_further_calls_until_reset() {
4870            let _reset = GlobalStateGuard;
4871            let server = MockServer::start().await;
4872
4873            let reset_at = SystemTime::now()
4874                .duration_since(time::UNIX_EPOCH)
4875                .unwrap()
4876                .as_secs()
4877                + 600;
4878
4879            // One response, and only one: it reports an exhausted core quota, so every
4880            // later call must be skipped locally rather than sent.
4881            Mock::given(method("GET"))
4882                .and(path("/issues"))
4883                .respond_with(
4884                    ResponseTemplate::new(200)
4885                        .set_body_json(serde_json::json!([{"number": 1}]))
4886                        .insert_header("x-ratelimit-remaining", "0")
4887                        .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4888                )
4889                .expect(1)
4890                .mount(&server)
4891                .await;
4892
4893            let (gh, _guard) = make_client();
4894            let url = format!("{}/issues", server.uri());
4895
4896            let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4897            assert_eq!(first.len(), 1);
4898
4899            // The next call returns immediately instead of waiting out the 600s window.
4900            let start = Instant::now();
4901            let err = gh
4902                .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4903                .await
4904                .unwrap_err();
4905            assert!(
4906                start.elapsed() < Duration::from_secs(1),
4907                "call blocked for {:?} instead of failing fast",
4908                start.elapsed()
4909            );
4910            assert!(err.to_string().contains("rate-limited"), "{err}");
4911
4912            // The engine reports the window from the same state the skip reads.
4913            let m = rate_limit_metrics();
4914            assert_eq!(m.rest_remaining, Some(0));
4915            assert_eq!(m.rest_reset_at, Some(reset_at));
4916            assert!(GhHttp::is_rate_limited().is_some());
4917
4918            // `.expect(1)` on the mock is verified on drop: no second request went out.
4919        }
4920
4921        // ── get_all_pages_from_wrapper tests ─────────────────────────────────
4922
4923        #[tokio::test]
4924        async fn get_all_pages_from_wrapper_single_page() {
4925            // Simulates GitHub's check-runs response: a wrapper object with the
4926            // array nested under a named field.
4927            let server = MockServer::start().await;
4928
4929            Mock::given(method("GET"))
4930                .and(path("/check-runs"))
4931                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4932                    "total_count": 2,
4933                    "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4934                })))
4935                .expect(1)
4936                .mount(&server)
4937                .await;
4938
4939            let (gh, _guard) = make_client();
4940            let url = format!("{}/check-runs", server.uri());
4941            let result: Vec<serde_json::Value> = gh
4942                .get_all_pages_from_wrapper(&url, "check_runs")
4943                .await
4944                .unwrap();
4945
4946            assert_eq!(result.len(), 2);
4947            assert_eq!(result[0]["id"], 1);
4948            assert_eq!(result[1]["id"], 2);
4949        }
4950
4951        #[tokio::test]
4952        async fn get_all_pages_from_wrapper_follows_link_next() {
4953            // Two-page check-runs response (common for repos with >100 distinct check names).
4954            let server = MockServer::start().await;
4955
4956            Mock::given(method("GET"))
4957                .and(path("/check-runs"))
4958                .respond_with(
4959                    ResponseTemplate::new(200)
4960                        .set_body_json(serde_json::json!({
4961                            "total_count": 3,
4962                            "check_runs": [{"id": 1, "name": "build"}]
4963                        }))
4964                        .insert_header(
4965                            "Link",
4966                            format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4967                        ),
4968                )
4969                .expect(1)
4970                .mount(&server)
4971                .await;
4972
4973            Mock::given(method("GET"))
4974                .and(path("/check-runs/page2"))
4975                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4976                    "total_count": 3,
4977                    "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4978                })))
4979                .expect(1)
4980                .mount(&server)
4981                .await;
4982
4983            let (gh, _guard) = make_client();
4984            let url = format!("{}/check-runs", server.uri());
4985            let result: Vec<serde_json::Value> = gh
4986                .get_all_pages_from_wrapper(&url, "check_runs")
4987                .await
4988                .unwrap();
4989
4990            assert_eq!(result.len(), 3);
4991            assert_eq!(result[0]["id"], 1);
4992            assert_eq!(result[1]["id"], 2);
4993            assert_eq!(result[2]["id"], 3);
4994        }
4995
4996        #[tokio::test]
4997        async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4998            // When the array field is missing or empty, the function should return
4999            // an empty vec rather than erroring.
5000            let server = MockServer::start().await;
5001
5002            Mock::given(method("GET"))
5003                .and(path("/check-runs"))
5004                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5005                    "total_count": 0,
5006                    "check_runs": []
5007                })))
5008                .expect(1)
5009                .mount(&server)
5010                .await;
5011
5012            let (gh, _guard) = make_client();
5013            let url = format!("{}/check-runs", server.uri());
5014            let result: Vec<serde_json::Value> = gh
5015                .get_all_pages_from_wrapper(&url, "check_runs")
5016                .await
5017                .unwrap();
5018
5019            assert_eq!(result.len(), 0);
5020        }
5021
5022        #[tokio::test]
5023        async fn get_all_pages_from_wrapper_error_on_non_success_status() {
5024            let server = MockServer::start().await;
5025
5026            Mock::given(method("GET"))
5027                .and(path("/check-runs"))
5028                .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
5029                .expect(1)
5030                .mount(&server)
5031                .await;
5032
5033            let (gh, _guard) = make_client();
5034            let url = format!("{}/check-runs", server.uri());
5035            let result = gh
5036                .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
5037                .await;
5038
5039            assert!(result.is_err());
5040            let err = result.unwrap_err().to_string();
5041            assert!(
5042                err.contains("422") || err.contains("failed"),
5043                "error should indicate failure: {err}"
5044            );
5045        }
5046    }
5047
5048    // ── 401 token-refresh tests (wiremock) ────────────────────────────────
5049
5050    #[cfg(test)]
5051    mod auth_retry {
5052        use super::test_client;
5053        use crate::github::token;
5054        use std::env;
5055        use std::sync::Arc;
5056        use std::sync::atomic::{AtomicUsize, Ordering};
5057        use wiremock::matchers::method;
5058        use wiremock::{Mock, MockServer, Request, ResponseTemplate};
5059
5060        fn bad_credentials() -> ResponseTemplate {
5061            ResponseTemplate::new(401).set_body_json(serde_json::json!({
5062                "message": "Bad credentials",
5063                "status": 401
5064            }))
5065        }
5066
5067        #[expect(
5068            clippy::await_holding_lock,
5069            reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5070        )]
5071        #[tokio::test]
5072        async fn retries_once_with_fresh_token_after_401() {
5073            let _env_guard = token::ENV_LOCK.lock().unwrap();
5074            let prev = env::var_os("GH_TOKEN");
5075            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5076            unsafe { env::set_var("GH_TOKEN", "bad_token") };
5077
5078            let server = MockServer::start().await;
5079            let hits = Arc::new(AtomicUsize::new(0));
5080            let hits_in_handler = Arc::clone(&hits);
5081            Mock::given(method("GET"))
5082                .respond_with(move |_req: &Request| {
5083                    if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
5084                        // The 401 handler clears the cache and re-resolves; simulate the
5085                        // operator having fixed the env var in the meantime
5086                        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5087                        unsafe { env::set_var("GH_TOKEN", "good_token") };
5088                        bad_credentials()
5089                    } else {
5090                        ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
5091                    }
5092                })
5093                .mount(&server)
5094                .await;
5095
5096            let gh = test_client();
5097            let result: anyhow::Result<serde_json::Value> =
5098                gh.get_json(&format!("{}/x", server.uri())).await;
5099
5100            match prev {
5101                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5102                Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
5103                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5104                None => unsafe { env::remove_var("GH_TOKEN") },
5105            }
5106
5107            assert!(
5108                result.is_ok(),
5109                "retry with re-resolved token should succeed: {:?}",
5110                result.err()
5111            );
5112            assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
5113        }
5114
5115        #[expect(
5116            clippy::await_holding_lock,
5117            reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5118        )]
5119        #[tokio::test]
5120        async fn double_401_bails_with_distinct_auth_error() {
5121            let _env_guard = token::ENV_LOCK.lock().unwrap();
5122            let prev = env::var_os("GH_TOKEN");
5123            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5124            unsafe { env::set_var("GH_TOKEN", "bad_token") };
5125
5126            let server = MockServer::start().await;
5127            let hits = Arc::new(AtomicUsize::new(0));
5128            let hits_in_handler = Arc::clone(&hits);
5129            Mock::given(method("GET"))
5130                .respond_with(move |_req: &Request| {
5131                    hits_in_handler.fetch_add(1, Ordering::SeqCst);
5132                    bad_credentials()
5133                })
5134                .mount(&server)
5135                .await;
5136
5137            let gh = test_client();
5138            let result: anyhow::Result<serde_json::Value> =
5139                gh.get_json(&format!("{}/x", server.uri())).await;
5140
5141            match prev {
5142                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5143                Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
5144                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
5145                None => unsafe { env::remove_var("GH_TOKEN") },
5146            }
5147
5148            let err = result.expect_err("persisting 401 must fail").to_string();
5149            assert!(
5150                err.contains("token re-resolved after cache clear but still rejected"),
5151                "expected distinct auth-failure error, got: {err}"
5152            );
5153            assert_eq!(
5154                hits.load(Ordering::SeqCst),
5155                2,
5156                "must retry exactly once, not loop"
5157            );
5158        }
5159    }
5160}