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::{Client, Response, StatusCode, header};
19use serde::Serialize;
20use std::sync::{
21 Arc, Mutex, OnceLock,
22 atomic::{AtomicU64, Ordering},
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 && remaining < self.throttle_threshold
226 && let Some(reset_epoch) = self.reset_at
227 {
228 let now_epoch = std::time::SystemTime::now()
229 .duration_since(std::time::UNIX_EPOCH)
230 .unwrap_or_default()
231 .as_secs();
232 if now_epoch < reset_epoch {
233 return Some(Duration::from_secs(reset_epoch - now_epoch + 1));
234 }
235 }
236 None
237 }
238
239 fn is_active(&self) -> Option<Duration> {
241 self.backoff_remaining()
242 .or_else(|| self.proactive_wait_duration())
243 }
244}
245
246static REST_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
249 std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
250
251static GRAPHQL_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
253 std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
254
255static METRIC_RATE_LIMIT_HITS: AtomicU64 = AtomicU64::new(0);
259static METRIC_PROACTIVE_THROTTLES: AtomicU64 = AtomicU64::new(0);
261static METRIC_5XX_HITS: AtomicU64 = AtomicU64::new(0);
263
264fn check_rate_limit_backoff(cell: &Mutex<RateLimit>, api: &str) -> anyhow::Result<()> {
274 let Some(remaining) = cell.lock().ok().and_then(|rl| rl.is_active()) else {
275 return Ok(());
276 };
277 METRIC_PROACTIVE_THROTTLES.fetch_add(1, Ordering::Relaxed);
278 anyhow::bail!(
279 "GitHub {} API rate-limited, skipping call for {}s",
280 api,
281 remaining.as_secs()
282 );
283}
284
285static CALL_COUNTS: std::sync::LazyLock<Mutex<std::collections::HashMap<(String, String), u64>>> =
287 std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
288
289fn count_api_call(url: &str) {
292 let path = url.split('?').next().unwrap_or(url);
293 let key = match path.split_once("/repos/") {
294 Some((_, rest)) => {
295 let mut parts = rest.split('/');
296 let project = format!(
297 "{}/{}",
298 parts.next().unwrap_or(""),
299 parts.next().unwrap_or("")
300 );
301 (project, parts.next().unwrap_or("repo").to_string())
302 }
303 None if path.ends_with("/graphql") => ("-".to_string(), "graphql".to_string()),
304 None => ("-".to_string(), "other".to_string()),
305 };
306 if let Ok(mut counts) = CALL_COUNTS.lock() {
307 *counts.entry(key).or_insert(0) += 1;
308 }
309}
310
311pub fn drain_call_counts() -> Vec<(String, String, u64)> {
313 let mut rows: Vec<_> = CALL_COUNTS
314 .lock()
315 .map(|mut c| c.drain().map(|((p, k), n)| (p, k, n)).collect())
316 .unwrap_or_default();
317 rows.sort_by_key(|r| std::cmp::Reverse(r.2));
318 rows
319}
320
321struct CachedPage {
325 etag: String,
326 body: String,
327 next: Option<String>,
329}
330
331static PAGE_CACHE: std::sync::LazyLock<Mutex<std::collections::HashMap<String, CachedPage>>> =
339 std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
340
341const PAGE_CACHE_MAX: usize = 64;
344
345fn page_cache_key<T>(url: &str, query: Option<&[(&str, &str)]>) -> Option<String> {
349 let mut key = String::from(std::any::type_name::<T>());
350 key.push(' ');
351 key.push_str(url);
352 if let Some(q) = query {
353 for (i, (k, v)) in q.iter().enumerate() {
354 key.push(if i == 0 { '?' } else { '&' });
355 key.push_str(k);
356 key.push('=');
357 key.push_str(v);
358 }
359 }
360 if key.contains("since=") {
361 return None;
362 }
363 Some(key)
364}
365
366fn page_cache_etag(key: &str) -> Option<String> {
367 PAGE_CACHE.lock().ok()?.get(key).map(|c| c.etag.clone())
368}
369
370fn page_cache_hit(key: &str) -> Option<(String, Option<String>)> {
372 let cache = PAGE_CACHE.lock().ok()?;
373 let entry = cache.get(key)?;
374 Some((entry.body.clone(), entry.next.clone()))
375}
376
377fn page_cache_store(key: String, etag: String, body: String, next: Option<String>) {
378 let Ok(mut cache) = PAGE_CACHE.lock() else {
379 return;
380 };
381 if cache.len() >= PAGE_CACHE_MAX && !cache.contains_key(&key) {
384 cache.clear();
385 }
386 cache.insert(key, CachedPage { etag, body, next });
387}
388
389fn maybe_record_body_rate_limit(cell: &Mutex<RateLimit>, status: StatusCode, body: &str) {
392 if status == StatusCode::FORBIDDEN {
393 let lower = body.to_lowercase();
394 if (lower.contains("rate limit")
395 || lower.contains("abuse detection")
396 || lower.contains("secondary rate"))
397 && let Ok(mut rl) = cell.lock()
398 {
399 rl.record_rate_limit();
400 }
401 }
402}
403
404fn record_response_for_limiter(cell: &Mutex<RateLimit>, resp: &Response) {
406 if let Ok(mut rl) = cell.lock() {
407 rl.update_from_headers(resp.headers());
408 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
411 rl.record_rate_limit();
412 } else if resp.status().is_success() || resp.status() == StatusCode::NOT_MODIFIED {
413 rl.record_success();
414 }
415 }
416 let status = resp.status();
418 if status.is_server_error() {
419 METRIC_5XX_HITS.fetch_add(1, Ordering::Relaxed);
420 if tokio::runtime::Handle::try_current().is_ok() {
421 tokio::spawn(async move {
422 crate::engine::cooldown::record_github_5xx().await;
423 });
424 }
425 }
426}
427
428#[derive(Clone)]
435pub struct GhHttp {
436 client: Client,
437 token_resolver: Arc<token::TokenResolver>,
438 app: Option<Arc<super::app::AppAuth>>,
440 owner_only: bool,
442 cached_username: OnceLock<String>,
443 #[cfg(test)]
444 api_base: Option<String>,
445}
446
447impl std::fmt::Debug for GhHttp {
448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 f.debug_struct("GhHttp")
450 .field("owner_only", &self.owner_only)
451 .finish_non_exhaustive()
452 }
453}
454
455impl GhHttp {
456 pub fn new() -> anyhow::Result<Self> {
464 let client = Client::builder()
465 .user_agent("orch/0.1 (reqwest)")
466 .pool_max_idle_per_host(4)
467 .timeout(Duration::from_secs(30))
468 .build()
469 .map_err(|e| anyhow::anyhow!("failed to build HTTP client (TLS init): {e}"))?;
470
471 Ok(Self {
472 client,
473 token_resolver: token::shared(),
474 app: None,
475 owner_only: false,
476 cached_username: OnceLock::new(),
477 #[cfg(test)]
478 api_base: None,
479 })
480 }
481
482 pub fn owner() -> anyhow::Result<Self> {
485 let mut gh = Self::new()?;
486 gh.owner_only = true;
487 Ok(gh)
488 }
489
490 #[cfg(test)]
491 pub(crate) fn for_test(api_base: String, app: Option<Arc<super::app::AppAuth>>) -> Self {
492 Self {
493 client: Client::new(),
494 token_resolver: Arc::new(token::TokenResolver::default_env()),
495 app,
496 owner_only: false,
497 cached_username: OnceLock::new(),
498 api_base: Some(api_base),
499 }
500 }
501
502 #[cfg_attr(
503 not(test),
504 expect(
505 clippy::unused_self,
506 reason = "only the test build reads the api base override"
507 )
508 )]
509 fn api_root(&self) -> &str {
510 #[cfg(test)]
511 if let Some(base) = &self.api_base {
512 return base;
513 }
514 GITHUB_API
515 }
516
517 pub fn is_rate_limited() -> Option<Duration> {
522 REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.is_active())
523 }
524
525 fn check_backoff(url: &str) -> anyhow::Result<()> {
527 count_api_call(url);
528 check_rate_limit_backoff(&REST_RATE_LIMIT, "REST")
529 }
530
531 fn check_graphql_backoff(url: &str) -> anyhow::Result<()> {
533 count_api_call(url);
534 check_rate_limit_backoff(&GRAPHQL_RATE_LIMIT, "GraphQL")
535 }
536
537 fn record_response(resp: &Response) {
538 record_response_for_limiter(&REST_RATE_LIMIT, resp);
539 }
540
541 fn record_graphql_response(resp: &Response) {
542 record_response_for_limiter(&GRAPHQL_RATE_LIMIT, resp);
543 }
544
545 fn maybe_record_rate_limit_from_body(status: StatusCode, body: &str) {
547 maybe_record_body_rate_limit(&REST_RATE_LIMIT, status, body);
548 }
549
550 fn maybe_record_graphql_rate_limit_from_body(status: StatusCode, body: &str) {
552 maybe_record_body_rate_limit(&GRAPHQL_RATE_LIMIT, status, body);
553 }
554
555 fn record_success() {
556 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
557 rl.record_success();
558 }
559 }
560
561 fn app(&self) -> Option<Arc<super::app::AppAuth>> {
564 if self.owner_only {
565 return None;
566 }
567
568 self.app.clone()
571 }
572
573 async fn auth_header(&self, url: &str) -> anyhow::Result<String> {
576 if let (Some(app), Some(repo)) = (self.app(), repo_from_url(url)) {
577 match app.token_for_repo(&repo).await {
578 Ok(Some(token)) => return Ok(format!("Bearer {token}")),
579 Ok(None) => {}
580 Err(e) => {
581 tracing::warn!(error = %e, repo, "GitHub App token unavailable, using the owner token");
582 }
583 }
584 }
585 match self.token_resolver.get_token().await? {
586 Some(token) if !token.is_empty() => Ok(format!("Bearer {token}")),
587 _ => anyhow::bail!("No GitHub token available — run `gh auth login` or set GH_TOKEN"),
588 }
589 }
590
591 async fn send_with_retries<F>(
594 &self,
595 mut make_req: F,
596 is_graphql: bool,
597 ) -> anyhow::Result<Response>
598 where
599 F: FnMut() -> anyhow::Result<reqwest::RequestBuilder>,
600 {
601 if engine_cooldown::is_agent_in_cooldown("github:5xx") {
603 if let Some(until) = engine_cooldown::cooldown_until("github:5xx") {
604 let now = chrono::Utc::now().timestamp();
605 let remaining = if until > now { until - now } else { 0 };
606 anyhow::bail!("GitHub API transient 5xx circuit-breaker active for {remaining}s");
607 }
608 anyhow::bail!("GitHub API transient 5xx circuit-breaker active");
609 }
610
611 let attempts = crate::config::get("gh.5xx.retry_attempts")
613 .ok()
614 .and_then(|v| v.parse::<u32>().ok())
615 .unwrap_or(3);
616 let base_secs = crate::config::get("gh.5xx.base_backoff_seconds")
617 .ok()
618 .and_then(|v| v.parse::<u64>().ok())
619 .unwrap_or(1);
620 let max_secs = crate::config::get("gh.5xx.max_backoff_seconds")
621 .ok()
622 .and_then(|v| v.parse::<u64>().ok())
623 .unwrap_or(30);
624 let circuit_cooldown_secs = crate::config::get("gh.5xx.circuit_breaker_cooldown_seconds")
625 .ok()
626 .and_then(|v| v.parse::<u64>().ok())
627 .unwrap_or(300);
628
629 let mut last_err: Option<anyhow::Error> = None;
630 let mut auth_override: Option<String> = None;
633 let mut auth_retried = false;
634 for attempt in 0..attempts {
635 let mut req = make_req()?;
636 if let Some(auth) = &auth_override {
637 req = req.header(header::AUTHORIZATION, auth.clone());
638 }
639 let resp_result = req.send().await;
640
641 match resp_result {
642 Ok(resp) => {
643 if is_graphql {
645 Self::record_graphql_response(&resp);
646 } else {
647 Self::record_response(&resp);
648 }
649
650 if resp.status() == StatusCode::UNAUTHORIZED {
651 let resp_url = resp.url().to_string();
652 let status = resp.status();
653 let body = resp.text().await.unwrap_or_default();
654 if body.contains("Bad credentials") && !auth_retried {
655 tracing::warn!(
656 "GitHub API 401 Bad credentials — clearing cached token and re-resolving"
657 );
658 self.token_resolver.clear_cache().await;
659 if let Some(app) = self.app() {
660 app.invalidate();
661 }
662 match self.auth_header(&resp_url).await {
663 Ok(new_auth) => {
664 auth_retried = true;
665 auth_override = Some(new_auth);
666 }
667 Err(e) => {
668 last_err = Some(
669 e.context("GitHub token re-resolution failed after 401"),
670 );
671 break;
672 }
673 }
674 continue;
676 }
677 if auth_retried {
678 self.token_resolver.clear_cache().await;
681 anyhow::bail!(
682 "GitHub authentication failed ({status}): token re-resolved after cache clear but still rejected"
683 );
684 }
685 anyhow::bail!("GitHub API request failed ({status}): {body}");
686 }
687
688 if resp.status().is_server_error() {
690 let status = resp.status();
691 let headers = resp.headers().clone();
692 let body = resp.text().await.unwrap_or_default();
693 let req_id = headers
694 .get("x-github-request-id")
695 .and_then(|v| v.to_str().ok())
696 .unwrap_or("<none>");
697 let snippet: String = body.chars().take(400).collect();
698 tracing::warn!(
699 status = %status,
700 request_id = %req_id,
701 attempt,
702 "GitHub server error (5xx) — attempt {attempt}: {snippet}"
703 );
704
705 last_err = Some(anyhow::anyhow!(
706 "GitHub API server error ({status}) request_id={req_id}: {snippet}"
707 ));
708
709 if attempt + 1 < attempts {
710 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
713 let mut backoff = base_secs.saturating_mul(exp);
714 if backoff > max_secs {
715 backoff = max_secs;
716 }
717 let jitter_ms = u64::from(attempt) * 100;
719 let sleep_dur = Duration::from_secs(backoff)
720 + Duration::from_millis(jitter_ms.min(1000));
721 tokio::time::sleep(sleep_dur).await;
722 continue;
723 }
724 tracing::warn!(
726 "GitHub API 5xx persisted after {} attempts — setting circuit-breaker for {}s",
727 attempts,
728 circuit_cooldown_secs
729 );
730 engine_cooldown::set_agent_cooldown("github:5xx", circuit_cooldown_secs)
731 .await;
732 break;
733 }
734
735 return Ok(resp);
737 }
738 Err(e) => {
739 tracing::warn!(err = %e, attempt, "HTTP request failed to send (transport error, GitHub not reached), will retry if attempts remain");
746 last_err = Some(anyhow::anyhow!(
747 "HTTP request failed to send (transport error): {e}"
748 ));
749 if attempt + 1 < attempts {
750 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
752 let backoff = base_secs.saturating_mul(exp);
753 let sleep_dur = Duration::from_secs(backoff)
754 + Duration::from_millis((u64::from(attempt) * 100).min(1000));
755 tokio::time::sleep(sleep_dur).await;
756 continue;
757 }
758 tracing::warn!(
762 "HTTP request failed to send after {} attempts (transport error, GitHub not reached) — not tripping the github:5xx circuit-breaker",
763 attempts
764 );
765 break;
766 }
767 }
768 }
769
770 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unexpected send_with_retries failure")))
771 }
772
773 async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> anyhow::Result<T> {
775 Self::check_backoff(url)?;
776 let auth = self.auth_header(url).await?;
777 let make_req = || {
779 Ok(self
780 .client
781 .get(url)
782 .header(header::AUTHORIZATION, auth.clone())
783 .header(header::ACCEPT, "application/vnd.github+json")
784 .header("X-GitHub-Api-Version", "2022-11-28"))
785 };
786 let resp = self.send_with_retries(make_req, false).await?;
787 let status = resp.status();
788 if !status.is_success() {
789 let body = resp.text().await.unwrap_or_default();
790 Self::maybe_record_rate_limit_from_body(status, &body);
791 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
792 }
793 let body = resp.text().await?;
794 serde_json::from_str(&body)
795 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
796 }
797
798 async fn get_bytes(&self, url: &str) -> anyhow::Result<Vec<u8>> {
800 Self::check_backoff(url)?;
801 let auth = self.auth_header(url).await?;
802 let make_req = || {
803 Ok(self
804 .client
805 .get(url)
806 .header(header::AUTHORIZATION, auth.clone())
807 .header(header::ACCEPT, "application/vnd.github+json")
808 .header("X-GitHub-Api-Version", "2022-11-28"))
809 };
810 let resp = self.send_with_retries(make_req, false).await?;
811 let status = resp.status();
812 if !status.is_success() {
813 let body = resp.text().await.unwrap_or_default();
814 Self::maybe_record_rate_limit_from_body(status, &body);
815 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
816 }
817 Ok(resp.bytes().await?.to_vec())
818 }
819
820 async fn get_with_query<T: serde::de::DeserializeOwned>(
822 &self,
823 url: &str,
824 query: &[(&str, &str)],
825 ) -> anyhow::Result<T> {
826 Self::check_backoff(url)?;
827 let auth = self.auth_header(url).await?;
828 let make_req = || {
829 Ok(self
830 .client
831 .get(url)
832 .query(query)
833 .header(header::AUTHORIZATION, auth.clone())
834 .header(header::ACCEPT, "application/vnd.github+json")
835 .header("X-GitHub-Api-Version", "2022-11-28"))
836 };
837 let resp = self.send_with_retries(make_req, false).await?;
838 let status = resp.status();
839 if !status.is_success() {
840 let body = resp.text().await.unwrap_or_default();
841 Self::maybe_record_rate_limit_from_body(status, &body);
842 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
843 }
844 let body = resp.text().await?;
845 serde_json::from_str(&body)
846 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
847 }
848
849 async fn post_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
851 Self::check_backoff(url)?;
852 let auth = self.auth_header(url).await?;
853 let make_req = || {
854 Ok(self
855 .client
856 .post(url)
857 .json(body)
858 .header(header::AUTHORIZATION, auth.clone())
859 .header(header::ACCEPT, "application/vnd.github+json")
860 .header("X-GitHub-Api-Version", "2022-11-28"))
861 };
862 let resp = self.send_with_retries(make_req, false).await?;
863 let status = resp.status();
864 let text_res = resp.text().await;
865 if !status.is_success() {
866 let text = text_res.unwrap_or_default();
867 Self::maybe_record_rate_limit_from_body(status, &text);
868 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
869 }
870 let text = text_res?;
871 Ok(text)
872 }
873
874 async fn post_json<T: serde::de::DeserializeOwned>(
876 &self,
877 url: &str,
878 body: &serde_json::Value,
879 ) -> anyhow::Result<T> {
880 let text = self.post_json_raw(url, body).await?;
881 Ok(serde_json::from_str(&text)?)
882 }
883
884 async fn patch_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
886 Self::check_backoff(url)?;
887 let auth = self.auth_header(url).await?;
888 let make_req = || {
889 Ok(self
890 .client
891 .patch(url)
892 .json(body)
893 .header(header::AUTHORIZATION, auth.clone())
894 .header(header::ACCEPT, "application/vnd.github+json")
895 .header("X-GitHub-Api-Version", "2022-11-28"))
896 };
897 let resp = self.send_with_retries(make_req, false).await?;
898 let status = resp.status();
899 let text_res = resp.text().await;
900 if !status.is_success() {
901 let text = text_res.unwrap_or_default();
902 Self::maybe_record_rate_limit_from_body(status, &text);
903 anyhow::bail!("GitHub API PATCH {url} failed ({status}): {text}");
904 }
905 let text = text_res?;
906 Ok(text)
907 }
908
909 async fn delete(&self, url: &str) -> anyhow::Result<StatusCode> {
911 Self::check_backoff(url)?;
912 let auth = self.auth_header(url).await?;
913 let make_req = || {
914 Ok(self
915 .client
916 .delete(url)
917 .header(header::AUTHORIZATION, auth.clone())
918 .header(header::ACCEPT, "application/vnd.github+json")
919 .header("X-GitHub-Api-Version", "2022-11-28"))
920 };
921 let resp = self.send_with_retries(make_req, false).await?;
922 let status = resp.status();
923 if !status.is_success() && status != StatusCode::NOT_FOUND {
924 let body = resp.text().await.unwrap_or_default();
925 Self::maybe_record_rate_limit_from_body(status, &body);
926 anyhow::bail!("GitHub API DELETE {url} failed ({status}): {body}");
927 }
928 Ok(status)
929 }
930
931 async fn get_all_pages<T: serde::de::DeserializeOwned>(
936 &self,
937 url: &str,
938 query: &[(&str, &str)],
939 ) -> anyhow::Result<Vec<T>> {
940 const MAX_JSON_DECODE_RETRIES: u32 = 2;
941 const JSON_DECODE_RETRY_BACKOFF_MS: u64 = 200;
942 Self::check_backoff(url)?;
943 let mut all: Vec<T> = Vec::new();
944 let mut next_url: Option<String> = None;
945 let mut is_first = true;
946
947 loop {
948 let (request_url, request_query) = if is_first {
949 is_first = false;
950 (url, Some(query))
951 } else {
952 let Some(u) = next_url.as_ref() else { break };
953 (u.as_str(), None)
954 };
955
956 let cache_key = page_cache_key::<T>(request_url, request_query);
957 let mut cached_etag = cache_key.as_deref().and_then(page_cache_etag);
958 let mut decode_attempt = 0;
959 let (page, next) = loop {
960 let auth = self.auth_header(request_url).await?;
961 let mut req = self
962 .client
963 .get(request_url)
964 .header(header::AUTHORIZATION, auth)
965 .header(header::ACCEPT, "application/vnd.github+json")
966 .header("X-GitHub-Api-Version", "2022-11-28");
967 if let Some(q) = request_query {
968 req = req.query(q);
969 }
970 if let Some(etag) = &cached_etag {
971 req = req.header(header::IF_NONE_MATCH, etag.clone());
972 }
973
974 let make_req = || {
975 req.try_clone()
976 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
977 };
978 let resp = self.send_with_retries(make_req, false).await?;
979 let status = resp.status();
980 let next_from_headers = parse_link_next(resp.headers());
981 let etag = resp
982 .headers()
983 .get(header::ETAG)
984 .and_then(|v| v.to_str().ok())
985 .map(std::string::ToString::to_string);
986 let content_type = resp
987 .headers()
988 .get(header::CONTENT_TYPE)
989 .and_then(|v| v.to_str().ok())
990 .map_or_else(|| "<missing>".to_string(), std::string::ToString::to_string);
991
992 if status == StatusCode::NOT_MODIFIED {
993 if let Some((body, next)) = cache_key.as_deref().and_then(page_cache_hit) {
994 break (serde_json::from_str::<Vec<T>>(&body)?, next);
995 }
996 cached_etag = None;
1001 continue;
1002 }
1003
1004 if !status.is_success() {
1005 let body = resp.text().await.unwrap_or_default();
1006 Self::maybe_record_rate_limit_from_body(status, &body);
1007 anyhow::bail!("GitHub API GET (paginated) failed ({status}): {body}");
1008 }
1009
1010 let text = resp.text().await?;
1011 match serde_json::from_str::<Vec<T>>(&text) {
1012 Ok(page) => {
1013 if let (Some(key), Some(etag)) = (cache_key.clone(), etag) {
1014 page_cache_store(key, etag, text, next_from_headers.clone());
1015 }
1016 break (page, next_from_headers);
1017 }
1018 Err(err) => {
1019 let snippet: String = text.chars().take(200).collect();
1020 let decode_error = anyhow::anyhow!(
1021 "GitHub API GET (paginated) JSON decode failed ({status}, content-type={content_type}): {snippet}"
1022 )
1023 .context(err);
1024 if decode_attempt < MAX_JSON_DECODE_RETRIES {
1025 decode_attempt += 1;
1026 tracing::warn!(
1027 attempt = decode_attempt,
1028 max_attempts = MAX_JSON_DECODE_RETRIES,
1029 status = %status,
1030 content_type,
1031 err = %decode_error,
1032 "json decode failed; retrying paginated GET"
1033 );
1034 tokio::time::sleep(Duration::from_millis(
1035 JSON_DECODE_RETRY_BACKOFF_MS * u64::from(decode_attempt),
1036 ))
1037 .await;
1038 continue;
1039 }
1040 return Err(decode_error);
1041 }
1042 }
1043 };
1044
1045 next_url = next;
1046 all.extend(page);
1047
1048 if next_url.is_none() {
1049 break;
1050 }
1051 }
1052
1053 Ok(all)
1054 }
1055
1056 async fn get_all_pages_from_wrapper<T: serde::de::DeserializeOwned>(
1067 &self,
1068 url: &str,
1069 array_field: &str,
1070 ) -> anyhow::Result<Vec<T>> {
1071 Self::check_backoff(url)?;
1072 let mut all: Vec<T> = Vec::new();
1073 let mut current_url: String = url.to_string();
1074
1075 loop {
1076 let auth = self.auth_header(¤t_url).await?;
1077 let make_req = || {
1078 self.client
1079 .get(¤t_url)
1080 .header(header::AUTHORIZATION, auth.clone())
1081 .header(header::ACCEPT, "application/vnd.github+json")
1082 .header("X-GitHub-Api-Version", "2022-11-28")
1083 .try_clone()
1084 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
1085 };
1086 let resp = self.send_with_retries(make_req, false).await?;
1087 let status = resp.status();
1088 let next = parse_link_next(resp.headers());
1089
1090 if !status.is_success() {
1091 let body = resp.text().await.unwrap_or_default();
1092 Self::maybe_record_rate_limit_from_body(status, &body);
1093 anyhow::bail!(
1094 "GitHub API GET (wrapped paginated) {current_url} failed ({status}): {body}"
1095 );
1096 }
1097
1098 let body = resp.text().await?;
1099 let wrapper: serde_json::Value = serde_json::from_str(&body).with_context(|| {
1100 format!("failed to parse wrapper response from {current_url}: {body}")
1101 })?;
1102 let items = wrapper
1103 .get(array_field)
1104 .and_then(|v| v.as_array())
1105 .cloned()
1106 .unwrap_or_default();
1107
1108 for item in items {
1109 match serde_json::from_value::<T>(item) {
1110 Ok(v) => all.push(v),
1111 Err(e) => {
1112 tracing::warn!(
1113 field = array_field,
1114 err = %e,
1115 "skipping item that failed to deserialize in wrapped pagination"
1116 );
1117 }
1118 }
1119 }
1120
1121 match next {
1122 Some(next_url) => current_url = next_url,
1123 None => break,
1124 }
1125 }
1126
1127 Ok(all)
1128 }
1129
1130 async fn graphql_request(
1132 &self,
1133 query: &str,
1134 extra_headers: &[(&str, &str)],
1135 variables: Option<serde_json::Value>,
1136 ) -> anyhow::Result<serde_json::Value> {
1137 self.graphql_request_to_url(
1138 &format!("{GITHUB_API}/graphql"),
1139 query,
1140 extra_headers,
1141 variables,
1142 )
1143 .await
1144 }
1145
1146 async fn graphql_request_to_url(
1147 &self,
1148 url: &str,
1149 query: &str,
1150 extra_headers: &[(&str, &str)],
1151 variables: Option<serde_json::Value>,
1152 ) -> anyhow::Result<serde_json::Value> {
1153 Self::check_graphql_backoff(url)?;
1154 let body = if let Some(vars) = variables {
1155 serde_json::json!({ "query": query, "variables": vars })
1156 } else {
1157 serde_json::json!({ "query": query })
1158 };
1159 let auth = self.auth_header(url).await?;
1160 let mut req = self
1161 .client
1162 .post(url)
1163 .json(&body)
1164 .header(header::AUTHORIZATION, auth)
1165 .header(header::ACCEPT, "application/vnd.github+json")
1166 .header("X-GitHub-Api-Version", "2022-11-28");
1167
1168 for (k, v) in extra_headers {
1169 req = req.header(*k, *v);
1170 }
1171
1172 let make_req = || {
1174 req.try_clone()
1175 .ok_or_else(|| anyhow::anyhow!("graphql request clone failed"))
1176 };
1177 let resp = self.send_with_retries(make_req, true).await?;
1178 let status = resp.status();
1179 let text_res = resp.text().await;
1180 if !status.is_success() {
1181 let text = text_res.unwrap_or_default();
1182 Self::maybe_record_graphql_rate_limit_from_body(status, &text);
1183 anyhow::bail!("GitHub GraphQL failed ({status}): {text}");
1184 }
1185 let text = text_res?;
1186 let json: serde_json::Value = serde_json::from_str(&text)?;
1187 if let Some(errors) = json.get("errors").and_then(|e| e.as_array())
1189 && !errors.is_empty()
1190 {
1191 let messages: Vec<&str> = errors
1192 .iter()
1193 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1194 .collect();
1195 if json.get("data").is_none() || json["data"].is_null() {
1196 anyhow::bail!("GitHub GraphQL errors: {}", messages.join("; "));
1198 }
1199 tracing::warn!(errors = ?messages, "GitHub GraphQL returned partial errors");
1201 }
1202 Ok(json)
1203 }
1204
1205 pub async fn graphql_with_vars(
1208 &self,
1209 query: &str,
1210 variables: serde_json::Value,
1211 ) -> anyhow::Result<serde_json::Value> {
1212 self.graphql_request(query, &[], Some(variables)).await
1213 }
1214
1215 pub async fn graphql_with_headers(
1216 &self,
1217 query: &str,
1218 headers: &[&str],
1219 ) -> anyhow::Result<serde_json::Value> {
1220 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1222 self.graphql_request(query, &pairs, None).await
1223 }
1224
1225 pub async fn graphql_with_headers_and_vars(
1226 &self,
1227 query: &str,
1228 headers: &[&str],
1229 variables: serde_json::Value,
1230 ) -> anyhow::Result<serde_json::Value> {
1231 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1232 self.graphql_request(query, &pairs, Some(variables)).await
1233 }
1234
1235 pub async fn auth_status(&self) -> anyhow::Result<()> {
1240 match self
1241 .get_json::<serde_json::Value>(&format!("{GITHUB_API}/user"))
1242 .await
1243 {
1244 Ok(_) => Ok(()),
1245 Err(e) => {
1246 let err_str = e.to_string();
1247
1248 if err_str.contains("401") || err_str.contains("Bad credentials") {
1250 anyhow::bail!(
1251 "GitHub authentication failed: Invalid or expired token. \
1252 Check your GH_TOKEN/GITHUB_TOKEN environment variables, \
1253 or gh.auth configuration in ~/.orch/config.yml. \
1254 Run `orch auth check` to verify your setup."
1255 );
1256 }
1257
1258 if err_str.contains("403") && err_str.contains("rate limit") {
1259 anyhow::bail!(
1260 "GitHub API rate limited. Wait before retrying, \
1261 or check your token's rate limit quota."
1262 );
1263 }
1264
1265 Err(e)
1266 }
1267 }
1268 }
1269
1270 pub async fn create_issue(
1272 &self,
1273 repo: &str,
1274 title: &str,
1275 body: &str,
1276 labels: &[String],
1277 ) -> anyhow::Result<GitHubIssue> {
1278 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1279 let payload = serde_json::json!({
1280 "title": title,
1281 "body": body,
1282 "labels": labels,
1283 });
1284 self.post_json(&url, &payload).await
1285 }
1286
1287 pub async fn update_issue(
1291 &self,
1292 repo: &str,
1293 number: u64,
1294 title: &str,
1295 body: &str,
1296 labels: &[String],
1297 ) -> anyhow::Result<GitHubIssue> {
1298 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
1299 let payload = serde_json::json!({
1300 "title": title,
1301 "body": body,
1302 "labels": labels,
1303 });
1304 let text = self.patch_json_raw(&url, &payload).await?;
1305 serde_json::from_str(&text)
1306 .with_context(|| format!("failed to parse issue update response: {text}"))
1307 }
1308
1309 pub async fn list_blocked_by(
1317 &self,
1318 repo: &str,
1319 issue_number: u64,
1320 ) -> anyhow::Result<crate::backends::BlockedByFetch> {
1321 use crate::backends::{BlockedByFetch, BlockedByIssue};
1322
1323 let mut next = Some(format!(
1324 "{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by?per_page=100"
1325 ));
1326 let mut found = Vec::new();
1327 while let Some(url) = next.take() {
1328 let resp = match self
1329 .dependency_request(&url, reqwest::Method::GET, None)
1330 .await
1331 {
1332 Ok(resp) => resp,
1333 Err(err) => return Ok(BlockedByFetch::Failed(err.to_string())),
1334 };
1335 let status = resp.status();
1336 if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
1337 let body = resp.text().await.unwrap_or_default();
1338 if status == StatusCode::FORBIDDEN
1339 && body.to_ascii_lowercase().contains("rate limit")
1340 {
1341 Self::maybe_record_rate_limit_from_body(status, &body);
1342 return Ok(BlockedByFetch::Failed(body));
1343 }
1344 return Ok(BlockedByFetch::Unavailable);
1345 }
1346 if !status.is_success() {
1347 let body = resp.text().await.unwrap_or_default();
1348 Self::maybe_record_rate_limit_from_body(status, &body);
1349 return Ok(BlockedByFetch::Failed(format!("{status}: {body}")));
1350 }
1351 Self::record_success();
1352 let page_next = parse_link_next(resp.headers());
1353 let body = resp.text().await.unwrap_or_default();
1354 let page: Vec<BlockedByPayload> = match serde_json::from_str(&body) {
1355 Ok(page) => page,
1356 Err(err) => {
1357 return Ok(BlockedByFetch::Failed(format!(
1358 "blocked_by parse failed: {err}"
1359 )));
1360 }
1361 };
1362 for item in page {
1363 if item.number <= 0 {
1364 continue;
1365 }
1366 let item_repo = item
1367 .repository
1368 .as_ref()
1369 .map(|r| r.full_name.clone())
1370 .filter(|name| !name.is_empty())
1371 .unwrap_or_else(|| repo.to_string());
1372 found.push(BlockedByIssue {
1373 github_id: item.id,
1374 repo: item_repo,
1375 number: item.number,
1376 state: item.state,
1377 state_reason: item.state_reason,
1378 });
1379 }
1380 next = page_next;
1381 }
1382 Ok(BlockedByFetch::Native(found))
1383 }
1384
1385 pub async fn add_blocked_by(
1389 &self,
1390 repo: &str,
1391 issue_number: u64,
1392 blocking_issue_id: i64,
1393 ) -> anyhow::Result<()> {
1394 let url =
1395 format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by");
1396 let resp = self
1397 .dependency_request(
1398 &url,
1399 reqwest::Method::POST,
1400 Some(serde_json::json!({ "issue_id": blocking_issue_id })),
1401 )
1402 .await?;
1403 let status = resp.status();
1404 if status.is_success() {
1405 Self::record_success();
1406 return Ok(());
1407 }
1408 let body = resp.text().await.unwrap_or_default();
1409 if body.to_ascii_lowercase().contains("already") {
1410 return Ok(());
1411 }
1412 Self::maybe_record_rate_limit_from_body(status, &body);
1413 anyhow::bail!("add blocked_by failed ({status}): {body}")
1414 }
1415
1416 pub async fn remove_blocked_by(
1418 &self,
1419 repo: &str,
1420 number: u64,
1421 blocking_issue_id: u64,
1422 ) -> anyhow::Result<()> {
1423 let url = format!(
1424 "{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
1425 );
1426 self.delete(&url).await?;
1427 Ok(())
1428 }
1429
1430 async fn dependency_request(
1432 &self,
1433 url: &str,
1434 method: reqwest::Method,
1435 body: Option<serde_json::Value>,
1436 ) -> anyhow::Result<Response> {
1437 Self::check_backoff(url)?;
1438 let auth = self.auth_header(url).await?;
1439 let make_req = || {
1440 let mut req = self
1441 .client
1442 .request(method.clone(), url)
1443 .header(header::AUTHORIZATION, auth.clone())
1444 .header(header::ACCEPT, "application/vnd.github+json")
1445 .header("X-GitHub-Api-Version", "2026-03-10");
1446 if let Some(ref payload) = body {
1447 req = req.json(payload);
1448 }
1449 Ok(req)
1450 };
1451 self.send_with_retries(make_req, false).await
1452 }
1453
1454 pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1456 self.get_json(&format!("{}/repos/{repo}/issues/{number}", self.api_root()))
1457 .await
1458 }
1459
1460 pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1462 self.get_issue(repo, number)
1463 .await
1464 .is_ok_and(|i| i.pull_request.is_some())
1465 }
1466
1467 pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1469 self.list_issues_with_state(repo, label, "open").await
1470 }
1471
1472 pub async fn list_issues_with_state(
1476 &self,
1477 repo: &str,
1478 label: &str,
1479 state: &str,
1480 ) -> anyhow::Result<Vec<GitHubIssue>> {
1481 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1482 let mut query = vec![("state", state), ("per_page", "100")];
1484 if !label.is_empty() {
1485 query.push(("labels", label));
1486 }
1487 let all: Vec<GitHubIssue> = self.get_all_pages(&url, &query).await?;
1488 Ok(all
1490 .into_iter()
1491 .filter(|i| i.pull_request.is_none())
1492 .collect())
1493 }
1494
1495 pub async fn list_issues_closed_since(
1499 &self,
1500 repo: &str,
1501 label: &str,
1502 since: &str,
1503 ) -> anyhow::Result<Vec<GitHubIssue>> {
1504 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1505 let all: Vec<GitHubIssue> = self
1506 .get_all_pages(
1507 &url,
1508 &[
1509 ("labels", label),
1510 ("state", "closed"),
1511 ("since", since),
1512 ("per_page", "100"),
1513 ],
1514 )
1515 .await?;
1516 Ok(all
1517 .into_iter()
1518 .filter(|i| i.pull_request.is_none())
1519 .collect())
1520 }
1521
1522 pub async fn list_closed_issues_since(
1524 &self,
1525 repo: &str,
1526 since: &str,
1527 ) -> anyhow::Result<Vec<GitHubIssue>> {
1528 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1529 let all: Vec<GitHubIssue> = self
1530 .get_all_pages(
1531 &url,
1532 &[("state", "closed"), ("since", since), ("per_page", "100")],
1533 )
1534 .await?;
1535 Ok(all
1536 .into_iter()
1537 .filter(|i| i.pull_request.is_none())
1538 .collect())
1539 }
1540
1541 pub async fn list_all_open_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1546 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1547 let params = [("state", "open"), ("per_page", "100")];
1548 let all: Vec<GitHubIssue> = self.get_all_pages(&url, ¶ms).await?;
1549 Ok(all
1551 .into_iter()
1552 .filter(|i| i.pull_request.is_none())
1553 .collect())
1554 }
1555
1556 pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1559 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1560 let all: Vec<serde_json::Value> = self
1561 .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1562 .await?;
1563 Ok(all)
1564 }
1565
1566 pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1570 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1571 let all: Vec<GitHubIssue> = self
1572 .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1573 .await?;
1574 Ok(all
1576 .into_iter()
1577 .filter(|i| i.pull_request.is_none())
1578 .collect())
1579 }
1580
1581 pub async fn add_labels(
1583 &self,
1584 repo: &str,
1585 number: &str,
1586 labels: &[String],
1587 ) -> anyhow::Result<()> {
1588 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1589 let payload = serde_json::json!({ "labels": labels });
1590 self.post_json_raw(&url, &payload).await?;
1591 Ok(())
1592 }
1593
1594 pub async fn ensure_label(
1596 &self,
1597 repo: &str,
1598 name: &str,
1599 color: &str,
1600 description: &str,
1601 ) -> anyhow::Result<()> {
1602 let encoded = urlencoding::encode(name);
1603 let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1604
1605 match self.get_bytes(&get_url).await {
1607 Ok(_) => return Ok(()),
1608 Err(e) if e.to_string().contains("404") => {
1609 tracing::debug!(repo, name, "ensure_label: label not found, creating");
1610 }
1611 Err(e) => {
1612 tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1613 }
1614 }
1615
1616 let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1618 let payload = serde_json::json!({
1619 "name": name,
1620 "color": color,
1621 "description": description,
1622 });
1623
1624 match self.post_json_raw(&create_url, &payload).await {
1625 Ok(_) => {
1626 Self::record_success();
1627 }
1628 Err(e) => {
1629 let msg = e.to_string();
1630 if msg.contains("422")
1631 || msg.contains("already_exists")
1632 || msg.contains("Unprocessable")
1633 {
1634 tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1635 return Ok(());
1636 }
1637 tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1639 }
1640 }
1641 Ok(())
1642 }
1643
1644 pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1646 let encoded = urlencoding::encode(label);
1647 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1648 match self.delete(&url).await {
1649 Ok(_) => Ok(()),
1650 Err(e) if e.to_string().contains("404") => {
1651 tracing::debug!(repo, number, label, "label already removed (404)");
1652 Ok(())
1653 }
1654 Err(e) => Err(e),
1655 }
1656 }
1657
1658 pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1660 let body = redact_posted_body(body);
1661 let url = format!("{}/repos/{repo}/issues/{number}/comments", self.api_root());
1662 let payload = serde_json::json!({ "body": body });
1663 self.post_json_raw(&url, &payload).await?;
1664 Ok(())
1665 }
1666
1667 pub async fn post_commit_status(
1669 &self,
1670 repo: &str,
1671 sha: &str,
1672 state: &str,
1673 context: &str,
1674 description: &str,
1675 ) -> anyhow::Result<()> {
1676 let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1677 let payload = serde_json::json!({
1678 "state": state,
1679 "context": context,
1680 "description": description,
1681 });
1682 self.post_json_raw(&url, &payload).await?;
1683 Ok(())
1684 }
1685
1686 pub async fn add_reaction(
1690 &self,
1691 repo: &str,
1692 number: &str,
1693 content: &str,
1694 ) -> anyhow::Result<()> {
1695 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1696 let payload = serde_json::json!({ "content": content });
1697 self.post_json_raw(&url, &payload).await?;
1698 Ok(())
1699 }
1700
1701 pub async fn add_comment_reaction(
1705 &self,
1706 repo: &str,
1707 comment_id: &str,
1708 content: &str,
1709 ) -> anyhow::Result<()> {
1710 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1711 let payload = serde_json::json!({ "content": content });
1712 self.post_json_raw(&url, &payload).await?;
1713 Ok(())
1714 }
1715
1716 pub async fn list_comments(
1718 &self,
1719 repo: &str,
1720 number: &str,
1721 ) -> anyhow::Result<Vec<GitHubComment>> {
1722 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1723 self.get_all_pages(&url, &[("per_page", "100")]).await
1724 }
1725
1726 pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1728 let owner = repo
1729 .split('/')
1730 .next()
1731 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1732 let head = format!("{owner}:{branch}");
1733
1734 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1735 let prs: Vec<serde_json::Value> = self
1736 .get_with_query(
1737 &url,
1738 &[("head", &head), ("state", "closed"), ("per_page", "1")],
1739 )
1740 .await?;
1741
1742 if prs.is_empty() {
1743 return Ok(false);
1744 }
1745 let merged_at = prs[0].get("merged_at");
1746 Ok(merged_at.is_some_and(|v| !v.is_null()))
1747 }
1748
1749 pub async fn get_closed_pr_state(
1762 &self,
1763 repo: &str,
1764 branch: &str,
1765 ) -> anyhow::Result<Option<(u64, bool, String)>> {
1766 let owner = repo
1767 .split('/')
1768 .next()
1769 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1770 let head = format!("{owner}:{branch}");
1771
1772 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1773 let prs: Vec<serde_json::Value> = self
1774 .get_with_query(
1775 &url,
1776 &[("head", &head), ("state", "closed"), ("per_page", "5")],
1777 )
1778 .await?;
1779
1780 if prs.is_empty() {
1781 return Ok(None);
1782 }
1783
1784 for pr in prs {
1787 let pr_branch = pr
1788 .get("head")
1789 .and_then(|h| h.get("ref"))
1790 .and_then(|r| r.as_str())
1791 .unwrap_or("");
1792
1793 if pr_branch != branch {
1794 tracing::debug!(
1795 repo,
1796 branch,
1797 pr_branch,
1798 "skipping PR with mismatched head ref"
1799 );
1800 continue;
1801 }
1802
1803 let pr_number = pr
1804 .get("number")
1805 .and_then(serde_json::Value::as_u64)
1806 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1807
1808 let merged_at = pr.get("merged_at");
1809 let merged = merged_at.is_some_and(|v| !v.is_null());
1810 let state = pr
1811 .get("state")
1812 .and_then(|v| v.as_str())
1813 .unwrap_or("closed")
1814 .to_string();
1815
1816 return Ok(Some((pr_number, merged, state)));
1817 }
1818
1819 Ok(None)
1821 }
1822
1823 pub async fn batch_is_pr_merged_by_branch(
1828 &self,
1829 repo: &str,
1830 branches: &[String],
1831 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1832 use std::fmt::Write as _;
1833
1834 if branches.is_empty() {
1835 return Ok(std::collections::HashMap::new());
1836 }
1837
1838 let (owner, name) = repo
1839 .split_once('/')
1840 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1841
1842 let mut var_defs = String::new();
1846 let mut aliases = String::new();
1847 let mut vars = serde_json::json!({ "owner": owner, "name": name });
1848 for (i, branch) in branches.iter().enumerate() {
1849 let _ = write!(var_defs, ", $b{i}: String!");
1850 let _ = write!(
1851 aliases,
1852 r"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "
1853 );
1854 vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1855 }
1856
1857 let query = format!(
1858 r"query($owner: String!, $name: String!{var_defs}) {{
1859 repository(owner: $owner, name: $name) {{ {aliases} }}
1860 }}"
1861 );
1862
1863 let resp = self.graphql_with_vars(&query, vars).await?;
1864
1865 if let Some(errors) = resp.get("errors").and_then(|e| e.as_array())
1867 && !errors.is_empty()
1868 {
1869 let messages: Vec<&str> = errors
1870 .iter()
1871 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1872 .collect();
1873 tracing::warn!(
1874 repo,
1875 errors = ?messages,
1876 "GraphQL returned errors in batch PR merge check"
1877 );
1878 }
1879
1880 Self::parse_batch_pr_merged_response(&resp, branches)
1881 }
1882
1883 fn parse_batch_pr_merged_response(
1884 resp: &serde_json::Value,
1885 branches: &[String],
1886 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1887 let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1888 let error_msg = resp
1890 .get("errors")
1891 .and_then(|e| e.as_array())
1892 .and_then(|arr| arr.first())
1893 .and_then(|e| e.get("message"))
1894 .and_then(|m| m.as_str())
1895 .unwrap_or("no error details");
1896 anyhow::anyhow!("missing /data/repository in GraphQL response: {error_msg}")
1897 })?;
1898
1899 let mut result = std::collections::HashMap::new();
1900 for (i, branch) in branches.iter().enumerate() {
1901 let alias = format!("b{i}");
1902 let merged = repo_data
1903 .get(&alias)
1904 .and_then(|v| v.get("nodes"))
1905 .and_then(|nodes| nodes.as_array())
1906 .and_then(|arr| arr.first())
1907 .and_then(|node| node.get("merged"))
1908 .and_then(serde_json::Value::as_bool)
1909 .unwrap_or(false);
1910 result.insert(branch.clone(), merged);
1911 }
1912
1913 Ok(result)
1914 }
1915
1916 pub async fn get_mentions(
1918 &self,
1919 repo: &str,
1920 since: &str,
1921 ) -> anyhow::Result<Vec<GitHubComment>> {
1922 let url = format!("{}/repos/{repo}/issues/comments", self.api_root());
1923 self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1924 .await
1925 }
1926
1927 pub async fn get_issue_comments(
1929 &self,
1930 repo: &str,
1931 issue_number: u64,
1932 ) -> anyhow::Result<Vec<GitHubComment>> {
1933 let url = format!(
1934 "{}/repos/{repo}/issues/{issue_number}/comments",
1935 self.api_root()
1936 );
1937 self.get_all_pages(&url, &[("per_page", "100")]).await
1938 }
1939
1940 pub async fn get_whoami(&self) -> anyhow::Result<String> {
1942 if let Some(cached) = self.cached_username.get() {
1943 return Ok(cached.clone());
1944 }
1945
1946 let mut owner = self.clone();
1948 owner.owner_only = true;
1949 let user: serde_json::Value = owner.get_json(&format!("{GITHUB_API}/user")).await?;
1950 let username = user
1951 .get("login")
1952 .and_then(|v| v.as_str())
1953 .map(String::from)
1954 .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1955
1956 let _ = self.cached_username.set(username.clone());
1957 Ok(username)
1958 }
1959
1960 pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1962 let owner = repo
1963 .split('/')
1964 .next()
1965 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
1966 let head = format!("{owner}:{branch}");
1967
1968 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1969 let prs: Vec<serde_json::Value> = self
1970 .get_with_query(
1971 &url,
1972 &[("head", &head), ("state", "open"), ("per_page", "1")],
1973 )
1974 .await?;
1975
1976 if prs.is_empty() {
1977 return Ok(None);
1978 }
1979 prs[0]
1980 .get("number")
1981 .and_then(serde_json::Value::as_u64)
1982 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1983 .map(Some)
1984 }
1985
1986 pub async fn create_pr(
1997 &self,
1998 repo: &str,
1999 title: &str,
2000 body: &str,
2001 head: &str,
2002 base: &str,
2003 ) -> anyhow::Result<String> {
2004 use reqwest::header;
2005
2006 #[derive(Serialize)]
2007 struct CreatePullRequest<'a> {
2008 title: &'a str,
2009 body: &'a str,
2010 head: &'a str,
2011 base: &'a str,
2012 }
2013
2014 let body = redact_posted_body(body);
2015 let url = format!("{}/repos/{repo}/pulls", self.api_root());
2016 Self::check_backoff(&url)?;
2017
2018 let payload = CreatePullRequest {
2019 title,
2020 body: &body,
2021 head,
2022 base,
2023 };
2024
2025 let auth = self.auth_header(&url).await?;
2026 let make_req = || {
2027 Ok(self
2028 .client
2029 .post(&url)
2030 .header(header::AUTHORIZATION, auth.clone())
2031 .header(header::ACCEPT, "application/vnd.github+json")
2032 .header(header::USER_AGENT, "orch")
2033 .header("X-GitHub-Api-Version", "2022-11-28")
2034 .json(&payload))
2035 };
2036 let response = self.send_with_retries(make_req, false).await?;
2037 let status = response.status();
2038 let text = response.text().await.unwrap_or_default();
2039 if !status.is_success() {
2040 Self::maybe_record_rate_limit_from_body(status, &text);
2041 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
2042 }
2043
2044 let pr: serde_json::Value = serde_json::from_str(&text)?;
2045 pr.get("html_url")
2046 .and_then(|v| v.as_str())
2047 .map(String::from)
2048 .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {pr:?}"))
2049 }
2050
2051 pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
2056 let url = format!("{GITHUB_API}/repos/{repo}");
2057 let resp: serde_json::Value = self.get_json(&url).await?;
2058 resp.get("default_branch")
2059 .and_then(|v| v.as_str())
2060 .map(String::from)
2061 .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
2062 }
2063
2064 pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
2067 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2068 self.get_json(&url).await
2069 }
2070
2071 pub async fn batch_get_pr_states(
2076 &self,
2077 repo: &str,
2078 pr_numbers: &[u64],
2079 ) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
2080 const CHUNK_SIZE: usize = 50;
2082
2083 if pr_numbers.is_empty() {
2084 return Ok(std::collections::HashMap::new());
2085 }
2086
2087 let (owner, name) = repo
2088 .split_once('/')
2089 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2090
2091 let mut result = std::collections::HashMap::new();
2092
2093 for chunk in pr_numbers.chunks(CHUNK_SIZE) {
2094 let aliases: String = chunk
2095 .iter()
2096 .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
2097 .collect::<Vec<_>>()
2098 .join("\n ");
2099
2100 let query = format!(
2101 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2102 );
2103
2104 let resp = self
2105 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2106 .await?;
2107 let repo_data = resp
2108 .pointer("/data/repository")
2109 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2110
2111 for n in chunk {
2112 if let Some(pr) = repo_data.get(format!("pr{n}")) {
2113 let merged = pr
2114 .get("merged")
2115 .and_then(serde_json::Value::as_bool)
2116 .unwrap_or(false);
2117 let state = pr
2118 .get("state")
2119 .and_then(|v| v.as_str())
2120 .unwrap_or("UNKNOWN")
2121 .to_lowercase();
2122 result.insert(*n, (merged, state));
2123 }
2124 }
2125 }
2126
2127 Ok(result)
2128 }
2129
2130 pub async fn batch_get_issue_states(
2135 &self,
2136 repo: &str,
2137 issue_numbers: &[u64],
2138 caller_context: &str,
2139 ) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
2140 const CHUNK_SIZE: usize = 50;
2142
2143 if issue_numbers.is_empty() {
2144 return Ok(std::collections::HashMap::new());
2145 }
2146
2147 let (valid_issue_numbers, invalid_issue_numbers) =
2148 sanitize_github_issue_numbers(issue_numbers);
2149 if !invalid_issue_numbers.is_empty() {
2150 tracing::warn!(
2151 repo,
2152 caller_context,
2153 invalid_issue_numbers = ?invalid_issue_numbers,
2154 total_supplied = issue_numbers.len(),
2155 "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
2156 );
2157 }
2158 if valid_issue_numbers.is_empty() {
2159 return Ok(std::collections::HashMap::new());
2160 }
2161
2162 let (owner, name) = repo
2163 .split_once('/')
2164 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
2165
2166 let mut result = std::collections::HashMap::new();
2167
2168 for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
2169 let aliases: String = chunk
2170 .iter()
2171 .map(|n| {
2172 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
2173 })
2174 .collect::<Vec<_>>()
2175 .join(" ");
2176
2177 let query = format!(
2178 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
2179 );
2180
2181 let resp = self
2182 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2183 .await?;
2184 let repo_data = resp
2185 .pointer("/data/repository")
2186 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2187
2188 for n in chunk {
2189 if let Some(issue) = repo_data.get(format!("issue{n}")) {
2190 let state = issue
2191 .get("state")
2192 .and_then(|v| v.as_str())
2193 .unwrap_or("UNKNOWN")
2194 .to_lowercase();
2195 let labels: Vec<String> = issue
2196 .pointer("/labels/nodes")
2197 .and_then(|v| v.as_array())
2198 .map(|arr| {
2199 arr.iter()
2200 .filter_map(|l| {
2201 l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2202 })
2203 .collect()
2204 })
2205 .unwrap_or_default();
2206 result.insert(n.to_string(), (state, labels));
2207 }
2208 }
2209 }
2210 Ok(result)
2211 }
2212
2213 pub async fn get_required_status_check_contexts(
2215 &self,
2216 repo: &str,
2217 branch: &str,
2218 ) -> anyhow::Result<Vec<RequiredStatusCheck>> {
2219 let branch = urlencoding::encode(branch);
2220 let classic_url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2221 let rules_url = format!("{GITHUB_API}/repos/{repo}/rules/branches/{branch}");
2222
2223 let classic_fut = self.get_json::<serde_json::Value>(&classic_url);
2224 let rules_fut = self.get_all_pages::<serde_json::Value>(&rules_url, &[("per_page", "100")]);
2225 let (classic_resp, rules_resp) = tokio::join!(classic_fut, rules_fut);
2226
2227 let classic = match classic_resp {
2228 Ok(value) => parse_classic_required_status_checks(&value),
2229 Err(e) => {
2230 if is_no_branch_protection_error(&e.to_string()) {
2231 Vec::new()
2232 } else {
2233 return Err(e);
2234 }
2235 }
2236 };
2237
2238 let ruleset = match rules_resp {
2239 Ok(rules) => parse_ruleset_required_status_checks(&rules),
2240 Err(e) => {
2241 if e.to_string().contains("404") {
2242 Vec::new()
2243 } else {
2244 return Err(e);
2245 }
2246 }
2247 };
2248
2249 Ok(merge_required_status_checks(classic, ruleset))
2250 }
2251
2252 pub async fn get_commit_status_contexts(
2254 &self,
2255 repo: &str,
2256 sha: &str,
2257 ) -> anyhow::Result<Vec<(String, String)>> {
2258 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2259 let statuses: Vec<serde_json::Value> =
2264 self.get_all_pages_from_wrapper(&url, "statuses").await?;
2265
2266 Ok(statuses
2267 .into_iter()
2268 .filter_map(|status| {
2269 let context = status.get("context").and_then(|v| v.as_str())?;
2270 let state = status.get("state").and_then(|v| v.as_str())?;
2271 Some((context.to_string(), state.to_string()))
2272 })
2273 .collect())
2274 }
2275
2276 fn combined_status_state(
2277 total: u64,
2278 failing: u64,
2279 pending: u64,
2280 has_workflows: bool,
2281 mergeable_state: Option<&str>,
2282 ) -> String {
2283 if failing > 0 {
2284 "failure".to_string()
2285 } else if pending > 0 {
2286 "pending".to_string()
2287 } else if total == 0 && has_workflows {
2288 if mergeable_state == Some("clean") {
2292 "success".to_string()
2293 } else {
2294 "pending".to_string()
2295 }
2296 } else {
2297 "success".to_string()
2298 }
2299 }
2300
2301 pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2306 let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2307 let resp = self.get_json::<serde_json::Value>(&url).await;
2308 match resp {
2309 Ok(value) => Ok(value
2310 .get("workflows")
2311 .and_then(|v| v.as_array())
2312 .is_some_and(|workflows| !workflows.is_empty())),
2313 Err(e) => {
2314 let err = e.to_string();
2315 if err.contains("404") {
2316 Ok(false)
2317 } else {
2318 Err(e)
2319 }
2320 }
2321 }
2322 }
2323
2324 pub async fn get_check_runs(
2326 &self,
2327 repo: &str,
2328 sha: &str,
2329 ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2330 let url = format!(
2341 "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2342 );
2343 self.get_all_pages_from_wrapper(&url, "check_runs").await
2344 }
2345
2346 pub async fn get_check_run_annotations(
2353 &self,
2354 repo: &str,
2355 check_run_id: u64,
2356 ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2357 let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2358 self.get_all_pages(&url, &[("per_page", "100")]).await
2359 }
2360
2361 pub async fn get_pr_reviews(
2363 &self,
2364 repo: &str,
2365 pr_number: u64,
2366 ) -> anyhow::Result<Vec<GitHubReview>> {
2367 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2368 self.get_all_pages(&url, &[("per_page", "100")]).await
2369 }
2370
2371 pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2376 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2377 let value: serde_json::Value = self.get_json(&url).await?;
2378 value
2379 .pointer("/commit/committer/date")
2380 .and_then(|v| v.as_str())
2381 .map(std::string::ToString::to_string)
2382 .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2383 }
2384
2385 pub async fn update_pr_body(
2387 &self,
2388 repo: &str,
2389 pr_number: u64,
2390 body: &str,
2391 ) -> anyhow::Result<()> {
2392 let body = redact_posted_body(body);
2393 let url = format!("{}/repos/{repo}/pulls/{pr_number}", self.api_root());
2394 let payload = serde_json::json!({ "body": body });
2395 self.patch_json_raw(&url, &payload).await?;
2396 Ok(())
2397 }
2398
2399 pub async fn close_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2401 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2402 let payload = serde_json::json!({ "state": "closed" });
2403 self.patch_json_raw(&url, &payload).await?;
2404 Ok(())
2405 }
2406
2407 pub async fn compare_behind_by(
2412 &self,
2413 repo: &str,
2414 base: &str,
2415 head_sha: &str,
2416 ) -> anyhow::Result<u64> {
2417 let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2418 let body: serde_json::Value = self.get_json(&url).await?;
2419 parse_behind_by(&body)
2420 }
2421
2422 pub async fn commit_meta(&self, repo: &str, sha: &str) -> anyhow::Result<CommitMeta> {
2424 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2425 let value: serde_json::Value = self.get_json(&url).await?;
2426 Ok(parse_commit_meta(&value))
2427 }
2428
2429 pub async fn branch_contains(
2431 &self,
2432 repo: &str,
2433 branch: &str,
2434 sha: &str,
2435 ) -> anyhow::Result<bool> {
2436 let url = format!("{GITHUB_API}/repos/{repo}/compare/{branch}...{sha}");
2437 let body: serde_json::Value = self.get_json(&url).await?;
2438 body.get("ahead_by")
2439 .and_then(serde_json::Value::as_u64)
2440 .map(|ahead| ahead == 0)
2441 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric ahead_by field"))
2442 }
2443
2444 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2446 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2447 let payload = serde_json::json!({ "state": "closed" });
2448 self.patch_json_raw(&url, &payload).await?;
2449 Ok(())
2450 }
2451
2452 pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2454 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2455 let payload = serde_json::json!({ "state": "open" });
2456 self.patch_json_raw(&url, &payload).await?;
2457 Ok(())
2458 }
2459
2460 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2462 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2463 Self::check_backoff(&url)?;
2464 let auth = self.auth_header(&url).await?;
2465 let make_req = || {
2466 Ok(self
2467 .client
2468 .get(&url)
2469 .header(header::AUTHORIZATION, auth.clone())
2470 .header(header::ACCEPT, "application/vnd.github+json")
2471 .header("X-GitHub-Api-Version", "2022-11-28"))
2472 };
2473 let resp = self.send_with_retries(make_req, false).await?;
2474 let status = resp.status();
2475 if status == StatusCode::NO_CONTENT {
2476 Ok(true)
2477 } else if status == StatusCode::NOT_FOUND {
2478 Ok(false)
2479 } else {
2480 let body = resp.text().await.unwrap_or_default();
2481 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2482 }
2483 }
2484
2485 pub async fn add_sub_issue(
2487 &self,
2488 parent_node_id: &str,
2489 child_node_id: &str,
2490 ) -> anyhow::Result<()> {
2491 let query = format!(
2492 r#"mutation {{
2493 addSubIssue(input: {{issueId: "{parent_node_id}", subIssueId: "{child_node_id}"}}) {{
2494 issue {{ number }}
2495 subIssue {{ number }}
2496 }}
2497 }}"#
2498 );
2499 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2500 .await?;
2501 Ok(())
2502 }
2503
2504 pub async fn link_issue_to_branch(
2516 &self,
2517 repo: &str,
2518 issue_number: u64,
2519 branch: &str,
2520 ) -> anyhow::Result<String> {
2521 let parts: Vec<&str> = repo.split('/').collect();
2522 if parts.len() != 2 {
2523 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2524 }
2525 let (owner, repo_name) = (parts[0], parts[1]);
2526
2527 let query = r"query($owner: String!, $name: String!, $number: Int!) {
2529 repository(owner: $owner, name: $name) {
2530 id
2531 issue(number: $number) {
2532 id
2533 }
2534 }
2535 }";
2536 let vars = serde_json::json!({
2537 "owner": owner,
2538 "name": repo_name,
2539 "number": issue_number,
2540 });
2541
2542 let result = self.graphql_with_vars(query, vars).await?;
2543
2544 let repo_id = result
2545 .pointer("/data/repository/id")
2546 .and_then(|v| v.as_str())
2547 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2548
2549 let issue_id = result
2550 .pointer("/data/repository/issue/id")
2551 .and_then(|v| v.as_str())
2552 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2553
2554 let branch_query = r"query($owner: String!, $name: String!, $ref: String!) {
2556 repository(owner: $owner, name: $name) {
2557 ref(qualifiedName: $ref) {
2558 target {
2559 oid
2560 }
2561 }
2562 }
2563 }";
2564 let branch_vars = serde_json::json!({
2565 "owner": owner,
2566 "name": repo_name,
2567 "ref": format!("refs/heads/{}", branch),
2568 });
2569
2570 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2571 let branch_oid = branch_result
2572 .pointer("/data/repository/ref/target/oid")
2573 .and_then(|v| v.as_str());
2574
2575 let branch_oid = if let Some(oid) = branch_oid {
2578 oid.to_string()
2579 } else {
2580 let default_query = r"query($owner: String!, $name: String!) {
2582 repository(owner: $owner, name: $name) {
2583 defaultBranchRef {
2584 target {
2585 oid
2586 }
2587 }
2588 }
2589 }";
2590 let default_vars = serde_json::json!({
2591 "owner": owner,
2592 "name": repo_name,
2593 });
2594 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2595 match default_result
2596 .pointer("/data/repository/defaultBranchRef/target/oid")
2597 .and_then(|v| v.as_str())
2598 {
2599 Some(oid) => {
2600 tracing::debug!(
2601 repo,
2602 issue_number,
2603 branch,
2604 "branch not found, using default branch OID to create and link"
2605 );
2606 oid.to_string()
2607 }
2608 None => {
2609 return Ok(format!("unlinked:{branch}"));
2610 }
2611 }
2612 };
2613
2614 let mutation = r"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2617 createLinkedBranch(input: {
2618 issueId: $issueId
2619 repositoryId: $repositoryId
2620 name: $name
2621 oid: $oid
2622 }) {
2623 linkedBranch {
2624 id
2625 ref {
2626 name
2627 }
2628 }
2629 }
2630 }";
2631 let mutation_vars = serde_json::json!({
2632 "issueId": issue_id,
2633 "repositoryId": repo_id,
2634 "name": branch,
2635 "oid": branch_oid,
2636 });
2637
2638 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2639
2640 match link_result {
2641 Ok(result) => {
2642 let linked_branch_id = result
2643 .pointer("/data/createLinkedBranch/linkedBranch/id")
2644 .and_then(|v| v.as_str())
2645 .map(String::from);
2646
2647 if let Some(id) = linked_branch_id {
2648 tracing::info!(
2649 repo,
2650 issue_number,
2651 branch,
2652 "linked issue to branch via GraphQL API"
2653 );
2654 Ok(id)
2655 } else {
2656 let errors = result
2658 .get("errors")
2659 .and_then(|e| e.as_array())
2660 .cloned()
2661 .unwrap_or_default();
2662
2663 if errors.iter().any(|e| {
2664 e.get("message")
2665 .and_then(|m| m.as_str())
2666 .is_some_and(|m| m.contains("already"))
2667 }) {
2668 tracing::debug!(
2669 repo,
2670 issue_number,
2671 branch,
2672 "issue already linked to branch"
2673 );
2674 Ok(format!("already_linked:{branch}"))
2675 } else {
2676 anyhow::bail!("failed to link issue to branch: {result:?}")
2677 }
2678 }
2679 }
2680 Err(e) => {
2681 let err_str = format!("{e}");
2683 if err_str.contains("already") || err_str.contains("existing") {
2684 tracing::debug!(
2685 repo,
2686 issue_number,
2687 branch,
2688 "issue already linked to branch (error check)"
2689 );
2690 Ok(format!("already_linked:{branch}"))
2691 } else {
2692 Err(e)
2693 }
2694 }
2695 }
2696 }
2697
2698 pub async fn merge_pr(
2701 &self,
2702 repo: &str,
2703 pr_number: u64,
2704 delete_branch: bool,
2705 head_sha: &str,
2706 ) -> anyhow::Result<()> {
2707 if !is_full_commit_sha(head_sha) {
2708 anyhow::bail!("merge_pr requires the full head sha, got {head_sha:?}");
2709 }
2710 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2711 Self::check_backoff(&url)?;
2712 let payload = merge_pr_body(head_sha);
2713 let auth = self.auth_header(&url).await?;
2714 let make_req = || {
2715 Ok(self
2716 .client
2717 .put(&url)
2718 .json(&payload)
2719 .header(header::AUTHORIZATION, auth.clone())
2720 .header(header::ACCEPT, "application/vnd.github+json")
2721 .header("X-GitHub-Api-Version", "2022-11-28"))
2722 };
2723 let resp = self.send_with_retries(make_req, false).await?;
2724 let status = resp.status();
2725 if !status.is_success() {
2726 let body = resp.text().await.unwrap_or_default();
2727 anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2728 }
2729 Self::record_success();
2730
2731 if delete_branch {
2733 let pr: serde_json::Value = self
2735 .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2736 .await?;
2737 if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2738 let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2739 if let Err(e) = self.delete(&del_url).await {
2740 let err_str = e.to_string();
2741 if err_str.contains("Reference does not exist") {
2742 tracing::debug!(
2743 ref_name,
2744 "branch already gone (GitHub auto-deleted on merge)"
2745 );
2746 } else {
2747 tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2748 }
2749 }
2750 }
2751 }
2752 Ok(())
2753 }
2754
2755 pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2761 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2762 Self::check_backoff(&url)?;
2763 let auth = self.auth_header(&url).await?;
2764 let make_req = || {
2765 Ok(self
2766 .client
2767 .put(&url)
2768 .header(header::AUTHORIZATION, auth.clone())
2769 .header(header::ACCEPT, "application/vnd.github+json")
2770 .header("X-GitHub-Api-Version", "2022-11-28"))
2771 };
2772 let resp = self.send_with_retries(make_req, false).await?;
2773 let status = resp.status();
2774 if !status.is_success() {
2775 let body = resp.text().await.unwrap_or_default();
2776 Self::maybe_record_rate_limit_from_body(status, &body);
2777 anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2778 }
2779 Self::record_success();
2780 Ok(())
2781 }
2782
2783 pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2787 let parts: Vec<&str> = repo.split('/').collect();
2788 if parts.len() != 2 {
2789 anyhow::bail!("invalid repo format: {repo}");
2790 }
2791 let (owner, repo_name) = (parts[0], parts[1]);
2792
2793 let pr_query = r"query($owner: String!, $name: String!, $number: Int!) {
2795 repository(owner: $owner, name: $name) {
2796 pullRequest(number: $number) { id }
2797 }
2798 }";
2799 let body = self
2800 .graphql_with_vars(
2801 pr_query,
2802 serde_json::json!({
2803 "owner": owner,
2804 "name": repo_name,
2805 "number": pr_number,
2806 }),
2807 )
2808 .await?;
2809 let pr_id = body
2810 .pointer("/data/repository/pullRequest/id")
2811 .and_then(|v| v.as_str())
2812 .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2813 .to_string();
2814
2815 let mutation = r"mutation($prId: ID!) {
2817 enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2818 pullRequest { autoMergeRequest { enabledAt } }
2819 }
2820 }";
2821 let body = self
2822 .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2823 .await?;
2824 if let Some(errors) = body.get("errors") {
2825 anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2826 }
2827
2828 tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2829 Ok(())
2830 }
2831
2832 pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2834 let parts: Vec<&str> = repo.split('/').collect();
2835 if parts.len() != 2 {
2836 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{repo}'");
2837 }
2838 let (owner, repo_name) = (parts[0], parts[1]);
2839 let number = match parse_positive_issue_number(number) {
2840 Ok(number) => number,
2841 Err(e) => {
2842 tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2843 return Ok(Vec::new());
2844 }
2845 };
2846
2847 let mut all_numbers: Vec<u64> = Vec::new();
2848 let mut cursor: Option<String> = None;
2849 let page_size = 100;
2850 let max_pages = 50;
2851 let mut page_count = 0;
2852
2853 loop {
2854 page_count += 1;
2855 if page_count > max_pages {
2856 tracing::warn!(
2857 repo,
2858 number,
2859 "get_sub_issues hit max page limit ({max_pages})"
2860 );
2861 break;
2862 }
2863
2864 let (query, vars) = if let Some(ref c) = cursor {
2865 (
2866 r"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2867 repository(owner: $owner, name: $name) {
2868 issue(number: $number) {
2869 subIssues(first: $first, after: $after) {
2870 nodes { number }
2871 pageInfo { hasNextPage endCursor }
2872 }
2873 }
2874 }
2875 }",
2876 serde_json::json!({
2877 "owner": owner,
2878 "name": repo_name,
2879 "number": number,
2880 "first": page_size,
2881 "after": c,
2882 }),
2883 )
2884 } else {
2885 (
2886 r"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2887 repository(owner: $owner, name: $name) {
2888 issue(number: $number) {
2889 subIssues(first: $first) {
2890 nodes { number }
2891 pageInfo { hasNextPage endCursor }
2892 }
2893 }
2894 }
2895 }",
2896 serde_json::json!({
2897 "owner": owner,
2898 "name": repo_name,
2899 "number": number,
2900 "first": page_size,
2901 }),
2902 )
2903 };
2904
2905 let result = self
2906 .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2907 .await?;
2908
2909 let sub_issues_data = result
2910 .get("data")
2911 .and_then(|d| d.get("repository"))
2912 .and_then(|r| r.get("issue"))
2913 .and_then(|i| i.get("subIssues"));
2914
2915 let nodes = sub_issues_data
2916 .and_then(|s| s.get("nodes"))
2917 .and_then(|n| n.as_array());
2918
2919 match nodes {
2920 Some(nodes) => {
2921 let numbers: Vec<u64> = nodes
2922 .iter()
2923 .filter_map(|n| n.get("number").and_then(serde_json::Value::as_u64))
2924 .collect();
2925 all_numbers.extend(numbers);
2926 }
2927 None => break,
2928 }
2929
2930 let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2931 let has_next = page_info
2932 .and_then(|p| p.get("hasNextPage"))
2933 .and_then(serde_json::Value::as_bool)
2934 .unwrap_or(false);
2935
2936 if !has_next {
2937 break;
2938 }
2939
2940 cursor = page_info
2941 .and_then(|p| p.get("endCursor"))
2942 .and_then(|c| c.as_str())
2943 .map(std::string::ToString::to_string);
2944 }
2945
2946 Ok(all_numbers)
2947 }
2948
2949 pub async fn get_combined_status(
2951 &self,
2952 repo: &str,
2953 git_ref: &str,
2954 has_workflows: bool,
2955 mergeable_state: Option<&str>,
2956 ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2957 let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2958 let resp: serde_json::Value = self.get_json(&url).await?;
2959
2960 let runs = resp
2961 .get("check_runs")
2962 .and_then(|v| v.as_array())
2963 .cloned()
2964 .unwrap_or_default();
2965
2966 let mut passing = 0u64;
2967 let mut failing = 0u64;
2968 let mut pending = 0u64;
2969
2970 for run in &runs {
2971 let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2972 let status = run
2973 .get("status")
2974 .and_then(|v| v.as_str())
2975 .unwrap_or("queued");
2976
2977 match status {
2978 "completed" => {
2979 if conclusion.is_empty() {
2980 pending += 1;
2981 } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2982 passing += 1;
2983 } else {
2984 failing += 1;
2985 }
2986 }
2987 _ => pending += 1,
2988 }
2989 }
2990
2991 let total = runs.len() as u64;
2992 let state =
2993 Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
2994
2995 Ok((state, total, passing, failing, pending))
2996 }
2997
2998 pub async fn rerun_failed_workflow(
3004 &self,
3005 repo: &str,
3006 workflow_name: &str,
3007 branch: &str,
3008 ) -> anyhow::Result<()> {
3009 let url = format!(
3011 "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
3012 );
3013 let resp: serde_json::Value = self.get_json(&url).await?;
3014 let runs = resp["workflow_runs"]
3015 .as_array()
3016 .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
3017
3018 let run_id = runs
3020 .iter()
3021 .find(|r| {
3022 r["name"].as_str() == Some(workflow_name)
3023 && matches!(r["conclusion"].as_str(), Some("failure" | "cancelled"))
3024 })
3025 .and_then(|r| r["id"].as_u64())
3026 .ok_or_else(|| {
3027 anyhow::anyhow!(
3028 "no failed run found for workflow '{workflow_name}' on branch '{branch}'"
3029 )
3030 })?;
3031
3032 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
3034 let auth = self.auth_header(&rerun_url).await?;
3035 let make_req = || {
3036 Ok(self
3037 .client
3038 .post(&rerun_url)
3039 .header(header::AUTHORIZATION, auth.clone())
3040 .header(header::ACCEPT, "application/vnd.github+json")
3041 .header("X-GitHub-Api-Version", "2022-11-28"))
3042 };
3043 let resp = self.send_with_retries(make_req, false).await?;
3044 let status = resp.status();
3045 if !status.is_success() {
3046 let body = resp.text().await.unwrap_or_default();
3047 Self::maybe_record_rate_limit_from_body(status, &body);
3048 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
3049 }
3050
3051 Ok(())
3052 }
3053
3054 pub async fn batch_fetch_pr_review_data(
3060 &self,
3061 repo: &str,
3062 pr_numbers: &[u64],
3063 ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
3064 if pr_numbers.is_empty() {
3065 return Ok(std::collections::HashMap::new());
3066 }
3067
3068 let (owner, name) = repo
3069 .split_once('/')
3070 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {repo}"))?;
3071
3072 let aliases: String = pr_numbers
3074 .iter()
3075 .map(|&n| build_pr_review_alias(n))
3076 .collect::<Vec<_>>()
3077 .join("\n");
3078
3079 let query = format!(
3080 r"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"
3081 );
3082
3083 let resp = self
3084 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
3085 .await?;
3086 let repo_data = resp
3087 .pointer("/data/repository")
3088 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
3089
3090 let mut result = std::collections::HashMap::new();
3091 for &n in pr_numbers {
3092 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
3093 continue;
3094 };
3095
3096 let merged = pr_data
3097 .get("merged")
3098 .and_then(serde_json::Value::as_bool)
3099 .unwrap_or(false);
3100 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
3101 Some("MERGEABLE") => Some(true),
3102 Some("CONFLICTING") => Some(false),
3103 _ => None, };
3105
3106 let mut reviews: Vec<GitHubReview> = pr_data
3107 .pointer("/reviews/nodes")
3108 .and_then(|v| v.as_array())
3109 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
3110 .unwrap_or_default();
3111
3112 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
3113 tracing::warn!(
3114 pr_number = n,
3115 fetched = reviews.len(),
3116 "batch review fetch truncated reviews connection at first page"
3117 );
3118 }
3119
3120 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
3124
3125 let mut review_comments: Vec<GitHubReviewComment> = pr_data
3126 .pointer("/reviewThreads/nodes")
3127 .and_then(|v| v.as_array())
3128 .map(|threads| {
3129 threads
3130 .iter()
3131 .filter_map(|thread| {
3132 thread
3133 .pointer("/comments/nodes")
3134 .and_then(|v| v.as_array())
3135 .map(|nodes| {
3136 nodes
3137 .iter()
3138 .filter_map(parse_graphql_review_comment)
3139 .collect::<Vec<_>>()
3140 })
3141 })
3142 .flatten()
3143 .collect()
3144 })
3145 .unwrap_or_default();
3146
3147 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3148 tracing::warn!(
3149 pr_number = n,
3150 fetched_threads = pr_data
3151 .pointer("/reviewThreads/nodes")
3152 .and_then(|v| v.as_array())
3153 .map_or(0, std::vec::Vec::len),
3154 "batch review fetch truncated reviewThreads connection at first page"
3155 );
3156 }
3157 if pr_data
3158 .pointer("/reviewThreads/nodes")
3159 .and_then(|v| v.as_array())
3160 .is_some_and(|threads| {
3161 threads.iter().any(|thread| {
3162 connection_has_next_page(thread.pointer("/comments/pageInfo"))
3163 })
3164 })
3165 {
3166 tracing::warn!(
3167 pr_number = n,
3168 "batch review fetch truncated one or more review thread comment connections"
3169 );
3170 }
3171
3172 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3175
3176 let mut issue_comments: Vec<GitHubComment> = pr_data
3177 .pointer("/comments/nodes")
3178 .and_then(|v| v.as_array())
3179 .map(|nodes| {
3180 nodes
3181 .iter()
3182 .filter_map(parse_graphql_issue_comment)
3183 .collect()
3184 })
3185 .unwrap_or_default();
3186
3187 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3188 issue_comments.extend(
3189 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3190 .await?,
3191 );
3192 }
3193
3194 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3199
3200 result.insert(
3201 n,
3202 PrReviewBatchData {
3203 merged,
3204 head_sha: pr_data
3205 .get("headRefOid")
3206 .and_then(|v| v.as_str())
3207 .unwrap_or_default()
3208 .to_string(),
3209 mergeable,
3210 reviews,
3211 review_comments,
3212 issue_comments,
3213 },
3214 );
3215 }
3216
3217 Ok(result)
3218 }
3219
3220 async fn fetch_pr_issue_comments_after(
3221 &self,
3222 owner: &str,
3223 name: &str,
3224 pr_number: u64,
3225 mut cursor: Option<String>,
3226 ) -> anyhow::Result<Vec<GitHubComment>> {
3227 let mut comments = Vec::new();
3228 let mut page_count = 0;
3229 let max_pages = 50;
3230
3231 while let Some(current_cursor) = cursor.take() {
3232 page_count += 1;
3233 if page_count > max_pages {
3234 tracing::warn!(
3235 pr_number,
3236 fetched = comments.len(),
3237 "stopping PR issue comment pagination after max page limit"
3238 );
3239 break;
3240 }
3241
3242 let (query, vars) =
3243 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
3244 let resp = self.graphql_with_vars(&query, vars).await?;
3245 let comments_data = resp
3246 .pointer("/data/repository/pullRequest/comments")
3247 .ok_or_else(|| {
3248 anyhow::anyhow!(
3249 "missing /data/repository/pullRequest/comments in GraphQL response"
3250 )
3251 })?;
3252
3253 comments.extend(
3254 comments_data
3255 .get("nodes")
3256 .and_then(|v| v.as_array())
3257 .into_iter()
3258 .flatten()
3259 .filter_map(parse_graphql_issue_comment),
3260 );
3261
3262 cursor = connection_end_cursor(comments_data.get("pageInfo"));
3263 }
3264
3265 Ok(comments)
3266 }
3267
3268 pub async fn get_automated_review_status(
3270 &self,
3271 repo: &str,
3272 pr_number: u64,
3273 ) -> anyhow::Result<Option<String>> {
3274 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3275
3276 let app_login = super::app::bot_login().await;
3278 let mut newest: Option<&crate::github::types::GitHubComment> = None;
3279 for c in &comments {
3280 if !c.body.starts_with("## Automated Review") {
3281 continue;
3282 }
3283 let trusted = match app_login.as_deref() {
3284 Some(app) if super::app::same_login(&c.user.login, app) => Ok(true),
3285 _ => self.is_collaborator(repo, &c.user.login).await,
3286 };
3287 match trusted {
3288 Ok(true) => match newest {
3289 None => newest = Some(c),
3290 Some(prev) => {
3291 if c.created_at > prev.created_at {
3292 newest = Some(c);
3293 }
3294 }
3295 },
3296 Ok(false) => {
3297 tracing::warn!(
3298 user = %c.user.login,
3299 pr_number,
3300 "ignoring automated review comment from non-collaborator"
3301 );
3302 }
3303 Err(e) => {
3304 tracing::debug!(
3305 user = %c.user.login,
3306 error = %e,
3307 "failed to check collaborator status, skipping comment"
3308 );
3309 }
3310 }
3311 }
3312
3313 match newest {
3314 Some(c) => {
3315 let first_line = c.body.lines().next().unwrap_or("");
3316 if first_line.contains("Automated Review \u{2014} Approve") {
3317 Ok(Some("approve".to_string()))
3318 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3319 Ok(Some("changes_requested".to_string()))
3320 } else {
3321 Ok(None)
3322 }
3323 }
3324 None => Ok(None),
3325 }
3326 }
3327}
3328
3329#[derive(Debug, Default, PartialEq)]
3335pub struct CommitMeta {
3336 pub parents: Vec<String>,
3337 pub author_login: Option<String>,
3338 pub committer_login: Option<String>,
3339}
3340
3341fn parse_commit_meta(commit: &serde_json::Value) -> CommitMeta {
3342 let login = |field: &str| {
3343 commit
3344 .pointer(&format!("/{field}/login"))
3345 .and_then(|v| v.as_str())
3346 .map(String::from)
3347 };
3348 CommitMeta {
3349 parents: commit
3350 .get("parents")
3351 .and_then(|p| p.as_array())
3352 .map(|parents| {
3353 parents
3354 .iter()
3355 .filter_map(|p| p.get("sha").and_then(|s| s.as_str()).map(String::from))
3356 .collect()
3357 })
3358 .unwrap_or_default(),
3359 author_login: login("author"),
3360 committer_login: login("committer"),
3361 }
3362}
3363
3364fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3365 body.get("behind_by")
3366 .and_then(serde_json::Value::as_u64)
3367 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3368}
3369
3370fn is_no_branch_protection_error(err: &str) -> bool {
3373 err.contains("404")
3374 || (err.contains("403")
3375 && err.contains("Upgrade to GitHub Pro or make this repository public"))
3376}
3377
3378fn parse_classic_required_status_checks(value: &serde_json::Value) -> Vec<RequiredStatusCheck> {
3380 if let Some(checks) = value
3381 .pointer("/required_status_checks/checks")
3382 .and_then(|v| v.as_array())
3383 {
3384 let parsed: Vec<RequiredStatusCheck> =
3385 checks.iter().filter_map(parse_classic_check).collect();
3386 if !parsed.is_empty() {
3387 return parsed;
3388 }
3389 }
3390
3391 value
3392 .pointer("/required_status_checks/contexts")
3393 .and_then(|v| v.as_array())
3394 .map(|contexts| {
3395 contexts
3396 .iter()
3397 .filter_map(|context| {
3398 context.as_str().map(|s| RequiredStatusCheck {
3399 context: s.to_string(),
3400 integration_id: None,
3401 })
3402 })
3403 .collect()
3404 })
3405 .unwrap_or_default()
3406}
3407
3408fn parse_classic_check(check: &serde_json::Value) -> Option<RequiredStatusCheck> {
3409 let context = check.get("context")?.as_str()?.to_string();
3410 let integration_id = check
3411 .get("app_id")
3412 .and_then(serde_json::Value::as_i64)
3413 .filter(|&id| id > 0);
3414 Some(RequiredStatusCheck {
3415 context,
3416 integration_id,
3417 })
3418}
3419
3420fn parse_ruleset_required_status_checks(rules: &[serde_json::Value]) -> Vec<RequiredStatusCheck> {
3422 let mut out = Vec::new();
3423 for rule in rules {
3424 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3425 continue;
3426 }
3427 let Some(checks) = rule
3428 .pointer("/parameters/required_status_checks")
3429 .or_else(|| rule.get("required_status_checks"))
3430 .and_then(|v| v.as_array())
3431 else {
3432 continue;
3433 };
3434 for check in checks {
3435 let Some(context) = check.get("context").and_then(|v| v.as_str()) else {
3436 continue;
3437 };
3438 let integration_id = check
3439 .get("integration_id")
3440 .and_then(serde_json::Value::as_i64)
3441 .filter(|&id| id > 0);
3442 out.push(RequiredStatusCheck {
3443 context: context.to_string(),
3444 integration_id,
3445 });
3446 }
3447 }
3448 out
3449}
3450
3451fn merge_required_status_checks(
3452 mut checks: Vec<RequiredStatusCheck>,
3453 extra: Vec<RequiredStatusCheck>,
3454) -> Vec<RequiredStatusCheck> {
3455 checks.extend(extra);
3456 let mut seen = std::collections::HashSet::new();
3457 checks.retain(|c| seen.insert((c.context.clone(), c.integration_id)));
3458 checks
3459}
3460
3461fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3462 let mut valid = Vec::with_capacity(issue_numbers.len());
3463 let mut invalid = Vec::new();
3464 for &issue_number in issue_numbers {
3465 if issue_number == 0 {
3466 invalid.push(issue_number);
3467 } else {
3468 valid.push(issue_number);
3469 }
3470 }
3471 (valid, invalid)
3472}
3473
3474#[derive(Debug, Clone)]
3478pub struct RateLimitMetrics {
3479 pub hits: u64,
3481 pub skipped_calls: u64,
3483 pub rest_remaining: Option<u32>,
3485 pub rest_reset_at: Option<u64>,
3487 pub graphql_remaining: Option<u32>,
3489 pub five_xx_hits: u64,
3491}
3492
3493pub fn rate_limit_metrics() -> RateLimitMetrics {
3495 let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
3496 .lock()
3497 .ok()
3498 .map_or((None, None), |rl| (rl.remaining, rl.reset_at));
3499 let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3500 RateLimitMetrics {
3501 hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3502 skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3503 rest_remaining,
3504 rest_reset_at,
3505 graphql_remaining,
3506 five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3507 }
3508}
3509
3510fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3514 let link = headers.get("link")?.to_str().ok()?;
3515 for part in link.split(',') {
3516 let part = part.trim();
3517 if part.contains("rel=\"next\"") {
3518 let start = part.find('<')? + 1;
3520 let end = part.find('>')?;
3521 if start >= end {
3522 return None;
3523 }
3524 return Some(part[start..end].to_string());
3525 }
3526 }
3527 None
3528}
3529
3530pub fn status_label_color(label: &str) -> &'static str {
3532 match label {
3533 "status:new" => "0e8a16",
3534 "status:routed" => "1d76db",
3535 "status:in_progress" => "fbca04",
3536 "status:done" => "6f42c1",
3537 "status:blocked" => "d73a4a",
3538 "status:in_review" => "0075ca",
3539 "status:approved" => "2ea44f",
3540 "status:needs_review" => "e4e669",
3541 "status:waiting" => "5319e7",
3542 _ => "c5def5",
3543 }
3544}
3545
3546#[derive(Debug, Clone, Default)]
3550pub struct PrReviewBatchData {
3551 pub merged: bool,
3553 pub head_sha: String,
3555 pub mergeable: Option<bool>,
3557 pub reviews: Vec<GitHubReview>,
3559 pub review_comments: Vec<GitHubReviewComment>,
3561 pub issue_comments: Vec<GitHubComment>,
3563}
3564
3565fn build_pr_review_alias(n: u64) -> String {
3568 format!(
3569 r"pr{n}: pullRequest(number: {n}) {{
3570 merged
3571 mergeable
3572 headRefOid
3573 reviews(first: 50) {{
3574 nodes {{
3575 databaseId
3576 author {{ login }}
3577 body
3578 state
3579 url
3580 submittedAt
3581 commit {{ oid }}
3582 }}
3583 pageInfo {{ hasNextPage }}
3584 }}
3585 reviewThreads(first: 50) {{
3586 nodes {{
3587 comments(first: 20) {{
3588 nodes {{
3589 databaseId
3590 author {{ login }}
3591 body
3592 path
3593 line
3594 originalLine
3595 commit {{ oid }}
3596 originalCommit {{ oid }}
3597 url
3598 createdAt
3599 updatedAt
3600 replyTo {{ databaseId }}
3601 diffHunk
3602 }}
3603 pageInfo {{ hasNextPage }}
3604 }}
3605 }}
3606 pageInfo {{ hasNextPage }}
3607 }}
3608 comments(first: 50) {{
3609 nodes {{
3610 databaseId
3611 author {{ login }}
3612 body
3613 createdAt
3614 updatedAt
3615 url
3616 }}
3617 pageInfo {{ hasNextPage endCursor }}
3618 }}
3619}}"
3620 )
3621}
3622
3623fn build_pr_issue_comments_page_query(
3624 owner: &str,
3625 name: &str,
3626 pr_number: u64,
3627 after_cursor: &str,
3628) -> (String, serde_json::Value) {
3629 let query = r"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3630 repository(owner: $owner, name: $name) {
3631 pullRequest(number: $number) {
3632 comments(first: 100, after: $after) {
3633 nodes {
3634 databaseId
3635 author { login }
3636 body
3637 createdAt
3638 updatedAt
3639 url
3640 }
3641 pageInfo { hasNextPage endCursor }
3642 }
3643 }
3644 }
3645}"
3646 .to_string();
3647 let vars = serde_json::json!({
3648 "owner": owner,
3649 "name": name,
3650 "number": pr_number,
3651 "after": after_cursor,
3652 });
3653 (query, vars)
3654}
3655
3656fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3657 page_info
3658 .and_then(|v| v.get("hasNextPage"))
3659 .and_then(serde_json::Value::as_bool)
3660 .unwrap_or(false)
3661}
3662
3663fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3664 if !connection_has_next_page(page_info) {
3665 return None;
3666 }
3667
3668 page_info
3669 .and_then(|v| v.get("endCursor"))
3670 .and_then(|v| v.as_str())
3671 .map(str::to_owned)
3672}
3673
3674fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3675 use super::types::GitHubUser;
3676 Some(GitHubReview {
3677 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3678 user: GitHubUser {
3679 login: node
3680 .pointer("/author/login")
3681 .and_then(|v| v.as_str())?
3682 .to_string(),
3683 },
3684 body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3685 state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3686 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3687 submitted_at: node
3688 .get("submittedAt")
3689 .and_then(|v| v.as_str())
3690 .unwrap_or("")
3691 .to_string(),
3692 commit_id: node
3693 .pointer("/commit/oid")
3694 .and_then(|v| v.as_str())
3695 .map(String::from),
3696 })
3697}
3698
3699fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3700 use super::types::GitHubUser;
3701 Some(GitHubReviewComment {
3702 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3703 user: GitHubUser {
3704 login: node
3705 .pointer("/author/login")
3706 .and_then(|v| v.as_str())?
3707 .to_string(),
3708 },
3709 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3710 path: node
3711 .get("path")
3712 .and_then(|v| v.as_str())
3713 .unwrap_or("")
3714 .to_string(),
3715 line: node
3716 .get("line")
3717 .and_then(serde_json::Value::as_u64)
3718 .and_then(|v| u32::try_from(v).ok()),
3719 original_line: node
3720 .get("originalLine")
3721 .and_then(serde_json::Value::as_u64)
3722 .and_then(|v| u32::try_from(v).ok()),
3723 commit_id: node
3724 .pointer("/commit/oid")
3725 .and_then(|v| v.as_str())
3726 .unwrap_or("")
3727 .to_string(),
3728 original_commit_id: node
3729 .pointer("/originalCommit/oid")
3730 .and_then(|v| v.as_str())
3731 .unwrap_or("")
3732 .to_string(),
3733 html_url: node
3734 .get("url")
3735 .and_then(|v| v.as_str())
3736 .unwrap_or("")
3737 .to_string(),
3738 created_at: node
3739 .get("createdAt")
3740 .and_then(|v| v.as_str())
3741 .unwrap_or("")
3742 .to_string(),
3743 updated_at: node
3744 .get("updatedAt")
3745 .and_then(|v| v.as_str())
3746 .unwrap_or("")
3747 .to_string(),
3748 in_reply_to_id: node
3749 .pointer("/replyTo/databaseId")
3750 .and_then(serde_json::Value::as_u64),
3751 diff_hunk: node
3752 .get("diffHunk")
3753 .and_then(|v| v.as_str())
3754 .map(String::from),
3755 })
3756}
3757
3758fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3759 use super::types::GitHubUser;
3760 Some(GitHubComment {
3761 id: node.get("databaseId").and_then(serde_json::Value::as_u64)?,
3762 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3763 user: GitHubUser {
3764 login: node
3765 .pointer("/author/login")
3766 .and_then(|v| v.as_str())?
3767 .to_string(),
3768 },
3769 created_at: node
3770 .get("createdAt")
3771 .and_then(|v| v.as_str())
3772 .unwrap_or("")
3773 .to_string(),
3774 updated_at: node
3775 .get("updatedAt")
3776 .and_then(|v| v.as_str())
3777 .map(String::from),
3778 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3779 issue_url: None,
3780 author_association: None,
3781 })
3782}
3783
3784#[cfg(test)]
3785mod tests {
3786 use super::*;
3787 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3788 use tokio::net::TcpListener;
3789
3790 #[test]
3791 fn repo_from_url_names_the_repository_or_nothing() {
3792 let api = "https://api.github.com";
3793 assert_eq!(
3794 repo_from_url(&format!("{api}/repos/me/a/pulls/9")).as_deref(),
3795 Some("me/a")
3796 );
3797 assert_eq!(
3798 repo_from_url(&format!("{api}/repos/org/b?per_page=1")).as_deref(),
3799 Some("org/b")
3800 );
3801 assert_eq!(
3802 repo_from_url(&format!("{api}/repos/org/b")).as_deref(),
3803 Some("org/b")
3804 );
3805 assert_eq!(repo_from_url(&format!("{api}/graphql")), None);
3806 assert_eq!(repo_from_url(&format!("{api}/user")), None);
3807 assert_eq!(repo_from_url(&format!("{api}/repos/only-owner")), None);
3808 }
3809
3810 #[test]
3811 fn no_branch_protection_errors() {
3812 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.\"}";
3813 assert!(is_no_branch_protection_error(free_plan));
3814 assert!(is_no_branch_protection_error(
3815 "failed (404 Not Found): {\"message\":\"Branch not protected\"}"
3816 ));
3817 assert!(!is_no_branch_protection_error(
3818 "failed (403 Forbidden): {\"message\":\"Resource not accessible\"}"
3819 ));
3820 }
3821
3822 #[test]
3823 fn parse_classic_required_status_checks_reads_checks_app_id() {
3824 let value = serde_json::json!({
3825 "required_status_checks": {
3826 "contexts": ["ci/test"],
3827 "checks": [{ "context": "ci/test", "app_id": 42 }]
3828 }
3829 });
3830 assert_eq!(
3831 parse_classic_required_status_checks(&value),
3832 vec![RequiredStatusCheck {
3833 context: "ci/test".to_string(),
3834 integration_id: Some(42),
3835 }]
3836 );
3837 }
3838
3839 #[test]
3840 fn parse_classic_required_status_checks_falls_back_to_contexts() {
3841 let value = serde_json::json!({
3842 "required_status_checks": {
3843 "contexts": ["ci/test", "lint"]
3844 }
3845 });
3846 assert_eq!(
3847 parse_classic_required_status_checks(&value),
3848 vec![
3849 RequiredStatusCheck {
3850 context: "ci/test".to_string(),
3851 integration_id: None,
3852 },
3853 RequiredStatusCheck {
3854 context: "lint".to_string(),
3855 integration_id: None,
3856 }
3857 ]
3858 );
3859 }
3860
3861 #[test]
3862 fn parse_classic_required_status_checks_empty_when_unprotected() {
3863 let checks = parse_classic_required_status_checks(&serde_json::json!({}));
3864 assert!(checks.is_empty(), "{checks:?}");
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!(
4262 page_cache_key::<GitHubIssue>(
4263 "/issues",
4264 Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
4265 )
4266 .is_none()
4267 );
4268 assert!(
4270 page_cache_key::<GitHubIssue>("/issues?since=2026-10-01T00:00:00Z&page=2", None)
4271 .is_none()
4272 );
4273 }
4274
4275 #[test]
4278 fn low_quota_fails_fast_instead_of_waiting_for_reset() {
4279 let mut rl = make_rl(30, 900);
4280 rl.remaining = Some(0);
4281 rl.reset_at = Some(
4282 std::time::SystemTime::now()
4283 .duration_since(std::time::UNIX_EPOCH)
4284 .unwrap()
4285 .as_secs()
4286 + 3600,
4287 );
4288 let cell = Mutex::new(rl);
4289
4290 let start = Instant::now();
4291 let err = check_rate_limit_backoff(&cell, "REST").unwrap_err();
4292 assert!(
4293 start.elapsed() < Duration::from_secs(1),
4294 "check blocked for {:?}",
4295 start.elapsed()
4296 );
4297 assert!(err.to_string().contains("rate-limited"), "{err}");
4298 }
4299
4300 #[test]
4302 fn healthy_quota_passes_the_check() {
4303 let mut rl = make_rl(30, 900);
4304 rl.remaining = Some(4000);
4305 rl.reset_at = Some(u64::MAX / 2);
4306 let cell = Mutex::new(rl);
4307 assert!(check_rate_limit_backoff(&cell, "REST").is_ok());
4308 }
4309
4310 #[test]
4311 fn rate_limit_proactive_pause() {
4312 let mut rl = make_rl(30, 900);
4313 rl.remaining = Some(0);
4315 let future_epoch = std::time::SystemTime::now()
4316 .duration_since(std::time::UNIX_EPOCH)
4317 .unwrap()
4318 .as_secs()
4319 + 60;
4320 rl.reset_at = Some(future_epoch);
4321 let pause = rl.is_active();
4322 assert!(pause.is_some());
4323 assert!(pause.unwrap().as_secs() <= 62);
4325 }
4326
4327 #[test]
4328 fn rate_limit_proactive_pause_threshold() {
4329 let mut rl = make_rl(30, 900);
4330 rl.remaining = Some(15);
4332 let future_epoch = std::time::SystemTime::now()
4333 .duration_since(std::time::UNIX_EPOCH)
4334 .unwrap()
4335 .as_secs()
4336 + 60;
4337 rl.reset_at = Some(future_epoch);
4338 assert!(rl.is_active().is_none());
4339
4340 rl.remaining = Some(5);
4342 assert!(rl.is_active().is_some());
4343 }
4344
4345 #[test]
4346 fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
4347 assert_eq!(
4349 GhHttp::combined_status_state(0, 0, 0, true, None),
4350 "pending"
4351 );
4352 assert_eq!(
4354 GhHttp::combined_status_state(0, 0, 0, false, None),
4355 "success"
4356 );
4357 assert_eq!(
4359 GhHttp::combined_status_state(3, 1, 0, true, None),
4360 "failure"
4361 );
4362 assert_eq!(
4364 GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
4365 "success"
4366 );
4367 assert_eq!(
4369 GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
4370 "pending"
4371 );
4372 }
4373
4374 #[test]
4375 fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
4376 let resp = serde_json::json!({
4377 "data": {},
4378 "errors": [{ "message": "Something went wrong" }],
4379 });
4380
4381 let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
4382 .unwrap_err()
4383 .to_string();
4384 assert!(err_text.contains("missing /data/repository in GraphQL response"));
4385 assert!(err_text.contains("Something went wrong"));
4386 }
4387
4388 #[test]
4389 fn rate_limit_uses_reset_at_for_backoff() {
4390 let mut rl = make_rl(30, 900);
4391 let future_epoch = std::time::SystemTime::now()
4393 .duration_since(std::time::UNIX_EPOCH)
4394 .unwrap()
4395 .as_secs()
4396 + 45;
4397 rl.reset_at = Some(future_epoch);
4398 rl.record_rate_limit();
4399 assert!(rl.backoff_delay.as_secs() >= 45);
4401 assert!(rl.backoff_delay.as_secs() <= 47);
4402 }
4403
4404 #[tokio::test]
4405 async fn send_with_retries_propagates_make_req_error() {
4406 let guard = std::env::var_os("GH_TOKEN");
4407 unsafe { std::env::set_var("GH_TOKEN", "test_token") };
4409 let gh = test_client();
4410 let result = gh
4411 .send_with_retries(
4412 || Err(anyhow::anyhow!("simulated request clone failure")),
4413 false,
4414 )
4415 .await;
4416 assert!(result.is_err());
4417 let err = result.unwrap_err().to_string();
4418 assert_eq!(err, "simulated request clone failure");
4419 match guard {
4420 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
4422 None => unsafe { std::env::remove_var("GH_TOKEN") },
4424 }
4425 }
4426
4427 #[tokio::test]
4433 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
4434 let guard = std::env::var_os("GH_TOKEN");
4435 unsafe { std::env::set_var("GH_TOKEN", "test_token") };
4437
4438 let addr = {
4442 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4443 listener.local_addr().unwrap()
4444 };
4445 let url = format!("http://{addr}/");
4446
4447 let gh = test_client();
4448 let make_req = || Ok(gh.client.get(&url));
4449 let result = gh.send_with_retries(make_req, false).await;
4450
4451 assert!(result.is_err());
4452 assert!(
4453 !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
4454 "transport error must not trip the github:5xx circuit breaker"
4455 );
4456 assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
4457
4458 match guard {
4459 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
4461 None => unsafe { std::env::remove_var("GH_TOKEN") },
4463 }
4464 }
4465
4466 #[tokio::test]
4467 async fn post_json_raw_propagates_body_read_errors_on_success() {
4468 let guard = std::env::var_os("GH_TOKEN");
4469 unsafe { std::env::set_var("GH_TOKEN", "test_token") };
4471 let server_url = spawn_truncated_body_server("{").await;
4472 let gh = test_client();
4473
4474 let result = gh
4475 .post_json_raw(
4476 &format!("{server_url}/post"),
4477 &serde_json::json!({"ok": true}),
4478 )
4479 .await;
4480
4481 let err = result.unwrap_err().to_string();
4482 assert!(
4483 !err.contains("EOF while parsing a value"),
4484 "should surface the body read error, got: {err}"
4485 );
4486 assert!(
4487 err.contains("error decoding response body")
4488 || err.contains("request or response body error")
4489 || err.contains("end of file before message length reached"),
4490 "expected a transport/body read error, got: {err}"
4491 );
4492
4493 match guard {
4494 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
4496 None => unsafe { std::env::remove_var("GH_TOKEN") },
4498 }
4499 }
4500
4501 #[tokio::test]
4502 async fn patch_json_raw_propagates_body_read_errors_on_success() {
4503 let guard = std::env::var_os("GH_TOKEN");
4504 unsafe { std::env::set_var("GH_TOKEN", "test_token") };
4506 let server_url = spawn_truncated_body_server("{").await;
4507 let gh = test_client();
4508
4509 let result = gh
4510 .patch_json_raw(
4511 &format!("{server_url}/patch"),
4512 &serde_json::json!({"ok": true}),
4513 )
4514 .await;
4515
4516 let err = result.unwrap_err().to_string();
4517 assert!(
4518 !err.contains("EOF while parsing a value"),
4519 "should surface the body read error, got: {err}"
4520 );
4521 assert!(
4522 err.contains("error decoding response body")
4523 || err.contains("request or response body error")
4524 || err.contains("end of file before message length reached"),
4525 "expected a transport/body read error, got: {err}"
4526 );
4527
4528 match guard {
4529 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
4531 None => unsafe { std::env::remove_var("GH_TOKEN") },
4533 }
4534 }
4535
4536 #[tokio::test]
4537 async fn graphql_request_propagates_body_read_errors_on_success() {
4538 let guard = std::env::var_os("GH_TOKEN");
4539 unsafe { std::env::set_var("GH_TOKEN", "test_token") };
4541 let server_url = spawn_truncated_body_server("{").await;
4542 let gh = test_client();
4543 let query = "query { viewer { login } }";
4544
4545 let result = gh
4546 .graphql_request_to_url(
4547 &format!("{server_url}/graphql"),
4548 query,
4549 &[],
4550 Some(serde_json::json!({"k": "v"})),
4551 )
4552 .await;
4553
4554 let err = result.unwrap_err().to_string();
4555 assert!(
4556 !err.contains("EOF while parsing a value"),
4557 "should surface the body read error, got: {err}"
4558 );
4559 assert!(
4560 err.contains("error decoding response body")
4561 || err.contains("request or response body error")
4562 || err.contains("end of file before message length reached"),
4563 "expected a transport/body read error, got: {err}"
4564 );
4565
4566 match guard {
4567 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
4569 None => unsafe { std::env::remove_var("GH_TOKEN") },
4571 }
4572 }
4573
4574 #[cfg(test)]
4577 mod pagination {
4578 use super::super::*;
4579 use std::ffi::OsString;
4580 use std::sync::Arc;
4581 use wiremock::matchers::{method, path};
4582 use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate, matchers};
4583
4584 struct NotMatcher<M>(M);
4587
4588 impl<M: Match> Match for NotMatcher<M> {
4589 fn matches(&self, request: &Request) -> bool {
4590 !self.0.matches(request)
4591 }
4592 }
4593
4594 struct EnvVarGuard {
4595 key: &'static str,
4596 previous: Option<OsString>,
4597 }
4598
4599 impl EnvVarGuard {
4600 fn set(key: &'static str, value: &str) -> Self {
4601 let previous = std::env::var_os(key);
4602 unsafe { std::env::set_var(key, value) };
4604 Self { key, previous }
4605 }
4606 }
4607
4608 impl Drop for EnvVarGuard {
4609 fn drop(&mut self) {
4610 match self.previous.take() {
4611 Some(value) => unsafe { std::env::set_var(self.key, value) },
4613 None => unsafe { std::env::remove_var(self.key) },
4615 }
4616 }
4617 }
4618
4619 fn make_client() -> (GhHttp, EnvVarGuard) {
4622 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4624 let client = GhHttp {
4625 client: reqwest::Client::new(),
4626 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
4627 app: None,
4628 owner_only: false,
4629 cached_username: OnceLock::new(),
4630 api_base: None,
4631 };
4632 (client, guard)
4633 }
4634
4635 #[tokio::test]
4636 async fn get_all_pages_single_page() {
4637 let server = MockServer::start().await;
4638
4639 Mock::given(method("GET"))
4640 .and(path("/items"))
4641 .respond_with(
4642 ResponseTemplate::new(200)
4643 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4644 )
4645 .expect(1)
4646 .mount(&server)
4647 .await;
4648
4649 let (gh, _guard) = make_client();
4650 let url = format!("{}/items", server.uri());
4651 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4652
4653 assert_eq!(result.len(), 2);
4654 assert_eq!(result[0]["id"], 1);
4655 assert_eq!(result[1]["id"], 2);
4656 }
4657
4658 #[tokio::test]
4659 async fn get_all_pages_follows_link_next() {
4660 let server = MockServer::start().await;
4661
4662 Mock::given(method("GET"))
4664 .and(path("/items"))
4665 .respond_with(
4666 ResponseTemplate::new(200)
4667 .set_body_json(serde_json::json!([{"id": 1}]))
4668 .insert_header(
4669 "Link",
4670 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4671 ),
4672 )
4673 .expect(1)
4674 .mount(&server)
4675 .await;
4676
4677 Mock::given(method("GET"))
4679 .and(path("/items/page2"))
4680 .respond_with(
4681 ResponseTemplate::new(200)
4682 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4683 )
4684 .expect(1)
4685 .mount(&server)
4686 .await;
4687
4688 let (gh, _guard) = make_client();
4689 let url = format!("{}/items", server.uri());
4690 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4691
4692 assert_eq!(result.len(), 3);
4693 assert_eq!(result[0]["id"], 1);
4694 assert_eq!(result[1]["id"], 2);
4695 assert_eq!(result[2]["id"], 3);
4696 }
4697
4698 #[tokio::test]
4699 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4700 let server = MockServer::start().await;
4703
4704 Mock::given(method("GET"))
4705 .and(path("/items"))
4706 .respond_with(
4707 ResponseTemplate::new(200)
4708 .set_body_json(serde_json::json!([{"id": 1}]))
4709 .insert_header(
4710 "Link",
4711 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4712 ),
4713 )
4714 .expect(1)
4715 .mount(&server)
4716 .await;
4717
4718 Mock::given(method("GET"))
4720 .and(path("/items/page2"))
4721 .respond_with(
4722 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4723 )
4724 .expect(1)
4725 .mount(&server)
4726 .await;
4727
4728 let (gh, _guard) = make_client();
4729 let url = format!("{}/items", server.uri());
4730 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4731
4732 assert_eq!(result.len(), 2);
4734 }
4735
4736 #[tokio::test]
4737 async fn get_all_pages_stops_on_malformed_link_header() {
4738 let server = MockServer::start().await;
4741
4742 Mock::given(method("GET"))
4743 .and(path("/items"))
4744 .respond_with(
4745 ResponseTemplate::new(200)
4746 .set_body_json(serde_json::json!([{"id": 1}]))
4747 .insert_header("Link", "garbage; rel=\"next\""),
4748 )
4749 .expect(1)
4750 .mount(&server)
4751 .await;
4752
4753 let (gh, _guard) = make_client();
4754 let url = format!("{}/items", server.uri());
4755 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4756
4757 assert_eq!(result.len(), 1);
4759 }
4760
4761 #[tokio::test]
4762 async fn get_all_pages_returns_error_on_malformed_json_body() {
4763 let server = MockServer::start().await;
4764
4765 Mock::given(method("GET"))
4766 .and(path("/items"))
4767 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4768 .expect(3)
4769 .mount(&server)
4770 .await;
4771
4772 let (gh, _guard) = make_client();
4773 let url = format!("{}/items", server.uri());
4774 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4775
4776 assert!(result.is_err());
4777 let err = result.unwrap_err().to_string();
4778 assert!(
4779 err.contains("JSON decode failed") || err.contains("expected ident"),
4780 "error should mention JSON decode or serde error: {err}"
4781 );
4782 }
4783
4784 struct GlobalStateGuard;
4789
4790 impl Drop for GlobalStateGuard {
4791 fn drop(&mut self) {
4792 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4793 *rl = RateLimit::new();
4794 }
4795 if let Ok(mut cache) = PAGE_CACHE.lock() {
4796 cache.clear();
4797 }
4798 }
4799 }
4800
4801 #[tokio::test]
4802 async fn get_all_pages_replays_body_on_304() {
4803 let _reset = GlobalStateGuard;
4804 let server = MockServer::start().await;
4805
4806 Mock::given(method("GET"))
4808 .and(path("/issues"))
4809 .and(matchers::header_exists("authorization"))
4810 .and(NotMatcher(matchers::header_exists("if-none-match")))
4811 .respond_with(
4812 ResponseTemplate::new(200)
4813 .set_body_json(serde_json::json!([{"number": 1}]))
4814 .insert_header("ETag", "\"abc123\""),
4815 )
4816 .expect(1)
4817 .mount(&server)
4818 .await;
4819
4820 Mock::given(method("GET"))
4822 .and(path("/issues"))
4823 .and(matchers::header("if-none-match", "\"abc123\""))
4824 .respond_with(ResponseTemplate::new(304))
4825 .expect(1)
4826 .mount(&server)
4827 .await;
4828
4829 let (gh, _guard) = make_client();
4830 let url = format!("{}/issues", server.uri());
4831
4832 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4833 let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4834
4835 assert_eq!(first, second);
4836 assert_eq!(second.len(), 1);
4837 assert_eq!(second[0]["number"], 1);
4838 }
4839
4840 #[tokio::test]
4841 async fn exhausted_quota_skips_further_calls_until_reset() {
4842 let _reset = GlobalStateGuard;
4843 let server = MockServer::start().await;
4844
4845 let reset_at = std::time::SystemTime::now()
4846 .duration_since(std::time::UNIX_EPOCH)
4847 .unwrap()
4848 .as_secs()
4849 + 600;
4850
4851 Mock::given(method("GET"))
4854 .and(path("/issues"))
4855 .respond_with(
4856 ResponseTemplate::new(200)
4857 .set_body_json(serde_json::json!([{"number": 1}]))
4858 .insert_header("x-ratelimit-remaining", "0")
4859 .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4860 )
4861 .expect(1)
4862 .mount(&server)
4863 .await;
4864
4865 let (gh, _guard) = make_client();
4866 let url = format!("{}/issues", server.uri());
4867
4868 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4869 assert_eq!(first.len(), 1);
4870
4871 let start = Instant::now();
4873 let err = gh
4874 .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4875 .await
4876 .unwrap_err();
4877 assert!(
4878 start.elapsed() < Duration::from_secs(1),
4879 "call blocked for {:?} instead of failing fast",
4880 start.elapsed()
4881 );
4882 assert!(err.to_string().contains("rate-limited"), "{err}");
4883
4884 let m = rate_limit_metrics();
4886 assert_eq!(m.rest_remaining, Some(0));
4887 assert_eq!(m.rest_reset_at, Some(reset_at));
4888 assert!(GhHttp::is_rate_limited().is_some());
4889
4890 }
4892
4893 #[tokio::test]
4896 async fn get_all_pages_from_wrapper_single_page() {
4897 let server = MockServer::start().await;
4900
4901 Mock::given(method("GET"))
4902 .and(path("/check-runs"))
4903 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4904 "total_count": 2,
4905 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4906 })))
4907 .expect(1)
4908 .mount(&server)
4909 .await;
4910
4911 let (gh, _guard) = make_client();
4912 let url = format!("{}/check-runs", server.uri());
4913 let result: Vec<serde_json::Value> = gh
4914 .get_all_pages_from_wrapper(&url, "check_runs")
4915 .await
4916 .unwrap();
4917
4918 assert_eq!(result.len(), 2);
4919 assert_eq!(result[0]["id"], 1);
4920 assert_eq!(result[1]["id"], 2);
4921 }
4922
4923 #[tokio::test]
4924 async fn get_all_pages_from_wrapper_follows_link_next() {
4925 let server = MockServer::start().await;
4927
4928 Mock::given(method("GET"))
4929 .and(path("/check-runs"))
4930 .respond_with(
4931 ResponseTemplate::new(200)
4932 .set_body_json(serde_json::json!({
4933 "total_count": 3,
4934 "check_runs": [{"id": 1, "name": "build"}]
4935 }))
4936 .insert_header(
4937 "Link",
4938 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4939 ),
4940 )
4941 .expect(1)
4942 .mount(&server)
4943 .await;
4944
4945 Mock::given(method("GET"))
4946 .and(path("/check-runs/page2"))
4947 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4948 "total_count": 3,
4949 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4950 })))
4951 .expect(1)
4952 .mount(&server)
4953 .await;
4954
4955 let (gh, _guard) = make_client();
4956 let url = format!("{}/check-runs", server.uri());
4957 let result: Vec<serde_json::Value> = gh
4958 .get_all_pages_from_wrapper(&url, "check_runs")
4959 .await
4960 .unwrap();
4961
4962 assert_eq!(result.len(), 3);
4963 assert_eq!(result[0]["id"], 1);
4964 assert_eq!(result[1]["id"], 2);
4965 assert_eq!(result[2]["id"], 3);
4966 }
4967
4968 #[tokio::test]
4969 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4970 let server = MockServer::start().await;
4973
4974 Mock::given(method("GET"))
4975 .and(path("/check-runs"))
4976 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4977 "total_count": 0,
4978 "check_runs": []
4979 })))
4980 .expect(1)
4981 .mount(&server)
4982 .await;
4983
4984 let (gh, _guard) = make_client();
4985 let url = format!("{}/check-runs", server.uri());
4986 let result: Vec<serde_json::Value> = gh
4987 .get_all_pages_from_wrapper(&url, "check_runs")
4988 .await
4989 .unwrap();
4990
4991 assert_eq!(result.len(), 0);
4992 }
4993
4994 #[tokio::test]
4995 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
4996 let server = MockServer::start().await;
4997
4998 Mock::given(method("GET"))
4999 .and(path("/check-runs"))
5000 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
5001 .expect(1)
5002 .mount(&server)
5003 .await;
5004
5005 let (gh, _guard) = make_client();
5006 let url = format!("{}/check-runs", server.uri());
5007 let result = gh
5008 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
5009 .await;
5010
5011 assert!(result.is_err());
5012 let err = result.unwrap_err().to_string();
5013 assert!(
5014 err.contains("422") || err.contains("failed"),
5015 "error should indicate failure: {err}"
5016 );
5017 }
5018 }
5019
5020 #[cfg(test)]
5023 mod auth_retry {
5024 use super::test_client;
5025 use std::sync::Arc;
5026 use std::sync::atomic::{AtomicUsize, Ordering};
5027 use wiremock::matchers::method;
5028 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
5029
5030 fn bad_credentials() -> ResponseTemplate {
5031 ResponseTemplate::new(401).set_body_json(serde_json::json!({
5032 "message": "Bad credentials",
5033 "status": 401
5034 }))
5035 }
5036
5037 #[expect(
5038 clippy::await_holding_lock,
5039 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5040 )]
5041 #[tokio::test]
5042 async fn retries_once_with_fresh_token_after_401() {
5043 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5044 let prev = std::env::var_os("GH_TOKEN");
5045 unsafe { std::env::set_var("GH_TOKEN", "bad_token") };
5047
5048 let server = MockServer::start().await;
5049 let hits = Arc::new(AtomicUsize::new(0));
5050 let hits_in_handler = std::sync::Arc::clone(&hits);
5051 Mock::given(method("GET"))
5052 .respond_with(move |_req: &Request| {
5053 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
5054 unsafe { std::env::set_var("GH_TOKEN", "good_token") };
5058 bad_credentials()
5059 } else {
5060 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
5061 }
5062 })
5063 .mount(&server)
5064 .await;
5065
5066 let gh = test_client();
5067 let result: anyhow::Result<serde_json::Value> =
5068 gh.get_json(&format!("{}/x", server.uri())).await;
5069
5070 match prev {
5071 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
5073 None => unsafe { std::env::remove_var("GH_TOKEN") },
5075 }
5076
5077 assert!(
5078 result.is_ok(),
5079 "retry with re-resolved token should succeed: {:?}",
5080 result.err()
5081 );
5082 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
5083 }
5084
5085 #[expect(
5086 clippy::await_holding_lock,
5087 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5088 )]
5089 #[tokio::test]
5090 async fn double_401_bails_with_distinct_auth_error() {
5091 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5092 let prev = std::env::var_os("GH_TOKEN");
5093 unsafe { std::env::set_var("GH_TOKEN", "bad_token") };
5095
5096 let server = MockServer::start().await;
5097 let hits = Arc::new(AtomicUsize::new(0));
5098 let hits_in_handler = std::sync::Arc::clone(&hits);
5099 Mock::given(method("GET"))
5100 .respond_with(move |_req: &Request| {
5101 hits_in_handler.fetch_add(1, Ordering::SeqCst);
5102 bad_credentials()
5103 })
5104 .mount(&server)
5105 .await;
5106
5107 let gh = test_client();
5108 let result: anyhow::Result<serde_json::Value> =
5109 gh.get_json(&format!("{}/x", server.uri())).await;
5110
5111 match prev {
5112 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
5114 None => unsafe { std::env::remove_var("GH_TOKEN") },
5116 }
5117
5118 let err = result.expect_err("persisting 401 must fail").to_string();
5119 assert!(
5120 err.contains("token re-resolved after cache clear but still rejected"),
5121 "expected distinct auth-failure error, got: {err}"
5122 );
5123 assert_eq!(
5124 hits.load(Ordering::SeqCst),
5125 2,
5126 "must retry exactly once, not loop"
5127 );
5128 }
5129 }
5130
5131 mod leak_redaction {
5134 use super::GhHttp;
5135 use super::test_client;
5136 use std::sync::{Arc, Mutex};
5137 use wiremock::matchers::{method, path};
5138 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
5139
5140 const TOKEN: &str = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
5141
5142 fn leaky_body() -> String {
5143 format!("agent notes mentioning {TOKEN}")
5144 }
5145
5146 fn client_for(server: &MockServer) -> GhHttp {
5147 let mut gh = test_client();
5148 gh.api_base = Some(server.uri());
5149 gh
5150 }
5151
5152 fn posted_json_body(raw: &str) -> String {
5153 let payload: serde_json::Value =
5154 serde_json::from_str(raw).expect("request body is JSON");
5155 payload["body"]
5156 .as_str()
5157 .expect("payload has a string body")
5158 .to_string()
5159 }
5160
5161 fn assert_token_redacted(raw: &str) {
5162 assert!(
5163 !raw.contains(TOKEN),
5164 "planted token must not appear in the request payload, got: {raw}"
5165 );
5166 let body = posted_json_body(raw);
5167 assert!(
5168 body.contains("[REDACTED:github_token]"),
5169 "redacted marker missing from posted body: {body}"
5170 );
5171 assert!(
5172 body.contains("potential secret(s) detected and redacted"),
5173 "security notice missing from posted body: {body}"
5174 );
5175 }
5176
5177 #[expect(
5178 clippy::await_holding_lock,
5179 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5180 )]
5181 #[tokio::test]
5182 async fn add_comment_redacts_github_token() {
5183 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5184 let prev = std::env::var_os("GH_TOKEN");
5185 unsafe { std::env::set_var("GH_TOKEN", "test_token") };
5187
5188 let server = MockServer::start().await;
5189 let captured = Arc::new(Mutex::new(None::<String>));
5190 let captured_in_handler = std::sync::Arc::clone(&captured);
5191 Mock::given(method("POST"))
5192 .and(path("/repos/owner/repo/issues/1/comments"))
5193 .respond_with(move |req: &Request| {
5194 *captured_in_handler.lock().unwrap() =
5195 Some(String::from_utf8_lossy(&req.body).into_owned());
5196 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": 1 }))
5197 })
5198 .expect(1)
5199 .mount(&server)
5200 .await;
5201
5202 let gh = client_for(&server);
5203 gh.add_comment("owner/repo", "1", &leaky_body())
5204 .await
5205 .expect("add_comment should succeed");
5206
5207 match prev {
5208 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
5210 None => unsafe { std::env::remove_var("GH_TOKEN") },
5212 }
5213
5214 let raw = captured
5215 .lock()
5216 .unwrap()
5217 .clone()
5218 .expect("add_comment must send a request body");
5219 assert_token_redacted(&raw);
5220 }
5221
5222 #[expect(
5223 clippy::await_holding_lock,
5224 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5225 )]
5226 #[tokio::test]
5227 async fn create_pr_redacts_github_token() {
5228 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5229 let prev = std::env::var_os("GH_TOKEN");
5230 unsafe { std::env::set_var("GH_TOKEN", "test_token") };
5232
5233 let server = MockServer::start().await;
5234 let captured = Arc::new(Mutex::new(None::<String>));
5235 let captured_in_handler = std::sync::Arc::clone(&captured);
5236 Mock::given(method("POST"))
5237 .and(path("/repos/owner/repo/pulls"))
5238 .respond_with(move |req: &Request| {
5239 *captured_in_handler.lock().unwrap() =
5240 Some(String::from_utf8_lossy(&req.body).into_owned());
5241 ResponseTemplate::new(201).set_body_json(serde_json::json!({
5242 "html_url": "https://github.com/owner/repo/pull/1"
5243 }))
5244 })
5245 .expect(1)
5246 .mount(&server)
5247 .await;
5248
5249 let gh = client_for(&server);
5250 gh.create_pr("owner/repo", "title", &leaky_body(), "head", "main")
5251 .await
5252 .expect("create_pr should succeed");
5253
5254 match prev {
5255 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
5257 None => unsafe { std::env::remove_var("GH_TOKEN") },
5259 }
5260
5261 let raw = captured
5262 .lock()
5263 .unwrap()
5264 .clone()
5265 .expect("create_pr must send a request body");
5266 assert_token_redacted(&raw);
5267 }
5268
5269 #[expect(
5270 clippy::await_holding_lock,
5271 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
5272 )]
5273 #[tokio::test]
5274 async fn update_pr_body_redacts_github_token() {
5275 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5276 let prev = std::env::var_os("GH_TOKEN");
5277 unsafe { std::env::set_var("GH_TOKEN", "test_token") };
5279
5280 let server = MockServer::start().await;
5281 let captured = Arc::new(Mutex::new(None::<String>));
5282 let captured_in_handler = std::sync::Arc::clone(&captured);
5283 Mock::given(method("PATCH"))
5284 .and(path("/repos/owner/repo/pulls/7"))
5285 .respond_with(move |req: &Request| {
5286 *captured_in_handler.lock().unwrap() =
5287 Some(String::from_utf8_lossy(&req.body).into_owned());
5288 ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
5289 })
5290 .expect(1)
5291 .mount(&server)
5292 .await;
5293
5294 let gh = client_for(&server);
5295 gh.update_pr_body("owner/repo", 7, &leaky_body())
5296 .await
5297 .expect("update_pr_body should succeed");
5298
5299 match prev {
5300 Some(v) => unsafe { std::env::set_var("GH_TOKEN", v) },
5302 None => unsafe { std::env::remove_var("GH_TOKEN") },
5304 }
5305
5306 let raw = captured
5307 .lock()
5308 .unwrap()
5309 .clone()
5310 .expect("update_pr_body must send a request body");
5311 assert_token_redacted(&raw);
5312 }
5313 }
5314}