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        let all: Vec<GitHubIssue> = self
1288            .get_all_pages(
1289                &url,
1290                &[("labels", label), ("state", state), ("per_page", "100")],
1291            )
1292            .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    /// Add a reaction to an issue.
1480    ///
1481    /// Supported content values: "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"
1482    pub async fn add_reaction(
1483        &self,
1484        repo: &str,
1485        number: &str,
1486        content: &str,
1487    ) -> anyhow::Result<()> {
1488        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1489        let payload = serde_json::json!({ "content": content });
1490        self.post_json_raw(&url, &payload).await?;
1491        Ok(())
1492    }
1493
1494    /// Add a reaction to an issue comment.
1495    ///
1496    /// Supported content values: "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"
1497    pub async fn add_comment_reaction(
1498        &self,
1499        repo: &str,
1500        comment_id: &str,
1501        content: &str,
1502    ) -> anyhow::Result<()> {
1503        let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1504        let payload = serde_json::json!({ "content": content });
1505        self.post_json_raw(&url, &payload).await?;
1506        Ok(())
1507    }
1508
1509    /// List comments on an issue.
1510    pub async fn list_comments(
1511        &self,
1512        repo: &str,
1513        number: &str,
1514    ) -> anyhow::Result<Vec<GitHubComment>> {
1515        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1516        self.get_all_pages(&url, &[("per_page", "100")]).await
1517    }
1518
1519    /// Check if a PR with the given branch was merged.
1520    pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1521        let owner = repo
1522            .split('/')
1523            .next()
1524            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1525        let head = format!("{}:{}", owner, branch);
1526
1527        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1528        let prs: Vec<serde_json::Value> = self
1529            .get_with_query(
1530                &url,
1531                &[("head", &head), ("state", "closed"), ("per_page", "1")],
1532            )
1533            .await?;
1534
1535        if prs.is_empty() {
1536            return Ok(false);
1537        }
1538        let merged_at = prs[0].get("merged_at");
1539        Ok(merged_at.map(|v| !v.is_null()).unwrap_or(false))
1540    }
1541
1542    /// Get the state of a closed PR for the given branch.
1543    ///
1544    /// Returns `Some((merged, state))` if a closed PR exists for the branch,
1545    /// where `merged` indicates if it was merged and `state` is the PR state ("closed" or "merged").
1546    /// Returns `None` if no closed PR exists for the branch.
1547    /// Get the state of a closed PR for a given branch.
1548    ///
1549    /// Returns `Some((pr_number, merged, state))` if a closed PR is found that
1550    /// matches the expected branch. Returns `None` if no matching closed PR is found.
1551    ///
1552    /// Validates that the returned PR's head ref matches the expected branch to
1553    /// prevent incorrect merge decisions when multiple PRs exist for the same branch.
1554    pub async fn get_closed_pr_state(
1555        &self,
1556        repo: &str,
1557        branch: &str,
1558    ) -> anyhow::Result<Option<(u64, bool, String)>> {
1559        let owner = repo
1560            .split('/')
1561            .next()
1562            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1563        let head = format!("{}:{}", owner, branch);
1564
1565        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1566        let prs: Vec<serde_json::Value> = self
1567            .get_with_query(
1568                &url,
1569                &[("head", &head), ("state", "closed"), ("per_page", "5")],
1570            )
1571            .await?;
1572
1573        if prs.is_empty() {
1574            return Ok(None);
1575        }
1576
1577        // Find the first PR where head ref actually matches our branch
1578        // This validates that we're working with the correct PR
1579        for pr in prs {
1580            let pr_branch = pr
1581                .get("head")
1582                .and_then(|h| h.get("ref"))
1583                .and_then(|r| r.as_str())
1584                .unwrap_or("");
1585
1586            if pr_branch != branch {
1587                tracing::debug!(
1588                    repo,
1589                    branch,
1590                    pr_branch,
1591                    "skipping PR with mismatched head ref"
1592                );
1593                continue;
1594            }
1595
1596            let pr_number = pr
1597                .get("number")
1598                .and_then(|n| n.as_u64())
1599                .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1600
1601            let merged_at = pr.get("merged_at");
1602            let merged = merged_at.map(|v| !v.is_null()).unwrap_or(false);
1603            let state = pr
1604                .get("state")
1605                .and_then(|v| v.as_str())
1606                .unwrap_or("closed")
1607                .to_string();
1608
1609            return Ok(Some((pr_number, merged, state)));
1610        }
1611
1612        // No PR with matching head ref found
1613        Ok(None)
1614    }
1615
1616    /// Batch-check whether the PR for each branch was merged via a single GraphQL query.
1617    ///
1618    /// Returns a map of branch → merged. Branches with no matching PR are omitted
1619    /// (callers should treat missing entries as not merged).
1620    pub async fn batch_is_pr_merged_by_branch(
1621        &self,
1622        repo: &str,
1623        branches: &[String],
1624    ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1625        if branches.is_empty() {
1626            return Ok(std::collections::HashMap::new());
1627        }
1628
1629        let (owner, name) = repo
1630            .split_once('/')
1631            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1632
1633        // Build one alias per branch using positional index to avoid GraphQL
1634        // alias restrictions on branch names that contain non-identifier chars.
1635        // Branch names are passed via variables ($b0, $b1, …) to avoid injection.
1636        let mut var_defs = String::new();
1637        let mut aliases = String::new();
1638        let mut vars = serde_json::json!({ "owner": owner, "name": name });
1639        for (i, branch) in branches.iter().enumerate() {
1640            var_defs.push_str(&format!(", $b{i}: String!"));
1641            aliases.push_str(&format!(
1642                r#"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "#
1643            ));
1644            vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1645        }
1646
1647        let query = format!(
1648            r#"query($owner: String!, $name: String!{var_defs}) {{
1649                repository(owner: $owner, name: $name) {{ {aliases} }}
1650            }}"#
1651        );
1652
1653        let resp = self.graphql_with_vars(&query, vars).await?;
1654
1655        // Surface GraphQL partial errors before checking data structure
1656        if let Some(errors) = resp.get("errors").and_then(|e| e.as_array()) {
1657            if !errors.is_empty() {
1658                let messages: Vec<&str> = errors
1659                    .iter()
1660                    .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1661                    .collect();
1662                tracing::warn!(
1663                    repo,
1664                    errors = ?messages,
1665                    "GraphQL returned errors in batch PR merge check"
1666                );
1667            }
1668        }
1669
1670        Self::parse_batch_pr_merged_response(&resp, branches)
1671    }
1672
1673    fn parse_batch_pr_merged_response(
1674        resp: &serde_json::Value,
1675        branches: &[String],
1676    ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1677        let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1678            // Check for errors array to provide a more helpful error message
1679            let error_msg = resp
1680                .get("errors")
1681                .and_then(|e| e.as_array())
1682                .and_then(|arr| arr.first())
1683                .and_then(|e| e.get("message"))
1684                .and_then(|m| m.as_str())
1685                .unwrap_or("no error details");
1686            anyhow::anyhow!(
1687                "missing /data/repository in GraphQL response: {}",
1688                error_msg
1689            )
1690        })?;
1691
1692        let mut result = std::collections::HashMap::new();
1693        for (i, branch) in branches.iter().enumerate() {
1694            let alias = format!("b{i}");
1695            let merged = repo_data
1696                .get(&alias)
1697                .and_then(|v| v.get("nodes"))
1698                .and_then(|nodes| nodes.as_array())
1699                .and_then(|arr| arr.first())
1700                .and_then(|node| node.get("merged"))
1701                .and_then(|v| v.as_bool())
1702                .unwrap_or(false);
1703            result.insert(branch.clone(), merged);
1704        }
1705
1706        Ok(result)
1707    }
1708
1709    /// Get issue/PR comments since a given timestamp (paginated).
1710    pub async fn get_mentions(
1711        &self,
1712        repo: &str,
1713        since: &str,
1714    ) -> anyhow::Result<Vec<GitHubComment>> {
1715        let url = format!("{GITHUB_API}/repos/{repo}/issues/comments");
1716        self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1717            .await
1718    }
1719
1720    /// Get all comments for a specific issue number.
1721    pub async fn get_issue_comments(
1722        &self,
1723        repo: &str,
1724        issue_number: u64,
1725    ) -> anyhow::Result<Vec<GitHubComment>> {
1726        let url = format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/comments");
1727        self.get_all_pages(&url, &[("per_page", "100")]).await
1728    }
1729
1730    /// Get the current authenticated username (cached after first call).
1731    pub async fn get_whoami(&self) -> anyhow::Result<String> {
1732        if let Some(cached) = self.cached_username.get() {
1733            return Ok(cached.clone());
1734        }
1735
1736        let user: serde_json::Value = self.get_json(&format!("{GITHUB_API}/user")).await?;
1737        let username = user
1738            .get("login")
1739            .and_then(|v| v.as_str())
1740            .map(String::from)
1741            .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1742
1743        let _ = self.cached_username.set(username.clone());
1744        Ok(username)
1745    }
1746
1747    /// Get PR number by branch name.
1748    pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1749        let owner = repo
1750            .split('/')
1751            .next()
1752            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1753        let head = format!("{}:{}", owner, branch);
1754
1755        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1756        let prs: Vec<serde_json::Value> = self
1757            .get_with_query(
1758                &url,
1759                &[("head", &head), ("state", "open"), ("per_page", "1")],
1760            )
1761            .await?;
1762
1763        if prs.is_empty() {
1764            return Ok(None);
1765        }
1766        prs[0]
1767            .get("number")
1768            .and_then(|n| n.as_u64())
1769            .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1770            .map(Some)
1771    }
1772
1773    /// Create a new pull request.
1774    ///
1775    /// # Arguments
1776    /// * `repo` - Repository in "owner/repo" format
1777    /// * `title` - PR title
1778    /// * `body` - PR body/description
1779    /// * `head` - Branch name to create PR from
1780    /// * `base` - Base branch to merge into (e.g., "main")
1781    ///
1782    /// Returns the created PR's URL on success.
1783    pub async fn create_pr(
1784        &self,
1785        repo: &str,
1786        title: &str,
1787        body: &str,
1788        head: &str,
1789        base: &str,
1790    ) -> anyhow::Result<String> {
1791        use reqwest::header;
1792
1793        Self::proactive_throttle_rest().await;
1794        Self::check_backoff()?;
1795        let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1796
1797        #[derive(Serialize)]
1798        struct CreatePullRequest<'a> {
1799            title: &'a str,
1800            body: &'a str,
1801            head: &'a str,
1802            base: &'a str,
1803        }
1804
1805        let payload = CreatePullRequest {
1806            title,
1807            body,
1808            head,
1809            base,
1810        };
1811
1812        let auth = self.auth_header().await?;
1813        let make_req = || {
1814            Ok(self
1815                .client
1816                .post(&url)
1817                .header(header::AUTHORIZATION, auth.clone())
1818                .header(header::ACCEPT, "application/vnd.github+json")
1819                .header(header::USER_AGENT, "orch")
1820                .header("X-GitHub-Api-Version", "2022-11-28")
1821                .json(&payload))
1822        };
1823        let response = self.send_with_retries(make_req, false).await?;
1824        let status = response.status();
1825        let text = response.text().await.unwrap_or_default();
1826        if !status.is_success() {
1827            Self::maybe_record_rate_limit_from_body(status, &text);
1828            anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
1829        }
1830
1831        let pr: serde_json::Value = serde_json::from_str(&text)?;
1832        pr.get("html_url")
1833            .and_then(|v| v.as_str())
1834            .map(String::from)
1835            .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {:?}", pr))
1836    }
1837
1838    /// Get the repository's default branch from the GitHub API.
1839    ///
1840    /// Used as a fallback when local git detection yields an invalid base
1841    /// branch for PR creation.
1842    pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
1843        let url = format!("{GITHUB_API}/repos/{repo}");
1844        let resp: serde_json::Value = self.get_json(&url).await?;
1845        resp.get("default_branch")
1846            .and_then(|v| v.as_str())
1847            .map(String::from)
1848            .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
1849    }
1850
1851    /// Get PR details by PR number.
1852    /// Returns the full PR object including the `mergeable` field.
1853    pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
1854        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
1855        self.get_json(&url).await
1856    }
1857
1858    /// Fetch the merged/state for multiple PRs in a single GraphQL query.
1859    ///
1860    /// Returns a map of pr_number → (merged, state).
1861    /// PRs that fail to fetch are silently omitted from the result.
1862    pub async fn batch_get_pr_states(
1863        &self,
1864        repo: &str,
1865        pr_numbers: &[u64],
1866    ) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
1867        if pr_numbers.is_empty() {
1868            return Ok(std::collections::HashMap::new());
1869        }
1870
1871        let (owner, name) = repo
1872            .split_once('/')
1873            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1874
1875        // GitHub GraphQL complexity limit: chunk at 50 to avoid slow/rejected queries.
1876        const CHUNK_SIZE: usize = 50;
1877        let mut result = std::collections::HashMap::new();
1878
1879        for chunk in pr_numbers.chunks(CHUNK_SIZE) {
1880            let aliases: String = chunk
1881                .iter()
1882                .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
1883                .collect::<Vec<_>>()
1884                .join("\n  ");
1885
1886            let query = format!(
1887                r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1888            );
1889
1890            let resp = self
1891                .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1892                .await?;
1893            let repo_data = resp
1894                .pointer("/data/repository")
1895                .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
1896
1897            for n in chunk {
1898                if let Some(pr) = repo_data.get(format!("pr{n}")) {
1899                    let merged = pr.get("merged").and_then(|v| v.as_bool()).unwrap_or(false);
1900                    let state = pr
1901                        .get("state")
1902                        .and_then(|v| v.as_str())
1903                        .unwrap_or("UNKNOWN")
1904                        .to_lowercase();
1905                    result.insert(*n, (merged, state));
1906                }
1907            }
1908        }
1909
1910        Ok(result)
1911    }
1912
1913    /// Batch-fetch state and labels for multiple GitHub issues in a single GraphQL request.
1914    ///
1915    /// Returns a map of issue number (as string) → `(state, labels)` where state is
1916    /// lowercase ("open" / "closed") to match the REST API convention used elsewhere.
1917    pub async fn batch_get_issue_states(
1918        &self,
1919        repo: &str,
1920        issue_numbers: &[u64],
1921        caller_context: &str,
1922    ) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
1923        if issue_numbers.is_empty() {
1924            return Ok(std::collections::HashMap::new());
1925        }
1926
1927        let (valid_issue_numbers, invalid_issue_numbers) =
1928            sanitize_github_issue_numbers(issue_numbers);
1929        if !invalid_issue_numbers.is_empty() {
1930            tracing::warn!(
1931                repo,
1932                caller_context,
1933                invalid_issue_numbers = ?invalid_issue_numbers,
1934                total_supplied = issue_numbers.len(),
1935                "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
1936            );
1937        }
1938        if valid_issue_numbers.is_empty() {
1939            return Ok(std::collections::HashMap::new());
1940        }
1941
1942        let (owner, name) = repo
1943            .split_once('/')
1944            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1945
1946        // GitHub GraphQL complexity limit: chunk at 50 to avoid slow/rejected queries.
1947        const CHUNK_SIZE: usize = 50;
1948        let mut result = std::collections::HashMap::new();
1949
1950        for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
1951            let aliases: String = chunk
1952                .iter()
1953                .map(|n| {
1954                    format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
1955                })
1956                .collect::<Vec<_>>()
1957                .join(" ");
1958
1959            let query = format!(
1960                r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1961            );
1962
1963            let resp = self
1964                .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1965                .await?;
1966            let repo_data = resp
1967                .pointer("/data/repository")
1968                .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
1969
1970            for n in chunk {
1971                if let Some(issue) = repo_data.get(format!("issue{n}")) {
1972                    let state = issue
1973                        .get("state")
1974                        .and_then(|v| v.as_str())
1975                        .unwrap_or("UNKNOWN")
1976                        .to_lowercase();
1977                    let labels: Vec<String> = issue
1978                        .pointer("/labels/nodes")
1979                        .and_then(|v| v.as_array())
1980                        .map(|arr| {
1981                            arr.iter()
1982                                .filter_map(|l| {
1983                                    l.get("name").and_then(|nm| nm.as_str()).map(String::from)
1984                                })
1985                                .collect()
1986                        })
1987                        .unwrap_or_default();
1988                    result.insert(n.to_string(), (state, labels));
1989                }
1990            }
1991        }
1992        Ok(result)
1993    }
1994
1995    /// Get the required status check contexts for a branch.
1996    pub async fn get_required_status_check_contexts(
1997        &self,
1998        repo: &str,
1999        branch: &str,
2000    ) -> anyhow::Result<Vec<String>> {
2001        let branch = urlencoding::encode(branch);
2002        let url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2003        let resp = self.get_json::<serde_json::Value>(&url).await;
2004        match resp {
2005            Ok(value) => Ok(value
2006                .pointer("/required_status_checks/contexts")
2007                .and_then(|v| v.as_array())
2008                .map(|contexts| {
2009                    contexts
2010                        .iter()
2011                        .filter_map(|context| context.as_str().map(|s| s.to_string()))
2012                        .collect()
2013                })
2014                .unwrap_or_default()),
2015            Err(e) => {
2016                let err = e.to_string();
2017                if err.contains("404") {
2018                    Ok(vec![])
2019                } else {
2020                    Err(e)
2021                }
2022            }
2023        }
2024    }
2025
2026    /// Get commit status contexts for a SHA.
2027    pub async fn get_commit_status_contexts(
2028        &self,
2029        repo: &str,
2030        sha: &str,
2031    ) -> anyhow::Result<Vec<(String, String)>> {
2032        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2033        // The /commits/{sha}/status endpoint returns a wrapper object:
2034        // { "state": "pending", "total_count": N, "statuses": [...] }
2035        // Use get_all_pages_from_wrapper to follow pagination and extract the
2036        // `statuses` array across pages so we don't silently truncate at 100.
2037        let statuses: Vec<serde_json::Value> =
2038            self.get_all_pages_from_wrapper(&url, "statuses").await?;
2039
2040        Ok(statuses
2041            .into_iter()
2042            .filter_map(|status| {
2043                let context = status.get("context").and_then(|v| v.as_str())?;
2044                let state = status.get("state").and_then(|v| v.as_str())?;
2045                Some((context.to_string(), state.to_string()))
2046            })
2047            .collect())
2048    }
2049
2050    fn combined_status_state(
2051        total: u64,
2052        failing: u64,
2053        pending: u64,
2054        has_workflows: bool,
2055        mergeable_state: Option<&str>,
2056    ) -> String {
2057        if failing > 0 {
2058            "failure".to_string()
2059        } else if pending > 0 {
2060            "pending".to_string()
2061        } else if total == 0 && has_workflows {
2062            // No check runs yet. If GitHub already reports mergeable_state="clean"
2063            // the PR is ready (e.g. all workflows use paths-ignore and skipped this
2064            // commit). Treating it as pending would stall auto-merge forever.
2065            if mergeable_state == Some("clean") {
2066                "success".to_string()
2067            } else {
2068                "pending".to_string()
2069            }
2070        } else {
2071            "success".to_string()
2072        }
2073    }
2074
2075    /// Return `true` when the repository has at least one GitHub Actions workflow.
2076    ///
2077    /// This is used to distinguish repos that genuinely have no CI configured from
2078    /// repos where CI exists but GitHub has not surfaced any runs yet.
2079    pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2080        let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2081        let resp = self.get_json::<serde_json::Value>(&url).await;
2082        match resp {
2083            Ok(value) => Ok(value
2084                .get("workflows")
2085                .and_then(|v| v.as_array())
2086                .map(|workflows| !workflows.is_empty())
2087                .unwrap_or(false)),
2088            Err(e) => {
2089                let err = e.to_string();
2090                if err.contains("404") {
2091                    Ok(false)
2092                } else {
2093                    Err(e)
2094                }
2095            }
2096        }
2097    }
2098
2099    /// Get check runs for a PR head SHA.
2100    pub async fn get_check_runs(
2101        &self,
2102        repo: &str,
2103        sha: &str,
2104    ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2105        // Use `filter=latest` so GitHub returns only the most recent check run
2106        // per check name. Without this, the API returns all runs (oldest
2107        // first) and callers that pick the first match may observe a stale
2108        // failed run when a newer run with the same name has succeeded.
2109        //
2110        // Use `per_page=100` and manual pagination via `get_all_pages_from_wrapper`
2111        // because this endpoint returns a *wrapper object* — `{ "total_count": N,
2112        // "check_runs": [...] }` — rather than a raw JSON array.  `get_all_pages`
2113        // cannot be used here because it calls `serde_json::from_str::<Vec<T>>`
2114        // which would fail on an object body.
2115        let url = format!(
2116            "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2117        );
2118        self.get_all_pages_from_wrapper(&url, "check_runs").await
2119    }
2120
2121    /// Get annotations for a specific check run.
2122    ///
2123    /// Returns the list of annotations attached to the check run, including
2124    /// billing failure messages ("The job was not started because recent account
2125    /// payments have failed …") that GitHub surfaces as annotations rather than
2126    /// as a distinct CI conclusion value.
2127    pub async fn get_check_run_annotations(
2128        &self,
2129        repo: &str,
2130        check_run_id: u64,
2131    ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2132        let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2133        self.get_all_pages(&url, &[("per_page", "100")]).await
2134    }
2135
2136    /// Get reviews for a PR.
2137    pub async fn get_pr_reviews(
2138        &self,
2139        repo: &str,
2140        pr_number: u64,
2141    ) -> anyhow::Result<Vec<GitHubReview>> {
2142        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2143        self.get_all_pages(&url, &[("per_page", "100")]).await
2144    }
2145
2146    /// Get the committer timestamp for a specific commit SHA.
2147    ///
2148    /// Returns an ISO-8601 string, e.g. `"2024-01-15T12:00:00Z"`.
2149    /// Used to determine whether the branch has been updated since a review was submitted.
2150    pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2151        let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2152        let value: serde_json::Value = self.get_json(&url).await?;
2153        value
2154            .pointer("/commit/committer/date")
2155            .and_then(|v| v.as_str())
2156            .map(|s| s.to_string())
2157            .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2158    }
2159
2160    /// Update the body of a pull request.
2161    pub async fn update_pr_body(
2162        &self,
2163        repo: &str,
2164        pr_number: u64,
2165        body: &str,
2166    ) -> anyhow::Result<()> {
2167        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2168        let payload = serde_json::json!({ "body": body });
2169        self.patch_json_raw(&url, &payload).await?;
2170        Ok(())
2171    }
2172
2173    /// Close a GitHub issue.
2174    pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2175        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2176        let payload = serde_json::json!({ "state": "closed" });
2177        self.patch_json_raw(&url, &payload).await?;
2178        Ok(())
2179    }
2180
2181    /// Reopen a closed GitHub issue.
2182    pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2183        let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2184        let payload = serde_json::json!({ "state": "open" });
2185        self.patch_json_raw(&url, &payload).await?;
2186        Ok(())
2187    }
2188
2189    /// Check if a user is a collaborator.
2190    pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2191        Self::proactive_throttle_rest().await;
2192        Self::check_backoff()?;
2193        let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2194        let auth = self.auth_header().await?;
2195        let make_req = || {
2196            Ok(self
2197                .client
2198                .get(&url)
2199                .header(header::AUTHORIZATION, auth.clone())
2200                .header(header::ACCEPT, "application/vnd.github+json")
2201                .header("X-GitHub-Api-Version", "2022-11-28"))
2202        };
2203        let resp = self.send_with_retries(make_req, false).await?;
2204        let status = resp.status();
2205        if status == StatusCode::NO_CONTENT {
2206            Ok(true)
2207        } else if status == StatusCode::NOT_FOUND {
2208            Ok(false)
2209        } else {
2210            let body = resp.text().await.unwrap_or_default();
2211            anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2212        }
2213    }
2214
2215    /// Add a sub-issue relationship via GraphQL.
2216    pub async fn add_sub_issue(
2217        &self,
2218        parent_node_id: &str,
2219        child_node_id: &str,
2220    ) -> anyhow::Result<()> {
2221        let query = format!(
2222            r#"mutation {{
2223                addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
2224                    issue {{ number }}
2225                    subIssue {{ number }}
2226                }}
2227            }}"#,
2228            parent_node_id, child_node_id
2229        );
2230        self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2231            .await?;
2232        Ok(())
2233    }
2234
2235    /// Link an issue to a branch using GraphQL.
2236    ///
2237    /// Creates a "Development" sidebar link in GitHub that connects the issue
2238    /// to the branch, similar to `gh issue develop`.
2239    ///
2240    /// # Arguments
2241    /// * `repo` - Repository in "owner/repo" format
2242    /// * `issue_number` - The issue number to link
2243    /// * `branch` - The branch name to link the issue to
2244    ///
2245    /// Returns the linked branch ID on success.
2246    pub async fn link_issue_to_branch(
2247        &self,
2248        repo: &str,
2249        issue_number: u64,
2250        branch: &str,
2251    ) -> anyhow::Result<String> {
2252        let parts: Vec<&str> = repo.split('/').collect();
2253        if parts.len() != 2 {
2254            anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2255        }
2256        let (owner, repo_name) = (parts[0], parts[1]);
2257
2258        // First, get the repository and issue node IDs
2259        let query = r#"query($owner: String!, $name: String!, $number: Int!) {
2260                repository(owner: $owner, name: $name) {
2261                    id
2262                    issue(number: $number) {
2263                        id
2264                    }
2265                }
2266            }"#;
2267        let vars = serde_json::json!({
2268            "owner": owner,
2269            "name": repo_name,
2270            "number": issue_number,
2271        });
2272
2273        let result = self.graphql_with_vars(query, vars).await?;
2274
2275        let repo_id = result
2276            .pointer("/data/repository/id")
2277            .and_then(|v| v.as_str())
2278            .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2279
2280        let issue_id = result
2281            .pointer("/data/repository/issue/id")
2282            .and_then(|v| v.as_str())
2283            .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2284
2285        // Fetch branch ref to get the commit OID required by createLinkedBranch
2286        let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
2287                repository(owner: $owner, name: $name) {
2288                    ref(qualifiedName: $ref) {
2289                        target {
2290                            oid
2291                        }
2292                    }
2293                }
2294            }"#;
2295        let branch_vars = serde_json::json!({
2296            "owner": owner,
2297            "name": repo_name,
2298            "ref": format!("refs/heads/{}", branch),
2299        });
2300
2301        let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2302        let branch_oid = branch_result
2303            .pointer("/data/repository/ref/target/oid")
2304            .and_then(|v| v.as_str());
2305
2306        // If branch doesn't exist yet, use the default branch OID.
2307        // createLinkedBranch will create the branch at that OID AND link it.
2308        let branch_oid = match branch_oid {
2309            Some(oid) => oid.to_string(),
2310            None => {
2311                // Get default branch OID as the base for the new branch
2312                let default_query = r#"query($owner: String!, $name: String!) {
2313                        repository(owner: $owner, name: $name) {
2314                            defaultBranchRef {
2315                                target {
2316                                    oid
2317                                }
2318                            }
2319                        }
2320                    }"#;
2321                let default_vars = serde_json::json!({
2322                    "owner": owner,
2323                    "name": repo_name,
2324                });
2325                let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2326                match default_result
2327                    .pointer("/data/repository/defaultBranchRef/target/oid")
2328                    .and_then(|v| v.as_str())
2329                {
2330                    Some(oid) => {
2331                        tracing::debug!(
2332                            repo,
2333                            issue_number,
2334                            branch,
2335                            "branch not found, using default branch OID to create and link"
2336                        );
2337                        oid.to_string()
2338                    }
2339                    None => {
2340                        return Ok(format!("unlinked:{}", branch));
2341                    }
2342                }
2343            }
2344        };
2345
2346        // Use createLinkedBranch mutation to link the issue to the existing branch.
2347        // Required fields: issueId, repositoryId, name (branch name), oid (commit SHA).
2348        let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2349                createLinkedBranch(input: {
2350                    issueId: $issueId
2351                    repositoryId: $repositoryId
2352                    name: $name
2353                    oid: $oid
2354                }) {
2355                    linkedBranch {
2356                        id
2357                        ref {
2358                            name
2359                        }
2360                    }
2361                }
2362            }"#;
2363        let mutation_vars = serde_json::json!({
2364            "issueId": issue_id,
2365            "repositoryId": repo_id,
2366            "name": branch,
2367            "oid": branch_oid,
2368        });
2369
2370        let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2371
2372        match link_result {
2373            Ok(result) => {
2374                let linked_branch_id = result
2375                    .pointer("/data/createLinkedBranch/linkedBranch/id")
2376                    .and_then(|v| v.as_str())
2377                    .map(String::from);
2378
2379                if let Some(id) = linked_branch_id {
2380                    tracing::info!(
2381                        repo,
2382                        issue_number,
2383                        branch,
2384                        "linked issue to branch via GraphQL API"
2385                    );
2386                    Ok(id)
2387                } else {
2388                    // Branch may already be linked - check for errors
2389                    let errors = result
2390                        .get("errors")
2391                        .and_then(|e| e.as_array())
2392                        .cloned()
2393                        .unwrap_or_default();
2394
2395                    if errors.iter().any(|e| {
2396                        e.get("message")
2397                            .and_then(|m| m.as_str())
2398                            .map(|m| m.contains("already"))
2399                            .unwrap_or(false)
2400                    }) {
2401                        tracing::debug!(
2402                            repo,
2403                            issue_number,
2404                            branch,
2405                            "issue already linked to branch"
2406                        );
2407                        Ok(format!("already_linked:{}", branch))
2408                    } else {
2409                        anyhow::bail!("failed to link issue to branch: {:?}", result)
2410                    }
2411                }
2412            }
2413            Err(e) => {
2414                // Check if it's a "already exists" type error
2415                let err_str = format!("{}", e);
2416                if err_str.contains("already") || err_str.contains("existing") {
2417                    tracing::debug!(
2418                        repo,
2419                        issue_number,
2420                        branch,
2421                        "issue already linked to branch (error check)"
2422                    );
2423                    Ok(format!("already_linked:{}", branch))
2424                } else {
2425                    Err(e)
2426                }
2427            }
2428        }
2429    }
2430
2431    /// Merge a PR using the REST API (squash merge).
2432    pub async fn merge_pr(
2433        &self,
2434        repo: &str,
2435        pr_number: u64,
2436        delete_branch: bool,
2437    ) -> anyhow::Result<()> {
2438        Self::proactive_throttle_rest().await;
2439        Self::check_backoff()?;
2440        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2441        let payload = serde_json::json!({ "merge_method": "squash" });
2442        let auth = self.auth_header().await?;
2443        let make_req = || {
2444            Ok(self
2445                .client
2446                .put(&url)
2447                .json(&payload)
2448                .header(header::AUTHORIZATION, auth.clone())
2449                .header(header::ACCEPT, "application/vnd.github+json")
2450                .header("X-GitHub-Api-Version", "2022-11-28"))
2451        };
2452        let resp = self.send_with_retries(make_req, false).await?;
2453        let status = resp.status();
2454        if !status.is_success() {
2455            let body = resp.text().await.unwrap_or_default();
2456            anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2457        }
2458        Self::record_success();
2459
2460        // Delete branch if requested
2461        if delete_branch {
2462            // Get the head ref (branch name) from the PR
2463            let pr: serde_json::Value = self
2464                .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2465                .await?;
2466            if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2467                let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2468                if let Err(e) = self.delete(&del_url).await {
2469                    let err_str = e.to_string();
2470                    if err_str.contains("Reference does not exist") {
2471                        tracing::debug!(
2472                            ref_name,
2473                            "branch already gone (GitHub auto-deleted on merge)"
2474                        );
2475                    } else {
2476                        tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2477                    }
2478                }
2479            }
2480        }
2481        Ok(())
2482    }
2483
2484    /// Update a PR's branch by merging the latest base branch into it
2485    /// (equivalent of the "Update branch" button on GitHub, or `gh pr
2486    /// update-branch`). Produces a new merge commit on the head branch,
2487    /// which re-triggers CI — used to give a PR a chance to pick up a fix
2488    /// that landed on the base branch after the PR's last CI run failed.
2489    pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2490        Self::proactive_throttle_rest().await;
2491        Self::check_backoff()?;
2492        let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2493        let auth = self.auth_header().await?;
2494        let make_req = || {
2495            Ok(self
2496                .client
2497                .put(&url)
2498                .header(header::AUTHORIZATION, auth.clone())
2499                .header(header::ACCEPT, "application/vnd.github+json")
2500                .header("X-GitHub-Api-Version", "2022-11-28"))
2501        };
2502        let resp = self.send_with_retries(make_req, false).await?;
2503        let status = resp.status();
2504        if !status.is_success() {
2505            let body = resp.text().await.unwrap_or_default();
2506            Self::maybe_record_rate_limit_from_body(status, &body);
2507            anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2508        }
2509        Self::record_success();
2510        Ok(())
2511    }
2512
2513    /// Enable auto-merge on a PR via GraphQL.
2514    ///
2515    /// GitHub will automatically merge the PR once all required checks pass.
2516    pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2517        let parts: Vec<&str> = repo.split('/').collect();
2518        if parts.len() != 2 {
2519            anyhow::bail!("invalid repo format: {repo}");
2520        }
2521        let (owner, repo_name) = (parts[0], parts[1]);
2522
2523        // First get the PR node ID
2524        let pr_query = r#"query($owner: String!, $name: String!, $number: Int!) {
2525            repository(owner: $owner, name: $name) {
2526                pullRequest(number: $number) { id }
2527            }
2528        }"#;
2529        let body = self
2530            .graphql_with_vars(
2531                pr_query,
2532                serde_json::json!({
2533                    "owner": owner,
2534                    "name": repo_name,
2535                    "number": pr_number,
2536                }),
2537            )
2538            .await?;
2539        let pr_id = body
2540            .pointer("/data/repository/pullRequest/id")
2541            .and_then(|v| v.as_str())
2542            .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2543            .to_string();
2544
2545        // Enable auto-merge with squash
2546        let mutation = r#"mutation($prId: ID!) {
2547            enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2548                pullRequest { autoMergeRequest { enabledAt } }
2549            }
2550        }"#;
2551        let body = self
2552            .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2553            .await?;
2554        if let Some(errors) = body.get("errors") {
2555            anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2556        }
2557
2558        tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2559        Ok(())
2560    }
2561
2562    /// Get sub-issues via GraphQL (paginated).
2563    pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2564        let parts: Vec<&str> = repo.split('/').collect();
2565        if parts.len() != 2 {
2566            anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2567        }
2568        let (owner, repo_name) = (parts[0], parts[1]);
2569        let number = match parse_positive_issue_number(number) {
2570            Ok(number) => number,
2571            Err(e) => {
2572                tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2573                return Ok(Vec::new());
2574            }
2575        };
2576
2577        let mut all_numbers: Vec<u64> = Vec::new();
2578        let mut cursor: Option<String> = None;
2579        let page_size = 100;
2580        let max_pages = 50;
2581        let mut page_count = 0;
2582
2583        loop {
2584            page_count += 1;
2585            if page_count > max_pages {
2586                tracing::warn!(
2587                    repo,
2588                    number,
2589                    "get_sub_issues hit max page limit ({max_pages})"
2590                );
2591                break;
2592            }
2593
2594            let (query, vars) = if let Some(ref c) = cursor {
2595                (
2596                    r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2597                    repository(owner: $owner, name: $name) {
2598                        issue(number: $number) {
2599                            subIssues(first: $first, after: $after) {
2600                                nodes { number }
2601                                pageInfo { hasNextPage endCursor }
2602                            }
2603                        }
2604                    }
2605                }"#,
2606                    serde_json::json!({
2607                        "owner": owner,
2608                        "name": repo_name,
2609                        "number": number,
2610                        "first": page_size,
2611                        "after": c,
2612                    }),
2613                )
2614            } else {
2615                (
2616                    r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2617                    repository(owner: $owner, name: $name) {
2618                        issue(number: $number) {
2619                            subIssues(first: $first) {
2620                                nodes { number }
2621                                pageInfo { hasNextPage endCursor }
2622                            }
2623                        }
2624                    }
2625                }"#,
2626                    serde_json::json!({
2627                        "owner": owner,
2628                        "name": repo_name,
2629                        "number": number,
2630                        "first": page_size,
2631                    }),
2632                )
2633            };
2634
2635            let result = self
2636                .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2637                .await?;
2638
2639            let sub_issues_data = result
2640                .get("data")
2641                .and_then(|d| d.get("repository"))
2642                .and_then(|r| r.get("issue"))
2643                .and_then(|i| i.get("subIssues"));
2644
2645            let nodes = sub_issues_data
2646                .and_then(|s| s.get("nodes"))
2647                .and_then(|n| n.as_array());
2648
2649            match nodes {
2650                Some(nodes) => {
2651                    let numbers: Vec<u64> = nodes
2652                        .iter()
2653                        .filter_map(|n| n.get("number").and_then(|num| num.as_u64()))
2654                        .collect();
2655                    all_numbers.extend(numbers);
2656                }
2657                None => break,
2658            }
2659
2660            let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2661            let has_next = page_info
2662                .and_then(|p| p.get("hasNextPage"))
2663                .and_then(|h| h.as_bool())
2664                .unwrap_or(false);
2665
2666            if !has_next {
2667                break;
2668            }
2669
2670            cursor = page_info
2671                .and_then(|p| p.get("endCursor"))
2672                .and_then(|c| c.as_str())
2673                .map(|s| s.to_string());
2674        }
2675
2676        Ok(all_numbers)
2677    }
2678
2679    /// Get combined CI check status for a git ref.
2680    pub async fn get_combined_status(
2681        &self,
2682        repo: &str,
2683        git_ref: &str,
2684        has_workflows: bool,
2685        mergeable_state: Option<&str>,
2686    ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2687        let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2688        let resp: serde_json::Value = self.get_json(&url).await?;
2689
2690        let runs = resp
2691            .get("check_runs")
2692            .and_then(|v| v.as_array())
2693            .cloned()
2694            .unwrap_or_default();
2695
2696        let mut passing = 0u64;
2697        let mut failing = 0u64;
2698        let mut pending = 0u64;
2699
2700        for run in &runs {
2701            let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2702            let status = run
2703                .get("status")
2704                .and_then(|v| v.as_str())
2705                .unwrap_or("queued");
2706
2707            match status {
2708                "completed" => {
2709                    if conclusion.is_empty() {
2710                        pending += 1;
2711                    } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2712                        passing += 1;
2713                    } else {
2714                        failing += 1;
2715                    }
2716                }
2717                _ => pending += 1,
2718            }
2719        }
2720
2721        let total = runs.len() as u64;
2722        let state =
2723            Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
2724
2725        Ok((state, total, passing, failing, pending))
2726    }
2727
2728    /// Rerun the latest failed workflow run for a given workflow name and branch.
2729    ///
2730    /// Finds the most recent failed (or cancelled) run of `workflow_name` on
2731    /// `branch` triggered by a `push` event, then calls the GitHub Actions
2732    /// rerun endpoint so the **same check run** on the commit flips green.
2733    pub async fn rerun_failed_workflow(
2734        &self,
2735        repo: &str,
2736        workflow_name: &str,
2737        branch: &str,
2738    ) -> anyhow::Result<()> {
2739        // List recent runs for this branch, filtered to push events
2740        let url = format!(
2741            "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
2742        );
2743        let resp: serde_json::Value = self.get_json(&url).await?;
2744        let runs = resp["workflow_runs"]
2745            .as_array()
2746            .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
2747
2748        // Find the latest failed/cancelled run matching the workflow name
2749        let run_id = runs
2750            .iter()
2751            .find(|r| {
2752                r["name"].as_str() == Some(workflow_name)
2753                    && matches!(
2754                        r["conclusion"].as_str(),
2755                        Some("failure") | Some("cancelled")
2756                    )
2757            })
2758            .and_then(|r| r["id"].as_u64())
2759            .ok_or_else(|| {
2760                anyhow::anyhow!(
2761                    "no failed run found for workflow '{}' on branch '{}'",
2762                    workflow_name,
2763                    branch
2764                )
2765            })?;
2766
2767        // POST rerun — no body needed
2768        let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
2769        let auth = self.auth_header().await?;
2770        let make_req = || {
2771            Ok(self
2772                .client
2773                .post(&rerun_url)
2774                .header(header::AUTHORIZATION, auth.clone())
2775                .header(header::ACCEPT, "application/vnd.github+json")
2776                .header("X-GitHub-Api-Version", "2022-11-28"))
2777        };
2778        let resp = self.send_with_retries(make_req, false).await?;
2779        let status = resp.status();
2780        if !status.is_success() {
2781            let body = resp.text().await.unwrap_or_default();
2782            Self::maybe_record_rate_limit_from_body(status, &body);
2783            anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
2784        }
2785
2786        Ok(())
2787    }
2788
2789    /// Fetch reviews, review comments, and issue comments for multiple PRs in a
2790    /// single GraphQL query instead of 4–5 REST calls per PR.
2791    ///
2792    /// Returns a map of `pr_number → PrReviewBatchData`.
2793    /// PRs missing from the response are omitted from the result.
2794    pub async fn batch_fetch_pr_review_data(
2795        &self,
2796        repo: &str,
2797        pr_numbers: &[u64],
2798    ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
2799        if pr_numbers.is_empty() {
2800            return Ok(std::collections::HashMap::new());
2801        }
2802
2803        let (owner, name) = repo
2804            .split_once('/')
2805            .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2806
2807        // Build one GraphQL alias per PR number.
2808        let aliases: String = pr_numbers
2809            .iter()
2810            .map(|&n| build_pr_review_alias(n))
2811            .collect::<Vec<_>>()
2812            .join("\n");
2813
2814        let query = format!(
2815            r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2816        );
2817
2818        let resp = self
2819            .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2820            .await?;
2821        let repo_data = resp
2822            .pointer("/data/repository")
2823            .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2824
2825        let mut result = std::collections::HashMap::new();
2826        for &n in pr_numbers {
2827            let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
2828                continue;
2829            };
2830
2831            let merged = pr_data
2832                .get("merged")
2833                .and_then(|v| v.as_bool())
2834                .unwrap_or(false);
2835            let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
2836                Some("MERGEABLE") => Some(true),
2837                Some("CONFLICTING") => Some(false),
2838                _ => None, // UNKNOWN or missing
2839            };
2840
2841            let mut reviews: Vec<GitHubReview> = pr_data
2842                .pointer("/reviews/nodes")
2843                .and_then(|v| v.as_array())
2844                .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
2845                .unwrap_or_default();
2846
2847            if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
2848                tracing::warn!(
2849                    pr_number = n,
2850                    fetched = reviews.len(),
2851                    "batch review fetch truncated reviews connection at first page"
2852                );
2853            }
2854
2855            // Ensure deterministic ordering by submitted timestamp (oldest -> newest).
2856            // GraphQL connections do not guarantee a stable sort unless explicitly
2857            // requested. Sort here so consumers can rely on chronological order.
2858            reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
2859
2860            let mut review_comments: Vec<GitHubReviewComment> = pr_data
2861                .pointer("/reviewThreads/nodes")
2862                .and_then(|v| v.as_array())
2863                .map(|threads| {
2864                    threads
2865                        .iter()
2866                        .filter_map(|thread| {
2867                            thread
2868                                .pointer("/comments/nodes")
2869                                .and_then(|v| v.as_array())
2870                                .map(|nodes| {
2871                                    nodes
2872                                        .iter()
2873                                        .filter_map(parse_graphql_review_comment)
2874                                        .collect::<Vec<_>>()
2875                                })
2876                        })
2877                        .flatten()
2878                        .collect()
2879                })
2880                .unwrap_or_default();
2881
2882            if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
2883                tracing::warn!(
2884                    pr_number = n,
2885                    fetched_threads = pr_data
2886                        .pointer("/reviewThreads/nodes")
2887                        .and_then(|v| v.as_array())
2888                        .map_or(0, |threads| threads.len()),
2889                    "batch review fetch truncated reviewThreads connection at first page"
2890                );
2891            }
2892            if pr_data
2893                .pointer("/reviewThreads/nodes")
2894                .and_then(|v| v.as_array())
2895                .is_some_and(|threads| {
2896                    threads.iter().any(|thread| {
2897                        connection_has_next_page(thread.pointer("/comments/pageInfo"))
2898                    })
2899                })
2900            {
2901                tracing::warn!(
2902                    pr_number = n,
2903                    "batch review fetch truncated one or more review thread comment connections"
2904                );
2905            }
2906
2907            // Sort inline review comments by created_at (oldest -> newest) so
2908            // callers can process them deterministically.
2909            review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2910
2911            let mut issue_comments: Vec<GitHubComment> = pr_data
2912                .pointer("/comments/nodes")
2913                .and_then(|v| v.as_array())
2914                .map(|nodes| {
2915                    nodes
2916                        .iter()
2917                        .filter_map(parse_graphql_issue_comment)
2918                        .collect()
2919                })
2920                .unwrap_or_default();
2921
2922            if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
2923                issue_comments.extend(
2924                    self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
2925                        .await?,
2926                );
2927            }
2928
2929            // Sort issue-level comments by created_at (oldest -> newest). The
2930            // review poll logic expects chronological ordering and may iterate
2931            // in reverse to find the latest comments — so ensure a consistent
2932            // ordering here regardless of GraphQL's default.
2933            issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2934
2935            result.insert(
2936                n,
2937                PrReviewBatchData {
2938                    merged,
2939                    mergeable,
2940                    reviews,
2941                    review_comments,
2942                    issue_comments,
2943                },
2944            );
2945        }
2946
2947        Ok(result)
2948    }
2949
2950    async fn fetch_pr_issue_comments_after(
2951        &self,
2952        owner: &str,
2953        name: &str,
2954        pr_number: u64,
2955        mut cursor: Option<String>,
2956    ) -> anyhow::Result<Vec<GitHubComment>> {
2957        let mut comments = Vec::new();
2958        let mut page_count = 0;
2959        let max_pages = 50;
2960
2961        while let Some(current_cursor) = cursor.take() {
2962            page_count += 1;
2963            if page_count > max_pages {
2964                tracing::warn!(
2965                    pr_number,
2966                    fetched = comments.len(),
2967                    "stopping PR issue comment pagination after max page limit"
2968                );
2969                break;
2970            }
2971
2972            let (query, vars) =
2973                build_pr_issue_comments_page_query(owner, name, pr_number, &current_cursor);
2974            let resp = self.graphql_with_vars(&query, vars).await?;
2975            let comments_data = resp
2976                .pointer("/data/repository/pullRequest/comments")
2977                .ok_or_else(|| {
2978                    anyhow::anyhow!(
2979                        "missing /data/repository/pullRequest/comments in GraphQL response"
2980                    )
2981                })?;
2982
2983            comments.extend(
2984                comments_data
2985                    .get("nodes")
2986                    .and_then(|v| v.as_array())
2987                    .into_iter()
2988                    .flatten()
2989                    .filter_map(parse_graphql_issue_comment),
2990            );
2991
2992            cursor = connection_end_cursor(comments_data.get("pageInfo"));
2993        }
2994
2995        Ok(comments)
2996    }
2997
2998    /// Check the latest automated review comment on a PR.
2999    pub async fn get_automated_review_status(
3000        &self,
3001        repo: &str,
3002        pr_number: u64,
3003    ) -> anyhow::Result<Option<String>> {
3004        let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3005
3006        // Find the most recent automated review comment authored by a collaborator.
3007        let mut newest: Option<&crate::github::types::GitHubComment> = None;
3008        for c in &comments {
3009            if !c.body.starts_with("## Automated Review") {
3010                continue;
3011            }
3012            match self.is_collaborator(repo, &c.user.login).await {
3013                Ok(true) => match newest {
3014                    None => newest = Some(c),
3015                    Some(prev) => {
3016                        if c.created_at > prev.created_at {
3017                            newest = Some(c);
3018                        }
3019                    }
3020                },
3021                Ok(false) => {
3022                    tracing::warn!(
3023                        user = %c.user.login,
3024                        pr_number,
3025                        "ignoring automated review comment from non-collaborator"
3026                    );
3027                    continue;
3028                }
3029                Err(e) => {
3030                    tracing::debug!(
3031                        user = %c.user.login,
3032                        error = %e,
3033                        "failed to check collaborator status, skipping comment"
3034                    );
3035                    continue;
3036                }
3037            }
3038        }
3039
3040        match newest {
3041            Some(c) => {
3042                let first_line = c.body.lines().next().unwrap_or("");
3043                if first_line.contains("Automated Review \u{2014} Approve") {
3044                    Ok(Some("approve".to_string()))
3045                } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3046                    Ok(Some("changes_requested".to_string()))
3047                } else {
3048                    Ok(None)
3049                }
3050            }
3051            None => Ok(None),
3052        }
3053    }
3054}
3055
3056fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3057    let mut valid = Vec::with_capacity(issue_numbers.len());
3058    let mut invalid = Vec::new();
3059    for &issue_number in issue_numbers {
3060        if issue_number == 0 {
3061            invalid.push(issue_number);
3062        } else {
3063            valid.push(issue_number);
3064        }
3065    }
3066    (valid, invalid)
3067}
3068
3069// ── Public metrics ───────────────────────────────────────────────────
3070
3071/// Snapshot of GitHub API rate-limit metrics.
3072#[derive(Debug, Clone)]
3073pub struct RateLimitMetrics {
3074    /// Total 403/429 rate-limit responses received (REST + GraphQL combined).
3075    pub hits: u64,
3076    /// Times proactive throttling slept before sending a request.
3077    pub proactive_throttles: u64,
3078    /// Total seconds spent waiting due to proactive throttle or hard backoff.
3079    pub wait_secs: u64,
3080    /// Current REST remaining quota (None if not yet observed).
3081    pub rest_remaining: Option<u32>,
3082    /// Current GraphQL remaining quota (None if not yet observed).
3083    pub graphql_remaining: Option<u32>,
3084    /// Total 5xx server error responses received (REST + GraphQL combined).
3085    pub five_xx_hits: u64,
3086}
3087
3088/// Return a snapshot of GitHub API rate-limit metrics.
3089pub fn rate_limit_metrics() -> RateLimitMetrics {
3090    let rest_remaining = REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3091    let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3092    RateLimitMetrics {
3093        hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3094        proactive_throttles: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3095        wait_secs: METRIC_WAIT_SECS_TOTAL.load(Ordering::Relaxed),
3096        rest_remaining,
3097        graphql_remaining,
3098        five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3099    }
3100}
3101
3102// ── Link header parser ───────────────────────────────────────────────
3103
3104/// Parse the `Link` header to find the `rel="next"` URL.
3105fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3106    let link = headers.get("link")?.to_str().ok()?;
3107    for part in link.split(',') {
3108        let part = part.trim();
3109        if part.contains("rel=\"next\"") {
3110            // Extract URL between < and >
3111            let start = part.find('<')? + 1;
3112            let end = part.find('>')?;
3113            if start >= end {
3114                return None;
3115            }
3116            return Some(part[start..end].to_string());
3117        }
3118    }
3119    None
3120}
3121
3122/// Return the hex color string for a given `status:*` label.
3123pub fn status_label_color(label: &str) -> &'static str {
3124    match label {
3125        "status:new" => "0e8a16",
3126        "status:routed" => "1d76db",
3127        "status:in_progress" => "fbca04",
3128        "status:done" => "6f42c1",
3129        "status:blocked" => "d73a4a",
3130        "status:in_review" => "0075ca",
3131        "status:needs_review" => "e4e669",
3132        "status:waiting" => "5319e7",
3133        _ => "c5def5",
3134    }
3135}
3136
3137// ── Batch GraphQL helpers ─────────────────────────────────────────────
3138
3139/// Data fetched for one PR by [`GhHttp::batch_fetch_pr_review_data`].
3140#[derive(Debug, Clone, Default)]
3141pub struct PrReviewBatchData {
3142    /// True when the pull request was merged.
3143    pub merged: bool,
3144    /// `Some(true)` = MERGEABLE, `Some(false)` = CONFLICTING, `None` = UNKNOWN.
3145    pub mergeable: Option<bool>,
3146    /// All reviews submitted on the PR.
3147    pub reviews: Vec<GitHubReview>,
3148    /// All inline review comments (from all review threads).
3149    pub review_comments: Vec<GitHubReviewComment>,
3150    /// All issue-level comments on the PR.
3151    pub issue_comments: Vec<GitHubComment>,
3152}
3153
3154/// Build a GraphQL alias fragment for one pull request, fetching all data
3155/// needed by the review poll loop in a single batch request.
3156fn build_pr_review_alias(n: u64) -> String {
3157    format!(
3158        r#"pr{n}: pullRequest(number: {n}) {{
3159  merged
3160  mergeable
3161  reviews(first: 50) {{
3162    nodes {{
3163      databaseId
3164      author {{ login }}
3165      body
3166      state
3167      url
3168      submittedAt
3169      commit {{ oid }}
3170    }}
3171    pageInfo {{ hasNextPage }}
3172  }}
3173  reviewThreads(first: 50) {{
3174    nodes {{
3175      comments(first: 20) {{
3176        nodes {{
3177          databaseId
3178          author {{ login }}
3179          body
3180          path
3181          line
3182          originalLine
3183          commit {{ oid }}
3184          originalCommit {{ oid }}
3185          url
3186          createdAt
3187          updatedAt
3188          replyTo {{ databaseId }}
3189          diffHunk
3190        }}
3191        pageInfo {{ hasNextPage }}
3192      }}
3193    }}
3194    pageInfo {{ hasNextPage }}
3195  }}
3196  comments(first: 50) {{
3197    nodes {{
3198      databaseId
3199      author {{ login }}
3200      body
3201      createdAt
3202      updatedAt
3203      url
3204    }}
3205    pageInfo {{ hasNextPage endCursor }}
3206  }}
3207}}"#
3208    )
3209}
3210
3211fn build_pr_issue_comments_page_query(
3212    owner: &str,
3213    name: &str,
3214    pr_number: u64,
3215    after_cursor: &str,
3216) -> (String, serde_json::Value) {
3217    let query = r#"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3218  repository(owner: $owner, name: $name) {
3219    pullRequest(number: $number) {
3220      comments(first: 100, after: $after) {
3221        nodes {
3222          databaseId
3223          author { login }
3224          body
3225          createdAt
3226          updatedAt
3227          url
3228        }
3229        pageInfo { hasNextPage endCursor }
3230      }
3231    }
3232  }
3233}"#
3234    .to_string();
3235    let vars = serde_json::json!({
3236        "owner": owner,
3237        "name": name,
3238        "number": pr_number,
3239        "after": after_cursor,
3240    });
3241    (query, vars)
3242}
3243
3244fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3245    page_info
3246        .and_then(|v| v.get("hasNextPage"))
3247        .and_then(|v| v.as_bool())
3248        .unwrap_or(false)
3249}
3250
3251fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3252    if !connection_has_next_page(page_info) {
3253        return None;
3254    }
3255
3256    page_info
3257        .and_then(|v| v.get("endCursor"))
3258        .and_then(|v| v.as_str())
3259        .map(str::to_owned)
3260}
3261
3262fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3263    use super::types::GitHubUser;
3264    Some(GitHubReview {
3265        id: node.get("databaseId").and_then(|v| v.as_u64())?,
3266        user: GitHubUser {
3267            login: node
3268                .pointer("/author/login")
3269                .and_then(|v| v.as_str())?
3270                .to_string(),
3271        },
3272        body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3273        state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3274        html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3275        submitted_at: node
3276            .get("submittedAt")
3277            .and_then(|v| v.as_str())
3278            .unwrap_or("")
3279            .to_string(),
3280        commit_id: node
3281            .pointer("/commit/oid")
3282            .and_then(|v| v.as_str())
3283            .map(String::from),
3284    })
3285}
3286
3287fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3288    use super::types::GitHubUser;
3289    Some(GitHubReviewComment {
3290        id: node.get("databaseId").and_then(|v| v.as_u64())?,
3291        user: GitHubUser {
3292            login: node
3293                .pointer("/author/login")
3294                .and_then(|v| v.as_str())?
3295                .to_string(),
3296        },
3297        body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3298        path: node
3299            .get("path")
3300            .and_then(|v| v.as_str())
3301            .unwrap_or("")
3302            .to_string(),
3303        line: node.get("line").and_then(|v| v.as_u64()).map(|v| v as u32),
3304        original_line: node
3305            .get("originalLine")
3306            .and_then(|v| v.as_u64())
3307            .map(|v| v as u32),
3308        commit_id: node
3309            .pointer("/commit/oid")
3310            .and_then(|v| v.as_str())
3311            .unwrap_or("")
3312            .to_string(),
3313        original_commit_id: node
3314            .pointer("/originalCommit/oid")
3315            .and_then(|v| v.as_str())
3316            .unwrap_or("")
3317            .to_string(),
3318        html_url: node
3319            .get("url")
3320            .and_then(|v| v.as_str())
3321            .unwrap_or("")
3322            .to_string(),
3323        created_at: node
3324            .get("createdAt")
3325            .and_then(|v| v.as_str())
3326            .unwrap_or("")
3327            .to_string(),
3328        updated_at: node
3329            .get("updatedAt")
3330            .and_then(|v| v.as_str())
3331            .unwrap_or("")
3332            .to_string(),
3333        in_reply_to_id: node.pointer("/replyTo/databaseId").and_then(|v| v.as_u64()),
3334        diff_hunk: node
3335            .get("diffHunk")
3336            .and_then(|v| v.as_str())
3337            .map(String::from),
3338    })
3339}
3340
3341fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3342    use super::types::GitHubUser;
3343    Some(GitHubComment {
3344        id: node.get("databaseId").and_then(|v| v.as_u64())?,
3345        body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3346        user: GitHubUser {
3347            login: node
3348                .pointer("/author/login")
3349                .and_then(|v| v.as_str())?
3350                .to_string(),
3351        },
3352        created_at: node
3353            .get("createdAt")
3354            .and_then(|v| v.as_str())
3355            .unwrap_or("")
3356            .to_string(),
3357        updated_at: node
3358            .get("updatedAt")
3359            .and_then(|v| v.as_str())
3360            .map(String::from),
3361        html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3362        issue_url: None,
3363        author_association: None,
3364    })
3365}
3366
3367#[cfg(test)]
3368mod tests {
3369    use super::*;
3370    use tokio::io::{AsyncReadExt, AsyncWriteExt};
3371    use tokio::net::TcpListener;
3372
3373    async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3374        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3375        let addr = listener.local_addr().unwrap();
3376
3377        tokio::spawn(async move {
3378            let (mut socket, _) = listener.accept().await.unwrap();
3379            let mut buf = [0u8; 4096];
3380            let _ = socket.read(&mut buf).await;
3381
3382            let declared_len = body_prefix.len() + 32;
3383            let response = format!(
3384                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
3385            );
3386            socket.write_all(response.as_bytes()).await.unwrap();
3387            socket.shutdown().await.unwrap();
3388        });
3389
3390        format!("http://{addr}")
3391    }
3392
3393    fn test_client() -> GhHttp {
3394        GhHttp {
3395            client: reqwest::Client::new(),
3396            token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3397            cached_username: OnceLock::new(),
3398        }
3399    }
3400
3401    /// Verify `GhHttp::new()` returns `Ok` and does not panic on construction.
3402    ///
3403    /// This test guards against regressions where `GhHttp::new()` might panic
3404    /// (e.g., if TLS initialisation fails or the builder API changes).
3405    #[test]
3406    fn gh_http_new_returns_ok() {
3407        let result = GhHttp::new();
3408        assert!(
3409            result.is_ok(),
3410            "GhHttp::new() must not panic and must return Ok; got: {:?}",
3411            result.err()
3412        );
3413    }
3414
3415    #[test]
3416    fn batch_get_issue_states_query_includes_labels_pagination() {
3417        let issue_numbers = [1u64, 2, 3];
3418
3419        let aliases: String = issue_numbers
3420            .iter()
3421            .map(|n| {
3422                format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
3423            })
3424            .collect::<Vec<_>>()
3425            .join(" ");
3426
3427        let query = format!(
3428            r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
3429        );
3430        assert!(query.contains("$owner"));
3431        assert!(query.contains("$name"));
3432        assert!(query.contains("issue1: issue(number: 1)"));
3433        assert!(query.contains("issue3: issue(number: 3)"));
3434        assert!(query.contains("labels(first: 100)"));
3435    }
3436
3437    #[test]
3438    fn parse_positive_issue_number_accepts_positive_values() {
3439        assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
3440    }
3441
3442    #[test]
3443    fn parse_positive_issue_number_rejects_zero() {
3444        let err = parse_positive_issue_number("0").unwrap_err().to_string();
3445        assert!(err.contains("invalid issue number: 0"));
3446    }
3447
3448    #[test]
3449    fn parse_positive_issue_number_rejects_internal_ids() {
3450        let err = parse_positive_issue_number("internal:5")
3451            .unwrap_err()
3452            .to_string();
3453        assert!(err.contains("invalid issue number: internal:5"));
3454    }
3455
3456    #[test]
3457    fn pr_review_batch_query_includes_page_info_for_nested_connections() {
3458        let query = build_pr_review_alias(42);
3459
3460        assert!(query.contains("reviews(first: 50)"));
3461        assert!(query.contains("reviews(first: 50) {\n    nodes"));
3462        assert!(query.contains("pageInfo { hasNextPage }"));
3463        assert!(query.contains("reviewThreads(first: 50)"));
3464        assert!(query.contains("comments(first: 20)"));
3465        assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3466        assert!(query.contains("updatedAt"));
3467    }
3468
3469    #[test]
3470    fn pr_issue_comments_page_query_uses_cursor_pagination() {
3471        let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
3472
3473        assert!(query.contains("pullRequest(number: $number)"));
3474        assert!(query.contains("comments(first: 100, after: $after)"));
3475        assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3476        assert!(query.contains("updatedAt"));
3477        assert_eq!(vars["owner"], "owner");
3478        assert_eq!(vars["name"], "repo");
3479        assert_eq!(vars["number"], 42);
3480        assert_eq!(vars["after"], "cursor123");
3481    }
3482
3483    #[test]
3484    fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
3485        let page_info = serde_json::json!({
3486            "hasNextPage": true,
3487            "endCursor": "cursor123"
3488        });
3489        assert_eq!(
3490            connection_end_cursor(Some(&page_info)),
3491            Some("cursor123".to_string())
3492        );
3493
3494        let final_page = serde_json::json!({
3495            "hasNextPage": false,
3496            "endCursor": "cursor456"
3497        });
3498        assert_eq!(connection_end_cursor(Some(&final_page)), None);
3499    }
3500
3501    #[test]
3502    fn status_label_colors_match_bash_palette() {
3503        assert_eq!(status_label_color("status:new"), "0e8a16");
3504        assert_eq!(status_label_color("status:routed"), "1d76db");
3505        assert_eq!(status_label_color("status:in_progress"), "fbca04");
3506        assert_eq!(status_label_color("status:done"), "6f42c1");
3507        assert_eq!(status_label_color("status:blocked"), "d73a4a");
3508        assert_eq!(status_label_color("status:in_review"), "0075ca");
3509        assert_eq!(status_label_color("status:needs_review"), "e4e669");
3510    }
3511
3512    #[test]
3513    fn unknown_label_gets_default_color() {
3514        assert_eq!(status_label_color("agent:claude"), "c5def5");
3515        assert_eq!(status_label_color("enhancement"), "c5def5");
3516        assert_eq!(status_label_color(""), "c5def5");
3517    }
3518
3519    #[test]
3520    fn parse_link_next_finds_next_url() {
3521        let mut headers = header::HeaderMap::new();
3522        headers.insert(
3523            "link",
3524            "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
3525                .parse()
3526                .unwrap(),
3527        );
3528        assert_eq!(
3529            parse_link_next(&headers),
3530            Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
3531        );
3532    }
3533
3534    #[test]
3535    fn parse_link_next_none_when_no_next() {
3536        let mut headers = header::HeaderMap::new();
3537        headers.insert(
3538            "link",
3539            "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
3540                .parse()
3541                .unwrap(),
3542        );
3543        assert_eq!(parse_link_next(&headers), None);
3544    }
3545
3546    #[test]
3547    fn parse_link_next_none_when_missing() {
3548        let headers = header::HeaderMap::new();
3549        assert_eq!(parse_link_next(&headers), None);
3550    }
3551
3552    #[test]
3553    fn parse_link_next_none_when_malformed() {
3554        let mut headers = header::HeaderMap::new();
3555        // Malformed: no angle-bracket URL, just garbage
3556        headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
3557        assert_eq!(parse_link_next(&headers), None);
3558    }
3559
3560    #[test]
3561    fn parse_link_next_none_when_gt_before_lt() {
3562        let mut headers = header::HeaderMap::new();
3563        headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
3564        assert_eq!(parse_link_next(&headers), None); // should not panic
3565    }
3566
3567    #[test]
3568    fn parse_link_next_none_when_empty_url() {
3569        let mut headers = header::HeaderMap::new();
3570        headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
3571        assert_eq!(parse_link_next(&headers), None);
3572    }
3573
3574    fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
3575        RateLimit {
3576            remaining: None,
3577            reset_at: None,
3578            backoff_until: None,
3579            backoff_delay: Duration::ZERO,
3580            backoff_base: Duration::from_secs(base_secs),
3581            backoff_max: Duration::from_secs(max_secs),
3582            throttle_threshold: 10,
3583        }
3584    }
3585
3586    #[test]
3587    fn rate_limit_inactive_by_default() {
3588        let rl = make_rl(30, 900);
3589        assert!(rl.is_active().is_none());
3590    }
3591
3592    #[test]
3593    fn rate_limit_backoff_activates() {
3594        let mut rl = make_rl(5, 60);
3595        rl.record_rate_limit();
3596        assert!(rl.is_active().is_some());
3597        assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3598    }
3599
3600    #[test]
3601    fn rate_limit_backoff_doubles() {
3602        let mut rl = make_rl(5, 60);
3603        rl.record_rate_limit();
3604        assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3605        rl.record_rate_limit();
3606        assert_eq!(rl.backoff_delay, Duration::from_secs(10));
3607        rl.record_rate_limit();
3608        assert_eq!(rl.backoff_delay, Duration::from_secs(20));
3609    }
3610
3611    #[test]
3612    fn rate_limit_backoff_caps_at_max() {
3613        let mut rl = make_rl(30, 60);
3614        rl.record_rate_limit(); // 30
3615        rl.record_rate_limit(); // 60
3616        rl.record_rate_limit(); // capped at 60
3617        assert_eq!(rl.backoff_delay, Duration::from_secs(60));
3618    }
3619
3620    #[test]
3621    fn rate_limit_success_resets() {
3622        let mut rl = make_rl(5, 60);
3623        rl.record_rate_limit();
3624        assert!(rl.is_active().is_some());
3625        rl.record_success();
3626        assert!(rl.is_active().is_none());
3627        assert_eq!(rl.backoff_delay, Duration::ZERO);
3628    }
3629
3630    #[test]
3631    fn rate_limit_proactive_pause() {
3632        let mut rl = make_rl(30, 900);
3633        // Simulate: 0 remaining (below default threshold of 10), reset 60s in the future
3634        rl.remaining = Some(0);
3635        let future_epoch = std::time::SystemTime::now()
3636            .duration_since(std::time::UNIX_EPOCH)
3637            .unwrap()
3638            .as_secs()
3639            + 60;
3640        rl.reset_at = Some(future_epoch);
3641        let pause = rl.is_active();
3642        assert!(pause.is_some());
3643        // Should be roughly 60 seconds (within tolerance, +1s buffer added)
3644        assert!(pause.unwrap().as_secs() <= 62);
3645    }
3646
3647    #[test]
3648    fn rate_limit_proactive_pause_threshold() {
3649        let mut rl = make_rl(30, 900);
3650        // remaining=15 is above default threshold of 10 — no proactive wait
3651        rl.remaining = Some(15);
3652        let future_epoch = std::time::SystemTime::now()
3653            .duration_since(std::time::UNIX_EPOCH)
3654            .unwrap()
3655            .as_secs()
3656            + 60;
3657        rl.reset_at = Some(future_epoch);
3658        assert!(rl.is_active().is_none());
3659
3660        // remaining=5 is below threshold — proactive wait activates
3661        rl.remaining = Some(5);
3662        assert!(rl.is_active().is_some());
3663    }
3664
3665    #[test]
3666    fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
3667        // No check runs yet and GitHub hasn't said it's clean → pending (wait for CI to queue)
3668        assert_eq!(
3669            GhHttp::combined_status_state(0, 0, 0, true, None),
3670            "pending"
3671        );
3672        // No workflows configured → no need to wait
3673        assert_eq!(
3674            GhHttp::combined_status_state(0, 0, 0, false, None),
3675            "success"
3676        );
3677        // Failing check → failure regardless
3678        assert_eq!(
3679            GhHttp::combined_status_state(3, 1, 0, true, None),
3680            "failure"
3681        );
3682        // paths-ignore filtered out all workflows: no check runs but GitHub says "clean"
3683        assert_eq!(
3684            GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
3685            "success"
3686        );
3687        // mergeable_state is dirty/blocked — still pending even with has_workflows
3688        assert_eq!(
3689            GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
3690            "pending"
3691        );
3692    }
3693
3694    #[test]
3695    fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
3696        let resp = serde_json::json!({
3697            "data": {},
3698            "errors": [{ "message": "Something went wrong" }],
3699        });
3700
3701        let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
3702            .unwrap_err()
3703            .to_string();
3704        assert!(err_text.contains("missing /data/repository in GraphQL response"));
3705        assert!(err_text.contains("Something went wrong"));
3706    }
3707
3708    #[test]
3709    fn rate_limit_uses_reset_at_for_backoff() {
3710        let mut rl = make_rl(30, 900);
3711        // Set reset_at to 45s in the future
3712        let future_epoch = std::time::SystemTime::now()
3713            .duration_since(std::time::UNIX_EPOCH)
3714            .unwrap()
3715            .as_secs()
3716            + 45;
3717        rl.reset_at = Some(future_epoch);
3718        rl.record_rate_limit();
3719        // Backoff should use reset_at (~46s) not the base (30s)
3720        assert!(rl.backoff_delay.as_secs() >= 45);
3721        assert!(rl.backoff_delay.as_secs() <= 47);
3722    }
3723
3724    #[tokio::test]
3725    async fn send_with_retries_propagates_make_req_error() {
3726        let guard = std::env::var_os("GH_TOKEN");
3727        std::env::set_var("GH_TOKEN", "test_token");
3728        let gh = test_client();
3729        let result = gh
3730            .send_with_retries(
3731                || Err(anyhow::anyhow!("simulated request clone failure")),
3732                false,
3733            )
3734            .await;
3735        assert!(result.is_err());
3736        let err = result.unwrap_err().to_string();
3737        assert_eq!(err, "simulated request clone failure");
3738        match guard {
3739            Some(v) => std::env::set_var("GH_TOKEN", v),
3740            None => std::env::remove_var("GH_TOKEN"),
3741        }
3742    }
3743
3744    /// A transport-level failure (connection refused — GitHub never reached)
3745    /// must exhaust retries without tripping the shared "github:5xx" circuit
3746    /// breaker: that breaker is read by the routing-phase skip and other
3747    /// subsystems as a signal specifically about GitHub's health, which a
3748    /// local connectivity blip says nothing about.
3749    #[tokio::test]
3750    async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
3751        let guard = std::env::var_os("GH_TOKEN");
3752        std::env::set_var("GH_TOKEN", "test_token");
3753
3754        // Bind then immediately drop a listener to get a port nothing is
3755        // listening on, so connecting reliably fails with "connection refused"
3756        // rather than depending on an external unreachable host.
3757        let addr = {
3758            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3759            listener.local_addr().unwrap()
3760        };
3761        let url = format!("http://{addr}/");
3762
3763        let gh = test_client();
3764        let make_req = || Ok(gh.client.get(&url));
3765        let result = gh.send_with_retries(make_req, false).await;
3766
3767        assert!(result.is_err());
3768        assert!(
3769            !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
3770            "transport error must not trip the github:5xx circuit breaker"
3771        );
3772        assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
3773
3774        match guard {
3775            Some(v) => std::env::set_var("GH_TOKEN", v),
3776            None => std::env::remove_var("GH_TOKEN"),
3777        }
3778    }
3779
3780    #[tokio::test]
3781    async fn post_json_raw_propagates_body_read_errors_on_success() {
3782        let guard = std::env::var_os("GH_TOKEN");
3783        std::env::set_var("GH_TOKEN", "test_token");
3784        let server_url = spawn_truncated_body_server("{").await;
3785        let gh = test_client();
3786
3787        let result = gh
3788            .post_json_raw(
3789                &format!("{server_url}/post"),
3790                &serde_json::json!({"ok": true}),
3791            )
3792            .await;
3793
3794        let err = result.unwrap_err().to_string();
3795        assert!(
3796            !err.contains("EOF while parsing a value"),
3797            "should surface the body read error, got: {err}"
3798        );
3799        assert!(
3800            err.contains("error decoding response body")
3801                || err.contains("request or response body error")
3802                || err.contains("end of file before message length reached"),
3803            "expected a transport/body read error, got: {err}"
3804        );
3805
3806        match guard {
3807            Some(v) => std::env::set_var("GH_TOKEN", v),
3808            None => std::env::remove_var("GH_TOKEN"),
3809        }
3810    }
3811
3812    #[tokio::test]
3813    async fn patch_json_raw_propagates_body_read_errors_on_success() {
3814        let guard = std::env::var_os("GH_TOKEN");
3815        std::env::set_var("GH_TOKEN", "test_token");
3816        let server_url = spawn_truncated_body_server("{").await;
3817        let gh = test_client();
3818
3819        let result = gh
3820            .patch_json_raw(
3821                &format!("{server_url}/patch"),
3822                &serde_json::json!({"ok": true}),
3823            )
3824            .await;
3825
3826        let err = result.unwrap_err().to_string();
3827        assert!(
3828            !err.contains("EOF while parsing a value"),
3829            "should surface the body read error, got: {err}"
3830        );
3831        assert!(
3832            err.contains("error decoding response body")
3833                || err.contains("request or response body error")
3834                || err.contains("end of file before message length reached"),
3835            "expected a transport/body read error, got: {err}"
3836        );
3837
3838        match guard {
3839            Some(v) => std::env::set_var("GH_TOKEN", v),
3840            None => std::env::remove_var("GH_TOKEN"),
3841        }
3842    }
3843
3844    #[tokio::test]
3845    async fn graphql_request_propagates_body_read_errors_on_success() {
3846        let guard = std::env::var_os("GH_TOKEN");
3847        std::env::set_var("GH_TOKEN", "test_token");
3848        let server_url = spawn_truncated_body_server("{").await;
3849        let gh = test_client();
3850        let query = "query { viewer { login } }";
3851
3852        let result = gh
3853            .graphql_request_to_url(
3854                &format!("{server_url}/graphql"),
3855                query,
3856                &[],
3857                Some(serde_json::json!({"k": "v"})),
3858            )
3859            .await;
3860
3861        let err = result.unwrap_err().to_string();
3862        assert!(
3863            !err.contains("EOF while parsing a value"),
3864            "should surface the body read error, got: {err}"
3865        );
3866        assert!(
3867            err.contains("error decoding response body")
3868                || err.contains("request or response body error")
3869                || err.contains("end of file before message length reached"),
3870            "expected a transport/body read error, got: {err}"
3871        );
3872
3873        match guard {
3874            Some(v) => std::env::set_var("GH_TOKEN", v),
3875            None => std::env::remove_var("GH_TOKEN"),
3876        }
3877    }
3878
3879    // ── get_all_pages integration tests (wiremock) ────────────────────────
3880
3881    #[cfg(test)]
3882    mod pagination {
3883        use super::super::*;
3884        use std::ffi::OsString;
3885        use std::sync::Arc;
3886        use wiremock::matchers::{method, path};
3887        use wiremock::{Mock, MockServer, ResponseTemplate};
3888
3889        struct EnvVarGuard {
3890            key: &'static str,
3891            previous: Option<OsString>,
3892        }
3893
3894        impl EnvVarGuard {
3895            fn set(key: &'static str, value: &str) -> Self {
3896                let previous = std::env::var_os(key);
3897                std::env::set_var(key, value);
3898                Self { key, previous }
3899            }
3900        }
3901
3902        impl Drop for EnvVarGuard {
3903            fn drop(&mut self) {
3904                match self.previous.take() {
3905                    Some(value) => std::env::set_var(self.key, value),
3906                    None => std::env::remove_var(self.key),
3907                }
3908            }
3909        }
3910
3911        /// Build a minimal GhHttp pointing at a wiremock server.
3912        /// The token resolver reads GH_TOKEN; we set it to a dummy value.
3913        fn make_client() -> (GhHttp, EnvVarGuard) {
3914            // Ensure a token is available so auth_header() doesn't bail.
3915            let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
3916            let client = GhHttp {
3917                client: reqwest::Client::new(),
3918                token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3919                cached_username: OnceLock::new(),
3920            };
3921            (client, guard)
3922        }
3923
3924        #[tokio::test]
3925        async fn get_all_pages_single_page() {
3926            let server = MockServer::start().await;
3927
3928            Mock::given(method("GET"))
3929                .and(path("/items"))
3930                .respond_with(
3931                    ResponseTemplate::new(200)
3932                        .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
3933                )
3934                .expect(1)
3935                .mount(&server)
3936                .await;
3937
3938            let (gh, _guard) = make_client();
3939            let url = format!("{}/items", server.uri());
3940            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3941
3942            assert_eq!(result.len(), 2);
3943            assert_eq!(result[0]["id"], 1);
3944            assert_eq!(result[1]["id"], 2);
3945        }
3946
3947        #[tokio::test]
3948        async fn get_all_pages_follows_link_next() {
3949            let server = MockServer::start().await;
3950
3951            // Page 1 — includes Link header pointing to page 2
3952            Mock::given(method("GET"))
3953                .and(path("/items"))
3954                .respond_with(
3955                    ResponseTemplate::new(200)
3956                        .set_body_json(serde_json::json!([{"id": 1}]))
3957                        .insert_header(
3958                            "Link",
3959                            format!("<{}/items/page2>; rel=\"next\"", server.uri()),
3960                        ),
3961                )
3962                .expect(1)
3963                .mount(&server)
3964                .await;
3965
3966            // Page 2 — no Link header (last page)
3967            Mock::given(method("GET"))
3968                .and(path("/items/page2"))
3969                .respond_with(
3970                    ResponseTemplate::new(200)
3971                        .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
3972                )
3973                .expect(1)
3974                .mount(&server)
3975                .await;
3976
3977            let (gh, _guard) = make_client();
3978            let url = format!("{}/items", server.uri());
3979            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3980
3981            assert_eq!(result.len(), 3);
3982            assert_eq!(result[0]["id"], 1);
3983            assert_eq!(result[1]["id"], 2);
3984            assert_eq!(result[2]["id"], 3);
3985        }
3986
3987        #[tokio::test]
3988        async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
3989            // Simulates a proxy/transient response that drops the Link header mid-pagination.
3990            // The previous `.unwrap()` would panic here; the fix breaks the loop instead.
3991            let server = MockServer::start().await;
3992
3993            Mock::given(method("GET"))
3994                .and(path("/items"))
3995                .respond_with(
3996                    ResponseTemplate::new(200)
3997                        .set_body_json(serde_json::json!([{"id": 1}]))
3998                        .insert_header(
3999                            "Link",
4000                            format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4001                        ),
4002                )
4003                .expect(1)
4004                .mount(&server)
4005                .await;
4006
4007            // Page 2 response has NO Link header — pagination should stop cleanly.
4008            Mock::given(method("GET"))
4009                .and(path("/items/page2"))
4010                .respond_with(
4011                    ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4012                )
4013                .expect(1)
4014                .mount(&server)
4015                .await;
4016
4017            let (gh, _guard) = make_client();
4018            let url = format!("{}/items", server.uri());
4019            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4020
4021            // Must not panic and must return both items collected so far.
4022            assert_eq!(result.len(), 2);
4023        }
4024
4025        #[tokio::test]
4026        async fn get_all_pages_stops_on_malformed_link_header() {
4027            // A malformed Link header (no angle-bracket URL) yields None from parse_link_next,
4028            // which should stop pagination rather than panic.
4029            let server = MockServer::start().await;
4030
4031            Mock::given(method("GET"))
4032                .and(path("/items"))
4033                .respond_with(
4034                    ResponseTemplate::new(200)
4035                        .set_body_json(serde_json::json!([{"id": 1}]))
4036                        .insert_header("Link", "garbage; rel=\"next\""),
4037                )
4038                .expect(1)
4039                .mount(&server)
4040                .await;
4041
4042            let (gh, _guard) = make_client();
4043            let url = format!("{}/items", server.uri());
4044            let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4045
4046            // Malformed Link header → pagination stops after first page, no panic.
4047            assert_eq!(result.len(), 1);
4048        }
4049
4050        #[tokio::test]
4051        async fn get_all_pages_returns_error_on_malformed_json_body() {
4052            let server = MockServer::start().await;
4053
4054            Mock::given(method("GET"))
4055                .and(path("/items"))
4056                .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4057                .expect(3)
4058                .mount(&server)
4059                .await;
4060
4061            let (gh, _guard) = make_client();
4062            let url = format!("{}/items", server.uri());
4063            let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4064
4065            assert!(result.is_err());
4066            let err = result.unwrap_err().to_string();
4067            assert!(
4068                err.contains("JSON decode failed") || err.contains("expected ident"),
4069                "error should mention JSON decode or serde error: {}",
4070                err
4071            );
4072        }
4073
4074        // ── get_all_pages_from_wrapper tests ─────────────────────────────────
4075
4076        #[tokio::test]
4077        async fn get_all_pages_from_wrapper_single_page() {
4078            // Simulates GitHub's check-runs response: a wrapper object with the
4079            // array nested under a named field.
4080            let server = MockServer::start().await;
4081
4082            Mock::given(method("GET"))
4083                .and(path("/check-runs"))
4084                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4085                    "total_count": 2,
4086                    "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4087                })))
4088                .expect(1)
4089                .mount(&server)
4090                .await;
4091
4092            let (gh, _guard) = make_client();
4093            let url = format!("{}/check-runs", server.uri());
4094            let result: Vec<serde_json::Value> = gh
4095                .get_all_pages_from_wrapper(&url, "check_runs")
4096                .await
4097                .unwrap();
4098
4099            assert_eq!(result.len(), 2);
4100            assert_eq!(result[0]["id"], 1);
4101            assert_eq!(result[1]["id"], 2);
4102        }
4103
4104        #[tokio::test]
4105        async fn get_all_pages_from_wrapper_follows_link_next() {
4106            // Two-page check-runs response (common for repos with >100 distinct check names).
4107            let server = MockServer::start().await;
4108
4109            Mock::given(method("GET"))
4110                .and(path("/check-runs"))
4111                .respond_with(
4112                    ResponseTemplate::new(200)
4113                        .set_body_json(serde_json::json!({
4114                            "total_count": 3,
4115                            "check_runs": [{"id": 1, "name": "build"}]
4116                        }))
4117                        .insert_header(
4118                            "Link",
4119                            format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4120                        ),
4121                )
4122                .expect(1)
4123                .mount(&server)
4124                .await;
4125
4126            Mock::given(method("GET"))
4127                .and(path("/check-runs/page2"))
4128                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4129                    "total_count": 3,
4130                    "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4131                })))
4132                .expect(1)
4133                .mount(&server)
4134                .await;
4135
4136            let (gh, _guard) = make_client();
4137            let url = format!("{}/check-runs", server.uri());
4138            let result: Vec<serde_json::Value> = gh
4139                .get_all_pages_from_wrapper(&url, "check_runs")
4140                .await
4141                .unwrap();
4142
4143            assert_eq!(result.len(), 3);
4144            assert_eq!(result[0]["id"], 1);
4145            assert_eq!(result[1]["id"], 2);
4146            assert_eq!(result[2]["id"], 3);
4147        }
4148
4149        #[tokio::test]
4150        async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4151            // When the array field is missing or empty, the function should return
4152            // an empty vec rather than erroring.
4153            let server = MockServer::start().await;
4154
4155            Mock::given(method("GET"))
4156                .and(path("/check-runs"))
4157                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4158                    "total_count": 0,
4159                    "check_runs": []
4160                })))
4161                .expect(1)
4162                .mount(&server)
4163                .await;
4164
4165            let (gh, _guard) = make_client();
4166            let url = format!("{}/check-runs", server.uri());
4167            let result: Vec<serde_json::Value> = gh
4168                .get_all_pages_from_wrapper(&url, "check_runs")
4169                .await
4170                .unwrap();
4171
4172            assert_eq!(result.len(), 0);
4173        }
4174
4175        #[tokio::test]
4176        async fn get_all_pages_from_wrapper_error_on_non_success_status() {
4177            let server = MockServer::start().await;
4178
4179            Mock::given(method("GET"))
4180                .and(path("/check-runs"))
4181                .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
4182                .expect(1)
4183                .mount(&server)
4184                .await;
4185
4186            let (gh, _guard) = make_client();
4187            let url = format!("{}/check-runs", server.uri());
4188            let result = gh
4189                .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
4190                .await;
4191
4192            assert!(result.is_err());
4193            let err = result.unwrap_err().to_string();
4194            assert!(
4195                err.contains("422") || err.contains("failed"),
4196                "error should indicate failure: {err}"
4197            );
4198        }
4199    }
4200
4201    // ── 401 token-refresh tests (wiremock) ────────────────────────────────
4202
4203    #[cfg(test)]
4204    mod auth_retry {
4205        use super::test_client;
4206        use std::sync::atomic::{AtomicUsize, Ordering};
4207        use std::sync::Arc;
4208        use wiremock::matchers::method;
4209        use wiremock::{Mock, MockServer, Request, ResponseTemplate};
4210
4211        fn bad_credentials() -> ResponseTemplate {
4212            ResponseTemplate::new(401).set_body_json(serde_json::json!({
4213                "message": "Bad credentials",
4214                "status": 401
4215            }))
4216        }
4217
4218        #[allow(clippy::await_holding_lock)]
4219        #[tokio::test]
4220        async fn retries_once_with_fresh_token_after_401() {
4221            let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4222            let prev = std::env::var_os("GH_TOKEN");
4223            std::env::set_var("GH_TOKEN", "bad_token");
4224
4225            let server = MockServer::start().await;
4226            let hits = Arc::new(AtomicUsize::new(0));
4227            let hits_in_handler = hits.clone();
4228            Mock::given(method("GET"))
4229                .respond_with(move |_req: &Request| {
4230                    if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
4231                        // The 401 handler clears the cache and re-resolves; simulate the
4232                        // operator having fixed the env var in the meantime
4233                        std::env::set_var("GH_TOKEN", "good_token");
4234                        bad_credentials()
4235                    } else {
4236                        ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
4237                    }
4238                })
4239                .mount(&server)
4240                .await;
4241
4242            let gh = test_client();
4243            let result: anyhow::Result<serde_json::Value> =
4244                gh.get_json(&format!("{}/x", server.uri())).await;
4245
4246            match prev {
4247                Some(v) => std::env::set_var("GH_TOKEN", v),
4248                None => std::env::remove_var("GH_TOKEN"),
4249            }
4250
4251            assert!(
4252                result.is_ok(),
4253                "retry with re-resolved token should succeed: {:?}",
4254                result.err()
4255            );
4256            assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
4257        }
4258
4259        #[allow(clippy::await_holding_lock)]
4260        #[tokio::test]
4261        async fn double_401_bails_with_distinct_auth_error() {
4262            let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4263            let prev = std::env::var_os("GH_TOKEN");
4264            std::env::set_var("GH_TOKEN", "bad_token");
4265
4266            let server = MockServer::start().await;
4267            let hits = Arc::new(AtomicUsize::new(0));
4268            let hits_in_handler = hits.clone();
4269            Mock::given(method("GET"))
4270                .respond_with(move |_req: &Request| {
4271                    hits_in_handler.fetch_add(1, Ordering::SeqCst);
4272                    bad_credentials()
4273                })
4274                .mount(&server)
4275                .await;
4276
4277            let gh = test_client();
4278            let result: anyhow::Result<serde_json::Value> =
4279                gh.get_json(&format!("{}/x", server.uri())).await;
4280
4281            match prev {
4282                Some(v) => std::env::set_var("GH_TOKEN", v),
4283                None => std::env::remove_var("GH_TOKEN"),
4284            }
4285
4286            let err = result.expect_err("persisting 401 must fail").to_string();
4287            assert!(
4288                err.contains("token re-resolved after cache clear but still rejected"),
4289                "expected distinct auth-failure error, got: {err}"
4290            );
4291            assert_eq!(
4292                hits.load(Ordering::SeqCst),
4293                2,
4294                "must retry exactly once, not loop"
4295            );
4296        }
4297    }
4298}