Skip to main content

orch/github/
http.rs

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