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