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