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, IssueDependency,
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    /// Replace an issue's title, body and labels.
1072    ///
1073    /// Labels replace the full set. Callers pass labels they want to keep
1074    pub async fn update_issue(
1075        &self,
1076        repo: &str,
1077        number: u64,
1078        title: &str,
1079        body: &str,
1080        labels: &[String],
1081    ) -> anyhow::Result<GitHubIssue> {
1082        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
1083        let payload = serde_json::json!({
1084            "title": title,
1085            "body": body,
1086            "labels": labels,
1087        });
1088        let text = self.patch_json_raw(&url, &payload).await?;
1089        serde_json::from_str(&text)
1090            .with_context(|| format!("failed to parse issue update response: {text}"))
1091    }
1092
1093    /// Issues that block `number`.
1094    ///
1095    /// `GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by`
1096    pub async fn list_blocked_by(
1097        &self,
1098        repo: &str,
1099        number: u64,
1100    ) -> anyhow::Result<Vec<IssueDependency>> {
1101        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by");
1102        self.get_all_pages(&url, &[("per_page", "100")]).await
1103    }
1104
1105    /// Mark `number` as blocked by the issue with database id `blocking_issue_id`.
1106    ///
1107    /// `POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by`
1108    /// with `{ "issue_id": blocking_issue_id }`. The id is the database id, not the issue number
1109    pub async fn add_blocked_by(
1110        &self,
1111        repo: &str,
1112        number: u64,
1113        blocking_issue_id: u64,
1114    ) -> anyhow::Result<()> {
1115        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by");
1116        let payload = serde_json::json!({ "issue_id": blocking_issue_id });
1117        self.post_json_raw(&url, &payload).await?;
1118        Ok(())
1119    }
1120
1121    /// Remove a blocked-by link. `issue_id` in the path is the blocking issue's database id
1122    pub async fn remove_blocked_by(
1123        &self,
1124        repo: &str,
1125        number: u64,
1126        blocking_issue_id: u64,
1127    ) -> anyhow::Result<()> {
1128        let url = format!(
1129            "{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
1130        );
1131        self.delete(&url).await?;
1132        Ok(())
1133    }
1134
1135    /// Get a single issue.
1136    pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1137        self.get_json(&format!("{GITHUB_API}/repos/{repo}/issues/{number}"))
1138            .await
1139    }
1140
1141    /// Check whether an issue number refers to a pull request.
1142    pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1143        self.get_issue(repo, number)
1144            .await
1145            .map(|i| i.pull_request.is_some())
1146            .unwrap_or(false)
1147    }
1148
1149    /// List issues filtered by a label (paginated).
1150    #[allow(dead_code)]
1151    pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1152        self.list_issues_with_state(repo, label, "open").await
1153    }
1154
1155    /// List issues filtered by a label and issue state (paginated).
1156    ///
1157    /// `state` is passed directly to the GitHub API: `"open"`, `"closed"`, or `"all"`.
1158    pub async fn list_issues_with_state(
1159        &self,
1160        repo: &str,
1161        label: &str,
1162        state: &str,
1163    ) -> anyhow::Result<Vec<GitHubIssue>> {
1164        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1165        let all: Vec<GitHubIssue> = self
1166            .get_all_pages(
1167                &url,
1168                &[("labels", label), ("state", state), ("per_page", "100")],
1169            )
1170            .await?;
1171        // GitHub /issues API returns PRs too — filter them out
1172        Ok(all
1173            .into_iter()
1174            .filter(|i| i.pull_request.is_none())
1175            .collect())
1176    }
1177
1178    /// List closed issues updated since `since` (ISO 8601), filtered by label.
1179    ///
1180    /// Used for dedup checks — prevents re-creating issues that were recently closed.
1181    #[allow(dead_code)]
1182    pub async fn list_issues_closed_since(
1183        &self,
1184        repo: &str,
1185        label: &str,
1186        since: &str,
1187    ) -> anyhow::Result<Vec<GitHubIssue>> {
1188        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1189        let all: Vec<GitHubIssue> = self
1190            .get_all_pages(
1191                &url,
1192                &[
1193                    ("labels", label),
1194                    ("state", "closed"),
1195                    ("since", since),
1196                    ("per_page", "100"),
1197                ],
1198            )
1199            .await?;
1200        Ok(all
1201            .into_iter()
1202            .filter(|i| i.pull_request.is_none())
1203            .collect())
1204    }
1205
1206    /// List closed issues updated since `since` (ISO 8601), no label filter.
1207    pub async fn list_closed_issues_since(
1208        &self,
1209        repo: &str,
1210        since: &str,
1211    ) -> anyhow::Result<Vec<GitHubIssue>> {
1212        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1213        let all: Vec<GitHubIssue> = self
1214            .get_all_pages(
1215                &url,
1216                &[("state", "closed"), ("since", since), ("per_page", "100")],
1217            )
1218            .await?;
1219        Ok(all
1220            .into_iter()
1221            .filter(|i| i.pull_request.is_none())
1222            .collect())
1223    }
1224
1225    /// List all open issues (no label filter, paginated).
1226    ///
1227    /// When `since` is `Some(ts)`, only returns issues updated after that ISO 8601 timestamp.
1228    /// This is more efficient for periodic syncs — GitHub filters server-side.
1229    pub async fn list_all_open_issues(
1230        &self,
1231        repo: &str,
1232        since: Option<&str>,
1233    ) -> anyhow::Result<Vec<GitHubIssue>> {
1234        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1235        let params = match since {
1236            Some(ts) => vec![("state", "open"), ("since", ts), ("per_page", "100")],
1237            None => vec![("state", "open"), ("per_page", "100")],
1238        };
1239        let all: Vec<GitHubIssue> = self.get_all_pages(&url, &params).await?;
1240        // GitHub /issues API returns PRs too — filter them out
1241        Ok(all
1242            .into_iter()
1243            .filter(|i| i.pull_request.is_none())
1244            .collect())
1245    }
1246
1247    /// List open pull requests (paginated) using the REST pulls endpoint.
1248    /// Returns raw JSON values for flexibility in callers.
1249    pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1250        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1251        let all: Vec<serde_json::Value> = self
1252            .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1253            .await?;
1254        Ok(all)
1255    }
1256
1257    /// List all issues (open and closed, no label filter, paginated).
1258    ///
1259    /// This is more efficient than making separate calls for open and closed.
1260    pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1261        let url = format!("{GITHUB_API}/repos/{repo}/issues");
1262        let all: Vec<GitHubIssue> = self
1263            .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1264            .await?;
1265        // GitHub /issues API returns PRs too — filter them out
1266        Ok(all
1267            .into_iter()
1268            .filter(|i| i.pull_request.is_none())
1269            .collect())
1270    }
1271
1272    /// Add labels to an issue.
1273    pub async fn add_labels(
1274        &self,
1275        repo: &str,
1276        number: &str,
1277        labels: &[String],
1278    ) -> anyhow::Result<()> {
1279        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1280        let payload = serde_json::json!({ "labels": labels });
1281        self.post_json_raw(&url, &payload).await?;
1282        Ok(())
1283    }
1284
1285    /// Ensure a label exists on the repo, creating it if needed.
1286    pub async fn ensure_label(
1287        &self,
1288        repo: &str,
1289        name: &str,
1290        color: &str,
1291        description: &str,
1292    ) -> anyhow::Result<()> {
1293        let encoded = urlencoding::encode(name);
1294        let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1295
1296        // Check if label exists
1297        match self.get_bytes(&get_url).await {
1298            Ok(_) => return Ok(()),
1299            Err(e) if e.to_string().contains("404") => {
1300                tracing::debug!(repo, name, "ensure_label: label not found, creating");
1301            }
1302            Err(e) => {
1303                tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1304            }
1305        }
1306
1307        // Create the label
1308        let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1309        let payload = serde_json::json!({
1310            "name": name,
1311            "color": color,
1312            "description": description,
1313        });
1314
1315        match self.post_json_raw(&create_url, &payload).await {
1316            Ok(_) => {
1317                Self::record_success();
1318            }
1319            Err(e) => {
1320                let msg = e.to_string();
1321                if msg.contains("422")
1322                    || msg.contains("already_exists")
1323                    || msg.contains("Unprocessable")
1324                {
1325                    tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1326                    return Ok(());
1327                }
1328                // Tolerate creation failures (matches old behavior).
1329                tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1330            }
1331        }
1332        Ok(())
1333    }
1334
1335    /// Remove a label from an issue.
1336    pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1337        let encoded = urlencoding::encode(label);
1338        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1339        match self.delete(&url).await {
1340            Ok(_) => Ok(()),
1341            Err(e) if e.to_string().contains("404") => {
1342                tracing::debug!(repo, number, label, "label already removed (404)");
1343                Ok(())
1344            }
1345            Err(e) => Err(e),
1346        }
1347    }
1348
1349    /// Add a comment to an issue.
1350    pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1351        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1352        let payload = serde_json::json!({ "body": body });
1353        self.post_json_raw(&url, &payload).await?;
1354        Ok(())
1355    }
1356
1357    /// Add a reaction to an issue.
1358    ///
1359    /// Supported content values: "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"
1360    pub async fn add_reaction(
1361        &self,
1362        repo: &str,
1363        number: &str,
1364        content: &str,
1365    ) -> anyhow::Result<()> {
1366        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1367        let payload = serde_json::json!({ "content": content });
1368        self.post_json_raw(&url, &payload).await?;
1369        Ok(())
1370    }
1371
1372    /// Add a reaction to an issue comment.
1373    ///
1374    /// Supported content values: "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"
1375    pub async fn add_comment_reaction(
1376        &self,
1377        repo: &str,
1378        comment_id: &str,
1379        content: &str,
1380    ) -> anyhow::Result<()> {
1381        let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1382        let payload = serde_json::json!({ "content": content });
1383        self.post_json_raw(&url, &payload).await?;
1384        Ok(())
1385    }
1386
1387    /// List comments on an issue.
1388    pub async fn list_comments(
1389        &self,
1390        repo: &str,
1391        number: &str,
1392    ) -> anyhow::Result<Vec<GitHubComment>> {
1393        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1394        self.get_all_pages(&url, &[("per_page", "100")]).await
1395    }
1396
1397    /// Check if a PR with the given branch was merged.
1398    pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1399        let owner = repo
1400            .split('/')
1401            .next()
1402            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1403        let head = format!("{}:{}", owner, branch);
1404
1405        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1406        let prs: Vec<serde_json::Value> = self
1407            .get_with_query(
1408                &url,
1409                &[("head", &head), ("state", "closed"), ("per_page", "1")],
1410            )
1411            .await?;
1412
1413        if prs.is_empty() {
1414            return Ok(false);
1415        }
1416        let merged_at = prs[0].get("merged_at");
1417        Ok(merged_at.map(|v| !v.is_null()).unwrap_or(false))
1418    }
1419
1420    /// Get the state of a closed PR for the given branch.
1421    ///
1422    /// Returns `Some((merged, state))` if a closed PR exists for the branch,
1423    /// where `merged` indicates if it was merged and `state` is the PR state ("closed" or "merged").
1424    /// Returns `None` if no closed PR exists for the branch.
1425    /// Get the state of a closed PR for a given branch.
1426    ///
1427    /// Returns `Some((pr_number, merged, state))` if a closed PR is found that
1428    /// matches the expected branch. Returns `None` if no matching closed PR is found.
1429    ///
1430    /// Validates that the returned PR's head ref matches the expected branch to
1431    /// prevent incorrect merge decisions when multiple PRs exist for the same branch.
1432    pub async fn get_closed_pr_state(
1433        &self,
1434        repo: &str,
1435        branch: &str,
1436    ) -> anyhow::Result<Option<(u64, bool, String)>> {
1437        let owner = repo
1438            .split('/')
1439            .next()
1440            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1441        let head = format!("{}:{}", owner, branch);
1442
1443        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1444        let prs: Vec<serde_json::Value> = self
1445            .get_with_query(
1446                &url,
1447                &[("head", &head), ("state", "closed"), ("per_page", "5")],
1448            )
1449            .await?;
1450
1451        if prs.is_empty() {
1452            return Ok(None);
1453        }
1454
1455        // Find the first PR where head ref actually matches our branch
1456        // This validates that we're working with the correct PR
1457        for pr in prs {
1458            let pr_branch = pr
1459                .get("head")
1460                .and_then(|h| h.get("ref"))
1461                .and_then(|r| r.as_str())
1462                .unwrap_or("");
1463
1464            if pr_branch != branch {
1465                tracing::debug!(
1466                    repo,
1467                    branch,
1468                    pr_branch,
1469                    "skipping PR with mismatched head ref"
1470                );
1471                continue;
1472            }
1473
1474            let pr_number = pr
1475                .get("number")
1476                .and_then(|n| n.as_u64())
1477                .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1478
1479            let merged_at = pr.get("merged_at");
1480            let merged = merged_at.map(|v| !v.is_null()).unwrap_or(false);
1481            let state = pr
1482                .get("state")
1483                .and_then(|v| v.as_str())
1484                .unwrap_or("closed")
1485                .to_string();
1486
1487            return Ok(Some((pr_number, merged, state)));
1488        }
1489
1490        // No PR with matching head ref found
1491        Ok(None)
1492    }
1493
1494    /// Batch-check whether the PR for each branch was merged via a single GraphQL query.
1495    ///
1496    /// Returns a map of branch → merged. Branches with no matching PR are omitted
1497    /// (callers should treat missing entries as not merged).
1498    pub async fn batch_is_pr_merged_by_branch(
1499        &self,
1500        repo: &str,
1501        branches: &[String],
1502    ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1503        if branches.is_empty() {
1504            return Ok(std::collections::HashMap::new());
1505        }
1506
1507        let (owner, name) = repo
1508            .split_once('/')
1509            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1510
1511        // Build one alias per branch using positional index to avoid GraphQL
1512        // alias restrictions on branch names that contain non-identifier chars.
1513        // Branch names are passed via variables ($b0, $b1, …) to avoid injection.
1514        let mut var_defs = String::new();
1515        let mut aliases = String::new();
1516        let mut vars = serde_json::json!({ "owner": owner, "name": name });
1517        for (i, branch) in branches.iter().enumerate() {
1518            var_defs.push_str(&format!(", $b{i}: String!"));
1519            aliases.push_str(&format!(
1520                r#"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "#
1521            ));
1522            vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1523        }
1524
1525        let query = format!(
1526            r#"query($owner: String!, $name: String!{var_defs}) {{
1527                repository(owner: $owner, name: $name) {{ {aliases} }}
1528            }}"#
1529        );
1530
1531        let resp = self.graphql_with_vars(&query, vars).await?;
1532
1533        // Surface GraphQL partial errors before checking data structure
1534        if let Some(errors) = resp.get("errors").and_then(|e| e.as_array()) {
1535            if !errors.is_empty() {
1536                let messages: Vec<&str> = errors
1537                    .iter()
1538                    .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1539                    .collect();
1540                tracing::warn!(
1541                    repo,
1542                    errors = ?messages,
1543                    "GraphQL returned errors in batch PR merge check"
1544                );
1545            }
1546        }
1547
1548        Self::parse_batch_pr_merged_response(&resp, branches)
1549    }
1550
1551    fn parse_batch_pr_merged_response(
1552        resp: &serde_json::Value,
1553        branches: &[String],
1554    ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1555        let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1556            // Check for errors array to provide a more helpful error message
1557            let error_msg = resp
1558                .get("errors")
1559                .and_then(|e| e.as_array())
1560                .and_then(|arr| arr.first())
1561                .and_then(|e| e.get("message"))
1562                .and_then(|m| m.as_str())
1563                .unwrap_or("no error details");
1564            anyhow::anyhow!(
1565                "missing /data/repository in GraphQL response: {}",
1566                error_msg
1567            )
1568        })?;
1569
1570        let mut result = std::collections::HashMap::new();
1571        for (i, branch) in branches.iter().enumerate() {
1572            let alias = format!("b{i}");
1573            let merged = repo_data
1574                .get(&alias)
1575                .and_then(|v| v.get("nodes"))
1576                .and_then(|nodes| nodes.as_array())
1577                .and_then(|arr| arr.first())
1578                .and_then(|node| node.get("merged"))
1579                .and_then(|v| v.as_bool())
1580                .unwrap_or(false);
1581            result.insert(branch.clone(), merged);
1582        }
1583
1584        Ok(result)
1585    }
1586
1587    /// Get issue/PR comments since a given timestamp (paginated).
1588    pub async fn get_mentions(
1589        &self,
1590        repo: &str,
1591        since: &str,
1592    ) -> anyhow::Result<Vec<GitHubComment>> {
1593        let url = format!("{GITHUB_API}/repos/{repo}/issues/comments");
1594        self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1595            .await
1596    }
1597
1598    /// Get all comments for a specific issue number.
1599    pub async fn get_issue_comments(
1600        &self,
1601        repo: &str,
1602        issue_number: u64,
1603    ) -> anyhow::Result<Vec<GitHubComment>> {
1604        let url = format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/comments");
1605        self.get_all_pages(&url, &[("per_page", "100")]).await
1606    }
1607
1608    /// Get the current authenticated username (cached after first call).
1609    pub async fn get_whoami(&self) -> anyhow::Result<String> {
1610        if let Some(cached) = self.cached_username.get() {
1611            return Ok(cached.clone());
1612        }
1613
1614        let user: serde_json::Value = self.get_json(&format!("{GITHUB_API}/user")).await?;
1615        let username = user
1616            .get("login")
1617            .and_then(|v| v.as_str())
1618            .map(String::from)
1619            .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1620
1621        let _ = self.cached_username.set(username.clone());
1622        Ok(username)
1623    }
1624
1625    /// Get PR number by branch name.
1626    pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1627        let owner = repo
1628            .split('/')
1629            .next()
1630            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1631        let head = format!("{}:{}", owner, branch);
1632
1633        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1634        let prs: Vec<serde_json::Value> = self
1635            .get_with_query(
1636                &url,
1637                &[("head", &head), ("state", "open"), ("per_page", "1")],
1638            )
1639            .await?;
1640
1641        if prs.is_empty() {
1642            return Ok(None);
1643        }
1644        prs[0]
1645            .get("number")
1646            .and_then(|n| n.as_u64())
1647            .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1648            .map(Some)
1649    }
1650
1651    /// Create a new pull request.
1652    ///
1653    /// # Arguments
1654    /// * `repo` - Repository in "owner/repo" format
1655    /// * `title` - PR title
1656    /// * `body` - PR body/description
1657    /// * `head` - Branch name to create PR from
1658    /// * `base` - Base branch to merge into (e.g., "main")
1659    ///
1660    /// Returns the created PR's URL on success.
1661    pub async fn create_pr(
1662        &self,
1663        repo: &str,
1664        title: &str,
1665        body: &str,
1666        head: &str,
1667        base: &str,
1668    ) -> anyhow::Result<String> {
1669        use reqwest::header;
1670
1671        Self::proactive_throttle_rest().await;
1672        Self::check_backoff()?;
1673        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1674
1675        #[derive(Serialize)]
1676        struct CreatePullRequest<'a> {
1677            title: &'a str,
1678            body: &'a str,
1679            head: &'a str,
1680            base: &'a str,
1681        }
1682
1683        let payload = CreatePullRequest {
1684            title,
1685            body,
1686            head,
1687            base,
1688        };
1689
1690        let auth = self.auth_header().await?;
1691        let make_req = || {
1692            Ok(self
1693                .client
1694                .post(&url)
1695                .header(header::AUTHORIZATION, auth.clone())
1696                .header(header::ACCEPT, "application/vnd.github+json")
1697                .header(header::USER_AGENT, "orch")
1698                .header("X-GitHub-Api-Version", "2022-11-28")
1699                .json(&payload))
1700        };
1701        let response = self.send_with_retries(make_req, false).await?;
1702        let status = response.status();
1703        let text = response.text().await.unwrap_or_default();
1704        if !status.is_success() {
1705            Self::maybe_record_rate_limit_from_body(status, &text);
1706            anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
1707        }
1708
1709        let pr: serde_json::Value = serde_json::from_str(&text)?;
1710        pr.get("html_url")
1711            .and_then(|v| v.as_str())
1712            .map(String::from)
1713            .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {:?}", pr))
1714    }
1715
1716    /// Get the repository's default branch from the GitHub API.
1717    ///
1718    /// Used as a fallback when local git detection yields an invalid base
1719    /// branch for PR creation.
1720    pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
1721        let url = format!("{GITHUB_API}/repos/{repo}");
1722        let resp: serde_json::Value = self.get_json(&url).await?;
1723        resp.get("default_branch")
1724            .and_then(|v| v.as_str())
1725            .map(String::from)
1726            .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
1727    }
1728
1729    /// Get PR details by PR number.
1730    /// Returns the full PR object including the `mergeable` field.
1731    pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
1732        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
1733        self.get_json(&url).await
1734    }
1735
1736    /// Fetch the merged/state for multiple PRs in a single GraphQL query.
1737    ///
1738    /// Returns a map of pr_number → (merged, state).
1739    /// PRs that fail to fetch are silently omitted from the result.
1740    pub async fn batch_get_pr_states(
1741        &self,
1742        repo: &str,
1743        pr_numbers: &[u64],
1744    ) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
1745        if pr_numbers.is_empty() {
1746            return Ok(std::collections::HashMap::new());
1747        }
1748
1749        let (owner, name) = repo
1750            .split_once('/')
1751            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1752
1753        // GitHub GraphQL complexity limit: chunk at 50 to avoid slow/rejected queries.
1754        const CHUNK_SIZE: usize = 50;
1755        let mut result = std::collections::HashMap::new();
1756
1757        for chunk in pr_numbers.chunks(CHUNK_SIZE) {
1758            let aliases: String = chunk
1759                .iter()
1760                .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
1761                .collect::<Vec<_>>()
1762                .join("\n  ");
1763
1764            let query = format!(
1765                r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1766            );
1767
1768            let resp = self
1769                .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1770                .await?;
1771            let repo_data = resp
1772                .pointer("/data/repository")
1773                .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
1774
1775            for n in chunk {
1776                if let Some(pr) = repo_data.get(format!("pr{n}")) {
1777                    let merged = pr.get("merged").and_then(|v| v.as_bool()).unwrap_or(false);
1778                    let state = pr
1779                        .get("state")
1780                        .and_then(|v| v.as_str())
1781                        .unwrap_or("UNKNOWN")
1782                        .to_lowercase();
1783                    result.insert(*n, (merged, state));
1784                }
1785            }
1786        }
1787
1788        Ok(result)
1789    }
1790
1791    /// Batch-fetch state and labels for multiple GitHub issues in a single GraphQL request.
1792    ///
1793    /// Returns a map of issue number (as string) → `(state, labels)` where state is
1794    /// lowercase ("open" / "closed") to match the REST API convention used elsewhere.
1795    pub async fn batch_get_issue_states(
1796        &self,
1797        repo: &str,
1798        issue_numbers: &[u64],
1799        caller_context: &str,
1800    ) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
1801        if issue_numbers.is_empty() {
1802            return Ok(std::collections::HashMap::new());
1803        }
1804
1805        let (valid_issue_numbers, invalid_issue_numbers) =
1806            sanitize_github_issue_numbers(issue_numbers);
1807        if !invalid_issue_numbers.is_empty() {
1808            tracing::warn!(
1809                repo,
1810                caller_context,
1811                invalid_issue_numbers = ?invalid_issue_numbers,
1812                total_supplied = issue_numbers.len(),
1813                "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
1814            );
1815        }
1816        if valid_issue_numbers.is_empty() {
1817            return Ok(std::collections::HashMap::new());
1818        }
1819
1820        let (owner, name) = repo
1821            .split_once('/')
1822            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1823
1824        // GitHub GraphQL complexity limit: chunk at 50 to avoid slow/rejected queries.
1825        const CHUNK_SIZE: usize = 50;
1826        let mut result = std::collections::HashMap::new();
1827
1828        for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
1829            let aliases: String = chunk
1830                .iter()
1831                .map(|n| {
1832                    format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
1833                })
1834                .collect::<Vec<_>>()
1835                .join(" ");
1836
1837            let query = format!(
1838                r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1839            );
1840
1841            let resp = self
1842                .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1843                .await?;
1844            let repo_data = resp
1845                .pointer("/data/repository")
1846                .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
1847
1848            for n in chunk {
1849                if let Some(issue) = repo_data.get(format!("issue{n}")) {
1850                    let state = issue
1851                        .get("state")
1852                        .and_then(|v| v.as_str())
1853                        .unwrap_or("UNKNOWN")
1854                        .to_lowercase();
1855                    let labels: Vec<String> = issue
1856                        .pointer("/labels/nodes")
1857                        .and_then(|v| v.as_array())
1858                        .map(|arr| {
1859                            arr.iter()
1860                                .filter_map(|l| {
1861                                    l.get("name").and_then(|nm| nm.as_str()).map(String::from)
1862                                })
1863                                .collect()
1864                        })
1865                        .unwrap_or_default();
1866                    result.insert(n.to_string(), (state, labels));
1867                }
1868            }
1869        }
1870        Ok(result)
1871    }
1872
1873    /// Get the required status check contexts for a branch.
1874    pub async fn get_required_status_check_contexts(
1875        &self,
1876        repo: &str,
1877        branch: &str,
1878    ) -> anyhow::Result<Vec<String>> {
1879        let branch = urlencoding::encode(branch);
1880        let url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
1881        let resp = self.get_json::<serde_json::Value>(&url).await;
1882        match resp {
1883            Ok(value) => Ok(value
1884                .pointer("/required_status_checks/contexts")
1885                .and_then(|v| v.as_array())
1886                .map(|contexts| {
1887                    contexts
1888                        .iter()
1889                        .filter_map(|context| context.as_str().map(|s| s.to_string()))
1890                        .collect()
1891                })
1892                .unwrap_or_default()),
1893            Err(e) => {
1894                let err = e.to_string();
1895                if err.contains("404") {
1896                    Ok(vec![])
1897                } else {
1898                    Err(e)
1899                }
1900            }
1901        }
1902    }
1903
1904    /// Get commit status contexts for a SHA.
1905    pub async fn get_commit_status_contexts(
1906        &self,
1907        repo: &str,
1908        sha: &str,
1909    ) -> anyhow::Result<Vec<(String, String)>> {
1910        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
1911        // The /commits/{sha}/status endpoint returns a wrapper object:
1912        // { "state": "pending", "total_count": N, "statuses": [...] }
1913        // Use get_all_pages_from_wrapper to follow pagination and extract the
1914        // `statuses` array across pages so we don't silently truncate at 100.
1915        let statuses: Vec<serde_json::Value> =
1916            self.get_all_pages_from_wrapper(&url, "statuses").await?;
1917
1918        Ok(statuses
1919            .into_iter()
1920            .filter_map(|status| {
1921                let context = status.get("context").and_then(|v| v.as_str())?;
1922                let state = status.get("state").and_then(|v| v.as_str())?;
1923                Some((context.to_string(), state.to_string()))
1924            })
1925            .collect())
1926    }
1927
1928    fn combined_status_state(
1929        total: u64,
1930        failing: u64,
1931        pending: u64,
1932        has_workflows: bool,
1933        mergeable_state: Option<&str>,
1934    ) -> String {
1935        if failing > 0 {
1936            "failure".to_string()
1937        } else if pending > 0 {
1938            "pending".to_string()
1939        } else if total == 0 && has_workflows {
1940            // No check runs yet. If GitHub already reports mergeable_state="clean"
1941            // the PR is ready (e.g. all workflows use paths-ignore and skipped this
1942            // commit). Treating it as pending would stall auto-merge forever.
1943            if mergeable_state == Some("clean") {
1944                "success".to_string()
1945            } else {
1946                "pending".to_string()
1947            }
1948        } else {
1949            "success".to_string()
1950        }
1951    }
1952
1953    /// Return `true` when the repository has at least one GitHub Actions workflow.
1954    ///
1955    /// This is used to distinguish repos that genuinely have no CI configured from
1956    /// repos where CI exists but GitHub has not surfaced any runs yet.
1957    pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
1958        let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
1959        let resp = self.get_json::<serde_json::Value>(&url).await;
1960        match resp {
1961            Ok(value) => Ok(value
1962                .get("workflows")
1963                .and_then(|v| v.as_array())
1964                .map(|workflows| !workflows.is_empty())
1965                .unwrap_or(false)),
1966            Err(e) => {
1967                let err = e.to_string();
1968                if err.contains("404") {
1969                    Ok(false)
1970                } else {
1971                    Err(e)
1972                }
1973            }
1974        }
1975    }
1976
1977    /// Get check runs for a PR head SHA.
1978    pub async fn get_check_runs(
1979        &self,
1980        repo: &str,
1981        sha: &str,
1982    ) -> anyhow::Result<Vec<GitHubCheckRun>> {
1983        // Use `filter=latest` so GitHub returns only the most recent check run
1984        // per check name. Without this, the API returns all runs (oldest
1985        // first) and callers that pick the first match may observe a stale
1986        // failed run when a newer run with the same name has succeeded.
1987        //
1988        // Use `per_page=100` and manual pagination via `get_all_pages_from_wrapper`
1989        // because this endpoint returns a *wrapper object* — `{ "total_count": N,
1990        // "check_runs": [...] }` — rather than a raw JSON array.  `get_all_pages`
1991        // cannot be used here because it calls `serde_json::from_str::<Vec<T>>`
1992        // which would fail on an object body.
1993        let url = format!(
1994            "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
1995        );
1996        self.get_all_pages_from_wrapper(&url, "check_runs").await
1997    }
1998
1999    /// Get annotations for a specific check run.
2000    ///
2001    /// Returns the list of annotations attached to the check run, including
2002    /// billing failure messages ("The job was not started because recent account
2003    /// payments have failed …") that GitHub surfaces as annotations rather than
2004    /// as a distinct CI conclusion value.
2005    pub async fn get_check_run_annotations(
2006        &self,
2007        repo: &str,
2008        check_run_id: u64,
2009    ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2010        let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2011        self.get_all_pages(&url, &[("per_page", "100")]).await
2012    }
2013
2014    /// Get reviews for a PR.
2015    pub async fn get_pr_reviews(
2016        &self,
2017        repo: &str,
2018        pr_number: u64,
2019    ) -> anyhow::Result<Vec<GitHubReview>> {
2020        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2021        self.get_all_pages(&url, &[("per_page", "100")]).await
2022    }
2023
2024    /// Get the committer timestamp for a specific commit SHA.
2025    ///
2026    /// Returns an ISO-8601 string, e.g. `"2024-01-15T12:00:00Z"`.
2027    /// Used to determine whether the branch has been updated since a review was submitted.
2028    pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2029        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2030        let value: serde_json::Value = self.get_json(&url).await?;
2031        value
2032            .pointer("/commit/committer/date")
2033            .and_then(|v| v.as_str())
2034            .map(|s| s.to_string())
2035            .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2036    }
2037
2038    /// Update the body of a pull request.
2039    pub async fn update_pr_body(
2040        &self,
2041        repo: &str,
2042        pr_number: u64,
2043        body: &str,
2044    ) -> anyhow::Result<()> {
2045        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2046        let payload = serde_json::json!({ "body": body });
2047        self.patch_json_raw(&url, &payload).await?;
2048        Ok(())
2049    }
2050
2051    /// Close a GitHub issue.
2052    pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2053        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2054        let payload = serde_json::json!({ "state": "closed" });
2055        self.patch_json_raw(&url, &payload).await?;
2056        Ok(())
2057    }
2058
2059    /// Reopen a closed GitHub issue.
2060    pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2061        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2062        let payload = serde_json::json!({ "state": "open" });
2063        self.patch_json_raw(&url, &payload).await?;
2064        Ok(())
2065    }
2066
2067    /// Check if a user is a collaborator.
2068    pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2069        Self::proactive_throttle_rest().await;
2070        Self::check_backoff()?;
2071        let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2072        let auth = self.auth_header().await?;
2073        let make_req = || {
2074            Ok(self
2075                .client
2076                .get(&url)
2077                .header(header::AUTHORIZATION, auth.clone())
2078                .header(header::ACCEPT, "application/vnd.github+json")
2079                .header("X-GitHub-Api-Version", "2022-11-28"))
2080        };
2081        let resp = self.send_with_retries(make_req, false).await?;
2082        let status = resp.status();
2083        if status == StatusCode::NO_CONTENT {
2084            Ok(true)
2085        } else if status == StatusCode::NOT_FOUND {
2086            Ok(false)
2087        } else {
2088            let body = resp.text().await.unwrap_or_default();
2089            anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2090        }
2091    }
2092
2093    /// Add a sub-issue relationship via GraphQL.
2094    pub async fn add_sub_issue(
2095        &self,
2096        parent_node_id: &str,
2097        child_node_id: &str,
2098    ) -> anyhow::Result<()> {
2099        let query = format!(
2100            r#"mutation {{
2101                addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
2102                    issue {{ number }}
2103                    subIssue {{ number }}
2104                }}
2105            }}"#,
2106            parent_node_id, child_node_id
2107        );
2108        self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2109            .await?;
2110        Ok(())
2111    }
2112
2113    /// Link an issue to a branch using GraphQL.
2114    ///
2115    /// Creates a "Development" sidebar link in GitHub that connects the issue
2116    /// to the branch, similar to `gh issue develop`.
2117    ///
2118    /// # Arguments
2119    /// * `repo` - Repository in "owner/repo" format
2120    /// * `issue_number` - The issue number to link
2121    /// * `branch` - The branch name to link the issue to
2122    ///
2123    /// Returns the linked branch ID on success.
2124    pub async fn link_issue_to_branch(
2125        &self,
2126        repo: &str,
2127        issue_number: u64,
2128        branch: &str,
2129    ) -> anyhow::Result<String> {
2130        let parts: Vec<&str> = repo.split('/').collect();
2131        if parts.len() != 2 {
2132            anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2133        }
2134        let (owner, repo_name) = (parts[0], parts[1]);
2135
2136        // First, get the repository and issue node IDs
2137        let query = r#"query($owner: String!, $name: String!, $number: Int!) {
2138                repository(owner: $owner, name: $name) {
2139                    id
2140                    issue(number: $number) {
2141                        id
2142                    }
2143                }
2144            }"#;
2145        let vars = serde_json::json!({
2146            "owner": owner,
2147            "name": repo_name,
2148            "number": issue_number,
2149        });
2150
2151        let result = self.graphql_with_vars(query, vars).await?;
2152
2153        let repo_id = result
2154            .pointer("/data/repository/id")
2155            .and_then(|v| v.as_str())
2156            .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2157
2158        let issue_id = result
2159            .pointer("/data/repository/issue/id")
2160            .and_then(|v| v.as_str())
2161            .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2162
2163        // Fetch branch ref to get the commit OID required by createLinkedBranch
2164        let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
2165                repository(owner: $owner, name: $name) {
2166                    ref(qualifiedName: $ref) {
2167                        target {
2168                            oid
2169                        }
2170                    }
2171                }
2172            }"#;
2173        let branch_vars = serde_json::json!({
2174            "owner": owner,
2175            "name": repo_name,
2176            "ref": format!("refs/heads/{}", branch),
2177        });
2178
2179        let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2180        let branch_oid = branch_result
2181            .pointer("/data/repository/ref/target/oid")
2182            .and_then(|v| v.as_str());
2183
2184        // If branch doesn't exist yet, use the default branch OID.
2185        // createLinkedBranch will create the branch at that OID AND link it.
2186        let branch_oid = match branch_oid {
2187            Some(oid) => oid.to_string(),
2188            None => {
2189                // Get default branch OID as the base for the new branch
2190                let default_query = r#"query($owner: String!, $name: String!) {
2191                        repository(owner: $owner, name: $name) {
2192                            defaultBranchRef {
2193                                target {
2194                                    oid
2195                                }
2196                            }
2197                        }
2198                    }"#;
2199                let default_vars = serde_json::json!({
2200                    "owner": owner,
2201                    "name": repo_name,
2202                });
2203                let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2204                match default_result
2205                    .pointer("/data/repository/defaultBranchRef/target/oid")
2206                    .and_then(|v| v.as_str())
2207                {
2208                    Some(oid) => {
2209                        tracing::debug!(
2210                            repo,
2211                            issue_number,
2212                            branch,
2213                            "branch not found, using default branch OID to create and link"
2214                        );
2215                        oid.to_string()
2216                    }
2217                    None => {
2218                        return Ok(format!("unlinked:{}", branch));
2219                    }
2220                }
2221            }
2222        };
2223
2224        // Use createLinkedBranch mutation to link the issue to the existing branch.
2225        // Required fields: issueId, repositoryId, name (branch name), oid (commit SHA).
2226        let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2227                createLinkedBranch(input: {
2228                    issueId: $issueId
2229                    repositoryId: $repositoryId
2230                    name: $name
2231                    oid: $oid
2232                }) {
2233                    linkedBranch {
2234                        id
2235                        ref {
2236                            name
2237                        }
2238                    }
2239                }
2240            }"#;
2241        let mutation_vars = serde_json::json!({
2242            "issueId": issue_id,
2243            "repositoryId": repo_id,
2244            "name": branch,
2245            "oid": branch_oid,
2246        });
2247
2248        let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2249
2250        match link_result {
2251            Ok(result) => {
2252                let linked_branch_id = result
2253                    .pointer("/data/createLinkedBranch/linkedBranch/id")
2254                    .and_then(|v| v.as_str())
2255                    .map(String::from);
2256
2257                if let Some(id) = linked_branch_id {
2258                    tracing::info!(
2259                        repo,
2260                        issue_number,
2261                        branch,
2262                        "linked issue to branch via GraphQL API"
2263                    );
2264                    Ok(id)
2265                } else {
2266                    // Branch may already be linked - check for errors
2267                    let errors = result
2268                        .get("errors")
2269                        .and_then(|e| e.as_array())
2270                        .cloned()
2271                        .unwrap_or_default();
2272
2273                    if errors.iter().any(|e| {
2274                        e.get("message")
2275                            .and_then(|m| m.as_str())
2276                            .map(|m| m.contains("already"))
2277                            .unwrap_or(false)
2278                    }) {
2279                        tracing::debug!(
2280                            repo,
2281                            issue_number,
2282                            branch,
2283                            "issue already linked to branch"
2284                        );
2285                        Ok(format!("already_linked:{}", branch))
2286                    } else {
2287                        anyhow::bail!("failed to link issue to branch: {:?}", result)
2288                    }
2289                }
2290            }
2291            Err(e) => {
2292                // Check if it's a "already exists" type error
2293                let err_str = format!("{}", e);
2294                if err_str.contains("already") || err_str.contains("existing") {
2295                    tracing::debug!(
2296                        repo,
2297                        issue_number,
2298                        branch,
2299                        "issue already linked to branch (error check)"
2300                    );
2301                    Ok(format!("already_linked:{}", branch))
2302                } else {
2303                    Err(e)
2304                }
2305            }
2306        }
2307    }
2308
2309    /// Merge a PR using the REST API (squash merge).
2310    pub async fn merge_pr(
2311        &self,
2312        repo: &str,
2313        pr_number: u64,
2314        delete_branch: bool,
2315    ) -> anyhow::Result<()> {
2316        Self::proactive_throttle_rest().await;
2317        Self::check_backoff()?;
2318        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2319        let payload = serde_json::json!({ "merge_method": "squash" });
2320        let auth = self.auth_header().await?;
2321        let make_req = || {
2322            Ok(self
2323                .client
2324                .put(&url)
2325                .json(&payload)
2326                .header(header::AUTHORIZATION, auth.clone())
2327                .header(header::ACCEPT, "application/vnd.github+json")
2328                .header("X-GitHub-Api-Version", "2022-11-28"))
2329        };
2330        let resp = self.send_with_retries(make_req, false).await?;
2331        let status = resp.status();
2332        if !status.is_success() {
2333            let body = resp.text().await.unwrap_or_default();
2334            anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2335        }
2336        Self::record_success();
2337
2338        // Delete branch if requested
2339        if delete_branch {
2340            // Get the head ref (branch name) from the PR
2341            let pr: serde_json::Value = self
2342                .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2343                .await?;
2344            if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2345                let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2346                if let Err(e) = self.delete(&del_url).await {
2347                    let err_str = e.to_string();
2348                    if err_str.contains("Reference does not exist") {
2349                        tracing::debug!(
2350                            ref_name,
2351                            "branch already gone (GitHub auto-deleted on merge)"
2352                        );
2353                    } else {
2354                        tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2355                    }
2356                }
2357            }
2358        }
2359        Ok(())
2360    }
2361
2362    /// Update a PR's branch by merging the latest base branch into it
2363    /// (equivalent of the "Update branch" button on GitHub, or `gh pr
2364    /// update-branch`). Produces a new merge commit on the head branch,
2365    /// which re-triggers CI — used to give a PR a chance to pick up a fix
2366    /// that landed on the base branch after the PR's last CI run failed.
2367    pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2368        Self::proactive_throttle_rest().await;
2369        Self::check_backoff()?;
2370        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2371        let auth = self.auth_header().await?;
2372        let make_req = || {
2373            Ok(self
2374                .client
2375                .put(&url)
2376                .header(header::AUTHORIZATION, auth.clone())
2377                .header(header::ACCEPT, "application/vnd.github+json")
2378                .header("X-GitHub-Api-Version", "2022-11-28"))
2379        };
2380        let resp = self.send_with_retries(make_req, false).await?;
2381        let status = resp.status();
2382        if !status.is_success() {
2383            let body = resp.text().await.unwrap_or_default();
2384            Self::maybe_record_rate_limit_from_body(status, &body);
2385            anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2386        }
2387        Self::record_success();
2388        Ok(())
2389    }
2390
2391    /// Enable auto-merge on a PR via GraphQL.
2392    ///
2393    /// GitHub will automatically merge the PR once all required checks pass.
2394    pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2395        let parts: Vec<&str> = repo.split('/').collect();
2396        if parts.len() != 2 {
2397            anyhow::bail!("invalid repo format: {repo}");
2398        }
2399        let (owner, repo_name) = (parts[0], parts[1]);
2400
2401        // First get the PR node ID
2402        let pr_query = r#"query($owner: String!, $name: String!, $number: Int!) {
2403            repository(owner: $owner, name: $name) {
2404                pullRequest(number: $number) { id }
2405            }
2406        }"#;
2407        let body = self
2408            .graphql_with_vars(
2409                pr_query,
2410                serde_json::json!({
2411                    "owner": owner,
2412                    "name": repo_name,
2413                    "number": pr_number,
2414                }),
2415            )
2416            .await?;
2417        let pr_id = body
2418            .pointer("/data/repository/pullRequest/id")
2419            .and_then(|v| v.as_str())
2420            .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2421            .to_string();
2422
2423        // Enable auto-merge with squash
2424        let mutation = r#"mutation($prId: ID!) {
2425            enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2426                pullRequest { autoMergeRequest { enabledAt } }
2427            }
2428        }"#;
2429        let body = self
2430            .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2431            .await?;
2432        if let Some(errors) = body.get("errors") {
2433            anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2434        }
2435
2436        tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2437        Ok(())
2438    }
2439
2440    /// Get sub-issues via GraphQL (paginated).
2441    pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2442        let parts: Vec<&str> = repo.split('/').collect();
2443        if parts.len() != 2 {
2444            anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2445        }
2446        let (owner, repo_name) = (parts[0], parts[1]);
2447        let number = match parse_positive_issue_number(number) {
2448            Ok(number) => number,
2449            Err(e) => {
2450                tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2451                return Ok(Vec::new());
2452            }
2453        };
2454
2455        let mut all_numbers: Vec<u64> = Vec::new();
2456        let mut cursor: Option<String> = None;
2457        let page_size = 100;
2458        let max_pages = 50;
2459        let mut page_count = 0;
2460
2461        loop {
2462            page_count += 1;
2463            if page_count > max_pages {
2464                tracing::warn!(
2465                    repo,
2466                    number,
2467                    "get_sub_issues hit max page limit ({max_pages})"
2468                );
2469                break;
2470            }
2471
2472            let (query, vars) = if let Some(ref c) = cursor {
2473                (
2474                    r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2475                    repository(owner: $owner, name: $name) {
2476                        issue(number: $number) {
2477                            subIssues(first: $first, after: $after) {
2478                                nodes { number }
2479                                pageInfo { hasNextPage endCursor }
2480                            }
2481                        }
2482                    }
2483                }"#,
2484                    serde_json::json!({
2485                        "owner": owner,
2486                        "name": repo_name,
2487                        "number": number,
2488                        "first": page_size,
2489                        "after": c,
2490                    }),
2491                )
2492            } else {
2493                (
2494                    r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2495                    repository(owner: $owner, name: $name) {
2496                        issue(number: $number) {
2497                            subIssues(first: $first) {
2498                                nodes { number }
2499                                pageInfo { hasNextPage endCursor }
2500                            }
2501                        }
2502                    }
2503                }"#,
2504                    serde_json::json!({
2505                        "owner": owner,
2506                        "name": repo_name,
2507                        "number": number,
2508                        "first": page_size,
2509                    }),
2510                )
2511            };
2512
2513            let result = self
2514                .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2515                .await?;
2516
2517            let sub_issues_data = result
2518                .get("data")
2519                .and_then(|d| d.get("repository"))
2520                .and_then(|r| r.get("issue"))
2521                .and_then(|i| i.get("subIssues"));
2522
2523            let nodes = sub_issues_data
2524                .and_then(|s| s.get("nodes"))
2525                .and_then(|n| n.as_array());
2526
2527            match nodes {
2528                Some(nodes) => {
2529                    let numbers: Vec<u64> = nodes
2530                        .iter()
2531                        .filter_map(|n| n.get("number").and_then(|num| num.as_u64()))
2532                        .collect();
2533                    all_numbers.extend(numbers);
2534                }
2535                None => break,
2536            }
2537
2538            let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2539            let has_next = page_info
2540                .and_then(|p| p.get("hasNextPage"))
2541                .and_then(|h| h.as_bool())
2542                .unwrap_or(false);
2543
2544            if !has_next {
2545                break;
2546            }
2547
2548            cursor = page_info
2549                .and_then(|p| p.get("endCursor"))
2550                .and_then(|c| c.as_str())
2551                .map(|s| s.to_string());
2552        }
2553
2554        Ok(all_numbers)
2555    }
2556
2557    /// Get combined CI check status for a git ref.
2558    pub async fn get_combined_status(
2559        &self,
2560        repo: &str,
2561        git_ref: &str,
2562        has_workflows: bool,
2563        mergeable_state: Option<&str>,
2564    ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2565        let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2566        let resp: serde_json::Value = self.get_json(&url).await?;
2567
2568        let runs = resp
2569            .get("check_runs")
2570            .and_then(|v| v.as_array())
2571            .cloned()
2572            .unwrap_or_default();
2573
2574        let mut passing = 0u64;
2575        let mut failing = 0u64;
2576        let mut pending = 0u64;
2577
2578        for run in &runs {
2579            let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2580            let status = run
2581                .get("status")
2582                .and_then(|v| v.as_str())
2583                .unwrap_or("queued");
2584
2585            match status {
2586                "completed" => {
2587                    if conclusion.is_empty() {
2588                        pending += 1;
2589                    } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2590                        passing += 1;
2591                    } else {
2592                        failing += 1;
2593                    }
2594                }
2595                _ => pending += 1,
2596            }
2597        }
2598
2599        let total = runs.len() as u64;
2600        let state =
2601            Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
2602
2603        Ok((state, total, passing, failing, pending))
2604    }
2605
2606    /// Rerun the latest failed workflow run for a given workflow name and branch.
2607    ///
2608    /// Finds the most recent failed (or cancelled) run of `workflow_name` on
2609    /// `branch` triggered by a `push` event, then calls the GitHub Actions
2610    /// rerun endpoint so the **same check run** on the commit flips green.
2611    pub async fn rerun_failed_workflow(
2612        &self,
2613        repo: &str,
2614        workflow_name: &str,
2615        branch: &str,
2616    ) -> anyhow::Result<()> {
2617        // List recent runs for this branch, filtered to push events
2618        let url = format!(
2619            "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
2620        );
2621        let resp: serde_json::Value = self.get_json(&url).await?;
2622        let runs = resp["workflow_runs"]
2623            .as_array()
2624            .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
2625
2626        // Find the latest failed/cancelled run matching the workflow name
2627        let run_id = runs
2628            .iter()
2629            .find(|r| {
2630                r["name"].as_str() == Some(workflow_name)
2631                    && matches!(
2632                        r["conclusion"].as_str(),
2633                        Some("failure") | Some("cancelled")
2634                    )
2635            })
2636            .and_then(|r| r["id"].as_u64())
2637            .ok_or_else(|| {
2638                anyhow::anyhow!(
2639                    "no failed run found for workflow '{}' on branch '{}'",
2640                    workflow_name,
2641                    branch
2642                )
2643            })?;
2644
2645        // POST rerun — no body needed
2646        let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
2647        let auth = self.auth_header().await?;
2648        let make_req = || {
2649            Ok(self
2650                .client
2651                .post(&rerun_url)
2652                .header(header::AUTHORIZATION, auth.clone())
2653                .header(header::ACCEPT, "application/vnd.github+json")
2654                .header("X-GitHub-Api-Version", "2022-11-28"))
2655        };
2656        let resp = self.send_with_retries(make_req, false).await?;
2657        let status = resp.status();
2658        if !status.is_success() {
2659            let body = resp.text().await.unwrap_or_default();
2660            Self::maybe_record_rate_limit_from_body(status, &body);
2661            anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
2662        }
2663
2664        Ok(())
2665    }
2666
2667    /// Fetch reviews, review comments, and issue comments for multiple PRs in a
2668    /// single GraphQL query instead of 4–5 REST calls per PR.
2669    ///
2670    /// Returns a map of `pr_number → PrReviewBatchData`.
2671    /// PRs missing from the response are omitted from the result.
2672    pub async fn batch_fetch_pr_review_data(
2673        &self,
2674        repo: &str,
2675        pr_numbers: &[u64],
2676    ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
2677        if pr_numbers.is_empty() {
2678            return Ok(std::collections::HashMap::new());
2679        }
2680
2681        let (owner, name) = repo
2682            .split_once('/')
2683            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2684
2685        // Build one GraphQL alias per PR number.
2686        let aliases: String = pr_numbers
2687            .iter()
2688            .map(|&n| build_pr_review_alias(n))
2689            .collect::<Vec<_>>()
2690            .join("\n");
2691
2692        let query = format!(
2693            r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2694        );
2695
2696        let resp = self
2697            .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2698            .await?;
2699        let repo_data = resp
2700            .pointer("/data/repository")
2701            .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2702
2703        let mut result = std::collections::HashMap::new();
2704        for &n in pr_numbers {
2705            let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
2706                continue;
2707            };
2708
2709            let merged = pr_data
2710                .get("merged")
2711                .and_then(|v| v.as_bool())
2712                .unwrap_or(false);
2713            let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
2714                Some("MERGEABLE") => Some(true),
2715                Some("CONFLICTING") => Some(false),
2716                _ => None, // UNKNOWN or missing
2717            };
2718
2719            let mut reviews: Vec<GitHubReview> = pr_data
2720                .pointer("/reviews/nodes")
2721                .and_then(|v| v.as_array())
2722                .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
2723                .unwrap_or_default();
2724
2725            if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
2726                tracing::warn!(
2727                    pr_number = n,
2728                    fetched = reviews.len(),
2729                    "batch review fetch truncated reviews connection at first page"
2730                );
2731            }
2732
2733            // Ensure deterministic ordering by submitted timestamp (oldest -> newest).
2734            // GraphQL connections do not guarantee a stable sort unless explicitly
2735            // requested. Sort here so consumers can rely on chronological order.
2736            reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
2737
2738            let mut review_comments: Vec<GitHubReviewComment> = pr_data
2739                .pointer("/reviewThreads/nodes")
2740                .and_then(|v| v.as_array())
2741                .map(|threads| {
2742                    threads
2743                        .iter()
2744                        .filter_map(|thread| {
2745                            thread
2746                                .pointer("/comments/nodes")
2747                                .and_then(|v| v.as_array())
2748                                .map(|nodes| {
2749                                    nodes
2750                                        .iter()
2751                                        .filter_map(parse_graphql_review_comment)
2752                                        .collect::<Vec<_>>()
2753                                })
2754                        })
2755                        .flatten()
2756                        .collect()
2757                })
2758                .unwrap_or_default();
2759
2760            if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
2761                tracing::warn!(
2762                    pr_number = n,
2763                    fetched_threads = pr_data
2764                        .pointer("/reviewThreads/nodes")
2765                        .and_then(|v| v.as_array())
2766                        .map_or(0, |threads| threads.len()),
2767                    "batch review fetch truncated reviewThreads connection at first page"
2768                );
2769            }
2770            if pr_data
2771                .pointer("/reviewThreads/nodes")
2772                .and_then(|v| v.as_array())
2773                .is_some_and(|threads| {
2774                    threads.iter().any(|thread| {
2775                        connection_has_next_page(thread.pointer("/comments/pageInfo"))
2776                    })
2777                })
2778            {
2779                tracing::warn!(
2780                    pr_number = n,
2781                    "batch review fetch truncated one or more review thread comment connections"
2782                );
2783            }
2784
2785            // Sort inline review comments by created_at (oldest -> newest) so
2786            // callers can process them deterministically.
2787            review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2788
2789            let mut issue_comments: Vec<GitHubComment> = pr_data
2790                .pointer("/comments/nodes")
2791                .and_then(|v| v.as_array())
2792                .map(|nodes| {
2793                    nodes
2794                        .iter()
2795                        .filter_map(parse_graphql_issue_comment)
2796                        .collect()
2797                })
2798                .unwrap_or_default();
2799
2800            if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
2801                issue_comments.extend(
2802                    self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
2803                        .await?,
2804                );
2805            }
2806
2807            // Sort issue-level comments by created_at (oldest -> newest). The
2808            // review poll logic expects chronological ordering and may iterate
2809            // in reverse to find the latest comments — so ensure a consistent
2810            // ordering here regardless of GraphQL's default.
2811            issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2812
2813            result.insert(
2814                n,
2815                PrReviewBatchData {
2816                    merged,
2817                    mergeable,
2818                    reviews,
2819                    review_comments,
2820                    issue_comments,
2821                },
2822            );
2823        }
2824
2825        Ok(result)
2826    }
2827
2828    async fn fetch_pr_issue_comments_after(
2829        &self,
2830        owner: &str,
2831        name: &str,
2832        pr_number: u64,
2833        mut cursor: Option<String>,
2834    ) -> anyhow::Result<Vec<GitHubComment>> {
2835        let mut comments = Vec::new();
2836        let mut page_count = 0;
2837        let max_pages = 50;
2838
2839        while let Some(current_cursor) = cursor.take() {
2840            page_count += 1;
2841            if page_count > max_pages {
2842                tracing::warn!(
2843                    pr_number,
2844                    fetched = comments.len(),
2845                    "stopping PR issue comment pagination after max page limit"
2846                );
2847                break;
2848            }
2849
2850            let (query, vars) =
2851                build_pr_issue_comments_page_query(owner, name, pr_number, &current_cursor);
2852            let resp = self.graphql_with_vars(&query, vars).await?;
2853            let comments_data = resp
2854                .pointer("/data/repository/pullRequest/comments")
2855                .ok_or_else(|| {
2856                    anyhow::anyhow!(
2857                        "missing /data/repository/pullRequest/comments in GraphQL response"
2858                    )
2859                })?;
2860
2861            comments.extend(
2862                comments_data
2863                    .get("nodes")
2864                    .and_then(|v| v.as_array())
2865                    .into_iter()
2866                    .flatten()
2867                    .filter_map(parse_graphql_issue_comment),
2868            );
2869
2870            cursor = connection_end_cursor(comments_data.get("pageInfo"));
2871        }
2872
2873        Ok(comments)
2874    }
2875
2876    /// Check the latest automated review comment on a PR.
2877    pub async fn get_automated_review_status(
2878        &self,
2879        repo: &str,
2880        pr_number: u64,
2881    ) -> anyhow::Result<Option<String>> {
2882        let comments = self.list_comments(repo, &pr_number.to_string()).await?;
2883
2884        // Find the most recent automated review comment authored by a collaborator.
2885        let mut newest: Option<&crate::github::types::GitHubComment> = None;
2886        for c in &comments {
2887            if !c.body.starts_with("## Automated Review") {
2888                continue;
2889            }
2890            match self.is_collaborator(repo, &c.user.login).await {
2891                Ok(true) => match newest {
2892                    None => newest = Some(c),
2893                    Some(prev) => {
2894                        if c.created_at > prev.created_at {
2895                            newest = Some(c);
2896                        }
2897                    }
2898                },
2899                Ok(false) => {
2900                    tracing::warn!(
2901                        user = %c.user.login,
2902                        pr_number,
2903                        "ignoring automated review comment from non-collaborator"
2904                    );
2905                    continue;
2906                }
2907                Err(e) => {
2908                    tracing::debug!(
2909                        user = %c.user.login,
2910                        error = %e,
2911                        "failed to check collaborator status, skipping comment"
2912                    );
2913                    continue;
2914                }
2915            }
2916        }
2917
2918        match newest {
2919            Some(c) => {
2920                let first_line = c.body.lines().next().unwrap_or("");
2921                if first_line.contains("Automated Review \u{2014} Approve") {
2922                    Ok(Some("approve".to_string()))
2923                } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
2924                    Ok(Some("changes_requested".to_string()))
2925                } else {
2926                    Ok(None)
2927                }
2928            }
2929            None => Ok(None),
2930        }
2931    }
2932}
2933
2934fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
2935    let mut valid = Vec::with_capacity(issue_numbers.len());
2936    let mut invalid = Vec::new();
2937    for &issue_number in issue_numbers {
2938        if issue_number == 0 {
2939            invalid.push(issue_number);
2940        } else {
2941            valid.push(issue_number);
2942        }
2943    }
2944    (valid, invalid)
2945}
2946
2947// ── Public metrics ───────────────────────────────────────────────────
2948
2949/// Snapshot of GitHub API rate-limit metrics.
2950#[derive(Debug, Clone)]
2951pub struct RateLimitMetrics {
2952    /// Total 403/429 rate-limit responses received (REST + GraphQL combined).
2953    pub hits: u64,
2954    /// Times proactive throttling slept before sending a request.
2955    pub proactive_throttles: u64,
2956    /// Total seconds spent waiting due to proactive throttle or hard backoff.
2957    pub wait_secs: u64,
2958    /// Current REST remaining quota (None if not yet observed).
2959    pub rest_remaining: Option<u32>,
2960    /// Current GraphQL remaining quota (None if not yet observed).
2961    pub graphql_remaining: Option<u32>,
2962    /// Total 5xx server error responses received (REST + GraphQL combined).
2963    pub five_xx_hits: u64,
2964}
2965
2966/// Return a snapshot of GitHub API rate-limit metrics.
2967pub fn rate_limit_metrics() -> RateLimitMetrics {
2968    let rest_remaining = REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
2969    let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
2970    RateLimitMetrics {
2971        hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
2972        proactive_throttles: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
2973        wait_secs: METRIC_WAIT_SECS_TOTAL.load(Ordering::Relaxed),
2974        rest_remaining,
2975        graphql_remaining,
2976        five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
2977    }
2978}
2979
2980// ── Link header parser ───────────────────────────────────────────────
2981
2982/// Parse the `Link` header to find the `rel="next"` URL.
2983fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
2984    let link = headers.get("link")?.to_str().ok()?;
2985    for part in link.split(',') {
2986        let part = part.trim();
2987        if part.contains("rel=\"next\"") {
2988            // Extract URL between < and >
2989            let start = part.find('<')? + 1;
2990            let end = part.find('>')?;
2991            if start >= end {
2992                return None;
2993            }
2994            return Some(part[start..end].to_string());
2995        }
2996    }
2997    None
2998}
2999
3000/// Return the hex color string for a given `status:*` label.
3001pub fn status_label_color(label: &str) -> &'static str {
3002    match label {
3003        "status:new" => "0e8a16",
3004        "status:routed" => "1d76db",
3005        "status:in_progress" => "fbca04",
3006        "status:done" => "6f42c1",
3007        "status:blocked" => "d73a4a",
3008        "status:in_review" => "0075ca",
3009        "status:needs_review" => "e4e669",
3010        _ => "c5def5",
3011    }
3012}
3013
3014// ── Batch GraphQL helpers ─────────────────────────────────────────────
3015
3016/// Data fetched for one PR by [`GhHttp::batch_fetch_pr_review_data`].
3017#[derive(Debug, Clone, Default)]
3018pub struct PrReviewBatchData {
3019    /// True when the pull request was merged.
3020    pub merged: bool,
3021    /// `Some(true)` = MERGEABLE, `Some(false)` = CONFLICTING, `None` = UNKNOWN.
3022    pub mergeable: Option<bool>,
3023    /// All reviews submitted on the PR.
3024    pub reviews: Vec<GitHubReview>,
3025    /// All inline review comments (from all review threads).
3026    pub review_comments: Vec<GitHubReviewComment>,
3027    /// All issue-level comments on the PR.
3028    pub issue_comments: Vec<GitHubComment>,
3029}
3030
3031/// Build a GraphQL alias fragment for one pull request, fetching all data
3032/// needed by the review poll loop in a single batch request.
3033fn build_pr_review_alias(n: u64) -> String {
3034    format!(
3035        r#"pr{n}: pullRequest(number: {n}) {{
3036  merged
3037  mergeable
3038  reviews(first: 50) {{
3039    nodes {{
3040      databaseId
3041      author {{ login }}
3042      body
3043      state
3044      url
3045      submittedAt
3046      commit {{ oid }}
3047    }}
3048    pageInfo {{ hasNextPage }}
3049  }}
3050  reviewThreads(first: 50) {{
3051    nodes {{
3052      comments(first: 20) {{
3053        nodes {{
3054          databaseId
3055          author {{ login }}
3056          body
3057          path
3058          line
3059          originalLine
3060          commit {{ oid }}
3061          originalCommit {{ oid }}
3062          url
3063          createdAt
3064          updatedAt
3065          replyTo {{ databaseId }}
3066          diffHunk
3067        }}
3068        pageInfo {{ hasNextPage }}
3069      }}
3070    }}
3071    pageInfo {{ hasNextPage }}
3072  }}
3073  comments(first: 50) {{
3074    nodes {{
3075      databaseId
3076      author {{ login }}
3077      body
3078      createdAt
3079      updatedAt
3080      url
3081    }}
3082    pageInfo {{ hasNextPage endCursor }}
3083  }}
3084}}"#
3085    )
3086}
3087
3088fn build_pr_issue_comments_page_query(
3089    owner: &str,
3090    name: &str,
3091    pr_number: u64,
3092    after_cursor: &str,
3093) -> (String, serde_json::Value) {
3094    let query = r#"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3095  repository(owner: $owner, name: $name) {
3096    pullRequest(number: $number) {
3097      comments(first: 100, after: $after) {
3098        nodes {
3099          databaseId
3100          author { login }
3101          body
3102          createdAt
3103          updatedAt
3104          url
3105        }
3106        pageInfo { hasNextPage endCursor }
3107      }
3108    }
3109  }
3110}"#
3111    .to_string();
3112    let vars = serde_json::json!({
3113        "owner": owner,
3114        "name": name,
3115        "number": pr_number,
3116        "after": after_cursor,
3117    });
3118    (query, vars)
3119}
3120
3121fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3122    page_info
3123        .and_then(|v| v.get("hasNextPage"))
3124        .and_then(|v| v.as_bool())
3125        .unwrap_or(false)
3126}
3127
3128fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3129    if !connection_has_next_page(page_info) {
3130        return None;
3131    }
3132
3133    page_info
3134        .and_then(|v| v.get("endCursor"))
3135        .and_then(|v| v.as_str())
3136        .map(str::to_owned)
3137}
3138
3139fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3140    use super::types::GitHubUser;
3141    Some(GitHubReview {
3142        id: node.get("databaseId").and_then(|v| v.as_u64())?,
3143        user: GitHubUser {
3144            login: node
3145                .pointer("/author/login")
3146                .and_then(|v| v.as_str())?
3147                .to_string(),
3148        },
3149        body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3150        state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3151        html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3152        submitted_at: node
3153            .get("submittedAt")
3154            .and_then(|v| v.as_str())
3155            .unwrap_or("")
3156            .to_string(),
3157        commit_id: node
3158            .pointer("/commit/oid")
3159            .and_then(|v| v.as_str())
3160            .map(String::from),
3161    })
3162}
3163
3164fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3165    use super::types::GitHubUser;
3166    Some(GitHubReviewComment {
3167        id: node.get("databaseId").and_then(|v| v.as_u64())?,
3168        user: GitHubUser {
3169            login: node
3170                .pointer("/author/login")
3171                .and_then(|v| v.as_str())?
3172                .to_string(),
3173        },
3174        body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3175        path: node
3176            .get("path")
3177            .and_then(|v| v.as_str())
3178            .unwrap_or("")
3179            .to_string(),
3180        line: node.get("line").and_then(|v| v.as_u64()).map(|v| v as u32),
3181        original_line: node
3182            .get("originalLine")
3183            .and_then(|v| v.as_u64())
3184            .map(|v| v as u32),
3185        commit_id: node
3186            .pointer("/commit/oid")
3187            .and_then(|v| v.as_str())
3188            .unwrap_or("")
3189            .to_string(),
3190        original_commit_id: node
3191            .pointer("/originalCommit/oid")
3192            .and_then(|v| v.as_str())
3193            .unwrap_or("")
3194            .to_string(),
3195        html_url: node
3196            .get("url")
3197            .and_then(|v| v.as_str())
3198            .unwrap_or("")
3199            .to_string(),
3200        created_at: node
3201            .get("createdAt")
3202            .and_then(|v| v.as_str())
3203            .unwrap_or("")
3204            .to_string(),
3205        updated_at: node
3206            .get("updatedAt")
3207            .and_then(|v| v.as_str())
3208            .unwrap_or("")
3209            .to_string(),
3210        in_reply_to_id: node.pointer("/replyTo/databaseId").and_then(|v| v.as_u64()),
3211        diff_hunk: node
3212            .get("diffHunk")
3213            .and_then(|v| v.as_str())
3214            .map(String::from),
3215    })
3216}
3217
3218fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3219    use super::types::GitHubUser;
3220    Some(GitHubComment {
3221        id: node.get("databaseId").and_then(|v| v.as_u64())?,
3222        body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3223        user: GitHubUser {
3224            login: node
3225                .pointer("/author/login")
3226                .and_then(|v| v.as_str())?
3227                .to_string(),
3228        },
3229        created_at: node
3230            .get("createdAt")
3231            .and_then(|v| v.as_str())
3232            .unwrap_or("")
3233            .to_string(),
3234        updated_at: node
3235            .get("updatedAt")
3236            .and_then(|v| v.as_str())
3237            .map(String::from),
3238        html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3239        issue_url: None,
3240        author_association: None,
3241    })
3242}
3243
3244#[cfg(test)]
3245mod tests {
3246    use super::*;
3247    use tokio::io::{AsyncReadExt, AsyncWriteExt};
3248    use tokio::net::TcpListener;
3249
3250    async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3251        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3252        let addr = listener.local_addr().unwrap();
3253
3254        tokio::spawn(async move {
3255            let (mut socket, _) = listener.accept().await.unwrap();
3256            let mut buf = [0u8; 4096];
3257            let _ = socket.read(&mut buf).await;
3258
3259            let declared_len = body_prefix.len() + 32;
3260            let response = format!(
3261                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
3262            );
3263            socket.write_all(response.as_bytes()).await.unwrap();
3264            socket.shutdown().await.unwrap();
3265        });
3266
3267        format!("http://{addr}")
3268    }
3269
3270    fn test_client() -> GhHttp {
3271        GhHttp {
3272            client: reqwest::Client::new(),
3273            token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3274            cached_username: OnceLock::new(),
3275        }
3276    }
3277
3278    /// Verify `GhHttp::new()` returns `Ok` and does not panic on construction.
3279    ///
3280    /// This test guards against regressions where `GhHttp::new()` might panic
3281    /// (e.g., if TLS initialisation fails or the builder API changes).
3282    #[test]
3283    fn gh_http_new_returns_ok() {
3284        let result = GhHttp::new();
3285        assert!(
3286            result.is_ok(),
3287            "GhHttp::new() must not panic and must return Ok; got: {:?}",
3288            result.err()
3289        );
3290    }
3291
3292    #[test]
3293    fn batch_get_issue_states_query_includes_labels_pagination() {
3294        let issue_numbers = [1u64, 2, 3];
3295
3296        let aliases: String = issue_numbers
3297            .iter()
3298            .map(|n| {
3299                format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
3300            })
3301            .collect::<Vec<_>>()
3302            .join(" ");
3303
3304        let query = format!(
3305            r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
3306        );
3307        assert!(query.contains("$owner"));
3308        assert!(query.contains("$name"));
3309        assert!(query.contains("issue1: issue(number: 1)"));
3310        assert!(query.contains("issue3: issue(number: 3)"));
3311        assert!(query.contains("labels(first: 100)"));
3312    }
3313
3314    #[test]
3315    fn parse_positive_issue_number_accepts_positive_values() {
3316        assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
3317    }
3318
3319    #[test]
3320    fn parse_positive_issue_number_rejects_zero() {
3321        let err = parse_positive_issue_number("0").unwrap_err().to_string();
3322        assert!(err.contains("invalid issue number: 0"));
3323    }
3324
3325    #[test]
3326    fn parse_positive_issue_number_rejects_internal_ids() {
3327        let err = parse_positive_issue_number("internal:5")
3328            .unwrap_err()
3329            .to_string();
3330        assert!(err.contains("invalid issue number: internal:5"));
3331    }
3332
3333    #[test]
3334    fn pr_review_batch_query_includes_page_info_for_nested_connections() {
3335        let query = build_pr_review_alias(42);
3336
3337        assert!(query.contains("reviews(first: 50)"));
3338        assert!(query.contains("reviews(first: 50) {\n    nodes"));
3339        assert!(query.contains("pageInfo { hasNextPage }"));
3340        assert!(query.contains("reviewThreads(first: 50)"));
3341        assert!(query.contains("comments(first: 20)"));
3342        assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3343        assert!(query.contains("updatedAt"));
3344    }
3345
3346    #[test]
3347    fn pr_issue_comments_page_query_uses_cursor_pagination() {
3348        let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
3349
3350        assert!(query.contains("pullRequest(number: $number)"));
3351        assert!(query.contains("comments(first: 100, after: $after)"));
3352        assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3353        assert!(query.contains("updatedAt"));
3354        assert_eq!(vars["owner"], "owner");
3355        assert_eq!(vars["name"], "repo");
3356        assert_eq!(vars["number"], 42);
3357        assert_eq!(vars["after"], "cursor123");
3358    }
3359
3360    #[test]
3361    fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
3362        let page_info = serde_json::json!({
3363            "hasNextPage": true,
3364            "endCursor": "cursor123"
3365        });
3366        assert_eq!(
3367            connection_end_cursor(Some(&page_info)),
3368            Some("cursor123".to_string())
3369        );
3370
3371        let final_page = serde_json::json!({
3372            "hasNextPage": false,
3373            "endCursor": "cursor456"
3374        });
3375        assert_eq!(connection_end_cursor(Some(&final_page)), None);
3376    }
3377
3378    #[test]
3379    fn status_label_colors_match_bash_palette() {
3380        assert_eq!(status_label_color("status:new"), "0e8a16");
3381        assert_eq!(status_label_color("status:routed"), "1d76db");
3382        assert_eq!(status_label_color("status:in_progress"), "fbca04");
3383        assert_eq!(status_label_color("status:done"), "6f42c1");
3384        assert_eq!(status_label_color("status:blocked"), "d73a4a");
3385        assert_eq!(status_label_color("status:in_review"), "0075ca");
3386        assert_eq!(status_label_color("status:needs_review"), "e4e669");
3387    }
3388
3389    #[test]
3390    fn unknown_label_gets_default_color() {
3391        assert_eq!(status_label_color("agent:claude"), "c5def5");
3392        assert_eq!(status_label_color("enhancement"), "c5def5");
3393        assert_eq!(status_label_color(""), "c5def5");
3394    }
3395
3396    #[test]
3397    fn parse_link_next_finds_next_url() {
3398        let mut headers = header::HeaderMap::new();
3399        headers.insert(
3400            "link",
3401            "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
3402                .parse()
3403                .unwrap(),
3404        );
3405        assert_eq!(
3406            parse_link_next(&headers),
3407            Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
3408        );
3409    }
3410
3411    #[test]
3412    fn parse_link_next_none_when_no_next() {
3413        let mut headers = header::HeaderMap::new();
3414        headers.insert(
3415            "link",
3416            "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
3417                .parse()
3418                .unwrap(),
3419        );
3420        assert_eq!(parse_link_next(&headers), None);
3421    }
3422
3423    #[test]
3424    fn parse_link_next_none_when_missing() {
3425        let headers = header::HeaderMap::new();
3426        assert_eq!(parse_link_next(&headers), None);
3427    }
3428
3429    #[test]
3430    fn parse_link_next_none_when_malformed() {
3431        let mut headers = header::HeaderMap::new();
3432        // Malformed: no angle-bracket URL, just garbage
3433        headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
3434        assert_eq!(parse_link_next(&headers), None);
3435    }
3436
3437    #[test]
3438    fn parse_link_next_none_when_gt_before_lt() {
3439        let mut headers = header::HeaderMap::new();
3440        headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
3441        assert_eq!(parse_link_next(&headers), None); // should not panic
3442    }
3443
3444    #[test]
3445    fn parse_link_next_none_when_empty_url() {
3446        let mut headers = header::HeaderMap::new();
3447        headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
3448        assert_eq!(parse_link_next(&headers), None);
3449    }
3450
3451    fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
3452        RateLimit {
3453            remaining: None,
3454            reset_at: None,
3455            backoff_until: None,
3456            backoff_delay: Duration::ZERO,
3457            backoff_base: Duration::from_secs(base_secs),
3458            backoff_max: Duration::from_secs(max_secs),
3459            throttle_threshold: 10,
3460        }
3461    }
3462
3463    #[test]
3464    fn rate_limit_inactive_by_default() {
3465        let rl = make_rl(30, 900);
3466        assert!(rl.is_active().is_none());
3467    }
3468
3469    #[test]
3470    fn rate_limit_backoff_activates() {
3471        let mut rl = make_rl(5, 60);
3472        rl.record_rate_limit();
3473        assert!(rl.is_active().is_some());
3474        assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3475    }
3476
3477    #[test]
3478    fn rate_limit_backoff_doubles() {
3479        let mut rl = make_rl(5, 60);
3480        rl.record_rate_limit();
3481        assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3482        rl.record_rate_limit();
3483        assert_eq!(rl.backoff_delay, Duration::from_secs(10));
3484        rl.record_rate_limit();
3485        assert_eq!(rl.backoff_delay, Duration::from_secs(20));
3486    }
3487
3488    #[test]
3489    fn rate_limit_backoff_caps_at_max() {
3490        let mut rl = make_rl(30, 60);
3491        rl.record_rate_limit(); // 30
3492        rl.record_rate_limit(); // 60
3493        rl.record_rate_limit(); // capped at 60
3494        assert_eq!(rl.backoff_delay, Duration::from_secs(60));
3495    }
3496
3497    #[test]
3498    fn rate_limit_success_resets() {
3499        let mut rl = make_rl(5, 60);
3500        rl.record_rate_limit();
3501        assert!(rl.is_active().is_some());
3502        rl.record_success();
3503        assert!(rl.is_active().is_none());
3504        assert_eq!(rl.backoff_delay, Duration::ZERO);
3505    }
3506
3507    #[test]
3508    fn rate_limit_proactive_pause() {
3509        let mut rl = make_rl(30, 900);
3510        // Simulate: 0 remaining (below default threshold of 10), reset 60s in the future
3511        rl.remaining = Some(0);
3512        let future_epoch = std::time::SystemTime::now()
3513            .duration_since(std::time::UNIX_EPOCH)
3514            .unwrap()
3515            .as_secs()
3516            + 60;
3517        rl.reset_at = Some(future_epoch);
3518        let pause = rl.is_active();
3519        assert!(pause.is_some());
3520        // Should be roughly 60 seconds (within tolerance, +1s buffer added)
3521        assert!(pause.unwrap().as_secs() <= 62);
3522    }
3523
3524    #[test]
3525    fn rate_limit_proactive_pause_threshold() {
3526        let mut rl = make_rl(30, 900);
3527        // remaining=15 is above default threshold of 10 — no proactive wait
3528        rl.remaining = Some(15);
3529        let future_epoch = std::time::SystemTime::now()
3530            .duration_since(std::time::UNIX_EPOCH)
3531            .unwrap()
3532            .as_secs()
3533            + 60;
3534        rl.reset_at = Some(future_epoch);
3535        assert!(rl.is_active().is_none());
3536
3537        // remaining=5 is below threshold — proactive wait activates
3538        rl.remaining = Some(5);
3539        assert!(rl.is_active().is_some());
3540    }
3541
3542    #[test]
3543    fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
3544        // No check runs yet and GitHub hasn't said it's clean → pending (wait for CI to queue)
3545        assert_eq!(
3546            GhHttp::combined_status_state(0, 0, 0, true, None),
3547            "pending"
3548        );
3549        // No workflows configured → no need to wait
3550        assert_eq!(
3551            GhHttp::combined_status_state(0, 0, 0, false, None),
3552            "success"
3553        );
3554        // Failing check → failure regardless
3555        assert_eq!(
3556            GhHttp::combined_status_state(3, 1, 0, true, None),
3557            "failure"
3558        );
3559        // paths-ignore filtered out all workflows: no check runs but GitHub says "clean"
3560        assert_eq!(
3561            GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
3562            "success"
3563        );
3564        // mergeable_state is dirty/blocked — still pending even with has_workflows
3565        assert_eq!(
3566            GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
3567            "pending"
3568        );
3569    }
3570
3571    #[test]
3572    fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
3573        let resp = serde_json::json!({
3574            "data": {},
3575            "errors": [{ "message": "Something went wrong" }],
3576        });
3577
3578        let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
3579            .unwrap_err()
3580            .to_string();
3581        assert!(err_text.contains("missing /data/repository in GraphQL response"));
3582        assert!(err_text.contains("Something went wrong"));
3583    }
3584
3585    #[test]
3586    fn rate_limit_uses_reset_at_for_backoff() {
3587        let mut rl = make_rl(30, 900);
3588        // Set reset_at to 45s in the future
3589        let future_epoch = std::time::SystemTime::now()
3590            .duration_since(std::time::UNIX_EPOCH)
3591            .unwrap()
3592            .as_secs()
3593            + 45;
3594        rl.reset_at = Some(future_epoch);
3595        rl.record_rate_limit();
3596        // Backoff should use reset_at (~46s) not the base (30s)
3597        assert!(rl.backoff_delay.as_secs() >= 45);
3598        assert!(rl.backoff_delay.as_secs() <= 47);
3599    }
3600
3601    #[tokio::test]
3602    async fn send_with_retries_propagates_make_req_error() {
3603        let guard = std::env::var_os("GH_TOKEN");
3604        std::env::set_var("GH_TOKEN", "test_token");
3605        let gh = test_client();
3606        let result = gh
3607            .send_with_retries(
3608                || Err(anyhow::anyhow!("simulated request clone failure")),
3609                false,
3610            )
3611            .await;
3612        assert!(result.is_err());
3613        let err = result.unwrap_err().to_string();
3614        assert_eq!(err, "simulated request clone failure");
3615        match guard {
3616            Some(v) => std::env::set_var("GH_TOKEN", v),
3617            None => std::env::remove_var("GH_TOKEN"),
3618        }
3619    }
3620
3621    /// A transport-level failure (connection refused — GitHub never reached)
3622    /// must exhaust retries without tripping the shared "github:5xx" circuit
3623    /// breaker: that breaker is read by the routing-phase skip and other
3624    /// subsystems as a signal specifically about GitHub's health, which a
3625    /// local connectivity blip says nothing about.
3626    #[tokio::test]
3627    async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
3628        let guard = std::env::var_os("GH_TOKEN");
3629        std::env::set_var("GH_TOKEN", "test_token");
3630
3631        // Bind then immediately drop a listener to get a port nothing is
3632        // listening on, so connecting reliably fails with "connection refused"
3633        // rather than depending on an external unreachable host.
3634        let addr = {
3635            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3636            listener.local_addr().unwrap()
3637        };
3638        let url = format!("http://{addr}/");
3639
3640        let gh = test_client();
3641        let make_req = || Ok(gh.client.get(&url));
3642        let result = gh.send_with_retries(make_req, false).await;
3643
3644        assert!(result.is_err());
3645        assert!(
3646            !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
3647            "transport error must not trip the github:5xx circuit breaker"
3648        );
3649        assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
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 post_json_raw_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
3664        let result = gh
3665            .post_json_raw(
3666                &format!("{server_url}/post"),
3667                &serde_json::json!({"ok": true}),
3668            )
3669            .await;
3670
3671        let err = result.unwrap_err().to_string();
3672        assert!(
3673            !err.contains("EOF while parsing a value"),
3674            "should surface the body read error, got: {err}"
3675        );
3676        assert!(
3677            err.contains("error decoding response body")
3678                || err.contains("request or response body error")
3679                || err.contains("end of file before message length reached"),
3680            "expected a transport/body read error, got: {err}"
3681        );
3682
3683        match guard {
3684            Some(v) => std::env::set_var("GH_TOKEN", v),
3685            None => std::env::remove_var("GH_TOKEN"),
3686        }
3687    }
3688
3689    #[tokio::test]
3690    async fn patch_json_raw_propagates_body_read_errors_on_success() {
3691        let guard = std::env::var_os("GH_TOKEN");
3692        std::env::set_var("GH_TOKEN", "test_token");
3693        let server_url = spawn_truncated_body_server("{").await;
3694        let gh = test_client();
3695
3696        let result = gh
3697            .patch_json_raw(
3698                &format!("{server_url}/patch"),
3699                &serde_json::json!({"ok": true}),
3700            )
3701            .await;
3702
3703        let err = result.unwrap_err().to_string();
3704        assert!(
3705            !err.contains("EOF while parsing a value"),
3706            "should surface the body read error, got: {err}"
3707        );
3708        assert!(
3709            err.contains("error decoding response body")
3710                || err.contains("request or response body error")
3711                || err.contains("end of file before message length reached"),
3712            "expected a transport/body read error, got: {err}"
3713        );
3714
3715        match guard {
3716            Some(v) => std::env::set_var("GH_TOKEN", v),
3717            None => std::env::remove_var("GH_TOKEN"),
3718        }
3719    }
3720
3721    #[tokio::test]
3722    async fn graphql_request_propagates_body_read_errors_on_success() {
3723        let guard = std::env::var_os("GH_TOKEN");
3724        std::env::set_var("GH_TOKEN", "test_token");
3725        let server_url = spawn_truncated_body_server("{").await;
3726        let gh = test_client();
3727        let query = "query { viewer { login } }";
3728
3729        let result = gh
3730            .graphql_request_to_url(
3731                &format!("{server_url}/graphql"),
3732                query,
3733                &[],
3734                Some(serde_json::json!({"k": "v"})),
3735            )
3736            .await;
3737
3738        let err = result.unwrap_err().to_string();
3739        assert!(
3740            !err.contains("EOF while parsing a value"),
3741            "should surface the body read error, got: {err}"
3742        );
3743        assert!(
3744            err.contains("error decoding response body")
3745                || err.contains("request or response body error")
3746                || err.contains("end of file before message length reached"),
3747            "expected a transport/body read error, got: {err}"
3748        );
3749
3750        match guard {
3751            Some(v) => std::env::set_var("GH_TOKEN", v),
3752            None => std::env::remove_var("GH_TOKEN"),
3753        }
3754    }
3755
3756    // ── get_all_pages integration tests (wiremock) ────────────────────────
3757
3758    #[cfg(test)]
3759    mod pagination {
3760        use super::super::*;
3761        use std::ffi::OsString;
3762        use std::sync::Arc;
3763        use wiremock::matchers::{method, path};
3764        use wiremock::{Mock, MockServer, ResponseTemplate};
3765
3766        struct EnvVarGuard {
3767            key: &'static str,
3768            previous: Option<OsString>,
3769        }
3770
3771        impl EnvVarGuard {
3772            fn set(key: &'static str, value: &str) -> Self {
3773                let previous = std::env::var_os(key);
3774                std::env::set_var(key, value);
3775                Self { key, previous }
3776            }
3777        }
3778
3779        impl Drop for EnvVarGuard {
3780            fn drop(&mut self) {
3781                match self.previous.take() {
3782                    Some(value) => std::env::set_var(self.key, value),
3783                    None => std::env::remove_var(self.key),
3784                }
3785            }
3786        }
3787
3788        /// Build a minimal GhHttp pointing at a wiremock server.
3789        /// The token resolver reads GH_TOKEN; we set it to a dummy value.
3790        fn make_client() -> (GhHttp, EnvVarGuard) {
3791            // Ensure a token is available so auth_header() doesn't bail.
3792            let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
3793            let client = GhHttp {
3794                client: reqwest::Client::new(),
3795                token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3796                cached_username: OnceLock::new(),
3797            };
3798            (client, guard)
3799        }
3800
3801        #[tokio::test]
3802        async fn get_all_pages_single_page() {
3803            let server = MockServer::start().await;
3804
3805            Mock::given(method("GET"))
3806                .and(path("/items"))
3807                .respond_with(
3808                    ResponseTemplate::new(200)
3809                        .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
3810                )
3811                .expect(1)
3812                .mount(&server)
3813                .await;
3814
3815            let (gh, _guard) = make_client();
3816            let url = format!("{}/items", server.uri());
3817            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3818
3819            assert_eq!(result.len(), 2);
3820            assert_eq!(result[0]["id"], 1);
3821            assert_eq!(result[1]["id"], 2);
3822        }
3823
3824        #[tokio::test]
3825        async fn get_all_pages_follows_link_next() {
3826            let server = MockServer::start().await;
3827
3828            // Page 1 — includes Link header pointing to page 2
3829            Mock::given(method("GET"))
3830                .and(path("/items"))
3831                .respond_with(
3832                    ResponseTemplate::new(200)
3833                        .set_body_json(serde_json::json!([{"id": 1}]))
3834                        .insert_header(
3835                            "Link",
3836                            format!("<{}/items/page2>; rel=\"next\"", server.uri()),
3837                        ),
3838                )
3839                .expect(1)
3840                .mount(&server)
3841                .await;
3842
3843            // Page 2 — no Link header (last page)
3844            Mock::given(method("GET"))
3845                .and(path("/items/page2"))
3846                .respond_with(
3847                    ResponseTemplate::new(200)
3848                        .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
3849                )
3850                .expect(1)
3851                .mount(&server)
3852                .await;
3853
3854            let (gh, _guard) = make_client();
3855            let url = format!("{}/items", server.uri());
3856            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3857
3858            assert_eq!(result.len(), 3);
3859            assert_eq!(result[0]["id"], 1);
3860            assert_eq!(result[1]["id"], 2);
3861            assert_eq!(result[2]["id"], 3);
3862        }
3863
3864        #[tokio::test]
3865        async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
3866            // Simulates a proxy/transient response that drops the Link header mid-pagination.
3867            // The previous `.unwrap()` would panic here; the fix breaks the loop instead.
3868            let server = MockServer::start().await;
3869
3870            Mock::given(method("GET"))
3871                .and(path("/items"))
3872                .respond_with(
3873                    ResponseTemplate::new(200)
3874                        .set_body_json(serde_json::json!([{"id": 1}]))
3875                        .insert_header(
3876                            "Link",
3877                            format!("<{}/items/page2>; rel=\"next\"", server.uri()),
3878                        ),
3879                )
3880                .expect(1)
3881                .mount(&server)
3882                .await;
3883
3884            // Page 2 response has NO Link header — pagination should stop cleanly.
3885            Mock::given(method("GET"))
3886                .and(path("/items/page2"))
3887                .respond_with(
3888                    ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
3889                )
3890                .expect(1)
3891                .mount(&server)
3892                .await;
3893
3894            let (gh, _guard) = make_client();
3895            let url = format!("{}/items", server.uri());
3896            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3897
3898            // Must not panic and must return both items collected so far.
3899            assert_eq!(result.len(), 2);
3900        }
3901
3902        #[tokio::test]
3903        async fn get_all_pages_stops_on_malformed_link_header() {
3904            // A malformed Link header (no angle-bracket URL) yields None from parse_link_next,
3905            // which should stop pagination rather than panic.
3906            let server = MockServer::start().await;
3907
3908            Mock::given(method("GET"))
3909                .and(path("/items"))
3910                .respond_with(
3911                    ResponseTemplate::new(200)
3912                        .set_body_json(serde_json::json!([{"id": 1}]))
3913                        .insert_header("Link", "garbage; rel=\"next\""),
3914                )
3915                .expect(1)
3916                .mount(&server)
3917                .await;
3918
3919            let (gh, _guard) = make_client();
3920            let url = format!("{}/items", server.uri());
3921            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3922
3923            // Malformed Link header → pagination stops after first page, no panic.
3924            assert_eq!(result.len(), 1);
3925        }
3926
3927        #[tokio::test]
3928        async fn get_all_pages_returns_error_on_malformed_json_body() {
3929            let server = MockServer::start().await;
3930
3931            Mock::given(method("GET"))
3932                .and(path("/items"))
3933                .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
3934                .expect(3)
3935                .mount(&server)
3936                .await;
3937
3938            let (gh, _guard) = make_client();
3939            let url = format!("{}/items", server.uri());
3940            let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
3941
3942            assert!(result.is_err());
3943            let err = result.unwrap_err().to_string();
3944            assert!(
3945                err.contains("JSON decode failed") || err.contains("expected ident"),
3946                "error should mention JSON decode or serde error: {}",
3947                err
3948            );
3949        }
3950
3951        // ── get_all_pages_from_wrapper tests ─────────────────────────────────
3952
3953        #[tokio::test]
3954        async fn get_all_pages_from_wrapper_single_page() {
3955            // Simulates GitHub's check-runs response: a wrapper object with the
3956            // array nested under a named field.
3957            let server = MockServer::start().await;
3958
3959            Mock::given(method("GET"))
3960                .and(path("/check-runs"))
3961                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3962                    "total_count": 2,
3963                    "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
3964                })))
3965                .expect(1)
3966                .mount(&server)
3967                .await;
3968
3969            let (gh, _guard) = make_client();
3970            let url = format!("{}/check-runs", server.uri());
3971            let result: Vec<serde_json::Value> = gh
3972                .get_all_pages_from_wrapper(&url, "check_runs")
3973                .await
3974                .unwrap();
3975
3976            assert_eq!(result.len(), 2);
3977            assert_eq!(result[0]["id"], 1);
3978            assert_eq!(result[1]["id"], 2);
3979        }
3980
3981        #[tokio::test]
3982        async fn get_all_pages_from_wrapper_follows_link_next() {
3983            // Two-page check-runs response (common for repos with >100 distinct check names).
3984            let server = MockServer::start().await;
3985
3986            Mock::given(method("GET"))
3987                .and(path("/check-runs"))
3988                .respond_with(
3989                    ResponseTemplate::new(200)
3990                        .set_body_json(serde_json::json!({
3991                            "total_count": 3,
3992                            "check_runs": [{"id": 1, "name": "build"}]
3993                        }))
3994                        .insert_header(
3995                            "Link",
3996                            format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
3997                        ),
3998                )
3999                .expect(1)
4000                .mount(&server)
4001                .await;
4002
4003            Mock::given(method("GET"))
4004                .and(path("/check-runs/page2"))
4005                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4006                    "total_count": 3,
4007                    "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4008                })))
4009                .expect(1)
4010                .mount(&server)
4011                .await;
4012
4013            let (gh, _guard) = make_client();
4014            let url = format!("{}/check-runs", server.uri());
4015            let result: Vec<serde_json::Value> = gh
4016                .get_all_pages_from_wrapper(&url, "check_runs")
4017                .await
4018                .unwrap();
4019
4020            assert_eq!(result.len(), 3);
4021            assert_eq!(result[0]["id"], 1);
4022            assert_eq!(result[1]["id"], 2);
4023            assert_eq!(result[2]["id"], 3);
4024        }
4025
4026        #[tokio::test]
4027        async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4028            // When the array field is missing or empty, the function should return
4029            // an empty vec rather than erroring.
4030            let server = MockServer::start().await;
4031
4032            Mock::given(method("GET"))
4033                .and(path("/check-runs"))
4034                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4035                    "total_count": 0,
4036                    "check_runs": []
4037                })))
4038                .expect(1)
4039                .mount(&server)
4040                .await;
4041
4042            let (gh, _guard) = make_client();
4043            let url = format!("{}/check-runs", server.uri());
4044            let result: Vec<serde_json::Value> = gh
4045                .get_all_pages_from_wrapper(&url, "check_runs")
4046                .await
4047                .unwrap();
4048
4049            assert_eq!(result.len(), 0);
4050        }
4051
4052        #[tokio::test]
4053        async fn get_all_pages_from_wrapper_error_on_non_success_status() {
4054            let server = MockServer::start().await;
4055
4056            Mock::given(method("GET"))
4057                .and(path("/check-runs"))
4058                .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
4059                .expect(1)
4060                .mount(&server)
4061                .await;
4062
4063            let (gh, _guard) = make_client();
4064            let url = format!("{}/check-runs", server.uri());
4065            let result = gh
4066                .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
4067                .await;
4068
4069            assert!(result.is_err());
4070            let err = result.unwrap_err().to_string();
4071            assert!(
4072                err.contains("422") || err.contains("failed"),
4073                "error should indicate failure: {err}"
4074            );
4075        }
4076    }
4077
4078    // ── 401 token-refresh tests (wiremock) ────────────────────────────────
4079
4080    #[cfg(test)]
4081    mod auth_retry {
4082        use super::test_client;
4083        use std::sync::atomic::{AtomicUsize, Ordering};
4084        use std::sync::Arc;
4085        use wiremock::matchers::method;
4086        use wiremock::{Mock, MockServer, Request, ResponseTemplate};
4087
4088        fn bad_credentials() -> ResponseTemplate {
4089            ResponseTemplate::new(401).set_body_json(serde_json::json!({
4090                "message": "Bad credentials",
4091                "status": 401
4092            }))
4093        }
4094
4095        #[allow(clippy::await_holding_lock)]
4096        #[tokio::test]
4097        async fn retries_once_with_fresh_token_after_401() {
4098            let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4099            let prev = std::env::var_os("GH_TOKEN");
4100            std::env::set_var("GH_TOKEN", "bad_token");
4101
4102            let server = MockServer::start().await;
4103            let hits = Arc::new(AtomicUsize::new(0));
4104            let hits_in_handler = hits.clone();
4105            Mock::given(method("GET"))
4106                .respond_with(move |_req: &Request| {
4107                    if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
4108                        // The 401 handler clears the cache and re-resolves; simulate the
4109                        // operator having fixed the env var in the meantime
4110                        std::env::set_var("GH_TOKEN", "good_token");
4111                        bad_credentials()
4112                    } else {
4113                        ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
4114                    }
4115                })
4116                .mount(&server)
4117                .await;
4118
4119            let gh = test_client();
4120            let result: anyhow::Result<serde_json::Value> =
4121                gh.get_json(&format!("{}/x", server.uri())).await;
4122
4123            match prev {
4124                Some(v) => std::env::set_var("GH_TOKEN", v),
4125                None => std::env::remove_var("GH_TOKEN"),
4126            }
4127
4128            assert!(
4129                result.is_ok(),
4130                "retry with re-resolved token should succeed: {:?}",
4131                result.err()
4132            );
4133            assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
4134        }
4135
4136        #[allow(clippy::await_holding_lock)]
4137        #[tokio::test]
4138        async fn double_401_bails_with_distinct_auth_error() {
4139            let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4140            let prev = std::env::var_os("GH_TOKEN");
4141            std::env::set_var("GH_TOKEN", "bad_token");
4142
4143            let server = MockServer::start().await;
4144            let hits = Arc::new(AtomicUsize::new(0));
4145            let hits_in_handler = hits.clone();
4146            Mock::given(method("GET"))
4147                .respond_with(move |_req: &Request| {
4148                    hits_in_handler.fetch_add(1, Ordering::SeqCst);
4149                    bad_credentials()
4150                })
4151                .mount(&server)
4152                .await;
4153
4154            let gh = test_client();
4155            let result: anyhow::Result<serde_json::Value> =
4156                gh.get_json(&format!("{}/x", server.uri())).await;
4157
4158            match prev {
4159                Some(v) => std::env::set_var("GH_TOKEN", v),
4160                None => std::env::remove_var("GH_TOKEN"),
4161            }
4162
4163            let err = result.expect_err("persisting 401 must fail").to_string();
4164            assert!(
4165                err.contains("token re-resolved after cache clear but still rejected"),
4166                "expected distinct auth-failure error, got: {err}"
4167            );
4168            assert_eq!(
4169                hits.load(Ordering::SeqCst),
4170                2,
4171                "must retry exactly once, not loop"
4172            );
4173        }
4174    }
4175}