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