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