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_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2289 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_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 compare_behind_by(
2300 &self,
2301 repo: &str,
2302 base: &str,
2303 head_sha: &str,
2304 ) -> anyhow::Result<u64> {
2305 let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2306 let body: serde_json::Value = self.get_json(&url).await?;
2307 parse_behind_by(&body)
2308 }
2309
2310 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2312 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2313 let payload = serde_json::json!({ "state": "closed" });
2314 self.patch_json_raw(&url, &payload).await?;
2315 Ok(())
2316 }
2317
2318 pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2320 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2321 let payload = serde_json::json!({ "state": "open" });
2322 self.patch_json_raw(&url, &payload).await?;
2323 Ok(())
2324 }
2325
2326 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2328 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2329 Self::check_backoff(&url)?;
2330 let auth = self.auth_header().await?;
2331 let make_req = || {
2332 Ok(self
2333 .client
2334 .get(&url)
2335 .header(header::AUTHORIZATION, auth.clone())
2336 .header(header::ACCEPT, "application/vnd.github+json")
2337 .header("X-GitHub-Api-Version", "2022-11-28"))
2338 };
2339 let resp = self.send_with_retries(make_req, false).await?;
2340 let status = resp.status();
2341 if status == StatusCode::NO_CONTENT {
2342 Ok(true)
2343 } else if status == StatusCode::NOT_FOUND {
2344 Ok(false)
2345 } else {
2346 let body = resp.text().await.unwrap_or_default();
2347 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2348 }
2349 }
2350
2351 pub async fn add_sub_issue(
2353 &self,
2354 parent_node_id: &str,
2355 child_node_id: &str,
2356 ) -> anyhow::Result<()> {
2357 let query = format!(
2358 r#"mutation {{
2359 addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
2360 issue {{ number }}
2361 subIssue {{ number }}
2362 }}
2363 }}"#,
2364 parent_node_id, child_node_id
2365 );
2366 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2367 .await?;
2368 Ok(())
2369 }
2370
2371 pub async fn link_issue_to_branch(
2383 &self,
2384 repo: &str,
2385 issue_number: u64,
2386 branch: &str,
2387 ) -> anyhow::Result<String> {
2388 let parts: Vec<&str> = repo.split('/').collect();
2389 if parts.len() != 2 {
2390 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2391 }
2392 let (owner, repo_name) = (parts[0], parts[1]);
2393
2394 let query = r#"query($owner: String!, $name: String!, $number: Int!) {
2396 repository(owner: $owner, name: $name) {
2397 id
2398 issue(number: $number) {
2399 id
2400 }
2401 }
2402 }"#;
2403 let vars = serde_json::json!({
2404 "owner": owner,
2405 "name": repo_name,
2406 "number": issue_number,
2407 });
2408
2409 let result = self.graphql_with_vars(query, vars).await?;
2410
2411 let repo_id = result
2412 .pointer("/data/repository/id")
2413 .and_then(|v| v.as_str())
2414 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2415
2416 let issue_id = result
2417 .pointer("/data/repository/issue/id")
2418 .and_then(|v| v.as_str())
2419 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2420
2421 let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
2423 repository(owner: $owner, name: $name) {
2424 ref(qualifiedName: $ref) {
2425 target {
2426 oid
2427 }
2428 }
2429 }
2430 }"#;
2431 let branch_vars = serde_json::json!({
2432 "owner": owner,
2433 "name": repo_name,
2434 "ref": format!("refs/heads/{}", branch),
2435 });
2436
2437 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2438 let branch_oid = branch_result
2439 .pointer("/data/repository/ref/target/oid")
2440 .and_then(|v| v.as_str());
2441
2442 let branch_oid = match branch_oid {
2445 Some(oid) => oid.to_string(),
2446 None => {
2447 let default_query = r#"query($owner: String!, $name: String!) {
2449 repository(owner: $owner, name: $name) {
2450 defaultBranchRef {
2451 target {
2452 oid
2453 }
2454 }
2455 }
2456 }"#;
2457 let default_vars = serde_json::json!({
2458 "owner": owner,
2459 "name": repo_name,
2460 });
2461 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2462 match default_result
2463 .pointer("/data/repository/defaultBranchRef/target/oid")
2464 .and_then(|v| v.as_str())
2465 {
2466 Some(oid) => {
2467 tracing::debug!(
2468 repo,
2469 issue_number,
2470 branch,
2471 "branch not found, using default branch OID to create and link"
2472 );
2473 oid.to_string()
2474 }
2475 None => {
2476 return Ok(format!("unlinked:{}", branch));
2477 }
2478 }
2479 }
2480 };
2481
2482 let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2485 createLinkedBranch(input: {
2486 issueId: $issueId
2487 repositoryId: $repositoryId
2488 name: $name
2489 oid: $oid
2490 }) {
2491 linkedBranch {
2492 id
2493 ref {
2494 name
2495 }
2496 }
2497 }
2498 }"#;
2499 let mutation_vars = serde_json::json!({
2500 "issueId": issue_id,
2501 "repositoryId": repo_id,
2502 "name": branch,
2503 "oid": branch_oid,
2504 });
2505
2506 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2507
2508 match link_result {
2509 Ok(result) => {
2510 let linked_branch_id = result
2511 .pointer("/data/createLinkedBranch/linkedBranch/id")
2512 .and_then(|v| v.as_str())
2513 .map(String::from);
2514
2515 if let Some(id) = linked_branch_id {
2516 tracing::info!(
2517 repo,
2518 issue_number,
2519 branch,
2520 "linked issue to branch via GraphQL API"
2521 );
2522 Ok(id)
2523 } else {
2524 let errors = result
2526 .get("errors")
2527 .and_then(|e| e.as_array())
2528 .cloned()
2529 .unwrap_or_default();
2530
2531 if errors.iter().any(|e| {
2532 e.get("message")
2533 .and_then(|m| m.as_str())
2534 .map(|m| m.contains("already"))
2535 .unwrap_or(false)
2536 }) {
2537 tracing::debug!(
2538 repo,
2539 issue_number,
2540 branch,
2541 "issue already linked to branch"
2542 );
2543 Ok(format!("already_linked:{}", branch))
2544 } else {
2545 anyhow::bail!("failed to link issue to branch: {:?}", result)
2546 }
2547 }
2548 }
2549 Err(e) => {
2550 let err_str = format!("{}", e);
2552 if err_str.contains("already") || err_str.contains("existing") {
2553 tracing::debug!(
2554 repo,
2555 issue_number,
2556 branch,
2557 "issue already linked to branch (error check)"
2558 );
2559 Ok(format!("already_linked:{}", branch))
2560 } else {
2561 Err(e)
2562 }
2563 }
2564 }
2565 }
2566
2567 pub async fn merge_pr(
2569 &self,
2570 repo: &str,
2571 pr_number: u64,
2572 delete_branch: bool,
2573 ) -> anyhow::Result<()> {
2574 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2575 Self::check_backoff(&url)?;
2576 let payload = serde_json::json!({ "merge_method": "squash" });
2577 let auth = self.auth_header().await?;
2578 let make_req = || {
2579 Ok(self
2580 .client
2581 .put(&url)
2582 .json(&payload)
2583 .header(header::AUTHORIZATION, auth.clone())
2584 .header(header::ACCEPT, "application/vnd.github+json")
2585 .header("X-GitHub-Api-Version", "2022-11-28"))
2586 };
2587 let resp = self.send_with_retries(make_req, false).await?;
2588 let status = resp.status();
2589 if !status.is_success() {
2590 let body = resp.text().await.unwrap_or_default();
2591 anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2592 }
2593 Self::record_success();
2594
2595 if delete_branch {
2597 let pr: serde_json::Value = self
2599 .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2600 .await?;
2601 if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2602 let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2603 if let Err(e) = self.delete(&del_url).await {
2604 let err_str = e.to_string();
2605 if err_str.contains("Reference does not exist") {
2606 tracing::debug!(
2607 ref_name,
2608 "branch already gone (GitHub auto-deleted on merge)"
2609 );
2610 } else {
2611 tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2612 }
2613 }
2614 }
2615 }
2616 Ok(())
2617 }
2618
2619 pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2625 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2626 Self::check_backoff(&url)?;
2627 let auth = self.auth_header().await?;
2628 let make_req = || {
2629 Ok(self
2630 .client
2631 .put(&url)
2632 .header(header::AUTHORIZATION, auth.clone())
2633 .header(header::ACCEPT, "application/vnd.github+json")
2634 .header("X-GitHub-Api-Version", "2022-11-28"))
2635 };
2636 let resp = self.send_with_retries(make_req, false).await?;
2637 let status = resp.status();
2638 if !status.is_success() {
2639 let body = resp.text().await.unwrap_or_default();
2640 Self::maybe_record_rate_limit_from_body(status, &body);
2641 anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2642 }
2643 Self::record_success();
2644 Ok(())
2645 }
2646
2647 pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2651 let parts: Vec<&str> = repo.split('/').collect();
2652 if parts.len() != 2 {
2653 anyhow::bail!("invalid repo format: {repo}");
2654 }
2655 let (owner, repo_name) = (parts[0], parts[1]);
2656
2657 let pr_query = r#"query($owner: String!, $name: String!, $number: Int!) {
2659 repository(owner: $owner, name: $name) {
2660 pullRequest(number: $number) { id }
2661 }
2662 }"#;
2663 let body = self
2664 .graphql_with_vars(
2665 pr_query,
2666 serde_json::json!({
2667 "owner": owner,
2668 "name": repo_name,
2669 "number": pr_number,
2670 }),
2671 )
2672 .await?;
2673 let pr_id = body
2674 .pointer("/data/repository/pullRequest/id")
2675 .and_then(|v| v.as_str())
2676 .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2677 .to_string();
2678
2679 let mutation = r#"mutation($prId: ID!) {
2681 enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2682 pullRequest { autoMergeRequest { enabledAt } }
2683 }
2684 }"#;
2685 let body = self
2686 .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2687 .await?;
2688 if let Some(errors) = body.get("errors") {
2689 anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2690 }
2691
2692 tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2693 Ok(())
2694 }
2695
2696 pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2698 let parts: Vec<&str> = repo.split('/').collect();
2699 if parts.len() != 2 {
2700 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2701 }
2702 let (owner, repo_name) = (parts[0], parts[1]);
2703 let number = match parse_positive_issue_number(number) {
2704 Ok(number) => number,
2705 Err(e) => {
2706 tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2707 return Ok(Vec::new());
2708 }
2709 };
2710
2711 let mut all_numbers: Vec<u64> = Vec::new();
2712 let mut cursor: Option<String> = None;
2713 let page_size = 100;
2714 let max_pages = 50;
2715 let mut page_count = 0;
2716
2717 loop {
2718 page_count += 1;
2719 if page_count > max_pages {
2720 tracing::warn!(
2721 repo,
2722 number,
2723 "get_sub_issues hit max page limit ({max_pages})"
2724 );
2725 break;
2726 }
2727
2728 let (query, vars) = if let Some(ref c) = cursor {
2729 (
2730 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2731 repository(owner: $owner, name: $name) {
2732 issue(number: $number) {
2733 subIssues(first: $first, after: $after) {
2734 nodes { number }
2735 pageInfo { hasNextPage endCursor }
2736 }
2737 }
2738 }
2739 }"#,
2740 serde_json::json!({
2741 "owner": owner,
2742 "name": repo_name,
2743 "number": number,
2744 "first": page_size,
2745 "after": c,
2746 }),
2747 )
2748 } else {
2749 (
2750 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2751 repository(owner: $owner, name: $name) {
2752 issue(number: $number) {
2753 subIssues(first: $first) {
2754 nodes { number }
2755 pageInfo { hasNextPage endCursor }
2756 }
2757 }
2758 }
2759 }"#,
2760 serde_json::json!({
2761 "owner": owner,
2762 "name": repo_name,
2763 "number": number,
2764 "first": page_size,
2765 }),
2766 )
2767 };
2768
2769 let result = self
2770 .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2771 .await?;
2772
2773 let sub_issues_data = result
2774 .get("data")
2775 .and_then(|d| d.get("repository"))
2776 .and_then(|r| r.get("issue"))
2777 .and_then(|i| i.get("subIssues"));
2778
2779 let nodes = sub_issues_data
2780 .and_then(|s| s.get("nodes"))
2781 .and_then(|n| n.as_array());
2782
2783 match nodes {
2784 Some(nodes) => {
2785 let numbers: Vec<u64> = nodes
2786 .iter()
2787 .filter_map(|n| n.get("number").and_then(|num| num.as_u64()))
2788 .collect();
2789 all_numbers.extend(numbers);
2790 }
2791 None => break,
2792 }
2793
2794 let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2795 let has_next = page_info
2796 .and_then(|p| p.get("hasNextPage"))
2797 .and_then(|h| h.as_bool())
2798 .unwrap_or(false);
2799
2800 if !has_next {
2801 break;
2802 }
2803
2804 cursor = page_info
2805 .and_then(|p| p.get("endCursor"))
2806 .and_then(|c| c.as_str())
2807 .map(|s| s.to_string());
2808 }
2809
2810 Ok(all_numbers)
2811 }
2812
2813 pub async fn get_combined_status(
2815 &self,
2816 repo: &str,
2817 git_ref: &str,
2818 has_workflows: bool,
2819 mergeable_state: Option<&str>,
2820 ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2821 let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2822 let resp: serde_json::Value = self.get_json(&url).await?;
2823
2824 let runs = resp
2825 .get("check_runs")
2826 .and_then(|v| v.as_array())
2827 .cloned()
2828 .unwrap_or_default();
2829
2830 let mut passing = 0u64;
2831 let mut failing = 0u64;
2832 let mut pending = 0u64;
2833
2834 for run in &runs {
2835 let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2836 let status = run
2837 .get("status")
2838 .and_then(|v| v.as_str())
2839 .unwrap_or("queued");
2840
2841 match status {
2842 "completed" => {
2843 if conclusion.is_empty() {
2844 pending += 1;
2845 } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2846 passing += 1;
2847 } else {
2848 failing += 1;
2849 }
2850 }
2851 _ => pending += 1,
2852 }
2853 }
2854
2855 let total = runs.len() as u64;
2856 let state =
2857 Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
2858
2859 Ok((state, total, passing, failing, pending))
2860 }
2861
2862 pub async fn rerun_failed_workflow(
2868 &self,
2869 repo: &str,
2870 workflow_name: &str,
2871 branch: &str,
2872 ) -> anyhow::Result<()> {
2873 let url = format!(
2875 "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
2876 );
2877 let resp: serde_json::Value = self.get_json(&url).await?;
2878 let runs = resp["workflow_runs"]
2879 .as_array()
2880 .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
2881
2882 let run_id = runs
2884 .iter()
2885 .find(|r| {
2886 r["name"].as_str() == Some(workflow_name)
2887 && matches!(
2888 r["conclusion"].as_str(),
2889 Some("failure") | Some("cancelled")
2890 )
2891 })
2892 .and_then(|r| r["id"].as_u64())
2893 .ok_or_else(|| {
2894 anyhow::anyhow!(
2895 "no failed run found for workflow '{}' on branch '{}'",
2896 workflow_name,
2897 branch
2898 )
2899 })?;
2900
2901 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
2903 let auth = self.auth_header().await?;
2904 let make_req = || {
2905 Ok(self
2906 .client
2907 .post(&rerun_url)
2908 .header(header::AUTHORIZATION, auth.clone())
2909 .header(header::ACCEPT, "application/vnd.github+json")
2910 .header("X-GitHub-Api-Version", "2022-11-28"))
2911 };
2912 let resp = self.send_with_retries(make_req, false).await?;
2913 let status = resp.status();
2914 if !status.is_success() {
2915 let body = resp.text().await.unwrap_or_default();
2916 Self::maybe_record_rate_limit_from_body(status, &body);
2917 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
2918 }
2919
2920 Ok(())
2921 }
2922
2923 pub async fn batch_fetch_pr_review_data(
2929 &self,
2930 repo: &str,
2931 pr_numbers: &[u64],
2932 ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
2933 if pr_numbers.is_empty() {
2934 return Ok(std::collections::HashMap::new());
2935 }
2936
2937 let (owner, name) = repo
2938 .split_once('/')
2939 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2940
2941 let aliases: String = pr_numbers
2943 .iter()
2944 .map(|&n| build_pr_review_alias(n))
2945 .collect::<Vec<_>>()
2946 .join("\n");
2947
2948 let query = format!(
2949 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2950 );
2951
2952 let resp = self
2953 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2954 .await?;
2955 let repo_data = resp
2956 .pointer("/data/repository")
2957 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2958
2959 let mut result = std::collections::HashMap::new();
2960 for &n in pr_numbers {
2961 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
2962 continue;
2963 };
2964
2965 let merged = pr_data
2966 .get("merged")
2967 .and_then(|v| v.as_bool())
2968 .unwrap_or(false);
2969 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
2970 Some("MERGEABLE") => Some(true),
2971 Some("CONFLICTING") => Some(false),
2972 _ => None, };
2974
2975 let mut reviews: Vec<GitHubReview> = pr_data
2976 .pointer("/reviews/nodes")
2977 .and_then(|v| v.as_array())
2978 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
2979 .unwrap_or_default();
2980
2981 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
2982 tracing::warn!(
2983 pr_number = n,
2984 fetched = reviews.len(),
2985 "batch review fetch truncated reviews connection at first page"
2986 );
2987 }
2988
2989 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
2993
2994 let mut review_comments: Vec<GitHubReviewComment> = pr_data
2995 .pointer("/reviewThreads/nodes")
2996 .and_then(|v| v.as_array())
2997 .map(|threads| {
2998 threads
2999 .iter()
3000 .filter_map(|thread| {
3001 thread
3002 .pointer("/comments/nodes")
3003 .and_then(|v| v.as_array())
3004 .map(|nodes| {
3005 nodes
3006 .iter()
3007 .filter_map(parse_graphql_review_comment)
3008 .collect::<Vec<_>>()
3009 })
3010 })
3011 .flatten()
3012 .collect()
3013 })
3014 .unwrap_or_default();
3015
3016 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3017 tracing::warn!(
3018 pr_number = n,
3019 fetched_threads = pr_data
3020 .pointer("/reviewThreads/nodes")
3021 .and_then(|v| v.as_array())
3022 .map_or(0, |threads| threads.len()),
3023 "batch review fetch truncated reviewThreads connection at first page"
3024 );
3025 }
3026 if pr_data
3027 .pointer("/reviewThreads/nodes")
3028 .and_then(|v| v.as_array())
3029 .is_some_and(|threads| {
3030 threads.iter().any(|thread| {
3031 connection_has_next_page(thread.pointer("/comments/pageInfo"))
3032 })
3033 })
3034 {
3035 tracing::warn!(
3036 pr_number = n,
3037 "batch review fetch truncated one or more review thread comment connections"
3038 );
3039 }
3040
3041 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3044
3045 let mut issue_comments: Vec<GitHubComment> = pr_data
3046 .pointer("/comments/nodes")
3047 .and_then(|v| v.as_array())
3048 .map(|nodes| {
3049 nodes
3050 .iter()
3051 .filter_map(parse_graphql_issue_comment)
3052 .collect()
3053 })
3054 .unwrap_or_default();
3055
3056 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3057 issue_comments.extend(
3058 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3059 .await?,
3060 );
3061 }
3062
3063 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3068
3069 result.insert(
3070 n,
3071 PrReviewBatchData {
3072 merged,
3073 mergeable,
3074 reviews,
3075 review_comments,
3076 issue_comments,
3077 },
3078 );
3079 }
3080
3081 Ok(result)
3082 }
3083
3084 async fn fetch_pr_issue_comments_after(
3085 &self,
3086 owner: &str,
3087 name: &str,
3088 pr_number: u64,
3089 mut cursor: Option<String>,
3090 ) -> anyhow::Result<Vec<GitHubComment>> {
3091 let mut comments = Vec::new();
3092 let mut page_count = 0;
3093 let max_pages = 50;
3094
3095 while let Some(current_cursor) = cursor.take() {
3096 page_count += 1;
3097 if page_count > max_pages {
3098 tracing::warn!(
3099 pr_number,
3100 fetched = comments.len(),
3101 "stopping PR issue comment pagination after max page limit"
3102 );
3103 break;
3104 }
3105
3106 let (query, vars) =
3107 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
3108 let resp = self.graphql_with_vars(&query, vars).await?;
3109 let comments_data = resp
3110 .pointer("/data/repository/pullRequest/comments")
3111 .ok_or_else(|| {
3112 anyhow::anyhow!(
3113 "missing /data/repository/pullRequest/comments in GraphQL response"
3114 )
3115 })?;
3116
3117 comments.extend(
3118 comments_data
3119 .get("nodes")
3120 .and_then(|v| v.as_array())
3121 .into_iter()
3122 .flatten()
3123 .filter_map(parse_graphql_issue_comment),
3124 );
3125
3126 cursor = connection_end_cursor(comments_data.get("pageInfo"));
3127 }
3128
3129 Ok(comments)
3130 }
3131
3132 pub async fn get_automated_review_status(
3134 &self,
3135 repo: &str,
3136 pr_number: u64,
3137 ) -> anyhow::Result<Option<String>> {
3138 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3139
3140 let mut newest: Option<&crate::github::types::GitHubComment> = None;
3142 for c in &comments {
3143 if !c.body.starts_with("## Automated Review") {
3144 continue;
3145 }
3146 match self.is_collaborator(repo, &c.user.login).await {
3147 Ok(true) => match newest {
3148 None => newest = Some(c),
3149 Some(prev) => {
3150 if c.created_at > prev.created_at {
3151 newest = Some(c);
3152 }
3153 }
3154 },
3155 Ok(false) => {
3156 tracing::warn!(
3157 user = %c.user.login,
3158 pr_number,
3159 "ignoring automated review comment from non-collaborator"
3160 );
3161 continue;
3162 }
3163 Err(e) => {
3164 tracing::debug!(
3165 user = %c.user.login,
3166 error = %e,
3167 "failed to check collaborator status, skipping comment"
3168 );
3169 continue;
3170 }
3171 }
3172 }
3173
3174 match newest {
3175 Some(c) => {
3176 let first_line = c.body.lines().next().unwrap_or("");
3177 if first_line.contains("Automated Review \u{2014} Approve") {
3178 Ok(Some("approve".to_string()))
3179 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3180 Ok(Some("changes_requested".to_string()))
3181 } else {
3182 Ok(None)
3183 }
3184 }
3185 None => Ok(None),
3186 }
3187 }
3188}
3189
3190fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3195 body.get("behind_by")
3196 .and_then(|v| v.as_u64())
3197 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3198}
3199
3200fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3201 let mut valid = Vec::with_capacity(issue_numbers.len());
3202 let mut invalid = Vec::new();
3203 for &issue_number in issue_numbers {
3204 if issue_number == 0 {
3205 invalid.push(issue_number);
3206 } else {
3207 valid.push(issue_number);
3208 }
3209 }
3210 (valid, invalid)
3211}
3212
3213#[derive(Debug, Clone)]
3217pub struct RateLimitMetrics {
3218 pub hits: u64,
3220 pub skipped_calls: u64,
3222 pub rest_remaining: Option<u32>,
3224 pub rest_reset_at: Option<u64>,
3226 pub graphql_remaining: Option<u32>,
3228 pub five_xx_hits: u64,
3230}
3231
3232pub fn rate_limit_metrics() -> RateLimitMetrics {
3234 let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
3235 .lock()
3236 .ok()
3237 .map(|rl| (rl.remaining, rl.reset_at))
3238 .unwrap_or((None, None));
3239 let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3240 RateLimitMetrics {
3241 hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3242 skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3243 rest_remaining,
3244 rest_reset_at,
3245 graphql_remaining,
3246 five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3247 }
3248}
3249
3250fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3254 let link = headers.get("link")?.to_str().ok()?;
3255 for part in link.split(',') {
3256 let part = part.trim();
3257 if part.contains("rel=\"next\"") {
3258 let start = part.find('<')? + 1;
3260 let end = part.find('>')?;
3261 if start >= end {
3262 return None;
3263 }
3264 return Some(part[start..end].to_string());
3265 }
3266 }
3267 None
3268}
3269
3270pub fn status_label_color(label: &str) -> &'static str {
3272 match label {
3273 "status:new" => "0e8a16",
3274 "status:routed" => "1d76db",
3275 "status:in_progress" => "fbca04",
3276 "status:done" => "6f42c1",
3277 "status:blocked" => "d73a4a",
3278 "status:in_review" => "0075ca",
3279 "status:needs_review" => "e4e669",
3280 "status:waiting" => "5319e7",
3281 _ => "c5def5",
3282 }
3283}
3284
3285#[derive(Debug, Clone, Default)]
3289pub struct PrReviewBatchData {
3290 pub merged: bool,
3292 pub mergeable: Option<bool>,
3294 pub reviews: Vec<GitHubReview>,
3296 pub review_comments: Vec<GitHubReviewComment>,
3298 pub issue_comments: Vec<GitHubComment>,
3300}
3301
3302fn build_pr_review_alias(n: u64) -> String {
3305 format!(
3306 r#"pr{n}: pullRequest(number: {n}) {{
3307 merged
3308 mergeable
3309 reviews(first: 50) {{
3310 nodes {{
3311 databaseId
3312 author {{ login }}
3313 body
3314 state
3315 url
3316 submittedAt
3317 commit {{ oid }}
3318 }}
3319 pageInfo {{ hasNextPage }}
3320 }}
3321 reviewThreads(first: 50) {{
3322 nodes {{
3323 comments(first: 20) {{
3324 nodes {{
3325 databaseId
3326 author {{ login }}
3327 body
3328 path
3329 line
3330 originalLine
3331 commit {{ oid }}
3332 originalCommit {{ oid }}
3333 url
3334 createdAt
3335 updatedAt
3336 replyTo {{ databaseId }}
3337 diffHunk
3338 }}
3339 pageInfo {{ hasNextPage }}
3340 }}
3341 }}
3342 pageInfo {{ hasNextPage }}
3343 }}
3344 comments(first: 50) {{
3345 nodes {{
3346 databaseId
3347 author {{ login }}
3348 body
3349 createdAt
3350 updatedAt
3351 url
3352 }}
3353 pageInfo {{ hasNextPage endCursor }}
3354 }}
3355}}"#
3356 )
3357}
3358
3359fn build_pr_issue_comments_page_query(
3360 owner: &str,
3361 name: &str,
3362 pr_number: u64,
3363 after_cursor: &str,
3364) -> (String, serde_json::Value) {
3365 let query = r#"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3366 repository(owner: $owner, name: $name) {
3367 pullRequest(number: $number) {
3368 comments(first: 100, after: $after) {
3369 nodes {
3370 databaseId
3371 author { login }
3372 body
3373 createdAt
3374 updatedAt
3375 url
3376 }
3377 pageInfo { hasNextPage endCursor }
3378 }
3379 }
3380 }
3381}"#
3382 .to_string();
3383 let vars = serde_json::json!({
3384 "owner": owner,
3385 "name": name,
3386 "number": pr_number,
3387 "after": after_cursor,
3388 });
3389 (query, vars)
3390}
3391
3392fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3393 page_info
3394 .and_then(|v| v.get("hasNextPage"))
3395 .and_then(|v| v.as_bool())
3396 .unwrap_or(false)
3397}
3398
3399fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3400 if !connection_has_next_page(page_info) {
3401 return None;
3402 }
3403
3404 page_info
3405 .and_then(|v| v.get("endCursor"))
3406 .and_then(|v| v.as_str())
3407 .map(str::to_owned)
3408}
3409
3410fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3411 use super::types::GitHubUser;
3412 Some(GitHubReview {
3413 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3414 user: GitHubUser {
3415 login: node
3416 .pointer("/author/login")
3417 .and_then(|v| v.as_str())?
3418 .to_string(),
3419 },
3420 body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3421 state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3422 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3423 submitted_at: node
3424 .get("submittedAt")
3425 .and_then(|v| v.as_str())
3426 .unwrap_or("")
3427 .to_string(),
3428 commit_id: node
3429 .pointer("/commit/oid")
3430 .and_then(|v| v.as_str())
3431 .map(String::from),
3432 })
3433}
3434
3435fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3436 use super::types::GitHubUser;
3437 Some(GitHubReviewComment {
3438 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3439 user: GitHubUser {
3440 login: node
3441 .pointer("/author/login")
3442 .and_then(|v| v.as_str())?
3443 .to_string(),
3444 },
3445 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3446 path: node
3447 .get("path")
3448 .and_then(|v| v.as_str())
3449 .unwrap_or("")
3450 .to_string(),
3451 line: node.get("line").and_then(|v| v.as_u64()).map(|v| v as u32),
3452 original_line: node
3453 .get("originalLine")
3454 .and_then(|v| v.as_u64())
3455 .map(|v| v as u32),
3456 commit_id: node
3457 .pointer("/commit/oid")
3458 .and_then(|v| v.as_str())
3459 .unwrap_or("")
3460 .to_string(),
3461 original_commit_id: node
3462 .pointer("/originalCommit/oid")
3463 .and_then(|v| v.as_str())
3464 .unwrap_or("")
3465 .to_string(),
3466 html_url: node
3467 .get("url")
3468 .and_then(|v| v.as_str())
3469 .unwrap_or("")
3470 .to_string(),
3471 created_at: node
3472 .get("createdAt")
3473 .and_then(|v| v.as_str())
3474 .unwrap_or("")
3475 .to_string(),
3476 updated_at: node
3477 .get("updatedAt")
3478 .and_then(|v| v.as_str())
3479 .unwrap_or("")
3480 .to_string(),
3481 in_reply_to_id: node.pointer("/replyTo/databaseId").and_then(|v| v.as_u64()),
3482 diff_hunk: node
3483 .get("diffHunk")
3484 .and_then(|v| v.as_str())
3485 .map(String::from),
3486 })
3487}
3488
3489fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3490 use super::types::GitHubUser;
3491 Some(GitHubComment {
3492 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3493 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3494 user: GitHubUser {
3495 login: node
3496 .pointer("/author/login")
3497 .and_then(|v| v.as_str())?
3498 .to_string(),
3499 },
3500 created_at: node
3501 .get("createdAt")
3502 .and_then(|v| v.as_str())
3503 .unwrap_or("")
3504 .to_string(),
3505 updated_at: node
3506 .get("updatedAt")
3507 .and_then(|v| v.as_str())
3508 .map(String::from),
3509 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3510 issue_url: None,
3511 author_association: None,
3512 })
3513}
3514
3515#[cfg(test)]
3516mod tests {
3517 use super::*;
3518 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3519 use tokio::net::TcpListener;
3520
3521 #[test]
3522 fn parse_behind_by_reads_compare_status() {
3523 let behind = serde_json::json!({"status": "behind", "behind_by": 3, "ahead_by": 0});
3525 assert_eq!(parse_behind_by(&behind).unwrap(), 3);
3526 let diverged = serde_json::json!({"status": "diverged", "behind_by": 1, "ahead_by": 2});
3527 assert_eq!(parse_behind_by(&diverged).unwrap(), 1);
3528 let ahead = serde_json::json!({"status": "ahead", "behind_by": 0, "ahead_by": 2});
3529 assert_eq!(parse_behind_by(&ahead).unwrap(), 0);
3530 assert!(parse_behind_by(&serde_json::json!({"status": "ahead"})).is_err());
3532 }
3533
3534 async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3535 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3536 let addr = listener.local_addr().unwrap();
3537
3538 tokio::spawn(async move {
3539 let (mut socket, _) = listener.accept().await.unwrap();
3540 let mut buf = [0u8; 4096];
3541 let _ = socket.read(&mut buf).await;
3542
3543 let declared_len = body_prefix.len() + 32;
3544 let response = format!(
3545 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
3546 );
3547 socket.write_all(response.as_bytes()).await.unwrap();
3548 socket.shutdown().await.unwrap();
3549 });
3550
3551 format!("http://{addr}")
3552 }
3553
3554 fn test_client() -> GhHttp {
3555 GhHttp {
3556 client: reqwest::Client::new(),
3557 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3558 cached_username: OnceLock::new(),
3559 }
3560 }
3561
3562 #[test]
3567 fn gh_http_new_returns_ok() {
3568 let result = GhHttp::new();
3569 assert!(
3570 result.is_ok(),
3571 "GhHttp::new() must not panic and must return Ok; got: {:?}",
3572 result.err()
3573 );
3574 }
3575
3576 #[test]
3577 fn batch_get_issue_states_query_includes_labels_pagination() {
3578 let issue_numbers = [1u64, 2, 3];
3579
3580 let aliases: String = issue_numbers
3581 .iter()
3582 .map(|n| {
3583 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
3584 })
3585 .collect::<Vec<_>>()
3586 .join(" ");
3587
3588 let query = format!(
3589 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
3590 );
3591 assert!(query.contains("$owner"));
3592 assert!(query.contains("$name"));
3593 assert!(query.contains("issue1: issue(number: 1)"));
3594 assert!(query.contains("issue3: issue(number: 3)"));
3595 assert!(query.contains("labels(first: 100)"));
3596 }
3597
3598 #[test]
3599 fn parse_positive_issue_number_accepts_positive_values() {
3600 assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
3601 }
3602
3603 #[test]
3604 fn parse_positive_issue_number_rejects_zero() {
3605 let err = parse_positive_issue_number("0").unwrap_err().to_string();
3606 assert!(err.contains("invalid issue number: 0"));
3607 }
3608
3609 #[test]
3610 fn parse_positive_issue_number_rejects_internal_ids() {
3611 let err = parse_positive_issue_number("internal:5")
3612 .unwrap_err()
3613 .to_string();
3614 assert!(err.contains("invalid issue number: internal:5"));
3615 }
3616
3617 #[test]
3618 fn pr_review_batch_query_includes_page_info_for_nested_connections() {
3619 let query = build_pr_review_alias(42);
3620
3621 assert!(query.contains("reviews(first: 50)"));
3622 assert!(query.contains("reviews(first: 50) {\n nodes"));
3623 assert!(query.contains("pageInfo { hasNextPage }"));
3624 assert!(query.contains("reviewThreads(first: 50)"));
3625 assert!(query.contains("comments(first: 20)"));
3626 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3627 assert!(query.contains("updatedAt"));
3628 }
3629
3630 #[test]
3631 fn pr_issue_comments_page_query_uses_cursor_pagination() {
3632 let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
3633
3634 assert!(query.contains("pullRequest(number: $number)"));
3635 assert!(query.contains("comments(first: 100, after: $after)"));
3636 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3637 assert!(query.contains("updatedAt"));
3638 assert_eq!(vars["owner"], "owner");
3639 assert_eq!(vars["name"], "repo");
3640 assert_eq!(vars["number"], 42);
3641 assert_eq!(vars["after"], "cursor123");
3642 }
3643
3644 #[test]
3645 fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
3646 let page_info = serde_json::json!({
3647 "hasNextPage": true,
3648 "endCursor": "cursor123"
3649 });
3650 assert_eq!(
3651 connection_end_cursor(Some(&page_info)),
3652 Some("cursor123".to_string())
3653 );
3654
3655 let final_page = serde_json::json!({
3656 "hasNextPage": false,
3657 "endCursor": "cursor456"
3658 });
3659 assert_eq!(connection_end_cursor(Some(&final_page)), None);
3660 }
3661
3662 #[test]
3663 fn status_label_colors_match_bash_palette() {
3664 assert_eq!(status_label_color("status:new"), "0e8a16");
3665 assert_eq!(status_label_color("status:routed"), "1d76db");
3666 assert_eq!(status_label_color("status:in_progress"), "fbca04");
3667 assert_eq!(status_label_color("status:done"), "6f42c1");
3668 assert_eq!(status_label_color("status:blocked"), "d73a4a");
3669 assert_eq!(status_label_color("status:in_review"), "0075ca");
3670 assert_eq!(status_label_color("status:needs_review"), "e4e669");
3671 }
3672
3673 #[test]
3674 fn unknown_label_gets_default_color() {
3675 assert_eq!(status_label_color("agent:claude"), "c5def5");
3676 assert_eq!(status_label_color("enhancement"), "c5def5");
3677 assert_eq!(status_label_color(""), "c5def5");
3678 }
3679
3680 #[test]
3681 fn parse_link_next_finds_next_url() {
3682 let mut headers = header::HeaderMap::new();
3683 headers.insert(
3684 "link",
3685 "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
3686 .parse()
3687 .unwrap(),
3688 );
3689 assert_eq!(
3690 parse_link_next(&headers),
3691 Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
3692 );
3693 }
3694
3695 #[test]
3696 fn parse_link_next_none_when_no_next() {
3697 let mut headers = header::HeaderMap::new();
3698 headers.insert(
3699 "link",
3700 "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
3701 .parse()
3702 .unwrap(),
3703 );
3704 assert_eq!(parse_link_next(&headers), None);
3705 }
3706
3707 #[test]
3708 fn parse_link_next_none_when_missing() {
3709 let headers = header::HeaderMap::new();
3710 assert_eq!(parse_link_next(&headers), None);
3711 }
3712
3713 #[test]
3714 fn parse_link_next_none_when_malformed() {
3715 let mut headers = header::HeaderMap::new();
3716 headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
3718 assert_eq!(parse_link_next(&headers), None);
3719 }
3720
3721 #[test]
3722 fn parse_link_next_none_when_gt_before_lt() {
3723 let mut headers = header::HeaderMap::new();
3724 headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
3725 assert_eq!(parse_link_next(&headers), None); }
3727
3728 #[test]
3729 fn parse_link_next_none_when_empty_url() {
3730 let mut headers = header::HeaderMap::new();
3731 headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
3732 assert_eq!(parse_link_next(&headers), None);
3733 }
3734
3735 fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
3736 RateLimit {
3737 remaining: None,
3738 reset_at: None,
3739 backoff_until: None,
3740 backoff_delay: Duration::ZERO,
3741 backoff_base: Duration::from_secs(base_secs),
3742 backoff_max: Duration::from_secs(max_secs),
3743 throttle_threshold: 10,
3744 }
3745 }
3746
3747 #[test]
3748 fn rate_limit_inactive_by_default() {
3749 let rl = make_rl(30, 900);
3750 assert!(rl.is_active().is_none());
3751 }
3752
3753 #[test]
3754 fn rate_limit_backoff_activates() {
3755 let mut rl = make_rl(5, 60);
3756 rl.record_rate_limit();
3757 assert!(rl.is_active().is_some());
3758 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3759 }
3760
3761 #[test]
3762 fn rate_limit_backoff_doubles() {
3763 let mut rl = make_rl(5, 60);
3764 rl.record_rate_limit();
3765 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3766 rl.record_rate_limit();
3767 assert_eq!(rl.backoff_delay, Duration::from_secs(10));
3768 rl.record_rate_limit();
3769 assert_eq!(rl.backoff_delay, Duration::from_secs(20));
3770 }
3771
3772 #[test]
3773 fn rate_limit_backoff_caps_at_max() {
3774 let mut rl = make_rl(30, 60);
3775 rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
3779 }
3780
3781 #[test]
3782 fn rate_limit_success_resets() {
3783 let mut rl = make_rl(5, 60);
3784 rl.record_rate_limit();
3785 assert!(rl.is_active().is_some());
3786 rl.record_success();
3787 assert!(rl.is_active().is_none());
3788 assert_eq!(rl.backoff_delay, Duration::ZERO);
3789 }
3790
3791 #[test]
3792 fn page_cache_key_separates_types_and_queries() {
3793 let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
3794 let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
3795 let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
3796 assert_ne!(a, b);
3797 assert_ne!(a, c);
3798 assert_eq!(
3799 a,
3800 page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
3801 );
3802 }
3803
3804 #[test]
3806 fn page_cache_key_skips_since_filtered_requests() {
3807 assert!(page_cache_key::<GitHubIssue>(
3808 "/issues",
3809 Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
3810 )
3811 .is_none());
3812 assert!(
3814 page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
3815 .is_none()
3816 );
3817 }
3818
3819 #[test]
3822 fn low_quota_fails_fast_instead_of_waiting_for_reset() {
3823 let mut rl = make_rl(30, 900);
3824 rl.remaining = Some(0);
3825 rl.reset_at = Some(
3826 std::time::SystemTime::now()
3827 .duration_since(std::time::UNIX_EPOCH)
3828 .unwrap()
3829 .as_secs()
3830 + 3600,
3831 );
3832 let cell = Mutex::new(rl);
3833
3834 let start = Instant::now();
3835 let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
3836 assert!(
3837 start.elapsed() < Duration::from_secs(1),
3838 "check blocked for {:?}",
3839 start.elapsed()
3840 );
3841 assert!(err.to_string().contains("rate-limited"), "{err}");
3842 }
3843
3844 #[test]
3846 fn healthy_quota_passes_the_check() {
3847 let mut rl = make_rl(30, 900);
3848 rl.remaining = Some(4000);
3849 rl.reset_at = Some(u64::MAX / 2);
3850 let cell = Mutex::new(rl);
3851 assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
3852 }
3853
3854 #[test]
3855 fn rate_limit_proactive_pause() {
3856 let mut rl = make_rl(30, 900);
3857 rl.remaining = Some(0);
3859 let future_epoch = std::time::SystemTime::now()
3860 .duration_since(std::time::UNIX_EPOCH)
3861 .unwrap()
3862 .as_secs()
3863 + 60;
3864 rl.reset_at = Some(future_epoch);
3865 let pause = rl.is_active();
3866 assert!(pause.is_some());
3867 assert!(pause.unwrap().as_secs() <= 62);
3869 }
3870
3871 #[test]
3872 fn rate_limit_proactive_pause_threshold() {
3873 let mut rl = make_rl(30, 900);
3874 rl.remaining = Some(15);
3876 let future_epoch = std::time::SystemTime::now()
3877 .duration_since(std::time::UNIX_EPOCH)
3878 .unwrap()
3879 .as_secs()
3880 + 60;
3881 rl.reset_at = Some(future_epoch);
3882 assert!(rl.is_active().is_none());
3883
3884 rl.remaining = Some(5);
3886 assert!(rl.is_active().is_some());
3887 }
3888
3889 #[test]
3890 fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
3891 assert_eq!(
3893 GhHttp::combined_status_state(0, 0, 0, true, None),
3894 "pending"
3895 );
3896 assert_eq!(
3898 GhHttp::combined_status_state(0, 0, 0, false, None),
3899 "success"
3900 );
3901 assert_eq!(
3903 GhHttp::combined_status_state(3, 1, 0, true, None),
3904 "failure"
3905 );
3906 assert_eq!(
3908 GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
3909 "success"
3910 );
3911 assert_eq!(
3913 GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
3914 "pending"
3915 );
3916 }
3917
3918 #[test]
3919 fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
3920 let resp = serde_json::json!({
3921 "data": {},
3922 "errors": [{ "message": "Something went wrong" }],
3923 });
3924
3925 let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
3926 .unwrap_err()
3927 .to_string();
3928 assert!(err_text.contains("missing /data/repository in GraphQL response"));
3929 assert!(err_text.contains("Something went wrong"));
3930 }
3931
3932 #[test]
3933 fn rate_limit_uses_reset_at_for_backoff() {
3934 let mut rl = make_rl(30, 900);
3935 let future_epoch = std::time::SystemTime::now()
3937 .duration_since(std::time::UNIX_EPOCH)
3938 .unwrap()
3939 .as_secs()
3940 + 45;
3941 rl.reset_at = Some(future_epoch);
3942 rl.record_rate_limit();
3943 assert!(rl.backoff_delay.as_secs() >= 45);
3945 assert!(rl.backoff_delay.as_secs() <= 47);
3946 }
3947
3948 #[tokio::test]
3949 async fn send_with_retries_propagates_make_req_error() {
3950 let guard = std::env::var_os("GH_TOKEN");
3951 std::env::set_var("GH_TOKEN", "test_token");
3952 let gh = test_client();
3953 let result = gh
3954 .send_with_retries(
3955 || Err(anyhow::anyhow!("simulated request clone failure")),
3956 false,
3957 )
3958 .await;
3959 assert!(result.is_err());
3960 let err = result.unwrap_err().to_string();
3961 assert_eq!(err, "simulated request clone failure");
3962 match guard {
3963 Some(v) => std::env::set_var("GH_TOKEN", v),
3964 None => std::env::remove_var("GH_TOKEN"),
3965 }
3966 }
3967
3968 #[tokio::test]
3974 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
3975 let guard = std::env::var_os("GH_TOKEN");
3976 std::env::set_var("GH_TOKEN", "test_token");
3977
3978 let addr = {
3982 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3983 listener.local_addr().unwrap()
3984 };
3985 let url = format!("http://{addr}/");
3986
3987 let gh = test_client();
3988 let make_req = || Ok(gh.client.get(&url));
3989 let result = gh.send_with_retries(make_req, false).await;
3990
3991 assert!(result.is_err());
3992 assert!(
3993 !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
3994 "transport error must not trip the github:5xx circuit breaker"
3995 );
3996 assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
3997
3998 match guard {
3999 Some(v) => std::env::set_var("GH_TOKEN", v),
4000 None => std::env::remove_var("GH_TOKEN"),
4001 }
4002 }
4003
4004 #[tokio::test]
4005 async fn post_json_raw_propagates_body_read_errors_on_success() {
4006 let guard = std::env::var_os("GH_TOKEN");
4007 std::env::set_var("GH_TOKEN", "test_token");
4008 let server_url = spawn_truncated_body_server("{").await;
4009 let gh = test_client();
4010
4011 let result = gh
4012 .post_json_raw(
4013 &format!("{server_url}/post"),
4014 &serde_json::json!({"ok": true}),
4015 )
4016 .await;
4017
4018 let err = result.unwrap_err().to_string();
4019 assert!(
4020 !err.contains("EOF while parsing a value"),
4021 "should surface the body read error, got: {err}"
4022 );
4023 assert!(
4024 err.contains("error decoding response body")
4025 || err.contains("request or response body error")
4026 || err.contains("end of file before message length reached"),
4027 "expected a transport/body read error, got: {err}"
4028 );
4029
4030 match guard {
4031 Some(v) => std::env::set_var("GH_TOKEN", v),
4032 None => std::env::remove_var("GH_TOKEN"),
4033 }
4034 }
4035
4036 #[tokio::test]
4037 async fn patch_json_raw_propagates_body_read_errors_on_success() {
4038 let guard = std::env::var_os("GH_TOKEN");
4039 std::env::set_var("GH_TOKEN", "test_token");
4040 let server_url = spawn_truncated_body_server("{").await;
4041 let gh = test_client();
4042
4043 let result = gh
4044 .patch_json_raw(
4045 &format!("{server_url}/patch"),
4046 &serde_json::json!({"ok": true}),
4047 )
4048 .await;
4049
4050 let err = result.unwrap_err().to_string();
4051 assert!(
4052 !err.contains("EOF while parsing a value"),
4053 "should surface the body read error, got: {err}"
4054 );
4055 assert!(
4056 err.contains("error decoding response body")
4057 || err.contains("request or response body error")
4058 || err.contains("end of file before message length reached"),
4059 "expected a transport/body read error, got: {err}"
4060 );
4061
4062 match guard {
4063 Some(v) => std::env::set_var("GH_TOKEN", v),
4064 None => std::env::remove_var("GH_TOKEN"),
4065 }
4066 }
4067
4068 #[tokio::test]
4069 async fn graphql_request_propagates_body_read_errors_on_success() {
4070 let guard = std::env::var_os("GH_TOKEN");
4071 std::env::set_var("GH_TOKEN", "test_token");
4072 let server_url = spawn_truncated_body_server("{").await;
4073 let gh = test_client();
4074 let query = "query { viewer { login } }";
4075
4076 let result = gh
4077 .graphql_request_to_url(
4078 &format!("{server_url}/graphql"),
4079 query,
4080 &[],
4081 Some(serde_json::json!({"k": "v"})),
4082 )
4083 .await;
4084
4085 let err = result.unwrap_err().to_string();
4086 assert!(
4087 !err.contains("EOF while parsing a value"),
4088 "should surface the body read error, got: {err}"
4089 );
4090 assert!(
4091 err.contains("error decoding response body")
4092 || err.contains("request or response body error")
4093 || err.contains("end of file before message length reached"),
4094 "expected a transport/body read error, got: {err}"
4095 );
4096
4097 match guard {
4098 Some(v) => std::env::set_var("GH_TOKEN", v),
4099 None => std::env::remove_var("GH_TOKEN"),
4100 }
4101 }
4102
4103 #[cfg(test)]
4106 mod pagination {
4107 use super::super::*;
4108 use std::ffi::OsString;
4109 use std::sync::Arc;
4110 use wiremock::matchers::{method, path};
4111 use wiremock::{matchers, Match, Mock, MockServer, Request, ResponseTemplate};
4112
4113 struct NotMatcher<M>(M);
4116
4117 impl<M: Match> Match for NotMatcher<M> {
4118 fn matches(&self, request: &Request) -> bool {
4119 !self.0.matches(request)
4120 }
4121 }
4122
4123 struct EnvVarGuard {
4124 key: &'static str,
4125 previous: Option<OsString>,
4126 }
4127
4128 impl EnvVarGuard {
4129 fn set(key: &'static str, value: &str) -> Self {
4130 let previous = std::env::var_os(key);
4131 std::env::set_var(key, value);
4132 Self { key, previous }
4133 }
4134 }
4135
4136 impl Drop for EnvVarGuard {
4137 fn drop(&mut self) {
4138 match self.previous.take() {
4139 Some(value) => std::env::set_var(self.key, value),
4140 None => std::env::remove_var(self.key),
4141 }
4142 }
4143 }
4144
4145 fn make_client() -> (GhHttp, EnvVarGuard) {
4148 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4150 let client = GhHttp {
4151 client: reqwest::Client::new(),
4152 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
4153 cached_username: OnceLock::new(),
4154 };
4155 (client, guard)
4156 }
4157
4158 #[tokio::test]
4159 async fn get_all_pages_single_page() {
4160 let server = MockServer::start().await;
4161
4162 Mock::given(method("GET"))
4163 .and(path("/items"))
4164 .respond_with(
4165 ResponseTemplate::new(200)
4166 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4167 )
4168 .expect(1)
4169 .mount(&server)
4170 .await;
4171
4172 let (gh, _guard) = make_client();
4173 let url = format!("{}/items", server.uri());
4174 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4175
4176 assert_eq!(result.len(), 2);
4177 assert_eq!(result[0]["id"], 1);
4178 assert_eq!(result[1]["id"], 2);
4179 }
4180
4181 #[tokio::test]
4182 async fn get_all_pages_follows_link_next() {
4183 let server = MockServer::start().await;
4184
4185 Mock::given(method("GET"))
4187 .and(path("/items"))
4188 .respond_with(
4189 ResponseTemplate::new(200)
4190 .set_body_json(serde_json::json!([{"id": 1}]))
4191 .insert_header(
4192 "Link",
4193 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4194 ),
4195 )
4196 .expect(1)
4197 .mount(&server)
4198 .await;
4199
4200 Mock::given(method("GET"))
4202 .and(path("/items/page2"))
4203 .respond_with(
4204 ResponseTemplate::new(200)
4205 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4206 )
4207 .expect(1)
4208 .mount(&server)
4209 .await;
4210
4211 let (gh, _guard) = make_client();
4212 let url = format!("{}/items", server.uri());
4213 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4214
4215 assert_eq!(result.len(), 3);
4216 assert_eq!(result[0]["id"], 1);
4217 assert_eq!(result[1]["id"], 2);
4218 assert_eq!(result[2]["id"], 3);
4219 }
4220
4221 #[tokio::test]
4222 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4223 let server = MockServer::start().await;
4226
4227 Mock::given(method("GET"))
4228 .and(path("/items"))
4229 .respond_with(
4230 ResponseTemplate::new(200)
4231 .set_body_json(serde_json::json!([{"id": 1}]))
4232 .insert_header(
4233 "Link",
4234 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4235 ),
4236 )
4237 .expect(1)
4238 .mount(&server)
4239 .await;
4240
4241 Mock::given(method("GET"))
4243 .and(path("/items/page2"))
4244 .respond_with(
4245 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4246 )
4247 .expect(1)
4248 .mount(&server)
4249 .await;
4250
4251 let (gh, _guard) = make_client();
4252 let url = format!("{}/items", server.uri());
4253 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4254
4255 assert_eq!(result.len(), 2);
4257 }
4258
4259 #[tokio::test]
4260 async fn get_all_pages_stops_on_malformed_link_header() {
4261 let server = MockServer::start().await;
4264
4265 Mock::given(method("GET"))
4266 .and(path("/items"))
4267 .respond_with(
4268 ResponseTemplate::new(200)
4269 .set_body_json(serde_json::json!([{"id": 1}]))
4270 .insert_header("Link", "garbage; rel=\"next\""),
4271 )
4272 .expect(1)
4273 .mount(&server)
4274 .await;
4275
4276 let (gh, _guard) = make_client();
4277 let url = format!("{}/items", server.uri());
4278 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4279
4280 assert_eq!(result.len(), 1);
4282 }
4283
4284 #[tokio::test]
4285 async fn get_all_pages_returns_error_on_malformed_json_body() {
4286 let server = MockServer::start().await;
4287
4288 Mock::given(method("GET"))
4289 .and(path("/items"))
4290 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4291 .expect(3)
4292 .mount(&server)
4293 .await;
4294
4295 let (gh, _guard) = make_client();
4296 let url = format!("{}/items", server.uri());
4297 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4298
4299 assert!(result.is_err());
4300 let err = result.unwrap_err().to_string();
4301 assert!(
4302 err.contains("JSON decode failed") || err.contains("expected ident"),
4303 "error should mention JSON decode or serde error: {}",
4304 err
4305 );
4306 }
4307
4308 struct GlobalStateGuard;
4313
4314 impl Drop for GlobalStateGuard {
4315 fn drop(&mut self) {
4316 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4317 *rl = RateLimit::new();
4318 }
4319 if let Ok(mut cache) = PAGE_CACHE.lock() {
4320 cache.clear();
4321 }
4322 }
4323 }
4324
4325 #[tokio::test]
4326 async fn get_all_pages_replays_body_on_304() {
4327 let _reset = GlobalStateGuard;
4328 let server = MockServer::start().await;
4329
4330 Mock::given(method("GET"))
4332 .and(path("/issues"))
4333 .and(matchers::header_exists("authorization"))
4334 .and(NotMatcher(matchers::header_exists("if-none-match")))
4335 .respond_with(
4336 ResponseTemplate::new(200)
4337 .set_body_json(serde_json::json!([{"number": 1}]))
4338 .insert_header("ETag", "\"abc123\""),
4339 )
4340 .expect(1)
4341 .mount(&server)
4342 .await;
4343
4344 Mock::given(method("GET"))
4346 .and(path("/issues"))
4347 .and(matchers::header("if-none-match", "\"abc123\""))
4348 .respond_with(ResponseTemplate::new(304))
4349 .expect(1)
4350 .mount(&server)
4351 .await;
4352
4353 let (gh, _guard) = make_client();
4354 let url = format!("{}/issues", server.uri());
4355
4356 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4357 let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4358
4359 assert_eq!(first, second);
4360 assert_eq!(second.len(), 1);
4361 assert_eq!(second[0]["number"], 1);
4362 }
4363
4364 #[tokio::test]
4365 async fn exhausted_quota_skips_further_calls_until_reset() {
4366 let _reset = GlobalStateGuard;
4367 let server = MockServer::start().await;
4368
4369 let reset_at = std::time::SystemTime::now()
4370 .duration_since(std::time::UNIX_EPOCH)
4371 .unwrap()
4372 .as_secs()
4373 + 600;
4374
4375 Mock::given(method("GET"))
4378 .and(path("/issues"))
4379 .respond_with(
4380 ResponseTemplate::new(200)
4381 .set_body_json(serde_json::json!([{"number": 1}]))
4382 .insert_header("x-ratelimit-remaining", "0")
4383 .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4384 )
4385 .expect(1)
4386 .mount(&server)
4387 .await;
4388
4389 let (gh, _guard) = make_client();
4390 let url = format!("{}/issues", server.uri());
4391
4392 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4393 assert_eq!(first.len(), 1);
4394
4395 let start = Instant::now();
4397 let err = gh
4398 .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4399 .await
4400 .unwrap_err();
4401 assert!(
4402 start.elapsed() < Duration::from_secs(1),
4403 "call blocked for {:?} instead of failing fast",
4404 start.elapsed()
4405 );
4406 assert!(err.to_string().contains("rate-limited"), "{err}");
4407
4408 let m = rate_limit_metrics();
4410 assert_eq!(m.rest_remaining, Some(0));
4411 assert_eq!(m.rest_reset_at, Some(reset_at));
4412 assert!(GhHttp::is_rate_limited().is_some());
4413
4414 }
4416
4417 #[tokio::test]
4420 async fn get_all_pages_from_wrapper_single_page() {
4421 let server = MockServer::start().await;
4424
4425 Mock::given(method("GET"))
4426 .and(path("/check-runs"))
4427 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4428 "total_count": 2,
4429 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4430 })))
4431 .expect(1)
4432 .mount(&server)
4433 .await;
4434
4435 let (gh, _guard) = make_client();
4436 let url = format!("{}/check-runs", server.uri());
4437 let result: Vec<serde_json::Value> = gh
4438 .get_all_pages_from_wrapper(&url, "check_runs")
4439 .await
4440 .unwrap();
4441
4442 assert_eq!(result.len(), 2);
4443 assert_eq!(result[0]["id"], 1);
4444 assert_eq!(result[1]["id"], 2);
4445 }
4446
4447 #[tokio::test]
4448 async fn get_all_pages_from_wrapper_follows_link_next() {
4449 let server = MockServer::start().await;
4451
4452 Mock::given(method("GET"))
4453 .and(path("/check-runs"))
4454 .respond_with(
4455 ResponseTemplate::new(200)
4456 .set_body_json(serde_json::json!({
4457 "total_count": 3,
4458 "check_runs": [{"id": 1, "name": "build"}]
4459 }))
4460 .insert_header(
4461 "Link",
4462 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4463 ),
4464 )
4465 .expect(1)
4466 .mount(&server)
4467 .await;
4468
4469 Mock::given(method("GET"))
4470 .and(path("/check-runs/page2"))
4471 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4472 "total_count": 3,
4473 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4474 })))
4475 .expect(1)
4476 .mount(&server)
4477 .await;
4478
4479 let (gh, _guard) = make_client();
4480 let url = format!("{}/check-runs", server.uri());
4481 let result: Vec<serde_json::Value> = gh
4482 .get_all_pages_from_wrapper(&url, "check_runs")
4483 .await
4484 .unwrap();
4485
4486 assert_eq!(result.len(), 3);
4487 assert_eq!(result[0]["id"], 1);
4488 assert_eq!(result[1]["id"], 2);
4489 assert_eq!(result[2]["id"], 3);
4490 }
4491
4492 #[tokio::test]
4493 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4494 let server = MockServer::start().await;
4497
4498 Mock::given(method("GET"))
4499 .and(path("/check-runs"))
4500 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4501 "total_count": 0,
4502 "check_runs": []
4503 })))
4504 .expect(1)
4505 .mount(&server)
4506 .await;
4507
4508 let (gh, _guard) = make_client();
4509 let url = format!("{}/check-runs", server.uri());
4510 let result: Vec<serde_json::Value> = gh
4511 .get_all_pages_from_wrapper(&url, "check_runs")
4512 .await
4513 .unwrap();
4514
4515 assert_eq!(result.len(), 0);
4516 }
4517
4518 #[tokio::test]
4519 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
4520 let server = MockServer::start().await;
4521
4522 Mock::given(method("GET"))
4523 .and(path("/check-runs"))
4524 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
4525 .expect(1)
4526 .mount(&server)
4527 .await;
4528
4529 let (gh, _guard) = make_client();
4530 let url = format!("{}/check-runs", server.uri());
4531 let result = gh
4532 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
4533 .await;
4534
4535 assert!(result.is_err());
4536 let err = result.unwrap_err().to_string();
4537 assert!(
4538 err.contains("422") || err.contains("failed"),
4539 "error should indicate failure: {err}"
4540 );
4541 }
4542 }
4543
4544 #[cfg(test)]
4547 mod auth_retry {
4548 use super::test_client;
4549 use std::sync::atomic::{AtomicUsize, Ordering};
4550 use std::sync::Arc;
4551 use wiremock::matchers::method;
4552 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
4553
4554 fn bad_credentials() -> ResponseTemplate {
4555 ResponseTemplate::new(401).set_body_json(serde_json::json!({
4556 "message": "Bad credentials",
4557 "status": 401
4558 }))
4559 }
4560
4561 #[allow(clippy::await_holding_lock)]
4562 #[tokio::test]
4563 async fn retries_once_with_fresh_token_after_401() {
4564 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4565 let prev = std::env::var_os("GH_TOKEN");
4566 std::env::set_var("GH_TOKEN", "bad_token");
4567
4568 let server = MockServer::start().await;
4569 let hits = Arc::new(AtomicUsize::new(0));
4570 let hits_in_handler = hits.clone();
4571 Mock::given(method("GET"))
4572 .respond_with(move |_req: &Request| {
4573 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
4574 std::env::set_var("GH_TOKEN", "good_token");
4577 bad_credentials()
4578 } else {
4579 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
4580 }
4581 })
4582 .mount(&server)
4583 .await;
4584
4585 let gh = test_client();
4586 let result: anyhow::Result<serde_json::Value> =
4587 gh.get_json(&format!("{}/x", server.uri())).await;
4588
4589 match prev {
4590 Some(v) => std::env::set_var("GH_TOKEN", v),
4591 None => std::env::remove_var("GH_TOKEN"),
4592 }
4593
4594 assert!(
4595 result.is_ok(),
4596 "retry with re-resolved token should succeed: {:?}",
4597 result.err()
4598 );
4599 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
4600 }
4601
4602 #[allow(clippy::await_holding_lock)]
4603 #[tokio::test]
4604 async fn double_401_bails_with_distinct_auth_error() {
4605 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4606 let prev = std::env::var_os("GH_TOKEN");
4607 std::env::set_var("GH_TOKEN", "bad_token");
4608
4609 let server = MockServer::start().await;
4610 let hits = Arc::new(AtomicUsize::new(0));
4611 let hits_in_handler = hits.clone();
4612 Mock::given(method("GET"))
4613 .respond_with(move |_req: &Request| {
4614 hits_in_handler.fetch_add(1, Ordering::SeqCst);
4615 bad_credentials()
4616 })
4617 .mount(&server)
4618 .await;
4619
4620 let gh = test_client();
4621 let result: anyhow::Result<serde_json::Value> =
4622 gh.get_json(&format!("{}/x", server.uri())).await;
4623
4624 match prev {
4625 Some(v) => std::env::set_var("GH_TOKEN", v),
4626 None => std::env::remove_var("GH_TOKEN"),
4627 }
4628
4629 let err = result.expect_err("persisting 401 must fail").to_string();
4630 assert!(
4631 err.contains("token re-resolved after cache clear but still rejected"),
4632 "expected distinct auth-failure error, got: {err}"
4633 );
4634 assert_eq!(
4635 hits.load(Ordering::SeqCst),
4636 2,
4637 "must retry exactly once, not loop"
4638 );
4639 }
4640 }
4641}