1use super::token;
12use super::types::{
13 GitHubAnnotation, GitHubCheckRun, GitHubComment, GitHubIssue, GitHubPullRequest, GitHubReview,
14 GitHubReviewComment,
15};
16use crate::engine::cooldown as engine_cooldown;
17use anyhow::Context;
18use reqwest::{header, Client, Response, StatusCode};
19use serde::Serialize;
20use std::sync::{
21 atomic::{AtomicU64, Ordering},
22 Arc, Mutex, OnceLock,
23};
24use std::time::{Duration, Instant};
25use urlencoding;
26
27#[cfg(not(test))]
28const GITHUB_API: &str = "https://api.github.com";
29#[cfg(test)]
31const GITHUB_API: &str = "http://127.0.0.1:9";
32
33#[derive(Debug, serde::Deserialize)]
34struct BlockedByPayload {
35 #[serde(default)]
36 id: i64,
37 number: i64,
38 #[serde(default)]
39 state: String,
40 #[serde(default)]
41 state_reason: Option<String>,
42 #[serde(default)]
43 repository: Option<BlockedByRepoPayload>,
44}
45
46#[derive(Debug, serde::Deserialize)]
47struct BlockedByRepoPayload {
48 full_name: String,
49}
50
51pub(crate) fn is_full_commit_sha(sha: &str) -> bool {
52 let len = sha.len();
53 (len == 40 || len == 64) && sha.bytes().all(|b| b.is_ascii_hexdigit())
54}
55
56fn merge_pr_body(head_sha: &str) -> serde_json::Value {
57 serde_json::json!({
58 "merge_method": "squash",
59 "sha": head_sha,
60 })
61}
62
63fn parse_positive_issue_number(number: &str) -> anyhow::Result<i64> {
64 let parsed = number
65 .parse::<i64>()
66 .with_context(|| format!("invalid issue number: {number}"))?;
67 if parsed <= 0 {
68 anyhow::bail!("invalid issue number: {number}");
69 }
70 Ok(parsed)
71}
72
73struct RateLimit {
77 remaining: Option<u32>,
79 reset_at: Option<u64>,
81 backoff_until: Option<Instant>,
83 backoff_delay: Duration,
84 backoff_base: Duration,
85 backoff_max: Duration,
86 throttle_threshold: u32,
89}
90
91impl RateLimit {
92 fn new() -> Self {
93 let base = crate::config::get("gh.backoff.base_seconds")
94 .ok()
95 .and_then(|v| v.parse::<u64>().ok())
96 .unwrap_or(30);
97 let max = crate::config::get("gh.backoff.max_seconds")
98 .ok()
99 .and_then(|v| v.parse::<u64>().ok())
100 .unwrap_or(900);
101 let threshold = crate::config::get("gh.rate_limit.throttle_threshold")
102 .ok()
103 .and_then(|v| v.parse::<u32>().ok())
104 .unwrap_or(200);
105
106 Self {
107 remaining: None,
108 reset_at: None,
109 backoff_until: None,
110 backoff_delay: Duration::ZERO,
111 backoff_base: Duration::from_secs(base),
112 backoff_max: Duration::from_secs(max),
113 throttle_threshold: threshold,
114 }
115 }
116
117 fn update_from_headers(&mut self, headers: &header::HeaderMap) {
119 if let Some(v) = headers.get("x-ratelimit-remaining") {
120 self.remaining = v.to_str().ok().and_then(|s| s.parse().ok());
121 }
122 if let Some(v) = headers.get("x-ratelimit-reset") {
123 self.reset_at = v.to_str().ok().and_then(|s| s.parse().ok());
124 }
125 }
126
127 fn record_success(&mut self) {
129 if self.backoff_delay > Duration::ZERO {
130 tracing::info!("GitHub backoff cleared after successful API call");
131 }
132 self.backoff_delay = Duration::ZERO;
133 self.backoff_until = None;
134 }
135
136 fn record_rate_limit(&mut self) {
138 METRIC_RATE_LIMIT_HITS.fetch_add(1, Ordering::Relaxed);
139
140 if let Some(reset_epoch) = self.reset_at {
142 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
143 Ok(since_epoch) => {
144 let now_epoch = since_epoch.as_secs();
145 if reset_epoch > now_epoch {
146 let wait_secs = reset_epoch - now_epoch + 1;
147 self.backoff_delay = Duration::from_secs(wait_secs);
148 self.backoff_until = Some(Instant::now() + self.backoff_delay);
149 tracing::warn!(
150 wait_secs,
151 "GitHub rate limit hit, waiting until reset time"
152 );
153 return;
154 }
155 }
156 Err(e) => {
157 tracing::error!(
158 error = %e,
159 "System clock is before UNIX epoch; ignoring reset_at and falling back to exponential backoff"
160 );
161 }
162 }
163 }
164
165 self.backoff_delay = if self.backoff_delay.is_zero() {
167 self.backoff_base
168 } else {
169 (self.backoff_delay * 2).min(self.backoff_max)
170 };
171 self.backoff_until = Some(Instant::now() + self.backoff_delay);
172 tracing::warn!(
173 delay_secs = self.backoff_delay.as_secs(),
174 "GitHub rate limit hit, backing off (no reset header)"
175 );
176 }
177
178 fn backoff_remaining(&self) -> Option<Duration> {
180 if let Some(until) = self.backoff_until {
181 let now = Instant::now();
182 if now < until {
183 return Some(until - now);
184 }
185 }
186 None
187 }
188
189 fn proactive_wait_duration(&self) -> Option<Duration> {
196 if self.backoff_remaining().is_some() {
198 return None;
199 }
200 if let Some(remaining) = self.remaining {
201 if remaining < self.throttle_threshold {
202 if let Some(reset_epoch) = self.reset_at {
203 let now_epoch = std::time::SystemTime::now()
204 .duration_since(std::time::UNIX_EPOCH)
205 .unwrap_or_default()
206 .as_secs();
207 if now_epoch < reset_epoch {
208 return Some(Duration::from_secs(reset_epoch - now_epoch + 1));
209 }
210 }
211 }
212 }
213 None
214 }
215
216 fn is_active(&self) -> Option<Duration> {
218 self.backoff_remaining()
219 .or_else(|| self.proactive_wait_duration())
220 }
221}
222
223static REST_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
226 std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
227
228static GRAPHQL_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
230 std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
231
232static METRIC_RATE_LIMIT_HITS: AtomicU64 = AtomicU64::new(0);
236static METRIC_PROACTIVE_THROTTLES: AtomicU64 = AtomicU64::new(0);
238static METRIC_5XX_HITS: AtomicU64 = AtomicU64::new(0);
240
241fn check_rate_limit_backoff(cell: &Mutex<RateLimit>, api: &str) -> anyhow::Result<()> {
251 let Some(remaining) = cell.lock().ok().and_then(|rl| rl.is_active()) else {
252 return Ok(());
253 };
254 METRIC_PROACTIVE_THROTTLES.fetch_add(1, Ordering::Relaxed);
255 anyhow::bail!(
256 "GitHub {} API rate-limited, skipping call for {}s",
257 api,
258 remaining.as_secs()
259 );
260}
261
262static CALL_COUNTS: std::sync::LazyLock<Mutex<std::collections::HashMap<(String, String), u64>>> =
264 std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
265
266fn count_api_call(url: &str) {
269 let path = url.split('?').next().unwrap_or(url);
270 let key = match path.split_once("/repos/") {
271 Some((_, rest)) => {
272 let mut parts = rest.split('/');
273 let project = format!(
274 "{}/{}",
275 parts.next().unwrap_or(""),
276 parts.next().unwrap_or("")
277 );
278 (project, parts.next().unwrap_or("repo").to_string())
279 }
280 None if path.ends_with("/graphql") => ("-".to_string(), "graphql".to_string()),
281 None => ("-".to_string(), "other".to_string()),
282 };
283 if let Ok(mut counts) = CALL_COUNTS.lock() {
284 *counts.entry(key).or_insert(0) += 1;
285 }
286}
287
288pub fn drain_call_counts() -> Vec<(String, String, u64)> {
290 let mut rows: Vec<_> = CALL_COUNTS
291 .lock()
292 .map(|mut c| c.drain().map(|((p, k), n)| (p, k, n)).collect())
293 .unwrap_or_default();
294 rows.sort_by_key(|r| std::cmp::Reverse(r.2));
295 rows
296}
297
298struct CachedPage {
302 etag: String,
303 body: String,
304 next: Option<String>,
306}
307
308static PAGE_CACHE: std::sync::LazyLock<Mutex<std::collections::HashMap<String, CachedPage>>> =
316 std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
317
318const PAGE_CACHE_MAX: usize = 64;
321
322fn page_cache_key<T>(url: &str, query: Option<&[(&str, &str)]>) -> Option<String> {
326 let mut key = String::from(std::any::type_name::<T>());
327 key.push(' ');
328 key.push_str(url);
329 if let Some(q) = query {
330 for (i, (k, v)) in q.iter().enumerate() {
331 key.push(if i == 0 { '?' } else { '&' });
332 key.push_str(k);
333 key.push('=');
334 key.push_str(v);
335 }
336 }
337 if key.contains("since=") {
338 return None;
339 }
340 Some(key)
341}
342
343fn page_cache_etag(key: &str) -> Option<String> {
344 PAGE_CACHE.lock().ok()?.get(key).map(|c| c.etag.clone())
345}
346
347fn page_cache_hit(key: &str) -> Option<(String, Option<String>)> {
349 let cache = PAGE_CACHE.lock().ok()?;
350 let entry = cache.get(key)?;
351 Some((entry.body.clone(), entry.next.clone()))
352}
353
354fn page_cache_store(key: String, etag: String, body: String, next: Option<String>) {
355 let Ok(mut cache) = PAGE_CACHE.lock() else {
356 return;
357 };
358 if cache.len() >= PAGE_CACHE_MAX && !cache.contains_key(&key) {
361 cache.clear();
362 }
363 cache.insert(key, CachedPage { etag, body, next });
364}
365
366fn maybe_record_body_rate_limit(cell: &Mutex<RateLimit>, status: StatusCode, body: &str) {
369 if status == StatusCode::FORBIDDEN {
370 let lower = body.to_lowercase();
371 if lower.contains("rate limit")
372 || lower.contains("abuse detection")
373 || lower.contains("secondary rate")
374 {
375 if let Ok(mut rl) = cell.lock() {
376 rl.record_rate_limit();
377 }
378 }
379 }
380}
381
382fn record_response_for_limiter(cell: &Mutex<RateLimit>, resp: &Response) {
384 if let Ok(mut rl) = cell.lock() {
385 rl.update_from_headers(resp.headers());
386 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
389 rl.record_rate_limit();
390 } else if resp.status().is_success() || resp.status() == StatusCode::NOT_MODIFIED {
391 rl.record_success();
392 }
393 }
394 let status = resp.status();
396 if status.is_server_error() {
397 METRIC_5XX_HITS.fetch_add(1, Ordering::Relaxed);
398 if tokio::runtime::Handle::try_current().is_ok() {
399 tokio::spawn(async move {
400 crate::engine::cooldown::record_github_5xx().await;
401 });
402 }
403 }
404}
405
406#[derive(Clone)]
413pub struct GhHttp {
414 client: Client,
415 token_resolver: Arc<token::TokenResolver>,
416 cached_username: OnceLock<String>,
417}
418
419impl GhHttp {
420 pub fn new() -> anyhow::Result<Self> {
428 let client = Client::builder()
429 .user_agent("orch/0.1 (reqwest)")
430 .pool_max_idle_per_host(4)
431 .timeout(Duration::from_secs(30))
432 .build()
433 .map_err(|e| anyhow::anyhow!("failed to build HTTP client (TLS init): {e}"))?;
434
435 Ok(Self {
436 client,
437 token_resolver: token::shared(),
438 cached_username: OnceLock::new(),
439 })
440 }
441
442 pub fn is_rate_limited() -> Option<Duration> {
447 REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.is_active())
448 }
449
450 fn check_backoff(url: &str) -> anyhow::Result<()> {
452 count_api_call(url);
453 check_rate_limit_backoff(&REST_RATE_LIMIT, "REST")
454 }
455
456 fn check_graphql_backoff(url: &str) -> anyhow::Result<()> {
458 count_api_call(url);
459 check_rate_limit_backoff(&GRAPHQL_RATE_LIMIT, "GraphQL")
460 }
461
462 fn record_response(resp: &Response) {
463 record_response_for_limiter(&REST_RATE_LIMIT, resp);
464 }
465
466 fn record_graphql_response(resp: &Response) {
467 record_response_for_limiter(&GRAPHQL_RATE_LIMIT, resp);
468 }
469
470 fn maybe_record_rate_limit_from_body(status: StatusCode, body: &str) {
472 maybe_record_body_rate_limit(&REST_RATE_LIMIT, status, body);
473 }
474
475 fn maybe_record_graphql_rate_limit_from_body(status: StatusCode, body: &str) {
477 maybe_record_body_rate_limit(&GRAPHQL_RATE_LIMIT, status, body);
478 }
479
480 fn record_success() {
481 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
482 rl.record_success();
483 }
484 }
485
486 async fn auth_header(&self) -> anyhow::Result<String> {
489 match self.token_resolver.get_token().await? {
490 Some(token) if !token.is_empty() => Ok(format!("Bearer {token}")),
491 _ => anyhow::bail!("No GitHub token available — run `gh auth login` or set GH_TOKEN"),
492 }
493 }
494
495 async fn send_with_retries<F>(
498 &self,
499 mut make_req: F,
500 is_graphql: bool,
501 ) -> anyhow::Result<Response>
502 where
503 F: FnMut() -> anyhow::Result<reqwest::RequestBuilder>,
504 {
505 if engine_cooldown::is_agent_in_cooldown("github:5xx") {
507 if let Some(until) = engine_cooldown::cooldown_until("github:5xx") {
508 let now = chrono::Utc::now().timestamp();
509 let remaining = if until > now { until - now } else { 0 };
510 anyhow::bail!(
511 "GitHub API transient 5xx circuit-breaker active for {}s",
512 remaining
513 );
514 } else {
515 anyhow::bail!("GitHub API transient 5xx circuit-breaker active");
516 }
517 }
518
519 let attempts = crate::config::get("gh.5xx.retry_attempts")
521 .ok()
522 .and_then(|v| v.parse::<u32>().ok())
523 .unwrap_or(3);
524 let base_secs = crate::config::get("gh.5xx.base_backoff_seconds")
525 .ok()
526 .and_then(|v| v.parse::<u64>().ok())
527 .unwrap_or(1);
528 let max_secs = crate::config::get("gh.5xx.max_backoff_seconds")
529 .ok()
530 .and_then(|v| v.parse::<u64>().ok())
531 .unwrap_or(30);
532 let circuit_cooldown_secs = crate::config::get("gh.5xx.circuit_breaker_cooldown_seconds")
533 .ok()
534 .and_then(|v| v.parse::<u64>().ok())
535 .unwrap_or(300);
536
537 let mut last_err: Option<anyhow::Error> = None;
538 let mut auth_override: Option<String> = None;
541 let mut auth_retried = false;
542 for attempt in 0..attempts {
543 let mut req = make_req()?;
544 if let Some(auth) = &auth_override {
545 req = req.header(header::AUTHORIZATION, auth.clone());
546 }
547 let resp_result = req.send().await;
548
549 match resp_result {
550 Ok(resp) => {
551 if is_graphql {
553 Self::record_graphql_response(&resp);
554 } else {
555 Self::record_response(&resp);
556 }
557
558 if resp.status() == StatusCode::UNAUTHORIZED {
559 let status = resp.status();
560 let body = resp.text().await.unwrap_or_default();
561 if body.contains("Bad credentials") && !auth_retried {
562 tracing::warn!(
563 "GitHub API 401 Bad credentials — clearing cached token and re-resolving"
564 );
565 self.token_resolver.clear_cache().await;
566 match self.auth_header().await {
567 Ok(new_auth) => {
568 auth_retried = true;
569 auth_override = Some(new_auth);
570 }
571 Err(e) => {
572 last_err = Some(
573 e.context("GitHub token re-resolution failed after 401"),
574 );
575 break;
576 }
577 }
578 continue;
580 }
581 if auth_retried {
582 self.token_resolver.clear_cache().await;
585 anyhow::bail!(
586 "GitHub authentication failed ({status}): token re-resolved after cache clear but still rejected"
587 );
588 }
589 anyhow::bail!("GitHub API request failed ({status}): {body}");
590 }
591
592 if resp.status().is_server_error() {
594 let status = resp.status();
595 let headers = resp.headers().clone();
596 let body = resp.text().await.unwrap_or_default();
597 let req_id = headers
598 .get("x-github-request-id")
599 .and_then(|v| v.to_str().ok())
600 .unwrap_or("<none>");
601 let snippet: String = body.chars().take(400).collect();
602 tracing::warn!(
603 status = %status,
604 request_id = %req_id,
605 attempt,
606 "GitHub server error (5xx) — attempt {attempt}: {snippet}"
607 );
608
609 last_err = Some(anyhow::anyhow!(
610 "GitHub API server error ({status}) request_id={}: {}",
611 req_id,
612 snippet
613 ));
614
615 if attempt + 1 < attempts {
616 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
619 let mut backoff = base_secs.saturating_mul(exp);
620 if backoff > max_secs {
621 backoff = max_secs;
622 }
623 let jitter_ms = attempt as u64 * 100;
625 let sleep_dur = Duration::from_secs(backoff)
626 + Duration::from_millis(jitter_ms.min(1000));
627 tokio::time::sleep(sleep_dur).await;
628 continue;
629 }
630 tracing::warn!(
632 "GitHub API 5xx persisted after {} attempts — setting circuit-breaker for {}s",
633 attempts,
634 circuit_cooldown_secs
635 );
636 engine_cooldown::set_agent_cooldown("github:5xx", circuit_cooldown_secs)
637 .await;
638 break;
639 }
640
641 return Ok(resp);
643 }
644 Err(e) => {
645 tracing::warn!(err = %e, attempt, "HTTP request failed to send (transport error, GitHub not reached), will retry if attempts remain");
652 last_err = Some(anyhow::anyhow!(
653 "HTTP request failed to send (transport error): {}",
654 e
655 ));
656 if attempt + 1 < attempts {
657 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
659 let backoff = base_secs.saturating_mul(exp);
660 let sleep_dur = Duration::from_secs(backoff)
661 + Duration::from_millis((attempt as u64 * 100).min(1000));
662 tokio::time::sleep(sleep_dur).await;
663 continue;
664 }
665 tracing::warn!(
669 "HTTP request failed to send after {} attempts (transport error, GitHub not reached) — not tripping the github:5xx circuit-breaker",
670 attempts
671 );
672 break;
673 }
674 }
675 }
676
677 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unexpected send_with_retries failure")))
678 }
679
680 async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> anyhow::Result<T> {
682 Self::check_backoff(url)?;
683 let auth = self.auth_header().await?;
684 let make_req = || {
686 Ok(self
687 .client
688 .get(url)
689 .header(header::AUTHORIZATION, auth.clone())
690 .header(header::ACCEPT, "application/vnd.github+json")
691 .header("X-GitHub-Api-Version", "2022-11-28"))
692 };
693 let resp = self.send_with_retries(make_req, false).await?;
694 let status = resp.status();
695 if !status.is_success() {
696 let body = resp.text().await.unwrap_or_default();
697 Self::maybe_record_rate_limit_from_body(status, &body);
698 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
699 }
700 let body = resp.text().await?;
701 serde_json::from_str(&body)
702 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
703 }
704
705 async fn get_bytes(&self, url: &str) -> anyhow::Result<Vec<u8>> {
707 Self::check_backoff(url)?;
708 let auth = self.auth_header().await?;
709 let make_req = || {
710 Ok(self
711 .client
712 .get(url)
713 .header(header::AUTHORIZATION, auth.clone())
714 .header(header::ACCEPT, "application/vnd.github+json")
715 .header("X-GitHub-Api-Version", "2022-11-28"))
716 };
717 let resp = self.send_with_retries(make_req, false).await?;
718 let status = resp.status();
719 if !status.is_success() {
720 let body = resp.text().await.unwrap_or_default();
721 Self::maybe_record_rate_limit_from_body(status, &body);
722 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
723 }
724 Ok(resp.bytes().await?.to_vec())
725 }
726
727 async fn get_with_query<T: serde::de::DeserializeOwned>(
729 &self,
730 url: &str,
731 query: &[(&str, &str)],
732 ) -> anyhow::Result<T> {
733 Self::check_backoff(url)?;
734 let auth = self.auth_header().await?;
735 let make_req = || {
736 Ok(self
737 .client
738 .get(url)
739 .query(query)
740 .header(header::AUTHORIZATION, auth.clone())
741 .header(header::ACCEPT, "application/vnd.github+json")
742 .header("X-GitHub-Api-Version", "2022-11-28"))
743 };
744 let resp = self.send_with_retries(make_req, false).await?;
745 let status = resp.status();
746 if !status.is_success() {
747 let body = resp.text().await.unwrap_or_default();
748 Self::maybe_record_rate_limit_from_body(status, &body);
749 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
750 }
751 let body = resp.text().await?;
752 serde_json::from_str(&body)
753 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
754 }
755
756 async fn post_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
758 Self::check_backoff(url)?;
759 let auth = self.auth_header().await?;
760 let make_req = || {
761 Ok(self
762 .client
763 .post(url)
764 .json(body)
765 .header(header::AUTHORIZATION, auth.clone())
766 .header(header::ACCEPT, "application/vnd.github+json")
767 .header("X-GitHub-Api-Version", "2022-11-28"))
768 };
769 let resp = self.send_with_retries(make_req, false).await?;
770 let status = resp.status();
771 let text_res = resp.text().await;
772 if !status.is_success() {
773 let text = text_res.unwrap_or_default();
774 Self::maybe_record_rate_limit_from_body(status, &text);
775 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
776 }
777 let text = text_res?;
778 Ok(text)
779 }
780
781 async fn post_json<T: serde::de::DeserializeOwned>(
783 &self,
784 url: &str,
785 body: &serde_json::Value,
786 ) -> anyhow::Result<T> {
787 let text = self.post_json_raw(url, body).await?;
788 Ok(serde_json::from_str(&text)?)
789 }
790
791 async fn patch_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
793 Self::check_backoff(url)?;
794 let auth = self.auth_header().await?;
795 let make_req = || {
796 Ok(self
797 .client
798 .patch(url)
799 .json(body)
800 .header(header::AUTHORIZATION, auth.clone())
801 .header(header::ACCEPT, "application/vnd.github+json")
802 .header("X-GitHub-Api-Version", "2022-11-28"))
803 };
804 let resp = self.send_with_retries(make_req, false).await?;
805 let status = resp.status();
806 let text_res = resp.text().await;
807 if !status.is_success() {
808 let text = text_res.unwrap_or_default();
809 Self::maybe_record_rate_limit_from_body(status, &text);
810 anyhow::bail!("GitHub API PATCH {url} failed ({status}): {text}");
811 }
812 let text = text_res?;
813 Ok(text)
814 }
815
816 async fn delete(&self, url: &str) -> anyhow::Result<StatusCode> {
818 Self::check_backoff(url)?;
819 let auth = self.auth_header().await?;
820 let make_req = || {
821 Ok(self
822 .client
823 .delete(url)
824 .header(header::AUTHORIZATION, auth.clone())
825 .header(header::ACCEPT, "application/vnd.github+json")
826 .header("X-GitHub-Api-Version", "2022-11-28"))
827 };
828 let resp = self.send_with_retries(make_req, false).await?;
829 let status = resp.status();
830 if !status.is_success() && status != StatusCode::NOT_FOUND {
831 let body = resp.text().await.unwrap_or_default();
832 Self::maybe_record_rate_limit_from_body(status, &body);
833 anyhow::bail!("GitHub API DELETE {url} failed ({status}): {body}");
834 }
835 Ok(status)
836 }
837
838 async fn get_all_pages<T: serde::de::DeserializeOwned>(
843 &self,
844 url: &str,
845 query: &[(&str, &str)],
846 ) -> anyhow::Result<Vec<T>> {
847 Self::check_backoff(url)?;
848 let mut all: Vec<T> = Vec::new();
849 let mut next_url: Option<String> = None;
850 let mut is_first = true;
851 const MAX_JSON_DECODE_RETRIES: u32 = 2;
852 const JSON_DECODE_RETRY_BACKOFF_MS: u64 = 200;
853
854 loop {
855 let (request_url, request_query) = if is_first {
856 is_first = false;
857 (url, Some(query))
858 } else {
859 let Some(u) = next_url.as_ref() else { break };
860 (u.as_str(), None)
861 };
862
863 let cache_key = page_cache_key::<T>(request_url, request_query);
864 let mut cached_etag = cache_key.as_deref().and_then(page_cache_etag);
865 let mut decode_attempt = 0;
866 let (page, next) = loop {
867 let auth = self.auth_header().await?;
868 let mut req = self
869 .client
870 .get(request_url)
871 .header(header::AUTHORIZATION, auth)
872 .header(header::ACCEPT, "application/vnd.github+json")
873 .header("X-GitHub-Api-Version", "2022-11-28");
874 if let Some(q) = request_query {
875 req = req.query(q);
876 }
877 if let Some(etag) = &cached_etag {
878 req = req.header(header::IF_NONE_MATCH, etag.clone());
879 }
880
881 let make_req = || {
882 req.try_clone()
883 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
884 };
885 let resp = self.send_with_retries(make_req, false).await?;
886 let status = resp.status();
887 let next_from_headers = parse_link_next(resp.headers());
888 let etag = resp
889 .headers()
890 .get(header::ETAG)
891 .and_then(|v| v.to_str().ok())
892 .map(|v| v.to_string());
893 let content_type = resp
894 .headers()
895 .get(header::CONTENT_TYPE)
896 .and_then(|v| v.to_str().ok())
897 .map(|v| v.to_string())
898 .unwrap_or_else(|| "<missing>".to_string());
899
900 if status == StatusCode::NOT_MODIFIED {
901 match cache_key.as_deref().and_then(page_cache_hit) {
902 Some((body, next)) => break (serde_json::from_str::<Vec<T>>(&body)?, next),
903 None => {
904 cached_etag = None;
909 continue;
910 }
911 }
912 }
913
914 if !status.is_success() {
915 let body = resp.text().await.unwrap_or_default();
916 Self::maybe_record_rate_limit_from_body(status, &body);
917 anyhow::bail!("GitHub API GET (paginated) failed ({status}): {body}");
918 }
919
920 let text = resp.text().await?;
921 match serde_json::from_str::<Vec<T>>(&text) {
922 Ok(page) => {
923 if let (Some(key), Some(etag)) = (cache_key.clone(), etag) {
924 page_cache_store(key, etag, text, next_from_headers.clone());
925 }
926 break (page, next_from_headers);
927 }
928 Err(err) => {
929 let snippet: String = text.chars().take(200).collect();
930 let decode_error = anyhow::anyhow!(
931 "GitHub API GET (paginated) JSON decode failed ({status}, content-type={content_type}): {snippet}"
932 )
933 .context(err);
934 if decode_attempt < MAX_JSON_DECODE_RETRIES {
935 decode_attempt += 1;
936 tracing::warn!(
937 attempt = decode_attempt,
938 max_attempts = MAX_JSON_DECODE_RETRIES,
939 status = %status,
940 content_type,
941 err = %decode_error,
942 "json decode failed; retrying paginated GET"
943 );
944 tokio::time::sleep(Duration::from_millis(
945 JSON_DECODE_RETRY_BACKOFF_MS * u64::from(decode_attempt),
946 ))
947 .await;
948 continue;
949 }
950 return Err(decode_error);
951 }
952 }
953 };
954
955 next_url = next;
956 all.extend(page);
957
958 if next_url.is_none() {
959 break;
960 }
961 }
962
963 Ok(all)
964 }
965
966 async fn get_all_pages_from_wrapper<T: serde::de::DeserializeOwned>(
977 &self,
978 url: &str,
979 array_field: &str,
980 ) -> anyhow::Result<Vec<T>> {
981 Self::check_backoff(url)?;
982 let mut all: Vec<T> = Vec::new();
983 let mut current_url: String = url.to_string();
984
985 loop {
986 let auth = self.auth_header().await?;
987 let make_req = || {
988 self.client
989 .get(¤t_url)
990 .header(header::AUTHORIZATION, auth.clone())
991 .header(header::ACCEPT, "application/vnd.github+json")
992 .header("X-GitHub-Api-Version", "2022-11-28")
993 .try_clone()
994 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
995 };
996 let resp = self.send_with_retries(make_req, false).await?;
997 let status = resp.status();
998 let next = parse_link_next(resp.headers());
999
1000 if !status.is_success() {
1001 let body = resp.text().await.unwrap_or_default();
1002 Self::maybe_record_rate_limit_from_body(status, &body);
1003 anyhow::bail!(
1004 "GitHub API GET (wrapped paginated) {current_url} failed ({status}): {body}"
1005 );
1006 }
1007
1008 let body = resp.text().await?;
1009 let wrapper: serde_json::Value = serde_json::from_str(&body).with_context(|| {
1010 format!("failed to parse wrapper response from {current_url}: {body}")
1011 })?;
1012 let items = wrapper
1013 .get(array_field)
1014 .and_then(|v| v.as_array())
1015 .cloned()
1016 .unwrap_or_default();
1017
1018 for item in items {
1019 match serde_json::from_value::<T>(item) {
1020 Ok(v) => all.push(v),
1021 Err(e) => {
1022 tracing::warn!(
1023 field = array_field,
1024 err = %e,
1025 "skipping item that failed to deserialize in wrapped pagination"
1026 );
1027 }
1028 }
1029 }
1030
1031 match next {
1032 Some(next_url) => current_url = next_url,
1033 None => break,
1034 }
1035 }
1036
1037 Ok(all)
1038 }
1039
1040 async fn graphql_request(
1042 &self,
1043 query: &str,
1044 extra_headers: &[(&str, &str)],
1045 variables: Option<serde_json::Value>,
1046 ) -> anyhow::Result<serde_json::Value> {
1047 self.graphql_request_to_url(
1048 &format!("{GITHUB_API}/graphql"),
1049 query,
1050 extra_headers,
1051 variables,
1052 )
1053 .await
1054 }
1055
1056 async fn graphql_request_to_url(
1057 &self,
1058 url: &str,
1059 query: &str,
1060 extra_headers: &[(&str, &str)],
1061 variables: Option<serde_json::Value>,
1062 ) -> anyhow::Result<serde_json::Value> {
1063 Self::check_graphql_backoff(url)?;
1064 let body = if let Some(vars) = variables {
1065 serde_json::json!({ "query": query, "variables": vars })
1066 } else {
1067 serde_json::json!({ "query": query })
1068 };
1069 let auth = self.auth_header().await?;
1070 let mut req = self
1071 .client
1072 .post(url)
1073 .json(&body)
1074 .header(header::AUTHORIZATION, auth)
1075 .header(header::ACCEPT, "application/vnd.github+json")
1076 .header("X-GitHub-Api-Version", "2022-11-28");
1077
1078 for (k, v) in extra_headers {
1079 req = req.header(*k, *v);
1080 }
1081
1082 let make_req = || {
1084 req.try_clone()
1085 .ok_or_else(|| anyhow::anyhow!("graphql request clone failed"))
1086 };
1087 let resp = self.send_with_retries(make_req, true).await?;
1088 let status = resp.status();
1089 let text_res = resp.text().await;
1090 if !status.is_success() {
1091 let text = text_res.unwrap_or_default();
1092 Self::maybe_record_graphql_rate_limit_from_body(status, &text);
1093 anyhow::bail!("GitHub GraphQL failed ({status}): {text}");
1094 }
1095 let text = text_res?;
1096 let json: serde_json::Value = serde_json::from_str(&text)?;
1097 if let Some(errors) = json.get("errors").and_then(|e| e.as_array()) {
1099 if !errors.is_empty() {
1100 let messages: Vec<&str> = errors
1101 .iter()
1102 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1103 .collect();
1104 if json.get("data").is_none() || json["data"].is_null() {
1105 anyhow::bail!("GitHub GraphQL errors: {}", messages.join("; "));
1107 }
1108 tracing::warn!(errors = ?messages, "GitHub GraphQL returned partial errors");
1110 }
1111 }
1112 Ok(json)
1113 }
1114
1115 pub async fn graphql_with_vars(
1118 &self,
1119 query: &str,
1120 variables: serde_json::Value,
1121 ) -> anyhow::Result<serde_json::Value> {
1122 self.graphql_request(query, &[], Some(variables)).await
1123 }
1124
1125 pub async fn graphql_with_headers(
1126 &self,
1127 query: &str,
1128 headers: &[&str],
1129 ) -> anyhow::Result<serde_json::Value> {
1130 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1132 self.graphql_request(query, &pairs, None).await
1133 }
1134
1135 pub async fn graphql_with_headers_and_vars(
1136 &self,
1137 query: &str,
1138 headers: &[&str],
1139 variables: serde_json::Value,
1140 ) -> anyhow::Result<serde_json::Value> {
1141 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1142 self.graphql_request(query, &pairs, Some(variables)).await
1143 }
1144
1145 pub async fn auth_status(&self) -> anyhow::Result<()> {
1150 match self
1151 .get_json::<serde_json::Value>(&format!("{GITHUB_API}/user"))
1152 .await
1153 {
1154 Ok(_) => Ok(()),
1155 Err(e) => {
1156 let err_str = e.to_string();
1157
1158 if err_str.contains("401") || err_str.contains("Bad credentials") {
1160 anyhow::bail!(
1161 "GitHub authentication failed: Invalid or expired token. \
1162 Check your GH_TOKEN/GITHUB_TOKEN environment variables, \
1163 or gh.auth configuration in ~/.orch/config.yml. \
1164 Run `orch auth check` to verify your setup."
1165 );
1166 }
1167
1168 if err_str.contains("403") && err_str.contains("rate limit") {
1169 anyhow::bail!(
1170 "GitHub API rate limited. Wait before retrying, \
1171 or check your token's rate limit quota."
1172 );
1173 }
1174
1175 Err(e)
1176 }
1177 }
1178 }
1179
1180 pub async fn create_issue(
1182 &self,
1183 repo: &str,
1184 title: &str,
1185 body: &str,
1186 labels: &[String],
1187 ) -> anyhow::Result<GitHubIssue> {
1188 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1189 let payload = serde_json::json!({
1190 "title": title,
1191 "body": body,
1192 "labels": labels,
1193 });
1194 self.post_json(&url, &payload).await
1195 }
1196
1197 pub async fn update_issue(
1201 &self,
1202 repo: &str,
1203 number: u64,
1204 title: &str,
1205 body: &str,
1206 labels: &[String],
1207 ) -> anyhow::Result<GitHubIssue> {
1208 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
1209 let payload = serde_json::json!({
1210 "title": title,
1211 "body": body,
1212 "labels": labels,
1213 });
1214 let text = self.patch_json_raw(&url, &payload).await?;
1215 serde_json::from_str(&text)
1216 .with_context(|| format!("failed to parse issue update response: {text}"))
1217 }
1218
1219 pub async fn list_blocked_by(
1227 &self,
1228 repo: &str,
1229 issue_number: u64,
1230 ) -> anyhow::Result<crate::backends::BlockedByFetch> {
1231 use crate::backends::{BlockedByFetch, BlockedByIssue};
1232
1233 let mut next = Some(format!(
1234 "{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by?per_page=100"
1235 ));
1236 let mut found = Vec::new();
1237 while let Some(url) = next.take() {
1238 let resp = match self
1239 .dependency_request(&url, reqwest::Method::GET, None)
1240 .await
1241 {
1242 Ok(resp) => resp,
1243 Err(err) => return Ok(BlockedByFetch::Failed(err.to_string())),
1244 };
1245 let status = resp.status();
1246 if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
1247 let body = resp.text().await.unwrap_or_default();
1248 if status == StatusCode::FORBIDDEN
1249 && body.to_ascii_lowercase().contains("rate limit")
1250 {
1251 Self::maybe_record_rate_limit_from_body(status, &body);
1252 return Ok(BlockedByFetch::Failed(body));
1253 }
1254 return Ok(BlockedByFetch::Unavailable);
1255 }
1256 if !status.is_success() {
1257 let body = resp.text().await.unwrap_or_default();
1258 Self::maybe_record_rate_limit_from_body(status, &body);
1259 return Ok(BlockedByFetch::Failed(format!("{status}: {body}")));
1260 }
1261 Self::record_success();
1262 let page_next = parse_link_next(resp.headers());
1263 let body = resp.text().await.unwrap_or_default();
1264 let page: Vec<BlockedByPayload> = match serde_json::from_str(&body) {
1265 Ok(page) => page,
1266 Err(err) => {
1267 return Ok(BlockedByFetch::Failed(format!(
1268 "blocked_by parse failed: {err}"
1269 )));
1270 }
1271 };
1272 for item in page {
1273 if item.number <= 0 {
1274 continue;
1275 }
1276 let item_repo = item
1277 .repository
1278 .as_ref()
1279 .map(|r| r.full_name.clone())
1280 .filter(|name| !name.is_empty())
1281 .unwrap_or_else(|| repo.to_string());
1282 found.push(BlockedByIssue {
1283 github_id: item.id,
1284 repo: item_repo,
1285 number: item.number,
1286 state: item.state,
1287 state_reason: item.state_reason,
1288 });
1289 }
1290 next = page_next;
1291 }
1292 Ok(BlockedByFetch::Native(found))
1293 }
1294
1295 pub async fn add_blocked_by(
1299 &self,
1300 repo: &str,
1301 issue_number: u64,
1302 blocking_issue_id: i64,
1303 ) -> anyhow::Result<()> {
1304 let url =
1305 format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by");
1306 let resp = self
1307 .dependency_request(
1308 &url,
1309 reqwest::Method::POST,
1310 Some(serde_json::json!({ "issue_id": blocking_issue_id })),
1311 )
1312 .await?;
1313 let status = resp.status();
1314 if status.is_success() {
1315 Self::record_success();
1316 return Ok(());
1317 }
1318 let body = resp.text().await.unwrap_or_default();
1319 if body.to_ascii_lowercase().contains("already") {
1320 return Ok(());
1321 }
1322 Self::maybe_record_rate_limit_from_body(status, &body);
1323 anyhow::bail!("add blocked_by failed ({status}): {body}")
1324 }
1325
1326 pub async fn remove_blocked_by(
1328 &self,
1329 repo: &str,
1330 number: u64,
1331 blocking_issue_id: u64,
1332 ) -> anyhow::Result<()> {
1333 let url = format!(
1334 "{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
1335 );
1336 self.delete(&url).await?;
1337 Ok(())
1338 }
1339
1340 async fn dependency_request(
1342 &self,
1343 url: &str,
1344 method: reqwest::Method,
1345 body: Option<serde_json::Value>,
1346 ) -> anyhow::Result<Response> {
1347 Self::check_backoff(url)?;
1348 let auth = self.auth_header().await?;
1349 let make_req = || {
1350 let mut req = self
1351 .client
1352 .request(method.clone(), url)
1353 .header(header::AUTHORIZATION, auth.clone())
1354 .header(header::ACCEPT, "application/vnd.github+json")
1355 .header("X-GitHub-Api-Version", "2026-03-10");
1356 if let Some(ref payload) = body {
1357 req = req.json(payload);
1358 }
1359 Ok(req)
1360 };
1361 self.send_with_retries(make_req, false).await
1362 }
1363
1364 pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1366 self.get_json(&format!("{GITHUB_API}/repos/{repo}/issues/{number}"))
1367 .await
1368 }
1369
1370 pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1372 self.get_issue(repo, number)
1373 .await
1374 .map(|i| i.pull_request.is_some())
1375 .unwrap_or(false)
1376 }
1377
1378 #[allow(dead_code)]
1380 pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1381 self.list_issues_with_state(repo, label, "open").await
1382 }
1383
1384 pub async fn list_issues_with_state(
1388 &self,
1389 repo: &str,
1390 label: &str,
1391 state: &str,
1392 ) -> anyhow::Result<Vec<GitHubIssue>> {
1393 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1394 let mut query = vec![("state", state), ("per_page", "100")];
1396 if !label.is_empty() {
1397 query.push(("labels", label));
1398 }
1399 let all: Vec<GitHubIssue> = self.get_all_pages(&url, &query).await?;
1400 Ok(all
1402 .into_iter()
1403 .filter(|i| i.pull_request.is_none())
1404 .collect())
1405 }
1406
1407 #[allow(dead_code)]
1411 pub async fn list_issues_closed_since(
1412 &self,
1413 repo: &str,
1414 label: &str,
1415 since: &str,
1416 ) -> anyhow::Result<Vec<GitHubIssue>> {
1417 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1418 let all: Vec<GitHubIssue> = self
1419 .get_all_pages(
1420 &url,
1421 &[
1422 ("labels", label),
1423 ("state", "closed"),
1424 ("since", since),
1425 ("per_page", "100"),
1426 ],
1427 )
1428 .await?;
1429 Ok(all
1430 .into_iter()
1431 .filter(|i| i.pull_request.is_none())
1432 .collect())
1433 }
1434
1435 pub async fn list_closed_issues_since(
1437 &self,
1438 repo: &str,
1439 since: &str,
1440 ) -> anyhow::Result<Vec<GitHubIssue>> {
1441 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1442 let all: Vec<GitHubIssue> = self
1443 .get_all_pages(
1444 &url,
1445 &[("state", "closed"), ("since", since), ("per_page", "100")],
1446 )
1447 .await?;
1448 Ok(all
1449 .into_iter()
1450 .filter(|i| i.pull_request.is_none())
1451 .collect())
1452 }
1453
1454 pub async fn list_all_open_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1459 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1460 let params = [("state", "open"), ("per_page", "100")];
1461 let all: Vec<GitHubIssue> = self.get_all_pages(&url, ¶ms).await?;
1462 Ok(all
1464 .into_iter()
1465 .filter(|i| i.pull_request.is_none())
1466 .collect())
1467 }
1468
1469 pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1472 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1473 let all: Vec<serde_json::Value> = self
1474 .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1475 .await?;
1476 Ok(all)
1477 }
1478
1479 pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1483 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1484 let all: Vec<GitHubIssue> = self
1485 .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1486 .await?;
1487 Ok(all
1489 .into_iter()
1490 .filter(|i| i.pull_request.is_none())
1491 .collect())
1492 }
1493
1494 pub async fn add_labels(
1496 &self,
1497 repo: &str,
1498 number: &str,
1499 labels: &[String],
1500 ) -> anyhow::Result<()> {
1501 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1502 let payload = serde_json::json!({ "labels": labels });
1503 self.post_json_raw(&url, &payload).await?;
1504 Ok(())
1505 }
1506
1507 pub async fn ensure_label(
1509 &self,
1510 repo: &str,
1511 name: &str,
1512 color: &str,
1513 description: &str,
1514 ) -> anyhow::Result<()> {
1515 let encoded = urlencoding::encode(name);
1516 let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1517
1518 match self.get_bytes(&get_url).await {
1520 Ok(_) => return Ok(()),
1521 Err(e) if e.to_string().contains("404") => {
1522 tracing::debug!(repo, name, "ensure_label: label not found, creating");
1523 }
1524 Err(e) => {
1525 tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1526 }
1527 }
1528
1529 let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1531 let payload = serde_json::json!({
1532 "name": name,
1533 "color": color,
1534 "description": description,
1535 });
1536
1537 match self.post_json_raw(&create_url, &payload).await {
1538 Ok(_) => {
1539 Self::record_success();
1540 }
1541 Err(e) => {
1542 let msg = e.to_string();
1543 if msg.contains("422")
1544 || msg.contains("already_exists")
1545 || msg.contains("Unprocessable")
1546 {
1547 tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1548 return Ok(());
1549 }
1550 tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1552 }
1553 }
1554 Ok(())
1555 }
1556
1557 pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1559 let encoded = urlencoding::encode(label);
1560 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1561 match self.delete(&url).await {
1562 Ok(_) => Ok(()),
1563 Err(e) if e.to_string().contains("404") => {
1564 tracing::debug!(repo, number, label, "label already removed (404)");
1565 Ok(())
1566 }
1567 Err(e) => Err(e),
1568 }
1569 }
1570
1571 pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1573 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1574 let payload = serde_json::json!({ "body": body });
1575 self.post_json_raw(&url, &payload).await?;
1576 Ok(())
1577 }
1578
1579 pub async fn post_commit_status(
1581 &self,
1582 repo: &str,
1583 sha: &str,
1584 state: &str,
1585 context: &str,
1586 description: &str,
1587 ) -> anyhow::Result<()> {
1588 let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1589 let payload = serde_json::json!({
1590 "state": state,
1591 "context": context,
1592 "description": description,
1593 });
1594 self.post_json_raw(&url, &payload).await?;
1595 Ok(())
1596 }
1597
1598 pub async fn add_reaction(
1602 &self,
1603 repo: &str,
1604 number: &str,
1605 content: &str,
1606 ) -> anyhow::Result<()> {
1607 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1608 let payload = serde_json::json!({ "content": content });
1609 self.post_json_raw(&url, &payload).await?;
1610 Ok(())
1611 }
1612
1613 pub async fn add_comment_reaction(
1617 &self,
1618 repo: &str,
1619 comment_id: &str,
1620 content: &str,
1621 ) -> anyhow::Result<()> {
1622 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1623 let payload = serde_json::json!({ "content": content });
1624 self.post_json_raw(&url, &payload).await?;
1625 Ok(())
1626 }
1627
1628 pub async fn list_comments(
1630 &self,
1631 repo: &str,
1632 number: &str,
1633 ) -> anyhow::Result<Vec<GitHubComment>> {
1634 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1635 self.get_all_pages(&url, &[("per_page", "100")]).await
1636 }
1637
1638 pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1640 let owner = repo
1641 .split('/')
1642 .next()
1643 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1644 let head = format!("{}:{}", owner, branch);
1645
1646 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1647 let prs: Vec<serde_json::Value> = self
1648 .get_with_query(
1649 &url,
1650 &[("head", &head), ("state", "closed"), ("per_page", "1")],
1651 )
1652 .await?;
1653
1654 if prs.is_empty() {
1655 return Ok(false);
1656 }
1657 let merged_at = prs[0].get("merged_at");
1658 Ok(merged_at.map(|v| !v.is_null()).unwrap_or(false))
1659 }
1660
1661 pub async fn get_closed_pr_state(
1674 &self,
1675 repo: &str,
1676 branch: &str,
1677 ) -> anyhow::Result<Option<(u64, bool, String)>> {
1678 let owner = repo
1679 .split('/')
1680 .next()
1681 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1682 let head = format!("{}:{}", owner, branch);
1683
1684 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1685 let prs: Vec<serde_json::Value> = self
1686 .get_with_query(
1687 &url,
1688 &[("head", &head), ("state", "closed"), ("per_page", "5")],
1689 )
1690 .await?;
1691
1692 if prs.is_empty() {
1693 return Ok(None);
1694 }
1695
1696 for pr in prs {
1699 let pr_branch = pr
1700 .get("head")
1701 .and_then(|h| h.get("ref"))
1702 .and_then(|r| r.as_str())
1703 .unwrap_or("");
1704
1705 if pr_branch != branch {
1706 tracing::debug!(
1707 repo,
1708 branch,
1709 pr_branch,
1710 "skipping PR with mismatched head ref"
1711 );
1712 continue;
1713 }
1714
1715 let pr_number = pr
1716 .get("number")
1717 .and_then(|n| n.as_u64())
1718 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1719
1720 let merged_at = pr.get("merged_at");
1721 let merged = merged_at.map(|v| !v.is_null()).unwrap_or(false);
1722 let state = pr
1723 .get("state")
1724 .and_then(|v| v.as_str())
1725 .unwrap_or("closed")
1726 .to_string();
1727
1728 return Ok(Some((pr_number, merged, state)));
1729 }
1730
1731 Ok(None)
1733 }
1734
1735 pub async fn batch_is_pr_merged_by_branch(
1740 &self,
1741 repo: &str,
1742 branches: &[String],
1743 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1744 if branches.is_empty() {
1745 return Ok(std::collections::HashMap::new());
1746 }
1747
1748 let (owner, name) = repo
1749 .split_once('/')
1750 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1751
1752 let mut var_defs = String::new();
1756 let mut aliases = String::new();
1757 let mut vars = serde_json::json!({ "owner": owner, "name": name });
1758 for (i, branch) in branches.iter().enumerate() {
1759 var_defs.push_str(&format!(", $b{i}: String!"));
1760 aliases.push_str(&format!(
1761 r#"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "#
1762 ));
1763 vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1764 }
1765
1766 let query = format!(
1767 r#"query($owner: String!, $name: String!{var_defs}) {{
1768 repository(owner: $owner, name: $name) {{ {aliases} }}
1769 }}"#
1770 );
1771
1772 let resp = self.graphql_with_vars(&query, vars).await?;
1773
1774 if let Some(errors) = resp.get("errors").and_then(|e| e.as_array()) {
1776 if !errors.is_empty() {
1777 let messages: Vec<&str> = errors
1778 .iter()
1779 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1780 .collect();
1781 tracing::warn!(
1782 repo,
1783 errors = ?messages,
1784 "GraphQL returned errors in batch PR merge check"
1785 );
1786 }
1787 }
1788
1789 Self::parse_batch_pr_merged_response(&resp, branches)
1790 }
1791
1792 fn parse_batch_pr_merged_response(
1793 resp: &serde_json::Value,
1794 branches: &[String],
1795 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1796 let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1797 let error_msg = resp
1799 .get("errors")
1800 .and_then(|e| e.as_array())
1801 .and_then(|arr| arr.first())
1802 .and_then(|e| e.get("message"))
1803 .and_then(|m| m.as_str())
1804 .unwrap_or("no error details");
1805 anyhow::anyhow!(
1806 "missing /data/repository in GraphQL response: {}",
1807 error_msg
1808 )
1809 })?;
1810
1811 let mut result = std::collections::HashMap::new();
1812 for (i, branch) in branches.iter().enumerate() {
1813 let alias = format!("b{i}");
1814 let merged = repo_data
1815 .get(&alias)
1816 .and_then(|v| v.get("nodes"))
1817 .and_then(|nodes| nodes.as_array())
1818 .and_then(|arr| arr.first())
1819 .and_then(|node| node.get("merged"))
1820 .and_then(|v| v.as_bool())
1821 .unwrap_or(false);
1822 result.insert(branch.clone(), merged);
1823 }
1824
1825 Ok(result)
1826 }
1827
1828 pub async fn get_mentions(
1830 &self,
1831 repo: &str,
1832 since: &str,
1833 ) -> anyhow::Result<Vec<GitHubComment>> {
1834 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments");
1835 self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1836 .await
1837 }
1838
1839 pub async fn get_issue_comments(
1841 &self,
1842 repo: &str,
1843 issue_number: u64,
1844 ) -> anyhow::Result<Vec<GitHubComment>> {
1845 let url = format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/comments");
1846 self.get_all_pages(&url, &[("per_page", "100")]).await
1847 }
1848
1849 pub async fn get_whoami(&self) -> anyhow::Result<String> {
1851 if let Some(cached) = self.cached_username.get() {
1852 return Ok(cached.clone());
1853 }
1854
1855 let user: serde_json::Value = self.get_json(&format!("{GITHUB_API}/user")).await?;
1856 let username = user
1857 .get("login")
1858 .and_then(|v| v.as_str())
1859 .map(String::from)
1860 .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1861
1862 let _ = self.cached_username.set(username.clone());
1863 Ok(username)
1864 }
1865
1866 pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1868 let owner = repo
1869 .split('/')
1870 .next()
1871 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1872 let head = format!("{}:{}", owner, branch);
1873
1874 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1875 let prs: Vec<serde_json::Value> = self
1876 .get_with_query(
1877 &url,
1878 &[("head", &head), ("state", "open"), ("per_page", "1")],
1879 )
1880 .await?;
1881
1882 if prs.is_empty() {
1883 return Ok(None);
1884 }
1885 prs[0]
1886 .get("number")
1887 .and_then(|n| n.as_u64())
1888 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1889 .map(Some)
1890 }
1891
1892 pub async fn create_pr(
1903 &self,
1904 repo: &str,
1905 title: &str,
1906 body: &str,
1907 head: &str,
1908 base: &str,
1909 ) -> anyhow::Result<String> {
1910 use reqwest::header;
1911
1912 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1913 Self::check_backoff(&url)?;
1914
1915 #[derive(Serialize)]
1916 struct CreatePullRequest<'a> {
1917 title: &'a str,
1918 body: &'a str,
1919 head: &'a str,
1920 base: &'a str,
1921 }
1922
1923 let payload = CreatePullRequest {
1924 title,
1925 body,
1926 head,
1927 base,
1928 };
1929
1930 let auth = self.auth_header().await?;
1931 let make_req = || {
1932 Ok(self
1933 .client
1934 .post(&url)
1935 .header(header::AUTHORIZATION, auth.clone())
1936 .header(header::ACCEPT, "application/vnd.github+json")
1937 .header(header::USER_AGENT, "orch")
1938 .header("X-GitHub-Api-Version", "2022-11-28")
1939 .json(&payload))
1940 };
1941 let response = self.send_with_retries(make_req, false).await?;
1942 let status = response.status();
1943 let text = response.text().await.unwrap_or_default();
1944 if !status.is_success() {
1945 Self::maybe_record_rate_limit_from_body(status, &text);
1946 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
1947 }
1948
1949 let pr: serde_json::Value = serde_json::from_str(&text)?;
1950 pr.get("html_url")
1951 .and_then(|v| v.as_str())
1952 .map(String::from)
1953 .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {:?}", pr))
1954 }
1955
1956 pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
1961 let url = format!("{GITHUB_API}/repos/{repo}");
1962 let resp: serde_json::Value = self.get_json(&url).await?;
1963 resp.get("default_branch")
1964 .and_then(|v| v.as_str())
1965 .map(String::from)
1966 .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
1967 }
1968
1969 pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
1972 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
1973 self.get_json(&url).await
1974 }
1975
1976 pub async fn batch_get_pr_states(
1981 &self,
1982 repo: &str,
1983 pr_numbers: &[u64],
1984 ) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
1985 if pr_numbers.is_empty() {
1986 return Ok(std::collections::HashMap::new());
1987 }
1988
1989 let (owner, name) = repo
1990 .split_once('/')
1991 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1992
1993 const CHUNK_SIZE: usize = 50;
1995 let mut result = std::collections::HashMap::new();
1996
1997 for chunk in pr_numbers.chunks(CHUNK_SIZE) {
1998 let aliases: String = chunk
1999 .iter()
2000 .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
2001 .collect::<Vec<_>>()
2002 .join("\n ");
2003
2004 let query = format!(
2005 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2006 );
2007
2008 let resp = self
2009 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2010 .await?;
2011 let repo_data = resp
2012 .pointer("/data/repository")
2013 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2014
2015 for n in chunk {
2016 if let Some(pr) = repo_data.get(format!("pr{n}")) {
2017 let merged = pr.get("merged").and_then(|v| v.as_bool()).unwrap_or(false);
2018 let state = pr
2019 .get("state")
2020 .and_then(|v| v.as_str())
2021 .unwrap_or("UNKNOWN")
2022 .to_lowercase();
2023 result.insert(*n, (merged, state));
2024 }
2025 }
2026 }
2027
2028 Ok(result)
2029 }
2030
2031 pub async fn batch_get_issue_states(
2036 &self,
2037 repo: &str,
2038 issue_numbers: &[u64],
2039 caller_context: &str,
2040 ) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
2041 if issue_numbers.is_empty() {
2042 return Ok(std::collections::HashMap::new());
2043 }
2044
2045 let (valid_issue_numbers, invalid_issue_numbers) =
2046 sanitize_github_issue_numbers(issue_numbers);
2047 if !invalid_issue_numbers.is_empty() {
2048 tracing::warn!(
2049 repo,
2050 caller_context,
2051 invalid_issue_numbers = ?invalid_issue_numbers,
2052 total_supplied = issue_numbers.len(),
2053 "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
2054 );
2055 }
2056 if valid_issue_numbers.is_empty() {
2057 return Ok(std::collections::HashMap::new());
2058 }
2059
2060 let (owner, name) = repo
2061 .split_once('/')
2062 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2063
2064 const CHUNK_SIZE: usize = 50;
2066 let mut result = std::collections::HashMap::new();
2067
2068 for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
2069 let aliases: String = chunk
2070 .iter()
2071 .map(|n| {
2072 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
2073 })
2074 .collect::<Vec<_>>()
2075 .join(" ");
2076
2077 let query = format!(
2078 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2079 );
2080
2081 let resp = self
2082 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2083 .await?;
2084 let repo_data = resp
2085 .pointer("/data/repository")
2086 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2087
2088 for n in chunk {
2089 if let Some(issue) = repo_data.get(format!("issue{n}")) {
2090 let state = issue
2091 .get("state")
2092 .and_then(|v| v.as_str())
2093 .unwrap_or("UNKNOWN")
2094 .to_lowercase();
2095 let labels: Vec<String> = issue
2096 .pointer("/labels/nodes")
2097 .and_then(|v| v.as_array())
2098 .map(|arr| {
2099 arr.iter()
2100 .filter_map(|l| {
2101 l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2102 })
2103 .collect()
2104 })
2105 .unwrap_or_default();
2106 result.insert(n.to_string(), (state, labels));
2107 }
2108 }
2109 }
2110 Ok(result)
2111 }
2112
2113 pub async fn get_required_status_check_contexts(
2115 &self,
2116 repo: &str,
2117 branch: &str,
2118 ) -> anyhow::Result<Vec<String>> {
2119 let branch = urlencoding::encode(branch);
2120 let url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2121 let resp = self.get_json::<serde_json::Value>(&url).await;
2122 match resp {
2123 Ok(value) => Ok(value
2124 .pointer("/required_status_checks/contexts")
2125 .and_then(|v| v.as_array())
2126 .map(|contexts| {
2127 contexts
2128 .iter()
2129 .filter_map(|context| context.as_str().map(|s| s.to_string()))
2130 .collect()
2131 })
2132 .unwrap_or_default()),
2133 Err(e) => {
2134 if is_no_branch_protection_error(&e.to_string()) {
2135 Ok(vec![])
2136 } else {
2137 Err(e)
2138 }
2139 }
2140 }
2141 }
2142
2143 pub async fn get_commit_status_contexts(
2145 &self,
2146 repo: &str,
2147 sha: &str,
2148 ) -> anyhow::Result<Vec<(String, String)>> {
2149 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2150 let statuses: Vec<serde_json::Value> =
2155 self.get_all_pages_from_wrapper(&url, "statuses").await?;
2156
2157 Ok(statuses
2158 .into_iter()
2159 .filter_map(|status| {
2160 let context = status.get("context").and_then(|v| v.as_str())?;
2161 let state = status.get("state").and_then(|v| v.as_str())?;
2162 Some((context.to_string(), state.to_string()))
2163 })
2164 .collect())
2165 }
2166
2167 fn combined_status_state(
2168 total: u64,
2169 failing: u64,
2170 pending: u64,
2171 has_workflows: bool,
2172 mergeable_state: Option<&str>,
2173 ) -> String {
2174 if failing > 0 {
2175 "failure".to_string()
2176 } else if pending > 0 {
2177 "pending".to_string()
2178 } else if total == 0 && has_workflows {
2179 if mergeable_state == Some("clean") {
2183 "success".to_string()
2184 } else {
2185 "pending".to_string()
2186 }
2187 } else {
2188 "success".to_string()
2189 }
2190 }
2191
2192 pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2197 let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2198 let resp = self.get_json::<serde_json::Value>(&url).await;
2199 match resp {
2200 Ok(value) => Ok(value
2201 .get("workflows")
2202 .and_then(|v| v.as_array())
2203 .map(|workflows| !workflows.is_empty())
2204 .unwrap_or(false)),
2205 Err(e) => {
2206 let err = e.to_string();
2207 if err.contains("404") {
2208 Ok(false)
2209 } else {
2210 Err(e)
2211 }
2212 }
2213 }
2214 }
2215
2216 pub async fn get_check_runs(
2218 &self,
2219 repo: &str,
2220 sha: &str,
2221 ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2222 let url = format!(
2233 "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2234 );
2235 self.get_all_pages_from_wrapper(&url, "check_runs").await
2236 }
2237
2238 pub async fn get_check_run_annotations(
2245 &self,
2246 repo: &str,
2247 check_run_id: u64,
2248 ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2249 let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2250 self.get_all_pages(&url, &[("per_page", "100")]).await
2251 }
2252
2253 pub async fn get_pr_reviews(
2255 &self,
2256 repo: &str,
2257 pr_number: u64,
2258 ) -> anyhow::Result<Vec<GitHubReview>> {
2259 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2260 self.get_all_pages(&url, &[("per_page", "100")]).await
2261 }
2262
2263 pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2268 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2269 let value: serde_json::Value = self.get_json(&url).await?;
2270 value
2271 .pointer("/commit/committer/date")
2272 .and_then(|v| v.as_str())
2273 .map(|s| s.to_string())
2274 .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2275 }
2276
2277 pub async fn update_pr_body(
2279 &self,
2280 repo: &str,
2281 pr_number: u64,
2282 body: &str,
2283 ) -> anyhow::Result<()> {
2284 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2285 let payload = serde_json::json!({ "body": body });
2286 self.patch_json_raw(&url, &payload).await?;
2287 Ok(())
2288 }
2289
2290 pub async fn close_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2292 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2293 let payload = serde_json::json!({ "state": "closed" });
2294 self.patch_json_raw(&url, &payload).await?;
2295 Ok(())
2296 }
2297
2298 pub async fn compare_behind_by(
2303 &self,
2304 repo: &str,
2305 base: &str,
2306 head_sha: &str,
2307 ) -> anyhow::Result<u64> {
2308 let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2309 let body: serde_json::Value = self.get_json(&url).await?;
2310 parse_behind_by(&body)
2311 }
2312
2313 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2315 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2316 let payload = serde_json::json!({ "state": "closed" });
2317 self.patch_json_raw(&url, &payload).await?;
2318 Ok(())
2319 }
2320
2321 pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2323 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2324 let payload = serde_json::json!({ "state": "open" });
2325 self.patch_json_raw(&url, &payload).await?;
2326 Ok(())
2327 }
2328
2329 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2331 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2332 Self::check_backoff(&url)?;
2333 let auth = self.auth_header().await?;
2334 let make_req = || {
2335 Ok(self
2336 .client
2337 .get(&url)
2338 .header(header::AUTHORIZATION, auth.clone())
2339 .header(header::ACCEPT, "application/vnd.github+json")
2340 .header("X-GitHub-Api-Version", "2022-11-28"))
2341 };
2342 let resp = self.send_with_retries(make_req, false).await?;
2343 let status = resp.status();
2344 if status == StatusCode::NO_CONTENT {
2345 Ok(true)
2346 } else if status == StatusCode::NOT_FOUND {
2347 Ok(false)
2348 } else {
2349 let body = resp.text().await.unwrap_or_default();
2350 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2351 }
2352 }
2353
2354 pub async fn add_sub_issue(
2356 &self,
2357 parent_node_id: &str,
2358 child_node_id: &str,
2359 ) -> anyhow::Result<()> {
2360 let query = format!(
2361 r#"mutation {{
2362 addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
2363 issue {{ number }}
2364 subIssue {{ number }}
2365 }}
2366 }}"#,
2367 parent_node_id, child_node_id
2368 );
2369 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2370 .await?;
2371 Ok(())
2372 }
2373
2374 pub async fn link_issue_to_branch(
2386 &self,
2387 repo: &str,
2388 issue_number: u64,
2389 branch: &str,
2390 ) -> anyhow::Result<String> {
2391 let parts: Vec<&str> = repo.split('/').collect();
2392 if parts.len() != 2 {
2393 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2394 }
2395 let (owner, repo_name) = (parts[0], parts[1]);
2396
2397 let query = r#"query($owner: String!, $name: String!, $number: Int!) {
2399 repository(owner: $owner, name: $name) {
2400 id
2401 issue(number: $number) {
2402 id
2403 }
2404 }
2405 }"#;
2406 let vars = serde_json::json!({
2407 "owner": owner,
2408 "name": repo_name,
2409 "number": issue_number,
2410 });
2411
2412 let result = self.graphql_with_vars(query, vars).await?;
2413
2414 let repo_id = result
2415 .pointer("/data/repository/id")
2416 .and_then(|v| v.as_str())
2417 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2418
2419 let issue_id = result
2420 .pointer("/data/repository/issue/id")
2421 .and_then(|v| v.as_str())
2422 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2423
2424 let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
2426 repository(owner: $owner, name: $name) {
2427 ref(qualifiedName: $ref) {
2428 target {
2429 oid
2430 }
2431 }
2432 }
2433 }"#;
2434 let branch_vars = serde_json::json!({
2435 "owner": owner,
2436 "name": repo_name,
2437 "ref": format!("refs/heads/{}", branch),
2438 });
2439
2440 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2441 let branch_oid = branch_result
2442 .pointer("/data/repository/ref/target/oid")
2443 .and_then(|v| v.as_str());
2444
2445 let branch_oid = match branch_oid {
2448 Some(oid) => oid.to_string(),
2449 None => {
2450 let default_query = r#"query($owner: String!, $name: String!) {
2452 repository(owner: $owner, name: $name) {
2453 defaultBranchRef {
2454 target {
2455 oid
2456 }
2457 }
2458 }
2459 }"#;
2460 let default_vars = serde_json::json!({
2461 "owner": owner,
2462 "name": repo_name,
2463 });
2464 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2465 match default_result
2466 .pointer("/data/repository/defaultBranchRef/target/oid")
2467 .and_then(|v| v.as_str())
2468 {
2469 Some(oid) => {
2470 tracing::debug!(
2471 repo,
2472 issue_number,
2473 branch,
2474 "branch not found, using default branch OID to create and link"
2475 );
2476 oid.to_string()
2477 }
2478 None => {
2479 return Ok(format!("unlinked:{}", branch));
2480 }
2481 }
2482 }
2483 };
2484
2485 let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2488 createLinkedBranch(input: {
2489 issueId: $issueId
2490 repositoryId: $repositoryId
2491 name: $name
2492 oid: $oid
2493 }) {
2494 linkedBranch {
2495 id
2496 ref {
2497 name
2498 }
2499 }
2500 }
2501 }"#;
2502 let mutation_vars = serde_json::json!({
2503 "issueId": issue_id,
2504 "repositoryId": repo_id,
2505 "name": branch,
2506 "oid": branch_oid,
2507 });
2508
2509 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2510
2511 match link_result {
2512 Ok(result) => {
2513 let linked_branch_id = result
2514 .pointer("/data/createLinkedBranch/linkedBranch/id")
2515 .and_then(|v| v.as_str())
2516 .map(String::from);
2517
2518 if let Some(id) = linked_branch_id {
2519 tracing::info!(
2520 repo,
2521 issue_number,
2522 branch,
2523 "linked issue to branch via GraphQL API"
2524 );
2525 Ok(id)
2526 } else {
2527 let errors = result
2529 .get("errors")
2530 .and_then(|e| e.as_array())
2531 .cloned()
2532 .unwrap_or_default();
2533
2534 if errors.iter().any(|e| {
2535 e.get("message")
2536 .and_then(|m| m.as_str())
2537 .map(|m| m.contains("already"))
2538 .unwrap_or(false)
2539 }) {
2540 tracing::debug!(
2541 repo,
2542 issue_number,
2543 branch,
2544 "issue already linked to branch"
2545 );
2546 Ok(format!("already_linked:{}", branch))
2547 } else {
2548 anyhow::bail!("failed to link issue to branch: {:?}", result)
2549 }
2550 }
2551 }
2552 Err(e) => {
2553 let err_str = format!("{}", e);
2555 if err_str.contains("already") || err_str.contains("existing") {
2556 tracing::debug!(
2557 repo,
2558 issue_number,
2559 branch,
2560 "issue already linked to branch (error check)"
2561 );
2562 Ok(format!("already_linked:{}", branch))
2563 } else {
2564 Err(e)
2565 }
2566 }
2567 }
2568 }
2569
2570 pub async fn merge_pr(
2573 &self,
2574 repo: &str,
2575 pr_number: u64,
2576 delete_branch: bool,
2577 head_sha: &str,
2578 ) -> anyhow::Result<()> {
2579 if !is_full_commit_sha(head_sha) {
2580 anyhow::bail!("merge_pr requires the full head sha, got {head_sha:?}");
2581 }
2582 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2583 Self::check_backoff(&url)?;
2584 let payload = merge_pr_body(head_sha);
2585 let auth = self.auth_header().await?;
2586 let make_req = || {
2587 Ok(self
2588 .client
2589 .put(&url)
2590 .json(&payload)
2591 .header(header::AUTHORIZATION, auth.clone())
2592 .header(header::ACCEPT, "application/vnd.github+json")
2593 .header("X-GitHub-Api-Version", "2022-11-28"))
2594 };
2595 let resp = self.send_with_retries(make_req, false).await?;
2596 let status = resp.status();
2597 if !status.is_success() {
2598 let body = resp.text().await.unwrap_or_default();
2599 anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2600 }
2601 Self::record_success();
2602
2603 if delete_branch {
2605 let pr: serde_json::Value = self
2607 .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2608 .await?;
2609 if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2610 let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2611 if let Err(e) = self.delete(&del_url).await {
2612 let err_str = e.to_string();
2613 if err_str.contains("Reference does not exist") {
2614 tracing::debug!(
2615 ref_name,
2616 "branch already gone (GitHub auto-deleted on merge)"
2617 );
2618 } else {
2619 tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2620 }
2621 }
2622 }
2623 }
2624 Ok(())
2625 }
2626
2627 pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2633 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2634 Self::check_backoff(&url)?;
2635 let auth = self.auth_header().await?;
2636 let make_req = || {
2637 Ok(self
2638 .client
2639 .put(&url)
2640 .header(header::AUTHORIZATION, auth.clone())
2641 .header(header::ACCEPT, "application/vnd.github+json")
2642 .header("X-GitHub-Api-Version", "2022-11-28"))
2643 };
2644 let resp = self.send_with_retries(make_req, false).await?;
2645 let status = resp.status();
2646 if !status.is_success() {
2647 let body = resp.text().await.unwrap_or_default();
2648 Self::maybe_record_rate_limit_from_body(status, &body);
2649 anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2650 }
2651 Self::record_success();
2652 Ok(())
2653 }
2654
2655 pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2659 let parts: Vec<&str> = repo.split('/').collect();
2660 if parts.len() != 2 {
2661 anyhow::bail!("invalid repo format: {repo}");
2662 }
2663 let (owner, repo_name) = (parts[0], parts[1]);
2664
2665 let pr_query = r#"query($owner: String!, $name: String!, $number: Int!) {
2667 repository(owner: $owner, name: $name) {
2668 pullRequest(number: $number) { id }
2669 }
2670 }"#;
2671 let body = self
2672 .graphql_with_vars(
2673 pr_query,
2674 serde_json::json!({
2675 "owner": owner,
2676 "name": repo_name,
2677 "number": pr_number,
2678 }),
2679 )
2680 .await?;
2681 let pr_id = body
2682 .pointer("/data/repository/pullRequest/id")
2683 .and_then(|v| v.as_str())
2684 .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2685 .to_string();
2686
2687 let mutation = r#"mutation($prId: ID!) {
2689 enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2690 pullRequest { autoMergeRequest { enabledAt } }
2691 }
2692 }"#;
2693 let body = self
2694 .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2695 .await?;
2696 if let Some(errors) = body.get("errors") {
2697 anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2698 }
2699
2700 tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2701 Ok(())
2702 }
2703
2704 pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2706 let parts: Vec<&str> = repo.split('/').collect();
2707 if parts.len() != 2 {
2708 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2709 }
2710 let (owner, repo_name) = (parts[0], parts[1]);
2711 let number = match parse_positive_issue_number(number) {
2712 Ok(number) => number,
2713 Err(e) => {
2714 tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2715 return Ok(Vec::new());
2716 }
2717 };
2718
2719 let mut all_numbers: Vec<u64> = Vec::new();
2720 let mut cursor: Option<String> = None;
2721 let page_size = 100;
2722 let max_pages = 50;
2723 let mut page_count = 0;
2724
2725 loop {
2726 page_count += 1;
2727 if page_count > max_pages {
2728 tracing::warn!(
2729 repo,
2730 number,
2731 "get_sub_issues hit max page limit ({max_pages})"
2732 );
2733 break;
2734 }
2735
2736 let (query, vars) = if let Some(ref c) = cursor {
2737 (
2738 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2739 repository(owner: $owner, name: $name) {
2740 issue(number: $number) {
2741 subIssues(first: $first, after: $after) {
2742 nodes { number }
2743 pageInfo { hasNextPage endCursor }
2744 }
2745 }
2746 }
2747 }"#,
2748 serde_json::json!({
2749 "owner": owner,
2750 "name": repo_name,
2751 "number": number,
2752 "first": page_size,
2753 "after": c,
2754 }),
2755 )
2756 } else {
2757 (
2758 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2759 repository(owner: $owner, name: $name) {
2760 issue(number: $number) {
2761 subIssues(first: $first) {
2762 nodes { number }
2763 pageInfo { hasNextPage endCursor }
2764 }
2765 }
2766 }
2767 }"#,
2768 serde_json::json!({
2769 "owner": owner,
2770 "name": repo_name,
2771 "number": number,
2772 "first": page_size,
2773 }),
2774 )
2775 };
2776
2777 let result = self
2778 .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2779 .await?;
2780
2781 let sub_issues_data = result
2782 .get("data")
2783 .and_then(|d| d.get("repository"))
2784 .and_then(|r| r.get("issue"))
2785 .and_then(|i| i.get("subIssues"));
2786
2787 let nodes = sub_issues_data
2788 .and_then(|s| s.get("nodes"))
2789 .and_then(|n| n.as_array());
2790
2791 match nodes {
2792 Some(nodes) => {
2793 let numbers: Vec<u64> = nodes
2794 .iter()
2795 .filter_map(|n| n.get("number").and_then(|num| num.as_u64()))
2796 .collect();
2797 all_numbers.extend(numbers);
2798 }
2799 None => break,
2800 }
2801
2802 let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2803 let has_next = page_info
2804 .and_then(|p| p.get("hasNextPage"))
2805 .and_then(|h| h.as_bool())
2806 .unwrap_or(false);
2807
2808 if !has_next {
2809 break;
2810 }
2811
2812 cursor = page_info
2813 .and_then(|p| p.get("endCursor"))
2814 .and_then(|c| c.as_str())
2815 .map(|s| s.to_string());
2816 }
2817
2818 Ok(all_numbers)
2819 }
2820
2821 pub async fn get_combined_status(
2823 &self,
2824 repo: &str,
2825 git_ref: &str,
2826 has_workflows: bool,
2827 mergeable_state: Option<&str>,
2828 ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2829 let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2830 let resp: serde_json::Value = self.get_json(&url).await?;
2831
2832 let runs = resp
2833 .get("check_runs")
2834 .and_then(|v| v.as_array())
2835 .cloned()
2836 .unwrap_or_default();
2837
2838 let mut passing = 0u64;
2839 let mut failing = 0u64;
2840 let mut pending = 0u64;
2841
2842 for run in &runs {
2843 let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2844 let status = run
2845 .get("status")
2846 .and_then(|v| v.as_str())
2847 .unwrap_or("queued");
2848
2849 match status {
2850 "completed" => {
2851 if conclusion.is_empty() {
2852 pending += 1;
2853 } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2854 passing += 1;
2855 } else {
2856 failing += 1;
2857 }
2858 }
2859 _ => pending += 1,
2860 }
2861 }
2862
2863 let total = runs.len() as u64;
2864 let state =
2865 Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
2866
2867 Ok((state, total, passing, failing, pending))
2868 }
2869
2870 pub async fn rerun_failed_workflow(
2876 &self,
2877 repo: &str,
2878 workflow_name: &str,
2879 branch: &str,
2880 ) -> anyhow::Result<()> {
2881 let url = format!(
2883 "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
2884 );
2885 let resp: serde_json::Value = self.get_json(&url).await?;
2886 let runs = resp["workflow_runs"]
2887 .as_array()
2888 .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
2889
2890 let run_id = runs
2892 .iter()
2893 .find(|r| {
2894 r["name"].as_str() == Some(workflow_name)
2895 && matches!(
2896 r["conclusion"].as_str(),
2897 Some("failure") | Some("cancelled")
2898 )
2899 })
2900 .and_then(|r| r["id"].as_u64())
2901 .ok_or_else(|| {
2902 anyhow::anyhow!(
2903 "no failed run found for workflow '{}' on branch '{}'",
2904 workflow_name,
2905 branch
2906 )
2907 })?;
2908
2909 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
2911 let auth = self.auth_header().await?;
2912 let make_req = || {
2913 Ok(self
2914 .client
2915 .post(&rerun_url)
2916 .header(header::AUTHORIZATION, auth.clone())
2917 .header(header::ACCEPT, "application/vnd.github+json")
2918 .header("X-GitHub-Api-Version", "2022-11-28"))
2919 };
2920 let resp = self.send_with_retries(make_req, false).await?;
2921 let status = resp.status();
2922 if !status.is_success() {
2923 let body = resp.text().await.unwrap_or_default();
2924 Self::maybe_record_rate_limit_from_body(status, &body);
2925 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
2926 }
2927
2928 Ok(())
2929 }
2930
2931 pub async fn batch_fetch_pr_review_data(
2937 &self,
2938 repo: &str,
2939 pr_numbers: &[u64],
2940 ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
2941 if pr_numbers.is_empty() {
2942 return Ok(std::collections::HashMap::new());
2943 }
2944
2945 let (owner, name) = repo
2946 .split_once('/')
2947 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2948
2949 let aliases: String = pr_numbers
2951 .iter()
2952 .map(|&n| build_pr_review_alias(n))
2953 .collect::<Vec<_>>()
2954 .join("\n");
2955
2956 let query = format!(
2957 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2958 );
2959
2960 let resp = self
2961 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2962 .await?;
2963 let repo_data = resp
2964 .pointer("/data/repository")
2965 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2966
2967 let mut result = std::collections::HashMap::new();
2968 for &n in pr_numbers {
2969 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
2970 continue;
2971 };
2972
2973 let merged = pr_data
2974 .get("merged")
2975 .and_then(|v| v.as_bool())
2976 .unwrap_or(false);
2977 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
2978 Some("MERGEABLE") => Some(true),
2979 Some("CONFLICTING") => Some(false),
2980 _ => None, };
2982
2983 let mut reviews: Vec<GitHubReview> = pr_data
2984 .pointer("/reviews/nodes")
2985 .and_then(|v| v.as_array())
2986 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
2987 .unwrap_or_default();
2988
2989 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
2990 tracing::warn!(
2991 pr_number = n,
2992 fetched = reviews.len(),
2993 "batch review fetch truncated reviews connection at first page"
2994 );
2995 }
2996
2997 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
3001
3002 let mut review_comments: Vec<GitHubReviewComment> = pr_data
3003 .pointer("/reviewThreads/nodes")
3004 .and_then(|v| v.as_array())
3005 .map(|threads| {
3006 threads
3007 .iter()
3008 .filter_map(|thread| {
3009 thread
3010 .pointer("/comments/nodes")
3011 .and_then(|v| v.as_array())
3012 .map(|nodes| {
3013 nodes
3014 .iter()
3015 .filter_map(parse_graphql_review_comment)
3016 .collect::<Vec<_>>()
3017 })
3018 })
3019 .flatten()
3020 .collect()
3021 })
3022 .unwrap_or_default();
3023
3024 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3025 tracing::warn!(
3026 pr_number = n,
3027 fetched_threads = pr_data
3028 .pointer("/reviewThreads/nodes")
3029 .and_then(|v| v.as_array())
3030 .map_or(0, |threads| threads.len()),
3031 "batch review fetch truncated reviewThreads connection at first page"
3032 );
3033 }
3034 if pr_data
3035 .pointer("/reviewThreads/nodes")
3036 .and_then(|v| v.as_array())
3037 .is_some_and(|threads| {
3038 threads.iter().any(|thread| {
3039 connection_has_next_page(thread.pointer("/comments/pageInfo"))
3040 })
3041 })
3042 {
3043 tracing::warn!(
3044 pr_number = n,
3045 "batch review fetch truncated one or more review thread comment connections"
3046 );
3047 }
3048
3049 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3052
3053 let mut issue_comments: Vec<GitHubComment> = pr_data
3054 .pointer("/comments/nodes")
3055 .and_then(|v| v.as_array())
3056 .map(|nodes| {
3057 nodes
3058 .iter()
3059 .filter_map(parse_graphql_issue_comment)
3060 .collect()
3061 })
3062 .unwrap_or_default();
3063
3064 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3065 issue_comments.extend(
3066 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3067 .await?,
3068 );
3069 }
3070
3071 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3076
3077 result.insert(
3078 n,
3079 PrReviewBatchData {
3080 merged,
3081 mergeable,
3082 reviews,
3083 review_comments,
3084 issue_comments,
3085 },
3086 );
3087 }
3088
3089 Ok(result)
3090 }
3091
3092 async fn fetch_pr_issue_comments_after(
3093 &self,
3094 owner: &str,
3095 name: &str,
3096 pr_number: u64,
3097 mut cursor: Option<String>,
3098 ) -> anyhow::Result<Vec<GitHubComment>> {
3099 let mut comments = Vec::new();
3100 let mut page_count = 0;
3101 let max_pages = 50;
3102
3103 while let Some(current_cursor) = cursor.take() {
3104 page_count += 1;
3105 if page_count > max_pages {
3106 tracing::warn!(
3107 pr_number,
3108 fetched = comments.len(),
3109 "stopping PR issue comment pagination after max page limit"
3110 );
3111 break;
3112 }
3113
3114 let (query, vars) =
3115 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
3116 let resp = self.graphql_with_vars(&query, vars).await?;
3117 let comments_data = resp
3118 .pointer("/data/repository/pullRequest/comments")
3119 .ok_or_else(|| {
3120 anyhow::anyhow!(
3121 "missing /data/repository/pullRequest/comments in GraphQL response"
3122 )
3123 })?;
3124
3125 comments.extend(
3126 comments_data
3127 .get("nodes")
3128 .and_then(|v| v.as_array())
3129 .into_iter()
3130 .flatten()
3131 .filter_map(parse_graphql_issue_comment),
3132 );
3133
3134 cursor = connection_end_cursor(comments_data.get("pageInfo"));
3135 }
3136
3137 Ok(comments)
3138 }
3139
3140 pub async fn get_automated_review_status(
3142 &self,
3143 repo: &str,
3144 pr_number: u64,
3145 ) -> anyhow::Result<Option<String>> {
3146 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3147
3148 let mut newest: Option<&crate::github::types::GitHubComment> = None;
3150 for c in &comments {
3151 if !c.body.starts_with("## Automated Review") {
3152 continue;
3153 }
3154 match self.is_collaborator(repo, &c.user.login).await {
3155 Ok(true) => match newest {
3156 None => newest = Some(c),
3157 Some(prev) => {
3158 if c.created_at > prev.created_at {
3159 newest = Some(c);
3160 }
3161 }
3162 },
3163 Ok(false) => {
3164 tracing::warn!(
3165 user = %c.user.login,
3166 pr_number,
3167 "ignoring automated review comment from non-collaborator"
3168 );
3169 continue;
3170 }
3171 Err(e) => {
3172 tracing::debug!(
3173 user = %c.user.login,
3174 error = %e,
3175 "failed to check collaborator status, skipping comment"
3176 );
3177 continue;
3178 }
3179 }
3180 }
3181
3182 match newest {
3183 Some(c) => {
3184 let first_line = c.body.lines().next().unwrap_or("");
3185 if first_line.contains("Automated Review \u{2014} Approve") {
3186 Ok(Some("approve".to_string()))
3187 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3188 Ok(Some("changes_requested".to_string()))
3189 } else {
3190 Ok(None)
3191 }
3192 }
3193 None => Ok(None),
3194 }
3195 }
3196}
3197
3198fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3203 body.get("behind_by")
3204 .and_then(|v| v.as_u64())
3205 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3206}
3207
3208fn is_no_branch_protection_error(err: &str) -> bool {
3211 err.contains("404")
3212 || (err.contains("403")
3213 && err.contains("Upgrade to GitHub Pro or make this repository public"))
3214}
3215
3216fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3217 let mut valid = Vec::with_capacity(issue_numbers.len());
3218 let mut invalid = Vec::new();
3219 for &issue_number in issue_numbers {
3220 if issue_number == 0 {
3221 invalid.push(issue_number);
3222 } else {
3223 valid.push(issue_number);
3224 }
3225 }
3226 (valid, invalid)
3227}
3228
3229#[derive(Debug, Clone)]
3233pub struct RateLimitMetrics {
3234 pub hits: u64,
3236 pub skipped_calls: u64,
3238 pub rest_remaining: Option<u32>,
3240 pub rest_reset_at: Option<u64>,
3242 pub graphql_remaining: Option<u32>,
3244 pub five_xx_hits: u64,
3246}
3247
3248pub fn rate_limit_metrics() -> RateLimitMetrics {
3250 let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
3251 .lock()
3252 .ok()
3253 .map(|rl| (rl.remaining, rl.reset_at))
3254 .unwrap_or((None, None));
3255 let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3256 RateLimitMetrics {
3257 hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3258 skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3259 rest_remaining,
3260 rest_reset_at,
3261 graphql_remaining,
3262 five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3263 }
3264}
3265
3266fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3270 let link = headers.get("link")?.to_str().ok()?;
3271 for part in link.split(',') {
3272 let part = part.trim();
3273 if part.contains("rel=\"next\"") {
3274 let start = part.find('<')? + 1;
3276 let end = part.find('>')?;
3277 if start >= end {
3278 return None;
3279 }
3280 return Some(part[start..end].to_string());
3281 }
3282 }
3283 None
3284}
3285
3286pub fn status_label_color(label: &str) -> &'static str {
3288 match label {
3289 "status:new" => "0e8a16",
3290 "status:routed" => "1d76db",
3291 "status:in_progress" => "fbca04",
3292 "status:done" => "6f42c1",
3293 "status:blocked" => "d73a4a",
3294 "status:in_review" => "0075ca",
3295 "status:needs_review" => "e4e669",
3296 "status:waiting" => "5319e7",
3297 _ => "c5def5",
3298 }
3299}
3300
3301#[derive(Debug, Clone, Default)]
3305pub struct PrReviewBatchData {
3306 pub merged: bool,
3308 pub mergeable: Option<bool>,
3310 pub reviews: Vec<GitHubReview>,
3312 pub review_comments: Vec<GitHubReviewComment>,
3314 pub issue_comments: Vec<GitHubComment>,
3316}
3317
3318fn build_pr_review_alias(n: u64) -> String {
3321 format!(
3322 r#"pr{n}: pullRequest(number: {n}) {{
3323 merged
3324 mergeable
3325 reviews(first: 50) {{
3326 nodes {{
3327 databaseId
3328 author {{ login }}
3329 body
3330 state
3331 url
3332 submittedAt
3333 commit {{ oid }}
3334 }}
3335 pageInfo {{ hasNextPage }}
3336 }}
3337 reviewThreads(first: 50) {{
3338 nodes {{
3339 comments(first: 20) {{
3340 nodes {{
3341 databaseId
3342 author {{ login }}
3343 body
3344 path
3345 line
3346 originalLine
3347 commit {{ oid }}
3348 originalCommit {{ oid }}
3349 url
3350 createdAt
3351 updatedAt
3352 replyTo {{ databaseId }}
3353 diffHunk
3354 }}
3355 pageInfo {{ hasNextPage }}
3356 }}
3357 }}
3358 pageInfo {{ hasNextPage }}
3359 }}
3360 comments(first: 50) {{
3361 nodes {{
3362 databaseId
3363 author {{ login }}
3364 body
3365 createdAt
3366 updatedAt
3367 url
3368 }}
3369 pageInfo {{ hasNextPage endCursor }}
3370 }}
3371}}"#
3372 )
3373}
3374
3375fn build_pr_issue_comments_page_query(
3376 owner: &str,
3377 name: &str,
3378 pr_number: u64,
3379 after_cursor: &str,
3380) -> (String, serde_json::Value) {
3381 let query = r#"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3382 repository(owner: $owner, name: $name) {
3383 pullRequest(number: $number) {
3384 comments(first: 100, after: $after) {
3385 nodes {
3386 databaseId
3387 author { login }
3388 body
3389 createdAt
3390 updatedAt
3391 url
3392 }
3393 pageInfo { hasNextPage endCursor }
3394 }
3395 }
3396 }
3397}"#
3398 .to_string();
3399 let vars = serde_json::json!({
3400 "owner": owner,
3401 "name": name,
3402 "number": pr_number,
3403 "after": after_cursor,
3404 });
3405 (query, vars)
3406}
3407
3408fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3409 page_info
3410 .and_then(|v| v.get("hasNextPage"))
3411 .and_then(|v| v.as_bool())
3412 .unwrap_or(false)
3413}
3414
3415fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3416 if !connection_has_next_page(page_info) {
3417 return None;
3418 }
3419
3420 page_info
3421 .and_then(|v| v.get("endCursor"))
3422 .and_then(|v| v.as_str())
3423 .map(str::to_owned)
3424}
3425
3426fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3427 use super::types::GitHubUser;
3428 Some(GitHubReview {
3429 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3430 user: GitHubUser {
3431 login: node
3432 .pointer("/author/login")
3433 .and_then(|v| v.as_str())?
3434 .to_string(),
3435 },
3436 body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3437 state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3438 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3439 submitted_at: node
3440 .get("submittedAt")
3441 .and_then(|v| v.as_str())
3442 .unwrap_or("")
3443 .to_string(),
3444 commit_id: node
3445 .pointer("/commit/oid")
3446 .and_then(|v| v.as_str())
3447 .map(String::from),
3448 })
3449}
3450
3451fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3452 use super::types::GitHubUser;
3453 Some(GitHubReviewComment {
3454 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3455 user: GitHubUser {
3456 login: node
3457 .pointer("/author/login")
3458 .and_then(|v| v.as_str())?
3459 .to_string(),
3460 },
3461 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3462 path: node
3463 .get("path")
3464 .and_then(|v| v.as_str())
3465 .unwrap_or("")
3466 .to_string(),
3467 line: node.get("line").and_then(|v| v.as_u64()).map(|v| v as u32),
3468 original_line: node
3469 .get("originalLine")
3470 .and_then(|v| v.as_u64())
3471 .map(|v| v as u32),
3472 commit_id: node
3473 .pointer("/commit/oid")
3474 .and_then(|v| v.as_str())
3475 .unwrap_or("")
3476 .to_string(),
3477 original_commit_id: node
3478 .pointer("/originalCommit/oid")
3479 .and_then(|v| v.as_str())
3480 .unwrap_or("")
3481 .to_string(),
3482 html_url: node
3483 .get("url")
3484 .and_then(|v| v.as_str())
3485 .unwrap_or("")
3486 .to_string(),
3487 created_at: node
3488 .get("createdAt")
3489 .and_then(|v| v.as_str())
3490 .unwrap_or("")
3491 .to_string(),
3492 updated_at: node
3493 .get("updatedAt")
3494 .and_then(|v| v.as_str())
3495 .unwrap_or("")
3496 .to_string(),
3497 in_reply_to_id: node.pointer("/replyTo/databaseId").and_then(|v| v.as_u64()),
3498 diff_hunk: node
3499 .get("diffHunk")
3500 .and_then(|v| v.as_str())
3501 .map(String::from),
3502 })
3503}
3504
3505fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3506 use super::types::GitHubUser;
3507 Some(GitHubComment {
3508 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3509 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3510 user: GitHubUser {
3511 login: node
3512 .pointer("/author/login")
3513 .and_then(|v| v.as_str())?
3514 .to_string(),
3515 },
3516 created_at: node
3517 .get("createdAt")
3518 .and_then(|v| v.as_str())
3519 .unwrap_or("")
3520 .to_string(),
3521 updated_at: node
3522 .get("updatedAt")
3523 .and_then(|v| v.as_str())
3524 .map(String::from),
3525 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3526 issue_url: None,
3527 author_association: None,
3528 })
3529}
3530
3531#[cfg(test)]
3532mod tests {
3533 use super::*;
3534 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3535 use tokio::net::TcpListener;
3536
3537 #[test]
3538 fn no_branch_protection_errors() {
3539 let free_plan = "GitHub API GET https://api.github.com/repos/o/r/branches/main/protection failed (403 Forbidden): {\"message\":\"Upgrade to GitHub Pro or make this repository public to enable this feature.\"}";
3540 assert!(is_no_branch_protection_error(free_plan));
3541 assert!(is_no_branch_protection_error(
3542 "failed (404 Not Found): {\"message\":\"Branch not protected\"}"
3543 ));
3544 assert!(!is_no_branch_protection_error(
3545 "failed (403 Forbidden): {\"message\":\"Resource not accessible\"}"
3546 ));
3547 }
3548
3549 #[test]
3550 fn parse_behind_by_reads_compare_status() {
3551 let behind = serde_json::json!({"status": "behind", "behind_by": 3, "ahead_by": 0});
3553 assert_eq!(parse_behind_by(&behind).unwrap(), 3);
3554 let diverged = serde_json::json!({"status": "diverged", "behind_by": 1, "ahead_by": 2});
3555 assert_eq!(parse_behind_by(&diverged).unwrap(), 1);
3556 let ahead = serde_json::json!({"status": "ahead", "behind_by": 0, "ahead_by": 2});
3557 assert_eq!(parse_behind_by(&ahead).unwrap(), 0);
3558 assert!(parse_behind_by(&serde_json::json!({"status": "ahead"})).is_err());
3560 }
3561
3562 async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3563 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3564 let addr = listener.local_addr().unwrap();
3565
3566 tokio::spawn(async move {
3567 let (mut socket, _) = listener.accept().await.unwrap();
3568 let mut buf = [0u8; 4096];
3569 let _ = socket.read(&mut buf).await;
3570
3571 let declared_len = body_prefix.len() + 32;
3572 let response = format!(
3573 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
3574 );
3575 socket.write_all(response.as_bytes()).await.unwrap();
3576 socket.shutdown().await.unwrap();
3577 });
3578
3579 format!("http://{addr}")
3580 }
3581
3582 #[test]
3583 fn merge_pr_body_pins_the_head_sha() {
3584 let sha = "0123456789abcdef0123456789abcdef01234567";
3585 let body = merge_pr_body(sha);
3586 assert_eq!(body["merge_method"], "squash");
3587 assert_eq!(body["sha"], sha);
3588 assert!(is_full_commit_sha(sha));
3589 assert!(!is_full_commit_sha("abc"));
3590 assert!(!is_full_commit_sha(""));
3591 }
3592
3593 fn test_client() -> GhHttp {
3594 GhHttp {
3595 client: reqwest::Client::new(),
3596 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3597 cached_username: OnceLock::new(),
3598 }
3599 }
3600
3601 #[test]
3606 fn gh_http_new_returns_ok() {
3607 let result = GhHttp::new();
3608 assert!(
3609 result.is_ok(),
3610 "GhHttp::new() must not panic and must return Ok; got: {:?}",
3611 result.err()
3612 );
3613 }
3614
3615 #[test]
3616 fn batch_get_issue_states_query_includes_labels_pagination() {
3617 let issue_numbers = [1u64, 2, 3];
3618
3619 let aliases: String = issue_numbers
3620 .iter()
3621 .map(|n| {
3622 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
3623 })
3624 .collect::<Vec<_>>()
3625 .join(" ");
3626
3627 let query = format!(
3628 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
3629 );
3630 assert!(query.contains("$owner"));
3631 assert!(query.contains("$name"));
3632 assert!(query.contains("issue1: issue(number: 1)"));
3633 assert!(query.contains("issue3: issue(number: 3)"));
3634 assert!(query.contains("labels(first: 100)"));
3635 }
3636
3637 #[test]
3638 fn parse_positive_issue_number_accepts_positive_values() {
3639 assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
3640 }
3641
3642 #[test]
3643 fn parse_positive_issue_number_rejects_zero() {
3644 let err = parse_positive_issue_number("0").unwrap_err().to_string();
3645 assert!(err.contains("invalid issue number: 0"));
3646 }
3647
3648 #[test]
3649 fn parse_positive_issue_number_rejects_internal_ids() {
3650 let err = parse_positive_issue_number("internal:5")
3651 .unwrap_err()
3652 .to_string();
3653 assert!(err.contains("invalid issue number: internal:5"));
3654 }
3655
3656 #[test]
3657 fn pr_review_batch_query_includes_page_info_for_nested_connections() {
3658 let query = build_pr_review_alias(42);
3659
3660 assert!(query.contains("reviews(first: 50)"));
3661 assert!(query.contains("reviews(first: 50) {\n nodes"));
3662 assert!(query.contains("pageInfo { hasNextPage }"));
3663 assert!(query.contains("reviewThreads(first: 50)"));
3664 assert!(query.contains("comments(first: 20)"));
3665 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3666 assert!(query.contains("updatedAt"));
3667 }
3668
3669 #[test]
3670 fn pr_issue_comments_page_query_uses_cursor_pagination() {
3671 let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
3672
3673 assert!(query.contains("pullRequest(number: $number)"));
3674 assert!(query.contains("comments(first: 100, after: $after)"));
3675 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3676 assert!(query.contains("updatedAt"));
3677 assert_eq!(vars["owner"], "owner");
3678 assert_eq!(vars["name"], "repo");
3679 assert_eq!(vars["number"], 42);
3680 assert_eq!(vars["after"], "cursor123");
3681 }
3682
3683 #[test]
3684 fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
3685 let page_info = serde_json::json!({
3686 "hasNextPage": true,
3687 "endCursor": "cursor123"
3688 });
3689 assert_eq!(
3690 connection_end_cursor(Some(&page_info)),
3691 Some("cursor123".to_string())
3692 );
3693
3694 let final_page = serde_json::json!({
3695 "hasNextPage": false,
3696 "endCursor": "cursor456"
3697 });
3698 assert_eq!(connection_end_cursor(Some(&final_page)), None);
3699 }
3700
3701 #[test]
3702 fn status_label_colors_match_bash_palette() {
3703 assert_eq!(status_label_color("status:new"), "0e8a16");
3704 assert_eq!(status_label_color("status:routed"), "1d76db");
3705 assert_eq!(status_label_color("status:in_progress"), "fbca04");
3706 assert_eq!(status_label_color("status:done"), "6f42c1");
3707 assert_eq!(status_label_color("status:blocked"), "d73a4a");
3708 assert_eq!(status_label_color("status:in_review"), "0075ca");
3709 assert_eq!(status_label_color("status:needs_review"), "e4e669");
3710 }
3711
3712 #[test]
3713 fn unknown_label_gets_default_color() {
3714 assert_eq!(status_label_color("agent:claude"), "c5def5");
3715 assert_eq!(status_label_color("enhancement"), "c5def5");
3716 assert_eq!(status_label_color(""), "c5def5");
3717 }
3718
3719 #[test]
3720 fn parse_link_next_finds_next_url() {
3721 let mut headers = header::HeaderMap::new();
3722 headers.insert(
3723 "link",
3724 "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
3725 .parse()
3726 .unwrap(),
3727 );
3728 assert_eq!(
3729 parse_link_next(&headers),
3730 Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
3731 );
3732 }
3733
3734 #[test]
3735 fn parse_link_next_none_when_no_next() {
3736 let mut headers = header::HeaderMap::new();
3737 headers.insert(
3738 "link",
3739 "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
3740 .parse()
3741 .unwrap(),
3742 );
3743 assert_eq!(parse_link_next(&headers), None);
3744 }
3745
3746 #[test]
3747 fn parse_link_next_none_when_missing() {
3748 let headers = header::HeaderMap::new();
3749 assert_eq!(parse_link_next(&headers), None);
3750 }
3751
3752 #[test]
3753 fn parse_link_next_none_when_malformed() {
3754 let mut headers = header::HeaderMap::new();
3755 headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
3757 assert_eq!(parse_link_next(&headers), None);
3758 }
3759
3760 #[test]
3761 fn parse_link_next_none_when_gt_before_lt() {
3762 let mut headers = header::HeaderMap::new();
3763 headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
3764 assert_eq!(parse_link_next(&headers), None); }
3766
3767 #[test]
3768 fn parse_link_next_none_when_empty_url() {
3769 let mut headers = header::HeaderMap::new();
3770 headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
3771 assert_eq!(parse_link_next(&headers), None);
3772 }
3773
3774 fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
3775 RateLimit {
3776 remaining: None,
3777 reset_at: None,
3778 backoff_until: None,
3779 backoff_delay: Duration::ZERO,
3780 backoff_base: Duration::from_secs(base_secs),
3781 backoff_max: Duration::from_secs(max_secs),
3782 throttle_threshold: 10,
3783 }
3784 }
3785
3786 #[test]
3787 fn rate_limit_inactive_by_default() {
3788 let rl = make_rl(30, 900);
3789 assert!(rl.is_active().is_none());
3790 }
3791
3792 #[test]
3793 fn rate_limit_backoff_activates() {
3794 let mut rl = make_rl(5, 60);
3795 rl.record_rate_limit();
3796 assert!(rl.is_active().is_some());
3797 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3798 }
3799
3800 #[test]
3801 fn rate_limit_backoff_doubles() {
3802 let mut rl = make_rl(5, 60);
3803 rl.record_rate_limit();
3804 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3805 rl.record_rate_limit();
3806 assert_eq!(rl.backoff_delay, Duration::from_secs(10));
3807 rl.record_rate_limit();
3808 assert_eq!(rl.backoff_delay, Duration::from_secs(20));
3809 }
3810
3811 #[test]
3812 fn rate_limit_backoff_caps_at_max() {
3813 let mut rl = make_rl(30, 60);
3814 rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
3818 }
3819
3820 #[test]
3821 fn rate_limit_success_resets() {
3822 let mut rl = make_rl(5, 60);
3823 rl.record_rate_limit();
3824 assert!(rl.is_active().is_some());
3825 rl.record_success();
3826 assert!(rl.is_active().is_none());
3827 assert_eq!(rl.backoff_delay, Duration::ZERO);
3828 }
3829
3830 #[test]
3831 fn page_cache_key_separates_types_and_queries() {
3832 let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
3833 let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
3834 let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
3835 assert_ne!(a, b);
3836 assert_ne!(a, c);
3837 assert_eq!(
3838 a,
3839 page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
3840 );
3841 }
3842
3843 #[test]
3845 fn page_cache_key_skips_since_filtered_requests() {
3846 assert!(page_cache_key::<GitHubIssue>(
3847 "/issues",
3848 Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
3849 )
3850 .is_none());
3851 assert!(
3853 page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
3854 .is_none()
3855 );
3856 }
3857
3858 #[test]
3861 fn low_quota_fails_fast_instead_of_waiting_for_reset() {
3862 let mut rl = make_rl(30, 900);
3863 rl.remaining = Some(0);
3864 rl.reset_at = Some(
3865 std::time::SystemTime::now()
3866 .duration_since(std::time::UNIX_EPOCH)
3867 .unwrap()
3868 .as_secs()
3869 + 3600,
3870 );
3871 let cell = Mutex::new(rl);
3872
3873 let start = Instant::now();
3874 let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
3875 assert!(
3876 start.elapsed() < Duration::from_secs(1),
3877 "check blocked for {:?}",
3878 start.elapsed()
3879 );
3880 assert!(err.to_string().contains("rate-limited"), "{err}");
3881 }
3882
3883 #[test]
3885 fn healthy_quota_passes_the_check() {
3886 let mut rl = make_rl(30, 900);
3887 rl.remaining = Some(4000);
3888 rl.reset_at = Some(u64::MAX / 2);
3889 let cell = Mutex::new(rl);
3890 assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
3891 }
3892
3893 #[test]
3894 fn rate_limit_proactive_pause() {
3895 let mut rl = make_rl(30, 900);
3896 rl.remaining = Some(0);
3898 let future_epoch = std::time::SystemTime::now()
3899 .duration_since(std::time::UNIX_EPOCH)
3900 .unwrap()
3901 .as_secs()
3902 + 60;
3903 rl.reset_at = Some(future_epoch);
3904 let pause = rl.is_active();
3905 assert!(pause.is_some());
3906 assert!(pause.unwrap().as_secs() <= 62);
3908 }
3909
3910 #[test]
3911 fn rate_limit_proactive_pause_threshold() {
3912 let mut rl = make_rl(30, 900);
3913 rl.remaining = Some(15);
3915 let future_epoch = std::time::SystemTime::now()
3916 .duration_since(std::time::UNIX_EPOCH)
3917 .unwrap()
3918 .as_secs()
3919 + 60;
3920 rl.reset_at = Some(future_epoch);
3921 assert!(rl.is_active().is_none());
3922
3923 rl.remaining = Some(5);
3925 assert!(rl.is_active().is_some());
3926 }
3927
3928 #[test]
3929 fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
3930 assert_eq!(
3932 GhHttp::combined_status_state(0, 0, 0, true, None),
3933 "pending"
3934 );
3935 assert_eq!(
3937 GhHttp::combined_status_state(0, 0, 0, false, None),
3938 "success"
3939 );
3940 assert_eq!(
3942 GhHttp::combined_status_state(3, 1, 0, true, None),
3943 "failure"
3944 );
3945 assert_eq!(
3947 GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
3948 "success"
3949 );
3950 assert_eq!(
3952 GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
3953 "pending"
3954 );
3955 }
3956
3957 #[test]
3958 fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
3959 let resp = serde_json::json!({
3960 "data": {},
3961 "errors": [{ "message": "Something went wrong" }],
3962 });
3963
3964 let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
3965 .unwrap_err()
3966 .to_string();
3967 assert!(err_text.contains("missing /data/repository in GraphQL response"));
3968 assert!(err_text.contains("Something went wrong"));
3969 }
3970
3971 #[test]
3972 fn rate_limit_uses_reset_at_for_backoff() {
3973 let mut rl = make_rl(30, 900);
3974 let future_epoch = std::time::SystemTime::now()
3976 .duration_since(std::time::UNIX_EPOCH)
3977 .unwrap()
3978 .as_secs()
3979 + 45;
3980 rl.reset_at = Some(future_epoch);
3981 rl.record_rate_limit();
3982 assert!(rl.backoff_delay.as_secs() >= 45);
3984 assert!(rl.backoff_delay.as_secs() <= 47);
3985 }
3986
3987 #[tokio::test]
3988 async fn send_with_retries_propagates_make_req_error() {
3989 let guard = std::env::var_os("GH_TOKEN");
3990 std::env::set_var("GH_TOKEN", "test_token");
3991 let gh = test_client();
3992 let result = gh
3993 .send_with_retries(
3994 || Err(anyhow::anyhow!("simulated request clone failure")),
3995 false,
3996 )
3997 .await;
3998 assert!(result.is_err());
3999 let err = result.unwrap_err().to_string();
4000 assert_eq!(err, "simulated request clone failure");
4001 match guard {
4002 Some(v) => std::env::set_var("GH_TOKEN", v),
4003 None => std::env::remove_var("GH_TOKEN"),
4004 }
4005 }
4006
4007 #[tokio::test]
4013 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
4014 let guard = std::env::var_os("GH_TOKEN");
4015 std::env::set_var("GH_TOKEN", "test_token");
4016
4017 let addr = {
4021 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4022 listener.local_addr().unwrap()
4023 };
4024 let url = format!("http://{addr}/");
4025
4026 let gh = test_client();
4027 let make_req = || Ok(gh.client.get(&url));
4028 let result = gh.send_with_retries(make_req, false).await;
4029
4030 assert!(result.is_err());
4031 assert!(
4032 !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
4033 "transport error must not trip the github:5xx circuit breaker"
4034 );
4035 assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
4036
4037 match guard {
4038 Some(v) => std::env::set_var("GH_TOKEN", v),
4039 None => std::env::remove_var("GH_TOKEN"),
4040 }
4041 }
4042
4043 #[tokio::test]
4044 async fn post_json_raw_propagates_body_read_errors_on_success() {
4045 let guard = std::env::var_os("GH_TOKEN");
4046 std::env::set_var("GH_TOKEN", "test_token");
4047 let server_url = spawn_truncated_body_server("{").await;
4048 let gh = test_client();
4049
4050 let result = gh
4051 .post_json_raw(
4052 &format!("{server_url}/post"),
4053 &serde_json::json!({"ok": true}),
4054 )
4055 .await;
4056
4057 let err = result.unwrap_err().to_string();
4058 assert!(
4059 !err.contains("EOF while parsing a value"),
4060 "should surface the body read error, got: {err}"
4061 );
4062 assert!(
4063 err.contains("error decoding response body")
4064 || err.contains("request or response body error")
4065 || err.contains("end of file before message length reached"),
4066 "expected a transport/body read error, got: {err}"
4067 );
4068
4069 match guard {
4070 Some(v) => std::env::set_var("GH_TOKEN", v),
4071 None => std::env::remove_var("GH_TOKEN"),
4072 }
4073 }
4074
4075 #[tokio::test]
4076 async fn patch_json_raw_propagates_body_read_errors_on_success() {
4077 let guard = std::env::var_os("GH_TOKEN");
4078 std::env::set_var("GH_TOKEN", "test_token");
4079 let server_url = spawn_truncated_body_server("{").await;
4080 let gh = test_client();
4081
4082 let result = gh
4083 .patch_json_raw(
4084 &format!("{server_url}/patch"),
4085 &serde_json::json!({"ok": true}),
4086 )
4087 .await;
4088
4089 let err = result.unwrap_err().to_string();
4090 assert!(
4091 !err.contains("EOF while parsing a value"),
4092 "should surface the body read error, got: {err}"
4093 );
4094 assert!(
4095 err.contains("error decoding response body")
4096 || err.contains("request or response body error")
4097 || err.contains("end of file before message length reached"),
4098 "expected a transport/body read error, got: {err}"
4099 );
4100
4101 match guard {
4102 Some(v) => std::env::set_var("GH_TOKEN", v),
4103 None => std::env::remove_var("GH_TOKEN"),
4104 }
4105 }
4106
4107 #[tokio::test]
4108 async fn graphql_request_propagates_body_read_errors_on_success() {
4109 let guard = std::env::var_os("GH_TOKEN");
4110 std::env::set_var("GH_TOKEN", "test_token");
4111 let server_url = spawn_truncated_body_server("{").await;
4112 let gh = test_client();
4113 let query = "query { viewer { login } }";
4114
4115 let result = gh
4116 .graphql_request_to_url(
4117 &format!("{server_url}/graphql"),
4118 query,
4119 &[],
4120 Some(serde_json::json!({"k": "v"})),
4121 )
4122 .await;
4123
4124 let err = result.unwrap_err().to_string();
4125 assert!(
4126 !err.contains("EOF while parsing a value"),
4127 "should surface the body read error, got: {err}"
4128 );
4129 assert!(
4130 err.contains("error decoding response body")
4131 || err.contains("request or response body error")
4132 || err.contains("end of file before message length reached"),
4133 "expected a transport/body read error, got: {err}"
4134 );
4135
4136 match guard {
4137 Some(v) => std::env::set_var("GH_TOKEN", v),
4138 None => std::env::remove_var("GH_TOKEN"),
4139 }
4140 }
4141
4142 #[cfg(test)]
4145 mod pagination {
4146 use super::super::*;
4147 use std::ffi::OsString;
4148 use std::sync::Arc;
4149 use wiremock::matchers::{method, path};
4150 use wiremock::{matchers, Match, Mock, MockServer, Request, ResponseTemplate};
4151
4152 struct NotMatcher<M>(M);
4155
4156 impl<M: Match> Match for NotMatcher<M> {
4157 fn matches(&self, request: &Request) -> bool {
4158 !self.0.matches(request)
4159 }
4160 }
4161
4162 struct EnvVarGuard {
4163 key: &'static str,
4164 previous: Option<OsString>,
4165 }
4166
4167 impl EnvVarGuard {
4168 fn set(key: &'static str, value: &str) -> Self {
4169 let previous = std::env::var_os(key);
4170 std::env::set_var(key, value);
4171 Self { key, previous }
4172 }
4173 }
4174
4175 impl Drop for EnvVarGuard {
4176 fn drop(&mut self) {
4177 match self.previous.take() {
4178 Some(value) => std::env::set_var(self.key, value),
4179 None => std::env::remove_var(self.key),
4180 }
4181 }
4182 }
4183
4184 fn make_client() -> (GhHttp, EnvVarGuard) {
4187 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4189 let client = GhHttp {
4190 client: reqwest::Client::new(),
4191 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
4192 cached_username: OnceLock::new(),
4193 };
4194 (client, guard)
4195 }
4196
4197 #[tokio::test]
4198 async fn get_all_pages_single_page() {
4199 let server = MockServer::start().await;
4200
4201 Mock::given(method("GET"))
4202 .and(path("/items"))
4203 .respond_with(
4204 ResponseTemplate::new(200)
4205 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4206 )
4207 .expect(1)
4208 .mount(&server)
4209 .await;
4210
4211 let (gh, _guard) = make_client();
4212 let url = format!("{}/items", server.uri());
4213 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4214
4215 assert_eq!(result.len(), 2);
4216 assert_eq!(result[0]["id"], 1);
4217 assert_eq!(result[1]["id"], 2);
4218 }
4219
4220 #[tokio::test]
4221 async fn get_all_pages_follows_link_next() {
4222 let server = MockServer::start().await;
4223
4224 Mock::given(method("GET"))
4226 .and(path("/items"))
4227 .respond_with(
4228 ResponseTemplate::new(200)
4229 .set_body_json(serde_json::json!([{"id": 1}]))
4230 .insert_header(
4231 "Link",
4232 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4233 ),
4234 )
4235 .expect(1)
4236 .mount(&server)
4237 .await;
4238
4239 Mock::given(method("GET"))
4241 .and(path("/items/page2"))
4242 .respond_with(
4243 ResponseTemplate::new(200)
4244 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4245 )
4246 .expect(1)
4247 .mount(&server)
4248 .await;
4249
4250 let (gh, _guard) = make_client();
4251 let url = format!("{}/items", server.uri());
4252 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4253
4254 assert_eq!(result.len(), 3);
4255 assert_eq!(result[0]["id"], 1);
4256 assert_eq!(result[1]["id"], 2);
4257 assert_eq!(result[2]["id"], 3);
4258 }
4259
4260 #[tokio::test]
4261 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4262 let server = MockServer::start().await;
4265
4266 Mock::given(method("GET"))
4267 .and(path("/items"))
4268 .respond_with(
4269 ResponseTemplate::new(200)
4270 .set_body_json(serde_json::json!([{"id": 1}]))
4271 .insert_header(
4272 "Link",
4273 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4274 ),
4275 )
4276 .expect(1)
4277 .mount(&server)
4278 .await;
4279
4280 Mock::given(method("GET"))
4282 .and(path("/items/page2"))
4283 .respond_with(
4284 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4285 )
4286 .expect(1)
4287 .mount(&server)
4288 .await;
4289
4290 let (gh, _guard) = make_client();
4291 let url = format!("{}/items", server.uri());
4292 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4293
4294 assert_eq!(result.len(), 2);
4296 }
4297
4298 #[tokio::test]
4299 async fn get_all_pages_stops_on_malformed_link_header() {
4300 let server = MockServer::start().await;
4303
4304 Mock::given(method("GET"))
4305 .and(path("/items"))
4306 .respond_with(
4307 ResponseTemplate::new(200)
4308 .set_body_json(serde_json::json!([{"id": 1}]))
4309 .insert_header("Link", "garbage; rel=\"next\""),
4310 )
4311 .expect(1)
4312 .mount(&server)
4313 .await;
4314
4315 let (gh, _guard) = make_client();
4316 let url = format!("{}/items", server.uri());
4317 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4318
4319 assert_eq!(result.len(), 1);
4321 }
4322
4323 #[tokio::test]
4324 async fn get_all_pages_returns_error_on_malformed_json_body() {
4325 let server = MockServer::start().await;
4326
4327 Mock::given(method("GET"))
4328 .and(path("/items"))
4329 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4330 .expect(3)
4331 .mount(&server)
4332 .await;
4333
4334 let (gh, _guard) = make_client();
4335 let url = format!("{}/items", server.uri());
4336 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4337
4338 assert!(result.is_err());
4339 let err = result.unwrap_err().to_string();
4340 assert!(
4341 err.contains("JSON decode failed") || err.contains("expected ident"),
4342 "error should mention JSON decode or serde error: {}",
4343 err
4344 );
4345 }
4346
4347 struct GlobalStateGuard;
4352
4353 impl Drop for GlobalStateGuard {
4354 fn drop(&mut self) {
4355 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4356 *rl = RateLimit::new();
4357 }
4358 if let Ok(mut cache) = PAGE_CACHE.lock() {
4359 cache.clear();
4360 }
4361 }
4362 }
4363
4364 #[tokio::test]
4365 async fn get_all_pages_replays_body_on_304() {
4366 let _reset = GlobalStateGuard;
4367 let server = MockServer::start().await;
4368
4369 Mock::given(method("GET"))
4371 .and(path("/issues"))
4372 .and(matchers::header_exists("authorization"))
4373 .and(NotMatcher(matchers::header_exists("if-none-match")))
4374 .respond_with(
4375 ResponseTemplate::new(200)
4376 .set_body_json(serde_json::json!([{"number": 1}]))
4377 .insert_header("ETag", "\"abc123\""),
4378 )
4379 .expect(1)
4380 .mount(&server)
4381 .await;
4382
4383 Mock::given(method("GET"))
4385 .and(path("/issues"))
4386 .and(matchers::header("if-none-match", "\"abc123\""))
4387 .respond_with(ResponseTemplate::new(304))
4388 .expect(1)
4389 .mount(&server)
4390 .await;
4391
4392 let (gh, _guard) = make_client();
4393 let url = format!("{}/issues", server.uri());
4394
4395 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4396 let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4397
4398 assert_eq!(first, second);
4399 assert_eq!(second.len(), 1);
4400 assert_eq!(second[0]["number"], 1);
4401 }
4402
4403 #[tokio::test]
4404 async fn exhausted_quota_skips_further_calls_until_reset() {
4405 let _reset = GlobalStateGuard;
4406 let server = MockServer::start().await;
4407
4408 let reset_at = std::time::SystemTime::now()
4409 .duration_since(std::time::UNIX_EPOCH)
4410 .unwrap()
4411 .as_secs()
4412 + 600;
4413
4414 Mock::given(method("GET"))
4417 .and(path("/issues"))
4418 .respond_with(
4419 ResponseTemplate::new(200)
4420 .set_body_json(serde_json::json!([{"number": 1}]))
4421 .insert_header("x-ratelimit-remaining", "0")
4422 .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4423 )
4424 .expect(1)
4425 .mount(&server)
4426 .await;
4427
4428 let (gh, _guard) = make_client();
4429 let url = format!("{}/issues", server.uri());
4430
4431 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4432 assert_eq!(first.len(), 1);
4433
4434 let start = Instant::now();
4436 let err = gh
4437 .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4438 .await
4439 .unwrap_err();
4440 assert!(
4441 start.elapsed() < Duration::from_secs(1),
4442 "call blocked for {:?} instead of failing fast",
4443 start.elapsed()
4444 );
4445 assert!(err.to_string().contains("rate-limited"), "{err}");
4446
4447 let m = rate_limit_metrics();
4449 assert_eq!(m.rest_remaining, Some(0));
4450 assert_eq!(m.rest_reset_at, Some(reset_at));
4451 assert!(GhHttp::is_rate_limited().is_some());
4452
4453 }
4455
4456 #[tokio::test]
4459 async fn get_all_pages_from_wrapper_single_page() {
4460 let server = MockServer::start().await;
4463
4464 Mock::given(method("GET"))
4465 .and(path("/check-runs"))
4466 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4467 "total_count": 2,
4468 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4469 })))
4470 .expect(1)
4471 .mount(&server)
4472 .await;
4473
4474 let (gh, _guard) = make_client();
4475 let url = format!("{}/check-runs", server.uri());
4476 let result: Vec<serde_json::Value> = gh
4477 .get_all_pages_from_wrapper(&url, "check_runs")
4478 .await
4479 .unwrap();
4480
4481 assert_eq!(result.len(), 2);
4482 assert_eq!(result[0]["id"], 1);
4483 assert_eq!(result[1]["id"], 2);
4484 }
4485
4486 #[tokio::test]
4487 async fn get_all_pages_from_wrapper_follows_link_next() {
4488 let server = MockServer::start().await;
4490
4491 Mock::given(method("GET"))
4492 .and(path("/check-runs"))
4493 .respond_with(
4494 ResponseTemplate::new(200)
4495 .set_body_json(serde_json::json!({
4496 "total_count": 3,
4497 "check_runs": [{"id": 1, "name": "build"}]
4498 }))
4499 .insert_header(
4500 "Link",
4501 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4502 ),
4503 )
4504 .expect(1)
4505 .mount(&server)
4506 .await;
4507
4508 Mock::given(method("GET"))
4509 .and(path("/check-runs/page2"))
4510 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4511 "total_count": 3,
4512 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4513 })))
4514 .expect(1)
4515 .mount(&server)
4516 .await;
4517
4518 let (gh, _guard) = make_client();
4519 let url = format!("{}/check-runs", server.uri());
4520 let result: Vec<serde_json::Value> = gh
4521 .get_all_pages_from_wrapper(&url, "check_runs")
4522 .await
4523 .unwrap();
4524
4525 assert_eq!(result.len(), 3);
4526 assert_eq!(result[0]["id"], 1);
4527 assert_eq!(result[1]["id"], 2);
4528 assert_eq!(result[2]["id"], 3);
4529 }
4530
4531 #[tokio::test]
4532 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4533 let server = MockServer::start().await;
4536
4537 Mock::given(method("GET"))
4538 .and(path("/check-runs"))
4539 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4540 "total_count": 0,
4541 "check_runs": []
4542 })))
4543 .expect(1)
4544 .mount(&server)
4545 .await;
4546
4547 let (gh, _guard) = make_client();
4548 let url = format!("{}/check-runs", server.uri());
4549 let result: Vec<serde_json::Value> = gh
4550 .get_all_pages_from_wrapper(&url, "check_runs")
4551 .await
4552 .unwrap();
4553
4554 assert_eq!(result.len(), 0);
4555 }
4556
4557 #[tokio::test]
4558 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
4559 let server = MockServer::start().await;
4560
4561 Mock::given(method("GET"))
4562 .and(path("/check-runs"))
4563 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
4564 .expect(1)
4565 .mount(&server)
4566 .await;
4567
4568 let (gh, _guard) = make_client();
4569 let url = format!("{}/check-runs", server.uri());
4570 let result = gh
4571 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
4572 .await;
4573
4574 assert!(result.is_err());
4575 let err = result.unwrap_err().to_string();
4576 assert!(
4577 err.contains("422") || err.contains("failed"),
4578 "error should indicate failure: {err}"
4579 );
4580 }
4581 }
4582
4583 #[cfg(test)]
4586 mod auth_retry {
4587 use super::test_client;
4588 use std::sync::atomic::{AtomicUsize, Ordering};
4589 use std::sync::Arc;
4590 use wiremock::matchers::method;
4591 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
4592
4593 fn bad_credentials() -> ResponseTemplate {
4594 ResponseTemplate::new(401).set_body_json(serde_json::json!({
4595 "message": "Bad credentials",
4596 "status": 401
4597 }))
4598 }
4599
4600 #[allow(clippy::await_holding_lock)]
4601 #[tokio::test]
4602 async fn retries_once_with_fresh_token_after_401() {
4603 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4604 let prev = std::env::var_os("GH_TOKEN");
4605 std::env::set_var("GH_TOKEN", "bad_token");
4606
4607 let server = MockServer::start().await;
4608 let hits = Arc::new(AtomicUsize::new(0));
4609 let hits_in_handler = hits.clone();
4610 Mock::given(method("GET"))
4611 .respond_with(move |_req: &Request| {
4612 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
4613 std::env::set_var("GH_TOKEN", "good_token");
4616 bad_credentials()
4617 } else {
4618 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
4619 }
4620 })
4621 .mount(&server)
4622 .await;
4623
4624 let gh = test_client();
4625 let result: anyhow::Result<serde_json::Value> =
4626 gh.get_json(&format!("{}/x", server.uri())).await;
4627
4628 match prev {
4629 Some(v) => std::env::set_var("GH_TOKEN", v),
4630 None => std::env::remove_var("GH_TOKEN"),
4631 }
4632
4633 assert!(
4634 result.is_ok(),
4635 "retry with re-resolved token should succeed: {:?}",
4636 result.err()
4637 );
4638 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
4639 }
4640
4641 #[allow(clippy::await_holding_lock)]
4642 #[tokio::test]
4643 async fn double_401_bails_with_distinct_auth_error() {
4644 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4645 let prev = std::env::var_os("GH_TOKEN");
4646 std::env::set_var("GH_TOKEN", "bad_token");
4647
4648 let server = MockServer::start().await;
4649 let hits = Arc::new(AtomicUsize::new(0));
4650 let hits_in_handler = hits.clone();
4651 Mock::given(method("GET"))
4652 .respond_with(move |_req: &Request| {
4653 hits_in_handler.fetch_add(1, Ordering::SeqCst);
4654 bad_credentials()
4655 })
4656 .mount(&server)
4657 .await;
4658
4659 let gh = test_client();
4660 let result: anyhow::Result<serde_json::Value> =
4661 gh.get_json(&format!("{}/x", server.uri())).await;
4662
4663 match prev {
4664 Some(v) => std::env::set_var("GH_TOKEN", v),
4665 None => std::env::remove_var("GH_TOKEN"),
4666 }
4667
4668 let err = result.expect_err("persisting 401 must fail").to_string();
4669 assert!(
4670 err.contains("token re-resolved after cache clear but still rejected"),
4671 "expected distinct auth-failure error, got: {err}"
4672 );
4673 assert_eq!(
4674 hits.load(Ordering::SeqCst),
4675 2,
4676 "must retry exactly once, not loop"
4677 );
4678 }
4679 }
4680}