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) => {
1352 let message = err.to_string();
1353 if message.contains("(transport error)") {
1355 return Ok(BlockedByFetch::Unreachable(message));
1356 }
1357 return Ok(BlockedByFetch::Failed(message));
1358 }
1359 };
1360 let status = resp.status();
1361 if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
1362 let body = resp.text().await.unwrap_or_default();
1363 if status == StatusCode::FORBIDDEN
1364 && body.to_ascii_lowercase().contains("rate limit")
1365 {
1366 Self::maybe_record_rate_limit_from_body(status, &body);
1367 return Ok(BlockedByFetch::Failed(body));
1368 }
1369 return Ok(BlockedByFetch::Unavailable);
1370 }
1371 if !status.is_success() {
1372 let body = resp.text().await.unwrap_or_default();
1373 Self::maybe_record_rate_limit_from_body(status, &body);
1374 return Ok(BlockedByFetch::Failed(format!("{status}: {body}")));
1375 }
1376 Self::record_success();
1377 let page_next = parse_link_next(resp.headers());
1378 let body = resp.text().await.unwrap_or_default();
1379 let page: Vec<BlockedByPayload> = match serde_json::from_str(&body) {
1380 Ok(page) => page,
1381 Err(err) => {
1382 return Ok(BlockedByFetch::Failed(format!(
1383 "blocked_by parse failed: {err}"
1384 )));
1385 }
1386 };
1387 for item in page {
1388 if item.number <= 0 {
1389 continue;
1390 }
1391 let item_repo = item
1392 .repository
1393 .as_ref()
1394 .map(|r| r.full_name.clone())
1395 .filter(|name| !name.is_empty())
1396 .unwrap_or_else(|| repo.to_string());
1397 found.push(BlockedByIssue {
1398 github_id: item.id,
1399 repo: item_repo,
1400 number: item.number,
1401 state: item.state,
1402 state_reason: item.state_reason,
1403 });
1404 }
1405 next = page_next;
1406 }
1407 Ok(BlockedByFetch::Native(found))
1408 }
1409
1410 pub async fn add_blocked_by(
1414 &self,
1415 repo: &str,
1416 issue_number: u64,
1417 blocking_issue_id: i64,
1418 ) -> anyhow::Result<()> {
1419 let url =
1420 format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by");
1421 let resp = self
1422 .dependency_request(
1423 &url,
1424 reqwest::Method::POST,
1425 Some(serde_json::json!({ "issue_id": blocking_issue_id })),
1426 )
1427 .await?;
1428 let status = resp.status();
1429 if status.is_success() {
1430 Self::record_success();
1431 return Ok(());
1432 }
1433 let body = resp.text().await.unwrap_or_default();
1434 if body.to_ascii_lowercase().contains("already") {
1435 return Ok(());
1436 }
1437 Self::maybe_record_rate_limit_from_body(status, &body);
1438 anyhow::bail!("add blocked_by failed ({status}): {body}")
1439 }
1440
1441 pub async fn remove_blocked_by(
1443 &self,
1444 repo: &str,
1445 number: u64,
1446 blocking_issue_id: u64,
1447 ) -> anyhow::Result<()> {
1448 let url = format!(
1449 "{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
1450 );
1451 self.delete(&url).await?;
1452 Ok(())
1453 }
1454
1455 async fn dependency_request(
1457 &self,
1458 url: &str,
1459 method: reqwest::Method,
1460 body: Option<serde_json::Value>,
1461 ) -> anyhow::Result<Response> {
1462 Self::check_backoff(url)?;
1463 let auth = self.auth_header(url).await?;
1464 let make_req = || {
1465 let mut req = self
1466 .client
1467 .request(method.clone(), url)
1468 .header(header::AUTHORIZATION, auth.clone())
1469 .header(header::ACCEPT, "application/vnd.github+json")
1470 .header("X-GitHub-Api-Version", "2026-03-10");
1471 if let Some(ref payload) = body {
1472 req = req.json(payload);
1473 }
1474 Ok(req)
1475 };
1476 self.send_with_retries(make_req, false).await
1477 }
1478
1479 pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1481 self.get_json(&format!("{}/repos/{repo}/issues/{number}", self.api_root()))
1482 .await
1483 }
1484
1485 pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1487 self.get_issue(repo, number)
1488 .await
1489 .is_ok_and(|i| i.pull_request.is_some())
1490 }
1491
1492 pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1494 self.list_issues_with_state(repo, label, "open").await
1495 }
1496
1497 pub async fn list_issues_with_state(
1501 &self,
1502 repo: &str,
1503 label: &str,
1504 state: &str,
1505 ) -> anyhow::Result<Vec<GitHubIssue>> {
1506 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1507 let mut query = vec![("state", state), ("per_page", "100")];
1509 if !label.is_empty() {
1510 query.push(("labels", label));
1511 }
1512 let all: Vec<GitHubIssue> = self.get_all_pages(&url, &query).await?;
1513 Ok(all
1515 .into_iter()
1516 .filter(|i| i.pull_request.is_none())
1517 .collect())
1518 }
1519
1520 pub async fn list_issues_closed_since(
1524 &self,
1525 repo: &str,
1526 label: &str,
1527 since: &str,
1528 ) -> anyhow::Result<Vec<GitHubIssue>> {
1529 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1530 let all: Vec<GitHubIssue> = self
1531 .get_all_pages(
1532 &url,
1533 &[
1534 ("labels", label),
1535 ("state", "closed"),
1536 ("since", since),
1537 ("per_page", "100"),
1538 ],
1539 )
1540 .await?;
1541 Ok(all
1542 .into_iter()
1543 .filter(|i| i.pull_request.is_none())
1544 .collect())
1545 }
1546
1547 pub async fn list_closed_issues_since(
1549 &self,
1550 repo: &str,
1551 since: &str,
1552 ) -> anyhow::Result<Vec<GitHubIssue>> {
1553 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1554 let all: Vec<GitHubIssue> = self
1555 .get_all_pages(
1556 &url,
1557 &[("state", "closed"), ("since", since), ("per_page", "100")],
1558 )
1559 .await?;
1560 Ok(all
1561 .into_iter()
1562 .filter(|i| i.pull_request.is_none())
1563 .collect())
1564 }
1565
1566 pub async fn list_all_open_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1571 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1572 let params = [("state", "open"), ("per_page", "100")];
1573 let all: Vec<GitHubIssue> = self.get_all_pages(&url, ¶ms).await?;
1574 Ok(all
1576 .into_iter()
1577 .filter(|i| i.pull_request.is_none())
1578 .collect())
1579 }
1580
1581 pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1584 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1585 let all: Vec<serde_json::Value> = self
1586 .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1587 .await?;
1588 Ok(all)
1589 }
1590
1591 pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1595 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1596 let all: Vec<GitHubIssue> = self
1597 .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1598 .await?;
1599 Ok(all
1601 .into_iter()
1602 .filter(|i| i.pull_request.is_none())
1603 .collect())
1604 }
1605
1606 pub async fn add_labels(
1608 &self,
1609 repo: &str,
1610 number: &str,
1611 labels: &[String],
1612 ) -> anyhow::Result<()> {
1613 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1614 let payload = serde_json::json!({ "labels": labels });
1615 self.post_json_raw(&url, &payload).await?;
1616 Ok(())
1617 }
1618
1619 pub async fn ensure_label(
1621 &self,
1622 repo: &str,
1623 name: &str,
1624 color: &str,
1625 description: &str,
1626 ) -> anyhow::Result<()> {
1627 let encoded = urlencoding::encode(name);
1628 let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1629
1630 match self.get_bytes(&get_url).await {
1632 Ok(_) => return Ok(()),
1633 Err(e) if e.to_string().contains("404") => {
1634 tracing::debug!(repo, name, "ensure_label: label not found, creating");
1635 }
1636 Err(e) => {
1637 tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1638 }
1639 }
1640
1641 let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1643 let payload = serde_json::json!({
1644 "name": name,
1645 "color": color,
1646 "description": description,
1647 });
1648
1649 match self.post_json_raw(&create_url, &payload).await {
1650 Ok(_) => {
1651 Self::record_success();
1652 }
1653 Err(e) => {
1654 let msg = e.to_string();
1655 if msg.contains("422")
1656 || msg.contains("already_exists")
1657 || msg.contains("Unprocessable")
1658 {
1659 tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1660 return Ok(());
1661 }
1662 tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1664 }
1665 }
1666 Ok(())
1667 }
1668
1669 pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1671 let encoded = urlencoding::encode(label);
1672 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1673 match self.delete(&url).await {
1674 Ok(_) => Ok(()),
1675 Err(e) if e.to_string().contains("404") => {
1676 tracing::debug!(repo, number, label, "label already removed (404)");
1677 Ok(())
1678 }
1679 Err(e) => Err(e),
1680 }
1681 }
1682
1683 pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1685 let body = redact_posted_body(body);
1686 let url = format!("{}/repos/{repo}/issues/{number}/comments", self.api_root());
1687 let payload = serde_json::json!({ "body": body });
1688 self.post_json_raw(&url, &payload).await?;
1689 Ok(())
1690 }
1691
1692 pub async fn post_commit_status(
1694 &self,
1695 repo: &str,
1696 sha: &str,
1697 state: &str,
1698 context: &str,
1699 description: &str,
1700 ) -> anyhow::Result<()> {
1701 let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1702 let payload = serde_json::json!({
1703 "state": state,
1704 "context": context,
1705 "description": description,
1706 });
1707 self.post_json_raw(&url, &payload).await?;
1708 Ok(())
1709 }
1710
1711 pub async fn add_reaction(
1715 &self,
1716 repo: &str,
1717 number: &str,
1718 content: &str,
1719 ) -> anyhow::Result<()> {
1720 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1721 let payload = serde_json::json!({ "content": content });
1722 self.post_json_raw(&url, &payload).await?;
1723 Ok(())
1724 }
1725
1726 pub async fn add_comment_reaction(
1730 &self,
1731 repo: &str,
1732 comment_id: &str,
1733 content: &str,
1734 ) -> anyhow::Result<()> {
1735 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1736 let payload = serde_json::json!({ "content": content });
1737 self.post_json_raw(&url, &payload).await?;
1738 Ok(())
1739 }
1740
1741 pub async fn list_comments(
1743 &self,
1744 repo: &str,
1745 number: &str,
1746 ) -> anyhow::Result<Vec<GitHubComment>> {
1747 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1748 self.get_all_pages(&url, &[("per_page", "100")]).await
1749 }
1750
1751 pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1753 let owner = repo
1754 .split('/')
1755 .next()
1756 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1757 let head = format!("{owner}:{branch}");
1758
1759 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1760 let prs: Vec<serde_json::Value> = self
1761 .get_with_query(
1762 &url,
1763 &[("head", &head), ("state", "closed"), ("per_page", "1")],
1764 )
1765 .await?;
1766
1767 if prs.is_empty() {
1768 return Ok(false);
1769 }
1770 let merged_at = prs[0].get("merged_at");
1771 Ok(merged_at.is_some_and(|v| !v.is_null()))
1772 }
1773
1774 pub async fn get_closed_pr_state(
1787 &self,
1788 repo: &str,
1789 branch: &str,
1790 ) -> anyhow::Result<Option<(u64, bool, String)>> {
1791 let owner = repo
1792 .split('/')
1793 .next()
1794 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1795 let head = format!("{owner}:{branch}");
1796
1797 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1798 let prs: Vec<serde_json::Value> = self
1799 .get_with_query(
1800 &url,
1801 &[("head", &head), ("state", "closed"), ("per_page", "5")],
1802 )
1803 .await?;
1804
1805 if prs.is_empty() {
1806 return Ok(None);
1807 }
1808
1809 for pr in prs {
1812 let pr_branch = pr
1813 .get("head")
1814 .and_then(|h| h.get("ref"))
1815 .and_then(|r| r.as_str())
1816 .unwrap_or("");
1817
1818 if pr_branch != branch {
1819 tracing::debug!(
1820 repo,
1821 branch,
1822 pr_branch,
1823 "skipping PR with mismatched head ref"
1824 );
1825 continue;
1826 }
1827
1828 let pr_number = pr
1829 .get("number")
1830 .and_then(serde_json::Value::as_u64)
1831 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1832
1833 let merged_at = pr.get("merged_at");
1834 let merged = merged_at.is_some_and(|v| !v.is_null());
1835 let state = pr
1836 .get("state")
1837 .and_then(|v| v.as_str())
1838 .unwrap_or("closed")
1839 .to_string();
1840
1841 return Ok(Some((pr_number, merged, state)));
1842 }
1843
1844 Ok(None)
1846 }
1847
1848 pub async fn batch_is_pr_merged_by_branch(
1853 &self,
1854 repo: &str,
1855 branches: &[String],
1856 ) -> anyhow::Result<HashMap<String, bool>> {
1857 if branches.is_empty() {
1858 return Ok(HashMap::new());
1859 }
1860
1861 let (owner, name) = repo
1862 .split_once('/')
1863 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1864
1865 let mut var_defs = String::new();
1869 let mut aliases = String::new();
1870 let mut vars = serde_json::json!({ "owner": owner, "name": name });
1871 for (i, branch) in branches.iter().enumerate() {
1872 let _ = write!(var_defs, ", $b{i}: String!");
1873 let _ = write!(
1874 aliases,
1875 r"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "
1876 );
1877 vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1878 }
1879
1880 let query = format!(
1881 r"query($owner: String!, $name: String!{var_defs}) {{
1882 repository(owner: $owner, name: $name) {{ {aliases} }}
1883 }}"
1884 );
1885
1886 let resp = self.graphql_with_vars(&query, vars).await?;
1887
1888 if let Some(errors) = resp.get("errors").and_then(|e| e.as_array())
1890 && !errors.is_empty()
1891 {
1892 let messages: Vec<&str> = errors
1893 .iter()
1894 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1895 .collect();
1896 tracing::warn!(
1897 repo,
1898 errors = ?messages,
1899 "GraphQL returned errors in batch PR merge check"
1900 );
1901 }
1902
1903 Self::parse_batch_pr_merged_response(&resp, branches)
1904 }
1905
1906 fn parse_batch_pr_merged_response(
1907 resp: &serde_json::Value,
1908 branches: &[String],
1909 ) -> anyhow::Result<HashMap<String, bool>> {
1910 let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1911 let error_msg = resp
1913 .get("errors")
1914 .and_then(|e| e.as_array())
1915 .and_then(|arr| arr.first())
1916 .and_then(|e| e.get("message"))
1917 .and_then(|m| m.as_str())
1918 .unwrap_or("no error details");
1919 anyhow::anyhow!("missing /data/repository in GraphQL response: {error_msg}")
1920 })?;
1921
1922 let mut result = HashMap::new();
1923 for (i, branch) in branches.iter().enumerate() {
1924 let alias = format!("b{i}");
1925 let merged = repo_data
1926 .get(&alias)
1927 .and_then(|v| v.get("nodes"))
1928 .and_then(|nodes| nodes.as_array())
1929 .and_then(|arr| arr.first())
1930 .and_then(|node| node.get("merged"))
1931 .and_then(serde_json::Value::as_bool)
1932 .unwrap_or(false);
1933 result.insert(branch.clone(), merged);
1934 }
1935
1936 Ok(result)
1937 }
1938
1939 pub async fn get_mentions(
1941 &self,
1942 repo: &str,
1943 since: &str,
1944 ) -> anyhow::Result<Vec<GitHubComment>> {
1945 let url = format!("{}/repos/{repo}/issues/comments", self.api_root());
1946 self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1947 .await
1948 }
1949
1950 pub async fn get_issue_comments(
1952 &self,
1953 repo: &str,
1954 issue_number: u64,
1955 ) -> anyhow::Result<Vec<GitHubComment>> {
1956 let url = format!(
1957 "{}/repos/{repo}/issues/{issue_number}/comments",
1958 self.api_root()
1959 );
1960 self.get_all_pages(&url, &[("per_page", "100")]).await
1961 }
1962
1963 pub async fn get_whoami(&self) -> anyhow::Result<String> {
1965 if let Some(cached) = self.cached_username.get() {
1966 return Ok(cached.clone());
1967 }
1968
1969 let mut owner = self.clone();
1971 owner.owner_only = true;
1972 let user: serde_json::Value = owner.get_json(&format!("{GITHUB_API}/user")).await?;
1973 let username = user
1974 .get("login")
1975 .and_then(|v| v.as_str())
1976 .map(String::from)
1977 .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1978
1979 let _ = self.cached_username.set(username.clone());
1980 Ok(username)
1981 }
1982
1983 pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1985 let owner = repo
1986 .split('/')
1987 .next()
1988 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1989 let head = format!("{owner}:{branch}");
1990
1991 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1992 let prs: Vec<serde_json::Value> = self
1993 .get_with_query(
1994 &url,
1995 &[("head", &head), ("state", "open"), ("per_page", "1")],
1996 )
1997 .await?;
1998
1999 if prs.is_empty() {
2000 return Ok(None);
2001 }
2002 prs[0]
2003 .get("number")
2004 .and_then(serde_json::Value::as_u64)
2005 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
2006 .map(Some)
2007 }
2008
2009 pub async fn create_pr(
2020 &self,
2021 repo: &str,
2022 title: &str,
2023 body: &str,
2024 head: &str,
2025 base: &str,
2026 ) -> anyhow::Result<String> {
2027 #[derive(Serialize)]
2028 struct CreatePullRequest<'a> {
2029 title: &'a str,
2030 body: &'a str,
2031 head: &'a str,
2032 base: &'a str,
2033 }
2034
2035 let body = redact_posted_body(body);
2036 let url = format!("{}/repos/{repo}/pulls", self.api_root());
2037 Self::check_backoff(&url)?;
2038
2039 let payload = CreatePullRequest {
2040 title,
2041 body: &body,
2042 head,
2043 base,
2044 };
2045
2046 let auth = self.auth_header(&url).await?;
2047 let make_req = || {
2048 Ok(self
2049 .client
2050 .post(&url)
2051 .header(header::AUTHORIZATION, auth.clone())
2052 .header(header::ACCEPT, "application/vnd.github+json")
2053 .header(header::USER_AGENT, "orch")
2054 .header("X-GitHub-Api-Version", "2022-11-28")
2055 .json(&payload))
2056 };
2057 let response = self.send_with_retries(make_req, false).await?;
2058 let status = response.status();
2059 let text = response.text().await.unwrap_or_default();
2060 if !status.is_success() {
2061 Self::maybe_record_rate_limit_from_body(status, &text);
2062 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
2063 }
2064
2065 let pr: serde_json::Value = serde_json::from_str(&text)?;
2066 pr.get("html_url")
2067 .and_then(|v| v.as_str())
2068 .map(String::from)
2069 .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {pr:?}"))
2070 }
2071
2072 pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
2077 let url = format!("{GITHUB_API}/repos/{repo}");
2078 let resp: serde_json::Value = self.get_json(&url).await?;
2079 resp.get("default_branch")
2080 .and_then(|v| v.as_str())
2081 .map(String::from)
2082 .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
2083 }
2084
2085 pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
2088 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2089 self.get_json(&url).await
2090 }
2091
2092 pub async fn batch_get_pr_states(
2097 &self,
2098 repo: &str,
2099 pr_numbers: &[u64],
2100 ) -> anyhow::Result<HashMap<u64, (bool, String)>> {
2101 const CHUNK_SIZE: usize = 50;
2103
2104 if pr_numbers.is_empty() {
2105 return Ok(HashMap::new());
2106 }
2107
2108 let (owner, name) = repo
2109 .split_once('/')
2110 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2111
2112 let mut result = HashMap::new();
2113
2114 for chunk in pr_numbers.chunks(CHUNK_SIZE) {
2115 let aliases: String = chunk
2116 .iter()
2117 .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
2118 .collect::<Vec<_>>()
2119 .join("\n ");
2120
2121 let query = format!(
2122 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2123 );
2124
2125 let resp = self
2126 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2127 .await?;
2128 let repo_data = resp
2129 .pointer("/data/repository")
2130 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2131
2132 for n in chunk {
2133 if let Some(pr) = repo_data.get(format!("pr{n}")) {
2134 let merged = pr
2135 .get("merged")
2136 .and_then(serde_json::Value::as_bool)
2137 .unwrap_or(false);
2138 let state = pr
2139 .get("state")
2140 .and_then(|v| v.as_str())
2141 .unwrap_or("UNKNOWN")
2142 .to_lowercase();
2143 result.insert(*n, (merged, state));
2144 }
2145 }
2146 }
2147
2148 Ok(result)
2149 }
2150
2151 pub async fn batch_get_issue_states(
2156 &self,
2157 repo: &str,
2158 issue_numbers: &[u64],
2159 caller_context: &str,
2160 ) -> anyhow::Result<HashMap<String, (String, Vec<String>)>> {
2161 const CHUNK_SIZE: usize = 50;
2163
2164 if issue_numbers.is_empty() {
2165 return Ok(HashMap::new());
2166 }
2167
2168 let (valid_issue_numbers, invalid_issue_numbers) =
2169 sanitize_github_issue_numbers(issue_numbers);
2170 if !invalid_issue_numbers.is_empty() {
2171 tracing::warn!(
2172 repo,
2173 caller_context,
2174 invalid_issue_numbers = ?invalid_issue_numbers,
2175 total_supplied = issue_numbers.len(),
2176 "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
2177 );
2178 }
2179 if valid_issue_numbers.is_empty() {
2180 return Ok(HashMap::new());
2181 }
2182
2183 let (owner, name) = repo
2184 .split_once('/')
2185 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2186
2187 let mut result = HashMap::new();
2188
2189 for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
2190 let aliases: String = chunk
2191 .iter()
2192 .map(|n| {
2193 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
2194 })
2195 .collect::<Vec<_>>()
2196 .join(" ");
2197
2198 let query = format!(
2199 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2200 );
2201
2202 let resp = self
2203 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2204 .await?;
2205 let repo_data = resp
2206 .pointer("/data/repository")
2207 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2208
2209 for n in chunk {
2210 if let Some(issue) = repo_data.get(format!("issue{n}")) {
2211 let state = issue
2212 .get("state")
2213 .and_then(|v| v.as_str())
2214 .unwrap_or("UNKNOWN")
2215 .to_lowercase();
2216 let labels: Vec<String> = issue
2217 .pointer("/labels/nodes")
2218 .and_then(|v| v.as_array())
2219 .map(|arr| {
2220 arr.iter()
2221 .filter_map(|l| {
2222 l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2223 })
2224 .collect()
2225 })
2226 .unwrap_or_default();
2227 result.insert(n.to_string(), (state, labels));
2228 }
2229 }
2230 }
2231 Ok(result)
2232 }
2233
2234 pub async fn get_required_status_check_contexts(
2236 &self,
2237 repo: &str,
2238 branch: &str,
2239 ) -> anyhow::Result<Vec<RequiredStatusCheck>> {
2240 let branch = urlencoding::encode(branch);
2241 let classic_url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2242 let rules_url = format!("{GITHUB_API}/repos/{repo}/rules/branches/{branch}");
2243
2244 let classic_fut = self.get_json::<serde_json::Value>(&classic_url);
2245 let rules_fut = self.get_all_pages::<serde_json::Value>(&rules_url, &[("per_page", "100")]);
2246 let (classic_resp, rules_resp) = tokio::join!(classic_fut, rules_fut);
2247
2248 let classic = match classic_resp {
2249 Ok(value) => parse_classic_required_status_checks(&value),
2250 Err(e) => {
2251 if is_no_branch_protection_error(&e.to_string()) {
2252 Vec::new()
2253 } else {
2254 return Err(e);
2255 }
2256 }
2257 };
2258
2259 let ruleset = match rules_resp {
2260 Ok(rules) => parse_ruleset_required_status_checks(&rules),
2261 Err(e) => {
2262 if e.to_string().contains("404") {
2263 Vec::new()
2264 } else {
2265 return Err(e);
2266 }
2267 }
2268 };
2269
2270 Ok(merge_required_status_checks(classic, ruleset))
2271 }
2272
2273 pub async fn get_commit_status_contexts(
2275 &self,
2276 repo: &str,
2277 sha: &str,
2278 ) -> anyhow::Result<Vec<(String, String)>> {
2279 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2280 let statuses: Vec<serde_json::Value> =
2285 self.get_all_pages_from_wrapper(&url, "statuses").await?;
2286
2287 Ok(statuses
2288 .into_iter()
2289 .filter_map(|status| {
2290 let context = status.get("context").and_then(|v| v.as_str())?;
2291 let state = status.get("state").and_then(|v| v.as_str())?;
2292 Some((context.to_string(), state.to_string()))
2293 })
2294 .collect())
2295 }
2296
2297 fn combined_status_state(
2298 total: u64,
2299 failing: u64,
2300 pending: u64,
2301 has_workflows: bool,
2302 mergeable_state: Option<&str>,
2303 ) -> String {
2304 if failing > 0 {
2305 "failure".to_string()
2306 } else if pending > 0 {
2307 "pending".to_string()
2308 } else if total == 0 && has_workflows {
2309 if mergeable_state == Some("clean") {
2313 "success".to_string()
2314 } else {
2315 "pending".to_string()
2316 }
2317 } else {
2318 "success".to_string()
2319 }
2320 }
2321
2322 pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2327 let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2328 let resp = self.get_json::<serde_json::Value>(&url).await;
2329 match resp {
2330 Ok(value) => Ok(value
2331 .get("workflows")
2332 .and_then(|v| v.as_array())
2333 .is_some_and(|workflows| !workflows.is_empty())),
2334 Err(e) => {
2335 let err = e.to_string();
2336 if err.contains("404") {
2337 Ok(false)
2338 } else {
2339 Err(e)
2340 }
2341 }
2342 }
2343 }
2344
2345 pub async fn get_check_runs(
2347 &self,
2348 repo: &str,
2349 sha: &str,
2350 ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2351 let url = format!(
2362 "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2363 );
2364 self.get_all_pages_from_wrapper(&url, "check_runs").await
2365 }
2366
2367 pub async fn get_check_run_annotations(
2374 &self,
2375 repo: &str,
2376 check_run_id: u64,
2377 ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2378 let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2379 self.get_all_pages(&url, &[("per_page", "100")]).await
2380 }
2381
2382 pub async fn get_pr_reviews(
2384 &self,
2385 repo: &str,
2386 pr_number: u64,
2387 ) -> anyhow::Result<Vec<GitHubReview>> {
2388 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2389 self.get_all_pages(&url, &[("per_page", "100")]).await
2390 }
2391
2392 pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2397 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2398 let value: serde_json::Value = self.get_json(&url).await?;
2399 value
2400 .pointer("/commit/committer/date")
2401 .and_then(|v| v.as_str())
2402 .map(ToString::to_string)
2403 .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2404 }
2405
2406 pub async fn update_pr_body(
2408 &self,
2409 repo: &str,
2410 pr_number: u64,
2411 body: &str,
2412 ) -> anyhow::Result<()> {
2413 let body = redact_posted_body(body);
2414 let url = format!("{}/repos/{repo}/pulls/{pr_number}", self.api_root());
2415 let payload = serde_json::json!({ "body": body });
2416 self.patch_json_raw(&url, &payload).await?;
2417 Ok(())
2418 }
2419
2420 pub async fn close_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2422 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2423 let payload = serde_json::json!({ "state": "closed" });
2424 self.patch_json_raw(&url, &payload).await?;
2425 Ok(())
2426 }
2427
2428 pub async fn compare_behind_by(
2433 &self,
2434 repo: &str,
2435 base: &str,
2436 head_sha: &str,
2437 ) -> anyhow::Result<u64> {
2438 let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2439 let body: serde_json::Value = self.get_json(&url).await?;
2440 parse_behind_by(&body)
2441 }
2442
2443 pub async fn commit_meta(&self, repo: &str, sha: &str) -> anyhow::Result<CommitMeta> {
2445 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2446 let value: serde_json::Value = self.get_json(&url).await?;
2447 Ok(parse_commit_meta(&value))
2448 }
2449
2450 pub async fn branch_contains(
2452 &self,
2453 repo: &str,
2454 branch: &str,
2455 sha: &str,
2456 ) -> anyhow::Result<bool> {
2457 let url = format!("{GITHUB_API}/repos/{repo}/compare/{branch}...{sha}");
2458 let body: serde_json::Value = self.get_json(&url).await?;
2459 body.get("ahead_by")
2460 .and_then(serde_json::Value::as_u64)
2461 .map(|ahead| ahead == 0)
2462 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric ahead_by field"))
2463 }
2464
2465 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2467 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2468 let payload = serde_json::json!({ "state": "closed" });
2469 self.patch_json_raw(&url, &payload).await?;
2470 Ok(())
2471 }
2472
2473 pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2475 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2476 let payload = serde_json::json!({ "state": "open" });
2477 self.patch_json_raw(&url, &payload).await?;
2478 Ok(())
2479 }
2480
2481 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2483 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2484 Self::check_backoff(&url)?;
2485 let auth = self.auth_header(&url).await?;
2486 let make_req = || {
2487 Ok(self
2488 .client
2489 .get(&url)
2490 .header(header::AUTHORIZATION, auth.clone())
2491 .header(header::ACCEPT, "application/vnd.github+json")
2492 .header("X-GitHub-Api-Version", "2022-11-28"))
2493 };
2494 let resp = self.send_with_retries(make_req, false).await?;
2495 let status = resp.status();
2496 if status == StatusCode::NO_CONTENT {
2497 Ok(true)
2498 } else if status == StatusCode::NOT_FOUND {
2499 Ok(false)
2500 } else {
2501 let body = resp.text().await.unwrap_or_default();
2502 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2503 }
2504 }
2505
2506 pub async fn add_sub_issue(
2508 &self,
2509 parent_node_id: &str,
2510 child_node_id: &str,
2511 ) -> anyhow::Result<()> {
2512 let query = format!(
2513 r#"mutation {{
2514 addSubIssue(input: {{issueId: "{parent_node_id}", subIssueId: "{child_node_id}"}}) {{
2515 issue {{ number }}
2516 subIssue {{ number }}
2517 }}
2518 }}"#
2519 );
2520 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2521 .await?;
2522 Ok(())
2523 }
2524
2525 pub async fn link_issue_to_branch(
2537 &self,
2538 repo: &str,
2539 issue_number: u64,
2540 branch: &str,
2541 ) -> anyhow::Result<String> {
2542 let parts: Vec<&str> = repo.split('/').collect();
2543 if parts.len() != 2 {
2544 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2545 }
2546 let (owner, repo_name) = (parts[0], parts[1]);
2547
2548 let query = r"query($owner: String!, $name: String!, $number: Int!) {
2550 repository(owner: $owner, name: $name) {
2551 id
2552 issue(number: $number) {
2553 id
2554 }
2555 }
2556 }";
2557 let vars = serde_json::json!({
2558 "owner": owner,
2559 "name": repo_name,
2560 "number": issue_number,
2561 });
2562
2563 let result = self.graphql_with_vars(query, vars).await?;
2564
2565 let repo_id = result
2566 .pointer("/data/repository/id")
2567 .and_then(|v| v.as_str())
2568 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2569
2570 let issue_id = result
2571 .pointer("/data/repository/issue/id")
2572 .and_then(|v| v.as_str())
2573 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2574
2575 let branch_query = r"query($owner: String!, $name: String!, $ref: String!) {
2577 repository(owner: $owner, name: $name) {
2578 ref(qualifiedName: $ref) {
2579 target {
2580 oid
2581 }
2582 }
2583 }
2584 }";
2585 let branch_vars = serde_json::json!({
2586 "owner": owner,
2587 "name": repo_name,
2588 "ref": format!("refs/heads/{}", branch),
2589 });
2590
2591 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2592 let branch_oid = branch_result
2593 .pointer("/data/repository/ref/target/oid")
2594 .and_then(|v| v.as_str());
2595
2596 let branch_oid = if let Some(oid) = branch_oid {
2599 oid.to_string()
2600 } else {
2601 let default_query = r"query($owner: String!, $name: String!) {
2603 repository(owner: $owner, name: $name) {
2604 defaultBranchRef {
2605 target {
2606 oid
2607 }
2608 }
2609 }
2610 }";
2611 let default_vars = serde_json::json!({
2612 "owner": owner,
2613 "name": repo_name,
2614 });
2615 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2616 match default_result
2617 .pointer("/data/repository/defaultBranchRef/target/oid")
2618 .and_then(|v| v.as_str())
2619 {
2620 Some(oid) => {
2621 tracing::debug!(
2622 repo,
2623 issue_number,
2624 branch,
2625 "branch not found, using default branch OID to create and link"
2626 );
2627 oid.to_string()
2628 }
2629 None => {
2630 return Ok(format!("unlinked:{branch}"));
2631 }
2632 }
2633 };
2634
2635 let mutation = r"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2638 createLinkedBranch(input: {
2639 issueId: $issueId
2640 repositoryId: $repositoryId
2641 name: $name
2642 oid: $oid
2643 }) {
2644 linkedBranch {
2645 id
2646 ref {
2647 name
2648 }
2649 }
2650 }
2651 }";
2652 let mutation_vars = serde_json::json!({
2653 "issueId": issue_id,
2654 "repositoryId": repo_id,
2655 "name": branch,
2656 "oid": branch_oid,
2657 });
2658
2659 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2660
2661 match link_result {
2662 Ok(result) => {
2663 let linked_branch_id = result
2664 .pointer("/data/createLinkedBranch/linkedBranch/id")
2665 .and_then(|v| v.as_str())
2666 .map(String::from);
2667
2668 if let Some(id) = linked_branch_id {
2669 tracing::info!(
2670 repo,
2671 issue_number,
2672 branch,
2673 "linked issue to branch via GraphQL API"
2674 );
2675 Ok(id)
2676 } else {
2677 let errors = result
2679 .get("errors")
2680 .and_then(|e| e.as_array())
2681 .cloned()
2682 .unwrap_or_default();
2683
2684 if errors.iter().any(|e| {
2685 e.get("message")
2686 .and_then(|m| m.as_str())
2687 .is_some_and(|m| m.contains("already"))
2688 }) {
2689 tracing::debug!(
2690 repo,
2691 issue_number,
2692 branch,
2693 "issue already linked to branch"
2694 );
2695 Ok(format!("already_linked:{branch}"))
2696 } else {
2697 anyhow::bail!("failed to link issue to branch: {result:?}")
2698 }
2699 }
2700 }
2701 Err(e) => {
2702 let err_str = format!("{e}");
2704 if err_str.contains("already") || err_str.contains("existing") {
2705 tracing::debug!(
2706 repo,
2707 issue_number,
2708 branch,
2709 "issue already linked to branch (error check)"
2710 );
2711 Ok(format!("already_linked:{branch}"))
2712 } else {
2713 Err(e)
2714 }
2715 }
2716 }
2717 }
2718
2719 pub async fn merge_pr(
2722 &self,
2723 repo: &str,
2724 pr_number: u64,
2725 delete_branch: bool,
2726 head_sha: &str,
2727 ) -> anyhow::Result<()> {
2728 if !is_full_commit_sha(head_sha) {
2729 anyhow::bail!("merge_pr requires the full head sha, got {head_sha:?}");
2730 }
2731 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2732 Self::check_backoff(&url)?;
2733 let payload = merge_pr_body(head_sha);
2734 let auth = self.auth_header(&url).await?;
2735 let make_req = || {
2736 Ok(self
2737 .client
2738 .put(&url)
2739 .json(&payload)
2740 .header(header::AUTHORIZATION, auth.clone())
2741 .header(header::ACCEPT, "application/vnd.github+json")
2742 .header("X-GitHub-Api-Version", "2022-11-28"))
2743 };
2744 let resp = self.send_with_retries(make_req, false).await?;
2745 let status = resp.status();
2746 if !status.is_success() {
2747 let body = resp.text().await.unwrap_or_default();
2748 anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2749 }
2750 Self::record_success();
2751
2752 if delete_branch {
2754 let pr: serde_json::Value = self
2756 .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2757 .await?;
2758 if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2759 let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2760 if let Err(e) = self.delete(&del_url).await {
2761 let err_str = e.to_string();
2762 if err_str.contains("Reference does not exist") {
2763 tracing::debug!(
2764 ref_name,
2765 "branch already gone (GitHub auto-deleted on merge)"
2766 );
2767 } else {
2768 tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2769 }
2770 }
2771 }
2772 }
2773 Ok(())
2774 }
2775
2776 pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2782 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2783 Self::check_backoff(&url)?;
2784 let auth = self.auth_header(&url).await?;
2785 let make_req = || {
2786 Ok(self
2787 .client
2788 .put(&url)
2789 .header(header::AUTHORIZATION, auth.clone())
2790 .header(header::ACCEPT, "application/vnd.github+json")
2791 .header("X-GitHub-Api-Version", "2022-11-28"))
2792 };
2793 let resp = self.send_with_retries(make_req, false).await?;
2794 let status = resp.status();
2795 if !status.is_success() {
2796 let body = resp.text().await.unwrap_or_default();
2797 Self::maybe_record_rate_limit_from_body(status, &body);
2798 anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2799 }
2800 Self::record_success();
2801 Ok(())
2802 }
2803
2804 pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2808 let parts: Vec<&str> = repo.split('/').collect();
2809 if parts.len() != 2 {
2810 anyhow::bail!("invalid repo format: {repo}");
2811 }
2812 let (owner, repo_name) = (parts[0], parts[1]);
2813
2814 let pr_query = r"query($owner: String!, $name: String!, $number: Int!) {
2816 repository(owner: $owner, name: $name) {
2817 pullRequest(number: $number) { id }
2818 }
2819 }";
2820 let body = self
2821 .graphql_with_vars(
2822 pr_query,
2823 serde_json::json!({
2824 "owner": owner,
2825 "name": repo_name,
2826 "number": pr_number,
2827 }),
2828 )
2829 .await?;
2830 let pr_id = body
2831 .pointer("/data/repository/pullRequest/id")
2832 .and_then(|v| v.as_str())
2833 .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2834 .to_string();
2835
2836 let mutation = r"mutation($prId: ID!) {
2838 enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2839 pullRequest { autoMergeRequest { enabledAt } }
2840 }
2841 }";
2842 let body = self
2843 .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2844 .await?;
2845 if let Some(errors) = body.get("errors") {
2846 anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2847 }
2848
2849 tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2850 Ok(())
2851 }
2852
2853 pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2855 let parts: Vec<&str> = repo.split('/').collect();
2856 if parts.len() != 2 {
2857 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2858 }
2859 let (owner, repo_name) = (parts[0], parts[1]);
2860 let number = match parse_positive_issue_number(number) {
2861 Ok(number) => number,
2862 Err(e) => {
2863 tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2864 return Ok(Vec::new());
2865 }
2866 };
2867
2868 let mut all_numbers: Vec<u64> = Vec::new();
2869 let mut cursor: Option<String> = None;
2870 let page_size = 100;
2871 let max_pages = 50;
2872 let mut page_count = 0;
2873
2874 loop {
2875 page_count += 1;
2876 if page_count > max_pages {
2877 tracing::warn!(
2878 repo,
2879 number,
2880 "get_sub_issues hit max page limit ({max_pages})"
2881 );
2882 break;
2883 }
2884
2885 let (query, vars) = if let Some(ref c) = cursor {
2886 (
2887 r"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2888 repository(owner: $owner, name: $name) {
2889 issue(number: $number) {
2890 subIssues(first: $first, after: $after) {
2891 nodes { number }
2892 pageInfo { hasNextPage endCursor }
2893 }
2894 }
2895 }
2896 }",
2897 serde_json::json!({
2898 "owner": owner,
2899 "name": repo_name,
2900 "number": number,
2901 "first": page_size,
2902 "after": c,
2903 }),
2904 )
2905 } else {
2906 (
2907 r"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2908 repository(owner: $owner, name: $name) {
2909 issue(number: $number) {
2910 subIssues(first: $first) {
2911 nodes { number }
2912 pageInfo { hasNextPage endCursor }
2913 }
2914 }
2915 }
2916 }",
2917 serde_json::json!({
2918 "owner": owner,
2919 "name": repo_name,
2920 "number": number,
2921 "first": page_size,
2922 }),
2923 )
2924 };
2925
2926 let result = self
2927 .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2928 .await?;
2929
2930 let sub_issues_data = result
2931 .get("data")
2932 .and_then(|d| d.get("repository"))
2933 .and_then(|r| r.get("issue"))
2934 .and_then(|i| i.get("subIssues"));
2935
2936 let nodes = sub_issues_data
2937 .and_then(|s| s.get("nodes"))
2938 .and_then(|n| n.as_array());
2939
2940 match nodes {
2941 Some(nodes) => {
2942 let numbers: Vec<u64> = nodes
2943 .iter()
2944 .filter_map(|n| n.get("number").and_then(serde_json::Value::as_u64))
2945 .collect();
2946 all_numbers.extend(numbers);
2947 }
2948 None => break,
2949 }
2950
2951 let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2952 let has_next = page_info
2953 .and_then(|p| p.get("hasNextPage"))
2954 .and_then(serde_json::Value::as_bool)
2955 .unwrap_or(false);
2956
2957 if !has_next {
2958 break;
2959 }
2960
2961 cursor = page_info
2962 .and_then(|p| p.get("endCursor"))
2963 .and_then(|c| c.as_str())
2964 .map(ToString::to_string);
2965 }
2966
2967 Ok(all_numbers)
2968 }
2969
2970 pub async fn get_combined_status(
2972 &self,
2973 repo: &str,
2974 git_ref: &str,
2975 has_workflows: bool,
2976 mergeable_state: Option<&str>,
2977 ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2978 let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2979 let resp: serde_json::Value = self.get_json(&url).await?;
2980
2981 let runs = resp
2982 .get("check_runs")
2983 .and_then(|v| v.as_array())
2984 .cloned()
2985 .unwrap_or_default();
2986
2987 let mut passing = 0u64;
2988 let mut failing = 0u64;
2989 let mut pending = 0u64;
2990
2991 for run in &runs {
2992 let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2993 let status = run
2994 .get("status")
2995 .and_then(|v| v.as_str())
2996 .unwrap_or("queued");
2997
2998 match status {
2999 "completed" => {
3000 if conclusion.is_empty() {
3001 pending += 1;
3002 } else if matches!(conclusion, "success" | "neutral" | "skipped") {
3003 passing += 1;
3004 } else {
3005 failing += 1;
3006 }
3007 }
3008 _ => pending += 1,
3009 }
3010 }
3011
3012 let total = runs.len() as u64;
3013 let state =
3014 Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
3015
3016 Ok((state, total, passing, failing, pending))
3017 }
3018
3019 pub async fn rerun_failed_workflow(
3025 &self,
3026 repo: &str,
3027 workflow_name: &str,
3028 branch: &str,
3029 ) -> anyhow::Result<()> {
3030 let url = format!(
3032 "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
3033 );
3034 let resp: serde_json::Value = self.get_json(&url).await?;
3035 let runs = resp["workflow_runs"]
3036 .as_array()
3037 .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
3038
3039 let run_id = runs
3041 .iter()
3042 .find(|r| {
3043 r["name"].as_str() == Some(workflow_name)
3044 && matches!(r["conclusion"].as_str(), Some("failure" | "cancelled"))
3045 })
3046 .and_then(|r| r["id"].as_u64())
3047 .ok_or_else(|| {
3048 anyhow::anyhow!(
3049 "no failed run found for workflow '{workflow_name}' on branch '{branch}'"
3050 )
3051 })?;
3052
3053 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
3055 let auth = self.auth_header(&rerun_url).await?;
3056 let make_req = || {
3057 Ok(self
3058 .client
3059 .post(&rerun_url)
3060 .header(header::AUTHORIZATION, auth.clone())
3061 .header(header::ACCEPT, "application/vnd.github+json")
3062 .header("X-GitHub-Api-Version", "2022-11-28"))
3063 };
3064 let resp = self.send_with_retries(make_req, false).await?;
3065 let status = resp.status();
3066 if !status.is_success() {
3067 let body = resp.text().await.unwrap_or_default();
3068 Self::maybe_record_rate_limit_from_body(status, &body);
3069 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
3070 }
3071
3072 Ok(())
3073 }
3074
3075 pub async fn batch_fetch_pr_review_data(
3081 &self,
3082 repo: &str,
3083 pr_numbers: &[u64],
3084 ) -> anyhow::Result<HashMap<u64, PrReviewBatchData>> {
3085 if pr_numbers.is_empty() {
3086 return Ok(HashMap::new());
3087 }
3088
3089 let (owner, name) = repo
3090 .split_once('/')
3091 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
3092
3093 let aliases: String = pr_numbers
3095 .iter()
3096 .map(|&n| build_pr_review_alias(n))
3097 .collect::<Vec<_>>()
3098 .join("\n");
3099
3100 let query = format!(
3101 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
3102 );
3103
3104 let resp = self
3105 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
3106 .await?;
3107 let repo_data = resp
3108 .pointer("/data/repository")
3109 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
3110
3111 let mut result = HashMap::new();
3112 for &n in pr_numbers {
3113 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
3114 continue;
3115 };
3116
3117 let merged = pr_data
3118 .get("merged")
3119 .and_then(serde_json::Value::as_bool)
3120 .unwrap_or(false);
3121 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
3122 Some("MERGEABLE") => Some(true),
3123 Some("CONFLICTING") => Some(false),
3124 _ => None, };
3126
3127 let mut reviews: Vec<GitHubReview> = pr_data
3128 .pointer("/reviews/nodes")
3129 .and_then(|v| v.as_array())
3130 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
3131 .unwrap_or_default();
3132
3133 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
3134 tracing::warn!(
3135 pr_number = n,
3136 fetched = reviews.len(),
3137 "batch review fetch truncated reviews connection at first page"
3138 );
3139 }
3140
3141 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
3145
3146 let mut review_comments: Vec<GitHubReviewComment> = pr_data
3147 .pointer("/reviewThreads/nodes")
3148 .and_then(|v| v.as_array())
3149 .map(|threads| {
3150 threads
3151 .iter()
3152 .filter_map(|thread| {
3153 thread
3154 .pointer("/comments/nodes")
3155 .and_then(|v| v.as_array())
3156 .map(|nodes| {
3157 nodes
3158 .iter()
3159 .filter_map(parse_graphql_review_comment)
3160 .collect::<Vec<_>>()
3161 })
3162 })
3163 .flatten()
3164 .collect()
3165 })
3166 .unwrap_or_default();
3167
3168 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3169 tracing::warn!(
3170 pr_number = n,
3171 fetched_threads = pr_data
3172 .pointer("/reviewThreads/nodes")
3173 .and_then(|v| v.as_array())
3174 .map_or(0, Vec::len),
3175 "batch review fetch truncated reviewThreads connection at first page"
3176 );
3177 }
3178 if pr_data
3179 .pointer("/reviewThreads/nodes")
3180 .and_then(|v| v.as_array())
3181 .is_some_and(|threads| {
3182 threads.iter().any(|thread| {
3183 connection_has_next_page(thread.pointer("/comments/pageInfo"))
3184 })
3185 })
3186 {
3187 tracing::warn!(
3188 pr_number = n,
3189 "batch review fetch truncated one or more review thread comment connections"
3190 );
3191 }
3192
3193 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3196
3197 let mut issue_comments: Vec<GitHubComment> = pr_data
3198 .pointer("/comments/nodes")
3199 .and_then(|v| v.as_array())
3200 .map(|nodes| {
3201 nodes
3202 .iter()
3203 .filter_map(parse_graphql_issue_comment)
3204 .collect()
3205 })
3206 .unwrap_or_default();
3207
3208 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3209 issue_comments.extend(
3210 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3211 .await?,
3212 );
3213 }
3214
3215 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3220
3221 result.insert(
3222 n,
3223 PrReviewBatchData {
3224 merged,
3225 head_sha: pr_data
3226 .get("headRefOid")
3227 .and_then(|v| v.as_str())
3228 .unwrap_or_default()
3229 .to_string(),
3230 mergeable,
3231 reviews,
3232 review_comments,
3233 issue_comments,
3234 },
3235 );
3236 }
3237
3238 Ok(result)
3239 }
3240
3241 async fn fetch_pr_issue_comments_after(
3242 &self,
3243 owner: &str,
3244 name: &str,
3245 pr_number: u64,
3246 mut cursor: Option<String>,
3247 ) -> anyhow::Result<Vec<GitHubComment>> {
3248 let mut comments = Vec::new();
3249 let mut page_count = 0;
3250 let max_pages = 50;
3251
3252 while let Some(current_cursor) = cursor.take() {
3253 page_count += 1;
3254 if page_count > max_pages {
3255 tracing::warn!(
3256 pr_number,
3257 fetched = comments.len(),
3258 "stopping PR issue comment pagination after max page limit"
3259 );
3260 break;
3261 }
3262
3263 let (query, vars) =
3264 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
3265 let resp = self.graphql_with_vars(&query, vars).await?;
3266 let comments_data = resp
3267 .pointer("/data/repository/pullRequest/comments")
3268 .ok_or_else(|| {
3269 anyhow::anyhow!(
3270 "missing /data/repository/pullRequest/comments in GraphQL response"
3271 )
3272 })?;
3273
3274 comments.extend(
3275 comments_data
3276 .get("nodes")
3277 .and_then(|v| v.as_array())
3278 .into_iter()
3279 .flatten()
3280 .filter_map(parse_graphql_issue_comment),
3281 );
3282
3283 cursor = connection_end_cursor(comments_data.get("pageInfo"));
3284 }
3285
3286 Ok(comments)
3287 }
3288
3289 pub async fn get_automated_review_status(
3291 &self,
3292 repo: &str,
3293 pr_number: u64,
3294 ) -> anyhow::Result<Option<String>> {
3295 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3296
3297 let app_login = super::app::bot_login().await;
3299 let mut newest: Option<&types::GitHubComment> = None;
3300 for c in &comments {
3301 if !c.body.starts_with("## Automated Review") {
3302 continue;
3303 }
3304 let trusted = match app_login.as_deref() {
3305 Some(app) if super::app::same_login(&c.user.login, app) => Ok(true),
3306 _ => self.is_collaborator(repo, &c.user.login).await,
3307 };
3308 match trusted {
3309 Ok(true) => match newest {
3310 None => newest = Some(c),
3311 Some(prev) => {
3312 if c.created_at > prev.created_at {
3313 newest = Some(c);
3314 }
3315 }
3316 },
3317 Ok(false) => {
3318 tracing::warn!(
3319 user = %c.user.login,
3320 pr_number,
3321 "ignoring automated review comment from non-collaborator"
3322 );
3323 }
3324 Err(e) => {
3325 tracing::debug!(
3326 user = %c.user.login,
3327 error = %e,
3328 "failed to check collaborator status, skipping comment"
3329 );
3330 }
3331 }
3332 }
3333
3334 match newest {
3335 Some(c) => {
3336 let first_line = c.body.lines().next().unwrap_or("");
3337 if first_line.contains("Automated Review \u{2014} Approve") {
3338 Ok(Some("approve".to_string()))
3339 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3340 Ok(Some("changes_requested".to_string()))
3341 } else {
3342 Ok(None)
3343 }
3344 }
3345 None => Ok(None),
3346 }
3347 }
3348}
3349
3350#[derive(Debug, Default, PartialEq)]
3356pub struct CommitMeta {
3357 pub parents: Vec<String>,
3358 pub author_login: Option<String>,
3359 pub committer_login: Option<String>,
3360}
3361
3362fn parse_commit_meta(commit: &serde_json::Value) -> CommitMeta {
3363 let login = |field: &str| {
3364 commit
3365 .pointer(&format!("/{field}/login"))
3366 .and_then(|v| v.as_str())
3367 .map(String::from)
3368 };
3369 CommitMeta {
3370 parents: commit
3371 .get("parents")
3372 .and_then(|p| p.as_array())
3373 .map(|parents| {
3374 parents
3375 .iter()
3376 .filter_map(|p| p.get("sha").and_then(|s| s.as_str()).map(String::from))
3377 .collect()
3378 })
3379 .unwrap_or_default(),
3380 author_login: login("author"),
3381 committer_login: login("committer"),
3382 }
3383}
3384
3385fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3386 body.get("behind_by")
3387 .and_then(serde_json::Value::as_u64)
3388 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3389}
3390
3391fn is_no_branch_protection_error(err: &str) -> bool {
3394 err.contains("404")
3395 || (err.contains("403")
3396 && err.contains("Upgrade to GitHub Pro or make this repository public"))
3397}
3398
3399fn parse_classic_required_status_checks(value: &serde_json::Value) -> Vec<RequiredStatusCheck> {
3401 if let Some(checks) = value
3402 .pointer("/required_status_checks/checks")
3403 .and_then(|v| v.as_array())
3404 {
3405 let parsed: Vec<RequiredStatusCheck> =
3406 checks.iter().filter_map(parse_classic_check).collect();
3407 if !parsed.is_empty() {
3408 return parsed;
3409 }
3410 }
3411
3412 value
3413 .pointer("/required_status_checks/contexts")
3414 .and_then(|v| v.as_array())
3415 .map(|contexts| {
3416 contexts
3417 .iter()
3418 .filter_map(|context| {
3419 context.as_str().map(|s| RequiredStatusCheck {
3420 context: s.to_string(),
3421 integration_id: None,
3422 })
3423 })
3424 .collect()
3425 })
3426 .unwrap_or_default()
3427}
3428
3429fn parse_classic_check(check: &serde_json::Value) -> Option<RequiredStatusCheck> {
3430 let context = check.get("context")?.as_str()?.to_string();
3431 let integration_id = check
3432 .get("app_id")
3433 .and_then(serde_json::Value::as_i64)
3434 .filter(|&id| id > 0);
3435 Some(RequiredStatusCheck {
3436 context,
3437 integration_id,
3438 })
3439}
3440
3441fn parse_ruleset_required_status_checks(rules: &[serde_json::Value]) -> Vec<RequiredStatusCheck> {
3443 let mut out = Vec::new();
3444 for rule in rules {
3445 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3446 continue;
3447 }
3448 let Some(checks) = rule
3449 .pointer("/parameters/required_status_checks")
3450 .or_else(|| rule.get("required_status_checks"))
3451 .and_then(|v| v.as_array())
3452 else {
3453 continue;
3454 };
3455 for check in checks {
3456 let Some(context) = check.get("context").and_then(|v| v.as_str()) else {
3457 continue;
3458 };
3459 let integration_id = check
3460 .get("integration_id")
3461 .and_then(serde_json::Value::as_i64)
3462 .filter(|&id| id > 0);
3463 out.push(RequiredStatusCheck {
3464 context: context.to_string(),
3465 integration_id,
3466 });
3467 }
3468 }
3469 out
3470}
3471
3472fn merge_required_status_checks(
3473 mut checks: Vec<RequiredStatusCheck>,
3474 extra: Vec<RequiredStatusCheck>,
3475) -> Vec<RequiredStatusCheck> {
3476 checks.extend(extra);
3477 let mut seen = HashSet::new();
3478 checks.retain(|c| seen.insert((c.context.clone(), c.integration_id)));
3479 checks
3480}
3481
3482fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3483 let mut valid = Vec::with_capacity(issue_numbers.len());
3484 let mut invalid = Vec::new();
3485 for &issue_number in issue_numbers {
3486 if issue_number == 0 {
3487 invalid.push(issue_number);
3488 } else {
3489 valid.push(issue_number);
3490 }
3491 }
3492 (valid, invalid)
3493}
3494
3495#[derive(Debug, Clone)]
3499pub struct RateLimitMetrics {
3500 pub hits: u64,
3502 pub skipped_calls: u64,
3504 pub rest_remaining: Option<u32>,
3506 pub rest_reset_at: Option<u64>,
3508 pub graphql_remaining: Option<u32>,
3510 pub five_xx_hits: u64,
3512}
3513
3514pub fn rate_limit_metrics() -> RateLimitMetrics {
3516 let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
3517 .lock()
3518 .ok()
3519 .map_or((None, None), |rl| (rl.remaining, rl.reset_at));
3520 let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3521 RateLimitMetrics {
3522 hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3523 skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3524 rest_remaining,
3525 rest_reset_at,
3526 graphql_remaining,
3527 five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3528 }
3529}
3530
3531fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3535 let link = headers.get("link")?.to_str().ok()?;
3536 for part in link.split(',') {
3537 let part = part.trim();
3538 if part.contains("rel=\"next\"") {
3539 let start = part.find('<')? + 1;
3541 let end = part.find('>')?;
3542 if start >= end {
3543 return None;
3544 }
3545 return Some(part[start..end].to_string());
3546 }
3547 }
3548 None
3549}
3550
3551pub fn status_label_color(label: &str) -> &'static str {
3553 match label {
3554 "status:new" => "0e8a16",
3555 "status:routed" => "1d76db",
3556 "status:in_progress" => "fbca04",
3557 "status:done" => "6f42c1",
3558 "status:blocked" => "d73a4a",
3559 "status:in_review" => "0075ca",
3560 "status:approved" => "2ea44f",
3561 "status:needs_review" => "e4e669",
3562 "status:waiting" => "5319e7",
3563 _ => "c5def5",
3564 }
3565}
3566
3567#[derive(Debug, Clone, Default)]
3571pub struct PrReviewBatchData {
3572 pub merged: bool,
3574 pub head_sha: String,
3576 pub mergeable: Option<bool>,
3578 pub reviews: Vec<GitHubReview>,
3580 pub review_comments: Vec<GitHubReviewComment>,
3582 pub issue_comments: Vec<GitHubComment>,
3584}
3585
3586fn build_pr_review_alias(n: u64) -> String {
3589 format!(
3590 r"pr{n}: pullRequest(number: {n}) {{
3591 merged
3592 mergeable
3593 headRefOid
3594 reviews(first: 50) {{
3595 nodes {{
3596 databaseId
3597 author {{ login }}
3598 body
3599 state
3600 url
3601 submittedAt
3602 commit {{ oid }}
3603 }}
3604 pageInfo {{ hasNextPage }}
3605 }}
3606 reviewThreads(first: 50) {{
3607 nodes {{
3608 comments(first: 20) {{
3609 nodes {{
3610 databaseId
3611 author {{ login }}
3612 body
3613 path
3614 line
3615 originalLine
3616 commit {{ oid }}
3617 originalCommit {{ oid }}
3618 url
3619 createdAt
3620 updatedAt
3621 replyTo {{ databaseId }}
3622 diffHunk
3623 }}
3624 pageInfo {{ hasNextPage }}
3625 }}
3626 }}
3627 pageInfo {{ hasNextPage }}
3628 }}
3629 comments(first: 50) {{
3630 nodes {{
3631 databaseId
3632 author {{ login }}
3633 body
3634 createdAt
3635 updatedAt
3636 url
3637 }}
3638 pageInfo {{ hasNextPage endCursor }}
3639 }}
3640}}"
3641 )
3642}
3643
3644fn build_pr_issue_comments_page_query(
3645 owner: &str,
3646 name: &str,
3647 pr_number: u64,
3648 after_cursor: &str,
3649) -> (String, serde_json::Value) {
3650 let query = r"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3651 repository(owner: $owner, name: $name) {
3652 pullRequest(number: $number) {
3653 comments(first: 100, after: $after) {
3654 nodes {
3655 databaseId
3656 author { login }
3657 body
3658 createdAt
3659 updatedAt
3660 url
3661 }
3662 pageInfo { hasNextPage endCursor }
3663 }
3664 }
3665 }
3666}"
3667 .to_string();
3668 let vars = serde_json::json!({
3669 "owner": owner,
3670 "name": name,
3671 "number": pr_number,
3672 "after": after_cursor,
3673 });
3674 (query, vars)
3675}
3676
3677fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3678 page_info
3679 .and_then(|v| v.get("hasNextPage"))
3680 .and_then(serde_json::Value::as_bool)
3681 .unwrap_or(false)
3682}
3683
3684fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3685 if !connection_has_next_page(page_info) {
3686 return None;
3687 }
3688
3689 page_info
3690 .and_then(|v| v.get("endCursor"))
3691 .and_then(|v| v.as_str())
3692 .map(str::to_owned)
3693}
3694
3695fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3696 Some(GitHubReview {
3697 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3698 user: GitHubUser {
3699 login: node
3700 .pointer("/author/login")
3701 .and_then(|v| v.as_str())?
3702 .to_string(),
3703 },
3704 body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3705 state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3706 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3707 submitted_at: node
3708 .get("submittedAt")
3709 .and_then(|v| v.as_str())
3710 .unwrap_or("")
3711 .to_string(),
3712 commit_id: node
3713 .pointer("/commit/oid")
3714 .and_then(|v| v.as_str())
3715 .map(String::from),
3716 })
3717}
3718
3719fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3720 Some(GitHubReviewComment {
3721 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3722 user: GitHubUser {
3723 login: node
3724 .pointer("/author/login")
3725 .and_then(|v| v.as_str())?
3726 .to_string(),
3727 },
3728 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3729 path: node
3730 .get("path")
3731 .and_then(|v| v.as_str())
3732 .unwrap_or("")
3733 .to_string(),
3734 line: node
3735 .get("line")
3736 .and_then(serde_json::Value::as_u64)
3737 .and_then(|v| u32::try_from(v).ok()),
3738 original_line: node
3739 .get("originalLine")
3740 .and_then(serde_json::Value::as_u64)
3741 .and_then(|v| u32::try_from(v).ok()),
3742 commit_id: node
3743 .pointer("/commit/oid")
3744 .and_then(|v| v.as_str())
3745 .unwrap_or("")
3746 .to_string(),
3747 original_commit_id: node
3748 .pointer("/originalCommit/oid")
3749 .and_then(|v| v.as_str())
3750 .unwrap_or("")
3751 .to_string(),
3752 html_url: node
3753 .get("url")
3754 .and_then(|v| v.as_str())
3755 .unwrap_or("")
3756 .to_string(),
3757 created_at: node
3758 .get("createdAt")
3759 .and_then(|v| v.as_str())
3760 .unwrap_or("")
3761 .to_string(),
3762 updated_at: node
3763 .get("updatedAt")
3764 .and_then(|v| v.as_str())
3765 .unwrap_or("")
3766 .to_string(),
3767 in_reply_to_id: node
3768 .pointer("/replyTo/databaseId")
3769 .and_then(serde_json::Value::as_u64),
3770 diff_hunk: node
3771 .get("diffHunk")
3772 .and_then(|v| v.as_str())
3773 .map(String::from),
3774 })
3775}
3776
3777fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3778 Some(GitHubComment {
3779 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3780 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3781 user: GitHubUser {
3782 login: node
3783 .pointer("/author/login")
3784 .and_then(|v| v.as_str())?
3785 .to_string(),
3786 },
3787 created_at: node
3788 .get("createdAt")
3789 .and_then(|v| v.as_str())
3790 .unwrap_or("")
3791 .to_string(),
3792 updated_at: node
3793 .get("updatedAt")
3794 .and_then(|v| v.as_str())
3795 .map(String::from),
3796 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3797 issue_url: None,
3798 author_association: None,
3799 })
3800}
3801
3802#[cfg(test)]
3803mod tests {
3804 mod leak_redaction;
3806
3807 use std::env;
3808
3809 use super::*;
3810 use crate::github::token::TokenResolver;
3811 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3812 use tokio::net::TcpListener;
3813
3814 #[test]
3815 fn repo_from_url_names_the_repository_or_nothing() {
3816 let api = "https://api.github.com";
3817 assert_eq!(
3818 repo_from_url(&format!("{api}/repos/me/a/pulls/9")).as_deref(),
3819 Some("me/a")
3820 );
3821 assert_eq!(
3822 repo_from_url(&format!("{api}/repos/org/b?per_page=1")).as_deref(),
3823 Some("org/b")
3824 );
3825 assert_eq!(
3826 repo_from_url(&format!("{api}/repos/org/b")).as_deref(),
3827 Some("org/b")
3828 );
3829 assert_eq!(repo_from_url(&format!("{api}/graphql")), None);
3830 assert_eq!(repo_from_url(&format!("{api}/user")), None);
3831 assert_eq!(repo_from_url(&format!("{api}/repos/only-owner")), None);
3832 }
3833
3834 #[test]
3835 fn no_branch_protection_errors() {
3836 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.\"}";
3837 assert!(is_no_branch_protection_error(free_plan));
3838 assert!(is_no_branch_protection_error(
3839 "failed (404 Not Found): {\"message\":\"Branch not protected\"}"
3840 ));
3841 assert!(!is_no_branch_protection_error(
3842 "failed (403 Forbidden): {\"message\":\"Resource not accessible\"}"
3843 ));
3844 }
3845
3846 #[test]
3847 fn parse_classic_required_status_checks_reads_checks_app_id() {
3848 let value = serde_json::json!({
3849 "required_status_checks": {
3850 "contexts": ["ci/test"],
3851 "checks": [{ "context": "ci/test", "app_id": 42 }]
3852 }
3853 });
3854 assert_eq!(
3855 parse_classic_required_status_checks(&value),
3856 vec![RequiredStatusCheck {
3857 context: "ci/test".to_string(),
3858 integration_id: Some(42),
3859 }]
3860 );
3861 }
3862
3863 #[test]
3864 fn parse_classic_required_status_checks_falls_back_to_contexts() {
3865 let value = serde_json::json!({
3866 "required_status_checks": {
3867 "contexts": ["ci/test", "lint"]
3868 }
3869 });
3870 assert_eq!(
3871 parse_classic_required_status_checks(&value),
3872 vec![
3873 RequiredStatusCheck {
3874 context: "ci/test".to_string(),
3875 integration_id: None,
3876 },
3877 RequiredStatusCheck {
3878 context: "lint".to_string(),
3879 integration_id: None,
3880 }
3881 ]
3882 );
3883 }
3884
3885 #[test]
3886 fn parse_classic_required_status_checks_empty_when_unprotected() {
3887 let checks = parse_classic_required_status_checks(&serde_json::json!({}));
3888 assert!(checks.is_empty(), "{checks:?}");
3889 }
3890
3891 #[test]
3892 fn parse_ruleset_required_status_checks_reads_integration_id() {
3893 let rules = vec![
3894 serde_json::json!({ "type": "pull_request" }),
3895 serde_json::json!({
3896 "type": "required_status_checks",
3897 "parameters": {
3898 "required_status_checks": [
3899 { "context": "ci/test", "integration_id": 42 },
3900 { "context": "lint" }
3901 ]
3902 }
3903 }),
3904 ];
3905 assert_eq!(
3906 parse_ruleset_required_status_checks(&rules),
3907 vec![
3908 RequiredStatusCheck {
3909 context: "ci/test".to_string(),
3910 integration_id: Some(42),
3911 },
3912 RequiredStatusCheck {
3913 context: "lint".to_string(),
3914 integration_id: None,
3915 }
3916 ]
3917 );
3918 }
3919
3920 #[test]
3921 fn parse_ruleset_required_status_checks_treats_zero_integration_id_as_any_app() {
3922 let rules = vec![serde_json::json!({
3923 "type": "required_status_checks",
3924 "parameters": {
3925 "required_status_checks": [{ "context": "ci/test", "integration_id": 0 }]
3926 }
3927 })];
3928 assert_eq!(
3929 parse_ruleset_required_status_checks(&rules),
3930 vec![RequiredStatusCheck {
3931 context: "ci/test".to_string(),
3932 integration_id: None,
3933 }]
3934 );
3935 }
3936
3937 #[test]
3938 fn merge_required_status_checks_dedupes_identical_pairs() {
3939 let classic = vec![RequiredStatusCheck {
3940 context: "ci/test".to_string(),
3941 integration_id: None,
3942 }];
3943 let ruleset = vec![
3944 RequiredStatusCheck {
3945 context: "ci/test".to_string(),
3946 integration_id: None,
3947 },
3948 RequiredStatusCheck {
3949 context: "ci/test".to_string(),
3950 integration_id: Some(42),
3951 },
3952 ];
3953 assert_eq!(
3954 merge_required_status_checks(classic, ruleset),
3955 vec![
3956 RequiredStatusCheck {
3957 context: "ci/test".to_string(),
3958 integration_id: None,
3959 },
3960 RequiredStatusCheck {
3961 context: "ci/test".to_string(),
3962 integration_id: Some(42),
3963 }
3964 ]
3965 );
3966 }
3967
3968 #[test]
3969 fn parse_commit_meta_reads_parents_in_order_and_logins() {
3970 let merge = serde_json::json!({
3971 "parents": [{"sha": "approved"}, {"sha": "base"}],
3972 "author": {"login": "owner"},
3973 "committer": {"login": "web-flow"},
3974 });
3975 let meta = parse_commit_meta(&merge);
3976 assert_eq!(meta.parents, vec!["approved", "base"]);
3977 assert_eq!(meta.author_login.as_deref(), Some("owner"));
3978 assert_eq!(meta.committer_login.as_deref(), Some("web-flow"));
3979 assert_eq!(
3980 parse_commit_meta(&serde_json::json!({})),
3981 CommitMeta::default()
3982 );
3983 }
3984
3985 #[test]
3986 fn parse_behind_by_reads_compare_status() {
3987 let behind = serde_json::json!({"status": "behind", "behind_by": 3, "ahead_by": 0});
3989 assert_eq!(parse_behind_by(&behind).unwrap(), 3);
3990 let diverged = serde_json::json!({"status": "diverged", "behind_by": 1, "ahead_by": 2});
3991 assert_eq!(parse_behind_by(&diverged).unwrap(), 1);
3992 let ahead = serde_json::json!({"status": "ahead", "behind_by": 0, "ahead_by": 2});
3993 assert_eq!(parse_behind_by(&ahead).unwrap(), 0);
3994 assert!(parse_behind_by(&serde_json::json!({"status": "ahead"})).is_err());
3996 }
3997
3998 async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3999 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4000 let addr = listener.local_addr().unwrap();
4001
4002 tokio::spawn(async move {
4003 let (mut socket, _) = listener.accept().await.unwrap();
4004 let mut buf = [0u8; 4096];
4005 let _ = socket.read(&mut buf).await;
4006
4007 let declared_len = body_prefix.len() + 32;
4008 let response = format!(
4009 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
4010 );
4011 socket.write_all(response.as_bytes()).await.unwrap();
4012 socket.shutdown().await.unwrap();
4013 });
4014
4015 format!("http://{addr}")
4016 }
4017
4018 #[test]
4019 fn merge_pr_body_pins_the_head_sha() {
4020 let sha = "0123456789abcdef0123456789abcdef01234567";
4021 let body = merge_pr_body(sha);
4022 assert_eq!(body["merge_method"], "squash");
4023 assert_eq!(body["sha"], sha);
4024 assert!(is_full_commit_sha(sha));
4025 assert!(!is_full_commit_sha("abc"));
4026 assert!(!is_full_commit_sha(""));
4027 }
4028
4029 fn test_client() -> GhHttp {
4030 GhHttp {
4031 client: reqwest::Client::new(),
4032 token_resolver: Arc::new(TokenResolver::default_env()),
4033 app: None,
4034 owner_only: false,
4035 cached_username: OnceLock::new(),
4036 api_base: None,
4037 }
4038 }
4039
4040 #[test]
4045 fn gh_http_new_returns_ok() {
4046 let result = GhHttp::new();
4047 assert!(
4048 result.is_ok(),
4049 "GhHttp::new() must not panic and must return Ok; got: {:?}",
4050 result.err()
4051 );
4052 }
4053
4054 #[test]
4055 fn batch_get_issue_states_query_includes_labels_pagination() {
4056 let issue_numbers = [1u64, 2, 3];
4057
4058 let aliases: String = issue_numbers
4059 .iter()
4060 .map(|n| {
4061 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
4062 })
4063 .collect::<Vec<_>>()
4064 .join(" ");
4065
4066 let query = format!(
4067 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
4068 );
4069 assert!(query.contains("$owner"));
4070 assert!(query.contains("$name"));
4071 assert!(query.contains("issue1: issue(number: 1)"));
4072 assert!(query.contains("issue3: issue(number: 3)"));
4073 assert!(query.contains("labels(first: 100)"));
4074 }
4075
4076 #[test]
4077 fn parse_positive_issue_number_accepts_positive_values() {
4078 assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
4079 }
4080
4081 #[test]
4082 fn parse_positive_issue_number_rejects_zero() {
4083 let err = parse_positive_issue_number("0").unwrap_err().to_string();
4084 assert!(err.contains("invalid issue number: 0"));
4085 }
4086
4087 #[test]
4088 fn parse_positive_issue_number_rejects_internal_ids() {
4089 let err = parse_positive_issue_number("internal:5")
4090 .unwrap_err()
4091 .to_string();
4092 assert!(err.contains("invalid issue number: internal:5"));
4093 }
4094
4095 #[test]
4096 fn pr_review_batch_query_includes_page_info_for_nested_connections() {
4097 let query = build_pr_review_alias(42);
4098
4099 assert!(query.contains("reviews(first: 50)"));
4100 assert!(query.contains("reviews(first: 50) {\n nodes"));
4101 assert!(query.contains("pageInfo { hasNextPage }"));
4102 assert!(query.contains("reviewThreads(first: 50)"));
4103 assert!(query.contains("comments(first: 20)"));
4104 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4105 assert!(query.contains("updatedAt"));
4106 }
4107
4108 #[test]
4109 fn pr_issue_comments_page_query_uses_cursor_pagination() {
4110 let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
4111
4112 assert!(query.contains("pullRequest(number: $number)"));
4113 assert!(query.contains("comments(first: 100, after: $after)"));
4114 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4115 assert!(query.contains("updatedAt"));
4116 assert_eq!(vars["owner"], "owner");
4117 assert_eq!(vars["name"], "repo");
4118 assert_eq!(vars["number"], 42);
4119 assert_eq!(vars["after"], "cursor123");
4120 }
4121
4122 #[test]
4123 fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
4124 let page_info = serde_json::json!({
4125 "hasNextPage": true,
4126 "endCursor": "cursor123"
4127 });
4128 assert_eq!(
4129 connection_end_cursor(Some(&page_info)),
4130 Some("cursor123".to_string())
4131 );
4132
4133 let final_page = serde_json::json!({
4134 "hasNextPage": false,
4135 "endCursor": "cursor456"
4136 });
4137 assert_eq!(connection_end_cursor(Some(&final_page)), None);
4138 }
4139
4140 #[test]
4141 fn status_label_colors_match_bash_palette() {
4142 assert_eq!(status_label_color("status:new"), "0e8a16");
4143 assert_eq!(status_label_color("status:routed"), "1d76db");
4144 assert_eq!(status_label_color("status:in_progress"), "fbca04");
4145 assert_eq!(status_label_color("status:done"), "6f42c1");
4146 assert_eq!(status_label_color("status:blocked"), "d73a4a");
4147 assert_eq!(status_label_color("status:in_review"), "0075ca");
4148 assert_eq!(status_label_color("status:needs_review"), "e4e669");
4149 }
4150
4151 #[test]
4152 fn unknown_label_gets_default_color() {
4153 assert_eq!(status_label_color("agent:claude"), "c5def5");
4154 assert_eq!(status_label_color("enhancement"), "c5def5");
4155 assert_eq!(status_label_color(""), "c5def5");
4156 }
4157
4158 #[test]
4159 fn parse_link_next_finds_next_url() {
4160 let mut headers = header::HeaderMap::new();
4161 headers.insert(
4162 "link",
4163 "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
4164 .parse()
4165 .unwrap(),
4166 );
4167 assert_eq!(
4168 parse_link_next(&headers),
4169 Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
4170 );
4171 }
4172
4173 #[test]
4174 fn parse_link_next_none_when_no_next() {
4175 let mut headers = header::HeaderMap::new();
4176 headers.insert(
4177 "link",
4178 "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
4179 .parse()
4180 .unwrap(),
4181 );
4182 assert_eq!(parse_link_next(&headers), None);
4183 }
4184
4185 #[test]
4186 fn parse_link_next_none_when_missing() {
4187 let headers = header::HeaderMap::new();
4188 assert_eq!(parse_link_next(&headers), None);
4189 }
4190
4191 #[test]
4192 fn parse_link_next_none_when_malformed() {
4193 let mut headers = header::HeaderMap::new();
4194 headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
4196 assert_eq!(parse_link_next(&headers), None);
4197 }
4198
4199 #[test]
4200 fn parse_link_next_none_when_gt_before_lt() {
4201 let mut headers = header::HeaderMap::new();
4202 headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
4203 assert_eq!(parse_link_next(&headers), None); }
4205
4206 #[test]
4207 fn parse_link_next_none_when_empty_url() {
4208 let mut headers = header::HeaderMap::new();
4209 headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
4210 assert_eq!(parse_link_next(&headers), None);
4211 }
4212
4213 fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
4214 RateLimit {
4215 remaining: None,
4216 reset_at: None,
4217 backoff_until: None,
4218 backoff_delay: Duration::ZERO,
4219 backoff_base: Duration::from_secs(base_secs),
4220 backoff_max: Duration::from_secs(max_secs),
4221 throttle_threshold: 10,
4222 }
4223 }
4224
4225 #[test]
4226 fn rate_limit_inactive_by_default() {
4227 let rl = make_rl(30, 900);
4228 assert!(rl.is_active().is_none());
4229 }
4230
4231 #[test]
4232 fn rate_limit_backoff_activates() {
4233 let mut rl = make_rl(5, 60);
4234 rl.record_rate_limit();
4235 assert!(rl.is_active().is_some());
4236 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4237 }
4238
4239 #[test]
4240 fn rate_limit_backoff_doubles() {
4241 let mut rl = make_rl(5, 60);
4242 rl.record_rate_limit();
4243 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4244 rl.record_rate_limit();
4245 assert_eq!(rl.backoff_delay, Duration::from_secs(10));
4246 rl.record_rate_limit();
4247 assert_eq!(rl.backoff_delay, Duration::from_secs(20));
4248 }
4249
4250 #[test]
4251 fn rate_limit_backoff_caps_at_max() {
4252 let mut rl = make_rl(30, 60);
4253 rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
4257 }
4258
4259 #[test]
4260 fn rate_limit_success_resets() {
4261 let mut rl = make_rl(5, 60);
4262 rl.record_rate_limit();
4263 assert!(rl.is_active().is_some());
4264 rl.record_success();
4265 assert!(rl.is_active().is_none());
4266 assert_eq!(rl.backoff_delay, Duration::ZERO);
4267 }
4268
4269 #[test]
4270 fn page_cache_key_separates_types_and_queries() {
4271 let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
4272 let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
4273 let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
4274 assert_ne!(a, b);
4275 assert_ne!(a, c);
4276 assert_eq!(
4277 a,
4278 page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
4279 );
4280 }
4281
4282 #[test]
4284 fn page_cache_key_skips_since_filtered_requests() {
4285 assert!(
4286 page_cache_key::<GitHubIssue>(
4287 "/issues",
4288 Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
4289 )
4290 .is_none()
4291 );
4292 assert!(
4294 page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
4295 .is_none()
4296 );
4297 }
4298
4299 #[test]
4302 fn low_quota_fails_fast_instead_of_waiting_for_reset() {
4303 let mut rl = make_rl(30, 900);
4304 rl.remaining = Some(0);
4305 rl.reset_at = Some(
4306 SystemTime::now()
4307 .duration_since(std_time::UNIX_EPOCH)
4308 .unwrap()
4309 .as_secs()
4310 + 3600,
4311 );
4312 let cell = Mutex::new(rl);
4313
4314 let start = Instant::now();
4315 let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
4316 assert!(
4317 start.elapsed() < Duration::from_secs(1),
4318 "check blocked for {:?}",
4319 start.elapsed()
4320 );
4321 assert!(err.to_string().contains("rate-limited"), "{err}");
4322 }
4323
4324 #[test]
4326 fn healthy_quota_passes_the_check() {
4327 let mut rl = make_rl(30, 900);
4328 rl.remaining = Some(4000);
4329 rl.reset_at = Some(u64::MAX / 2);
4330 let cell = Mutex::new(rl);
4331 assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
4332 }
4333
4334 #[test]
4335 fn rate_limit_proactive_pause() {
4336 let mut rl = make_rl(30, 900);
4337 rl.remaining = Some(0);
4339 let future_epoch = SystemTime::now()
4340 .duration_since(std_time::UNIX_EPOCH)
4341 .unwrap()
4342 .as_secs()
4343 + 60;
4344 rl.reset_at = Some(future_epoch);
4345 let pause = rl.is_active();
4346 assert!(pause.is_some());
4347 assert!(pause.unwrap().as_secs() <= 62);
4349 }
4350
4351 #[test]
4352 fn rate_limit_proactive_pause_threshold() {
4353 let mut rl = make_rl(30, 900);
4354 rl.remaining = Some(15);
4356 let future_epoch = SystemTime::now()
4357 .duration_since(std_time::UNIX_EPOCH)
4358 .unwrap()
4359 .as_secs()
4360 + 60;
4361 rl.reset_at = Some(future_epoch);
4362 assert!(rl.is_active().is_none());
4363
4364 rl.remaining = Some(5);
4366 assert!(rl.is_active().is_some());
4367 }
4368
4369 #[test]
4370 fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
4371 assert_eq!(
4373 GhHttp::combined_status_state(0, 0, 0, true, None),
4374 "pending"
4375 );
4376 assert_eq!(
4378 GhHttp::combined_status_state(0, 0, 0, false, None),
4379 "success"
4380 );
4381 assert_eq!(
4383 GhHttp::combined_status_state(3, 1, 0, true, None),
4384 "failure"
4385 );
4386 assert_eq!(
4388 GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
4389 "success"
4390 );
4391 assert_eq!(
4393 GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
4394 "pending"
4395 );
4396 }
4397
4398 #[test]
4399 fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
4400 let resp = serde_json::json!({
4401 "data": {},
4402 "errors": [{ "message": "Something went wrong" }],
4403 });
4404
4405 let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
4406 .unwrap_err()
4407 .to_string();
4408 assert!(err_text.contains("missing /data/repository in GraphQL response"));
4409 assert!(err_text.contains("Something went wrong"));
4410 }
4411
4412 #[test]
4413 fn rate_limit_uses_reset_at_for_backoff() {
4414 let mut rl = make_rl(30, 900);
4415 let future_epoch = SystemTime::now()
4417 .duration_since(std_time::UNIX_EPOCH)
4418 .unwrap()
4419 .as_secs()
4420 + 45;
4421 rl.reset_at = Some(future_epoch);
4422 rl.record_rate_limit();
4423 assert!(rl.backoff_delay.as_secs() >= 45);
4425 assert!(rl.backoff_delay.as_secs() <= 47);
4426 }
4427
4428 #[tokio::test]
4429 async fn send_with_retries_propagates_make_req_error() {
4430 let guard = env::var_os("GH_TOKEN");
4431 unsafe { env::set_var("GH_TOKEN", "test_token") };
4433 let gh = test_client();
4434 let result = gh
4435 .send_with_retries(
4436 || Err(anyhow::anyhow!("simulated request clone failure")),
4437 false,
4438 )
4439 .await;
4440 assert!(result.is_err());
4441 let err = result.unwrap_err().to_string();
4442 assert_eq!(err, "simulated request clone failure");
4443 match guard {
4444 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4446 None => unsafe { env::remove_var("GH_TOKEN") },
4448 }
4449 }
4450
4451 #[tokio::test]
4457 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
4458 let guard = env::var_os("GH_TOKEN");
4459 unsafe { env::set_var("GH_TOKEN", "test_token") };
4461
4462 let addr = {
4466 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4467 listener.local_addr().unwrap()
4468 };
4469 let url = format!("http://{addr}/");
4470
4471 let gh = test_client();
4472 let make_req = || Ok(gh.client.get(&url));
4473 let result = gh.send_with_retries(make_req, false).await;
4474
4475 assert!(result.is_err());
4476 assert!(
4477 !cooldown::is_agent_in_cooldown("github:5xx"),
4478 "transport error must not trip the github:5xx circuit breaker"
4479 );
4480 assert!(cooldown::cooldown_until("github:5xx").is_none());
4481
4482 match guard {
4483 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4485 None => unsafe { env::remove_var("GH_TOKEN") },
4487 }
4488 }
4489
4490 #[tokio::test]
4491 async fn post_json_raw_propagates_body_read_errors_on_success() {
4492 let guard = env::var_os("GH_TOKEN");
4493 unsafe { env::set_var("GH_TOKEN", "test_token") };
4495 let server_url = spawn_truncated_body_server("{").await;
4496 let gh = test_client();
4497
4498 let result = gh
4499 .post_json_raw(
4500 &format!("{server_url}/post"),
4501 &serde_json::json!({"ok": true}),
4502 )
4503 .await;
4504
4505 let err = result.unwrap_err().to_string();
4506 assert!(
4507 !err.contains("EOF while parsing a value"),
4508 "should surface the body read error, got: {err}"
4509 );
4510 assert!(
4511 err.contains("error decoding response body")
4512 || err.contains("request or response body error")
4513 || err.contains("end of file before message length reached"),
4514 "expected a transport/body read error, got: {err}"
4515 );
4516
4517 match guard {
4518 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4520 None => unsafe { env::remove_var("GH_TOKEN") },
4522 }
4523 }
4524
4525 #[tokio::test]
4526 async fn patch_json_raw_propagates_body_read_errors_on_success() {
4527 let guard = env::var_os("GH_TOKEN");
4528 unsafe { env::set_var("GH_TOKEN", "test_token") };
4530 let server_url = spawn_truncated_body_server("{").await;
4531 let gh = test_client();
4532
4533 let result = gh
4534 .patch_json_raw(
4535 &format!("{server_url}/patch"),
4536 &serde_json::json!({"ok": true}),
4537 )
4538 .await;
4539
4540 let err = result.unwrap_err().to_string();
4541 assert!(
4542 !err.contains("EOF while parsing a value"),
4543 "should surface the body read error, got: {err}"
4544 );
4545 assert!(
4546 err.contains("error decoding response body")
4547 || err.contains("request or response body error")
4548 || err.contains("end of file before message length reached"),
4549 "expected a transport/body read error, got: {err}"
4550 );
4551
4552 match guard {
4553 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4555 None => unsafe { env::remove_var("GH_TOKEN") },
4557 }
4558 }
4559
4560 #[tokio::test]
4561 async fn graphql_request_propagates_body_read_errors_on_success() {
4562 let guard = env::var_os("GH_TOKEN");
4563 unsafe { env::set_var("GH_TOKEN", "test_token") };
4565 let server_url = spawn_truncated_body_server("{").await;
4566 let gh = test_client();
4567 let query = "query { viewer { login } }";
4568
4569 let result = gh
4570 .graphql_request_to_url(
4571 &format!("{server_url}/graphql"),
4572 query,
4573 &[],
4574 Some(serde_json::json!({"k": "v"})),
4575 )
4576 .await;
4577
4578 let err = result.unwrap_err().to_string();
4579 assert!(
4580 !err.contains("EOF while parsing a value"),
4581 "should surface the body read error, got: {err}"
4582 );
4583 assert!(
4584 err.contains("error decoding response body")
4585 || err.contains("request or response body error")
4586 || err.contains("end of file before message length reached"),
4587 "expected a transport/body read error, got: {err}"
4588 );
4589
4590 match guard {
4591 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
4593 None => unsafe { env::remove_var("GH_TOKEN") },
4595 }
4596 }
4597
4598 #[cfg(test)]
4601 mod pagination {
4602 use super::super::*;
4603 use crate::github::token::TokenResolver;
4604 use std::env;
4605 use std::ffi::OsString;
4606 use std::sync::Arc;
4607 use std::time;
4608 use std::time::SystemTime;
4609 use wiremock::matchers::{method, path};
4610 use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate, matchers};
4611
4612 struct NotMatcher<M>(M);
4615
4616 impl<M: Match> Match for NotMatcher<M> {
4617 fn matches(&self, request: &Request) -> bool {
4618 !self.0.matches(request)
4619 }
4620 }
4621
4622 struct EnvVarGuard {
4623 key: &'static str,
4624 previous: Option<OsString>,
4625 }
4626
4627 impl EnvVarGuard {
4628 fn set(key: &'static str, value: &str) -> Self {
4629 let previous = env::var_os(key);
4630 unsafe { env::set_var(key, value) };
4632 Self { key, previous }
4633 }
4634 }
4635
4636 impl Drop for EnvVarGuard {
4637 fn drop(&mut self) {
4638 match self.previous.take() {
4639 Some(value) => unsafe { env::set_var(self.key, value) },
4641 None => unsafe { env::remove_var(self.key) },
4643 }
4644 }
4645 }
4646
4647 fn make_client() -> (GhHttp, EnvVarGuard) {
4650 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4652 let client = GhHttp {
4653 client: reqwest::Client::new(),
4654 token_resolver: Arc::new(TokenResolver::default_env()),
4655 app: None,
4656 owner_only: false,
4657 cached_username: OnceLock::new(),
4658 api_base: None,
4659 };
4660 (client, guard)
4661 }
4662
4663 #[tokio::test]
4664 async fn get_all_pages_single_page() {
4665 let server = MockServer::start().await;
4666
4667 Mock::given(method("GET"))
4668 .and(path("/items"))
4669 .respond_with(
4670 ResponseTemplate::new(200)
4671 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4672 )
4673 .expect(1)
4674 .mount(&server)
4675 .await;
4676
4677 let (gh, _guard) = make_client();
4678 let url = format!("{}/items", server.uri());
4679 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4680
4681 assert_eq!(result.len(), 2);
4682 assert_eq!(result[0]["id"], 1);
4683 assert_eq!(result[1]["id"], 2);
4684 }
4685
4686 #[tokio::test]
4687 async fn get_all_pages_follows_link_next() {
4688 let server = MockServer::start().await;
4689
4690 Mock::given(method("GET"))
4692 .and(path("/items"))
4693 .respond_with(
4694 ResponseTemplate::new(200)
4695 .set_body_json(serde_json::json!([{"id": 1}]))
4696 .insert_header(
4697 "Link",
4698 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4699 ),
4700 )
4701 .expect(1)
4702 .mount(&server)
4703 .await;
4704
4705 Mock::given(method("GET"))
4707 .and(path("/items/page2"))
4708 .respond_with(
4709 ResponseTemplate::new(200)
4710 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4711 )
4712 .expect(1)
4713 .mount(&server)
4714 .await;
4715
4716 let (gh, _guard) = make_client();
4717 let url = format!("{}/items", server.uri());
4718 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4719
4720 assert_eq!(result.len(), 3);
4721 assert_eq!(result[0]["id"], 1);
4722 assert_eq!(result[1]["id"], 2);
4723 assert_eq!(result[2]["id"], 3);
4724 }
4725
4726 #[tokio::test]
4727 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4728 let server = MockServer::start().await;
4731
4732 Mock::given(method("GET"))
4733 .and(path("/items"))
4734 .respond_with(
4735 ResponseTemplate::new(200)
4736 .set_body_json(serde_json::json!([{"id": 1}]))
4737 .insert_header(
4738 "Link",
4739 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4740 ),
4741 )
4742 .expect(1)
4743 .mount(&server)
4744 .await;
4745
4746 Mock::given(method("GET"))
4748 .and(path("/items/page2"))
4749 .respond_with(
4750 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4751 )
4752 .expect(1)
4753 .mount(&server)
4754 .await;
4755
4756 let (gh, _guard) = make_client();
4757 let url = format!("{}/items", server.uri());
4758 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4759
4760 assert_eq!(result.len(), 2);
4762 }
4763
4764 #[tokio::test]
4765 async fn get_all_pages_stops_on_malformed_link_header() {
4766 let server = MockServer::start().await;
4769
4770 Mock::given(method("GET"))
4771 .and(path("/items"))
4772 .respond_with(
4773 ResponseTemplate::new(200)
4774 .set_body_json(serde_json::json!([{"id": 1}]))
4775 .insert_header("Link", "garbage; rel=\"next\""),
4776 )
4777 .expect(1)
4778 .mount(&server)
4779 .await;
4780
4781 let (gh, _guard) = make_client();
4782 let url = format!("{}/items", server.uri());
4783 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4784
4785 assert_eq!(result.len(), 1);
4787 }
4788
4789 #[tokio::test]
4790 async fn get_all_pages_returns_error_on_malformed_json_body() {
4791 let server = MockServer::start().await;
4792
4793 Mock::given(method("GET"))
4794 .and(path("/items"))
4795 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4796 .expect(3)
4797 .mount(&server)
4798 .await;
4799
4800 let (gh, _guard) = make_client();
4801 let url = format!("{}/items", server.uri());
4802 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4803
4804 assert!(result.is_err());
4805 let err = result.unwrap_err().to_string();
4806 assert!(
4807 err.contains("JSON decode failed") || err.contains("expected ident"),
4808 "error should mention JSON decode or serde error: {err}"
4809 );
4810 }
4811
4812 struct GlobalStateGuard;
4817
4818 impl Drop for GlobalStateGuard {
4819 fn drop(&mut self) {
4820 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4821 *rl = RateLimit::new();
4822 }
4823 if let Ok(mut cache) = PAGE_CACHE.lock() {
4824 cache.clear();
4825 }
4826 }
4827 }
4828
4829 #[tokio::test]
4830 async fn get_all_pages_replays_body_on_304() {
4831 let _reset = GlobalStateGuard;
4832 let server = MockServer::start().await;
4833
4834 Mock::given(method("GET"))
4836 .and(path("/issues"))
4837 .and(matchers::header_exists("authorization"))
4838 .and(NotMatcher(matchers::header_exists("if-none-match")))
4839 .respond_with(
4840 ResponseTemplate::new(200)
4841 .set_body_json(serde_json::json!([{"number": 1}]))
4842 .insert_header("ETag", "\"abc123\""),
4843 )
4844 .expect(1)
4845 .mount(&server)
4846 .await;
4847
4848 Mock::given(method("GET"))
4850 .and(path("/issues"))
4851 .and(matchers::header("if-none-match", "\"abc123\""))
4852 .respond_with(ResponseTemplate::new(304))
4853 .expect(1)
4854 .mount(&server)
4855 .await;
4856
4857 let (gh, _guard) = make_client();
4858 let url = format!("{}/issues", server.uri());
4859
4860 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4861 let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4862
4863 assert_eq!(first, second);
4864 assert_eq!(second.len(), 1);
4865 assert_eq!(second[0]["number"], 1);
4866 }
4867
4868 #[tokio::test]
4869 async fn exhausted_quota_skips_further_calls_until_reset() {
4870 let _reset = GlobalStateGuard;
4871 let server = MockServer::start().await;
4872
4873 let reset_at = SystemTime::now()
4874 .duration_since(time::UNIX_EPOCH)
4875 .unwrap()
4876 .as_secs()
4877 + 600;
4878
4879 Mock::given(method("GET"))
4882 .and(path("/issues"))
4883 .respond_with(
4884 ResponseTemplate::new(200)
4885 .set_body_json(serde_json::json!([{"number": 1}]))
4886 .insert_header("x-ratelimit-remaining", "0")
4887 .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4888 )
4889 .expect(1)
4890 .mount(&server)
4891 .await;
4892
4893 let (gh, _guard) = make_client();
4894 let url = format!("{}/issues", server.uri());
4895
4896 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4897 assert_eq!(first.len(), 1);
4898
4899 let start = Instant::now();
4901 let err = gh
4902 .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4903 .await
4904 .unwrap_err();
4905 assert!(
4906 start.elapsed() < Duration::from_secs(1),
4907 "call blocked for {:?} instead of failing fast",
4908 start.elapsed()
4909 );
4910 assert!(err.to_string().contains("rate-limited"), "{err}");
4911
4912 let m = rate_limit_metrics();
4914 assert_eq!(m.rest_remaining, Some(0));
4915 assert_eq!(m.rest_reset_at, Some(reset_at));
4916 assert!(GhHttp::is_rate_limited().is_some());
4917
4918 }
4920
4921 #[tokio::test]
4924 async fn get_all_pages_from_wrapper_single_page() {
4925 let server = MockServer::start().await;
4928
4929 Mock::given(method("GET"))
4930 .and(path("/check-runs"))
4931 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4932 "total_count": 2,
4933 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4934 })))
4935 .expect(1)
4936 .mount(&server)
4937 .await;
4938
4939 let (gh, _guard) = make_client();
4940 let url = format!("{}/check-runs", server.uri());
4941 let result: Vec<serde_json::Value> = gh
4942 .get_all_pages_from_wrapper(&url, "check_runs")
4943 .await
4944 .unwrap();
4945
4946 assert_eq!(result.len(), 2);
4947 assert_eq!(result[0]["id"], 1);
4948 assert_eq!(result[1]["id"], 2);
4949 }
4950
4951 #[tokio::test]
4952 async fn get_all_pages_from_wrapper_follows_link_next() {
4953 let server = MockServer::start().await;
4955
4956 Mock::given(method("GET"))
4957 .and(path("/check-runs"))
4958 .respond_with(
4959 ResponseTemplate::new(200)
4960 .set_body_json(serde_json::json!({
4961 "total_count": 3,
4962 "check_runs": [{"id": 1, "name": "build"}]
4963 }))
4964 .insert_header(
4965 "Link",
4966 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4967 ),
4968 )
4969 .expect(1)
4970 .mount(&server)
4971 .await;
4972
4973 Mock::given(method("GET"))
4974 .and(path("/check-runs/page2"))
4975 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4976 "total_count": 3,
4977 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4978 })))
4979 .expect(1)
4980 .mount(&server)
4981 .await;
4982
4983 let (gh, _guard) = make_client();
4984 let url = format!("{}/check-runs", server.uri());
4985 let result: Vec<serde_json::Value> = gh
4986 .get_all_pages_from_wrapper(&url, "check_runs")
4987 .await
4988 .unwrap();
4989
4990 assert_eq!(result.len(), 3);
4991 assert_eq!(result[0]["id"], 1);
4992 assert_eq!(result[1]["id"], 2);
4993 assert_eq!(result[2]["id"], 3);
4994 }
4995
4996 #[tokio::test]
4997 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4998 let server = MockServer::start().await;
5001
5002 Mock::given(method("GET"))
5003 .and(path("/check-runs"))
5004 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5005 "total_count": 0,
5006 "check_runs": []
5007 })))
5008 .expect(1)
5009 .mount(&server)
5010 .await;
5011
5012 let (gh, _guard) = make_client();
5013 let url = format!("{}/check-runs", server.uri());
5014 let result: Vec<serde_json::Value> = gh
5015 .get_all_pages_from_wrapper(&url, "check_runs")
5016 .await
5017 .unwrap();
5018
5019 assert_eq!(result.len(), 0);
5020 }
5021
5022 #[tokio::test]
5023 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
5024 let server = MockServer::start().await;
5025
5026 Mock::given(method("GET"))
5027 .and(path("/check-runs"))
5028 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
5029 .expect(1)
5030 .mount(&server)
5031 .await;
5032
5033 let (gh, _guard) = make_client();
5034 let url = format!("{}/check-runs", server.uri());
5035 let result = gh
5036 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
5037 .await;
5038
5039 assert!(result.is_err());
5040 let err = result.unwrap_err().to_string();
5041 assert!(
5042 err.contains("422") || err.contains("failed"),
5043 "error should indicate failure: {err}"
5044 );
5045 }
5046 }
5047
5048 #[cfg(test)]
5051 mod auth_retry {
5052 use super::test_client;
5053 use crate::github::token;
5054 use std::env;
5055 use std::sync::Arc;
5056 use std::sync::atomic::{AtomicUsize, Ordering};
5057 use wiremock::matchers::method;
5058 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
5059
5060 fn bad_credentials() -> ResponseTemplate {
5061 ResponseTemplate::new(401).set_body_json(serde_json::json!({
5062 "message": "Bad credentials",
5063 "status": 401
5064 }))
5065 }
5066
5067 #[expect(
5068 clippy::await_holding_lock,
5069 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5070 )]
5071 #[tokio::test]
5072 async fn retries_once_with_fresh_token_after_401() {
5073 let _env_guard = token::ENV_LOCK.lock().unwrap();
5074 let prev = env::var_os("GH_TOKEN");
5075 unsafe { env::set_var("GH_TOKEN", "bad_token") };
5077
5078 let server = MockServer::start().await;
5079 let hits = Arc::new(AtomicUsize::new(0));
5080 let hits_in_handler = Arc::clone(&hits);
5081 Mock::given(method("GET"))
5082 .respond_with(move |_req: &Request| {
5083 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
5084 unsafe { env::set_var("GH_TOKEN", "good_token") };
5088 bad_credentials()
5089 } else {
5090 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
5091 }
5092 })
5093 .mount(&server)
5094 .await;
5095
5096 let gh = test_client();
5097 let result: anyhow::Result<serde_json::Value> =
5098 gh.get_json(&format!("{}/x", server.uri())).await;
5099
5100 match prev {
5101 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
5103 None => unsafe { env::remove_var("GH_TOKEN") },
5105 }
5106
5107 assert!(
5108 result.is_ok(),
5109 "retry with re-resolved token should succeed: {:?}",
5110 result.err()
5111 );
5112 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
5113 }
5114
5115 #[expect(
5116 clippy::await_holding_lock,
5117 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5118 )]
5119 #[tokio::test]
5120 async fn double_401_bails_with_distinct_auth_error() {
5121 let _env_guard = token::ENV_LOCK.lock().unwrap();
5122 let prev = env::var_os("GH_TOKEN");
5123 unsafe { env::set_var("GH_TOKEN", "bad_token") };
5125
5126 let server = MockServer::start().await;
5127 let hits = Arc::new(AtomicUsize::new(0));
5128 let hits_in_handler = Arc::clone(&hits);
5129 Mock::given(method("GET"))
5130 .respond_with(move |_req: &Request| {
5131 hits_in_handler.fetch_add(1, Ordering::SeqCst);
5132 bad_credentials()
5133 })
5134 .mount(&server)
5135 .await;
5136
5137 let gh = test_client();
5138 let result: anyhow::Result<serde_json::Value> =
5139 gh.get_json(&format!("{}/x", server.uri())).await;
5140
5141 match prev {
5142 Some(v) => unsafe { env::set_var("GH_TOKEN", v) },
5144 None => unsafe { env::remove_var("GH_TOKEN") },
5146 }
5147
5148 let err = result.expect_err("persisting 401 must fail").to_string();
5149 assert!(
5150 err.contains("token re-resolved after cache clear but still rejected"),
5151 "expected distinct auth-failure error, got: {err}"
5152 );
5153 assert_eq!(
5154 hits.load(Ordering::SeqCst),
5155 2,
5156 "must retry exactly once, not loop"
5157 );
5158 }
5159 }
5160}