Skip to main content

orch/github/
http.rs

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