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(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1448 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1449 let params = [("state", "open"), ("per_page", "100")];
1450 let all: Vec<GitHubIssue> = self.get_all_pages(&url, ¶ms).await?;
1451 Ok(all
1453 .into_iter()
1454 .filter(|i| i.pull_request.is_none())
1455 .collect())
1456 }
1457
1458 pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1461 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1462 let all: Vec<serde_json::Value> = self
1463 .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1464 .await?;
1465 Ok(all)
1466 }
1467
1468 pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1472 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1473 let all: Vec<GitHubIssue> = self
1474 .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1475 .await?;
1476 Ok(all
1478 .into_iter()
1479 .filter(|i| i.pull_request.is_none())
1480 .collect())
1481 }
1482
1483 pub async fn add_labels(
1485 &self,
1486 repo: &str,
1487 number: &str,
1488 labels: &[String],
1489 ) -> anyhow::Result<()> {
1490 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1491 let payload = serde_json::json!({ "labels": labels });
1492 self.post_json_raw(&url, &payload).await?;
1493 Ok(())
1494 }
1495
1496 pub async fn ensure_label(
1498 &self,
1499 repo: &str,
1500 name: &str,
1501 color: &str,
1502 description: &str,
1503 ) -> anyhow::Result<()> {
1504 let encoded = urlencoding::encode(name);
1505 let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1506
1507 match self.get_bytes(&get_url).await {
1509 Ok(_) => return Ok(()),
1510 Err(e) if e.to_string().contains("404") => {
1511 tracing::debug!(repo, name, "ensure_label: label not found, creating");
1512 }
1513 Err(e) => {
1514 tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1515 }
1516 }
1517
1518 let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1520 let payload = serde_json::json!({
1521 "name": name,
1522 "color": color,
1523 "description": description,
1524 });
1525
1526 match self.post_json_raw(&create_url, &payload).await {
1527 Ok(_) => {
1528 Self::record_success();
1529 }
1530 Err(e) => {
1531 let msg = e.to_string();
1532 if msg.contains("422")
1533 || msg.contains("already_exists")
1534 || msg.contains("Unprocessable")
1535 {
1536 tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1537 return Ok(());
1538 }
1539 tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1541 }
1542 }
1543 Ok(())
1544 }
1545
1546 pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1548 let encoded = urlencoding::encode(label);
1549 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1550 match self.delete(&url).await {
1551 Ok(_) => Ok(()),
1552 Err(e) if e.to_string().contains("404") => {
1553 tracing::debug!(repo, number, label, "label already removed (404)");
1554 Ok(())
1555 }
1556 Err(e) => Err(e),
1557 }
1558 }
1559
1560 pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1562 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1563 let payload = serde_json::json!({ "body": body });
1564 self.post_json_raw(&url, &payload).await?;
1565 Ok(())
1566 }
1567
1568 pub async fn post_commit_status(
1570 &self,
1571 repo: &str,
1572 sha: &str,
1573 state: &str,
1574 context: &str,
1575 description: &str,
1576 ) -> anyhow::Result<()> {
1577 let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1578 let payload = serde_json::json!({
1579 "state": state,
1580 "context": context,
1581 "description": description,
1582 });
1583 self.post_json_raw(&url, &payload).await?;
1584 Ok(())
1585 }
1586
1587 pub async fn add_reaction(
1591 &self,
1592 repo: &str,
1593 number: &str,
1594 content: &str,
1595 ) -> anyhow::Result<()> {
1596 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1597 let payload = serde_json::json!({ "content": content });
1598 self.post_json_raw(&url, &payload).await?;
1599 Ok(())
1600 }
1601
1602 pub async fn add_comment_reaction(
1606 &self,
1607 repo: &str,
1608 comment_id: &str,
1609 content: &str,
1610 ) -> anyhow::Result<()> {
1611 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1612 let payload = serde_json::json!({ "content": content });
1613 self.post_json_raw(&url, &payload).await?;
1614 Ok(())
1615 }
1616
1617 pub async fn list_comments(
1619 &self,
1620 repo: &str,
1621 number: &str,
1622 ) -> anyhow::Result<Vec<GitHubComment>> {
1623 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1624 self.get_all_pages(&url, &[("per_page", "100")]).await
1625 }
1626
1627 pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1629 let owner = repo
1630 .split('/')
1631 .next()
1632 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1633 let head = format!("{}:{}", owner, branch);
1634
1635 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1636 let prs: Vec<serde_json::Value> = self
1637 .get_with_query(
1638 &url,
1639 &[("head", &head), ("state", "closed"), ("per_page", "1")],
1640 )
1641 .await?;
1642
1643 if prs.is_empty() {
1644 return Ok(false);
1645 }
1646 let merged_at = prs[0].get("merged_at");
1647 Ok(merged_at.map(|v| !v.is_null()).unwrap_or(false))
1648 }
1649
1650 pub async fn get_closed_pr_state(
1663 &self,
1664 repo: &str,
1665 branch: &str,
1666 ) -> anyhow::Result<Option<(u64, bool, String)>> {
1667 let owner = repo
1668 .split('/')
1669 .next()
1670 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1671 let head = format!("{}:{}", owner, branch);
1672
1673 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1674 let prs: Vec<serde_json::Value> = self
1675 .get_with_query(
1676 &url,
1677 &[("head", &head), ("state", "closed"), ("per_page", "5")],
1678 )
1679 .await?;
1680
1681 if prs.is_empty() {
1682 return Ok(None);
1683 }
1684
1685 for pr in prs {
1688 let pr_branch = pr
1689 .get("head")
1690 .and_then(|h| h.get("ref"))
1691 .and_then(|r| r.as_str())
1692 .unwrap_or("");
1693
1694 if pr_branch != branch {
1695 tracing::debug!(
1696 repo,
1697 branch,
1698 pr_branch,
1699 "skipping PR with mismatched head ref"
1700 );
1701 continue;
1702 }
1703
1704 let pr_number = pr
1705 .get("number")
1706 .and_then(|n| n.as_u64())
1707 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1708
1709 let merged_at = pr.get("merged_at");
1710 let merged = merged_at.map(|v| !v.is_null()).unwrap_or(false);
1711 let state = pr
1712 .get("state")
1713 .and_then(|v| v.as_str())
1714 .unwrap_or("closed")
1715 .to_string();
1716
1717 return Ok(Some((pr_number, merged, state)));
1718 }
1719
1720 Ok(None)
1722 }
1723
1724 pub async fn batch_is_pr_merged_by_branch(
1729 &self,
1730 repo: &str,
1731 branches: &[String],
1732 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1733 if branches.is_empty() {
1734 return Ok(std::collections::HashMap::new());
1735 }
1736
1737 let (owner, name) = repo
1738 .split_once('/')
1739 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1740
1741 let mut var_defs = String::new();
1745 let mut aliases = String::new();
1746 let mut vars = serde_json::json!({ "owner": owner, "name": name });
1747 for (i, branch) in branches.iter().enumerate() {
1748 var_defs.push_str(&format!(", $b{i}: String!"));
1749 aliases.push_str(&format!(
1750 r#"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "#
1751 ));
1752 vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1753 }
1754
1755 let query = format!(
1756 r#"query($owner: String!, $name: String!{var_defs}) {{
1757 repository(owner: $owner, name: $name) {{ {aliases} }}
1758 }}"#
1759 );
1760
1761 let resp = self.graphql_with_vars(&query, vars).await?;
1762
1763 if let Some(errors) = resp.get("errors").and_then(|e| e.as_array()) {
1765 if !errors.is_empty() {
1766 let messages: Vec<&str> = errors
1767 .iter()
1768 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1769 .collect();
1770 tracing::warn!(
1771 repo,
1772 errors = ?messages,
1773 "GraphQL returned errors in batch PR merge check"
1774 );
1775 }
1776 }
1777
1778 Self::parse_batch_pr_merged_response(&resp, branches)
1779 }
1780
1781 fn parse_batch_pr_merged_response(
1782 resp: &serde_json::Value,
1783 branches: &[String],
1784 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1785 let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1786 let error_msg = resp
1788 .get("errors")
1789 .and_then(|e| e.as_array())
1790 .and_then(|arr| arr.first())
1791 .and_then(|e| e.get("message"))
1792 .and_then(|m| m.as_str())
1793 .unwrap_or("no error details");
1794 anyhow::anyhow!(
1795 "missing /data/repository in GraphQL response: {}",
1796 error_msg
1797 )
1798 })?;
1799
1800 let mut result = std::collections::HashMap::new();
1801 for (i, branch) in branches.iter().enumerate() {
1802 let alias = format!("b{i}");
1803 let merged = repo_data
1804 .get(&alias)
1805 .and_then(|v| v.get("nodes"))
1806 .and_then(|nodes| nodes.as_array())
1807 .and_then(|arr| arr.first())
1808 .and_then(|node| node.get("merged"))
1809 .and_then(|v| v.as_bool())
1810 .unwrap_or(false);
1811 result.insert(branch.clone(), merged);
1812 }
1813
1814 Ok(result)
1815 }
1816
1817 pub async fn get_mentions(
1819 &self,
1820 repo: &str,
1821 since: &str,
1822 ) -> anyhow::Result<Vec<GitHubComment>> {
1823 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments");
1824 self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1825 .await
1826 }
1827
1828 pub async fn get_issue_comments(
1830 &self,
1831 repo: &str,
1832 issue_number: u64,
1833 ) -> anyhow::Result<Vec<GitHubComment>> {
1834 let url = format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/comments");
1835 self.get_all_pages(&url, &[("per_page", "100")]).await
1836 }
1837
1838 pub async fn get_whoami(&self) -> anyhow::Result<String> {
1840 if let Some(cached) = self.cached_username.get() {
1841 return Ok(cached.clone());
1842 }
1843
1844 let user: serde_json::Value = self.get_json(&format!("{GITHUB_API}/user")).await?;
1845 let username = user
1846 .get("login")
1847 .and_then(|v| v.as_str())
1848 .map(String::from)
1849 .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1850
1851 let _ = self.cached_username.set(username.clone());
1852 Ok(username)
1853 }
1854
1855 pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1857 let owner = repo
1858 .split('/')
1859 .next()
1860 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1861 let head = format!("{}:{}", owner, branch);
1862
1863 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1864 let prs: Vec<serde_json::Value> = self
1865 .get_with_query(
1866 &url,
1867 &[("head", &head), ("state", "open"), ("per_page", "1")],
1868 )
1869 .await?;
1870
1871 if prs.is_empty() {
1872 return Ok(None);
1873 }
1874 prs[0]
1875 .get("number")
1876 .and_then(|n| n.as_u64())
1877 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1878 .map(Some)
1879 }
1880
1881 pub async fn create_pr(
1892 &self,
1893 repo: &str,
1894 title: &str,
1895 body: &str,
1896 head: &str,
1897 base: &str,
1898 ) -> anyhow::Result<String> {
1899 use reqwest::header;
1900
1901 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1902 Self::check_backoff(&url)?;
1903
1904 #[derive(Serialize)]
1905 struct CreatePullRequest<'a> {
1906 title: &'a str,
1907 body: &'a str,
1908 head: &'a str,
1909 base: &'a str,
1910 }
1911
1912 let payload = CreatePullRequest {
1913 title,
1914 body,
1915 head,
1916 base,
1917 };
1918
1919 let auth = self.auth_header().await?;
1920 let make_req = || {
1921 Ok(self
1922 .client
1923 .post(&url)
1924 .header(header::AUTHORIZATION, auth.clone())
1925 .header(header::ACCEPT, "application/vnd.github+json")
1926 .header(header::USER_AGENT, "orch")
1927 .header("X-GitHub-Api-Version", "2022-11-28")
1928 .json(&payload))
1929 };
1930 let response = self.send_with_retries(make_req, false).await?;
1931 let status = response.status();
1932 let text = response.text().await.unwrap_or_default();
1933 if !status.is_success() {
1934 Self::maybe_record_rate_limit_from_body(status, &text);
1935 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
1936 }
1937
1938 let pr: serde_json::Value = serde_json::from_str(&text)?;
1939 pr.get("html_url")
1940 .and_then(|v| v.as_str())
1941 .map(String::from)
1942 .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {:?}", pr))
1943 }
1944
1945 pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
1950 let url = format!("{GITHUB_API}/repos/{repo}");
1951 let resp: serde_json::Value = self.get_json(&url).await?;
1952 resp.get("default_branch")
1953 .and_then(|v| v.as_str())
1954 .map(String::from)
1955 .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
1956 }
1957
1958 pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
1961 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
1962 self.get_json(&url).await
1963 }
1964
1965 pub async fn batch_get_pr_states(
1970 &self,
1971 repo: &str,
1972 pr_numbers: &[u64],
1973 ) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
1974 if pr_numbers.is_empty() {
1975 return Ok(std::collections::HashMap::new());
1976 }
1977
1978 let (owner, name) = repo
1979 .split_once('/')
1980 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1981
1982 const CHUNK_SIZE: usize = 50;
1984 let mut result = std::collections::HashMap::new();
1985
1986 for chunk in pr_numbers.chunks(CHUNK_SIZE) {
1987 let aliases: String = chunk
1988 .iter()
1989 .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
1990 .collect::<Vec<_>>()
1991 .join("\n ");
1992
1993 let query = format!(
1994 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1995 );
1996
1997 let resp = self
1998 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1999 .await?;
2000 let repo_data = resp
2001 .pointer("/data/repository")
2002 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2003
2004 for n in chunk {
2005 if let Some(pr) = repo_data.get(format!("pr{n}")) {
2006 let merged = pr.get("merged").and_then(|v| v.as_bool()).unwrap_or(false);
2007 let state = pr
2008 .get("state")
2009 .and_then(|v| v.as_str())
2010 .unwrap_or("UNKNOWN")
2011 .to_lowercase();
2012 result.insert(*n, (merged, state));
2013 }
2014 }
2015 }
2016
2017 Ok(result)
2018 }
2019
2020 pub async fn batch_get_issue_states(
2025 &self,
2026 repo: &str,
2027 issue_numbers: &[u64],
2028 caller_context: &str,
2029 ) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
2030 if issue_numbers.is_empty() {
2031 return Ok(std::collections::HashMap::new());
2032 }
2033
2034 let (valid_issue_numbers, invalid_issue_numbers) =
2035 sanitize_github_issue_numbers(issue_numbers);
2036 if !invalid_issue_numbers.is_empty() {
2037 tracing::warn!(
2038 repo,
2039 caller_context,
2040 invalid_issue_numbers = ?invalid_issue_numbers,
2041 total_supplied = issue_numbers.len(),
2042 "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
2043 );
2044 }
2045 if valid_issue_numbers.is_empty() {
2046 return Ok(std::collections::HashMap::new());
2047 }
2048
2049 let (owner, name) = repo
2050 .split_once('/')
2051 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2052
2053 const CHUNK_SIZE: usize = 50;
2055 let mut result = std::collections::HashMap::new();
2056
2057 for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
2058 let aliases: String = chunk
2059 .iter()
2060 .map(|n| {
2061 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
2062 })
2063 .collect::<Vec<_>>()
2064 .join(" ");
2065
2066 let query = format!(
2067 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2068 );
2069
2070 let resp = self
2071 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2072 .await?;
2073 let repo_data = resp
2074 .pointer("/data/repository")
2075 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2076
2077 for n in chunk {
2078 if let Some(issue) = repo_data.get(format!("issue{n}")) {
2079 let state = issue
2080 .get("state")
2081 .and_then(|v| v.as_str())
2082 .unwrap_or("UNKNOWN")
2083 .to_lowercase();
2084 let labels: Vec<String> = issue
2085 .pointer("/labels/nodes")
2086 .and_then(|v| v.as_array())
2087 .map(|arr| {
2088 arr.iter()
2089 .filter_map(|l| {
2090 l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2091 })
2092 .collect()
2093 })
2094 .unwrap_or_default();
2095 result.insert(n.to_string(), (state, labels));
2096 }
2097 }
2098 }
2099 Ok(result)
2100 }
2101
2102 pub async fn get_required_status_check_contexts(
2104 &self,
2105 repo: &str,
2106 branch: &str,
2107 ) -> anyhow::Result<Vec<String>> {
2108 let branch = urlencoding::encode(branch);
2109 let url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2110 let resp = self.get_json::<serde_json::Value>(&url).await;
2111 match resp {
2112 Ok(value) => Ok(value
2113 .pointer("/required_status_checks/contexts")
2114 .and_then(|v| v.as_array())
2115 .map(|contexts| {
2116 contexts
2117 .iter()
2118 .filter_map(|context| context.as_str().map(|s| s.to_string()))
2119 .collect()
2120 })
2121 .unwrap_or_default()),
2122 Err(e) => {
2123 if is_no_branch_protection_error(&e.to_string()) {
2124 Ok(vec![])
2125 } else {
2126 Err(e)
2127 }
2128 }
2129 }
2130 }
2131
2132 pub async fn get_commit_status_contexts(
2134 &self,
2135 repo: &str,
2136 sha: &str,
2137 ) -> anyhow::Result<Vec<(String, String)>> {
2138 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2139 let statuses: Vec<serde_json::Value> =
2144 self.get_all_pages_from_wrapper(&url, "statuses").await?;
2145
2146 Ok(statuses
2147 .into_iter()
2148 .filter_map(|status| {
2149 let context = status.get("context").and_then(|v| v.as_str())?;
2150 let state = status.get("state").and_then(|v| v.as_str())?;
2151 Some((context.to_string(), state.to_string()))
2152 })
2153 .collect())
2154 }
2155
2156 fn combined_status_state(
2157 total: u64,
2158 failing: u64,
2159 pending: u64,
2160 has_workflows: bool,
2161 mergeable_state: Option<&str>,
2162 ) -> String {
2163 if failing > 0 {
2164 "failure".to_string()
2165 } else if pending > 0 {
2166 "pending".to_string()
2167 } else if total == 0 && has_workflows {
2168 if mergeable_state == Some("clean") {
2172 "success".to_string()
2173 } else {
2174 "pending".to_string()
2175 }
2176 } else {
2177 "success".to_string()
2178 }
2179 }
2180
2181 pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2186 let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2187 let resp = self.get_json::<serde_json::Value>(&url).await;
2188 match resp {
2189 Ok(value) => Ok(value
2190 .get("workflows")
2191 .and_then(|v| v.as_array())
2192 .map(|workflows| !workflows.is_empty())
2193 .unwrap_or(false)),
2194 Err(e) => {
2195 let err = e.to_string();
2196 if err.contains("404") {
2197 Ok(false)
2198 } else {
2199 Err(e)
2200 }
2201 }
2202 }
2203 }
2204
2205 pub async fn get_check_runs(
2207 &self,
2208 repo: &str,
2209 sha: &str,
2210 ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2211 let url = format!(
2222 "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2223 );
2224 self.get_all_pages_from_wrapper(&url, "check_runs").await
2225 }
2226
2227 pub async fn get_check_run_annotations(
2234 &self,
2235 repo: &str,
2236 check_run_id: u64,
2237 ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2238 let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2239 self.get_all_pages(&url, &[("per_page", "100")]).await
2240 }
2241
2242 pub async fn get_pr_reviews(
2244 &self,
2245 repo: &str,
2246 pr_number: u64,
2247 ) -> anyhow::Result<Vec<GitHubReview>> {
2248 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2249 self.get_all_pages(&url, &[("per_page", "100")]).await
2250 }
2251
2252 pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2257 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2258 let value: serde_json::Value = self.get_json(&url).await?;
2259 value
2260 .pointer("/commit/committer/date")
2261 .and_then(|v| v.as_str())
2262 .map(|s| s.to_string())
2263 .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2264 }
2265
2266 pub async fn update_pr_body(
2268 &self,
2269 repo: &str,
2270 pr_number: u64,
2271 body: &str,
2272 ) -> anyhow::Result<()> {
2273 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2274 let payload = serde_json::json!({ "body": body });
2275 self.patch_json_raw(&url, &payload).await?;
2276 Ok(())
2277 }
2278
2279 pub async fn close_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2281 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2282 let payload = serde_json::json!({ "state": "closed" });
2283 self.patch_json_raw(&url, &payload).await?;
2284 Ok(())
2285 }
2286
2287 pub async fn compare_behind_by(
2292 &self,
2293 repo: &str,
2294 base: &str,
2295 head_sha: &str,
2296 ) -> anyhow::Result<u64> {
2297 let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2298 let body: serde_json::Value = self.get_json(&url).await?;
2299 parse_behind_by(&body)
2300 }
2301
2302 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2304 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2305 let payload = serde_json::json!({ "state": "closed" });
2306 self.patch_json_raw(&url, &payload).await?;
2307 Ok(())
2308 }
2309
2310 pub async fn reopen_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": "open" });
2314 self.patch_json_raw(&url, &payload).await?;
2315 Ok(())
2316 }
2317
2318 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2320 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2321 Self::check_backoff(&url)?;
2322 let auth = self.auth_header().await?;
2323 let make_req = || {
2324 Ok(self
2325 .client
2326 .get(&url)
2327 .header(header::AUTHORIZATION, auth.clone())
2328 .header(header::ACCEPT, "application/vnd.github+json")
2329 .header("X-GitHub-Api-Version", "2022-11-28"))
2330 };
2331 let resp = self.send_with_retries(make_req, false).await?;
2332 let status = resp.status();
2333 if status == StatusCode::NO_CONTENT {
2334 Ok(true)
2335 } else if status == StatusCode::NOT_FOUND {
2336 Ok(false)
2337 } else {
2338 let body = resp.text().await.unwrap_or_default();
2339 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2340 }
2341 }
2342
2343 pub async fn add_sub_issue(
2345 &self,
2346 parent_node_id: &str,
2347 child_node_id: &str,
2348 ) -> anyhow::Result<()> {
2349 let query = format!(
2350 r#"mutation {{
2351 addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
2352 issue {{ number }}
2353 subIssue {{ number }}
2354 }}
2355 }}"#,
2356 parent_node_id, child_node_id
2357 );
2358 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2359 .await?;
2360 Ok(())
2361 }
2362
2363 pub async fn link_issue_to_branch(
2375 &self,
2376 repo: &str,
2377 issue_number: u64,
2378 branch: &str,
2379 ) -> anyhow::Result<String> {
2380 let parts: Vec<&str> = repo.split('/').collect();
2381 if parts.len() != 2 {
2382 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2383 }
2384 let (owner, repo_name) = (parts[0], parts[1]);
2385
2386 let query = r#"query($owner: String!, $name: String!, $number: Int!) {
2388 repository(owner: $owner, name: $name) {
2389 id
2390 issue(number: $number) {
2391 id
2392 }
2393 }
2394 }"#;
2395 let vars = serde_json::json!({
2396 "owner": owner,
2397 "name": repo_name,
2398 "number": issue_number,
2399 });
2400
2401 let result = self.graphql_with_vars(query, vars).await?;
2402
2403 let repo_id = result
2404 .pointer("/data/repository/id")
2405 .and_then(|v| v.as_str())
2406 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2407
2408 let issue_id = result
2409 .pointer("/data/repository/issue/id")
2410 .and_then(|v| v.as_str())
2411 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2412
2413 let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
2415 repository(owner: $owner, name: $name) {
2416 ref(qualifiedName: $ref) {
2417 target {
2418 oid
2419 }
2420 }
2421 }
2422 }"#;
2423 let branch_vars = serde_json::json!({
2424 "owner": owner,
2425 "name": repo_name,
2426 "ref": format!("refs/heads/{}", branch),
2427 });
2428
2429 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2430 let branch_oid = branch_result
2431 .pointer("/data/repository/ref/target/oid")
2432 .and_then(|v| v.as_str());
2433
2434 let branch_oid = match branch_oid {
2437 Some(oid) => oid.to_string(),
2438 None => {
2439 let default_query = r#"query($owner: String!, $name: String!) {
2441 repository(owner: $owner, name: $name) {
2442 defaultBranchRef {
2443 target {
2444 oid
2445 }
2446 }
2447 }
2448 }"#;
2449 let default_vars = serde_json::json!({
2450 "owner": owner,
2451 "name": repo_name,
2452 });
2453 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2454 match default_result
2455 .pointer("/data/repository/defaultBranchRef/target/oid")
2456 .and_then(|v| v.as_str())
2457 {
2458 Some(oid) => {
2459 tracing::debug!(
2460 repo,
2461 issue_number,
2462 branch,
2463 "branch not found, using default branch OID to create and link"
2464 );
2465 oid.to_string()
2466 }
2467 None => {
2468 return Ok(format!("unlinked:{}", branch));
2469 }
2470 }
2471 }
2472 };
2473
2474 let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2477 createLinkedBranch(input: {
2478 issueId: $issueId
2479 repositoryId: $repositoryId
2480 name: $name
2481 oid: $oid
2482 }) {
2483 linkedBranch {
2484 id
2485 ref {
2486 name
2487 }
2488 }
2489 }
2490 }"#;
2491 let mutation_vars = serde_json::json!({
2492 "issueId": issue_id,
2493 "repositoryId": repo_id,
2494 "name": branch,
2495 "oid": branch_oid,
2496 });
2497
2498 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2499
2500 match link_result {
2501 Ok(result) => {
2502 let linked_branch_id = result
2503 .pointer("/data/createLinkedBranch/linkedBranch/id")
2504 .and_then(|v| v.as_str())
2505 .map(String::from);
2506
2507 if let Some(id) = linked_branch_id {
2508 tracing::info!(
2509 repo,
2510 issue_number,
2511 branch,
2512 "linked issue to branch via GraphQL API"
2513 );
2514 Ok(id)
2515 } else {
2516 let errors = result
2518 .get("errors")
2519 .and_then(|e| e.as_array())
2520 .cloned()
2521 .unwrap_or_default();
2522
2523 if errors.iter().any(|e| {
2524 e.get("message")
2525 .and_then(|m| m.as_str())
2526 .map(|m| m.contains("already"))
2527 .unwrap_or(false)
2528 }) {
2529 tracing::debug!(
2530 repo,
2531 issue_number,
2532 branch,
2533 "issue already linked to branch"
2534 );
2535 Ok(format!("already_linked:{}", branch))
2536 } else {
2537 anyhow::bail!("failed to link issue to branch: {:?}", result)
2538 }
2539 }
2540 }
2541 Err(e) => {
2542 let err_str = format!("{}", e);
2544 if err_str.contains("already") || err_str.contains("existing") {
2545 tracing::debug!(
2546 repo,
2547 issue_number,
2548 branch,
2549 "issue already linked to branch (error check)"
2550 );
2551 Ok(format!("already_linked:{}", branch))
2552 } else {
2553 Err(e)
2554 }
2555 }
2556 }
2557 }
2558
2559 pub async fn merge_pr(
2561 &self,
2562 repo: &str,
2563 pr_number: u64,
2564 delete_branch: bool,
2565 ) -> anyhow::Result<()> {
2566 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2567 Self::check_backoff(&url)?;
2568 let payload = serde_json::json!({ "merge_method": "squash" });
2569 let auth = self.auth_header().await?;
2570 let make_req = || {
2571 Ok(self
2572 .client
2573 .put(&url)
2574 .json(&payload)
2575 .header(header::AUTHORIZATION, auth.clone())
2576 .header(header::ACCEPT, "application/vnd.github+json")
2577 .header("X-GitHub-Api-Version", "2022-11-28"))
2578 };
2579 let resp = self.send_with_retries(make_req, false).await?;
2580 let status = resp.status();
2581 if !status.is_success() {
2582 let body = resp.text().await.unwrap_or_default();
2583 anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2584 }
2585 Self::record_success();
2586
2587 if delete_branch {
2589 let pr: serde_json::Value = self
2591 .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2592 .await?;
2593 if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2594 let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2595 if let Err(e) = self.delete(&del_url).await {
2596 let err_str = e.to_string();
2597 if err_str.contains("Reference does not exist") {
2598 tracing::debug!(
2599 ref_name,
2600 "branch already gone (GitHub auto-deleted on merge)"
2601 );
2602 } else {
2603 tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2604 }
2605 }
2606 }
2607 }
2608 Ok(())
2609 }
2610
2611 pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2617 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2618 Self::check_backoff(&url)?;
2619 let auth = self.auth_header().await?;
2620 let make_req = || {
2621 Ok(self
2622 .client
2623 .put(&url)
2624 .header(header::AUTHORIZATION, auth.clone())
2625 .header(header::ACCEPT, "application/vnd.github+json")
2626 .header("X-GitHub-Api-Version", "2022-11-28"))
2627 };
2628 let resp = self.send_with_retries(make_req, false).await?;
2629 let status = resp.status();
2630 if !status.is_success() {
2631 let body = resp.text().await.unwrap_or_default();
2632 Self::maybe_record_rate_limit_from_body(status, &body);
2633 anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2634 }
2635 Self::record_success();
2636 Ok(())
2637 }
2638
2639 pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2643 let parts: Vec<&str> = repo.split('/').collect();
2644 if parts.len() != 2 {
2645 anyhow::bail!("invalid repo format: {repo}");
2646 }
2647 let (owner, repo_name) = (parts[0], parts[1]);
2648
2649 let pr_query = r#"query($owner: String!, $name: String!, $number: Int!) {
2651 repository(owner: $owner, name: $name) {
2652 pullRequest(number: $number) { id }
2653 }
2654 }"#;
2655 let body = self
2656 .graphql_with_vars(
2657 pr_query,
2658 serde_json::json!({
2659 "owner": owner,
2660 "name": repo_name,
2661 "number": pr_number,
2662 }),
2663 )
2664 .await?;
2665 let pr_id = body
2666 .pointer("/data/repository/pullRequest/id")
2667 .and_then(|v| v.as_str())
2668 .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2669 .to_string();
2670
2671 let mutation = r#"mutation($prId: ID!) {
2673 enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2674 pullRequest { autoMergeRequest { enabledAt } }
2675 }
2676 }"#;
2677 let body = self
2678 .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2679 .await?;
2680 if let Some(errors) = body.get("errors") {
2681 anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2682 }
2683
2684 tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2685 Ok(())
2686 }
2687
2688 pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2690 let parts: Vec<&str> = repo.split('/').collect();
2691 if parts.len() != 2 {
2692 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2693 }
2694 let (owner, repo_name) = (parts[0], parts[1]);
2695 let number = match parse_positive_issue_number(number) {
2696 Ok(number) => number,
2697 Err(e) => {
2698 tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2699 return Ok(Vec::new());
2700 }
2701 };
2702
2703 let mut all_numbers: Vec<u64> = Vec::new();
2704 let mut cursor: Option<String> = None;
2705 let page_size = 100;
2706 let max_pages = 50;
2707 let mut page_count = 0;
2708
2709 loop {
2710 page_count += 1;
2711 if page_count > max_pages {
2712 tracing::warn!(
2713 repo,
2714 number,
2715 "get_sub_issues hit max page limit ({max_pages})"
2716 );
2717 break;
2718 }
2719
2720 let (query, vars) = if let Some(ref c) = cursor {
2721 (
2722 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2723 repository(owner: $owner, name: $name) {
2724 issue(number: $number) {
2725 subIssues(first: $first, after: $after) {
2726 nodes { number }
2727 pageInfo { hasNextPage endCursor }
2728 }
2729 }
2730 }
2731 }"#,
2732 serde_json::json!({
2733 "owner": owner,
2734 "name": repo_name,
2735 "number": number,
2736 "first": page_size,
2737 "after": c,
2738 }),
2739 )
2740 } else {
2741 (
2742 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2743 repository(owner: $owner, name: $name) {
2744 issue(number: $number) {
2745 subIssues(first: $first) {
2746 nodes { number }
2747 pageInfo { hasNextPage endCursor }
2748 }
2749 }
2750 }
2751 }"#,
2752 serde_json::json!({
2753 "owner": owner,
2754 "name": repo_name,
2755 "number": number,
2756 "first": page_size,
2757 }),
2758 )
2759 };
2760
2761 let result = self
2762 .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2763 .await?;
2764
2765 let sub_issues_data = result
2766 .get("data")
2767 .and_then(|d| d.get("repository"))
2768 .and_then(|r| r.get("issue"))
2769 .and_then(|i| i.get("subIssues"));
2770
2771 let nodes = sub_issues_data
2772 .and_then(|s| s.get("nodes"))
2773 .and_then(|n| n.as_array());
2774
2775 match nodes {
2776 Some(nodes) => {
2777 let numbers: Vec<u64> = nodes
2778 .iter()
2779 .filter_map(|n| n.get("number").and_then(|num| num.as_u64()))
2780 .collect();
2781 all_numbers.extend(numbers);
2782 }
2783 None => break,
2784 }
2785
2786 let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2787 let has_next = page_info
2788 .and_then(|p| p.get("hasNextPage"))
2789 .and_then(|h| h.as_bool())
2790 .unwrap_or(false);
2791
2792 if !has_next {
2793 break;
2794 }
2795
2796 cursor = page_info
2797 .and_then(|p| p.get("endCursor"))
2798 .and_then(|c| c.as_str())
2799 .map(|s| s.to_string());
2800 }
2801
2802 Ok(all_numbers)
2803 }
2804
2805 pub async fn get_combined_status(
2807 &self,
2808 repo: &str,
2809 git_ref: &str,
2810 has_workflows: bool,
2811 mergeable_state: Option<&str>,
2812 ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2813 let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2814 let resp: serde_json::Value = self.get_json(&url).await?;
2815
2816 let runs = resp
2817 .get("check_runs")
2818 .and_then(|v| v.as_array())
2819 .cloned()
2820 .unwrap_or_default();
2821
2822 let mut passing = 0u64;
2823 let mut failing = 0u64;
2824 let mut pending = 0u64;
2825
2826 for run in &runs {
2827 let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2828 let status = run
2829 .get("status")
2830 .and_then(|v| v.as_str())
2831 .unwrap_or("queued");
2832
2833 match status {
2834 "completed" => {
2835 if conclusion.is_empty() {
2836 pending += 1;
2837 } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2838 passing += 1;
2839 } else {
2840 failing += 1;
2841 }
2842 }
2843 _ => pending += 1,
2844 }
2845 }
2846
2847 let total = runs.len() as u64;
2848 let state =
2849 Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
2850
2851 Ok((state, total, passing, failing, pending))
2852 }
2853
2854 pub async fn rerun_failed_workflow(
2860 &self,
2861 repo: &str,
2862 workflow_name: &str,
2863 branch: &str,
2864 ) -> anyhow::Result<()> {
2865 let url = format!(
2867 "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
2868 );
2869 let resp: serde_json::Value = self.get_json(&url).await?;
2870 let runs = resp["workflow_runs"]
2871 .as_array()
2872 .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
2873
2874 let run_id = runs
2876 .iter()
2877 .find(|r| {
2878 r["name"].as_str() == Some(workflow_name)
2879 && matches!(
2880 r["conclusion"].as_str(),
2881 Some("failure") | Some("cancelled")
2882 )
2883 })
2884 .and_then(|r| r["id"].as_u64())
2885 .ok_or_else(|| {
2886 anyhow::anyhow!(
2887 "no failed run found for workflow '{}' on branch '{}'",
2888 workflow_name,
2889 branch
2890 )
2891 })?;
2892
2893 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
2895 let auth = self.auth_header().await?;
2896 let make_req = || {
2897 Ok(self
2898 .client
2899 .post(&rerun_url)
2900 .header(header::AUTHORIZATION, auth.clone())
2901 .header(header::ACCEPT, "application/vnd.github+json")
2902 .header("X-GitHub-Api-Version", "2022-11-28"))
2903 };
2904 let resp = self.send_with_retries(make_req, false).await?;
2905 let status = resp.status();
2906 if !status.is_success() {
2907 let body = resp.text().await.unwrap_or_default();
2908 Self::maybe_record_rate_limit_from_body(status, &body);
2909 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
2910 }
2911
2912 Ok(())
2913 }
2914
2915 pub async fn batch_fetch_pr_review_data(
2921 &self,
2922 repo: &str,
2923 pr_numbers: &[u64],
2924 ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
2925 if pr_numbers.is_empty() {
2926 return Ok(std::collections::HashMap::new());
2927 }
2928
2929 let (owner, name) = repo
2930 .split_once('/')
2931 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2932
2933 let aliases: String = pr_numbers
2935 .iter()
2936 .map(|&n| build_pr_review_alias(n))
2937 .collect::<Vec<_>>()
2938 .join("\n");
2939
2940 let query = format!(
2941 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2942 );
2943
2944 let resp = self
2945 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2946 .await?;
2947 let repo_data = resp
2948 .pointer("/data/repository")
2949 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2950
2951 let mut result = std::collections::HashMap::new();
2952 for &n in pr_numbers {
2953 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
2954 continue;
2955 };
2956
2957 let merged = pr_data
2958 .get("merged")
2959 .and_then(|v| v.as_bool())
2960 .unwrap_or(false);
2961 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
2962 Some("MERGEABLE") => Some(true),
2963 Some("CONFLICTING") => Some(false),
2964 _ => None, };
2966
2967 let mut reviews: Vec<GitHubReview> = pr_data
2968 .pointer("/reviews/nodes")
2969 .and_then(|v| v.as_array())
2970 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
2971 .unwrap_or_default();
2972
2973 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
2974 tracing::warn!(
2975 pr_number = n,
2976 fetched = reviews.len(),
2977 "batch review fetch truncated reviews connection at first page"
2978 );
2979 }
2980
2981 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
2985
2986 let mut review_comments: Vec<GitHubReviewComment> = pr_data
2987 .pointer("/reviewThreads/nodes")
2988 .and_then(|v| v.as_array())
2989 .map(|threads| {
2990 threads
2991 .iter()
2992 .filter_map(|thread| {
2993 thread
2994 .pointer("/comments/nodes")
2995 .and_then(|v| v.as_array())
2996 .map(|nodes| {
2997 nodes
2998 .iter()
2999 .filter_map(parse_graphql_review_comment)
3000 .collect::<Vec<_>>()
3001 })
3002 })
3003 .flatten()
3004 .collect()
3005 })
3006 .unwrap_or_default();
3007
3008 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3009 tracing::warn!(
3010 pr_number = n,
3011 fetched_threads = pr_data
3012 .pointer("/reviewThreads/nodes")
3013 .and_then(|v| v.as_array())
3014 .map_or(0, |threads| threads.len()),
3015 "batch review fetch truncated reviewThreads connection at first page"
3016 );
3017 }
3018 if pr_data
3019 .pointer("/reviewThreads/nodes")
3020 .and_then(|v| v.as_array())
3021 .is_some_and(|threads| {
3022 threads.iter().any(|thread| {
3023 connection_has_next_page(thread.pointer("/comments/pageInfo"))
3024 })
3025 })
3026 {
3027 tracing::warn!(
3028 pr_number = n,
3029 "batch review fetch truncated one or more review thread comment connections"
3030 );
3031 }
3032
3033 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3036
3037 let mut issue_comments: Vec<GitHubComment> = pr_data
3038 .pointer("/comments/nodes")
3039 .and_then(|v| v.as_array())
3040 .map(|nodes| {
3041 nodes
3042 .iter()
3043 .filter_map(parse_graphql_issue_comment)
3044 .collect()
3045 })
3046 .unwrap_or_default();
3047
3048 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3049 issue_comments.extend(
3050 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3051 .await?,
3052 );
3053 }
3054
3055 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3060
3061 result.insert(
3062 n,
3063 PrReviewBatchData {
3064 merged,
3065 mergeable,
3066 reviews,
3067 review_comments,
3068 issue_comments,
3069 },
3070 );
3071 }
3072
3073 Ok(result)
3074 }
3075
3076 async fn fetch_pr_issue_comments_after(
3077 &self,
3078 owner: &str,
3079 name: &str,
3080 pr_number: u64,
3081 mut cursor: Option<String>,
3082 ) -> anyhow::Result<Vec<GitHubComment>> {
3083 let mut comments = Vec::new();
3084 let mut page_count = 0;
3085 let max_pages = 50;
3086
3087 while let Some(current_cursor) = cursor.take() {
3088 page_count += 1;
3089 if page_count > max_pages {
3090 tracing::warn!(
3091 pr_number,
3092 fetched = comments.len(),
3093 "stopping PR issue comment pagination after max page limit"
3094 );
3095 break;
3096 }
3097
3098 let (query, vars) =
3099 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
3100 let resp = self.graphql_with_vars(&query, vars).await?;
3101 let comments_data = resp
3102 .pointer("/data/repository/pullRequest/comments")
3103 .ok_or_else(|| {
3104 anyhow::anyhow!(
3105 "missing /data/repository/pullRequest/comments in GraphQL response"
3106 )
3107 })?;
3108
3109 comments.extend(
3110 comments_data
3111 .get("nodes")
3112 .and_then(|v| v.as_array())
3113 .into_iter()
3114 .flatten()
3115 .filter_map(parse_graphql_issue_comment),
3116 );
3117
3118 cursor = connection_end_cursor(comments_data.get("pageInfo"));
3119 }
3120
3121 Ok(comments)
3122 }
3123
3124 pub async fn get_automated_review_status(
3126 &self,
3127 repo: &str,
3128 pr_number: u64,
3129 ) -> anyhow::Result<Option<String>> {
3130 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3131
3132 let mut newest: Option<&crate::github::types::GitHubComment> = None;
3134 for c in &comments {
3135 if !c.body.starts_with("## Automated Review") {
3136 continue;
3137 }
3138 match self.is_collaborator(repo, &c.user.login).await {
3139 Ok(true) => match newest {
3140 None => newest = Some(c),
3141 Some(prev) => {
3142 if c.created_at > prev.created_at {
3143 newest = Some(c);
3144 }
3145 }
3146 },
3147 Ok(false) => {
3148 tracing::warn!(
3149 user = %c.user.login,
3150 pr_number,
3151 "ignoring automated review comment from non-collaborator"
3152 );
3153 continue;
3154 }
3155 Err(e) => {
3156 tracing::debug!(
3157 user = %c.user.login,
3158 error = %e,
3159 "failed to check collaborator status, skipping comment"
3160 );
3161 continue;
3162 }
3163 }
3164 }
3165
3166 match newest {
3167 Some(c) => {
3168 let first_line = c.body.lines().next().unwrap_or("");
3169 if first_line.contains("Automated Review \u{2014} Approve") {
3170 Ok(Some("approve".to_string()))
3171 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3172 Ok(Some("changes_requested".to_string()))
3173 } else {
3174 Ok(None)
3175 }
3176 }
3177 None => Ok(None),
3178 }
3179 }
3180}
3181
3182fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3187 body.get("behind_by")
3188 .and_then(|v| v.as_u64())
3189 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3190}
3191
3192fn is_no_branch_protection_error(err: &str) -> bool {
3195 err.contains("404")
3196 || (err.contains("403")
3197 && err.contains("Upgrade to GitHub Pro or make this repository public"))
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 no_branch_protection_errors() {
3523 let free_plan = "GitHub API GET https://api.github.com/repos/o/r/branches/main/protection failed (403 Forbidden): {\"message\":\"Upgrade to GitHub Pro or make this repository public to enable this feature.\"}";
3524 assert!(is_no_branch_protection_error(free_plan));
3525 assert!(is_no_branch_protection_error(
3526 "failed (404 Not Found): {\"message\":\"Branch not protected\"}"
3527 ));
3528 assert!(!is_no_branch_protection_error(
3529 "failed (403 Forbidden): {\"message\":\"Resource not accessible\"}"
3530 ));
3531 }
3532
3533 #[test]
3534 fn parse_behind_by_reads_compare_status() {
3535 let behind = serde_json::json!({"status": "behind", "behind_by": 3, "ahead_by": 0});
3537 assert_eq!(parse_behind_by(&behind).unwrap(), 3);
3538 let diverged = serde_json::json!({"status": "diverged", "behind_by": 1, "ahead_by": 2});
3539 assert_eq!(parse_behind_by(&diverged).unwrap(), 1);
3540 let ahead = serde_json::json!({"status": "ahead", "behind_by": 0, "ahead_by": 2});
3541 assert_eq!(parse_behind_by(&ahead).unwrap(), 0);
3542 assert!(parse_behind_by(&serde_json::json!({"status": "ahead"})).is_err());
3544 }
3545
3546 async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3547 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3548 let addr = listener.local_addr().unwrap();
3549
3550 tokio::spawn(async move {
3551 let (mut socket, _) = listener.accept().await.unwrap();
3552 let mut buf = [0u8; 4096];
3553 let _ = socket.read(&mut buf).await;
3554
3555 let declared_len = body_prefix.len() + 32;
3556 let response = format!(
3557 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
3558 );
3559 socket.write_all(response.as_bytes()).await.unwrap();
3560 socket.shutdown().await.unwrap();
3561 });
3562
3563 format!("http://{addr}")
3564 }
3565
3566 fn test_client() -> GhHttp {
3567 GhHttp {
3568 client: reqwest::Client::new(),
3569 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3570 cached_username: OnceLock::new(),
3571 }
3572 }
3573
3574 #[test]
3579 fn gh_http_new_returns_ok() {
3580 let result = GhHttp::new();
3581 assert!(
3582 result.is_ok(),
3583 "GhHttp::new() must not panic and must return Ok; got: {:?}",
3584 result.err()
3585 );
3586 }
3587
3588 #[test]
3589 fn batch_get_issue_states_query_includes_labels_pagination() {
3590 let issue_numbers = [1u64, 2, 3];
3591
3592 let aliases: String = issue_numbers
3593 .iter()
3594 .map(|n| {
3595 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
3596 })
3597 .collect::<Vec<_>>()
3598 .join(" ");
3599
3600 let query = format!(
3601 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
3602 );
3603 assert!(query.contains("$owner"));
3604 assert!(query.contains("$name"));
3605 assert!(query.contains("issue1: issue(number: 1)"));
3606 assert!(query.contains("issue3: issue(number: 3)"));
3607 assert!(query.contains("labels(first: 100)"));
3608 }
3609
3610 #[test]
3611 fn parse_positive_issue_number_accepts_positive_values() {
3612 assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
3613 }
3614
3615 #[test]
3616 fn parse_positive_issue_number_rejects_zero() {
3617 let err = parse_positive_issue_number("0").unwrap_err().to_string();
3618 assert!(err.contains("invalid issue number: 0"));
3619 }
3620
3621 #[test]
3622 fn parse_positive_issue_number_rejects_internal_ids() {
3623 let err = parse_positive_issue_number("internal:5")
3624 .unwrap_err()
3625 .to_string();
3626 assert!(err.contains("invalid issue number: internal:5"));
3627 }
3628
3629 #[test]
3630 fn pr_review_batch_query_includes_page_info_for_nested_connections() {
3631 let query = build_pr_review_alias(42);
3632
3633 assert!(query.contains("reviews(first: 50)"));
3634 assert!(query.contains("reviews(first: 50) {\n nodes"));
3635 assert!(query.contains("pageInfo { hasNextPage }"));
3636 assert!(query.contains("reviewThreads(first: 50)"));
3637 assert!(query.contains("comments(first: 20)"));
3638 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3639 assert!(query.contains("updatedAt"));
3640 }
3641
3642 #[test]
3643 fn pr_issue_comments_page_query_uses_cursor_pagination() {
3644 let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
3645
3646 assert!(query.contains("pullRequest(number: $number)"));
3647 assert!(query.contains("comments(first: 100, after: $after)"));
3648 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3649 assert!(query.contains("updatedAt"));
3650 assert_eq!(vars["owner"], "owner");
3651 assert_eq!(vars["name"], "repo");
3652 assert_eq!(vars["number"], 42);
3653 assert_eq!(vars["after"], "cursor123");
3654 }
3655
3656 #[test]
3657 fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
3658 let page_info = serde_json::json!({
3659 "hasNextPage": true,
3660 "endCursor": "cursor123"
3661 });
3662 assert_eq!(
3663 connection_end_cursor(Some(&page_info)),
3664 Some("cursor123".to_string())
3665 );
3666
3667 let final_page = serde_json::json!({
3668 "hasNextPage": false,
3669 "endCursor": "cursor456"
3670 });
3671 assert_eq!(connection_end_cursor(Some(&final_page)), None);
3672 }
3673
3674 #[test]
3675 fn status_label_colors_match_bash_palette() {
3676 assert_eq!(status_label_color("status:new"), "0e8a16");
3677 assert_eq!(status_label_color("status:routed"), "1d76db");
3678 assert_eq!(status_label_color("status:in_progress"), "fbca04");
3679 assert_eq!(status_label_color("status:done"), "6f42c1");
3680 assert_eq!(status_label_color("status:blocked"), "d73a4a");
3681 assert_eq!(status_label_color("status:in_review"), "0075ca");
3682 assert_eq!(status_label_color("status:needs_review"), "e4e669");
3683 }
3684
3685 #[test]
3686 fn unknown_label_gets_default_color() {
3687 assert_eq!(status_label_color("agent:claude"), "c5def5");
3688 assert_eq!(status_label_color("enhancement"), "c5def5");
3689 assert_eq!(status_label_color(""), "c5def5");
3690 }
3691
3692 #[test]
3693 fn parse_link_next_finds_next_url() {
3694 let mut headers = header::HeaderMap::new();
3695 headers.insert(
3696 "link",
3697 "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
3698 .parse()
3699 .unwrap(),
3700 );
3701 assert_eq!(
3702 parse_link_next(&headers),
3703 Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
3704 );
3705 }
3706
3707 #[test]
3708 fn parse_link_next_none_when_no_next() {
3709 let mut headers = header::HeaderMap::new();
3710 headers.insert(
3711 "link",
3712 "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
3713 .parse()
3714 .unwrap(),
3715 );
3716 assert_eq!(parse_link_next(&headers), None);
3717 }
3718
3719 #[test]
3720 fn parse_link_next_none_when_missing() {
3721 let headers = header::HeaderMap::new();
3722 assert_eq!(parse_link_next(&headers), None);
3723 }
3724
3725 #[test]
3726 fn parse_link_next_none_when_malformed() {
3727 let mut headers = header::HeaderMap::new();
3728 headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
3730 assert_eq!(parse_link_next(&headers), None);
3731 }
3732
3733 #[test]
3734 fn parse_link_next_none_when_gt_before_lt() {
3735 let mut headers = header::HeaderMap::new();
3736 headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
3737 assert_eq!(parse_link_next(&headers), None); }
3739
3740 #[test]
3741 fn parse_link_next_none_when_empty_url() {
3742 let mut headers = header::HeaderMap::new();
3743 headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
3744 assert_eq!(parse_link_next(&headers), None);
3745 }
3746
3747 fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
3748 RateLimit {
3749 remaining: None,
3750 reset_at: None,
3751 backoff_until: None,
3752 backoff_delay: Duration::ZERO,
3753 backoff_base: Duration::from_secs(base_secs),
3754 backoff_max: Duration::from_secs(max_secs),
3755 throttle_threshold: 10,
3756 }
3757 }
3758
3759 #[test]
3760 fn rate_limit_inactive_by_default() {
3761 let rl = make_rl(30, 900);
3762 assert!(rl.is_active().is_none());
3763 }
3764
3765 #[test]
3766 fn rate_limit_backoff_activates() {
3767 let mut rl = make_rl(5, 60);
3768 rl.record_rate_limit();
3769 assert!(rl.is_active().is_some());
3770 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3771 }
3772
3773 #[test]
3774 fn rate_limit_backoff_doubles() {
3775 let mut rl = make_rl(5, 60);
3776 rl.record_rate_limit();
3777 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3778 rl.record_rate_limit();
3779 assert_eq!(rl.backoff_delay, Duration::from_secs(10));
3780 rl.record_rate_limit();
3781 assert_eq!(rl.backoff_delay, Duration::from_secs(20));
3782 }
3783
3784 #[test]
3785 fn rate_limit_backoff_caps_at_max() {
3786 let mut rl = make_rl(30, 60);
3787 rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
3791 }
3792
3793 #[test]
3794 fn rate_limit_success_resets() {
3795 let mut rl = make_rl(5, 60);
3796 rl.record_rate_limit();
3797 assert!(rl.is_active().is_some());
3798 rl.record_success();
3799 assert!(rl.is_active().is_none());
3800 assert_eq!(rl.backoff_delay, Duration::ZERO);
3801 }
3802
3803 #[test]
3804 fn page_cache_key_separates_types_and_queries() {
3805 let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
3806 let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
3807 let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
3808 assert_ne!(a, b);
3809 assert_ne!(a, c);
3810 assert_eq!(
3811 a,
3812 page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
3813 );
3814 }
3815
3816 #[test]
3818 fn page_cache_key_skips_since_filtered_requests() {
3819 assert!(page_cache_key::<GitHubIssue>(
3820 "/issues",
3821 Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
3822 )
3823 .is_none());
3824 assert!(
3826 page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
3827 .is_none()
3828 );
3829 }
3830
3831 #[test]
3834 fn low_quota_fails_fast_instead_of_waiting_for_reset() {
3835 let mut rl = make_rl(30, 900);
3836 rl.remaining = Some(0);
3837 rl.reset_at = Some(
3838 std::time::SystemTime::now()
3839 .duration_since(std::time::UNIX_EPOCH)
3840 .unwrap()
3841 .as_secs()
3842 + 3600,
3843 );
3844 let cell = Mutex::new(rl);
3845
3846 let start = Instant::now();
3847 let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
3848 assert!(
3849 start.elapsed() < Duration::from_secs(1),
3850 "check blocked for {:?}",
3851 start.elapsed()
3852 );
3853 assert!(err.to_string().contains("rate-limited"), "{err}");
3854 }
3855
3856 #[test]
3858 fn healthy_quota_passes_the_check() {
3859 let mut rl = make_rl(30, 900);
3860 rl.remaining = Some(4000);
3861 rl.reset_at = Some(u64::MAX / 2);
3862 let cell = Mutex::new(rl);
3863 assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
3864 }
3865
3866 #[test]
3867 fn rate_limit_proactive_pause() {
3868 let mut rl = make_rl(30, 900);
3869 rl.remaining = Some(0);
3871 let future_epoch = std::time::SystemTime::now()
3872 .duration_since(std::time::UNIX_EPOCH)
3873 .unwrap()
3874 .as_secs()
3875 + 60;
3876 rl.reset_at = Some(future_epoch);
3877 let pause = rl.is_active();
3878 assert!(pause.is_some());
3879 assert!(pause.unwrap().as_secs() <= 62);
3881 }
3882
3883 #[test]
3884 fn rate_limit_proactive_pause_threshold() {
3885 let mut rl = make_rl(30, 900);
3886 rl.remaining = Some(15);
3888 let future_epoch = std::time::SystemTime::now()
3889 .duration_since(std::time::UNIX_EPOCH)
3890 .unwrap()
3891 .as_secs()
3892 + 60;
3893 rl.reset_at = Some(future_epoch);
3894 assert!(rl.is_active().is_none());
3895
3896 rl.remaining = Some(5);
3898 assert!(rl.is_active().is_some());
3899 }
3900
3901 #[test]
3902 fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
3903 assert_eq!(
3905 GhHttp::combined_status_state(0, 0, 0, true, None),
3906 "pending"
3907 );
3908 assert_eq!(
3910 GhHttp::combined_status_state(0, 0, 0, false, None),
3911 "success"
3912 );
3913 assert_eq!(
3915 GhHttp::combined_status_state(3, 1, 0, true, None),
3916 "failure"
3917 );
3918 assert_eq!(
3920 GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
3921 "success"
3922 );
3923 assert_eq!(
3925 GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
3926 "pending"
3927 );
3928 }
3929
3930 #[test]
3931 fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
3932 let resp = serde_json::json!({
3933 "data": {},
3934 "errors": [{ "message": "Something went wrong" }],
3935 });
3936
3937 let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
3938 .unwrap_err()
3939 .to_string();
3940 assert!(err_text.contains("missing /data/repository in GraphQL response"));
3941 assert!(err_text.contains("Something went wrong"));
3942 }
3943
3944 #[test]
3945 fn rate_limit_uses_reset_at_for_backoff() {
3946 let mut rl = make_rl(30, 900);
3947 let future_epoch = std::time::SystemTime::now()
3949 .duration_since(std::time::UNIX_EPOCH)
3950 .unwrap()
3951 .as_secs()
3952 + 45;
3953 rl.reset_at = Some(future_epoch);
3954 rl.record_rate_limit();
3955 assert!(rl.backoff_delay.as_secs() >= 45);
3957 assert!(rl.backoff_delay.as_secs() <= 47);
3958 }
3959
3960 #[tokio::test]
3961 async fn send_with_retries_propagates_make_req_error() {
3962 let guard = std::env::var_os("GH_TOKEN");
3963 std::env::set_var("GH_TOKEN", "test_token");
3964 let gh = test_client();
3965 let result = gh
3966 .send_with_retries(
3967 || Err(anyhow::anyhow!("simulated request clone failure")),
3968 false,
3969 )
3970 .await;
3971 assert!(result.is_err());
3972 let err = result.unwrap_err().to_string();
3973 assert_eq!(err, "simulated request clone failure");
3974 match guard {
3975 Some(v) => std::env::set_var("GH_TOKEN", v),
3976 None => std::env::remove_var("GH_TOKEN"),
3977 }
3978 }
3979
3980 #[tokio::test]
3986 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
3987 let guard = std::env::var_os("GH_TOKEN");
3988 std::env::set_var("GH_TOKEN", "test_token");
3989
3990 let addr = {
3994 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3995 listener.local_addr().unwrap()
3996 };
3997 let url = format!("http://{addr}/");
3998
3999 let gh = test_client();
4000 let make_req = || Ok(gh.client.get(&url));
4001 let result = gh.send_with_retries(make_req, false).await;
4002
4003 assert!(result.is_err());
4004 assert!(
4005 !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
4006 "transport error must not trip the github:5xx circuit breaker"
4007 );
4008 assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
4009
4010 match guard {
4011 Some(v) => std::env::set_var("GH_TOKEN", v),
4012 None => std::env::remove_var("GH_TOKEN"),
4013 }
4014 }
4015
4016 #[tokio::test]
4017 async fn post_json_raw_propagates_body_read_errors_on_success() {
4018 let guard = std::env::var_os("GH_TOKEN");
4019 std::env::set_var("GH_TOKEN", "test_token");
4020 let server_url = spawn_truncated_body_server("{").await;
4021 let gh = test_client();
4022
4023 let result = gh
4024 .post_json_raw(
4025 &format!("{server_url}/post"),
4026 &serde_json::json!({"ok": true}),
4027 )
4028 .await;
4029
4030 let err = result.unwrap_err().to_string();
4031 assert!(
4032 !err.contains("EOF while parsing a value"),
4033 "should surface the body read error, got: {err}"
4034 );
4035 assert!(
4036 err.contains("error decoding response body")
4037 || err.contains("request or response body error")
4038 || err.contains("end of file before message length reached"),
4039 "expected a transport/body read error, got: {err}"
4040 );
4041
4042 match guard {
4043 Some(v) => std::env::set_var("GH_TOKEN", v),
4044 None => std::env::remove_var("GH_TOKEN"),
4045 }
4046 }
4047
4048 #[tokio::test]
4049 async fn patch_json_raw_propagates_body_read_errors_on_success() {
4050 let guard = std::env::var_os("GH_TOKEN");
4051 std::env::set_var("GH_TOKEN", "test_token");
4052 let server_url = spawn_truncated_body_server("{").await;
4053 let gh = test_client();
4054
4055 let result = gh
4056 .patch_json_raw(
4057 &format!("{server_url}/patch"),
4058 &serde_json::json!({"ok": true}),
4059 )
4060 .await;
4061
4062 let err = result.unwrap_err().to_string();
4063 assert!(
4064 !err.contains("EOF while parsing a value"),
4065 "should surface the body read error, got: {err}"
4066 );
4067 assert!(
4068 err.contains("error decoding response body")
4069 || err.contains("request or response body error")
4070 || err.contains("end of file before message length reached"),
4071 "expected a transport/body read error, got: {err}"
4072 );
4073
4074 match guard {
4075 Some(v) => std::env::set_var("GH_TOKEN", v),
4076 None => std::env::remove_var("GH_TOKEN"),
4077 }
4078 }
4079
4080 #[tokio::test]
4081 async fn graphql_request_propagates_body_read_errors_on_success() {
4082 let guard = std::env::var_os("GH_TOKEN");
4083 std::env::set_var("GH_TOKEN", "test_token");
4084 let server_url = spawn_truncated_body_server("{").await;
4085 let gh = test_client();
4086 let query = "query { viewer { login } }";
4087
4088 let result = gh
4089 .graphql_request_to_url(
4090 &format!("{server_url}/graphql"),
4091 query,
4092 &[],
4093 Some(serde_json::json!({"k": "v"})),
4094 )
4095 .await;
4096
4097 let err = result.unwrap_err().to_string();
4098 assert!(
4099 !err.contains("EOF while parsing a value"),
4100 "should surface the body read error, got: {err}"
4101 );
4102 assert!(
4103 err.contains("error decoding response body")
4104 || err.contains("request or response body error")
4105 || err.contains("end of file before message length reached"),
4106 "expected a transport/body read error, got: {err}"
4107 );
4108
4109 match guard {
4110 Some(v) => std::env::set_var("GH_TOKEN", v),
4111 None => std::env::remove_var("GH_TOKEN"),
4112 }
4113 }
4114
4115 #[cfg(test)]
4118 mod pagination {
4119 use super::super::*;
4120 use std::ffi::OsString;
4121 use std::sync::Arc;
4122 use wiremock::matchers::{method, path};
4123 use wiremock::{matchers, Match, Mock, MockServer, Request, ResponseTemplate};
4124
4125 struct NotMatcher<M>(M);
4128
4129 impl<M: Match> Match for NotMatcher<M> {
4130 fn matches(&self, request: &Request) -> bool {
4131 !self.0.matches(request)
4132 }
4133 }
4134
4135 struct EnvVarGuard {
4136 key: &'static str,
4137 previous: Option<OsString>,
4138 }
4139
4140 impl EnvVarGuard {
4141 fn set(key: &'static str, value: &str) -> Self {
4142 let previous = std::env::var_os(key);
4143 std::env::set_var(key, value);
4144 Self { key, previous }
4145 }
4146 }
4147
4148 impl Drop for EnvVarGuard {
4149 fn drop(&mut self) {
4150 match self.previous.take() {
4151 Some(value) => std::env::set_var(self.key, value),
4152 None => std::env::remove_var(self.key),
4153 }
4154 }
4155 }
4156
4157 fn make_client() -> (GhHttp, EnvVarGuard) {
4160 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4162 let client = GhHttp {
4163 client: reqwest::Client::new(),
4164 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
4165 cached_username: OnceLock::new(),
4166 };
4167 (client, guard)
4168 }
4169
4170 #[tokio::test]
4171 async fn get_all_pages_single_page() {
4172 let server = MockServer::start().await;
4173
4174 Mock::given(method("GET"))
4175 .and(path("/items"))
4176 .respond_with(
4177 ResponseTemplate::new(200)
4178 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4179 )
4180 .expect(1)
4181 .mount(&server)
4182 .await;
4183
4184 let (gh, _guard) = make_client();
4185 let url = format!("{}/items", server.uri());
4186 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4187
4188 assert_eq!(result.len(), 2);
4189 assert_eq!(result[0]["id"], 1);
4190 assert_eq!(result[1]["id"], 2);
4191 }
4192
4193 #[tokio::test]
4194 async fn get_all_pages_follows_link_next() {
4195 let server = MockServer::start().await;
4196
4197 Mock::given(method("GET"))
4199 .and(path("/items"))
4200 .respond_with(
4201 ResponseTemplate::new(200)
4202 .set_body_json(serde_json::json!([{"id": 1}]))
4203 .insert_header(
4204 "Link",
4205 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4206 ),
4207 )
4208 .expect(1)
4209 .mount(&server)
4210 .await;
4211
4212 Mock::given(method("GET"))
4214 .and(path("/items/page2"))
4215 .respond_with(
4216 ResponseTemplate::new(200)
4217 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4218 )
4219 .expect(1)
4220 .mount(&server)
4221 .await;
4222
4223 let (gh, _guard) = make_client();
4224 let url = format!("{}/items", server.uri());
4225 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4226
4227 assert_eq!(result.len(), 3);
4228 assert_eq!(result[0]["id"], 1);
4229 assert_eq!(result[1]["id"], 2);
4230 assert_eq!(result[2]["id"], 3);
4231 }
4232
4233 #[tokio::test]
4234 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4235 let server = MockServer::start().await;
4238
4239 Mock::given(method("GET"))
4240 .and(path("/items"))
4241 .respond_with(
4242 ResponseTemplate::new(200)
4243 .set_body_json(serde_json::json!([{"id": 1}]))
4244 .insert_header(
4245 "Link",
4246 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4247 ),
4248 )
4249 .expect(1)
4250 .mount(&server)
4251 .await;
4252
4253 Mock::given(method("GET"))
4255 .and(path("/items/page2"))
4256 .respond_with(
4257 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4258 )
4259 .expect(1)
4260 .mount(&server)
4261 .await;
4262
4263 let (gh, _guard) = make_client();
4264 let url = format!("{}/items", server.uri());
4265 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4266
4267 assert_eq!(result.len(), 2);
4269 }
4270
4271 #[tokio::test]
4272 async fn get_all_pages_stops_on_malformed_link_header() {
4273 let server = MockServer::start().await;
4276
4277 Mock::given(method("GET"))
4278 .and(path("/items"))
4279 .respond_with(
4280 ResponseTemplate::new(200)
4281 .set_body_json(serde_json::json!([{"id": 1}]))
4282 .insert_header("Link", "garbage; rel=\"next\""),
4283 )
4284 .expect(1)
4285 .mount(&server)
4286 .await;
4287
4288 let (gh, _guard) = make_client();
4289 let url = format!("{}/items", server.uri());
4290 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4291
4292 assert_eq!(result.len(), 1);
4294 }
4295
4296 #[tokio::test]
4297 async fn get_all_pages_returns_error_on_malformed_json_body() {
4298 let server = MockServer::start().await;
4299
4300 Mock::given(method("GET"))
4301 .and(path("/items"))
4302 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4303 .expect(3)
4304 .mount(&server)
4305 .await;
4306
4307 let (gh, _guard) = make_client();
4308 let url = format!("{}/items", server.uri());
4309 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4310
4311 assert!(result.is_err());
4312 let err = result.unwrap_err().to_string();
4313 assert!(
4314 err.contains("JSON decode failed") || err.contains("expected ident"),
4315 "error should mention JSON decode or serde error: {}",
4316 err
4317 );
4318 }
4319
4320 struct GlobalStateGuard;
4325
4326 impl Drop for GlobalStateGuard {
4327 fn drop(&mut self) {
4328 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4329 *rl = RateLimit::new();
4330 }
4331 if let Ok(mut cache) = PAGE_CACHE.lock() {
4332 cache.clear();
4333 }
4334 }
4335 }
4336
4337 #[tokio::test]
4338 async fn get_all_pages_replays_body_on_304() {
4339 let _reset = GlobalStateGuard;
4340 let server = MockServer::start().await;
4341
4342 Mock::given(method("GET"))
4344 .and(path("/issues"))
4345 .and(matchers::header_exists("authorization"))
4346 .and(NotMatcher(matchers::header_exists("if-none-match")))
4347 .respond_with(
4348 ResponseTemplate::new(200)
4349 .set_body_json(serde_json::json!([{"number": 1}]))
4350 .insert_header("ETag", "\"abc123\""),
4351 )
4352 .expect(1)
4353 .mount(&server)
4354 .await;
4355
4356 Mock::given(method("GET"))
4358 .and(path("/issues"))
4359 .and(matchers::header("if-none-match", "\"abc123\""))
4360 .respond_with(ResponseTemplate::new(304))
4361 .expect(1)
4362 .mount(&server)
4363 .await;
4364
4365 let (gh, _guard) = make_client();
4366 let url = format!("{}/issues", server.uri());
4367
4368 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4369 let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4370
4371 assert_eq!(first, second);
4372 assert_eq!(second.len(), 1);
4373 assert_eq!(second[0]["number"], 1);
4374 }
4375
4376 #[tokio::test]
4377 async fn exhausted_quota_skips_further_calls_until_reset() {
4378 let _reset = GlobalStateGuard;
4379 let server = MockServer::start().await;
4380
4381 let reset_at = std::time::SystemTime::now()
4382 .duration_since(std::time::UNIX_EPOCH)
4383 .unwrap()
4384 .as_secs()
4385 + 600;
4386
4387 Mock::given(method("GET"))
4390 .and(path("/issues"))
4391 .respond_with(
4392 ResponseTemplate::new(200)
4393 .set_body_json(serde_json::json!([{"number": 1}]))
4394 .insert_header("x-ratelimit-remaining", "0")
4395 .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4396 )
4397 .expect(1)
4398 .mount(&server)
4399 .await;
4400
4401 let (gh, _guard) = make_client();
4402 let url = format!("{}/issues", server.uri());
4403
4404 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4405 assert_eq!(first.len(), 1);
4406
4407 let start = Instant::now();
4409 let err = gh
4410 .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4411 .await
4412 .unwrap_err();
4413 assert!(
4414 start.elapsed() < Duration::from_secs(1),
4415 "call blocked for {:?} instead of failing fast",
4416 start.elapsed()
4417 );
4418 assert!(err.to_string().contains("rate-limited"), "{err}");
4419
4420 let m = rate_limit_metrics();
4422 assert_eq!(m.rest_remaining, Some(0));
4423 assert_eq!(m.rest_reset_at, Some(reset_at));
4424 assert!(GhHttp::is_rate_limited().is_some());
4425
4426 }
4428
4429 #[tokio::test]
4432 async fn get_all_pages_from_wrapper_single_page() {
4433 let server = MockServer::start().await;
4436
4437 Mock::given(method("GET"))
4438 .and(path("/check-runs"))
4439 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4440 "total_count": 2,
4441 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4442 })))
4443 .expect(1)
4444 .mount(&server)
4445 .await;
4446
4447 let (gh, _guard) = make_client();
4448 let url = format!("{}/check-runs", server.uri());
4449 let result: Vec<serde_json::Value> = gh
4450 .get_all_pages_from_wrapper(&url, "check_runs")
4451 .await
4452 .unwrap();
4453
4454 assert_eq!(result.len(), 2);
4455 assert_eq!(result[0]["id"], 1);
4456 assert_eq!(result[1]["id"], 2);
4457 }
4458
4459 #[tokio::test]
4460 async fn get_all_pages_from_wrapper_follows_link_next() {
4461 let server = MockServer::start().await;
4463
4464 Mock::given(method("GET"))
4465 .and(path("/check-runs"))
4466 .respond_with(
4467 ResponseTemplate::new(200)
4468 .set_body_json(serde_json::json!({
4469 "total_count": 3,
4470 "check_runs": [{"id": 1, "name": "build"}]
4471 }))
4472 .insert_header(
4473 "Link",
4474 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4475 ),
4476 )
4477 .expect(1)
4478 .mount(&server)
4479 .await;
4480
4481 Mock::given(method("GET"))
4482 .and(path("/check-runs/page2"))
4483 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4484 "total_count": 3,
4485 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4486 })))
4487 .expect(1)
4488 .mount(&server)
4489 .await;
4490
4491 let (gh, _guard) = make_client();
4492 let url = format!("{}/check-runs", server.uri());
4493 let result: Vec<serde_json::Value> = gh
4494 .get_all_pages_from_wrapper(&url, "check_runs")
4495 .await
4496 .unwrap();
4497
4498 assert_eq!(result.len(), 3);
4499 assert_eq!(result[0]["id"], 1);
4500 assert_eq!(result[1]["id"], 2);
4501 assert_eq!(result[2]["id"], 3);
4502 }
4503
4504 #[tokio::test]
4505 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4506 let server = MockServer::start().await;
4509
4510 Mock::given(method("GET"))
4511 .and(path("/check-runs"))
4512 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4513 "total_count": 0,
4514 "check_runs": []
4515 })))
4516 .expect(1)
4517 .mount(&server)
4518 .await;
4519
4520 let (gh, _guard) = make_client();
4521 let url = format!("{}/check-runs", server.uri());
4522 let result: Vec<serde_json::Value> = gh
4523 .get_all_pages_from_wrapper(&url, "check_runs")
4524 .await
4525 .unwrap();
4526
4527 assert_eq!(result.len(), 0);
4528 }
4529
4530 #[tokio::test]
4531 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
4532 let server = MockServer::start().await;
4533
4534 Mock::given(method("GET"))
4535 .and(path("/check-runs"))
4536 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
4537 .expect(1)
4538 .mount(&server)
4539 .await;
4540
4541 let (gh, _guard) = make_client();
4542 let url = format!("{}/check-runs", server.uri());
4543 let result = gh
4544 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
4545 .await;
4546
4547 assert!(result.is_err());
4548 let err = result.unwrap_err().to_string();
4549 assert!(
4550 err.contains("422") || err.contains("failed"),
4551 "error should indicate failure: {err}"
4552 );
4553 }
4554 }
4555
4556 #[cfg(test)]
4559 mod auth_retry {
4560 use super::test_client;
4561 use std::sync::atomic::{AtomicUsize, Ordering};
4562 use std::sync::Arc;
4563 use wiremock::matchers::method;
4564 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
4565
4566 fn bad_credentials() -> ResponseTemplate {
4567 ResponseTemplate::new(401).set_body_json(serde_json::json!({
4568 "message": "Bad credentials",
4569 "status": 401
4570 }))
4571 }
4572
4573 #[allow(clippy::await_holding_lock)]
4574 #[tokio::test]
4575 async fn retries_once_with_fresh_token_after_401() {
4576 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4577 let prev = std::env::var_os("GH_TOKEN");
4578 std::env::set_var("GH_TOKEN", "bad_token");
4579
4580 let server = MockServer::start().await;
4581 let hits = Arc::new(AtomicUsize::new(0));
4582 let hits_in_handler = hits.clone();
4583 Mock::given(method("GET"))
4584 .respond_with(move |_req: &Request| {
4585 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
4586 std::env::set_var("GH_TOKEN", "good_token");
4589 bad_credentials()
4590 } else {
4591 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
4592 }
4593 })
4594 .mount(&server)
4595 .await;
4596
4597 let gh = test_client();
4598 let result: anyhow::Result<serde_json::Value> =
4599 gh.get_json(&format!("{}/x", server.uri())).await;
4600
4601 match prev {
4602 Some(v) => std::env::set_var("GH_TOKEN", v),
4603 None => std::env::remove_var("GH_TOKEN"),
4604 }
4605
4606 assert!(
4607 result.is_ok(),
4608 "retry with re-resolved token should succeed: {:?}",
4609 result.err()
4610 );
4611 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
4612 }
4613
4614 #[allow(clippy::await_holding_lock)]
4615 #[tokio::test]
4616 async fn double_401_bails_with_distinct_auth_error() {
4617 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4618 let prev = std::env::var_os("GH_TOKEN");
4619 std::env::set_var("GH_TOKEN", "bad_token");
4620
4621 let server = MockServer::start().await;
4622 let hits = Arc::new(AtomicUsize::new(0));
4623 let hits_in_handler = hits.clone();
4624 Mock::given(method("GET"))
4625 .respond_with(move |_req: &Request| {
4626 hits_in_handler.fetch_add(1, Ordering::SeqCst);
4627 bad_credentials()
4628 })
4629 .mount(&server)
4630 .await;
4631
4632 let gh = test_client();
4633 let result: anyhow::Result<serde_json::Value> =
4634 gh.get_json(&format!("{}/x", server.uri())).await;
4635
4636 match prev {
4637 Some(v) => std::env::set_var("GH_TOKEN", v),
4638 None => std::env::remove_var("GH_TOKEN"),
4639 }
4640
4641 let err = result.expect_err("persisting 401 must fail").to_string();
4642 assert!(
4643 err.contains("token re-resolved after cache clear but still rejected"),
4644 "expected distinct auth-failure error, got: {err}"
4645 );
4646 assert_eq!(
4647 hits.load(Ordering::SeqCst),
4648 2,
4649 "must retry exactly once, not loop"
4650 );
4651 }
4652 }
4653}