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