Skip to main content

orch/github/
http.rs

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