Skip to main content

orch/github/
http.rs

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