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