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