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