Skip to main content

orch/github/
http.rs

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