1use super::token;
12use super::types::GitHubUser;
13use super::types::{
14 GitHubAnnotation, GitHubCheckRun, GitHubComment, GitHubIssue, GitHubPullRequest, GitHubReview,
15 GitHubReviewComment, RequiredStatusCheck,
16};
17use crate::backends::BlockedByFetch;
18use crate::backends::BlockedByIssue;
19use crate::config;
20use crate::engine::cooldown as engine_cooldown;
21use crate::engine::cooldown;
22use crate::github::types;
23use crate::security;
24use anyhow::Context;
25use reqwest::{Client, Response, StatusCode, header};
26use serde::Serialize;
27use serde::de::DeserializeOwned;
28use std::any;
29use std::cmp::Reverse;
30use std::collections::HashMap;
31use std::collections::HashSet;
32use std::fmt;
33use std::fmt::Debug;
34use std::fmt::Formatter;
35use std::fmt::Write as _;
36use std::sync::LazyLock;
37use std::sync::{
38 Arc, Mutex, OnceLock,
39 atomic::{AtomicU64, Ordering},
40};
41use std::time as std_time;
42use std::time::SystemTime;
43use std::time::{Duration, Instant};
44use tokio::runtime::Handle;
45use tokio::time;
46use urlencoding;
47
48#[cfg(not(test))]
49const GITHUB_API: &str = "https://api.github.com";
50#[cfg(test)]
52const GITHUB_API: &str = "http://127.0.0.1:9";
53
54#[derive(Debug, serde::Deserialize)]
55struct BlockedByPayload {
56 #[serde(default)]
57 id: i64,
58 number: i64,
59 #[serde(default)]
60 state: String,
61 #[serde(default)]
62 state_reason: Option<String>,
63 #[serde(default)]
64 repository: Option<BlockedByRepoPayload>,
65}
66
67#[derive(Debug, serde::Deserialize)]
68struct BlockedByRepoPayload {
69 full_name: String,
70}
71
72pub(crate) fn is_full_commit_sha(sha: &str) -> bool {
73 let len = sha.len();
74 (len == 40 || len == 64) && sha.bytes().all(|b| b.is_ascii_hexdigit())
75}
76
77fn repo_from_url(url: &str) -> Option<String> {
80 let rest = url.split_once("/repos/")?.1;
81 let mut parts = rest.split(['/', '?']);
82 let owner = parts.next().filter(|s| !s.is_empty())?;
83 let name = parts.next().filter(|s| !s.is_empty())?;
84 Some(format!("{owner}/{name}"))
85}
86
87pub(crate) fn redact_posted_body(body: &str) -> String {
88 if !security::has_leaks(body) {
89 return body.to_string();
90 }
91 let leaks = security::scan(body);
92 let rules: Vec<&str> = leaks.iter().map(|leak| leak.rule).collect();
93 let warning = format!(
94 "\n\n> ⚠️ **Security Notice**: {} potential secret(s) detected and redacted: {}",
95 leaks.len(),
96 rules.join(", ")
97 );
98 format!("{}{warning}", security::redact(body))
99}
100
101fn merge_pr_body(head_sha: &str) -> serde_json::Value {
102 serde_json::json!({
103 "merge_method": "squash",
104 "sha": head_sha,
105 })
106}
107
108fn parse_positive_issue_number(number: &str) -> anyhow::Result<i64> {
109 let parsed = number
110 .parse::<i64>()
111 .with_context(|| format!("invalid issue number: {number}"))?;
112 if parsed <= 0 {
113 anyhow::bail!("invalid issue number: {number}");
114 }
115 Ok(parsed)
116}
117
118struct RateLimit {
122 remaining: Option<u32>,
124 reset_at: Option<u64>,
126 backoff_until: Option<Instant>,
128 backoff_delay: Duration,
129 backoff_base: Duration,
130 backoff_max: Duration,
131 throttle_threshold: u32,
134}
135
136impl RateLimit {
137 fn new() -> Self {
138 let base = config::get("gh.backoff.base_seconds")
139 .ok()
140 .and_then(|v| v.parse::<u64>().ok())
141 .unwrap_or(30);
142 let max = config::get("gh.backoff.max_seconds")
143 .ok()
144 .and_then(|v| v.parse::<u64>().ok())
145 .unwrap_or(900);
146 let threshold = config::get("gh.rate_limit.throttle_threshold")
147 .ok()
148 .and_then(|v| v.parse::<u32>().ok())
149 .unwrap_or(200);
150
151 Self {
152 remaining: None,
153 reset_at: None,
154 backoff_until: None,
155 backoff_delay: Duration::ZERO,
156 backoff_base: Duration::from_secs(base),
157 backoff_max: Duration::from_secs(max),
158 throttle_threshold: threshold,
159 }
160 }
161
162 fn update_from_headers(&mut self, headers: &header::HeaderMap) {
164 if let Some(v) = headers.get("x-ratelimit-remaining") {
165 self.remaining = v.to_str().ok().and_then(|s| s.parse().ok());
166 }
167 if let Some(v) = headers.get("x-ratelimit-reset") {
168 self.reset_at = v.to_str().ok().and_then(|s| s.parse().ok());
169 }
170 }
171
172 fn record_success(&mut self) {
174 if self.backoff_delay > Duration::ZERO {
175 tracing::info!("GitHub backoff cleared after successful API call");
176 }
177 self.backoff_delay = Duration::ZERO;
178 self.backoff_until = None;
179 }
180
181 fn record_rate_limit(&mut self) {
183 METRIC_RATE_LIMIT_HITS.fetch_add(1, Ordering::Relaxed);
184
185 if let Some(reset_epoch) = self.reset_at {
187 match SystemTime::now().duration_since(std_time::UNIX_EPOCH) {
188 Ok(since_epoch) => {
189 let now_epoch = since_epoch.as_secs();
190 if reset_epoch > now_epoch {
191 let wait_secs = reset_epoch - now_epoch + 1;
192 self.backoff_delay = Duration::from_secs(wait_secs);
193 self.backoff_until = Some(Instant::now() + self.backoff_delay);
194 tracing::warn!(
195 wait_secs,
196 "GitHub rate limit hit, waiting until reset time"
197 );
198 return;
199 }
200 }
201 Err(e) => {
202 tracing::error!(
203 error = %e,
204 "System clock is before UNIX epoch; ignoring reset_at and falling back to exponential backoff"
205 );
206 }
207 }
208 }
209
210 self.backoff_delay = if self.backoff_delay.is_zero() {
212 self.backoff_base
213 } else {
214 (self.backoff_delay * 2).min(self.backoff_max)
215 };
216 self.backoff_until = Some(Instant::now() + self.backoff_delay);
217 tracing::warn!(
218 delay_secs = self.backoff_delay.as_secs(),
219 "GitHub rate limit hit, backing off (no reset header)"
220 );
221 }
222
223 fn backoff_remaining(&self) -> Option<Duration> {
225 if let Some(until) = self.backoff_until {
226 let now = Instant::now();
227 if now < until {
228 return Some(until - now);
229 }
230 }
231 None
232 }
233
234 fn proactive_wait_duration(&self) -> Option<Duration> {
241 if self.backoff_remaining().is_some() {
243 return None;
244 }
245 if let Some(remaining) = self.remaining
246 && remaining < self.throttle_threshold
247 && let Some(reset_epoch) = self.reset_at
248 {
249 let now_epoch = SystemTime::now()
250 .duration_since(std_time::UNIX_EPOCH)
251 .unwrap_or_default()
252 .as_secs();
253 if now_epoch < reset_epoch {
254 return Some(Duration::from_secs(reset_epoch - now_epoch + 1));
255 }
256 }
257 None
258 }
259
260 fn is_active(&self) -> Option<Duration> {
262 self.backoff_remaining()
263 .or_else(|| self.proactive_wait_duration())
264 }
265}
266
267static REST_RATE_LIMIT: LazyLock<Mutex<RateLimit>> = LazyLock::new(|| Mutex::new(RateLimit::new()));
270
271static GRAPHQL_RATE_LIMIT: LazyLock<Mutex<RateLimit>> =
273 LazyLock::new(|| Mutex::new(RateLimit::new()));
274
275static METRIC_RATE_LIMIT_HITS: AtomicU64 = AtomicU64::new(0);
279static METRIC_PROACTIVE_THROTTLES: AtomicU64 = AtomicU64::new(0);
281static METRIC_5XX_HITS: AtomicU64 = AtomicU64::new(0);
283
284fn check_rate_limit_backoff(cell: &Mutex<RateLimit>, api: &str) -> anyhow::Result<()> {
294 let Some(remaining) = cell.lock().ok().and_then(|rl| rl.is_active()) else {
295 return Ok(());
296 };
297 METRIC_PROACTIVE_THROTTLES.fetch_add(1, Ordering::Relaxed);
298 anyhow::bail!(
299 "GitHub {} API rate-limited, skipping call for {}s",
300 api,
301 remaining.as_secs()
302 );
303}
304
305static CALL_COUNTS: LazyLock<Mutex<HashMap<(String, String), u64>>> =
307 LazyLock::new(|| Mutex::new(HashMap::new()));
308
309fn count_api_call(url: &str) {
312 let path = url.split('?').next().unwrap_or(url);
313 let key = match path.split_once("/repos/") {
314 Some((_, rest)) => {
315 let mut parts = rest.split('/');
316 let project = format!(
317 "{}/{}",
318 parts.next().unwrap_or(""),
319 parts.next().unwrap_or("")
320 );
321 (project, parts.next().unwrap_or("repo").to_string())
322 }
323 None if path.ends_with("/graphql") => ("-".to_string(), "graphql".to_string()),
324 None => ("-".to_string(), "other".to_string()),
325 };
326 if let Ok(mut counts) = CALL_COUNTS.lock() {
327 *counts.entry(key).or_insert(0) += 1;
328 }
329}
330
331pub fn drain_call_counts() -> Vec<(String, String, u64)> {
333 let mut rows: Vec<_> = CALL_COUNTS
334 .lock()
335 .map(|mut c| c.drain().map(|((p, k), n)| (p, k, n)).collect())
336 .unwrap_or_default();
337 rows.sort_by_key(|r| Reverse(r.2));
338 rows
339}
340
341struct CachedPage {
345 etag: String,
346 body: String,
347 next: Option<String>,
349}
350
351static PAGE_CACHE: LazyLock<Mutex<HashMap<String, CachedPage>>> =
359 LazyLock::new(|| Mutex::new(HashMap::new()));
360
361const PAGE_CACHE_MAX: usize = 64;
364
365fn page_cache_key<T>(url: &str, query: Option<&[(&str, &str)]>) -> Option<String> {
369 let mut key = String::from(any::type_name::<T>());
370 key.push(' ');
371 key.push_str(url);
372 if let Some(q) = query {
373 for (i, (k, v)) in q.iter().enumerate() {
374 key.push(if i == 0 { '?' } else { '&' });
375 key.push_str(k);
376 key.push('=');
377 key.push_str(v);
378 }
379 }
380 if key.contains("since=") {
381 return None;
382 }
383 Some(key)
384}
385
386fn page_cache_etag(key: &str) -> Option<String> {
387 PAGE_CACHE.lock().ok()?.get(key).map(|c| c.etag.clone())
388}
389
390fn page_cache_hit(key: &str) -> Option<(String, Option<String>)> {
392 let cache = PAGE_CACHE.lock().ok()?;
393 let entry = cache.get(key)?;
394 Some((entry.body.clone(), entry.next.clone()))
395}
396
397fn page_cache_store(key: String, etag: String, body: String, next: Option<String>) {
398 let Ok(mut cache) = PAGE_CACHE.lock() else {
399 return;
400 };
401 if cache.len() >= PAGE_CACHE_MAX && !cache.contains_key(&key) {
404 cache.clear();
405 }
406 cache.insert(key, CachedPage { etag, body, next });
407}
408
409fn maybe_record_body_rate_limit(cell: &Mutex<RateLimit>, status: StatusCode, body: &str) {
412 if status == StatusCode::FORBIDDEN {
413 let lower = body.to_lowercase();
414 if (lower.contains("rate limit")
415 || lower.contains("abuse detection")
416 || lower.contains("secondary rate"))
417 && let Ok(mut rl) = cell.lock()
418 {
419 rl.record_rate_limit();
420 }
421 }
422}
423
424fn record_response_for_limiter(cell: &Mutex<RateLimit>, resp: &Response) {
426 if let Ok(mut rl) = cell.lock() {
427 rl.update_from_headers(resp.headers());
428 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
431 rl.record_rate_limit();
432 } else if resp.status().is_success() || resp.status() == StatusCode::NOT_MODIFIED {
433 rl.record_success();
434 }
435 }
436 let status = resp.status();
438 if status.is_server_error() {
439 METRIC_5XX_HITS.fetch_add(1, Ordering::Relaxed);
440 if Handle::try_current().is_ok() {
441 tokio::spawn(async move {
442 cooldown::record_github_5xx().await;
443 });
444 }
445 }
446}
447
448#[derive(Clone)]
455pub struct GhHttp {
456 client: Client,
457 token_resolver: Arc<token::TokenResolver>,
458 app: Option<Arc<super::app::AppAuth>>,
460 owner_only: bool,
462 cached_username: OnceLock<String>,
463 #[cfg(test)]
464 api_base: Option<String>,
465}
466
467impl Debug for GhHttp {
468 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
469 f.debug_struct("GhHttp")
470 .field("owner_only", &self.owner_only)
471 .finish_non_exhaustive()
472 }
473}
474
475impl GhHttp {
476 pub fn new() -> anyhow::Result<Self> {
484 let client = Client::builder()
485 .user_agent("orch/0.1 (reqwest)")
486 .pool_max_idle_per_host(4)
487 .timeout(Duration::from_secs(30))
488 .build()
489 .map_err(|e| anyhow::anyhow!("failed to build HTTP client (TLS init): {e}"))?;
490
491 Ok(Self {
492 client,
493 token_resolver: token::shared(),
494 app: None,
495 owner_only: false,
496 cached_username: OnceLock::new(),
497 #[cfg(test)]
498 api_base: None,
499 })
500 }
501
502 pub fn owner() -> anyhow::Result<Self> {
505 let mut gh = Self::new()?;
506 gh.owner_only = true;
507 Ok(gh)
508 }
509
510 #[cfg(test)]
511 pub(crate) fn for_test(api_base: String, app: Option<Arc<super::app::AppAuth>>) -> Self {
512 Self {
513 client: Client::new(),
514 token_resolver: Arc::new(token::TokenResolver::default_env()),
515 app,
516 owner_only: false,
517 cached_username: OnceLock::new(),
518 api_base: Some(api_base),
519 }
520 }
521
522 #[cfg_attr(
523 not(test),
524 expect(
525 clippy::unused_self,
526 reason = "only the test build reads the api base override"
527 )
528 )]
529 fn api_root(&self) -> &str {
530 #[cfg(test)]
531 if let Some(base) = &self.api_base {
532 return base;
533 }
534 GITHUB_API
535 }
536
537 pub fn is_rate_limited() -> Option<Duration> {
542 REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.is_active())
543 }
544
545 fn check_backoff(url: &str) -> anyhow::Result<()> {
547 count_api_call(url);
548 check_rate_limit_backoff(&REST_RATE_LIMIT, "REST")
549 }
550
551 fn check_graphql_backoff(url: &str) -> anyhow::Result<()> {
553 count_api_call(url);
554 check_rate_limit_backoff(&GRAPHQL_RATE_LIMIT, "GraphQL")
555 }
556
557 fn record_response(resp: &Response) {
558 record_response_for_limiter(&REST_RATE_LIMIT, resp);
559 }
560
561 fn record_graphql_response(resp: &Response) {
562 record_response_for_limiter(&GRAPHQL_RATE_LIMIT, resp);
563 }
564
565 fn maybe_record_rate_limit_from_body(status: StatusCode, body: &str) {
567 maybe_record_body_rate_limit(&REST_RATE_LIMIT, status, body);
568 }
569
570 fn maybe_record_graphql_rate_limit_from_body(status: StatusCode, body: &str) {
572 maybe_record_body_rate_limit(&GRAPHQL_RATE_LIMIT, status, body);
573 }
574
575 fn record_success() {
576 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
577 rl.record_success();
578 }
579 }
580
581 fn app(&self) -> Option<Arc<super::app::AppAuth>> {
584 if self.owner_only {
585 return None;
586 }
587
588 self.app.clone()
591 }
592
593 async fn auth_header(&self, url: &str) -> anyhow::Result<String> {
596 if let (Some(app), Some(repo)) = (self.app(), repo_from_url(url)) {
597 match app.token_for_repo(&repo).await {
598 Ok(Some(token)) => return Ok(format!("Bearer {token}")),
599 Ok(None) => {}
600 Err(e) => {
601 tracing::warn!(error = %e, repo, "GitHub App token unavailable, using the owner token");
602 }
603 }
604 }
605 match self.token_resolver.get_token().await? {
606 Some(token) if !token.is_empty() => Ok(format!("Bearer {token}")),
607 _ => anyhow::bail!("No GitHub token available — run `gh auth login` or set GH_TOKEN"),
608 }
609 }
610
611 async fn send_with_retries<F>(
614 &self,
615 mut make_req: F,
616 is_graphql: bool,
617 ) -> anyhow::Result<Response>
618 where
619 F: FnMut() -> anyhow::Result<reqwest::RequestBuilder>,
620 {
621 if engine_cooldown::is_agent_in_cooldown("github:5xx") {
623 if let Some(until) = engine_cooldown::cooldown_until("github:5xx") {
624 let now = chrono::Utc::now().timestamp();
625 let remaining = if until > now { until - now } else { 0 };
626 anyhow::bail!("GitHub API transient 5xx circuit-breaker active for {remaining}s");
627 }
628 anyhow::bail!("GitHub API transient 5xx circuit-breaker active");
629 }
630
631 let attempts = config::get("gh.5xx.retry_attempts")
633 .ok()
634 .and_then(|v| v.parse::<u32>().ok())
635 .unwrap_or(3);
636 let base_secs = config::get("gh.5xx.base_backoff_seconds")
637 .ok()
638 .and_then(|v| v.parse::<u64>().ok())
639 .unwrap_or(1);
640 let max_secs = config::get("gh.5xx.max_backoff_seconds")
641 .ok()
642 .and_then(|v| v.parse::<u64>().ok())
643 .unwrap_or(30);
644 let circuit_cooldown_secs = config::get("gh.5xx.circuit_breaker_cooldown_seconds")
645 .ok()
646 .and_then(|v| v.parse::<u64>().ok())
647 .unwrap_or(300);
648
649 let mut last_err: Option<anyhow::Error> = None;
650 let mut auth_override: Option<String> = None;
653 let mut auth_retried = false;
654 for attempt in 0..attempts {
655 let mut req = make_req()?;
656 if let Some(auth) = &auth_override {
657 req = req.header(header::AUTHORIZATION, auth.clone());
658 }
659 let resp_result = req.send().await;
660
661 match resp_result {
662 Ok(resp) => {
663 if is_graphql {
665 Self::record_graphql_response(&resp);
666 } else {
667 Self::record_response(&resp);
668 }
669
670 if resp.status() == StatusCode::UNAUTHORIZED {
671 let resp_url = resp.url().to_string();
672 let status = resp.status();
673 let body = resp.text().await.unwrap_or_default();
674 if body.contains("Bad credentials") && !auth_retried {
675 tracing::warn!(
676 "GitHub API 401 Bad credentials — clearing cached token and re-resolving"
677 );
678 self.token_resolver.clear_cache().await;
679 if let Some(app) = self.app() {
680 app.invalidate();
681 }
682 match self.auth_header(&resp_url).await {
683 Ok(new_auth) => {
684 auth_retried = true;
685 auth_override = Some(new_auth);
686 }
687 Err(e) => {
688 last_err = Some(
689 e.context("GitHub token re-resolution failed after 401"),
690 );
691 break;
692 }
693 }
694 continue;
696 }
697 if auth_retried {
698 self.token_resolver.clear_cache().await;
701 anyhow::bail!(
702 "GitHub authentication failed ({status}): token re-resolved after cache clear but still rejected"
703 );
704 }
705 anyhow::bail!("GitHub API request failed ({status}): {body}");
706 }
707
708 if resp.status().is_server_error() {
710 let status = resp.status();
711 let headers = resp.headers().clone();
712 let body = resp.text().await.unwrap_or_default();
713 let req_id = headers
714 .get("x-github-request-id")
715 .and_then(|v| v.to_str().ok())
716 .unwrap_or("<none>");
717 let snippet: String = body.chars().take(400).collect();
718 tracing::warn!(
719 status = %status,
720 request_id = %req_id,
721 attempt,
722 "GitHub server error (5xx) — attempt {attempt}: {snippet}"
723 );
724
725 last_err = Some(anyhow::anyhow!(
726 "GitHub API server error ({status}) request_id={req_id}: {snippet}"
727 ));
728
729 if attempt + 1 < attempts {
730 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
733 let mut backoff = base_secs.saturating_mul(exp);
734 if backoff > max_secs {
735 backoff = max_secs;
736 }
737 let jitter_ms = u64::from(attempt) * 100;
739 let sleep_dur = Duration::from_secs(backoff)
740 + Duration::from_millis(jitter_ms.min(1000));
741 time::sleep(sleep_dur).await;
742 continue;
743 }
744 tracing::warn!(
746 "GitHub API 5xx persisted after {} attempts — setting circuit-breaker for {}s",
747 attempts,
748 circuit_cooldown_secs
749 );
750 engine_cooldown::set_agent_cooldown("github:5xx", circuit_cooldown_secs)
751 .await;
752 break;
753 }
754
755 return Ok(resp);
757 }
758 Err(e) => {
759 tracing::warn!(err = %e, attempt, "HTTP request failed to send (transport error, GitHub not reached), will retry if attempts remain");
766 last_err = Some(anyhow::anyhow!(
767 "HTTP request failed to send (transport error): {e}"
768 ));
769 if attempt + 1 < attempts {
770 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
772 let backoff = base_secs.saturating_mul(exp);
773 let sleep_dur = Duration::from_secs(backoff)
774 + Duration::from_millis((u64::from(attempt) * 100).min(1000));
775 time::sleep(sleep_dur).await;
776 continue;
777 }
778 tracing::warn!(
782 "HTTP request failed to send after {} attempts (transport error, GitHub not reached) — not tripping the github:5xx circuit-breaker",
783 attempts
784 );
785 break;
786 }
787 }
788 }
789
790 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unexpected send_with_retries failure")))
791 }
792
793 async fn get_json<T: DeserializeOwned>(&self, url: &str) -> anyhow::Result<T> {
795 Self::check_backoff(url)?;
796 let auth = self.auth_header(url).await?;
797 let make_req = || {
799 Ok(self
800 .client
801 .get(url)
802 .header(header::AUTHORIZATION, auth.clone())
803 .header(header::ACCEPT, "application/vnd.github+json")
804 .header("X-GitHub-Api-Version", "2022-11-28"))
805 };
806 let resp = self.send_with_retries(make_req, false).await?;
807 let status = resp.status();
808 if !status.is_success() {
809 let body = resp.text().await.unwrap_or_default();
810 Self::maybe_record_rate_limit_from_body(status, &body);
811 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
812 }
813 let body = resp.text().await?;
814 serde_json::from_str(&body)
815 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
816 }
817
818 async fn get_bytes(&self, url: &str) -> anyhow::Result<Vec<u8>> {
820 Self::check_backoff(url)?;
821 let auth = self.auth_header(url).await?;
822 let make_req = || {
823 Ok(self
824 .client
825 .get(url)
826 .header(header::AUTHORIZATION, auth.clone())
827 .header(header::ACCEPT, "application/vnd.github+json")
828 .header("X-GitHub-Api-Version", "2022-11-28"))
829 };
830 let resp = self.send_with_retries(make_req, false).await?;
831 let status = resp.status();
832 if !status.is_success() {
833 let body = resp.text().await.unwrap_or_default();
834 Self::maybe_record_rate_limit_from_body(status, &body);
835 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
836 }
837 Ok(resp.bytes().await?.to_vec())
838 }
839
840 async fn get_with_query<T: DeserializeOwned>(
842 &self,
843 url: &str,
844 query: &[(&str, &str)],
845 ) -> anyhow::Result<T> {
846 Self::check_backoff(url)?;
847 let auth = self.auth_header(url).await?;
848 let make_req = || {
849 Ok(self
850 .client
851 .get(url)
852 .query(query)
853 .header(header::AUTHORIZATION, auth.clone())
854 .header(header::ACCEPT, "application/vnd.github+json")
855 .header("X-GitHub-Api-Version", "2022-11-28"))
856 };
857 let resp = self.send_with_retries(make_req, false).await?;
858 let status = resp.status();
859 if !status.is_success() {
860 let body = resp.text().await.unwrap_or_default();
861 Self::maybe_record_rate_limit_from_body(status, &body);
862 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
863 }
864 let body = resp.text().await?;
865 serde_json::from_str(&body)
866 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
867 }
868
869 async fn post_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
871 Self::check_backoff(url)?;
872 let auth = self.auth_header(url).await?;
873 let make_req = || {
874 Ok(self
875 .client
876 .post(url)
877 .json(body)
878 .header(header::AUTHORIZATION, auth.clone())
879 .header(header::ACCEPT, "application/vnd.github+json")
880 .header("X-GitHub-Api-Version", "2022-11-28"))
881 };
882 let resp = self.send_with_retries(make_req, false).await?;
883 let status = resp.status();
884 let text_res = resp.text().await;
885 if !status.is_success() {
886 let text = text_res.unwrap_or_default();
887 Self::maybe_record_rate_limit_from_body(status, &text);
888 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
889 }
890 let text = text_res?;
891 Ok(text)
892 }
893
894 async fn post_json<T: DeserializeOwned>(
896 &self,
897 url: &str,
898 body: &serde_json::Value,
899 ) -> anyhow::Result<T> {
900 let text = self.post_json_raw(url, body).await?;
901 Ok(serde_json::from_str(&text)?)
902 }
903
904 async fn patch_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
906 Self::check_backoff(url)?;
907 let auth = self.auth_header(url).await?;
908 let make_req = || {
909 Ok(self
910 .client
911 .patch(url)
912 .json(body)
913 .header(header::AUTHORIZATION, auth.clone())
914 .header(header::ACCEPT, "application/vnd.github+json")
915 .header("X-GitHub-Api-Version", "2022-11-28"))
916 };
917 let resp = self.send_with_retries(make_req, false).await?;
918 let status = resp.status();
919 let text_res = resp.text().await;
920 if !status.is_success() {
921 let text = text_res.unwrap_or_default();
922 Self::maybe_record_rate_limit_from_body(status, &text);
923 anyhow::bail!("GitHub API PATCH {url} failed ({status}): {text}");
924 }
925 let text = text_res?;
926 Ok(text)
927 }
928
929 async fn delete(&self, url: &str) -> anyhow::Result<StatusCode> {
931 Self::check_backoff(url)?;
932 let auth = self.auth_header(url).await?;
933 let make_req = || {
934 Ok(self
935 .client
936 .delete(url)
937 .header(header::AUTHORIZATION, auth.clone())
938 .header(header::ACCEPT, "application/vnd.github+json")
939 .header("X-GitHub-Api-Version", "2022-11-28"))
940 };
941 let resp = self.send_with_retries(make_req, false).await?;
942 let status = resp.status();
943 if !status.is_success() && status != StatusCode::NOT_FOUND {
944 let body = resp.text().await.unwrap_or_default();
945 Self::maybe_record_rate_limit_from_body(status, &body);
946 anyhow::bail!("GitHub API DELETE {url} failed ({status}): {body}");
947 }
948 Ok(status)
949 }
950
951 async fn get_all_pages<T: DeserializeOwned>(
956 &self,
957 url: &str,
958 query: &[(&str, &str)],
959 ) -> anyhow::Result<Vec<T>> {
960 const MAX_JSON_DECODE_RETRIES: u32 = 2;
961 const JSON_DECODE_RETRY_BACKOFF_MS: u64 = 200;
962 Self::check_backoff(url)?;
963 let mut all: Vec<T> = Vec::new();
964 let mut next_url: Option<String> = None;
965 let mut is_first = true;
966
967 loop {
968 let (request_url, request_query) = if is_first {
969 is_first = false;
970 (url, Some(query))
971 } else {
972 let Some(u) = next_url.as_ref() else { break };
973 (u.as_str(), None)
974 };
975
976 let cache_key = page_cache_key::<T>(request_url, request_query);
977 let mut cached_etag = cache_key.as_deref().and_then(page_cache_etag);
978 let mut decode_attempt = 0;
979 let (page, next) = loop {
980 let auth = self.auth_header(request_url).await?;
981 let mut req = self
982 .client
983 .get(request_url)
984 .header(header::AUTHORIZATION, auth)
985 .header(header::ACCEPT, "application/vnd.github+json")
986 .header("X-GitHub-Api-Version", "2022-11-28");
987 if let Some(q) = request_query {
988 req = req.query(q);
989 }
990 if let Some(etag) = &cached_etag {
991 req = req.header(header::IF_NONE_MATCH, etag.clone());
992 }
993
994 let make_req = || {
995 req.try_clone()
996 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
997 };
998 let resp = self.send_with_retries(make_req, false).await?;
999 let status = resp.status();
1000 let next_from_headers = parse_link_next(resp.headers());
1001 let etag = resp
1002 .headers()
1003 .get(header::ETAG)
1004 .and_then(|v| v.to_str().ok())
1005 .map(ToString::to_string);
1006 let content_type = resp
1007 .headers()
1008 .get(header::CONTENT_TYPE)
1009 .and_then(|v| v.to_str().ok())
1010 .map_or_else(|| "<missing>".to_string(), ToString::to_string);
1011
1012 if status == StatusCode::NOT_MODIFIED {
1013 if let Some((body, next)) = cache_key.as_deref().and_then(page_cache_hit) {
1014 break (serde_json::from_str::<Vec<T>>(&body)?, next);
1015 }
1016 cached_etag = None;
1021 continue;
1022 }
1023
1024 if !status.is_success() {
1025 let body = resp.text().await.unwrap_or_default();
1026 Self::maybe_record_rate_limit_from_body(status, &body);
1027 anyhow::bail!("GitHub API GET (paginated) failed ({status}): {body}");
1028 }
1029
1030 let text = resp.text().await?;
1031 match serde_json::from_str::<Vec<T>>(&text) {
1032 Ok(page) => {
1033 if let (Some(key), Some(etag)) = (cache_key.clone(), etag) {
1034 page_cache_store(key, etag, text, next_from_headers.clone());
1035 }
1036 break (page, next_from_headers);
1037 }
1038 Err(err) => {
1039 let snippet: String = text.chars().take(200).collect();
1040 let decode_error = anyhow::anyhow!(
1041 "GitHub API GET (paginated) JSON decode failed ({status}, content-type={content_type}): {snippet}"
1042 )
1043 .context(err);
1044 if decode_attempt < MAX_JSON_DECODE_RETRIES {
1045 decode_attempt += 1;
1046 tracing::warn!(
1047 attempt = decode_attempt,
1048 max_attempts = MAX_JSON_DECODE_RETRIES,
1049 status = %status,
1050 content_type,
1051 err = %decode_error,
1052 "json decode failed; retrying paginated GET"
1053 );
1054 time::sleep(Duration::from_millis(
1055 JSON_DECODE_RETRY_BACKOFF_MS * u64::from(decode_attempt),
1056 ))
1057 .await;
1058 continue;
1059 }
1060 return Err(decode_error);
1061 }
1062 }
1063 };
1064
1065 next_url = next;
1066 all.extend(page);
1067
1068 if next_url.is_none() {
1069 break;
1070 }
1071 }
1072
1073 Ok(all)
1074 }
1075
1076 async fn get_all_pages_from_wrapper<T: DeserializeOwned>(
1087 &self,
1088 url: &str,
1089 array_field: &str,
1090 ) -> anyhow::Result<Vec<T>> {
1091 Self::check_backoff(url)?;
1092 let mut all: Vec<T> = Vec::new();
1093 let mut current_url: String = url.to_string();
1094
1095 loop {
1096 let auth = self.auth_header(¤t_url).await?;
1097 let make_req = || {
1098 self.client
1099 .get(¤t_url)
1100 .header(header::AUTHORIZATION, auth.clone())
1101 .header(header::ACCEPT, "application/vnd.github+json")
1102 .header("X-GitHub-Api-Version", "2022-11-28")
1103 .try_clone()
1104 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
1105 };
1106 let resp = self.send_with_retries(make_req, false).await?;
1107 let status = resp.status();
1108 let next = parse_link_next(resp.headers());
1109
1110 if !status.is_success() {
1111 let body = resp.text().await.unwrap_or_default();
1112 Self::maybe_record_rate_limit_from_body(status, &body);
1113 anyhow::bail!(
1114 "GitHub API GET (wrapped paginated) {current_url} failed ({status}): {body}"
1115 );
1116 }
1117
1118 let body = resp.text().await?;
1119 let wrapper: serde_json::Value = serde_json::from_str(&body).with_context(|| {
1120 format!("failed to parse wrapper response from {current_url}: {body}")
1121 })?;
1122 let items = wrapper
1123 .get(array_field)
1124 .and_then(|v| v.as_array())
1125 .cloned()
1126 .unwrap_or_default();
1127
1128 for item in items {
1129 match serde_json::from_value::<T>(item) {
1130 Ok(v) => all.push(v),
1131 Err(e) => {
1132 tracing::warn!(
1133 field = array_field,
1134 err = %e,
1135 "skipping item that failed to deserialize in wrapped pagination"
1136 );
1137 }
1138 }
1139 }
1140
1141 match next {
1142 Some(next_url) => current_url = next_url,
1143 None => break,
1144 }
1145 }
1146
1147 Ok(all)
1148 }
1149
1150 async fn graphql_request(
1152 &self,
1153 query: &str,
1154 extra_headers: &[(&str, &str)],
1155 variables: Option<serde_json::Value>,
1156 ) -> anyhow::Result<serde_json::Value> {
1157 self.graphql_request_to_url(
1158 &format!("{GITHUB_API}/graphql"),
1159 query,
1160 extra_headers,
1161 variables,
1162 )
1163 .await
1164 }
1165
1166 async fn graphql_request_to_url(
1167 &self,
1168 url: &str,
1169 query: &str,
1170 extra_headers: &[(&str, &str)],
1171 variables: Option<serde_json::Value>,
1172 ) -> anyhow::Result<serde_json::Value> {
1173 Self::check_graphql_backoff(url)?;
1174 let body = if let Some(vars) = variables {
1175 serde_json::json!({ "query": query, "variables": vars })
1176 } else {
1177 serde_json::json!({ "query": query })
1178 };
1179 let auth = self.auth_header(url).await?;
1180 let mut req = self
1181 .client
1182 .post(url)
1183 .json(&body)
1184 .header(header::AUTHORIZATION, auth)
1185 .header(header::ACCEPT, "application/vnd.github+json")
1186 .header("X-GitHub-Api-Version", "2022-11-28");
1187
1188 for (k, v) in extra_headers {
1189 req = req.header(*k, *v);
1190 }
1191
1192 let make_req = || {
1194 req.try_clone()
1195 .ok_or_else(|| anyhow::anyhow!("graphql request clone failed"))
1196 };
1197 let resp = self.send_with_retries(make_req, true).await?;
1198 let status = resp.status();
1199 let text_res = resp.text().await;
1200 if !status.is_success() {
1201 let text = text_res.unwrap_or_default();
1202 Self::maybe_record_graphql_rate_limit_from_body(status, &text);
1203 anyhow::bail!("GitHub GraphQL failed ({status}): {text}");
1204 }
1205 let text = text_res?;
1206 let json: serde_json::Value = serde_json::from_str(&text)?;
1207 if let Some(errors) = json.get("errors").and_then(|e| e.as_array())
1209 && !errors.is_empty()
1210 {
1211 let messages: Vec<&str> = errors
1212 .iter()
1213 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1214 .collect();
1215 if json.get("data").is_none() || json["data"].is_null() {
1216 anyhow::bail!("GitHub GraphQL errors: {}", messages.join("; "));
1218 }
1219 tracing::warn!(errors = ?messages, "GitHub GraphQL returned partial errors");
1221 }
1222 Ok(json)
1223 }
1224
1225 pub async fn graphql_with_vars(
1228 &self,
1229 query: &str,
1230 variables: serde_json::Value,
1231 ) -> anyhow::Result<serde_json::Value> {
1232 self.graphql_request(query, &[], Some(variables)).await
1233 }
1234
1235 pub async fn graphql_with_headers(
1236 &self,
1237 query: &str,
1238 headers: &[&str],
1239 ) -> anyhow::Result<serde_json::Value> {
1240 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1242 self.graphql_request(query, &pairs, None).await
1243 }
1244
1245 pub async fn graphql_with_headers_and_vars(
1246 &self,
1247 query: &str,
1248 headers: &[&str],
1249 variables: serde_json::Value,
1250 ) -> anyhow::Result<serde_json::Value> {
1251 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1252 self.graphql_request(query, &pairs, Some(variables)).await
1253 }
1254
1255 pub async fn auth_status(&self) -> anyhow::Result<()> {
1260 match self
1261 .get_json::<serde_json::Value>(&format!("{GITHUB_API}/user"))
1262 .await
1263 {
1264 Ok(_) => Ok(()),
1265 Err(e) => {
1266 let err_str = e.to_string();
1267
1268 if err_str.contains("401") || err_str.contains("Bad credentials") {
1270 anyhow::bail!(
1271 "GitHub authentication failed: Invalid or expired token. \
1272 Check your GH_TOKEN/GITHUB_TOKEN environment variables, \
1273 or gh.auth configuration in ~/.orch/config.yml. \
1274 Run `orch auth check` to verify your setup."
1275 );
1276 }
1277
1278 if err_str.contains("403") && err_str.contains("rate limit") {
1279 anyhow::bail!(
1280 "GitHub API rate limited. Wait before retrying, \
1281 or check your token's rate limit quota."
1282 );
1283 }
1284
1285 Err(e)
1286 }
1287 }
1288 }
1289
1290 pub async fn create_issue(
1292 &self,
1293 repo: &str,
1294 title: &str,
1295 body: &str,
1296 labels: &[String],
1297 ) -> anyhow::Result<GitHubIssue> {
1298 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1299 let payload = serde_json::json!({
1300 "title": title,
1301 "body": body,
1302 "labels": labels,
1303 });
1304 self.post_json(&url, &payload).await
1305 }
1306
1307 pub async fn update_issue(
1311 &self,
1312 repo: &str,
1313 number: u64,
1314 title: &str,
1315 body: &str,
1316 labels: &[String],
1317 ) -> anyhow::Result<GitHubIssue> {
1318 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
1319 let payload = serde_json::json!({
1320 "title": title,
1321 "body": body,
1322 "labels": labels,
1323 });
1324 let text = self.patch_json_raw(&url, &payload).await?;
1325 serde_json::from_str(&text)
1326 .with_context(|| format!("failed to parse issue update response: {text}"))
1327 }
1328
1329 pub async fn list_blocked_by(
1337 &self,
1338 repo: &str,
1339 issue_number: u64,
1340 ) -> anyhow::Result<BlockedByFetch> {
1341 let mut next = Some(format!(
1342 "{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by?per_page=100"
1343 ));
1344 let mut found = Vec::new();
1345 while let Some(url) = next.take() {
1346 let resp = match self
1347 .dependency_request(&url, reqwest::Method::GET, None)
1348 .await
1349 {
1350 Ok(resp) => resp,
1351 Err(err) => return Ok(BlockedByFetch::Failed(err.to_string())),
1352 };
1353 let status = resp.status();
1354 if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
1355 let body = resp.text().await.unwrap_or_default();
1356 if status == StatusCode::FORBIDDEN
1357 && body.to_ascii_lowercase().contains("rate limit")
1358 {
1359 Self::maybe_record_rate_limit_from_body(status, &body);
1360 return Ok(BlockedByFetch::Failed(body));
1361 }
1362 return Ok(BlockedByFetch::Unavailable);
1363 }
1364 if !status.is_success() {
1365 let body = resp.text().await.unwrap_or_default();
1366 Self::maybe_record_rate_limit_from_body(status, &body);
1367 return Ok(BlockedByFetch::Failed(format!("{status}: {body}")));
1368 }
1369 Self::record_success();
1370 let page_next = parse_link_next(resp.headers());
1371 let body = resp.text().await.unwrap_or_default();
1372 let page: Vec<BlockedByPayload> = match serde_json::from_str(&body) {
1373 Ok(page) => page,
1374 Err(err) => {
1375 return Ok(BlockedByFetch::Failed(format!(
1376 "blocked_by parse failed: {err}"
1377 )));
1378 }
1379 };
1380 for item in page {
1381 if item.number <= 0 {
1382 continue;
1383 }
1384 let item_repo = item
1385 .repository
1386 .as_ref()
1387 .map(|r| r.full_name.clone())
1388 .filter(|name| !name.is_empty())
1389 .unwrap_or_else(|| repo.to_string());
1390 found.push(BlockedByIssue {
1391 github_id: item.id,
1392 repo: item_repo,
1393 number: item.number,
1394 state: item.state,
1395 state_reason: item.state_reason,
1396 });
1397 }
1398 next = page_next;
1399 }
1400 Ok(BlockedByFetch::Native(found))
1401 }
1402
1403 pub async fn add_blocked_by(
1407 &self,
1408 repo: &str,
1409 issue_number: u64,
1410 blocking_issue_id: i64,
1411 ) -> anyhow::Result<()> {
1412 let url =
1413 format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by");
1414 let resp = self
1415 .dependency_request(
1416 &url,
1417 reqwest::Method::POST,
1418 Some(serde_json::json!({ "issue_id": blocking_issue_id })),
1419 )
1420 .await?;
1421 let status = resp.status();
1422 if status.is_success() {
1423 Self::record_success();
1424 return Ok(());
1425 }
1426 let body = resp.text().await.unwrap_or_default();
1427 if body.to_ascii_lowercase().contains("already") {
1428 return Ok(());
1429 }
1430 Self::maybe_record_rate_limit_from_body(status, &body);
1431 anyhow::bail!("add blocked_by failed ({status}): {body}")
1432 }
1433
1434 pub async fn remove_blocked_by(
1436 &self,
1437 repo: &str,
1438 number: u64,
1439 blocking_issue_id: u64,
1440 ) -> anyhow::Result<()> {
1441 let url = format!(
1442 "{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
1443 );
1444 self.delete(&url).await?;
1445 Ok(())
1446 }
1447
1448 async fn dependency_request(
1450 &self,
1451 url: &str,
1452 method: reqwest::Method,
1453 body: Option<serde_json::Value>,
1454 ) -> anyhow::Result<Response> {
1455 Self::check_backoff(url)?;
1456 let auth = self.auth_header(url).await?;
1457 let make_req = || {
1458 let mut req = self
1459 .client
1460 .request(method.clone(), url)
1461 .header(header::AUTHORIZATION, auth.clone())
1462 .header(header::ACCEPT, "application/vnd.github+json")
1463 .header("X-GitHub-Api-Version", "2026-03-10");
1464 if let Some(ref payload) = body {
1465 req = req.json(payload);
1466 }
1467 Ok(req)
1468 };
1469 self.send_with_retries(make_req, false).await
1470 }
1471
1472 pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1474 self.get_json(&format!("{}/repos/{repo}/issues/{number}", self.api_root()))
1475 .await
1476 }
1477
1478 pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1480 self.get_issue(repo, number)
1481 .await
1482 .is_ok_and(|i| i.pull_request.is_some())
1483 }
1484
1485 pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1487 self.list_issues_with_state(repo, label, "open").await
1488 }
1489
1490 pub async fn list_issues_with_state(
1494 &self,
1495 repo: &str,
1496 label: &str,
1497 state: &str,
1498 ) -> anyhow::Result<Vec<GitHubIssue>> {
1499 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1500 let mut query = vec![("state", state), ("per_page", "100")];
1502 if !label.is_empty() {
1503 query.push(("labels", label));
1504 }
1505 let all: Vec<GitHubIssue> = self.get_all_pages(&url, &query).await?;
1506 Ok(all
1508 .into_iter()
1509 .filter(|i| i.pull_request.is_none())
1510 .collect())
1511 }
1512
1513 pub async fn list_issues_closed_since(
1517 &self,
1518 repo: &str,
1519 label: &str,
1520 since: &str,
1521 ) -> anyhow::Result<Vec<GitHubIssue>> {
1522 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1523 let all: Vec<GitHubIssue> = self
1524 .get_all_pages(
1525 &url,
1526 &[
1527 ("labels", label),
1528 ("state", "closed"),
1529 ("since", since),
1530 ("per_page", "100"),
1531 ],
1532 )
1533 .await?;
1534 Ok(all
1535 .into_iter()
1536 .filter(|i| i.pull_request.is_none())
1537 .collect())
1538 }
1539
1540 pub async fn list_closed_issues_since(
1542 &self,
1543 repo: &str,
1544 since: &str,
1545 ) -> anyhow::Result<Vec<GitHubIssue>> {
1546 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1547 let all: Vec<GitHubIssue> = self
1548 .get_all_pages(
1549 &url,
1550 &[("state", "closed"), ("since", since), ("per_page", "100")],
1551 )
1552 .await?;
1553 Ok(all
1554 .into_iter()
1555 .filter(|i| i.pull_request.is_none())
1556 .collect())
1557 }
1558
1559 pub async fn list_all_open_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1564 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1565 let params = [("state", "open"), ("per_page", "100")];
1566 let all: Vec<GitHubIssue> = self.get_all_pages(&url, ¶ms).await?;
1567 Ok(all
1569 .into_iter()
1570 .filter(|i| i.pull_request.is_none())
1571 .collect())
1572 }
1573
1574 pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1577 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1578 let all: Vec<serde_json::Value> = self
1579 .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1580 .await?;
1581 Ok(all)
1582 }
1583
1584 pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1588 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1589 let all: Vec<GitHubIssue> = self
1590 .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1591 .await?;
1592 Ok(all
1594 .into_iter()
1595 .filter(|i| i.pull_request.is_none())
1596 .collect())
1597 }
1598
1599 pub async fn add_labels(
1601 &self,
1602 repo: &str,
1603 number: &str,
1604 labels: &[String],
1605 ) -> anyhow::Result<()> {
1606 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1607 let payload = serde_json::json!({ "labels": labels });
1608 self.post_json_raw(&url, &payload).await?;
1609 Ok(())
1610 }
1611
1612 pub async fn ensure_label(
1614 &self,
1615 repo: &str,
1616 name: &str,
1617 color: &str,
1618 description: &str,
1619 ) -> anyhow::Result<()> {
1620 let encoded = urlencoding::encode(name);
1621 let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1622
1623 match self.get_bytes(&get_url).await {
1625 Ok(_) => return Ok(()),
1626 Err(e) if e.to_string().contains("404") => {
1627 tracing::debug!(repo, name, "ensure_label: label not found, creating");
1628 }
1629 Err(e) => {
1630 tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1631 }
1632 }
1633
1634 let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1636 let payload = serde_json::json!({
1637 "name": name,
1638 "color": color,
1639 "description": description,
1640 });
1641
1642 match self.post_json_raw(&create_url, &payload).await {
1643 Ok(_) => {
1644 Self::record_success();
1645 }
1646 Err(e) => {
1647 let msg = e.to_string();
1648 if msg.contains("422")
1649 || msg.contains("already_exists")
1650 || msg.contains("Unprocessable")
1651 {
1652 tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1653 return Ok(());
1654 }
1655 tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1657 }
1658 }
1659 Ok(())
1660 }
1661
1662 pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1664 let encoded = urlencoding::encode(label);
1665 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1666 match self.delete(&url).await {
1667 Ok(_) => Ok(()),
1668 Err(e) if e.to_string().contains("404") => {
1669 tracing::debug!(repo, number, label, "label already removed (404)");
1670 Ok(())
1671 }
1672 Err(e) => Err(e),
1673 }
1674 }
1675
1676 pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1678 let body = redact_posted_body(body);
1679 let url = format!("{}/repos/{repo}/issues/{number}/comments", self.api_root());
1680 let payload = serde_json::json!({ "body": body });
1681 self.post_json_raw(&url, &payload).await?;
1682 Ok(())
1683 }
1684
1685 pub async fn post_commit_status(
1687 &self,
1688 repo: &str,
1689 sha: &str,
1690 state: &str,
1691 context: &str,
1692 description: &str,
1693 ) -> anyhow::Result<()> {
1694 let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1695 let payload = serde_json::json!({
1696 "state": state,
1697 "context": context,
1698 "description": description,
1699 });
1700 self.post_json_raw(&url, &payload).await?;
1701 Ok(())
1702 }
1703
1704 pub async fn add_reaction(
1708 &self,
1709 repo: &str,
1710 number: &str,
1711 content: &str,
1712 ) -> anyhow::Result<()> {
1713 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1714 let payload = serde_json::json!({ "content": content });
1715 self.post_json_raw(&url, &payload).await?;
1716 Ok(())
1717 }
1718
1719 pub async fn add_comment_reaction(
1723 &self,
1724 repo: &str,
1725 comment_id: &str,
1726 content: &str,
1727 ) -> anyhow::Result<()> {
1728 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1729 let payload = serde_json::json!({ "content": content });
1730 self.post_json_raw(&url, &payload).await?;
1731 Ok(())
1732 }
1733
1734 pub async fn list_comments(
1736 &self,
1737 repo: &str,
1738 number: &str,
1739 ) -> anyhow::Result<Vec<GitHubComment>> {
1740 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1741 self.get_all_pages(&url, &[("per_page", "100")]).await
1742 }
1743
1744 pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1746 let owner = repo
1747 .split('/')
1748 .next()
1749 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1750 let head = format!("{owner}:{branch}");
1751
1752 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1753 let prs: Vec<serde_json::Value> = self
1754 .get_with_query(
1755 &url,
1756 &[("head", &head), ("state", "closed"), ("per_page", "1")],
1757 )
1758 .await?;
1759
1760 if prs.is_empty() {
1761 return Ok(false);
1762 }
1763 let merged_at = prs[0].get("merged_at");
1764 Ok(merged_at.is_some_and(|v| !v.is_null()))
1765 }
1766
1767 pub async fn get_closed_pr_state(
1780 &self,
1781 repo: &str,
1782 branch: &str,
1783 ) -> anyhow::Result<Option<(u64, bool, String)>> {
1784 let owner = repo
1785 .split('/')
1786 .next()
1787 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1788 let head = format!("{owner}:{branch}");
1789
1790 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1791 let prs: Vec<serde_json::Value> = self
1792 .get_with_query(
1793 &url,
1794 &[("head", &head), ("state", "closed"), ("per_page", "5")],
1795 )
1796 .await?;
1797
1798 if prs.is_empty() {
1799 return Ok(None);
1800 }
1801
1802 for pr in prs {
1805 let pr_branch = pr
1806 .get("head")
1807 .and_then(|h| h.get("ref"))
1808 .and_then(|r| r.as_str())
1809 .unwrap_or("");
1810
1811 if pr_branch != branch {
1812 tracing::debug!(
1813 repo,
1814 branch,
1815 pr_branch,
1816 "skipping PR with mismatched head ref"
1817 );
1818 continue;
1819 }
1820
1821 let pr_number = pr
1822 .get("number")
1823 .and_then(serde_json::Value::as_u64)
1824 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1825
1826 let merged_at = pr.get("merged_at");
1827 let merged = merged_at.is_some_and(|v| !v.is_null());
1828 let state = pr
1829 .get("state")
1830 .and_then(|v| v.as_str())
1831 .unwrap_or("closed")
1832 .to_string();
1833
1834 return Ok(Some((pr_number, merged, state)));
1835 }
1836
1837 Ok(None)
1839 }
1840
1841 pub async fn batch_is_pr_merged_by_branch(
1846 &self,
1847 repo: &str,
1848 branches: &[String],
1849 ) -> anyhow::Result<HashMap<String, bool>> {
1850 if branches.is_empty() {
1851 return Ok(HashMap::new());
1852 }
1853
1854 let (owner, name) = repo
1855 .split_once('/')
1856 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1857
1858 let mut var_defs = String::new();
1862 let mut aliases = String::new();
1863 let mut vars = serde_json::json!({ "owner": owner, "name": name });
1864 for (i, branch) in branches.iter().enumerate() {
1865 let _ = write!(var_defs, ", $b{i}: String!");
1866 let _ = write!(
1867 aliases,
1868 r"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "
1869 );
1870 vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1871 }
1872
1873 let query = format!(
1874 r"query($owner: String!, $name: String!{var_defs}) {{
1875 repository(owner: $owner, name: $name) {{ {aliases} }}
1876 }}"
1877 );
1878
1879 let resp = self.graphql_with_vars(&query, vars).await?;
1880
1881 if let Some(errors) = resp.get("errors").and_then(|e| e.as_array())
1883 && !errors.is_empty()
1884 {
1885 let messages: Vec<&str> = errors
1886 .iter()
1887 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1888 .collect();
1889 tracing::warn!(
1890 repo,
1891 errors = ?messages,
1892 "GraphQL returned errors in batch PR merge check"
1893 );
1894 }
1895
1896 Self::parse_batch_pr_merged_response(&resp, branches)
1897 }
1898
1899 fn parse_batch_pr_merged_response(
1900 resp: &serde_json::Value,
1901 branches: &[String],
1902 ) -> anyhow::Result<HashMap<String, bool>> {
1903 let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1904 let error_msg = resp
1906 .get("errors")
1907 .and_then(|e| e.as_array())
1908 .and_then(|arr| arr.first())
1909 .and_then(|e| e.get("message"))
1910 .and_then(|m| m.as_str())
1911 .unwrap_or("no error details");
1912 anyhow::anyhow!("missing /data/repository in GraphQL response: {error_msg}")
1913 })?;
1914
1915 let mut result = HashMap::new();
1916 for (i, branch) in branches.iter().enumerate() {
1917 let alias = format!("b{i}");
1918 let merged = repo_data
1919 .get(&alias)
1920 .and_then(|v| v.get("nodes"))
1921 .and_then(|nodes| nodes.as_array())
1922 .and_then(|arr| arr.first())
1923 .and_then(|node| node.get("merged"))
1924 .and_then(serde_json::Value::as_bool)
1925 .unwrap_or(false);
1926 result.insert(branch.clone(), merged);
1927 }
1928
1929 Ok(result)
1930 }
1931
1932 pub async fn get_mentions(
1934 &self,
1935 repo: &str,
1936 since: &str,
1937 ) -> anyhow::Result<Vec<GitHubComment>> {
1938 let url = format!("{}/repos/{repo}/issues/comments", self.api_root());
1939 self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1940 .await
1941 }
1942
1943 pub async fn get_issue_comments(
1945 &self,
1946 repo: &str,
1947 issue_number: u64,
1948 ) -> anyhow::Result<Vec<GitHubComment>> {
1949 let url = format!(
1950 "{}/repos/{repo}/issues/{issue_number}/comments",
1951 self.api_root()
1952 );
1953 self.get_all_pages(&url, &[("per_page", "100")]).await
1954 }
1955
1956 pub async fn get_whoami(&self) -> anyhow::Result<String> {
1958 if let Some(cached) = self.cached_username.get() {
1959 return Ok(cached.clone());
1960 }
1961
1962 let mut owner = self.clone();
1964 owner.owner_only = true;
1965 let user: serde_json::Value = owner.get_json(&format!("{GITHUB_API}/user")).await?;
1966 let username = user
1967 .get("login")
1968 .and_then(|v| v.as_str())
1969 .map(String::from)
1970 .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1971
1972 let _ = self.cached_username.set(username.clone());
1973 Ok(username)
1974 }
1975
1976 pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1978 let owner = repo
1979 .split('/')
1980 .next()
1981 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1982 let head = format!("{owner}:{branch}");
1983
1984 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1985 let prs: Vec<serde_json::Value> = self
1986 .get_with_query(
1987 &url,
1988 &[("head", &head), ("state", "open"), ("per_page", "1")],
1989 )
1990 .await?;
1991
1992 if prs.is_empty() {
1993 return Ok(None);
1994 }
1995 prs[0]
1996 .get("number")
1997 .and_then(serde_json::Value::as_u64)
1998 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1999 .map(Some)
2000 }
2001
2002 pub async fn create_pr(
2013 &self,
2014 repo: &str,
2015 title: &str,
2016 body: &str,
2017 head: &str,
2018 base: &str,
2019 ) -> anyhow::Result<String> {
2020 #[derive(Serialize)]
2021 struct CreatePullRequest<'a> {
2022 title: &'a str,
2023 body: &'a str,
2024 head: &'a str,
2025 base: &'a str,
2026 }
2027
2028 let body = redact_posted_body(body);
2029 let url = format!("{}/repos/{repo}/pulls", self.api_root());
2030 Self::check_backoff(&url)?;
2031
2032 let payload = CreatePullRequest {
2033 title,
2034 body: &body,
2035 head,
2036 base,
2037 };
2038
2039 let auth = self.auth_header(&url).await?;
2040 let make_req = || {
2041 Ok(self
2042 .client
2043 .post(&url)
2044 .header(header::AUTHORIZATION, auth.clone())
2045 .header(header::ACCEPT, "application/vnd.github+json")
2046 .header(header::USER_AGENT, "orch")
2047 .header("X-GitHub-Api-Version", "2022-11-28")
2048 .json(&payload))
2049 };
2050 let response = self.send_with_retries(make_req, false).await?;
2051 let status = response.status();
2052 let text = response.text().await.unwrap_or_default();
2053 if !status.is_success() {
2054 Self::maybe_record_rate_limit_from_body(status, &text);
2055 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
2056 }
2057
2058 let pr: serde_json::Value = serde_json::from_str(&text)?;
2059 pr.get("html_url")
2060 .and_then(|v| v.as_str())
2061 .map(String::from)
2062 .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {pr:?}"))
2063 }
2064
2065 pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
2070 let url = format!("{GITHUB_API}/repos/{repo}");
2071 let resp: serde_json::Value = self.get_json(&url).await?;
2072 resp.get("default_branch")
2073 .and_then(|v| v.as_str())
2074 .map(String::from)
2075 .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
2076 }
2077
2078 pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
2081 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2082 self.get_json(&url).await
2083 }
2084
2085 pub async fn batch_get_pr_states(
2090 &self,
2091 repo: &str,
2092 pr_numbers: &[u64],
2093 ) -> anyhow::Result<HashMap<u64, (bool, String)>> {
2094 const CHUNK_SIZE: usize = 50;
2096
2097 if pr_numbers.is_empty() {
2098 return Ok(HashMap::new());
2099 }
2100
2101 let (owner, name) = repo
2102 .split_once('/')
2103 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2104
2105 let mut result = HashMap::new();
2106
2107 for chunk in pr_numbers.chunks(CHUNK_SIZE) {
2108 let aliases: String = chunk
2109 .iter()
2110 .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
2111 .collect::<Vec<_>>()
2112 .join("\n ");
2113
2114 let query = format!(
2115 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2116 );
2117
2118 let resp = self
2119 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2120 .await?;
2121 let repo_data = resp
2122 .pointer("/data/repository")
2123 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2124
2125 for n in chunk {
2126 if let Some(pr) = repo_data.get(format!("pr{n}")) {
2127 let merged = pr
2128 .get("merged")
2129 .and_then(serde_json::Value::as_bool)
2130 .unwrap_or(false);
2131 let state = pr
2132 .get("state")
2133 .and_then(|v| v.as_str())
2134 .unwrap_or("UNKNOWN")
2135 .to_lowercase();
2136 result.insert(*n, (merged, state));
2137 }
2138 }
2139 }
2140
2141 Ok(result)
2142 }
2143
2144 pub async fn batch_get_issue_states(
2149 &self,
2150 repo: &str,
2151 issue_numbers: &[u64],
2152 caller_context: &str,
2153 ) -> anyhow::Result<HashMap<String, (String, Vec<String>)>> {
2154 const CHUNK_SIZE: usize = 50;
2156
2157 if issue_numbers.is_empty() {
2158 return Ok(HashMap::new());
2159 }
2160
2161 let (valid_issue_numbers, invalid_issue_numbers) =
2162 sanitize_github_issue_numbers(issue_numbers);
2163 if !invalid_issue_numbers.is_empty() {
2164 tracing::warn!(
2165 repo,
2166 caller_context,
2167 invalid_issue_numbers = ?invalid_issue_numbers,
2168 total_supplied = issue_numbers.len(),
2169 "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
2170 );
2171 }
2172 if valid_issue_numbers.is_empty() {
2173 return Ok(HashMap::new());
2174 }
2175
2176 let (owner, name) = repo
2177 .split_once('/')
2178 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2179
2180 let mut result = HashMap::new();
2181
2182 for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
2183 let aliases: String = chunk
2184 .iter()
2185 .map(|n| {
2186 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
2187 })
2188 .collect::<Vec<_>>()
2189 .join(" ");
2190
2191 let query = format!(
2192 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2193 );
2194
2195 let resp = self
2196 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2197 .await?;
2198 let repo_data = resp
2199 .pointer("/data/repository")
2200 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2201
2202 for n in chunk {
2203 if let Some(issue) = repo_data.get(format!("issue{n}")) {
2204 let state = issue
2205 .get("state")
2206 .and_then(|v| v.as_str())
2207 .unwrap_or("UNKNOWN")
2208 .to_lowercase();
2209 let labels: Vec<String> = issue
2210 .pointer("/labels/nodes")
2211 .and_then(|v| v.as_array())
2212 .map(|arr| {
2213 arr.iter()
2214 .filter_map(|l| {
2215 l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2216 })
2217 .collect()
2218 })
2219 .unwrap_or_default();
2220 result.insert(n.to_string(), (state, labels));
2221 }
2222 }
2223 }
2224 Ok(result)
2225 }
2226
2227 pub async fn get_required_status_check_contexts(
2229 &self,
2230 repo: &str,
2231 branch: &str,
2232 ) -> anyhow::Result<Vec<RequiredStatusCheck>> {
2233 let branch = urlencoding::encode(branch);
2234 let classic_url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2235 let rules_url = format!("{GITHUB_API}/repos/{repo}/rules/branches/{branch}");
2236
2237 let classic_fut = self.get_json::<serde_json::Value>(&classic_url);
2238 let rules_fut = self.get_all_pages::<serde_json::Value>(&rules_url, &[("per_page", "100")]);
2239 let (classic_resp, rules_resp) = tokio::join!(classic_fut, rules_fut);
2240
2241 let classic = match classic_resp {
2242 Ok(value) => parse_classic_required_status_checks(&value),
2243 Err(e) => {
2244 if is_no_branch_protection_error(&e.to_string()) {
2245 Vec::new()
2246 } else {
2247 return Err(e);
2248 }
2249 }
2250 };
2251
2252 let ruleset = match rules_resp {
2253 Ok(rules) => parse_ruleset_required_status_checks(&rules),
2254 Err(e) => {
2255 if e.to_string().contains("404") {
2256 Vec::new()
2257 } else {
2258 return Err(e);
2259 }
2260 }
2261 };
2262
2263 Ok(merge_required_status_checks(classic, ruleset))
2264 }
2265
2266 pub async fn get_commit_status_contexts(
2268 &self,
2269 repo: &str,
2270 sha: &str,
2271 ) -> anyhow::Result<Vec<(String, String)>> {
2272 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2273 let statuses: Vec<serde_json::Value> =
2278 self.get_all_pages_from_wrapper(&url, "statuses").await?;
2279
2280 Ok(statuses
2281 .into_iter()
2282 .filter_map(|status| {
2283 let context = status.get("context").and_then(|v| v.as_str())?;
2284 let state = status.get("state").and_then(|v| v.as_str())?;
2285 Some((context.to_string(), state.to_string()))
2286 })
2287 .collect())
2288 }
2289
2290 fn combined_status_state(
2291 total: u64,
2292 failing: u64,
2293 pending: u64,
2294 has_workflows: bool,
2295 mergeable_state: Option<&str>,
2296 ) -> String {
2297 if failing > 0 {
2298 "failure".to_string()
2299 } else if pending > 0 {
2300 "pending".to_string()
2301 } else if total == 0 && has_workflows {
2302 if mergeable_state == Some("clean") {
2306 "success".to_string()
2307 } else {
2308 "pending".to_string()
2309 }
2310 } else {
2311 "success".to_string()
2312 }
2313 }
2314
2315 pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2320 let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2321 let resp = self.get_json::<serde_json::Value>(&url).await;
2322 match resp {
2323 Ok(value) => Ok(value
2324 .get("workflows")
2325 .and_then(|v| v.as_array())
2326 .is_some_and(|workflows| !workflows.is_empty())),
2327 Err(e) => {
2328 let err = e.to_string();
2329 if err.contains("404") {
2330 Ok(false)
2331 } else {
2332 Err(e)
2333 }
2334 }
2335 }
2336 }
2337
2338 pub async fn get_check_runs(
2340 &self,
2341 repo: &str,
2342 sha: &str,
2343 ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2344 let url = format!(
2355 "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2356 );
2357 self.get_all_pages_from_wrapper(&url, "check_runs").await
2358 }
2359
2360 pub async fn get_check_run_annotations(
2367 &self,
2368 repo: &str,
2369 check_run_id: u64,
2370 ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2371 let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2372 self.get_all_pages(&url, &[("per_page", "100")]).await
2373 }
2374
2375 pub async fn get_pr_reviews(
2377 &self,
2378 repo: &str,
2379 pr_number: u64,
2380 ) -> anyhow::Result<Vec<GitHubReview>> {
2381 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2382 self.get_all_pages(&url, &[("per_page", "100")]).await
2383 }
2384
2385 pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2390 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2391 let value: serde_json::Value = self.get_json(&url).await?;
2392 value
2393 .pointer("/commit/committer/date")
2394 .and_then(|v| v.as_str())
2395 .map(ToString::to_string)
2396 .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2397 }
2398
2399 pub async fn update_pr_body(
2401 &self,
2402 repo: &str,
2403 pr_number: u64,
2404 body: &str,
2405 ) -> anyhow::Result<()> {
2406 let body = redact_posted_body(body);
2407 let url = format!("{}/repos/{repo}/pulls/{pr_number}", self.api_root());
2408 let payload = serde_json::json!({ "body": body });
2409 self.patch_json_raw(&url, &payload).await?;
2410 Ok(())
2411 }
2412
2413 pub async fn close_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2415 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2416 let payload = serde_json::json!({ "state": "closed" });
2417 self.patch_json_raw(&url, &payload).await?;
2418 Ok(())
2419 }
2420
2421 pub async fn compare_behind_by(
2426 &self,
2427 repo: &str,
2428 base: &str,
2429 head_sha: &str,
2430 ) -> anyhow::Result<u64> {
2431 let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2432 let body: serde_json::Value = self.get_json(&url).await?;
2433 parse_behind_by(&body)
2434 }
2435
2436 pub async fn commit_meta(&self, repo: &str, sha: &str) -> anyhow::Result<CommitMeta> {
2438 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2439 let value: serde_json::Value = self.get_json(&url).await?;
2440 Ok(parse_commit_meta(&value))
2441 }
2442
2443 pub async fn branch_contains(
2445 &self,
2446 repo: &str,
2447 branch: &str,
2448 sha: &str,
2449 ) -> anyhow::Result<bool> {
2450 let url = format!("{GITHUB_API}/repos/{repo}/compare/{branch}...{sha}");
2451 let body: serde_json::Value = self.get_json(&url).await?;
2452 body.get("ahead_by")
2453 .and_then(serde_json::Value::as_u64)
2454 .map(|ahead| ahead == 0)
2455 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric ahead_by field"))
2456 }
2457
2458 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2460 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2461 let payload = serde_json::json!({ "state": "closed" });
2462 self.patch_json_raw(&url, &payload).await?;
2463 Ok(())
2464 }
2465
2466 pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2468 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2469 let payload = serde_json::json!({ "state": "open" });
2470 self.patch_json_raw(&url, &payload).await?;
2471 Ok(())
2472 }
2473
2474 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2476 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2477 Self::check_backoff(&url)?;
2478 let auth = self.auth_header(&url).await?;
2479 let make_req = || {
2480 Ok(self
2481 .client
2482 .get(&url)
2483 .header(header::AUTHORIZATION, auth.clone())
2484 .header(header::ACCEPT, "application/vnd.github+json")
2485 .header("X-GitHub-Api-Version", "2022-11-28"))
2486 };
2487 let resp = self.send_with_retries(make_req, false).await?;
2488 let status = resp.status();
2489 if status == StatusCode::NO_CONTENT {
2490 Ok(true)
2491 } else if status == StatusCode::NOT_FOUND {
2492 Ok(false)
2493 } else {
2494 let body = resp.text().await.unwrap_or_default();
2495 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2496 }
2497 }
2498
2499 pub async fn add_sub_issue(
2501 &self,
2502 parent_node_id: &str,
2503 child_node_id: &str,
2504 ) -> anyhow::Result<()> {
2505 let query = format!(
2506 r#"mutation {{
2507 addSubIssue(input: {{issueId: "{parent_node_id}", subIssueId: "{child_node_id}"}}) {{
2508 issue {{ number }}
2509 subIssue {{ number }}
2510 }}
2511 }}"#
2512 );
2513 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2514 .await?;
2515 Ok(())
2516 }
2517
2518 pub async fn link_issue_to_branch(
2530 &self,
2531 repo: &str,
2532 issue_number: u64,
2533 branch: &str,
2534 ) -> anyhow::Result<String> {
2535 let parts: Vec<&str> = repo.split('/').collect();
2536 if parts.len() != 2 {
2537 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2538 }
2539 let (owner, repo_name) = (parts[0], parts[1]);
2540
2541 let query = r"query($owner: String!, $name: String!, $number: Int!) {
2543 repository(owner: $owner, name: $name) {
2544 id
2545 issue(number: $number) {
2546 id
2547 }
2548 }
2549 }";
2550 let vars = serde_json::json!({
2551 "owner": owner,
2552 "name": repo_name,
2553 "number": issue_number,
2554 });
2555
2556 let result = self.graphql_with_vars(query, vars).await?;
2557
2558 let repo_id = result
2559 .pointer("/data/repository/id")
2560 .and_then(|v| v.as_str())
2561 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2562
2563 let issue_id = result
2564 .pointer("/data/repository/issue/id")
2565 .and_then(|v| v.as_str())
2566 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2567
2568 let branch_query = r"query($owner: String!, $name: String!, $ref: String!) {
2570 repository(owner: $owner, name: $name) {
2571 ref(qualifiedName: $ref) {
2572 target {
2573 oid
2574 }
2575 }
2576 }
2577 }";
2578 let branch_vars = serde_json::json!({
2579 "owner": owner,
2580 "name": repo_name,
2581 "ref": format!("refs/heads/{}", branch),
2582 });
2583
2584 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2585 let branch_oid = branch_result
2586 .pointer("/data/repository/ref/target/oid")
2587 .and_then(|v| v.as_str());
2588
2589 let branch_oid = if let Some(oid) = branch_oid {
2592 oid.to_string()
2593 } else {
2594 let default_query = r"query($owner: String!, $name: String!) {
2596 repository(owner: $owner, name: $name) {
2597 defaultBranchRef {
2598 target {
2599 oid
2600 }
2601 }
2602 }
2603 }";
2604 let default_vars = serde_json::json!({
2605 "owner": owner,
2606 "name": repo_name,
2607 });
2608 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2609 match default_result
2610 .pointer("/data/repository/defaultBranchRef/target/oid")
2611 .and_then(|v| v.as_str())
2612 {
2613 Some(oid) => {
2614 tracing::debug!(
2615 repo,
2616 issue_number,
2617 branch,
2618 "branch not found, using default branch OID to create and link"
2619 );
2620 oid.to_string()
2621 }
2622 None => {
2623 return Ok(format!("unlinked:{branch}"));
2624 }
2625 }
2626 };
2627
2628 let mutation = r"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2631 createLinkedBranch(input: {
2632 issueId: $issueId
2633 repositoryId: $repositoryId
2634 name: $name
2635 oid: $oid
2636 }) {
2637 linkedBranch {
2638 id
2639 ref {
2640 name
2641 }
2642 }
2643 }
2644 }";
2645 let mutation_vars = serde_json::json!({
2646 "issueId": issue_id,
2647 "repositoryId": repo_id,
2648 "name": branch,
2649 "oid": branch_oid,
2650 });
2651
2652 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2653
2654 match link_result {
2655 Ok(result) => {
2656 let linked_branch_id = result
2657 .pointer("/data/createLinkedBranch/linkedBranch/id")
2658 .and_then(|v| v.as_str())
2659 .map(String::from);
2660
2661 if let Some(id) = linked_branch_id {
2662 tracing::info!(
2663 repo,
2664 issue_number,
2665 branch,
2666 "linked issue to branch via GraphQL API"
2667 );
2668 Ok(id)
2669 } else {
2670 let errors = result
2672 .get("errors")
2673 .and_then(|e| e.as_array())
2674 .cloned()
2675 .unwrap_or_default();
2676
2677 if errors.iter().any(|e| {
2678 e.get("message")
2679 .and_then(|m| m.as_str())
2680 .is_some_and(|m| m.contains("already"))
2681 }) {
2682 tracing::debug!(
2683 repo,
2684 issue_number,
2685 branch,
2686 "issue already linked to branch"
2687 );
2688 Ok(format!("already_linked:{branch}"))
2689 } else {
2690 anyhow::bail!("failed to link issue to branch: {result:?}")
2691 }
2692 }
2693 }
2694 Err(e) => {
2695 let err_str = format!("{e}");
2697 if err_str.contains("already") || err_str.contains("existing") {
2698 tracing::debug!(
2699 repo,
2700 issue_number,
2701 branch,
2702 "issue already linked to branch (error check)"
2703 );
2704 Ok(format!("already_linked:{branch}"))
2705 } else {
2706 Err(e)
2707 }
2708 }
2709 }
2710 }
2711
2712 pub async fn merge_pr(
2715 &self,
2716 repo: &str,
2717 pr_number: u64,
2718 delete_branch: bool,
2719 head_sha: &str,
2720 ) -> anyhow::Result<()> {
2721 if !is_full_commit_sha(head_sha) {
2722 anyhow::bail!("merge_pr requires the full head sha, got {head_sha:?}");
2723 }
2724 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2725 Self::check_backoff(&url)?;
2726 let payload = merge_pr_body(head_sha);
2727 let auth = self.auth_header(&url).await?;
2728 let make_req = || {
2729 Ok(self
2730 .client
2731 .put(&url)
2732 .json(&payload)
2733 .header(header::AUTHORIZATION, auth.clone())
2734 .header(header::ACCEPT, "application/vnd.github+json")
2735 .header("X-GitHub-Api-Version", "2022-11-28"))
2736 };
2737 let resp = self.send_with_retries(make_req, false).await?;
2738 let status = resp.status();
2739 if !status.is_success() {
2740 let body = resp.text().await.unwrap_or_default();
2741 anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2742 }
2743 Self::record_success();
2744
2745 if delete_branch {
2747 let pr: serde_json::Value = self
2749 .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2750 .await?;
2751 if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2752 let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2753 if let Err(e) = self.delete(&del_url).await {
2754 let err_str = e.to_string();
2755 if err_str.contains("Reference does not exist") {
2756 tracing::debug!(
2757 ref_name,
2758 "branch already gone (GitHub auto-deleted on merge)"
2759 );
2760 } else {
2761 tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2762 }
2763 }
2764 }
2765 }
2766 Ok(())
2767 }
2768
2769 pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2775 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2776 Self::check_backoff(&url)?;
2777 let auth = self.auth_header(&url).await?;
2778 let make_req = || {
2779 Ok(self
2780 .client
2781 .put(&url)
2782 .header(header::AUTHORIZATION, auth.clone())
2783 .header(header::ACCEPT, "application/vnd.github+json")
2784 .header("X-GitHub-Api-Version", "2022-11-28"))
2785 };
2786 let resp = self.send_with_retries(make_req, false).await?;
2787 let status = resp.status();
2788 if !status.is_success() {
2789 let body = resp.text().await.unwrap_or_default();
2790 Self::maybe_record_rate_limit_from_body(status, &body);
2791 anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2792 }
2793 Self::record_success();
2794 Ok(())
2795 }
2796
2797 pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2801 let parts: Vec<&str> = repo.split('/').collect();
2802 if parts.len() != 2 {
2803 anyhow::bail!("invalid repo format: {repo}");
2804 }
2805 let (owner, repo_name) = (parts[0], parts[1]);
2806
2807 let pr_query = r"query($owner: String!, $name: String!, $number: Int!) {
2809 repository(owner: $owner, name: $name) {
2810 pullRequest(number: $number) { id }
2811 }
2812 }";
2813 let body = self
2814 .graphql_with_vars(
2815 pr_query,
2816 serde_json::json!({
2817 "owner": owner,
2818 "name": repo_name,
2819 "number": pr_number,
2820 }),
2821 )
2822 .await?;
2823 let pr_id = body
2824 .pointer("/data/repository/pullRequest/id")
2825 .and_then(|v| v.as_str())
2826 .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2827 .to_string();
2828
2829 let mutation = r"mutation($prId: ID!) {
2831 enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2832 pullRequest { autoMergeRequest { enabledAt } }
2833 }
2834 }";
2835 let body = self
2836 .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2837 .await?;
2838 if let Some(errors) = body.get("errors") {
2839 anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2840 }
2841
2842 tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2843 Ok(())
2844 }
2845
2846 pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2848 let parts: Vec<&str> = repo.split('/').collect();
2849 if parts.len() != 2 {
2850 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2851 }
2852 let (owner, repo_name) = (parts[0], parts[1]);
2853 let number = match parse_positive_issue_number(number) {
2854 Ok(number) => number,
2855 Err(e) => {
2856 tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2857 return Ok(Vec::new());
2858 }
2859 };
2860
2861 let mut all_numbers: Vec<u64> = Vec::new();
2862 let mut cursor: Option<String> = None;
2863 let page_size = 100;
2864 let max_pages = 50;
2865 let mut page_count = 0;
2866
2867 loop {
2868 page_count += 1;
2869 if page_count > max_pages {
2870 tracing::warn!(
2871 repo,
2872 number,
2873 "get_sub_issues hit max page limit ({max_pages})"
2874 );
2875 break;
2876 }
2877
2878 let (query, vars) = if let Some(ref c) = cursor {
2879 (
2880 r"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2881 repository(owner: $owner, name: $name) {
2882 issue(number: $number) {
2883 subIssues(first: $first, after: $after) {
2884 nodes { number }
2885 pageInfo { hasNextPage endCursor }
2886 }
2887 }
2888 }
2889 }",
2890 serde_json::json!({
2891 "owner": owner,
2892 "name": repo_name,
2893 "number": number,
2894 "first": page_size,
2895 "after": c,
2896 }),
2897 )
2898 } else {
2899 (
2900 r"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2901 repository(owner: $owner, name: $name) {
2902 issue(number: $number) {
2903 subIssues(first: $first) {
2904 nodes { number }
2905 pageInfo { hasNextPage endCursor }
2906 }
2907 }
2908 }
2909 }",
2910 serde_json::json!({
2911 "owner": owner,
2912 "name": repo_name,
2913 "number": number,
2914 "first": page_size,
2915 }),
2916 )
2917 };
2918
2919 let result = self
2920 .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2921 .await?;
2922
2923 let sub_issues_data = result
2924 .get("data")
2925 .and_then(|d| d.get("repository"))
2926 .and_then(|r| r.get("issue"))
2927 .and_then(|i| i.get("subIssues"));
2928
2929 let nodes = sub_issues_data
2930 .and_then(|s| s.get("nodes"))
2931 .and_then(|n| n.as_array());
2932
2933 match nodes {
2934 Some(nodes) => {
2935 let numbers: Vec<u64> = nodes
2936 .iter()
2937 .filter_map(|n| n.get("number").and_then(serde_json::Value::as_u64))
2938 .collect();
2939 all_numbers.extend(numbers);
2940 }
2941 None => break,
2942 }
2943
2944 let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2945 let has_next = page_info
2946 .and_then(|p| p.get("hasNextPage"))
2947 .and_then(serde_json::Value::as_bool)
2948 .unwrap_or(false);
2949
2950 if !has_next {
2951 break;
2952 }
2953
2954 cursor = page_info
2955 .and_then(|p| p.get("endCursor"))
2956 .and_then(|c| c.as_str())
2957 .map(ToString::to_string);
2958 }
2959
2960 Ok(all_numbers)
2961 }
2962
2963 pub async fn get_combined_status(
2965 &self,
2966 repo: &str,
2967 git_ref: &str,
2968 has_workflows: bool,
2969 mergeable_state: Option<&str>,
2970 ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2971 let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2972 let resp: serde_json::Value = self.get_json(&url).await?;
2973
2974 let runs = resp
2975 .get("check_runs")
2976 .and_then(|v| v.as_array())
2977 .cloned()
2978 .unwrap_or_default();
2979
2980 let mut passing = 0u64;
2981 let mut failing = 0u64;
2982 let mut pending = 0u64;
2983
2984 for run in &runs {
2985 let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2986 let status = run
2987 .get("status")
2988 .and_then(|v| v.as_str())
2989 .unwrap_or("queued");
2990
2991 match status {
2992 "completed" => {
2993 if conclusion.is_empty() {
2994 pending += 1;
2995 } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2996 passing += 1;
2997 } else {
2998 failing += 1;
2999 }
3000 }
3001 _ => pending += 1,
3002 }
3003 }
3004
3005 let total = runs.len() as u64;
3006 let state =
3007 Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
3008
3009 Ok((state, total, passing, failing, pending))
3010 }
3011
3012 pub async fn rerun_failed_workflow(
3018 &self,
3019 repo: &str,
3020 workflow_name: &str,
3021 branch: &str,
3022 ) -> anyhow::Result<()> {
3023 let url = format!(
3025 "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
3026 );
3027 let resp: serde_json::Value = self.get_json(&url).await?;
3028 let runs = resp["workflow_runs"]
3029 .as_array()
3030 .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
3031
3032 let run_id = runs
3034 .iter()
3035 .find(|r| {
3036 r["name"].as_str() == Some(workflow_name)
3037 && matches!(r["conclusion"].as_str(), Some("failure" | "cancelled"))
3038 })
3039 .and_then(|r| r["id"].as_u64())
3040 .ok_or_else(|| {
3041 anyhow::anyhow!(
3042 "no failed run found for workflow '{workflow_name}' on branch '{branch}'"
3043 )
3044 })?;
3045
3046 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
3048 let auth = self.auth_header(&rerun_url).await?;
3049 let make_req = || {
3050 Ok(self
3051 .client
3052 .post(&rerun_url)
3053 .header(header::AUTHORIZATION, auth.clone())
3054 .header(header::ACCEPT, "application/vnd.github+json")
3055 .header("X-GitHub-Api-Version", "2022-11-28"))
3056 };
3057 let resp = self.send_with_retries(make_req, false).await?;
3058 let status = resp.status();
3059 if !status.is_success() {
3060 let body = resp.text().await.unwrap_or_default();
3061 Self::maybe_record_rate_limit_from_body(status, &body);
3062 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
3063 }
3064
3065 Ok(())
3066 }
3067
3068 pub async fn batch_fetch_pr_review_data(
3074 &self,
3075 repo: &str,
3076 pr_numbers: &[u64],
3077 ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
3078 if pr_numbers.is_empty() {
3079 return Ok(HashMap::new());
3080 }
3081
3082 let (owner, name) = repo
3083 .split_once('/')
3084 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
3085
3086 let aliases: String = pr_numbers
3088 .iter()
3089 .map(|&n| build_pr_review_alias(n))
3090 .collect::<Vec<_>>()
3091 .join("\n");
3092
3093 let query = format!(
3094 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
3095 );
3096
3097 let resp = self
3098 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
3099 .await?;
3100 let repo_data = resp
3101 .pointer("/data/repository")
3102 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
3103
3104 let mut result = HashMap::new();
3105 for &n in pr_numbers {
3106 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
3107 continue;
3108 };
3109
3110 let merged = pr_data
3111 .get("merged")
3112 .and_then(serde_json::Value::as_bool)
3113 .unwrap_or(false);
3114 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
3115 Some("MERGEABLE") => Some(true),
3116 Some("CONFLICTING") => Some(false),
3117 _ => None, };
3119
3120 let mut reviews: Vec<GitHubReview> = pr_data
3121 .pointer("/reviews/nodes")
3122 .and_then(|v| v.as_array())
3123 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
3124 .unwrap_or_default();
3125
3126 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
3127 tracing::warn!(
3128 pr_number = n,
3129 fetched = reviews.len(),
3130 "batch review fetch truncated reviews connection at first page"
3131 );
3132 }
3133
3134 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
3138
3139 let mut review_comments: Vec<GitHubReviewComment> = pr_data
3140 .pointer("/reviewThreads/nodes")
3141 .and_then(|v| v.as_array())
3142 .map(|threads| {
3143 threads
3144 .iter()
3145 .filter_map(|thread| {
3146 thread
3147 .pointer("/comments/nodes")
3148 .and_then(|v| v.as_array())
3149 .map(|nodes| {
3150 nodes
3151 .iter()
3152 .filter_map(parse_graphql_review_comment)
3153 .collect::<Vec<_>>()
3154 })
3155 })
3156 .flatten()
3157 .collect()
3158 })
3159 .unwrap_or_default();
3160
3161 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3162 tracing::warn!(
3163 pr_number = n,
3164 fetched_threads = pr_data
3165 .pointer("/reviewThreads/nodes")
3166 .and_then(|v| v.as_array())
3167 .map_or(0, Vec::len),
3168 "batch review fetch truncated reviewThreads connection at first page"
3169 );
3170 }
3171 if pr_data
3172 .pointer("/reviewThreads/nodes")
3173 .and_then(|v| v.as_array())
3174 .is_some_and(|threads| {
3175 threads.iter().any(|thread| {
3176 connection_has_next_page(thread.pointer("/comments/pageInfo"))
3177 })
3178 })
3179 {
3180 tracing::warn!(
3181 pr_number = n,
3182 "batch review fetch truncated one or more review thread comment connections"
3183 );
3184 }
3185
3186 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3189
3190 let mut issue_comments: Vec<GitHubComment> = pr_data
3191 .pointer("/comments/nodes")
3192 .and_then(|v| v.as_array())
3193 .map(|nodes| {
3194 nodes
3195 .iter()
3196 .filter_map(parse_graphql_issue_comment)
3197 .collect()
3198 })
3199 .unwrap_or_default();
3200
3201 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3202 issue_comments.extend(
3203 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3204 .await?,
3205 );
3206 }
3207
3208 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3213
3214 result.insert(
3215 n,
3216 PrReviewBatchData {
3217 merged,
3218 head_sha: pr_data
3219 .get("headRefOid")
3220 .and_then(|v| v.as_str())
3221 .unwrap_or_default()
3222 .to_string(),
3223 mergeable,
3224 reviews,
3225 review_comments,
3226 issue_comments,
3227 },
3228 );
3229 }
3230
3231 Ok(result)
3232 }
3233
3234 async fn fetch_pr_issue_comments_after(
3235 &self,
3236 owner: &str,
3237 name: &str,
3238 pr_number: u64,
3239 mut cursor: Option<String>,
3240 ) -> anyhow::Result<Vec<GitHubComment>> {
3241 let mut comments = Vec::new();
3242 let mut page_count = 0;
3243 let max_pages = 50;
3244
3245 while let Some(current_cursor) = cursor.take() {
3246 page_count += 1;
3247 if page_count > max_pages {
3248 tracing::warn!(
3249 pr_number,
3250 fetched = comments.len(),
3251 "stopping PR issue comment pagination after max page limit"
3252 );
3253 break;
3254 }
3255
3256 let (query, vars) =
3257 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
3258 let resp = self.graphql_with_vars(&query, vars).await?;
3259 let comments_data = resp
3260 .pointer("/data/repository/pullRequest/comments")
3261 .ok_or_else(|| {
3262 anyhow::anyhow!(
3263 "missing /data/repository/pullRequest/comments in GraphQL response"
3264 )
3265 })?;
3266
3267 comments.extend(
3268 comments_data
3269 .get("nodes")
3270 .and_then(|v| v.as_array())
3271 .into_iter()
3272 .flatten()
3273 .filter_map(parse_graphql_issue_comment),
3274 );
3275
3276 cursor = connection_end_cursor(comments_data.get("pageInfo"));
3277 }
3278
3279 Ok(comments)
3280 }
3281
3282 pub async fn get_automated_review_status(
3284 &self,
3285 repo: &str,
3286 pr_number: u64,
3287 ) -> anyhow::Result<Option<String>> {
3288 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3289
3290 let app_login = super::app::bot_login().await;
3292 let mut newest: Option<&types::GitHubComment> = None;
3293 for c in &comments {
3294 if !c.body.starts_with("## Automated Review") {
3295 continue;
3296 }
3297 let trusted = match app_login.as_deref() {
3298 Some(app) if super::app::same_login(&c.user.login, app) => Ok(true),
3299 _ => self.is_collaborator(repo, &c.user.login).await,
3300 };
3301 match trusted {
3302 Ok(true) => match newest {
3303 None => newest = Some(c),
3304 Some(prev) => {
3305 if c.created_at > prev.created_at {
3306 newest = Some(c);
3307 }
3308 }
3309 },
3310 Ok(false) => {
3311 tracing::warn!(
3312 user = %c.user.login,
3313 pr_number,
3314 "ignoring automated review comment from non-collaborator"
3315 );
3316 }
3317 Err(e) => {
3318 tracing::debug!(
3319 user = %c.user.login,
3320 error = %e,
3321 "failed to check collaborator status, skipping comment"
3322 );
3323 }
3324 }
3325 }
3326
3327 match newest {
3328 Some(c) => {
3329 let first_line = c.body.lines().next().unwrap_or("");
3330 if first_line.contains("Automated Review \u{2014} Approve") {
3331 Ok(Some("approve".to_string()))
3332 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3333 Ok(Some("changes_requested".to_string()))
3334 } else {
3335 Ok(None)
3336 }
3337 }
3338 None => Ok(None),
3339 }
3340 }
3341}
3342
3343#[derive(Debug, Default, PartialEq)]
3349pub struct CommitMeta {
3350 pub parents: Vec<String>,
3351 pub author_login: Option<String>,
3352 pub committer_login: Option<String>,
3353}
3354
3355fn parse_commit_meta(commit: &serde_json::Value) -> CommitMeta {
3356 let login = |field: &str| {
3357 commit
3358 .pointer(&format!("/{field}/login"))
3359 .and_then(|v| v.as_str())
3360 .map(String::from)
3361 };
3362 CommitMeta {
3363 parents: commit
3364 .get("parents")
3365 .and_then(|p| p.as_array())
3366 .map(|parents| {
3367 parents
3368 .iter()
3369 .filter_map(|p| p.get("sha").and_then(|s| s.as_str()).map(String::from))
3370 .collect()
3371 })
3372 .unwrap_or_default(),
3373 author_login: login("author"),
3374 committer_login: login("committer"),
3375 }
3376}
3377
3378fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3379 body.get("behind_by")
3380 .and_then(serde_json::Value::as_u64)
3381 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3382}
3383
3384fn is_no_branch_protection_error(err: &str) -> bool {
3387 err.contains("404")
3388 || (err.contains("403")
3389 && err.contains("Upgrade to GitHub Pro or make this repository public"))
3390}
3391
3392fn parse_classic_required_status_checks(value: &serde_json::Value) -> Vec<RequiredStatusCheck> {
3394 if let Some(checks) = value
3395 .pointer("/required_status_checks/checks")
3396 .and_then(|v| v.as_array())
3397 {
3398 let parsed: Vec<RequiredStatusCheck> =
3399 checks.iter().filter_map(parse_classic_check).collect();
3400 if !parsed.is_empty() {
3401 return parsed;
3402 }
3403 }
3404
3405 value
3406 .pointer("/required_status_checks/contexts")
3407 .and_then(|v| v.as_array())
3408 .map(|contexts| {
3409 contexts
3410 .iter()
3411 .filter_map(|context| {
3412 context.as_str().map(|s| RequiredStatusCheck {
3413 context: s.to_string(),
3414 integration_id: None,
3415 })
3416 })
3417 .collect()
3418 })
3419 .unwrap_or_default()
3420}
3421
3422fn parse_classic_check(check: &serde_json::Value) -> Option<RequiredStatusCheck> {
3423 let context = check.get("context")?.as_str()?.to_string();
3424 let integration_id = check
3425 .get("app_id")
3426 .and_then(serde_json::Value::as_i64)
3427 .filter(|&id| id > 0);
3428 Some(RequiredStatusCheck {
3429 context,
3430 integration_id,
3431 })
3432}
3433
3434fn parse_ruleset_required_status_checks(rules: &[serde_json::Value]) -> Vec<RequiredStatusCheck> {
3436 let mut out = Vec::new();
3437 for rule in rules {
3438 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3439 continue;
3440 }
3441 let Some(checks) = rule
3442 .pointer("/parameters/required_status_checks")
3443 .or_else(|| rule.get("required_status_checks"))
3444 .and_then(|v| v.as_array())
3445 else {
3446 continue;
3447 };
3448 for check in checks {
3449 let Some(context) = check.get("context").and_then(|v| v.as_str()) else {
3450 continue;
3451 };
3452 let integration_id = check
3453 .get("integration_id")
3454 .and_then(serde_json::Value::as_i64)
3455 .filter(|&id| id > 0);
3456 out.push(RequiredStatusCheck {
3457 context: context.to_string(),
3458 integration_id,
3459 });
3460 }
3461 }
3462 out
3463}
3464
3465fn merge_required_status_checks(
3466 mut checks: Vec<RequiredStatusCheck>,
3467 extra: Vec<RequiredStatusCheck>,
3468) -> Vec<RequiredStatusCheck> {
3469 checks.extend(extra);
3470 let mut seen = HashSet::new();
3471 checks.retain(|c| seen.insert((c.context.clone(), c.integration_id)));
3472 checks
3473}
3474
3475fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3476 let mut valid = Vec::with_capacity(issue_numbers.len());
3477 let mut invalid = Vec::new();
3478 for &issue_number in issue_numbers {
3479 if issue_number == 0 {
3480 invalid.push(issue_number);
3481 } else {
3482 valid.push(issue_number);
3483 }
3484 }
3485 (valid, invalid)
3486}
3487
3488#[derive(Debug, Clone)]
3492pub struct RateLimitMetrics {
3493 pub hits: u64,
3495 pub skipped_calls: u64,
3497 pub rest_remaining: Option<u32>,
3499 pub rest_reset_at: Option<u64>,
3501 pub graphql_remaining: Option<u32>,
3503 pub five_xx_hits: u64,
3505}
3506
3507pub fn rate_limit_metrics() -> RateLimitMetrics {
3509 let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
3510 .lock()
3511 .ok()
3512 .map_or((None, None), |rl| (rl.remaining, rl.reset_at));
3513 let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3514 RateLimitMetrics {
3515 hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3516 skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3517 rest_remaining,
3518 rest_reset_at,
3519 graphql_remaining,
3520 five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3521 }
3522}
3523
3524fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3528 let link = headers.get("link")?.to_str().ok()?;
3529 for part in link.split(',') {
3530 let part = part.trim();
3531 if part.contains("rel=\"next\"") {
3532 let start = part.find('<')? + 1;
3534 let end = part.find('>')?;
3535 if start >= end {
3536 return None;
3537 }
3538 return Some(part[start..end].to_string());
3539 }
3540 }
3541 None
3542}
3543
3544pub fn status_label_color(label: &str) -> &'static str {
3546 match label {
3547 "status:new" => "0e8a16",
3548 "status:routed" => "1d76db",
3549 "status:in_progress" => "fbca04",
3550 "status:done" => "6f42c1",
3551 "status:blocked" => "d73a4a",
3552 "status:in_review" => "0075ca",
3553 "status:approved" => "2ea44f",
3554 "status:needs_review" => "e4e669",
3555 "status:waiting" => "5319e7",
3556 _ => "c5def5",
3557 }
3558}
3559
3560#[derive(Debug, Clone, Default)]
3564pub struct PrReviewBatchData {
3565 pub merged: bool,
3567 pub head_sha: String,
3569 pub mergeable: Option<bool>,
3571 pub reviews: Vec<GitHubReview>,
3573 pub review_comments: Vec<GitHubReviewComment>,
3575 pub issue_comments: Vec<GitHubComment>,
3577}
3578
3579fn build_pr_review_alias(n: u64) -> String {
3582 format!(
3583 r"pr{n}: pullRequest(number: {n}) {{
3584 merged
3585 mergeable
3586 headRefOid
3587 reviews(first: 50) {{
3588 nodes {{
3589 databaseId
3590 author {{ login }}
3591 body
3592 state
3593 url
3594 submittedAt
3595 commit {{ oid }}
3596 }}
3597 pageInfo {{ hasNextPage }}
3598 }}
3599 reviewThreads(first: 50) {{
3600 nodes {{
3601 comments(first: 20) {{
3602 nodes {{
3603 databaseId
3604 author {{ login }}
3605 body
3606 path
3607 line
3608 originalLine
3609 commit {{ oid }}
3610 originalCommit {{ oid }}
3611 url
3612 createdAt
3613 updatedAt
3614 replyTo {{ databaseId }}
3615 diffHunk
3616 }}
3617 pageInfo {{ hasNextPage }}
3618 }}
3619 }}
3620 pageInfo {{ hasNextPage }}
3621 }}
3622 comments(first: 50) {{
3623 nodes {{
3624 databaseId
3625 author {{ login }}
3626 body
3627 createdAt
3628 updatedAt
3629 url
3630 }}
3631 pageInfo {{ hasNextPage endCursor }}
3632 }}
3633}}"
3634 )
3635}
3636
3637fn build_pr_issue_comments_page_query(
3638 owner: &str,
3639 name: &str,
3640 pr_number: u64,
3641 after_cursor: &str,
3642) -> (String, serde_json::Value) {
3643 let query = r"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3644 repository(owner: $owner, name: $name) {
3645 pullRequest(number: $number) {
3646 comments(first: 100, after: $after) {
3647 nodes {
3648 databaseId
3649 author { login }
3650 body
3651 createdAt
3652 updatedAt
3653 url
3654 }
3655 pageInfo { hasNextPage endCursor }
3656 }
3657 }
3658 }
3659}"
3660 .to_string();
3661 let vars = serde_json::json!({
3662 "owner": owner,
3663 "name": name,
3664 "number": pr_number,
3665 "after": after_cursor,
3666 });
3667 (query, vars)
3668}
3669
3670fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3671 page_info
3672 .and_then(|v| v.get("hasNextPage"))
3673 .and_then(serde_json::Value::as_bool)
3674 .unwrap_or(false)
3675}
3676
3677fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3678 if !connection_has_next_page(page_info) {
3679 return None;
3680 }
3681
3682 page_info
3683 .and_then(|v| v.get("endCursor"))
3684 .and_then(|v| v.as_str())
3685 .map(str::to_owned)
3686}
3687
3688fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3689 Some(GitHubReview {
3690 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3691 user: GitHubUser {
3692 login: node
3693 .pointer("/author/login")
3694 .and_then(|v| v.as_str())?
3695 .to_string(),
3696 },
3697 body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3698 state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3699 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3700 submitted_at: node
3701 .get("submittedAt")
3702 .and_then(|v| v.as_str())
3703 .unwrap_or("")
3704 .to_string(),
3705 commit_id: node
3706 .pointer("/commit/oid")
3707 .and_then(|v| v.as_str())
3708 .map(String::from),
3709 })
3710}
3711
3712fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3713 Some(GitHubReviewComment {
3714 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3715 user: GitHubUser {
3716 login: node
3717 .pointer("/author/login")
3718 .and_then(|v| v.as_str())?
3719 .to_string(),
3720 },
3721 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3722 path: node
3723 .get("path")
3724 .and_then(|v| v.as_str())
3725 .unwrap_or("")
3726 .to_string(),
3727 line: node
3728 .get("line")
3729 .and_then(serde_json::Value::as_u64)
3730 .and_then(|v| u32::try_from(v).ok()),
3731 original_line: node
3732 .get("originalLine")
3733 .and_then(serde_json::Value::as_u64)
3734 .and_then(|v| u32::try_from(v).ok()),
3735 commit_id: node
3736 .pointer("/commit/oid")
3737 .and_then(|v| v.as_str())
3738 .unwrap_or("")
3739 .to_string(),
3740 original_commit_id: node
3741 .pointer("/originalCommit/oid")
3742 .and_then(|v| v.as_str())
3743 .unwrap_or("")
3744 .to_string(),
3745 html_url: node
3746 .get("url")
3747 .and_then(|v| v.as_str())
3748 .unwrap_or("")
3749 .to_string(),
3750 created_at: node
3751 .get("createdAt")
3752 .and_then(|v| v.as_str())
3753 .unwrap_or("")
3754 .to_string(),
3755 updated_at: node
3756 .get("updatedAt")
3757 .and_then(|v| v.as_str())
3758 .unwrap_or("")
3759 .to_string(),
3760 in_reply_to_id: node
3761 .pointer("/replyTo/databaseId")
3762 .and_then(serde_json::Value::as_u64),
3763 diff_hunk: node
3764 .get("diffHunk")
3765 .and_then(|v| v.as_str())
3766 .map(String::from),
3767 })
3768}
3769
3770fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3771 Some(GitHubComment {
3772 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3773 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3774 user: GitHubUser {
3775 login: node
3776 .pointer("/author/login")
3777 .and_then(|v| v.as_str())?
3778 .to_string(),
3779 },
3780 created_at: node
3781 .get("createdAt")
3782 .and_then(|v| v.as_str())
3783 .unwrap_or("")
3784 .to_string(),
3785 updated_at: node
3786 .get("updatedAt")
3787 .and_then(|v| v.as_str())
3788 .map(String::from),
3789 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3790 issue_url: None,
3791 author_association: None,
3792 })
3793}
3794
3795#[cfg(test)]
3796mod tests {
3797 mod leak_redaction;
3799
3800 use std::env;
3801
3802 use super::*;
3803 use crate::github::token::TokenResolver;
3804 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3805 use tokio::net::TcpListener;
3806
3807 #[test]
3808 fn repo_from_url_names_the_repository_or_nothing() {
3809 let api = "https://api.github.com";
3810 assert_eq!(
3811 repo_from_url(&format!("{api}/repos/me/a/pulls/9")).as_deref(),
3812 Some("me/a")
3813 );
3814 assert_eq!(
3815 repo_from_url(&format!("{api}/repos/org/b?per_page=1")).as_deref(),
3816 Some("org/b")
3817 );
3818 assert_eq!(
3819 repo_from_url(&format!("{api}/repos/org/b")).as_deref(),
3820 Some("org/b")
3821 );
3822 assert_eq!(repo_from_url(&format!("{api}/graphql")), None);
3823 assert_eq!(repo_from_url(&format!("{api}/user")), None);
3824 assert_eq!(repo_from_url(&format!("{api}/repos/only-owner")), None);
3825 }
3826
3827 #[test]
3828 fn no_branch_protection_errors() {
3829 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.\"}";
3830 assert!(is_no_branch_protection_error(free_plan));
3831 assert!(is_no_branch_protection_error(
3832 "failed (404 Not Found): {\"message\":\"Branch not protected\"}"
3833 ));
3834 assert!(!is_no_branch_protection_error(
3835 "failed (403 Forbidden): {\"message\":\"Resource not accessible\"}"
3836 ));
3837 }
3838
3839 #[test]
3840 fn parse_classic_required_status_checks_reads_checks_app_id() {
3841 let value = serde_json::json!({
3842 "required_status_checks": {
3843 "contexts": ["ci/test"],
3844 "checks": [{ "context": "ci/test", "app_id": 42 }]
3845 }
3846 });
3847 assert_eq!(
3848 parse_classic_required_status_checks(&value),
3849 vec![RequiredStatusCheck {
3850 context: "ci/test".to_string(),
3851 integration_id: Some(42),
3852 }]
3853 );
3854 }
3855
3856 #[test]
3857 fn parse_classic_required_status_checks_falls_back_to_contexts() {
3858 let value = serde_json::json!({
3859 "required_status_checks": {
3860 "contexts": ["ci/test", "lint"]
3861 }
3862 });
3863 assert_eq!(
3864 parse_classic_required_status_checks(&value),
3865 vec![
3866 RequiredStatusCheck {
3867 context: "ci/test".to_string(),
3868 integration_id: None,
3869 },
3870 RequiredStatusCheck {
3871 context: "lint".to_string(),
3872 integration_id: None,
3873 }
3874 ]
3875 );
3876 }
3877
3878 #[test]
3879 fn parse_classic_required_status_checks_empty_when_unprotected() {
3880 let checks = parse_classic_required_status_checks(&serde_json::json!({}));
3881 assert!(checks.is_empty(), "{checks:?}");
3882 }
3883
3884 #[test]
3885 fn parse_ruleset_required_status_checks_reads_integration_id() {
3886 let rules = vec![
3887 serde_json::json!({ "type": "pull_request" }),
3888 serde_json::json!({
3889 "type": "required_status_checks",
3890 "parameters": {
3891 "required_status_checks": [
3892 { "context": "ci/test", "integration_id": 42 },
3893 { "context": "lint" }
3894 ]
3895 }
3896 }),
3897 ];
3898 assert_eq!(
3899 parse_ruleset_required_status_checks(&rules),
3900 vec![
3901 RequiredStatusCheck {
3902 context: "ci/test".to_string(),
3903 integration_id: Some(42),
3904 },
3905 RequiredStatusCheck {
3906 context: "lint".to_string(),
3907 integration_id: None,
3908 }
3909 ]
3910 );
3911 }
3912
3913 #[test]
3914 fn parse_ruleset_required_status_checks_treats_zero_integration_id_as_any_app() {
3915 let rules = vec![serde_json::json!({
3916 "type": "required_status_checks",
3917 "parameters": {
3918 "required_status_checks": [{ "context": "ci/test", "integration_id": 0 }]
3919 }
3920 })];
3921 assert_eq!(
3922 parse_ruleset_required_status_checks(&rules),
3923 vec![RequiredStatusCheck {
3924 context: "ci/test".to_string(),
3925 integration_id: None,
3926 }]
3927 );
3928 }
3929
3930 #[test]
3931 fn merge_required_status_checks_dedupes_identical_pairs() {
3932 let classic = vec![RequiredStatusCheck {
3933 context: "ci/test".to_string(),
3934 integration_id: None,
3935 }];
3936 let ruleset = vec![
3937 RequiredStatusCheck {
3938 context: "ci/test".to_string(),
3939 integration_id: None,
3940 },
3941 RequiredStatusCheck {
3942 context: "ci/test".to_string(),
3943 integration_id: Some(42),
3944 },
3945 ];
3946 assert_eq!(
3947 merge_required_status_checks(classic, ruleset),
3948 vec![
3949 RequiredStatusCheck {
3950 context: "ci/test".to_string(),
3951 integration_id: None,
3952 },
3953 RequiredStatusCheck {
3954 context: "ci/test".to_string(),
3955 integration_id: Some(42),
3956 }
3957 ]
3958 );
3959 }
3960
3961 #[test]
3962 fn parse_commit_meta_reads_parents_in_order_and_logins() {
3963 let merge = serde_json::json!({
3964 "parents": [{"sha": "approved"}, {"sha": "base"}],
3965 "author": {"login": "owner"},
3966 "committer": {"login": "web-flow"},
3967 });
3968 let meta = parse_commit_meta(&merge);
3969 assert_eq!(meta.parents, vec!["approved", "base"]);
3970 assert_eq!(meta.author_login.as_deref(), Some("owner"));
3971 assert_eq!(meta.committer_login.as_deref(), Some("web-flow"));
3972 assert_eq!(
3973 parse_commit_meta(&serde_json::json!({})),
3974 CommitMeta::default()
3975 );
3976 }
3977
3978 #[test]
3979 fn parse_behind_by_reads_compare_status() {
3980 let behind = serde_json::json!({"status": "behind", "behind_by": 3, "ahead_by": 0});
3982 assert_eq!(parse_behind_by(&behind).unwrap(), 3);
3983 let diverged = serde_json::json!({"status": "diverged", "behind_by": 1, "ahead_by": 2});
3984 assert_eq!(parse_behind_by(&diverged).unwrap(), 1);
3985 let ahead = serde_json::json!({"status": "ahead", "behind_by": 0, "ahead_by": 2});
3986 assert_eq!(parse_behind_by(&ahead).unwrap(), 0);
3987 assert!(parse_behind_by(&serde_json::json!({"status": "ahead"})).is_err());
3989 }
3990
3991 async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3992 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3993 let addr = listener.local_addr().unwrap();
3994
3995 tokio::spawn(async move {
3996 let (mut socket, _) = listener.accept().await.unwrap();
3997 let mut buf = [0u8; 4096];
3998 let _ = socket.read(&mut buf).await;
3999
4000 let declared_len = body_prefix.len() + 32;
4001 let response = format!(
4002 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
4003 );
4004 socket.write_all(response.as_bytes()).await.unwrap();
4005 socket.shutdown().await.unwrap();
4006 });
4007
4008 format!("http://{addr}")
4009 }
4010
4011 #[test]
4012 fn merge_pr_body_pins_the_head_sha() {
4013 let sha = "0123456789abcdef0123456789abcdef01234567";
4014 let body = merge_pr_body(sha);
4015 assert_eq!(body["merge_method"], "squash");
4016 assert_eq!(body["sha"], sha);
4017 assert!(is_full_commit_sha(sha));
4018 assert!(!is_full_commit_sha("abc"));
4019 assert!(!is_full_commit_sha(""));
4020 }
4021
4022 fn test_client() -> GhHttp {
4023 GhHttp {
4024 client: reqwest::Client::new(),
4025 token_resolver: Arc::new(TokenResolver::default_env()),
4026 app: None,
4027 owner_only: false,
4028 cached_username: OnceLock::new(),
4029 api_base: None,
4030 }
4031 }
4032
4033 #[test]
4038 fn gh_http_new_returns_ok() {
4039 let result = GhHttp::new();
4040 assert!(
4041 result.is_ok(),
4042 "GhHttp::new() must not panic and must return Ok; got: {:?}",
4043 result.err()
4044 );
4045 }
4046
4047 #[test]
4048 fn batch_get_issue_states_query_includes_labels_pagination() {
4049 let issue_numbers = [1u64, 2, 3];
4050
4051 let aliases: String = issue_numbers
4052 .iter()
4053 .map(|n| {
4054 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
4055 })
4056 .collect::<Vec<_>>()
4057 .join(" ");
4058
4059 let query = format!(
4060 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
4061 );
4062 assert!(query.contains("$owner"));
4063 assert!(query.contains("$name"));
4064 assert!(query.contains("issue1: issue(number: 1)"));
4065 assert!(query.contains("issue3: issue(number: 3)"));
4066 assert!(query.contains("labels(first: 100)"));
4067 }
4068
4069 #[test]
4070 fn parse_positive_issue_number_accepts_positive_values() {
4071 assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
4072 }
4073
4074 #[test]
4075 fn parse_positive_issue_number_rejects_zero() {
4076 let err = parse_positive_issue_number("0").unwrap_err().to_string();
4077 assert!(err.contains("invalid issue number: 0"));
4078 }
4079
4080 #[test]
4081 fn parse_positive_issue_number_rejects_internal_ids() {
4082 let err = parse_positive_issue_number("internal:5")
4083 .unwrap_err()
4084 .to_string();
4085 assert!(err.contains("invalid issue number: internal:5"));
4086 }
4087
4088 #[test]
4089 fn pr_review_batch_query_includes_page_info_for_nested_connections() {
4090 let query = build_pr_review_alias(42);
4091
4092 assert!(query.contains("reviews(first: 50)"));
4093 assert!(query.contains("reviews(first: 50) {\n nodes"));
4094 assert!(query.contains("pageInfo { hasNextPage }"));
4095 assert!(query.contains("reviewThreads(first: 50)"));
4096 assert!(query.contains("comments(first: 20)"));
4097 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4098 assert!(query.contains("updatedAt"));
4099 }
4100
4101 #[test]
4102 fn pr_issue_comments_page_query_uses_cursor_pagination() {
4103 let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
4104
4105 assert!(query.contains("pullRequest(number: $number)"));
4106 assert!(query.contains("comments(first: 100, after: $after)"));
4107 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4108 assert!(query.contains("updatedAt"));
4109 assert_eq!(vars["owner"], "owner");
4110 assert_eq!(vars["name"], "repo");
4111 assert_eq!(vars["number"], 42);
4112 assert_eq!(vars["after"], "cursor123");
4113 }
4114
4115 #[test]
4116 fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
4117 let page_info = serde_json::json!({
4118 "hasNextPage": true,
4119 "endCursor": "cursor123"
4120 });
4121 assert_eq!(
4122 connection_end_cursor(Some(&page_info)),
4123 Some("cursor123".to_string())
4124 );
4125
4126 let final_page = serde_json::json!({
4127 "hasNextPage": false,
4128 "endCursor": "cursor456"
4129 });
4130 assert_eq!(connection_end_cursor(Some(&final_page)), None);
4131 }
4132
4133 #[test]
4134 fn status_label_colors_match_bash_palette() {
4135 assert_eq!(status_label_color("status:new"), "0e8a16");
4136 assert_eq!(status_label_color("status:routed"), "1d76db");
4137 assert_eq!(status_label_color("status:in_progress"), "fbca04");
4138 assert_eq!(status_label_color("status:done"), "6f42c1");
4139 assert_eq!(status_label_color("status:blocked"), "d73a4a");
4140 assert_eq!(status_label_color("status:in_review"), "0075ca");
4141 assert_eq!(status_label_color("status:needs_review"), "e4e669");
4142 }
4143
4144 #[test]
4145 fn unknown_label_gets_default_color() {
4146 assert_eq!(status_label_color("agent:claude"), "c5def5");
4147 assert_eq!(status_label_color("enhancement"), "c5def5");
4148 assert_eq!(status_label_color(""), "c5def5");
4149 }
4150
4151 #[test]
4152 fn parse_link_next_finds_next_url() {
4153 let mut headers = header::HeaderMap::new();
4154 headers.insert(
4155 "link",
4156 "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
4157 .parse()
4158 .unwrap(),
4159 );
4160 assert_eq!(
4161 parse_link_next(&headers),
4162 Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
4163 );
4164 }
4165
4166 #[test]
4167 fn parse_link_next_none_when_no_next() {
4168 let mut headers = header::HeaderMap::new();
4169 headers.insert(
4170 "link",
4171 "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
4172 .parse()
4173 .unwrap(),
4174 );
4175 assert_eq!(parse_link_next(&headers), None);
4176 }
4177
4178 #[test]
4179 fn parse_link_next_none_when_missing() {
4180 let headers = header::HeaderMap::new();
4181 assert_eq!(parse_link_next(&headers), None);
4182 }
4183
4184 #[test]
4185 fn parse_link_next_none_when_malformed() {
4186 let mut headers = header::HeaderMap::new();
4187 headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
4189 assert_eq!(parse_link_next(&headers), None);
4190 }
4191
4192 #[test]
4193 fn parse_link_next_none_when_gt_before_lt() {
4194 let mut headers = header::HeaderMap::new();
4195 headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
4196 assert_eq!(parse_link_next(&headers), None); }
4198
4199 #[test]
4200 fn parse_link_next_none_when_empty_url() {
4201 let mut headers = header::HeaderMap::new();
4202 headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
4203 assert_eq!(parse_link_next(&headers), None);
4204 }
4205
4206 fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
4207 RateLimit {
4208 remaining: None,
4209 reset_at: None,
4210 backoff_until: None,
4211 backoff_delay: Duration::ZERO,
4212 backoff_base: Duration::from_secs(base_secs),
4213 backoff_max: Duration::from_secs(max_secs),
4214 throttle_threshold: 10,
4215 }
4216 }
4217
4218 #[test]
4219 fn rate_limit_inactive_by_default() {
4220 let rl = make_rl(30, 900);
4221 assert!(rl.is_active().is_none());
4222 }
4223
4224 #[test]
4225 fn rate_limit_backoff_activates() {
4226 let mut rl = make_rl(5, 60);
4227 rl.record_rate_limit();
4228 assert!(rl.is_active().is_some());
4229 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4230 }
4231
4232 #[test]
4233 fn rate_limit_backoff_doubles() {
4234 let mut rl = make_rl(5, 60);
4235 rl.record_rate_limit();
4236 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4237 rl.record_rate_limit();
4238 assert_eq!(rl.backoff_delay, Duration::from_secs(10));
4239 rl.record_rate_limit();
4240 assert_eq!(rl.backoff_delay, Duration::from_secs(20));
4241 }
4242
4243 #[test]
4244 fn rate_limit_backoff_caps_at_max() {
4245 let mut rl = make_rl(30, 60);
4246 rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
4250 }
4251
4252 #[test]
4253 fn rate_limit_success_resets() {
4254 let mut rl = make_rl(5, 60);
4255 rl.record_rate_limit();
4256 assert!(rl.is_active().is_some());
4257 rl.record_success();
4258 assert!(rl.is_active().is_none());
4259 assert_eq!(rl.backoff_delay, Duration::ZERO);
4260 }
4261
4262 #[test]
4263 fn page_cache_key_separates_types_and_queries() {
4264 let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
4265 let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
4266 let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
4267 assert_ne!(a, b);
4268 assert_ne!(a, c);
4269 assert_eq!(
4270 a,
4271 page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
4272 );
4273 }
4274
4275 #[test]
4277 fn page_cache_key_skips_since_filtered_requests() {
4278 assert!(
4279 page_cache_key::<GitHubIssue>(
4280 "/issues",
4281 Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
4282 )
4283 .is_none()
4284 );
4285 assert!(
4287 page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
4288 .is_none()
4289 );
4290 }
4291
4292 #[test]
4295 fn low_quota_fails_fast_instead_of_waiting_for_reset() {
4296 let mut rl = make_rl(30, 900);
4297 rl.remaining = Some(0);
4298 rl.reset_at = Some(
4299 SystemTime::now()
4300 .duration_since(std_time::UNIX_EPOCH)
4301 .unwrap()
4302 .as_secs()
4303 + 3600,
4304 );
4305 let cell = Mutex::new(rl);
4306
4307 let start = Instant::now();
4308 let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
4309 assert!(
4310 start.elapsed() < Duration::from_secs(1),
4311 "check blocked for {:?}",
4312 start.elapsed()
4313 );
4314 assert!(err.to_string().contains("rate-limited"), "{err}");
4315 }
4316
4317 #[test]
4319 fn healthy_quota_passes_the_check() {
4320 let mut rl = make_rl(30, 900);
4321 rl.remaining = Some(4000);
4322 rl.reset_at = Some(u64::MAX / 2);
4323 let cell = Mutex::new(rl);
4324 assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
4325 }
4326
4327 #[test]
4328 fn rate_limit_proactive_pause() {
4329 let mut rl = make_rl(30, 900);
4330 rl.remaining = Some(0);
4332 let future_epoch = SystemTime::now()
4333 .duration_since(std_time::UNIX_EPOCH)
4334 .unwrap()
4335 .as_secs()
4336 + 60;
4337 rl.reset_at = Some(future_epoch);
4338 let pause = rl.is_active();
4339 assert!(pause.is_some());
4340 assert!(pause.unwrap().as_secs() <= 62);
4342 }
4343
4344 #[test]
4345 fn rate_limit_proactive_pause_threshold() {
4346 let mut rl = make_rl(30, 900);
4347 rl.remaining = Some(15);
4349 let future_epoch = SystemTime::now()
4350 .duration_since(std_time::UNIX_EPOCH)
4351 .unwrap()
4352 .as_secs()
4353 + 60;
4354 rl.reset_at = Some(future_epoch);
4355 assert!(rl.is_active().is_none());
4356
4357 rl.remaining = Some(5);
4359 assert!(rl.is_active().is_some());
4360 }
4361
4362 #[test]
4363 fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
4364 assert_eq!(
4366 GhHttp::combined_status_state(0, 0, 0, true, None),
4367 "pending"
4368 );
4369 assert_eq!(
4371 GhHttp::combined_status_state(0, 0, 0, false, None),
4372 "success"
4373 );
4374 assert_eq!(
4376 GhHttp::combined_status_state(3, 1, 0, true, None),
4377 "failure"
4378 );
4379 assert_eq!(
4381 GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
4382 "success"
4383 );
4384 assert_eq!(
4386 GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
4387 "pending"
4388 );
4389 }
4390
4391 #[test]
4392 fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
4393 let resp = serde_json::json!({
4394 "data": {},
4395 "errors": [{ "message": "Something went wrong" }],
4396 });
4397
4398 let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
4399 .unwrap_err()
4400 .to_string();
4401 assert!(err_text.contains("missing /data/repository in GraphQL response"));
4402 assert!(err_text.contains("Something went wrong"));
4403 }
4404
4405 #[test]
4406 fn rate_limit_uses_reset_at_for_backoff() {
4407 let mut rl = make_rl(30, 900);
4408 let future_epoch = SystemTime::now()
4410 .duration_since(std_time::UNIX_EPOCH)
4411 .unwrap()
4412 .as_secs()
4413 + 45;
4414 rl.reset_at = Some(future_epoch);
4415 rl.record_rate_limit();
4416 assert!(rl.backoff_delay.as_secs() >= 45);
4418 assert!(rl.backoff_delay.as_secs() <= 47);
4419 }
4420
4421 #[tokio::test]
4422 async fn send_with_retries_propagates_make_req_error() {
4423 let guard = env::var_os("GH_TOKEN");
4424 unsafe { env::set_var("GH_TOKEN", "test_token") };
4426 let gh = test_client();
4427 let result = gh
4428 .send_with_retries(
4429 || Err(anyhow::anyhow!("simulated request clone failure")),
4430 false,
4431 )
4432 .await;
4433 assert!(result.is_err());
4434 let err = result.unwrap_err().to_string();
4435 assert_eq!(err, "simulated request clone failure");
4436 match guard {
4437 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4439 None => unsafe { env::remove_var("GH_TOKEN") },
4441 }
4442 }
4443
4444 #[tokio::test]
4450 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
4451 let guard = env::var_os("GH_TOKEN");
4452 unsafe { env::set_var("GH_TOKEN", "test_token") };
4454
4455 let addr = {
4459 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4460 listener.local_addr().unwrap()
4461 };
4462 let url = format!("http://{addr}/");
4463
4464 let gh = test_client();
4465 let make_req = || Ok(gh.client.get(&url));
4466 let result = gh.send_with_retries(make_req, false).await;
4467
4468 assert!(result.is_err());
4469 assert!(
4470 !cooldown::is_agent_in_cooldown("github:5xx"),
4471 "transport error must not trip the github:5xx circuit breaker"
4472 );
4473 assert!(cooldown::cooldown_until("github:5xx").is_none());
4474
4475 match guard {
4476 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4478 None => unsafe { env::remove_var("GH_TOKEN") },
4480 }
4481 }
4482
4483 #[tokio::test]
4484 async fn post_json_raw_propagates_body_read_errors_on_success() {
4485 let guard = env::var_os("GH_TOKEN");
4486 unsafe { env::set_var("GH_TOKEN", "test_token") };
4488 let server_url = spawn_truncated_body_server("{").await;
4489 let gh = test_client();
4490
4491 let result = gh
4492 .post_json_raw(
4493 &format!("{server_url}/post"),
4494 &serde_json::json!({"ok": true}),
4495 )
4496 .await;
4497
4498 let err = result.unwrap_err().to_string();
4499 assert!(
4500 !err.contains("EOF while parsing a value"),
4501 "should surface the body read error, got: {err}"
4502 );
4503 assert!(
4504 err.contains("error decoding response body")
4505 || err.contains("request or response body error")
4506 || err.contains("end of file before message length reached"),
4507 "expected a transport/body read error, got: {err}"
4508 );
4509
4510 match guard {
4511 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4513 None => unsafe { env::remove_var("GH_TOKEN") },
4515 }
4516 }
4517
4518 #[tokio::test]
4519 async fn patch_json_raw_propagates_body_read_errors_on_success() {
4520 let guard = env::var_os("GH_TOKEN");
4521 unsafe { env::set_var("GH_TOKEN", "test_token") };
4523 let server_url = spawn_truncated_body_server("{").await;
4524 let gh = test_client();
4525
4526 let result = gh
4527 .patch_json_raw(
4528 &format!("{server_url}/patch"),
4529 &serde_json::json!({"ok": true}),
4530 )
4531 .await;
4532
4533 let err = result.unwrap_err().to_string();
4534 assert!(
4535 !err.contains("EOF while parsing a value"),
4536 "should surface the body read error, got: {err}"
4537 );
4538 assert!(
4539 err.contains("error decoding response body")
4540 || err.contains("request or response body error")
4541 || err.contains("end of file before message length reached"),
4542 "expected a transport/body read error, got: {err}"
4543 );
4544
4545 match guard {
4546 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4548 None => unsafe { env::remove_var("GH_TOKEN") },
4550 }
4551 }
4552
4553 #[tokio::test]
4554 async fn graphql_request_propagates_body_read_errors_on_success() {
4555 let guard = env::var_os("GH_TOKEN");
4556 unsafe { env::set_var("GH_TOKEN", "test_token") };
4558 let server_url = spawn_truncated_body_server("{").await;
4559 let gh = test_client();
4560 let query = "query { viewer { login } }";
4561
4562 let result = gh
4563 .graphql_request_to_url(
4564 &format!("{server_url}/graphql"),
4565 query,
4566 &[],
4567 Some(serde_json::json!({"k": "v"})),
4568 )
4569 .await;
4570
4571 let err = result.unwrap_err().to_string();
4572 assert!(
4573 !err.contains("EOF while parsing a value"),
4574 "should surface the body read error, got: {err}"
4575 );
4576 assert!(
4577 err.contains("error decoding response body")
4578 || err.contains("request or response body error")
4579 || err.contains("end of file before message length reached"),
4580 "expected a transport/body read error, got: {err}"
4581 );
4582
4583 match guard {
4584 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4586 None => unsafe { env::remove_var("GH_TOKEN") },
4588 }
4589 }
4590
4591 #[cfg(test)]
4594 mod pagination {
4595 use super::super::*;
4596 use crate::github::token::TokenResolver;
4597 use std::env;
4598 use std::ffi::OsString;
4599 use std::sync::Arc;
4600 use std::time;
4601 use std::time::SystemTime;
4602 use wiremock::matchers::{method, path};
4603 use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate, matchers};
4604
4605 struct NotMatcher<M>(M);
4608
4609 impl<M: Match> Match for NotMatcher<M> {
4610 fn matches(&self, request: &Request) -> bool {
4611 !self.0.matches(request)
4612 }
4613 }
4614
4615 struct EnvVarGuard {
4616 key: &'static str,
4617 previous: Option<OsString>,
4618 }
4619
4620 impl EnvVarGuard {
4621 fn set(key: &'static str, value: &str) -> Self {
4622 let previous = env::var_os(key);
4623 unsafe { env::set_var(key, value) };
4625 Self { key, previous }
4626 }
4627 }
4628
4629 impl Drop for EnvVarGuard {
4630 fn drop(&mut self) {
4631 match self.previous.take() {
4632 Some(value) => unsafe { env::set_var(self.key, value) },
4634 None => unsafe { env::remove_var(self.key) },
4636 }
4637 }
4638 }
4639
4640 fn make_client() -> (GhHttp, EnvVarGuard) {
4643 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4645 let client = GhHttp {
4646 client: reqwest::Client::new(),
4647 token_resolver: Arc::new(TokenResolver::default_env()),
4648 app: None,
4649 owner_only: false,
4650 cached_username: OnceLock::new(),
4651 api_base: None,
4652 };
4653 (client, guard)
4654 }
4655
4656 #[tokio::test]
4657 async fn get_all_pages_single_page() {
4658 let server = MockServer::start().await;
4659
4660 Mock::given(method("GET"))
4661 .and(path("/items"))
4662 .respond_with(
4663 ResponseTemplate::new(200)
4664 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4665 )
4666 .expect(1)
4667 .mount(&server)
4668 .await;
4669
4670 let (gh, _guard) = make_client();
4671 let url = format!("{}/items", server.uri());
4672 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4673
4674 assert_eq!(result.len(), 2);
4675 assert_eq!(result[0]["id"], 1);
4676 assert_eq!(result[1]["id"], 2);
4677 }
4678
4679 #[tokio::test]
4680 async fn get_all_pages_follows_link_next() {
4681 let server = MockServer::start().await;
4682
4683 Mock::given(method("GET"))
4685 .and(path("/items"))
4686 .respond_with(
4687 ResponseTemplate::new(200)
4688 .set_body_json(serde_json::json!([{"id": 1}]))
4689 .insert_header(
4690 "Link",
4691 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4692 ),
4693 )
4694 .expect(1)
4695 .mount(&server)
4696 .await;
4697
4698 Mock::given(method("GET"))
4700 .and(path("/items/page2"))
4701 .respond_with(
4702 ResponseTemplate::new(200)
4703 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4704 )
4705 .expect(1)
4706 .mount(&server)
4707 .await;
4708
4709 let (gh, _guard) = make_client();
4710 let url = format!("{}/items", server.uri());
4711 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4712
4713 assert_eq!(result.len(), 3);
4714 assert_eq!(result[0]["id"], 1);
4715 assert_eq!(result[1]["id"], 2);
4716 assert_eq!(result[2]["id"], 3);
4717 }
4718
4719 #[tokio::test]
4720 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4721 let server = MockServer::start().await;
4724
4725 Mock::given(method("GET"))
4726 .and(path("/items"))
4727 .respond_with(
4728 ResponseTemplate::new(200)
4729 .set_body_json(serde_json::json!([{"id": 1}]))
4730 .insert_header(
4731 "Link",
4732 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4733 ),
4734 )
4735 .expect(1)
4736 .mount(&server)
4737 .await;
4738
4739 Mock::given(method("GET"))
4741 .and(path("/items/page2"))
4742 .respond_with(
4743 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4744 )
4745 .expect(1)
4746 .mount(&server)
4747 .await;
4748
4749 let (gh, _guard) = make_client();
4750 let url = format!("{}/items", server.uri());
4751 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4752
4753 assert_eq!(result.len(), 2);
4755 }
4756
4757 #[tokio::test]
4758 async fn get_all_pages_stops_on_malformed_link_header() {
4759 let server = MockServer::start().await;
4762
4763 Mock::given(method("GET"))
4764 .and(path("/items"))
4765 .respond_with(
4766 ResponseTemplate::new(200)
4767 .set_body_json(serde_json::json!([{"id": 1}]))
4768 .insert_header("Link", "garbage; rel=\"next\""),
4769 )
4770 .expect(1)
4771 .mount(&server)
4772 .await;
4773
4774 let (gh, _guard) = make_client();
4775 let url = format!("{}/items", server.uri());
4776 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4777
4778 assert_eq!(result.len(), 1);
4780 }
4781
4782 #[tokio::test]
4783 async fn get_all_pages_returns_error_on_malformed_json_body() {
4784 let server = MockServer::start().await;
4785
4786 Mock::given(method("GET"))
4787 .and(path("/items"))
4788 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4789 .expect(3)
4790 .mount(&server)
4791 .await;
4792
4793 let (gh, _guard) = make_client();
4794 let url = format!("{}/items", server.uri());
4795 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4796
4797 assert!(result.is_err());
4798 let err = result.unwrap_err().to_string();
4799 assert!(
4800 err.contains("JSON decode failed") || err.contains("expected ident"),
4801 "error should mention JSON decode or serde error: {err}"
4802 );
4803 }
4804
4805 struct GlobalStateGuard;
4810
4811 impl Drop for GlobalStateGuard {
4812 fn drop(&mut self) {
4813 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4814 *rl = RateLimit::new();
4815 }
4816 if let Ok(mut cache) = PAGE_CACHE.lock() {
4817 cache.clear();
4818 }
4819 }
4820 }
4821
4822 #[tokio::test]
4823 async fn get_all_pages_replays_body_on_304() {
4824 let _reset = GlobalStateGuard;
4825 let server = MockServer::start().await;
4826
4827 Mock::given(method("GET"))
4829 .and(path("/issues"))
4830 .and(matchers::header_exists("authorization"))
4831 .and(NotMatcher(matchers::header_exists("if-none-match")))
4832 .respond_with(
4833 ResponseTemplate::new(200)
4834 .set_body_json(serde_json::json!([{"number": 1}]))
4835 .insert_header("ETag", "\"abc123\""),
4836 )
4837 .expect(1)
4838 .mount(&server)
4839 .await;
4840
4841 Mock::given(method("GET"))
4843 .and(path("/issues"))
4844 .and(matchers::header("if-none-match", "\"abc123\""))
4845 .respond_with(ResponseTemplate::new(304))
4846 .expect(1)
4847 .mount(&server)
4848 .await;
4849
4850 let (gh, _guard) = make_client();
4851 let url = format!("{}/issues", server.uri());
4852
4853 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4854 let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4855
4856 assert_eq!(first, second);
4857 assert_eq!(second.len(), 1);
4858 assert_eq!(second[0]["number"], 1);
4859 }
4860
4861 #[tokio::test]
4862 async fn exhausted_quota_skips_further_calls_until_reset() {
4863 let _reset = GlobalStateGuard;
4864 let server = MockServer::start().await;
4865
4866 let reset_at = SystemTime::now()
4867 .duration_since(time::UNIX_EPOCH)
4868 .unwrap()
4869 .as_secs()
4870 + 600;
4871
4872 Mock::given(method("GET"))
4875 .and(path("/issues"))
4876 .respond_with(
4877 ResponseTemplate::new(200)
4878 .set_body_json(serde_json::json!([{"number": 1}]))
4879 .insert_header("x-ratelimit-remaining", "0")
4880 .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4881 )
4882 .expect(1)
4883 .mount(&server)
4884 .await;
4885
4886 let (gh, _guard) = make_client();
4887 let url = format!("{}/issues", server.uri());
4888
4889 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4890 assert_eq!(first.len(), 1);
4891
4892 let start = Instant::now();
4894 let err = gh
4895 .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4896 .await
4897 .unwrap_err();
4898 assert!(
4899 start.elapsed() < Duration::from_secs(1),
4900 "call blocked for {:?} instead of failing fast",
4901 start.elapsed()
4902 );
4903 assert!(err.to_string().contains("rate-limited"), "{err}");
4904
4905 let m = rate_limit_metrics();
4907 assert_eq!(m.rest_remaining, Some(0));
4908 assert_eq!(m.rest_reset_at, Some(reset_at));
4909 assert!(GhHttp::is_rate_limited().is_some());
4910
4911 }
4913
4914 #[tokio::test]
4917 async fn get_all_pages_from_wrapper_single_page() {
4918 let server = MockServer::start().await;
4921
4922 Mock::given(method("GET"))
4923 .and(path("/check-runs"))
4924 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4925 "total_count": 2,
4926 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4927 })))
4928 .expect(1)
4929 .mount(&server)
4930 .await;
4931
4932 let (gh, _guard) = make_client();
4933 let url = format!("{}/check-runs", server.uri());
4934 let result: Vec<serde_json::Value> = gh
4935 .get_all_pages_from_wrapper(&url, "check_runs")
4936 .await
4937 .unwrap();
4938
4939 assert_eq!(result.len(), 2);
4940 assert_eq!(result[0]["id"], 1);
4941 assert_eq!(result[1]["id"], 2);
4942 }
4943
4944 #[tokio::test]
4945 async fn get_all_pages_from_wrapper_follows_link_next() {
4946 let server = MockServer::start().await;
4948
4949 Mock::given(method("GET"))
4950 .and(path("/check-runs"))
4951 .respond_with(
4952 ResponseTemplate::new(200)
4953 .set_body_json(serde_json::json!({
4954 "total_count": 3,
4955 "check_runs": [{"id": 1, "name": "build"}]
4956 }))
4957 .insert_header(
4958 "Link",
4959 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4960 ),
4961 )
4962 .expect(1)
4963 .mount(&server)
4964 .await;
4965
4966 Mock::given(method("GET"))
4967 .and(path("/check-runs/page2"))
4968 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4969 "total_count": 3,
4970 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4971 })))
4972 .expect(1)
4973 .mount(&server)
4974 .await;
4975
4976 let (gh, _guard) = make_client();
4977 let url = format!("{}/check-runs", server.uri());
4978 let result: Vec<serde_json::Value> = gh
4979 .get_all_pages_from_wrapper(&url, "check_runs")
4980 .await
4981 .unwrap();
4982
4983 assert_eq!(result.len(), 3);
4984 assert_eq!(result[0]["id"], 1);
4985 assert_eq!(result[1]["id"], 2);
4986 assert_eq!(result[2]["id"], 3);
4987 }
4988
4989 #[tokio::test]
4990 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4991 let server = MockServer::start().await;
4994
4995 Mock::given(method("GET"))
4996 .and(path("/check-runs"))
4997 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4998 "total_count": 0,
4999 "check_runs": []
5000 })))
5001 .expect(1)
5002 .mount(&server)
5003 .await;
5004
5005 let (gh, _guard) = make_client();
5006 let url = format!("{}/check-runs", server.uri());
5007 let result: Vec<serde_json::Value> = gh
5008 .get_all_pages_from_wrapper(&url, "check_runs")
5009 .await
5010 .unwrap();
5011
5012 assert_eq!(result.len(), 0);
5013 }
5014
5015 #[tokio::test]
5016 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
5017 let server = MockServer::start().await;
5018
5019 Mock::given(method("GET"))
5020 .and(path("/check-runs"))
5021 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
5022 .expect(1)
5023 .mount(&server)
5024 .await;
5025
5026 let (gh, _guard) = make_client();
5027 let url = format!("{}/check-runs", server.uri());
5028 let result = gh
5029 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
5030 .await;
5031
5032 assert!(result.is_err());
5033 let err = result.unwrap_err().to_string();
5034 assert!(
5035 err.contains("422") || err.contains("failed"),
5036 "error should indicate failure: {err}"
5037 );
5038 }
5039 }
5040
5041 #[cfg(test)]
5044 mod auth_retry {
5045 use super::test_client;
5046 use crate::github::token;
5047 use std::env;
5048 use std::sync::Arc;
5049 use std::sync::atomic::{AtomicUsize, Ordering};
5050 use wiremock::matchers::method;
5051 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
5052
5053 fn bad_credentials() -> ResponseTemplate {
5054 ResponseTemplate::new(401).set_body_json(serde_json::json!({
5055 "message": "Bad credentials",
5056 "status": 401
5057 }))
5058 }
5059
5060 #[expect(
5061 clippy::await_holding_lock,
5062 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5063 )]
5064 #[tokio::test]
5065 async fn retries_once_with_fresh_token_after_401() {
5066 let _env_guard = token::ENV_LOCK.lock().unwrap();
5067 let prev = env::var_os("GH_TOKEN");
5068 unsafe { env::set_var("GH_TOKEN", "bad_token") };
5070
5071 let server = MockServer::start().await;
5072 let hits = Arc::new(AtomicUsize::new(0));
5073 let hits_in_handler = Arc::clone(&hits);
5074 Mock::given(method("GET"))
5075 .respond_with(move |_req: &Request| {
5076 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
5077 unsafe { env::set_var("GH_TOKEN", "good_token") };
5081 bad_credentials()
5082 } else {
5083 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
5084 }
5085 })
5086 .mount(&server)
5087 .await;
5088
5089 let gh = test_client();
5090 let result: anyhow::Result<serde_json::Value> =
5091 gh.get_json(&format!("{}/x", server.uri())).await;
5092
5093 match prev {
5094 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
5096 None => unsafe { env::remove_var("GH_TOKEN") },
5098 }
5099
5100 assert!(
5101 result.is_ok(),
5102 "retry with re-resolved token should succeed: {:?}",
5103 result.err()
5104 );
5105 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
5106 }
5107
5108 #[expect(
5109 clippy::await_holding_lock,
5110 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5111 )]
5112 #[tokio::test]
5113 async fn double_401_bails_with_distinct_auth_error() {
5114 let _env_guard = token::ENV_LOCK.lock().unwrap();
5115 let prev = env::var_os("GH_TOKEN");
5116 unsafe { env::set_var("GH_TOKEN", "bad_token") };
5118
5119 let server = MockServer::start().await;
5120 let hits = Arc::new(AtomicUsize::new(0));
5121 let hits_in_handler = Arc::clone(&hits);
5122 Mock::given(method("GET"))
5123 .respond_with(move |_req: &Request| {
5124 hits_in_handler.fetch_add(1, Ordering::SeqCst);
5125 bad_credentials()
5126 })
5127 .mount(&server)
5128 .await;
5129
5130 let gh = test_client();
5131 let result: anyhow::Result<serde_json::Value> =
5132 gh.get_json(&format!("{}/x", server.uri())).await;
5133
5134 match prev {
5135 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
5137 None => unsafe { env::remove_var("GH_TOKEN") },
5139 }
5140
5141 let err = result.expect_err("persisting 401 must fail").to_string();
5142 assert!(
5143 err.contains("token re-resolved after cache clear but still rejected"),
5144 "expected distinct auth-failure error, got: {err}"
5145 );
5146 assert_eq!(
5147 hits.load(Ordering::SeqCst),
5148 2,
5149 "must retry exactly once, not loop"
5150 );
5151 }
5152 }
5153}