Skip to main content

orch/github/
http.rs

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