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