1use super::token;
12use super::types::{
13 GitHubAnnotation, GitHubCheckRun, GitHubComment, GitHubIssue, GitHubPullRequest, GitHubReview,
14 GitHubReviewComment, RequiredStatusCheck,
15};
16use crate::engine::cooldown as engine_cooldown;
17use anyhow::Context;
18use reqwest::{header, Client, Response, StatusCode};
19use serde::Serialize;
20use std::sync::{
21 atomic::{AtomicU64, Ordering},
22 Arc, Mutex, OnceLock,
23};
24use std::time::{Duration, Instant};
25use urlencoding;
26
27#[cfg(not(test))]
28const GITHUB_API: &str = "https://api.github.com";
29#[cfg(test)]
31const GITHUB_API: &str = "http://127.0.0.1:9";
32
33#[derive(Debug, serde::Deserialize)]
34struct BlockedByPayload {
35 #[serde(default)]
36 id: i64,
37 number: i64,
38 #[serde(default)]
39 state: String,
40 #[serde(default)]
41 state_reason: Option<String>,
42 #[serde(default)]
43 repository: Option<BlockedByRepoPayload>,
44}
45
46#[derive(Debug, serde::Deserialize)]
47struct BlockedByRepoPayload {
48 full_name: String,
49}
50
51pub(crate) fn is_full_commit_sha(sha: &str) -> bool {
52 let len = sha.len();
53 (len == 40 || len == 64) && sha.bytes().all(|b| b.is_ascii_hexdigit())
54}
55
56fn repo_from_url(url: &str) -> Option<String> {
59 let rest = url.split_once("/repos/")?.1;
60 let mut parts = rest.split(['/', '?']);
61 let owner = parts.next().filter(|s| !s.is_empty())?;
62 let name = parts.next().filter(|s| !s.is_empty())?;
63 Some(format!("{owner}/{name}"))
64}
65
66pub(crate) fn redact_posted_body(body: &str) -> String {
67 if !crate::security::has_leaks(body) {
68 return body.to_string();
69 }
70 let leaks = crate::security::scan(body);
71 let rules: Vec<&str> = leaks.iter().map(|leak| leak.rule).collect();
72 let warning = format!(
73 "\n\n> ⚠️ **Security Notice**: {} potential secret(s) detected and redacted: {}",
74 leaks.len(),
75 rules.join(", ")
76 );
77 format!("{}{warning}", crate::security::redact(body))
78}
79
80fn merge_pr_body(head_sha: &str) -> serde_json::Value {
81 serde_json::json!({
82 "merge_method": "squash",
83 "sha": head_sha,
84 })
85}
86
87fn parse_positive_issue_number(number: &str) -> anyhow::Result<i64> {
88 let parsed = number
89 .parse::<i64>()
90 .with_context(|| format!("invalid issue number: {number}"))?;
91 if parsed <= 0 {
92 anyhow::bail!("invalid issue number: {number}");
93 }
94 Ok(parsed)
95}
96
97struct RateLimit {
101 remaining: Option<u32>,
103 reset_at: Option<u64>,
105 backoff_until: Option<Instant>,
107 backoff_delay: Duration,
108 backoff_base: Duration,
109 backoff_max: Duration,
110 throttle_threshold: u32,
113}
114
115impl RateLimit {
116 fn new() -> Self {
117 let base = crate::config::get("gh.backoff.base_seconds")
118 .ok()
119 .and_then(|v| v.parse::<u64>().ok())
120 .unwrap_or(30);
121 let max = crate::config::get("gh.backoff.max_seconds")
122 .ok()
123 .and_then(|v| v.parse::<u64>().ok())
124 .unwrap_or(900);
125 let threshold = crate::config::get("gh.rate_limit.throttle_threshold")
126 .ok()
127 .and_then(|v| v.parse::<u32>().ok())
128 .unwrap_or(200);
129
130 Self {
131 remaining: None,
132 reset_at: None,
133 backoff_until: None,
134 backoff_delay: Duration::ZERO,
135 backoff_base: Duration::from_secs(base),
136 backoff_max: Duration::from_secs(max),
137 throttle_threshold: threshold,
138 }
139 }
140
141 fn update_from_headers(&mut self, headers: &header::HeaderMap) {
143 if let Some(v) = headers.get("x-ratelimit-remaining") {
144 self.remaining = v.to_str().ok().and_then(|s| s.parse().ok());
145 }
146 if let Some(v) = headers.get("x-ratelimit-reset") {
147 self.reset_at = v.to_str().ok().and_then(|s| s.parse().ok());
148 }
149 }
150
151 fn record_success(&mut self) {
153 if self.backoff_delay > Duration::ZERO {
154 tracing::info!("GitHub backoff cleared after successful API call");
155 }
156 self.backoff_delay = Duration::ZERO;
157 self.backoff_until = None;
158 }
159
160 fn record_rate_limit(&mut self) {
162 METRIC_RATE_LIMIT_HITS.fetch_add(1, Ordering::Relaxed);
163
164 if let Some(reset_epoch) = self.reset_at {
166 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
167 Ok(since_epoch) => {
168 let now_epoch = since_epoch.as_secs();
169 if reset_epoch > now_epoch {
170 let wait_secs = reset_epoch - now_epoch + 1;
171 self.backoff_delay = Duration::from_secs(wait_secs);
172 self.backoff_until = Some(Instant::now() + self.backoff_delay);
173 tracing::warn!(
174 wait_secs,
175 "GitHub rate limit hit, waiting until reset time"
176 );
177 return;
178 }
179 }
180 Err(e) => {
181 tracing::error!(
182 error = %e,
183 "System clock is before UNIX epoch; ignoring reset_at and falling back to exponential backoff"
184 );
185 }
186 }
187 }
188
189 self.backoff_delay = if self.backoff_delay.is_zero() {
191 self.backoff_base
192 } else {
193 (self.backoff_delay * 2).min(self.backoff_max)
194 };
195 self.backoff_until = Some(Instant::now() + self.backoff_delay);
196 tracing::warn!(
197 delay_secs = self.backoff_delay.as_secs(),
198 "GitHub rate limit hit, backing off (no reset header)"
199 );
200 }
201
202 fn backoff_remaining(&self) -> Option<Duration> {
204 if let Some(until) = self.backoff_until {
205 let now = Instant::now();
206 if now < until {
207 return Some(until - now);
208 }
209 }
210 None
211 }
212
213 fn proactive_wait_duration(&self) -> Option<Duration> {
220 if self.backoff_remaining().is_some() {
222 return None;
223 }
224 if let Some(remaining) = self.remaining {
225 if remaining < self.throttle_threshold {
226 if let Some(reset_epoch) = self.reset_at {
227 let now_epoch = std::time::SystemTime::now()
228 .duration_since(std::time::UNIX_EPOCH)
229 .unwrap_or_default()
230 .as_secs();
231 if now_epoch < reset_epoch {
232 return Some(Duration::from_secs(reset_epoch - now_epoch + 1));
233 }
234 }
235 }
236 }
237 None
238 }
239
240 fn is_active(&self) -> Option<Duration> {
242 self.backoff_remaining()
243 .or_else(|| self.proactive_wait_duration())
244 }
245}
246
247static REST_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
250 std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
251
252static GRAPHQL_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
254 std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
255
256static METRIC_RATE_LIMIT_HITS: AtomicU64 = AtomicU64::new(0);
260static METRIC_PROACTIVE_THROTTLES: AtomicU64 = AtomicU64::new(0);
262static METRIC_5XX_HITS: AtomicU64 = AtomicU64::new(0);
264
265fn check_rate_limit_backoff(cell: &Mutex<RateLimit>, api: &str) -> anyhow::Result<()> {
275 let Some(remaining) = cell.lock().ok().and_then(|rl| rl.is_active()) else {
276 return Ok(());
277 };
278 METRIC_PROACTIVE_THROTTLES.fetch_add(1, Ordering::Relaxed);
279 anyhow::bail!(
280 "GitHub {} API rate-limited, skipping call for {}s",
281 api,
282 remaining.as_secs()
283 );
284}
285
286static CALL_COUNTS: std::sync::LazyLock<Mutex<std::collections::HashMap<(String, String), u64>>> =
288 std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
289
290fn count_api_call(url: &str) {
293 let path = url.split('?').next().unwrap_or(url);
294 let key = match path.split_once("/repos/") {
295 Some((_, rest)) => {
296 let mut parts = rest.split('/');
297 let project = format!(
298 "{}/{}",
299 parts.next().unwrap_or(""),
300 parts.next().unwrap_or("")
301 );
302 (project, parts.next().unwrap_or("repo").to_string())
303 }
304 None if path.ends_with("/graphql") => ("-".to_string(), "graphql".to_string()),
305 None => ("-".to_string(), "other".to_string()),
306 };
307 if let Ok(mut counts) = CALL_COUNTS.lock() {
308 *counts.entry(key).or_insert(0) += 1;
309 }
310}
311
312pub fn drain_call_counts() -> Vec<(String, String, u64)> {
314 let mut rows: Vec<_> = CALL_COUNTS
315 .lock()
316 .map(|mut c| c.drain().map(|((p, k), n)| (p, k, n)).collect())
317 .unwrap_or_default();
318 rows.sort_by_key(|r| std::cmp::Reverse(r.2));
319 rows
320}
321
322struct CachedPage {
326 etag: String,
327 body: String,
328 next: Option<String>,
330}
331
332static PAGE_CACHE: std::sync::LazyLock<Mutex<std::collections::HashMap<String, CachedPage>>> =
340 std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
341
342const PAGE_CACHE_MAX: usize = 64;
345
346fn page_cache_key<T>(url: &str, query: Option<&[(&str, &str)]>) -> Option<String> {
350 let mut key = String::from(std::any::type_name::<T>());
351 key.push(' ');
352 key.push_str(url);
353 if let Some(q) = query {
354 for (i, (k, v)) in q.iter().enumerate() {
355 key.push(if i == 0 { '?' } else { '&' });
356 key.push_str(k);
357 key.push('=');
358 key.push_str(v);
359 }
360 }
361 if key.contains("since=") {
362 return None;
363 }
364 Some(key)
365}
366
367fn page_cache_etag(key: &str) -> Option<String> {
368 PAGE_CACHE.lock().ok()?.get(key).map(|c| c.etag.clone())
369}
370
371fn page_cache_hit(key: &str) -> Option<(String, Option<String>)> {
373 let cache = PAGE_CACHE.lock().ok()?;
374 let entry = cache.get(key)?;
375 Some((entry.body.clone(), entry.next.clone()))
376}
377
378fn page_cache_store(key: String, etag: String, body: String, next: Option<String>) {
379 let Ok(mut cache) = PAGE_CACHE.lock() else {
380 return;
381 };
382 if cache.len() >= PAGE_CACHE_MAX && !cache.contains_key(&key) {
385 cache.clear();
386 }
387 cache.insert(key, CachedPage { etag, body, next });
388}
389
390fn maybe_record_body_rate_limit(cell: &Mutex<RateLimit>, status: StatusCode, body: &str) {
393 if status == StatusCode::FORBIDDEN {
394 let lower = body.to_lowercase();
395 if lower.contains("rate limit")
396 || lower.contains("abuse detection")
397 || lower.contains("secondary rate")
398 {
399 if let Ok(mut rl) = cell.lock() {
400 rl.record_rate_limit();
401 }
402 }
403 }
404}
405
406fn record_response_for_limiter(cell: &Mutex<RateLimit>, resp: &Response) {
408 if let Ok(mut rl) = cell.lock() {
409 rl.update_from_headers(resp.headers());
410 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
413 rl.record_rate_limit();
414 } else if resp.status().is_success() || resp.status() == StatusCode::NOT_MODIFIED {
415 rl.record_success();
416 }
417 }
418 let status = resp.status();
420 if status.is_server_error() {
421 METRIC_5XX_HITS.fetch_add(1, Ordering::Relaxed);
422 if tokio::runtime::Handle::try_current().is_ok() {
423 tokio::spawn(async move {
424 crate::engine::cooldown::record_github_5xx().await;
425 });
426 }
427 }
428}
429
430#[derive(Clone)]
437pub struct GhHttp {
438 client: Client,
439 token_resolver: Arc<token::TokenResolver>,
440 app: Option<Arc<super::app::AppAuth>>,
442 owner_only: bool,
444 cached_username: OnceLock<String>,
445 #[cfg(test)]
446 api_base: Option<String>,
447}
448
449impl GhHttp {
450 pub fn new() -> anyhow::Result<Self> {
458 let client = Client::builder()
459 .user_agent("orch/0.1 (reqwest)")
460 .pool_max_idle_per_host(4)
461 .timeout(Duration::from_secs(30))
462 .build()
463 .map_err(|e| anyhow::anyhow!("failed to build HTTP client (TLS init): {e}"))?;
464
465 Ok(Self {
466 client,
467 token_resolver: token::shared(),
468 app: None,
469 owner_only: false,
470 cached_username: OnceLock::new(),
471 #[cfg(test)]
472 api_base: None,
473 })
474 }
475
476 pub fn owner() -> anyhow::Result<Self> {
479 let mut gh = Self::new()?;
480 gh.owner_only = true;
481 Ok(gh)
482 }
483
484 #[cfg(test)]
485 pub(crate) fn for_test(api_base: String, app: Option<Arc<super::app::AppAuth>>) -> Self {
486 Self {
487 client: Client::new(),
488 token_resolver: Arc::new(token::TokenResolver::default_env()),
489 app,
490 owner_only: false,
491 cached_username: OnceLock::new(),
492 api_base: Some(api_base),
493 }
494 }
495
496 #[cfg_attr(not(test), allow(clippy::unused_self))]
497 fn api_root(&self) -> &str {
498 #[cfg(test)]
499 if let Some(base) = &self.api_base {
500 return base;
501 }
502 GITHUB_API
503 }
504
505 pub fn is_rate_limited() -> Option<Duration> {
510 REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.is_active())
511 }
512
513 fn check_backoff(url: &str) -> anyhow::Result<()> {
515 count_api_call(url);
516 check_rate_limit_backoff(&REST_RATE_LIMIT, "REST")
517 }
518
519 fn check_graphql_backoff(url: &str) -> anyhow::Result<()> {
521 count_api_call(url);
522 check_rate_limit_backoff(&GRAPHQL_RATE_LIMIT, "GraphQL")
523 }
524
525 fn record_response(resp: &Response) {
526 record_response_for_limiter(&REST_RATE_LIMIT, resp);
527 }
528
529 fn record_graphql_response(resp: &Response) {
530 record_response_for_limiter(&GRAPHQL_RATE_LIMIT, resp);
531 }
532
533 fn maybe_record_rate_limit_from_body(status: StatusCode, body: &str) {
535 maybe_record_body_rate_limit(&REST_RATE_LIMIT, status, body);
536 }
537
538 fn maybe_record_graphql_rate_limit_from_body(status: StatusCode, body: &str) {
540 maybe_record_body_rate_limit(&GRAPHQL_RATE_LIMIT, status, body);
541 }
542
543 fn record_success() {
544 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
545 rl.record_success();
546 }
547 }
548
549 fn app(&self) -> Option<Arc<super::app::AppAuth>> {
552 if self.owner_only {
553 return None;
554 }
555
556 self.app.clone()
559 }
560
561 async fn auth_header(&self, url: &str) -> anyhow::Result<String> {
564 if let (Some(app), Some(repo)) = (self.app(), repo_from_url(url)) {
565 match app.token_for_repo(&repo).await {
566 Ok(Some(token)) => return Ok(format!("Bearer {token}")),
567 Ok(None) => {}
568 Err(e) => {
569 tracing::warn!(error = %e, repo, "GitHub App token unavailable, using the owner token");
570 }
571 }
572 }
573 match self.token_resolver.get_token().await? {
574 Some(token) if !token.is_empty() => Ok(format!("Bearer {token}")),
575 _ => anyhow::bail!("No GitHub token available — run `gh auth login` or set GH_TOKEN"),
576 }
577 }
578
579 async fn send_with_retries<F>(
582 &self,
583 mut make_req: F,
584 is_graphql: bool,
585 ) -> anyhow::Result<Response>
586 where
587 F: FnMut() -> anyhow::Result<reqwest::RequestBuilder>,
588 {
589 if engine_cooldown::is_agent_in_cooldown("github:5xx") {
591 if let Some(until) = engine_cooldown::cooldown_until("github:5xx") {
592 let now = chrono::Utc::now().timestamp();
593 let remaining = if until > now { until - now } else { 0 };
594 anyhow::bail!(
595 "GitHub API transient 5xx circuit-breaker active for {}s",
596 remaining
597 );
598 } else {
599 anyhow::bail!("GitHub API transient 5xx circuit-breaker active");
600 }
601 }
602
603 let attempts = crate::config::get("gh.5xx.retry_attempts")
605 .ok()
606 .and_then(|v| v.parse::<u32>().ok())
607 .unwrap_or(3);
608 let base_secs = crate::config::get("gh.5xx.base_backoff_seconds")
609 .ok()
610 .and_then(|v| v.parse::<u64>().ok())
611 .unwrap_or(1);
612 let max_secs = crate::config::get("gh.5xx.max_backoff_seconds")
613 .ok()
614 .and_then(|v| v.parse::<u64>().ok())
615 .unwrap_or(30);
616 let circuit_cooldown_secs = crate::config::get("gh.5xx.circuit_breaker_cooldown_seconds")
617 .ok()
618 .and_then(|v| v.parse::<u64>().ok())
619 .unwrap_or(300);
620
621 let mut last_err: Option<anyhow::Error> = None;
622 let mut auth_override: Option<String> = None;
625 let mut auth_retried = false;
626 for attempt in 0..attempts {
627 let mut req = make_req()?;
628 if let Some(auth) = &auth_override {
629 req = req.header(header::AUTHORIZATION, auth.clone());
630 }
631 let resp_result = req.send().await;
632
633 match resp_result {
634 Ok(resp) => {
635 if is_graphql {
637 Self::record_graphql_response(&resp);
638 } else {
639 Self::record_response(&resp);
640 }
641
642 if resp.status() == StatusCode::UNAUTHORIZED {
643 let resp_url = resp.url().to_string();
644 let status = resp.status();
645 let body = resp.text().await.unwrap_or_default();
646 if body.contains("Bad credentials") && !auth_retried {
647 tracing::warn!(
648 "GitHub API 401 Bad credentials — clearing cached token and re-resolving"
649 );
650 self.token_resolver.clear_cache().await;
651 if let Some(app) = self.app() {
652 app.invalidate();
653 }
654 match self.auth_header(&resp_url).await {
655 Ok(new_auth) => {
656 auth_retried = true;
657 auth_override = Some(new_auth);
658 }
659 Err(e) => {
660 last_err = Some(
661 e.context("GitHub token re-resolution failed after 401"),
662 );
663 break;
664 }
665 }
666 continue;
668 }
669 if auth_retried {
670 self.token_resolver.clear_cache().await;
673 anyhow::bail!(
674 "GitHub authentication failed ({status}): token re-resolved after cache clear but still rejected"
675 );
676 }
677 anyhow::bail!("GitHub API request failed ({status}): {body}");
678 }
679
680 if resp.status().is_server_error() {
682 let status = resp.status();
683 let headers = resp.headers().clone();
684 let body = resp.text().await.unwrap_or_default();
685 let req_id = headers
686 .get("x-github-request-id")
687 .and_then(|v| v.to_str().ok())
688 .unwrap_or("<none>");
689 let snippet: String = body.chars().take(400).collect();
690 tracing::warn!(
691 status = %status,
692 request_id = %req_id,
693 attempt,
694 "GitHub server error (5xx) — attempt {attempt}: {snippet}"
695 );
696
697 last_err = Some(anyhow::anyhow!(
698 "GitHub API server error ({status}) request_id={}: {}",
699 req_id,
700 snippet
701 ));
702
703 if attempt + 1 < attempts {
704 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
707 let mut backoff = base_secs.saturating_mul(exp);
708 if backoff > max_secs {
709 backoff = max_secs;
710 }
711 let jitter_ms = attempt as u64 * 100;
713 let sleep_dur = Duration::from_secs(backoff)
714 + Duration::from_millis(jitter_ms.min(1000));
715 tokio::time::sleep(sleep_dur).await;
716 continue;
717 }
718 tracing::warn!(
720 "GitHub API 5xx persisted after {} attempts — setting circuit-breaker for {}s",
721 attempts,
722 circuit_cooldown_secs
723 );
724 engine_cooldown::set_agent_cooldown("github:5xx", circuit_cooldown_secs)
725 .await;
726 break;
727 }
728
729 return Ok(resp);
731 }
732 Err(e) => {
733 tracing::warn!(err = %e, attempt, "HTTP request failed to send (transport error, GitHub not reached), will retry if attempts remain");
740 last_err = Some(anyhow::anyhow!(
741 "HTTP request failed to send (transport error): {}",
742 e
743 ));
744 if attempt + 1 < attempts {
745 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
747 let backoff = base_secs.saturating_mul(exp);
748 let sleep_dur = Duration::from_secs(backoff)
749 + Duration::from_millis((attempt as u64 * 100).min(1000));
750 tokio::time::sleep(sleep_dur).await;
751 continue;
752 }
753 tracing::warn!(
757 "HTTP request failed to send after {} attempts (transport error, GitHub not reached) — not tripping the github:5xx circuit-breaker",
758 attempts
759 );
760 break;
761 }
762 }
763 }
764
765 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unexpected send_with_retries failure")))
766 }
767
768 async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> anyhow::Result<T> {
770 Self::check_backoff(url)?;
771 let auth = self.auth_header(url).await?;
772 let make_req = || {
774 Ok(self
775 .client
776 .get(url)
777 .header(header::AUTHORIZATION, auth.clone())
778 .header(header::ACCEPT, "application/vnd.github+json")
779 .header("X-GitHub-Api-Version", "2022-11-28"))
780 };
781 let resp = self.send_with_retries(make_req, false).await?;
782 let status = resp.status();
783 if !status.is_success() {
784 let body = resp.text().await.unwrap_or_default();
785 Self::maybe_record_rate_limit_from_body(status, &body);
786 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
787 }
788 let body = resp.text().await?;
789 serde_json::from_str(&body)
790 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
791 }
792
793 async fn get_bytes(&self, url: &str) -> anyhow::Result<Vec<u8>> {
795 Self::check_backoff(url)?;
796 let auth = self.auth_header(url).await?;
797 let make_req = || {
798 Ok(self
799 .client
800 .get(url)
801 .header(header::AUTHORIZATION, auth.clone())
802 .header(header::ACCEPT, "application/vnd.github+json")
803 .header("X-GitHub-Api-Version", "2022-11-28"))
804 };
805 let resp = self.send_with_retries(make_req, false).await?;
806 let status = resp.status();
807 if !status.is_success() {
808 let body = resp.text().await.unwrap_or_default();
809 Self::maybe_record_rate_limit_from_body(status, &body);
810 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
811 }
812 Ok(resp.bytes().await?.to_vec())
813 }
814
815 async fn get_with_query<T: serde::de::DeserializeOwned>(
817 &self,
818 url: &str,
819 query: &[(&str, &str)],
820 ) -> anyhow::Result<T> {
821 Self::check_backoff(url)?;
822 let auth = self.auth_header(url).await?;
823 let make_req = || {
824 Ok(self
825 .client
826 .get(url)
827 .query(query)
828 .header(header::AUTHORIZATION, auth.clone())
829 .header(header::ACCEPT, "application/vnd.github+json")
830 .header("X-GitHub-Api-Version", "2022-11-28"))
831 };
832 let resp = self.send_with_retries(make_req, false).await?;
833 let status = resp.status();
834 if !status.is_success() {
835 let body = resp.text().await.unwrap_or_default();
836 Self::maybe_record_rate_limit_from_body(status, &body);
837 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
838 }
839 let body = resp.text().await?;
840 serde_json::from_str(&body)
841 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
842 }
843
844 async fn post_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
846 Self::check_backoff(url)?;
847 let auth = self.auth_header(url).await?;
848 let make_req = || {
849 Ok(self
850 .client
851 .post(url)
852 .json(body)
853 .header(header::AUTHORIZATION, auth.clone())
854 .header(header::ACCEPT, "application/vnd.github+json")
855 .header("X-GitHub-Api-Version", "2022-11-28"))
856 };
857 let resp = self.send_with_retries(make_req, false).await?;
858 let status = resp.status();
859 let text_res = resp.text().await;
860 if !status.is_success() {
861 let text = text_res.unwrap_or_default();
862 Self::maybe_record_rate_limit_from_body(status, &text);
863 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
864 }
865 let text = text_res?;
866 Ok(text)
867 }
868
869 async fn post_json<T: serde::de::DeserializeOwned>(
871 &self,
872 url: &str,
873 body: &serde_json::Value,
874 ) -> anyhow::Result<T> {
875 let text = self.post_json_raw(url, body).await?;
876 Ok(serde_json::from_str(&text)?)
877 }
878
879 async fn patch_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
881 Self::check_backoff(url)?;
882 let auth = self.auth_header(url).await?;
883 let make_req = || {
884 Ok(self
885 .client
886 .patch(url)
887 .json(body)
888 .header(header::AUTHORIZATION, auth.clone())
889 .header(header::ACCEPT, "application/vnd.github+json")
890 .header("X-GitHub-Api-Version", "2022-11-28"))
891 };
892 let resp = self.send_with_retries(make_req, false).await?;
893 let status = resp.status();
894 let text_res = resp.text().await;
895 if !status.is_success() {
896 let text = text_res.unwrap_or_default();
897 Self::maybe_record_rate_limit_from_body(status, &text);
898 anyhow::bail!("GitHub API PATCH {url} failed ({status}): {text}");
899 }
900 let text = text_res?;
901 Ok(text)
902 }
903
904 async fn delete(&self, url: &str) -> anyhow::Result<StatusCode> {
906 Self::check_backoff(url)?;
907 let auth = self.auth_header(url).await?;
908 let make_req = || {
909 Ok(self
910 .client
911 .delete(url)
912 .header(header::AUTHORIZATION, auth.clone())
913 .header(header::ACCEPT, "application/vnd.github+json")
914 .header("X-GitHub-Api-Version", "2022-11-28"))
915 };
916 let resp = self.send_with_retries(make_req, false).await?;
917 let status = resp.status();
918 if !status.is_success() && status != StatusCode::NOT_FOUND {
919 let body = resp.text().await.unwrap_or_default();
920 Self::maybe_record_rate_limit_from_body(status, &body);
921 anyhow::bail!("GitHub API DELETE {url} failed ({status}): {body}");
922 }
923 Ok(status)
924 }
925
926 async fn get_all_pages<T: serde::de::DeserializeOwned>(
931 &self,
932 url: &str,
933 query: &[(&str, &str)],
934 ) -> anyhow::Result<Vec<T>> {
935 Self::check_backoff(url)?;
936 let mut all: Vec<T> = Vec::new();
937 let mut next_url: Option<String> = None;
938 let mut is_first = true;
939 const MAX_JSON_DECODE_RETRIES: u32 = 2;
940 const JSON_DECODE_RETRY_BACKOFF_MS: u64 = 200;
941
942 loop {
943 let (request_url, request_query) = if is_first {
944 is_first = false;
945 (url, Some(query))
946 } else {
947 let Some(u) = next_url.as_ref() else { break };
948 (u.as_str(), None)
949 };
950
951 let cache_key = page_cache_key::<T>(request_url, request_query);
952 let mut cached_etag = cache_key.as_deref().and_then(page_cache_etag);
953 let mut decode_attempt = 0;
954 let (page, next) = loop {
955 let auth = self.auth_header(request_url).await?;
956 let mut req = self
957 .client
958 .get(request_url)
959 .header(header::AUTHORIZATION, auth)
960 .header(header::ACCEPT, "application/vnd.github+json")
961 .header("X-GitHub-Api-Version", "2022-11-28");
962 if let Some(q) = request_query {
963 req = req.query(q);
964 }
965 if let Some(etag) = &cached_etag {
966 req = req.header(header::IF_NONE_MATCH, etag.clone());
967 }
968
969 let make_req = || {
970 req.try_clone()
971 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
972 };
973 let resp = self.send_with_retries(make_req, false).await?;
974 let status = resp.status();
975 let next_from_headers = parse_link_next(resp.headers());
976 let etag = resp
977 .headers()
978 .get(header::ETAG)
979 .and_then(|v| v.to_str().ok())
980 .map(|v| v.to_string());
981 let content_type = resp
982 .headers()
983 .get(header::CONTENT_TYPE)
984 .and_then(|v| v.to_str().ok())
985 .map(|v| v.to_string())
986 .unwrap_or_else(|| "<missing>".to_string());
987
988 if status == StatusCode::NOT_MODIFIED {
989 match cache_key.as_deref().and_then(page_cache_hit) {
990 Some((body, next)) => break (serde_json::from_str::<Vec<T>>(&body)?, next),
991 None => {
992 cached_etag = None;
997 continue;
998 }
999 }
1000 }
1001
1002 if !status.is_success() {
1003 let body = resp.text().await.unwrap_or_default();
1004 Self::maybe_record_rate_limit_from_body(status, &body);
1005 anyhow::bail!("GitHub API GET (paginated) failed ({status}): {body}");
1006 }
1007
1008 let text = resp.text().await?;
1009 match serde_json::from_str::<Vec<T>>(&text) {
1010 Ok(page) => {
1011 if let (Some(key), Some(etag)) = (cache_key.clone(), etag) {
1012 page_cache_store(key, etag, text, next_from_headers.clone());
1013 }
1014 break (page, next_from_headers);
1015 }
1016 Err(err) => {
1017 let snippet: String = text.chars().take(200).collect();
1018 let decode_error = anyhow::anyhow!(
1019 "GitHub API GET (paginated) JSON decode failed ({status}, content-type={content_type}): {snippet}"
1020 )
1021 .context(err);
1022 if decode_attempt < MAX_JSON_DECODE_RETRIES {
1023 decode_attempt += 1;
1024 tracing::warn!(
1025 attempt = decode_attempt,
1026 max_attempts = MAX_JSON_DECODE_RETRIES,
1027 status = %status,
1028 content_type,
1029 err = %decode_error,
1030 "json decode failed; retrying paginated GET"
1031 );
1032 tokio::time::sleep(Duration::from_millis(
1033 JSON_DECODE_RETRY_BACKOFF_MS * u64::from(decode_attempt),
1034 ))
1035 .await;
1036 continue;
1037 }
1038 return Err(decode_error);
1039 }
1040 }
1041 };
1042
1043 next_url = next;
1044 all.extend(page);
1045
1046 if next_url.is_none() {
1047 break;
1048 }
1049 }
1050
1051 Ok(all)
1052 }
1053
1054 async fn get_all_pages_from_wrapper<T: serde::de::DeserializeOwned>(
1065 &self,
1066 url: &str,
1067 array_field: &str,
1068 ) -> anyhow::Result<Vec<T>> {
1069 Self::check_backoff(url)?;
1070 let mut all: Vec<T> = Vec::new();
1071 let mut current_url: String = url.to_string();
1072
1073 loop {
1074 let auth = self.auth_header(¤t_url).await?;
1075 let make_req = || {
1076 self.client
1077 .get(¤t_url)
1078 .header(header::AUTHORIZATION, auth.clone())
1079 .header(header::ACCEPT, "application/vnd.github+json")
1080 .header("X-GitHub-Api-Version", "2022-11-28")
1081 .try_clone()
1082 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
1083 };
1084 let resp = self.send_with_retries(make_req, false).await?;
1085 let status = resp.status();
1086 let next = parse_link_next(resp.headers());
1087
1088 if !status.is_success() {
1089 let body = resp.text().await.unwrap_or_default();
1090 Self::maybe_record_rate_limit_from_body(status, &body);
1091 anyhow::bail!(
1092 "GitHub API GET (wrapped paginated) {current_url} failed ({status}): {body}"
1093 );
1094 }
1095
1096 let body = resp.text().await?;
1097 let wrapper: serde_json::Value = serde_json::from_str(&body).with_context(|| {
1098 format!("failed to parse wrapper response from {current_url}: {body}")
1099 })?;
1100 let items = wrapper
1101 .get(array_field)
1102 .and_then(|v| v.as_array())
1103 .cloned()
1104 .unwrap_or_default();
1105
1106 for item in items {
1107 match serde_json::from_value::<T>(item) {
1108 Ok(v) => all.push(v),
1109 Err(e) => {
1110 tracing::warn!(
1111 field = array_field,
1112 err = %e,
1113 "skipping item that failed to deserialize in wrapped pagination"
1114 );
1115 }
1116 }
1117 }
1118
1119 match next {
1120 Some(next_url) => current_url = next_url,
1121 None => break,
1122 }
1123 }
1124
1125 Ok(all)
1126 }
1127
1128 async fn graphql_request(
1130 &self,
1131 query: &str,
1132 extra_headers: &[(&str, &str)],
1133 variables: Option<serde_json::Value>,
1134 ) -> anyhow::Result<serde_json::Value> {
1135 self.graphql_request_to_url(
1136 &format!("{GITHUB_API}/graphql"),
1137 query,
1138 extra_headers,
1139 variables,
1140 )
1141 .await
1142 }
1143
1144 async fn graphql_request_to_url(
1145 &self,
1146 url: &str,
1147 query: &str,
1148 extra_headers: &[(&str, &str)],
1149 variables: Option<serde_json::Value>,
1150 ) -> anyhow::Result<serde_json::Value> {
1151 Self::check_graphql_backoff(url)?;
1152 let body = if let Some(vars) = variables {
1153 serde_json::json!({ "query": query, "variables": vars })
1154 } else {
1155 serde_json::json!({ "query": query })
1156 };
1157 let auth = self.auth_header(url).await?;
1158 let mut req = self
1159 .client
1160 .post(url)
1161 .json(&body)
1162 .header(header::AUTHORIZATION, auth)
1163 .header(header::ACCEPT, "application/vnd.github+json")
1164 .header("X-GitHub-Api-Version", "2022-11-28");
1165
1166 for (k, v) in extra_headers {
1167 req = req.header(*k, *v);
1168 }
1169
1170 let make_req = || {
1172 req.try_clone()
1173 .ok_or_else(|| anyhow::anyhow!("graphql request clone failed"))
1174 };
1175 let resp = self.send_with_retries(make_req, true).await?;
1176 let status = resp.status();
1177 let text_res = resp.text().await;
1178 if !status.is_success() {
1179 let text = text_res.unwrap_or_default();
1180 Self::maybe_record_graphql_rate_limit_from_body(status, &text);
1181 anyhow::bail!("GitHub GraphQL failed ({status}): {text}");
1182 }
1183 let text = text_res?;
1184 let json: serde_json::Value = serde_json::from_str(&text)?;
1185 if let Some(errors) = json.get("errors").and_then(|e| e.as_array()) {
1187 if !errors.is_empty() {
1188 let messages: Vec<&str> = errors
1189 .iter()
1190 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1191 .collect();
1192 if json.get("data").is_none() || json["data"].is_null() {
1193 anyhow::bail!("GitHub GraphQL errors: {}", messages.join("; "));
1195 }
1196 tracing::warn!(errors = ?messages, "GitHub GraphQL returned partial errors");
1198 }
1199 }
1200 Ok(json)
1201 }
1202
1203 pub async fn graphql_with_vars(
1206 &self,
1207 query: &str,
1208 variables: serde_json::Value,
1209 ) -> anyhow::Result<serde_json::Value> {
1210 self.graphql_request(query, &[], Some(variables)).await
1211 }
1212
1213 pub async fn graphql_with_headers(
1214 &self,
1215 query: &str,
1216 headers: &[&str],
1217 ) -> anyhow::Result<serde_json::Value> {
1218 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1220 self.graphql_request(query, &pairs, None).await
1221 }
1222
1223 pub async fn graphql_with_headers_and_vars(
1224 &self,
1225 query: &str,
1226 headers: &[&str],
1227 variables: serde_json::Value,
1228 ) -> anyhow::Result<serde_json::Value> {
1229 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1230 self.graphql_request(query, &pairs, Some(variables)).await
1231 }
1232
1233 pub async fn auth_status(&self) -> anyhow::Result<()> {
1238 match self
1239 .get_json::<serde_json::Value>(&format!("{GITHUB_API}/user"))
1240 .await
1241 {
1242 Ok(_) => Ok(()),
1243 Err(e) => {
1244 let err_str = e.to_string();
1245
1246 if err_str.contains("401") || err_str.contains("Bad credentials") {
1248 anyhow::bail!(
1249 "GitHub authentication failed: Invalid or expired token. \
1250 Check your GH_TOKEN/GITHUB_TOKEN environment variables, \
1251 or gh.auth configuration in ~/.orch/config.yml. \
1252 Run `orch auth check` to verify your setup."
1253 );
1254 }
1255
1256 if err_str.contains("403") && err_str.contains("rate limit") {
1257 anyhow::bail!(
1258 "GitHub API rate limited. Wait before retrying, \
1259 or check your token's rate limit quota."
1260 );
1261 }
1262
1263 Err(e)
1264 }
1265 }
1266 }
1267
1268 pub async fn create_issue(
1270 &self,
1271 repo: &str,
1272 title: &str,
1273 body: &str,
1274 labels: &[String],
1275 ) -> anyhow::Result<GitHubIssue> {
1276 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1277 let payload = serde_json::json!({
1278 "title": title,
1279 "body": body,
1280 "labels": labels,
1281 });
1282 self.post_json(&url, &payload).await
1283 }
1284
1285 pub async fn update_issue(
1289 &self,
1290 repo: &str,
1291 number: u64,
1292 title: &str,
1293 body: &str,
1294 labels: &[String],
1295 ) -> anyhow::Result<GitHubIssue> {
1296 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
1297 let payload = serde_json::json!({
1298 "title": title,
1299 "body": body,
1300 "labels": labels,
1301 });
1302 let text = self.patch_json_raw(&url, &payload).await?;
1303 serde_json::from_str(&text)
1304 .with_context(|| format!("failed to parse issue update response: {text}"))
1305 }
1306
1307 pub async fn list_blocked_by(
1315 &self,
1316 repo: &str,
1317 issue_number: u64,
1318 ) -> anyhow::Result<crate::backends::BlockedByFetch> {
1319 use crate::backends::{BlockedByFetch, BlockedByIssue};
1320
1321 let mut next = Some(format!(
1322 "{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by?per_page=100"
1323 ));
1324 let mut found = Vec::new();
1325 while let Some(url) = next.take() {
1326 let resp = match self
1327 .dependency_request(&url, reqwest::Method::GET, None)
1328 .await
1329 {
1330 Ok(resp) => resp,
1331 Err(err) => return Ok(BlockedByFetch::Failed(err.to_string())),
1332 };
1333 let status = resp.status();
1334 if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
1335 let body = resp.text().await.unwrap_or_default();
1336 if status == StatusCode::FORBIDDEN
1337 && body.to_ascii_lowercase().contains("rate limit")
1338 {
1339 Self::maybe_record_rate_limit_from_body(status, &body);
1340 return Ok(BlockedByFetch::Failed(body));
1341 }
1342 return Ok(BlockedByFetch::Unavailable);
1343 }
1344 if !status.is_success() {
1345 let body = resp.text().await.unwrap_or_default();
1346 Self::maybe_record_rate_limit_from_body(status, &body);
1347 return Ok(BlockedByFetch::Failed(format!("{status}: {body}")));
1348 }
1349 Self::record_success();
1350 let page_next = parse_link_next(resp.headers());
1351 let body = resp.text().await.unwrap_or_default();
1352 let page: Vec<BlockedByPayload> = match serde_json::from_str(&body) {
1353 Ok(page) => page,
1354 Err(err) => {
1355 return Ok(BlockedByFetch::Failed(format!(
1356 "blocked_by parse failed: {err}"
1357 )));
1358 }
1359 };
1360 for item in page {
1361 if item.number <= 0 {
1362 continue;
1363 }
1364 let item_repo = item
1365 .repository
1366 .as_ref()
1367 .map(|r| r.full_name.clone())
1368 .filter(|name| !name.is_empty())
1369 .unwrap_or_else(|| repo.to_string());
1370 found.push(BlockedByIssue {
1371 github_id: item.id,
1372 repo: item_repo,
1373 number: item.number,
1374 state: item.state,
1375 state_reason: item.state_reason,
1376 });
1377 }
1378 next = page_next;
1379 }
1380 Ok(BlockedByFetch::Native(found))
1381 }
1382
1383 pub async fn add_blocked_by(
1387 &self,
1388 repo: &str,
1389 issue_number: u64,
1390 blocking_issue_id: i64,
1391 ) -> anyhow::Result<()> {
1392 let url =
1393 format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/dependencies/blocked_by");
1394 let resp = self
1395 .dependency_request(
1396 &url,
1397 reqwest::Method::POST,
1398 Some(serde_json::json!({ "issue_id": blocking_issue_id })),
1399 )
1400 .await?;
1401 let status = resp.status();
1402 if status.is_success() {
1403 Self::record_success();
1404 return Ok(());
1405 }
1406 let body = resp.text().await.unwrap_or_default();
1407 if body.to_ascii_lowercase().contains("already") {
1408 return Ok(());
1409 }
1410 Self::maybe_record_rate_limit_from_body(status, &body);
1411 anyhow::bail!("add blocked_by failed ({status}): {body}")
1412 }
1413
1414 pub async fn remove_blocked_by(
1416 &self,
1417 repo: &str,
1418 number: u64,
1419 blocking_issue_id: u64,
1420 ) -> anyhow::Result<()> {
1421 let url = format!(
1422 "{GITHUB_API}/repos/{repo}/issues/{number}/dependencies/blocked_by/{blocking_issue_id}"
1423 );
1424 self.delete(&url).await?;
1425 Ok(())
1426 }
1427
1428 async fn dependency_request(
1430 &self,
1431 url: &str,
1432 method: reqwest::Method,
1433 body: Option<serde_json::Value>,
1434 ) -> anyhow::Result<Response> {
1435 Self::check_backoff(url)?;
1436 let auth = self.auth_header(url).await?;
1437 let make_req = || {
1438 let mut req = self
1439 .client
1440 .request(method.clone(), url)
1441 .header(header::AUTHORIZATION, auth.clone())
1442 .header(header::ACCEPT, "application/vnd.github+json")
1443 .header("X-GitHub-Api-Version", "2026-03-10");
1444 if let Some(ref payload) = body {
1445 req = req.json(payload);
1446 }
1447 Ok(req)
1448 };
1449 self.send_with_retries(make_req, false).await
1450 }
1451
1452 pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1454 self.get_json(&format!("{}/repos/{repo}/issues/{number}", self.api_root()))
1455 .await
1456 }
1457
1458 pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1460 self.get_issue(repo, number)
1461 .await
1462 .map(|i| i.pull_request.is_some())
1463 .unwrap_or(false)
1464 }
1465
1466 #[allow(dead_code)]
1468 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 #[allow(dead_code)]
1499 pub async fn list_issues_closed_since(
1500 &self,
1501 repo: &str,
1502 label: &str,
1503 since: &str,
1504 ) -> anyhow::Result<Vec<GitHubIssue>> {
1505 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1506 let all: Vec<GitHubIssue> = self
1507 .get_all_pages(
1508 &url,
1509 &[
1510 ("labels", label),
1511 ("state", "closed"),
1512 ("since", since),
1513 ("per_page", "100"),
1514 ],
1515 )
1516 .await?;
1517 Ok(all
1518 .into_iter()
1519 .filter(|i| i.pull_request.is_none())
1520 .collect())
1521 }
1522
1523 pub async fn list_closed_issues_since(
1525 &self,
1526 repo: &str,
1527 since: &str,
1528 ) -> anyhow::Result<Vec<GitHubIssue>> {
1529 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1530 let all: Vec<GitHubIssue> = self
1531 .get_all_pages(
1532 &url,
1533 &[("state", "closed"), ("since", since), ("per_page", "100")],
1534 )
1535 .await?;
1536 Ok(all
1537 .into_iter()
1538 .filter(|i| i.pull_request.is_none())
1539 .collect())
1540 }
1541
1542 pub async fn list_all_open_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1547 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1548 let params = [("state", "open"), ("per_page", "100")];
1549 let all: Vec<GitHubIssue> = self.get_all_pages(&url, ¶ms).await?;
1550 Ok(all
1552 .into_iter()
1553 .filter(|i| i.pull_request.is_none())
1554 .collect())
1555 }
1556
1557 pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1560 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1561 let all: Vec<serde_json::Value> = self
1562 .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1563 .await?;
1564 Ok(all)
1565 }
1566
1567 pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1571 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1572 let all: Vec<GitHubIssue> = self
1573 .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1574 .await?;
1575 Ok(all
1577 .into_iter()
1578 .filter(|i| i.pull_request.is_none())
1579 .collect())
1580 }
1581
1582 pub async fn add_labels(
1584 &self,
1585 repo: &str,
1586 number: &str,
1587 labels: &[String],
1588 ) -> anyhow::Result<()> {
1589 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1590 let payload = serde_json::json!({ "labels": labels });
1591 self.post_json_raw(&url, &payload).await?;
1592 Ok(())
1593 }
1594
1595 pub async fn ensure_label(
1597 &self,
1598 repo: &str,
1599 name: &str,
1600 color: &str,
1601 description: &str,
1602 ) -> anyhow::Result<()> {
1603 let encoded = urlencoding::encode(name);
1604 let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1605
1606 match self.get_bytes(&get_url).await {
1608 Ok(_) => return Ok(()),
1609 Err(e) if e.to_string().contains("404") => {
1610 tracing::debug!(repo, name, "ensure_label: label not found, creating");
1611 }
1612 Err(e) => {
1613 tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1614 }
1615 }
1616
1617 let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1619 let payload = serde_json::json!({
1620 "name": name,
1621 "color": color,
1622 "description": description,
1623 });
1624
1625 match self.post_json_raw(&create_url, &payload).await {
1626 Ok(_) => {
1627 Self::record_success();
1628 }
1629 Err(e) => {
1630 let msg = e.to_string();
1631 if msg.contains("422")
1632 || msg.contains("already_exists")
1633 || msg.contains("Unprocessable")
1634 {
1635 tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1636 return Ok(());
1637 }
1638 tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1640 }
1641 }
1642 Ok(())
1643 }
1644
1645 pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1647 let encoded = urlencoding::encode(label);
1648 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1649 match self.delete(&url).await {
1650 Ok(_) => Ok(()),
1651 Err(e) if e.to_string().contains("404") => {
1652 tracing::debug!(repo, number, label, "label already removed (404)");
1653 Ok(())
1654 }
1655 Err(e) => Err(e),
1656 }
1657 }
1658
1659 pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1661 let body = redact_posted_body(body);
1662 let url = format!("{}/repos/{repo}/issues/{number}/comments", self.api_root());
1663 let payload = serde_json::json!({ "body": body });
1664 self.post_json_raw(&url, &payload).await?;
1665 Ok(())
1666 }
1667
1668 pub async fn post_commit_status(
1670 &self,
1671 repo: &str,
1672 sha: &str,
1673 state: &str,
1674 context: &str,
1675 description: &str,
1676 ) -> anyhow::Result<()> {
1677 let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1678 let payload = serde_json::json!({
1679 "state": state,
1680 "context": context,
1681 "description": description,
1682 });
1683 self.post_json_raw(&url, &payload).await?;
1684 Ok(())
1685 }
1686
1687 pub async fn add_reaction(
1691 &self,
1692 repo: &str,
1693 number: &str,
1694 content: &str,
1695 ) -> anyhow::Result<()> {
1696 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1697 let payload = serde_json::json!({ "content": content });
1698 self.post_json_raw(&url, &payload).await?;
1699 Ok(())
1700 }
1701
1702 pub async fn add_comment_reaction(
1706 &self,
1707 repo: &str,
1708 comment_id: &str,
1709 content: &str,
1710 ) -> anyhow::Result<()> {
1711 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1712 let payload = serde_json::json!({ "content": content });
1713 self.post_json_raw(&url, &payload).await?;
1714 Ok(())
1715 }
1716
1717 pub async fn list_comments(
1719 &self,
1720 repo: &str,
1721 number: &str,
1722 ) -> anyhow::Result<Vec<GitHubComment>> {
1723 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1724 self.get_all_pages(&url, &[("per_page", "100")]).await
1725 }
1726
1727 pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1729 let owner = repo
1730 .split('/')
1731 .next()
1732 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1733 let head = format!("{}:{}", owner, branch);
1734
1735 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1736 let prs: Vec<serde_json::Value> = self
1737 .get_with_query(
1738 &url,
1739 &[("head", &head), ("state", "closed"), ("per_page", "1")],
1740 )
1741 .await?;
1742
1743 if prs.is_empty() {
1744 return Ok(false);
1745 }
1746 let merged_at = prs[0].get("merged_at");
1747 Ok(merged_at.map(|v| !v.is_null()).unwrap_or(false))
1748 }
1749
1750 pub async fn get_closed_pr_state(
1763 &self,
1764 repo: &str,
1765 branch: &str,
1766 ) -> anyhow::Result<Option<(u64, bool, String)>> {
1767 let owner = repo
1768 .split('/')
1769 .next()
1770 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1771 let head = format!("{}:{}", owner, branch);
1772
1773 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1774 let prs: Vec<serde_json::Value> = self
1775 .get_with_query(
1776 &url,
1777 &[("head", &head), ("state", "closed"), ("per_page", "5")],
1778 )
1779 .await?;
1780
1781 if prs.is_empty() {
1782 return Ok(None);
1783 }
1784
1785 for pr in prs {
1788 let pr_branch = pr
1789 .get("head")
1790 .and_then(|h| h.get("ref"))
1791 .and_then(|r| r.as_str())
1792 .unwrap_or("");
1793
1794 if pr_branch != branch {
1795 tracing::debug!(
1796 repo,
1797 branch,
1798 pr_branch,
1799 "skipping PR with mismatched head ref"
1800 );
1801 continue;
1802 }
1803
1804 let pr_number = pr
1805 .get("number")
1806 .and_then(|n| n.as_u64())
1807 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1808
1809 let merged_at = pr.get("merged_at");
1810 let merged = merged_at.map(|v| !v.is_null()).unwrap_or(false);
1811 let state = pr
1812 .get("state")
1813 .and_then(|v| v.as_str())
1814 .unwrap_or("closed")
1815 .to_string();
1816
1817 return Ok(Some((pr_number, merged, state)));
1818 }
1819
1820 Ok(None)
1822 }
1823
1824 pub async fn batch_is_pr_merged_by_branch(
1829 &self,
1830 repo: &str,
1831 branches: &[String],
1832 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1833 if branches.is_empty() {
1834 return Ok(std::collections::HashMap::new());
1835 }
1836
1837 let (owner, name) = repo
1838 .split_once('/')
1839 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1840
1841 let mut var_defs = String::new();
1845 let mut aliases = String::new();
1846 let mut vars = serde_json::json!({ "owner": owner, "name": name });
1847 for (i, branch) in branches.iter().enumerate() {
1848 var_defs.push_str(&format!(", $b{i}: String!"));
1849 aliases.push_str(&format!(
1850 r#"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "#
1851 ));
1852 vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1853 }
1854
1855 let query = format!(
1856 r#"query($owner: String!, $name: String!{var_defs}) {{
1857 repository(owner: $owner, name: $name) {{ {aliases} }}
1858 }}"#
1859 );
1860
1861 let resp = self.graphql_with_vars(&query, vars).await?;
1862
1863 if let Some(errors) = resp.get("errors").and_then(|e| e.as_array()) {
1865 if !errors.is_empty() {
1866 let messages: Vec<&str> = errors
1867 .iter()
1868 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1869 .collect();
1870 tracing::warn!(
1871 repo,
1872 errors = ?messages,
1873 "GraphQL returned errors in batch PR merge check"
1874 );
1875 }
1876 }
1877
1878 Self::parse_batch_pr_merged_response(&resp, branches)
1879 }
1880
1881 fn parse_batch_pr_merged_response(
1882 resp: &serde_json::Value,
1883 branches: &[String],
1884 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1885 let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1886 let error_msg = resp
1888 .get("errors")
1889 .and_then(|e| e.as_array())
1890 .and_then(|arr| arr.first())
1891 .and_then(|e| e.get("message"))
1892 .and_then(|m| m.as_str())
1893 .unwrap_or("no error details");
1894 anyhow::anyhow!(
1895 "missing /data/repository in GraphQL response: {}",
1896 error_msg
1897 )
1898 })?;
1899
1900 let mut result = std::collections::HashMap::new();
1901 for (i, branch) in branches.iter().enumerate() {
1902 let alias = format!("b{i}");
1903 let merged = repo_data
1904 .get(&alias)
1905 .and_then(|v| v.get("nodes"))
1906 .and_then(|nodes| nodes.as_array())
1907 .and_then(|arr| arr.first())
1908 .and_then(|node| node.get("merged"))
1909 .and_then(|v| v.as_bool())
1910 .unwrap_or(false);
1911 result.insert(branch.clone(), merged);
1912 }
1913
1914 Ok(result)
1915 }
1916
1917 pub async fn get_mentions(
1919 &self,
1920 repo: &str,
1921 since: &str,
1922 ) -> anyhow::Result<Vec<GitHubComment>> {
1923 let url = format!("{}/repos/{repo}/issues/comments", self.api_root());
1924 self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1925 .await
1926 }
1927
1928 pub async fn get_issue_comments(
1930 &self,
1931 repo: &str,
1932 issue_number: u64,
1933 ) -> anyhow::Result<Vec<GitHubComment>> {
1934 let url = format!(
1935 "{}/repos/{repo}/issues/{issue_number}/comments",
1936 self.api_root()
1937 );
1938 self.get_all_pages(&url, &[("per_page", "100")]).await
1939 }
1940
1941 pub async fn get_whoami(&self) -> anyhow::Result<String> {
1943 if let Some(cached) = self.cached_username.get() {
1944 return Ok(cached.clone());
1945 }
1946
1947 let mut owner = self.clone();
1949 owner.owner_only = true;
1950 let user: serde_json::Value = owner.get_json(&format!("{GITHUB_API}/user")).await?;
1951 let username = user
1952 .get("login")
1953 .and_then(|v| v.as_str())
1954 .map(String::from)
1955 .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1956
1957 let _ = self.cached_username.set(username.clone());
1958 Ok(username)
1959 }
1960
1961 pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1963 let owner = repo
1964 .split('/')
1965 .next()
1966 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1967 let head = format!("{}:{}", owner, branch);
1968
1969 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1970 let prs: Vec<serde_json::Value> = self
1971 .get_with_query(
1972 &url,
1973 &[("head", &head), ("state", "open"), ("per_page", "1")],
1974 )
1975 .await?;
1976
1977 if prs.is_empty() {
1978 return Ok(None);
1979 }
1980 prs[0]
1981 .get("number")
1982 .and_then(|n| n.as_u64())
1983 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1984 .map(Some)
1985 }
1986
1987 pub async fn create_pr(
1998 &self,
1999 repo: &str,
2000 title: &str,
2001 body: &str,
2002 head: &str,
2003 base: &str,
2004 ) -> anyhow::Result<String> {
2005 use reqwest::header;
2006
2007 let body = redact_posted_body(body);
2008 let url = format!("{}/repos/{repo}/pulls", self.api_root());
2009 Self::check_backoff(&url)?;
2010
2011 #[derive(Serialize)]
2012 struct CreatePullRequest<'a> {
2013 title: &'a str,
2014 body: &'a str,
2015 head: &'a str,
2016 base: &'a str,
2017 }
2018
2019 let payload = CreatePullRequest {
2020 title,
2021 body: &body,
2022 head,
2023 base,
2024 };
2025
2026 let auth = self.auth_header(&url).await?;
2027 let make_req = || {
2028 Ok(self
2029 .client
2030 .post(&url)
2031 .header(header::AUTHORIZATION, auth.clone())
2032 .header(header::ACCEPT, "application/vnd.github+json")
2033 .header(header::USER_AGENT, "orch")
2034 .header("X-GitHub-Api-Version", "2022-11-28")
2035 .json(&payload))
2036 };
2037 let response = self.send_with_retries(make_req, false).await?;
2038 let status = response.status();
2039 let text = response.text().await.unwrap_or_default();
2040 if !status.is_success() {
2041 Self::maybe_record_rate_limit_from_body(status, &text);
2042 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
2043 }
2044
2045 let pr: serde_json::Value = serde_json::from_str(&text)?;
2046 pr.get("html_url")
2047 .and_then(|v| v.as_str())
2048 .map(String::from)
2049 .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {:?}", pr))
2050 }
2051
2052 pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
2057 let url = format!("{GITHUB_API}/repos/{repo}");
2058 let resp: serde_json::Value = self.get_json(&url).await?;
2059 resp.get("default_branch")
2060 .and_then(|v| v.as_str())
2061 .map(String::from)
2062 .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
2063 }
2064
2065 pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
2068 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2069 self.get_json(&url).await
2070 }
2071
2072 pub async fn batch_get_pr_states(
2077 &self,
2078 repo: &str,
2079 pr_numbers: &[u64],
2080 ) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
2081 if pr_numbers.is_empty() {
2082 return Ok(std::collections::HashMap::new());
2083 }
2084
2085 let (owner, name) = repo
2086 .split_once('/')
2087 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2088
2089 const CHUNK_SIZE: usize = 50;
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.get("merged").and_then(|v| v.as_bool()).unwrap_or(false);
2114 let state = pr
2115 .get("state")
2116 .and_then(|v| v.as_str())
2117 .unwrap_or("UNKNOWN")
2118 .to_lowercase();
2119 result.insert(*n, (merged, state));
2120 }
2121 }
2122 }
2123
2124 Ok(result)
2125 }
2126
2127 pub async fn batch_get_issue_states(
2132 &self,
2133 repo: &str,
2134 issue_numbers: &[u64],
2135 caller_context: &str,
2136 ) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
2137 if issue_numbers.is_empty() {
2138 return Ok(std::collections::HashMap::new());
2139 }
2140
2141 let (valid_issue_numbers, invalid_issue_numbers) =
2142 sanitize_github_issue_numbers(issue_numbers);
2143 if !invalid_issue_numbers.is_empty() {
2144 tracing::warn!(
2145 repo,
2146 caller_context,
2147 invalid_issue_numbers = ?invalid_issue_numbers,
2148 total_supplied = issue_numbers.len(),
2149 "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
2150 );
2151 }
2152 if valid_issue_numbers.is_empty() {
2153 return Ok(std::collections::HashMap::new());
2154 }
2155
2156 let (owner, name) = repo
2157 .split_once('/')
2158 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2159
2160 const CHUNK_SIZE: usize = 50;
2162 let mut result = std::collections::HashMap::new();
2163
2164 for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
2165 let aliases: String = chunk
2166 .iter()
2167 .map(|n| {
2168 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
2169 })
2170 .collect::<Vec<_>>()
2171 .join(" ");
2172
2173 let query = format!(
2174 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2175 );
2176
2177 let resp = self
2178 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2179 .await?;
2180 let repo_data = resp
2181 .pointer("/data/repository")
2182 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2183
2184 for n in chunk {
2185 if let Some(issue) = repo_data.get(format!("issue{n}")) {
2186 let state = issue
2187 .get("state")
2188 .and_then(|v| v.as_str())
2189 .unwrap_or("UNKNOWN")
2190 .to_lowercase();
2191 let labels: Vec<String> = issue
2192 .pointer("/labels/nodes")
2193 .and_then(|v| v.as_array())
2194 .map(|arr| {
2195 arr.iter()
2196 .filter_map(|l| {
2197 l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2198 })
2199 .collect()
2200 })
2201 .unwrap_or_default();
2202 result.insert(n.to_string(), (state, labels));
2203 }
2204 }
2205 }
2206 Ok(result)
2207 }
2208
2209 pub async fn get_required_status_check_contexts(
2211 &self,
2212 repo: &str,
2213 branch: &str,
2214 ) -> anyhow::Result<Vec<RequiredStatusCheck>> {
2215 let branch = urlencoding::encode(branch);
2216 let classic_url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2217 let rules_url = format!("{GITHUB_API}/repos/{repo}/rules/branches/{branch}");
2218
2219 let classic_fut = self.get_json::<serde_json::Value>(&classic_url);
2220 let rules_fut = self.get_all_pages::<serde_json::Value>(&rules_url, &[("per_page", "100")]);
2221 let (classic_resp, rules_resp) = tokio::join!(classic_fut, rules_fut);
2222
2223 let classic = match classic_resp {
2224 Ok(value) => parse_classic_required_status_checks(&value),
2225 Err(e) => {
2226 if is_no_branch_protection_error(&e.to_string()) {
2227 Vec::new()
2228 } else {
2229 return Err(e);
2230 }
2231 }
2232 };
2233
2234 let ruleset = match rules_resp {
2235 Ok(rules) => parse_ruleset_required_status_checks(&rules),
2236 Err(e) => {
2237 if e.to_string().contains("404") {
2238 Vec::new()
2239 } else {
2240 return Err(e);
2241 }
2242 }
2243 };
2244
2245 Ok(merge_required_status_checks(classic, ruleset))
2246 }
2247
2248 pub async fn get_commit_status_contexts(
2250 &self,
2251 repo: &str,
2252 sha: &str,
2253 ) -> anyhow::Result<Vec<(String, String)>> {
2254 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2255 let statuses: Vec<serde_json::Value> =
2260 self.get_all_pages_from_wrapper(&url, "statuses").await?;
2261
2262 Ok(statuses
2263 .into_iter()
2264 .filter_map(|status| {
2265 let context = status.get("context").and_then(|v| v.as_str())?;
2266 let state = status.get("state").and_then(|v| v.as_str())?;
2267 Some((context.to_string(), state.to_string()))
2268 })
2269 .collect())
2270 }
2271
2272 fn combined_status_state(
2273 total: u64,
2274 failing: u64,
2275 pending: u64,
2276 has_workflows: bool,
2277 mergeable_state: Option<&str>,
2278 ) -> String {
2279 if failing > 0 {
2280 "failure".to_string()
2281 } else if pending > 0 {
2282 "pending".to_string()
2283 } else if total == 0 && has_workflows {
2284 if mergeable_state == Some("clean") {
2288 "success".to_string()
2289 } else {
2290 "pending".to_string()
2291 }
2292 } else {
2293 "success".to_string()
2294 }
2295 }
2296
2297 pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2302 let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2303 let resp = self.get_json::<serde_json::Value>(&url).await;
2304 match resp {
2305 Ok(value) => Ok(value
2306 .get("workflows")
2307 .and_then(|v| v.as_array())
2308 .map(|workflows| !workflows.is_empty())
2309 .unwrap_or(false)),
2310 Err(e) => {
2311 let err = e.to_string();
2312 if err.contains("404") {
2313 Ok(false)
2314 } else {
2315 Err(e)
2316 }
2317 }
2318 }
2319 }
2320
2321 pub async fn get_check_runs(
2323 &self,
2324 repo: &str,
2325 sha: &str,
2326 ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2327 let url = format!(
2338 "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2339 );
2340 self.get_all_pages_from_wrapper(&url, "check_runs").await
2341 }
2342
2343 pub async fn get_check_run_annotations(
2350 &self,
2351 repo: &str,
2352 check_run_id: u64,
2353 ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2354 let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2355 self.get_all_pages(&url, &[("per_page", "100")]).await
2356 }
2357
2358 pub async fn get_pr_reviews(
2360 &self,
2361 repo: &str,
2362 pr_number: u64,
2363 ) -> anyhow::Result<Vec<GitHubReview>> {
2364 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2365 self.get_all_pages(&url, &[("per_page", "100")]).await
2366 }
2367
2368 pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2373 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2374 let value: serde_json::Value = self.get_json(&url).await?;
2375 value
2376 .pointer("/commit/committer/date")
2377 .and_then(|v| v.as_str())
2378 .map(|s| s.to_string())
2379 .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2380 }
2381
2382 pub async fn update_pr_body(
2384 &self,
2385 repo: &str,
2386 pr_number: u64,
2387 body: &str,
2388 ) -> anyhow::Result<()> {
2389 let body = redact_posted_body(body);
2390 let url = format!("{}/repos/{repo}/pulls/{pr_number}", self.api_root());
2391 let payload = serde_json::json!({ "body": body });
2392 self.patch_json_raw(&url, &payload).await?;
2393 Ok(())
2394 }
2395
2396 pub async fn close_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2398 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2399 let payload = serde_json::json!({ "state": "closed" });
2400 self.patch_json_raw(&url, &payload).await?;
2401 Ok(())
2402 }
2403
2404 pub async fn compare_behind_by(
2409 &self,
2410 repo: &str,
2411 base: &str,
2412 head_sha: &str,
2413 ) -> anyhow::Result<u64> {
2414 let url = format!("{GITHUB_API}/repos/{repo}/compare/{base}...{head_sha}");
2415 let body: serde_json::Value = self.get_json(&url).await?;
2416 parse_behind_by(&body)
2417 }
2418
2419 pub async fn commit_meta(&self, repo: &str, sha: &str) -> anyhow::Result<CommitMeta> {
2421 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2422 let value: serde_json::Value = self.get_json(&url).await?;
2423 Ok(parse_commit_meta(&value))
2424 }
2425
2426 pub async fn branch_contains(
2428 &self,
2429 repo: &str,
2430 branch: &str,
2431 sha: &str,
2432 ) -> anyhow::Result<bool> {
2433 let url = format!("{GITHUB_API}/repos/{repo}/compare/{branch}...{sha}");
2434 let body: serde_json::Value = self.get_json(&url).await?;
2435 body.get("ahead_by")
2436 .and_then(|v| v.as_u64())
2437 .map(|ahead| ahead == 0)
2438 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric ahead_by field"))
2439 }
2440
2441 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2443 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2444 let payload = serde_json::json!({ "state": "closed" });
2445 self.patch_json_raw(&url, &payload).await?;
2446 Ok(())
2447 }
2448
2449 pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2451 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2452 let payload = serde_json::json!({ "state": "open" });
2453 self.patch_json_raw(&url, &payload).await?;
2454 Ok(())
2455 }
2456
2457 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2459 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2460 Self::check_backoff(&url)?;
2461 let auth = self.auth_header(&url).await?;
2462 let make_req = || {
2463 Ok(self
2464 .client
2465 .get(&url)
2466 .header(header::AUTHORIZATION, auth.clone())
2467 .header(header::ACCEPT, "application/vnd.github+json")
2468 .header("X-GitHub-Api-Version", "2022-11-28"))
2469 };
2470 let resp = self.send_with_retries(make_req, false).await?;
2471 let status = resp.status();
2472 if status == StatusCode::NO_CONTENT {
2473 Ok(true)
2474 } else if status == StatusCode::NOT_FOUND {
2475 Ok(false)
2476 } else {
2477 let body = resp.text().await.unwrap_or_default();
2478 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2479 }
2480 }
2481
2482 pub async fn add_sub_issue(
2484 &self,
2485 parent_node_id: &str,
2486 child_node_id: &str,
2487 ) -> anyhow::Result<()> {
2488 let query = format!(
2489 r#"mutation {{
2490 addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
2491 issue {{ number }}
2492 subIssue {{ number }}
2493 }}
2494 }}"#,
2495 parent_node_id, child_node_id
2496 );
2497 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2498 .await?;
2499 Ok(())
2500 }
2501
2502 pub async fn link_issue_to_branch(
2514 &self,
2515 repo: &str,
2516 issue_number: u64,
2517 branch: &str,
2518 ) -> anyhow::Result<String> {
2519 let parts: Vec<&str> = repo.split('/').collect();
2520 if parts.len() != 2 {
2521 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2522 }
2523 let (owner, repo_name) = (parts[0], parts[1]);
2524
2525 let query = r#"query($owner: String!, $name: String!, $number: Int!) {
2527 repository(owner: $owner, name: $name) {
2528 id
2529 issue(number: $number) {
2530 id
2531 }
2532 }
2533 }"#;
2534 let vars = serde_json::json!({
2535 "owner": owner,
2536 "name": repo_name,
2537 "number": issue_number,
2538 });
2539
2540 let result = self.graphql_with_vars(query, vars).await?;
2541
2542 let repo_id = result
2543 .pointer("/data/repository/id")
2544 .and_then(|v| v.as_str())
2545 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2546
2547 let issue_id = result
2548 .pointer("/data/repository/issue/id")
2549 .and_then(|v| v.as_str())
2550 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2551
2552 let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
2554 repository(owner: $owner, name: $name) {
2555 ref(qualifiedName: $ref) {
2556 target {
2557 oid
2558 }
2559 }
2560 }
2561 }"#;
2562 let branch_vars = serde_json::json!({
2563 "owner": owner,
2564 "name": repo_name,
2565 "ref": format!("refs/heads/{}", branch),
2566 });
2567
2568 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2569 let branch_oid = branch_result
2570 .pointer("/data/repository/ref/target/oid")
2571 .and_then(|v| v.as_str());
2572
2573 let branch_oid = match branch_oid {
2576 Some(oid) => oid.to_string(),
2577 None => {
2578 let default_query = r#"query($owner: String!, $name: String!) {
2580 repository(owner: $owner, name: $name) {
2581 defaultBranchRef {
2582 target {
2583 oid
2584 }
2585 }
2586 }
2587 }"#;
2588 let default_vars = serde_json::json!({
2589 "owner": owner,
2590 "name": repo_name,
2591 });
2592 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2593 match default_result
2594 .pointer("/data/repository/defaultBranchRef/target/oid")
2595 .and_then(|v| v.as_str())
2596 {
2597 Some(oid) => {
2598 tracing::debug!(
2599 repo,
2600 issue_number,
2601 branch,
2602 "branch not found, using default branch OID to create and link"
2603 );
2604 oid.to_string()
2605 }
2606 None => {
2607 return Ok(format!("unlinked:{}", branch));
2608 }
2609 }
2610 }
2611 };
2612
2613 let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2616 createLinkedBranch(input: {
2617 issueId: $issueId
2618 repositoryId: $repositoryId
2619 name: $name
2620 oid: $oid
2621 }) {
2622 linkedBranch {
2623 id
2624 ref {
2625 name
2626 }
2627 }
2628 }
2629 }"#;
2630 let mutation_vars = serde_json::json!({
2631 "issueId": issue_id,
2632 "repositoryId": repo_id,
2633 "name": branch,
2634 "oid": branch_oid,
2635 });
2636
2637 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2638
2639 match link_result {
2640 Ok(result) => {
2641 let linked_branch_id = result
2642 .pointer("/data/createLinkedBranch/linkedBranch/id")
2643 .and_then(|v| v.as_str())
2644 .map(String::from);
2645
2646 if let Some(id) = linked_branch_id {
2647 tracing::info!(
2648 repo,
2649 issue_number,
2650 branch,
2651 "linked issue to branch via GraphQL API"
2652 );
2653 Ok(id)
2654 } else {
2655 let errors = result
2657 .get("errors")
2658 .and_then(|e| e.as_array())
2659 .cloned()
2660 .unwrap_or_default();
2661
2662 if errors.iter().any(|e| {
2663 e.get("message")
2664 .and_then(|m| m.as_str())
2665 .map(|m| m.contains("already"))
2666 .unwrap_or(false)
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(|num| num.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(|h| h.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(|s| s.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!(
3024 r["conclusion"].as_str(),
3025 Some("failure") | Some("cancelled")
3026 )
3027 })
3028 .and_then(|r| r["id"].as_u64())
3029 .ok_or_else(|| {
3030 anyhow::anyhow!(
3031 "no failed run found for workflow '{}' on branch '{}'",
3032 workflow_name,
3033 branch
3034 )
3035 })?;
3036
3037 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
3039 let auth = self.auth_header(&rerun_url).await?;
3040 let make_req = || {
3041 Ok(self
3042 .client
3043 .post(&rerun_url)
3044 .header(header::AUTHORIZATION, auth.clone())
3045 .header(header::ACCEPT, "application/vnd.github+json")
3046 .header("X-GitHub-Api-Version", "2022-11-28"))
3047 };
3048 let resp = self.send_with_retries(make_req, false).await?;
3049 let status = resp.status();
3050 if !status.is_success() {
3051 let body = resp.text().await.unwrap_or_default();
3052 Self::maybe_record_rate_limit_from_body(status, &body);
3053 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
3054 }
3055
3056 Ok(())
3057 }
3058
3059 pub async fn batch_fetch_pr_review_data(
3065 &self,
3066 repo: &str,
3067 pr_numbers: &[u64],
3068 ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
3069 if pr_numbers.is_empty() {
3070 return Ok(std::collections::HashMap::new());
3071 }
3072
3073 let (owner, name) = repo
3074 .split_once('/')
3075 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
3076
3077 let aliases: String = pr_numbers
3079 .iter()
3080 .map(|&n| build_pr_review_alias(n))
3081 .collect::<Vec<_>>()
3082 .join("\n");
3083
3084 let query = format!(
3085 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
3086 );
3087
3088 let resp = self
3089 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
3090 .await?;
3091 let repo_data = resp
3092 .pointer("/data/repository")
3093 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
3094
3095 let mut result = std::collections::HashMap::new();
3096 for &n in pr_numbers {
3097 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
3098 continue;
3099 };
3100
3101 let merged = pr_data
3102 .get("merged")
3103 .and_then(|v| v.as_bool())
3104 .unwrap_or(false);
3105 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
3106 Some("MERGEABLE") => Some(true),
3107 Some("CONFLICTING") => Some(false),
3108 _ => None, };
3110
3111 let mut reviews: Vec<GitHubReview> = pr_data
3112 .pointer("/reviews/nodes")
3113 .and_then(|v| v.as_array())
3114 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
3115 .unwrap_or_default();
3116
3117 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
3118 tracing::warn!(
3119 pr_number = n,
3120 fetched = reviews.len(),
3121 "batch review fetch truncated reviews connection at first page"
3122 );
3123 }
3124
3125 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
3129
3130 let mut review_comments: Vec<GitHubReviewComment> = pr_data
3131 .pointer("/reviewThreads/nodes")
3132 .and_then(|v| v.as_array())
3133 .map(|threads| {
3134 threads
3135 .iter()
3136 .filter_map(|thread| {
3137 thread
3138 .pointer("/comments/nodes")
3139 .and_then(|v| v.as_array())
3140 .map(|nodes| {
3141 nodes
3142 .iter()
3143 .filter_map(parse_graphql_review_comment)
3144 .collect::<Vec<_>>()
3145 })
3146 })
3147 .flatten()
3148 .collect()
3149 })
3150 .unwrap_or_default();
3151
3152 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
3153 tracing::warn!(
3154 pr_number = n,
3155 fetched_threads = pr_data
3156 .pointer("/reviewThreads/nodes")
3157 .and_then(|v| v.as_array())
3158 .map_or(0, |threads| threads.len()),
3159 "batch review fetch truncated reviewThreads connection at first page"
3160 );
3161 }
3162 if pr_data
3163 .pointer("/reviewThreads/nodes")
3164 .and_then(|v| v.as_array())
3165 .is_some_and(|threads| {
3166 threads.iter().any(|thread| {
3167 connection_has_next_page(thread.pointer("/comments/pageInfo"))
3168 })
3169 })
3170 {
3171 tracing::warn!(
3172 pr_number = n,
3173 "batch review fetch truncated one or more review thread comment connections"
3174 );
3175 }
3176
3177 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3180
3181 let mut issue_comments: Vec<GitHubComment> = pr_data
3182 .pointer("/comments/nodes")
3183 .and_then(|v| v.as_array())
3184 .map(|nodes| {
3185 nodes
3186 .iter()
3187 .filter_map(parse_graphql_issue_comment)
3188 .collect()
3189 })
3190 .unwrap_or_default();
3191
3192 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
3193 issue_comments.extend(
3194 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
3195 .await?,
3196 );
3197 }
3198
3199 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
3204
3205 result.insert(
3206 n,
3207 PrReviewBatchData {
3208 merged,
3209 head_sha: pr_data
3210 .get("headRefOid")
3211 .and_then(|v| v.as_str())
3212 .unwrap_or_default()
3213 .to_string(),
3214 mergeable,
3215 reviews,
3216 review_comments,
3217 issue_comments,
3218 },
3219 );
3220 }
3221
3222 Ok(result)
3223 }
3224
3225 async fn fetch_pr_issue_comments_after(
3226 &self,
3227 owner: &str,
3228 name: &str,
3229 pr_number: u64,
3230 mut cursor: Option<String>,
3231 ) -> anyhow::Result<Vec<GitHubComment>> {
3232 let mut comments = Vec::new();
3233 let mut page_count = 0;
3234 let max_pages = 50;
3235
3236 while let Some(current_cursor) = cursor.take() {
3237 page_count += 1;
3238 if page_count > max_pages {
3239 tracing::warn!(
3240 pr_number,
3241 fetched = comments.len(),
3242 "stopping PR issue comment pagination after max page limit"
3243 );
3244 break;
3245 }
3246
3247 let (query, vars) =
3248 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
3249 let resp = self.graphql_with_vars(&query, vars).await?;
3250 let comments_data = resp
3251 .pointer("/data/repository/pullRequest/comments")
3252 .ok_or_else(|| {
3253 anyhow::anyhow!(
3254 "missing /data/repository/pullRequest/comments in GraphQL response"
3255 )
3256 })?;
3257
3258 comments.extend(
3259 comments_data
3260 .get("nodes")
3261 .and_then(|v| v.as_array())
3262 .into_iter()
3263 .flatten()
3264 .filter_map(parse_graphql_issue_comment),
3265 );
3266
3267 cursor = connection_end_cursor(comments_data.get("pageInfo"));
3268 }
3269
3270 Ok(comments)
3271 }
3272
3273 pub async fn get_automated_review_status(
3275 &self,
3276 repo: &str,
3277 pr_number: u64,
3278 ) -> anyhow::Result<Option<String>> {
3279 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3280
3281 let app_login = super::app::bot_login().await;
3283 let mut newest: Option<&crate::github::types::GitHubComment> = None;
3284 for c in &comments {
3285 if !c.body.starts_with("## Automated Review") {
3286 continue;
3287 }
3288 let trusted = match app_login.as_deref() {
3289 Some(app) if super::app::same_login(&c.user.login, app) => Ok(true),
3290 _ => self.is_collaborator(repo, &c.user.login).await,
3291 };
3292 match trusted {
3293 Ok(true) => match newest {
3294 None => newest = Some(c),
3295 Some(prev) => {
3296 if c.created_at > prev.created_at {
3297 newest = Some(c);
3298 }
3299 }
3300 },
3301 Ok(false) => {
3302 tracing::warn!(
3303 user = %c.user.login,
3304 pr_number,
3305 "ignoring automated review comment from non-collaborator"
3306 );
3307 continue;
3308 }
3309 Err(e) => {
3310 tracing::debug!(
3311 user = %c.user.login,
3312 error = %e,
3313 "failed to check collaborator status, skipping comment"
3314 );
3315 continue;
3316 }
3317 }
3318 }
3319
3320 match newest {
3321 Some(c) => {
3322 let first_line = c.body.lines().next().unwrap_or("");
3323 if first_line.contains("Automated Review \u{2014} Approve") {
3324 Ok(Some("approve".to_string()))
3325 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3326 Ok(Some("changes_requested".to_string()))
3327 } else {
3328 Ok(None)
3329 }
3330 }
3331 None => Ok(None),
3332 }
3333 }
3334}
3335
3336#[derive(Debug, Default, PartialEq)]
3342pub struct CommitMeta {
3343 pub parents: Vec<String>,
3344 pub author_login: Option<String>,
3345 pub committer_login: Option<String>,
3346}
3347
3348fn parse_commit_meta(commit: &serde_json::Value) -> CommitMeta {
3349 let login = |field: &str| {
3350 commit
3351 .pointer(&format!("/{field}/login"))
3352 .and_then(|v| v.as_str())
3353 .map(String::from)
3354 };
3355 CommitMeta {
3356 parents: commit
3357 .get("parents")
3358 .and_then(|p| p.as_array())
3359 .map(|parents| {
3360 parents
3361 .iter()
3362 .filter_map(|p| p.get("sha").and_then(|s| s.as_str()).map(String::from))
3363 .collect()
3364 })
3365 .unwrap_or_default(),
3366 author_login: login("author"),
3367 committer_login: login("committer"),
3368 }
3369}
3370
3371fn parse_behind_by(body: &serde_json::Value) -> anyhow::Result<u64> {
3372 body.get("behind_by")
3373 .and_then(|v| v.as_u64())
3374 .ok_or_else(|| anyhow::anyhow!("compare response has no numeric behind_by field"))
3375}
3376
3377fn is_no_branch_protection_error(err: &str) -> bool {
3380 err.contains("404")
3381 || (err.contains("403")
3382 && err.contains("Upgrade to GitHub Pro or make this repository public"))
3383}
3384
3385fn parse_classic_required_status_checks(value: &serde_json::Value) -> Vec<RequiredStatusCheck> {
3387 if let Some(checks) = value
3388 .pointer("/required_status_checks/checks")
3389 .and_then(|v| v.as_array())
3390 {
3391 let parsed: Vec<RequiredStatusCheck> =
3392 checks.iter().filter_map(parse_classic_check).collect();
3393 if !parsed.is_empty() {
3394 return parsed;
3395 }
3396 }
3397
3398 value
3399 .pointer("/required_status_checks/contexts")
3400 .and_then(|v| v.as_array())
3401 .map(|contexts| {
3402 contexts
3403 .iter()
3404 .filter_map(|context| {
3405 context.as_str().map(|s| RequiredStatusCheck {
3406 context: s.to_string(),
3407 integration_id: None,
3408 })
3409 })
3410 .collect()
3411 })
3412 .unwrap_or_default()
3413}
3414
3415fn parse_classic_check(check: &serde_json::Value) -> Option<RequiredStatusCheck> {
3416 let context = check.get("context")?.as_str()?.to_string();
3417 let integration_id = check
3418 .get("app_id")
3419 .and_then(|v| v.as_i64())
3420 .filter(|&id| id > 0);
3421 Some(RequiredStatusCheck {
3422 context,
3423 integration_id,
3424 })
3425}
3426
3427fn parse_ruleset_required_status_checks(rules: &[serde_json::Value]) -> Vec<RequiredStatusCheck> {
3429 let mut out = Vec::new();
3430 for rule in rules {
3431 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3432 continue;
3433 }
3434 let Some(checks) = rule
3435 .pointer("/parameters/required_status_checks")
3436 .or_else(|| rule.get("required_status_checks"))
3437 .and_then(|v| v.as_array())
3438 else {
3439 continue;
3440 };
3441 for check in checks {
3442 let Some(context) = check.get("context").and_then(|v| v.as_str()) else {
3443 continue;
3444 };
3445 let integration_id = check
3446 .get("integration_id")
3447 .and_then(|v| v.as_i64())
3448 .filter(|&id| id > 0);
3449 out.push(RequiredStatusCheck {
3450 context: context.to_string(),
3451 integration_id,
3452 });
3453 }
3454 }
3455 out
3456}
3457
3458fn merge_required_status_checks(
3459 mut checks: Vec<RequiredStatusCheck>,
3460 extra: Vec<RequiredStatusCheck>,
3461) -> Vec<RequiredStatusCheck> {
3462 checks.extend(extra);
3463 let mut seen = std::collections::HashSet::new();
3464 checks.retain(|c| seen.insert((c.context.clone(), c.integration_id)));
3465 checks
3466}
3467
3468fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3469 let mut valid = Vec::with_capacity(issue_numbers.len());
3470 let mut invalid = Vec::new();
3471 for &issue_number in issue_numbers {
3472 if issue_number == 0 {
3473 invalid.push(issue_number);
3474 } else {
3475 valid.push(issue_number);
3476 }
3477 }
3478 (valid, invalid)
3479}
3480
3481#[derive(Debug, Clone)]
3485pub struct RateLimitMetrics {
3486 pub hits: u64,
3488 pub skipped_calls: u64,
3490 pub rest_remaining: Option<u32>,
3492 pub rest_reset_at: Option<u64>,
3494 pub graphql_remaining: Option<u32>,
3496 pub five_xx_hits: u64,
3498}
3499
3500pub fn rate_limit_metrics() -> RateLimitMetrics {
3502 let (rest_remaining, rest_reset_at) = REST_RATE_LIMIT
3503 .lock()
3504 .ok()
3505 .map(|rl| (rl.remaining, rl.reset_at))
3506 .unwrap_or((None, None));
3507 let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3508 RateLimitMetrics {
3509 hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3510 skipped_calls: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3511 rest_remaining,
3512 rest_reset_at,
3513 graphql_remaining,
3514 five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3515 }
3516}
3517
3518fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3522 let link = headers.get("link")?.to_str().ok()?;
3523 for part in link.split(',') {
3524 let part = part.trim();
3525 if part.contains("rel=\"next\"") {
3526 let start = part.find('<')? + 1;
3528 let end = part.find('>')?;
3529 if start >= end {
3530 return None;
3531 }
3532 return Some(part[start..end].to_string());
3533 }
3534 }
3535 None
3536}
3537
3538pub fn status_label_color(label: &str) -> &'static str {
3540 match label {
3541 "status:new" => "0e8a16",
3542 "status:routed" => "1d76db",
3543 "status:in_progress" => "fbca04",
3544 "status:done" => "6f42c1",
3545 "status:blocked" => "d73a4a",
3546 "status:in_review" => "0075ca",
3547 "status:approved" => "2ea44f",
3548 "status:needs_review" => "e4e669",
3549 "status:waiting" => "5319e7",
3550 _ => "c5def5",
3551 }
3552}
3553
3554#[derive(Debug, Clone, Default)]
3558pub struct PrReviewBatchData {
3559 pub merged: bool,
3561 pub head_sha: String,
3563 pub mergeable: Option<bool>,
3565 pub reviews: Vec<GitHubReview>,
3567 pub review_comments: Vec<GitHubReviewComment>,
3569 pub issue_comments: Vec<GitHubComment>,
3571}
3572
3573fn build_pr_review_alias(n: u64) -> String {
3576 format!(
3577 r#"pr{n}: pullRequest(number: {n}) {{
3578 merged
3579 mergeable
3580 headRefOid
3581 reviews(first: 50) {{
3582 nodes {{
3583 databaseId
3584 author {{ login }}
3585 body
3586 state
3587 url
3588 submittedAt
3589 commit {{ oid }}
3590 }}
3591 pageInfo {{ hasNextPage }}
3592 }}
3593 reviewThreads(first: 50) {{
3594 nodes {{
3595 comments(first: 20) {{
3596 nodes {{
3597 databaseId
3598 author {{ login }}
3599 body
3600 path
3601 line
3602 originalLine
3603 commit {{ oid }}
3604 originalCommit {{ oid }}
3605 url
3606 createdAt
3607 updatedAt
3608 replyTo {{ databaseId }}
3609 diffHunk
3610 }}
3611 pageInfo {{ hasNextPage }}
3612 }}
3613 }}
3614 pageInfo {{ hasNextPage }}
3615 }}
3616 comments(first: 50) {{
3617 nodes {{
3618 databaseId
3619 author {{ login }}
3620 body
3621 createdAt
3622 updatedAt
3623 url
3624 }}
3625 pageInfo {{ hasNextPage endCursor }}
3626 }}
3627}}"#
3628 )
3629}
3630
3631fn build_pr_issue_comments_page_query(
3632 owner: &str,
3633 name: &str,
3634 pr_number: u64,
3635 after_cursor: &str,
3636) -> (String, serde_json::Value) {
3637 let query = r#"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3638 repository(owner: $owner, name: $name) {
3639 pullRequest(number: $number) {
3640 comments(first: 100, after: $after) {
3641 nodes {
3642 databaseId
3643 author { login }
3644 body
3645 createdAt
3646 updatedAt
3647 url
3648 }
3649 pageInfo { hasNextPage endCursor }
3650 }
3651 }
3652 }
3653}"#
3654 .to_string();
3655 let vars = serde_json::json!({
3656 "owner": owner,
3657 "name": name,
3658 "number": pr_number,
3659 "after": after_cursor,
3660 });
3661 (query, vars)
3662}
3663
3664fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3665 page_info
3666 .and_then(|v| v.get("hasNextPage"))
3667 .and_then(|v| v.as_bool())
3668 .unwrap_or(false)
3669}
3670
3671fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3672 if !connection_has_next_page(page_info) {
3673 return None;
3674 }
3675
3676 page_info
3677 .and_then(|v| v.get("endCursor"))
3678 .and_then(|v| v.as_str())
3679 .map(str::to_owned)
3680}
3681
3682fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3683 use super::types::GitHubUser;
3684 Some(GitHubReview {
3685 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3686 user: GitHubUser {
3687 login: node
3688 .pointer("/author/login")
3689 .and_then(|v| v.as_str())?
3690 .to_string(),
3691 },
3692 body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3693 state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3694 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3695 submitted_at: node
3696 .get("submittedAt")
3697 .and_then(|v| v.as_str())
3698 .unwrap_or("")
3699 .to_string(),
3700 commit_id: node
3701 .pointer("/commit/oid")
3702 .and_then(|v| v.as_str())
3703 .map(String::from),
3704 })
3705}
3706
3707fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3708 use super::types::GitHubUser;
3709 Some(GitHubReviewComment {
3710 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3711 user: GitHubUser {
3712 login: node
3713 .pointer("/author/login")
3714 .and_then(|v| v.as_str())?
3715 .to_string(),
3716 },
3717 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3718 path: node
3719 .get("path")
3720 .and_then(|v| v.as_str())
3721 .unwrap_or("")
3722 .to_string(),
3723 line: node.get("line").and_then(|v| v.as_u64()).map(|v| v as u32),
3724 original_line: node
3725 .get("originalLine")
3726 .and_then(|v| v.as_u64())
3727 .map(|v| v as u32),
3728 commit_id: node
3729 .pointer("/commit/oid")
3730 .and_then(|v| v.as_str())
3731 .unwrap_or("")
3732 .to_string(),
3733 original_commit_id: node
3734 .pointer("/originalCommit/oid")
3735 .and_then(|v| v.as_str())
3736 .unwrap_or("")
3737 .to_string(),
3738 html_url: node
3739 .get("url")
3740 .and_then(|v| v.as_str())
3741 .unwrap_or("")
3742 .to_string(),
3743 created_at: node
3744 .get("createdAt")
3745 .and_then(|v| v.as_str())
3746 .unwrap_or("")
3747 .to_string(),
3748 updated_at: node
3749 .get("updatedAt")
3750 .and_then(|v| v.as_str())
3751 .unwrap_or("")
3752 .to_string(),
3753 in_reply_to_id: node.pointer("/replyTo/databaseId").and_then(|v| v.as_u64()),
3754 diff_hunk: node
3755 .get("diffHunk")
3756 .and_then(|v| v.as_str())
3757 .map(String::from),
3758 })
3759}
3760
3761fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3762 use super::types::GitHubUser;
3763 Some(GitHubComment {
3764 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3765 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3766 user: GitHubUser {
3767 login: node
3768 .pointer("/author/login")
3769 .and_then(|v| v.as_str())?
3770 .to_string(),
3771 },
3772 created_at: node
3773 .get("createdAt")
3774 .and_then(|v| v.as_str())
3775 .unwrap_or("")
3776 .to_string(),
3777 updated_at: node
3778 .get("updatedAt")
3779 .and_then(|v| v.as_str())
3780 .map(String::from),
3781 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3782 issue_url: None,
3783 author_association: None,
3784 })
3785}
3786
3787#[cfg(test)]
3788mod tests {
3789 use super::*;
3790 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3791 use tokio::net::TcpListener;
3792
3793 #[test]
3794 fn repo_from_url_names_the_repository_or_nothing() {
3795 let api = "https://api.github.com";
3796 assert_eq!(
3797 repo_from_url(&format!("{api}/repos/me/a/pulls/9")).as_deref(),
3798 Some("me/a")
3799 );
3800 assert_eq!(
3801 repo_from_url(&format!("{api}/repos/org/b?per_page=1")).as_deref(),
3802 Some("org/b")
3803 );
3804 assert_eq!(
3805 repo_from_url(&format!("{api}/repos/org/b")).as_deref(),
3806 Some("org/b")
3807 );
3808 assert_eq!(repo_from_url(&format!("{api}/graphql")), None);
3809 assert_eq!(repo_from_url(&format!("{api}/user")), None);
3810 assert_eq!(repo_from_url(&format!("{api}/repos/only-owner")), None);
3811 }
3812
3813 #[test]
3814 fn no_branch_protection_errors() {
3815 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.\"}";
3816 assert!(is_no_branch_protection_error(free_plan));
3817 assert!(is_no_branch_protection_error(
3818 "failed (404 Not Found): {\"message\":\"Branch not protected\"}"
3819 ));
3820 assert!(!is_no_branch_protection_error(
3821 "failed (403 Forbidden): {\"message\":\"Resource not accessible\"}"
3822 ));
3823 }
3824
3825 #[test]
3826 fn parse_classic_required_status_checks_reads_checks_app_id() {
3827 let value = serde_json::json!({
3828 "required_status_checks": {
3829 "contexts": ["ci/test"],
3830 "checks": [{ "context": "ci/test", "app_id": 42 }]
3831 }
3832 });
3833 assert_eq!(
3834 parse_classic_required_status_checks(&value),
3835 vec![RequiredStatusCheck {
3836 context: "ci/test".to_string(),
3837 integration_id: Some(42),
3838 }]
3839 );
3840 }
3841
3842 #[test]
3843 fn parse_classic_required_status_checks_falls_back_to_contexts() {
3844 let value = serde_json::json!({
3845 "required_status_checks": {
3846 "contexts": ["ci/test", "lint"]
3847 }
3848 });
3849 assert_eq!(
3850 parse_classic_required_status_checks(&value),
3851 vec![
3852 RequiredStatusCheck {
3853 context: "ci/test".to_string(),
3854 integration_id: None,
3855 },
3856 RequiredStatusCheck {
3857 context: "lint".to_string(),
3858 integration_id: None,
3859 }
3860 ]
3861 );
3862 }
3863
3864 #[test]
3865 fn parse_classic_required_status_checks_empty_when_unprotected() {
3866 assert!(parse_classic_required_status_checks(&serde_json::json!({})).is_empty());
3867 }
3868
3869 #[test]
3870 fn parse_ruleset_required_status_checks_reads_integration_id() {
3871 let rules = vec![
3872 serde_json::json!({ "type": "pull_request" }),
3873 serde_json::json!({
3874 "type": "required_status_checks",
3875 "parameters": {
3876 "required_status_checks": [
3877 { "context": "ci/test", "integration_id": 42 },
3878 { "context": "lint" }
3879 ]
3880 }
3881 }),
3882 ];
3883 assert_eq!(
3884 parse_ruleset_required_status_checks(&rules),
3885 vec![
3886 RequiredStatusCheck {
3887 context: "ci/test".to_string(),
3888 integration_id: Some(42),
3889 },
3890 RequiredStatusCheck {
3891 context: "lint".to_string(),
3892 integration_id: None,
3893 }
3894 ]
3895 );
3896 }
3897
3898 #[test]
3899 fn parse_ruleset_required_status_checks_treats_zero_integration_id_as_any_app() {
3900 let rules = vec![serde_json::json!({
3901 "type": "required_status_checks",
3902 "parameters": {
3903 "required_status_checks": [{ "context": "ci/test", "integration_id": 0 }]
3904 }
3905 })];
3906 assert_eq!(
3907 parse_ruleset_required_status_checks(&rules),
3908 vec![RequiredStatusCheck {
3909 context: "ci/test".to_string(),
3910 integration_id: None,
3911 }]
3912 );
3913 }
3914
3915 #[test]
3916 fn merge_required_status_checks_dedupes_identical_pairs() {
3917 let classic = vec![RequiredStatusCheck {
3918 context: "ci/test".to_string(),
3919 integration_id: None,
3920 }];
3921 let ruleset = vec![
3922 RequiredStatusCheck {
3923 context: "ci/test".to_string(),
3924 integration_id: None,
3925 },
3926 RequiredStatusCheck {
3927 context: "ci/test".to_string(),
3928 integration_id: Some(42),
3929 },
3930 ];
3931 assert_eq!(
3932 merge_required_status_checks(classic, ruleset),
3933 vec![
3934 RequiredStatusCheck {
3935 context: "ci/test".to_string(),
3936 integration_id: None,
3937 },
3938 RequiredStatusCheck {
3939 context: "ci/test".to_string(),
3940 integration_id: Some(42),
3941 }
3942 ]
3943 );
3944 }
3945
3946 #[test]
3947 fn parse_commit_meta_reads_parents_in_order_and_logins() {
3948 let merge = serde_json::json!({
3949 "parents": [{"sha": "approved"}, {"sha": "base"}],
3950 "author": {"login": "owner"},
3951 "committer": {"login": "web-flow"},
3952 });
3953 let meta = parse_commit_meta(&merge);
3954 assert_eq!(meta.parents, vec!["approved", "base"]);
3955 assert_eq!(meta.author_login.as_deref(), Some("owner"));
3956 assert_eq!(meta.committer_login.as_deref(), Some("web-flow"));
3957 assert_eq!(
3958 parse_commit_meta(&serde_json::json!({})),
3959 CommitMeta::default()
3960 );
3961 }
3962
3963 #[test]
3964 fn parse_behind_by_reads_compare_status() {
3965 let behind = serde_json::json!({"status": "behind", "behind_by": 3, "ahead_by": 0});
3967 assert_eq!(parse_behind_by(&behind).unwrap(), 3);
3968 let diverged = serde_json::json!({"status": "diverged", "behind_by": 1, "ahead_by": 2});
3969 assert_eq!(parse_behind_by(&diverged).unwrap(), 1);
3970 let ahead = serde_json::json!({"status": "ahead", "behind_by": 0, "ahead_by": 2});
3971 assert_eq!(parse_behind_by(&ahead).unwrap(), 0);
3972 assert!(parse_behind_by(&serde_json::json!({"status": "ahead"})).is_err());
3974 }
3975
3976 async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3977 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3978 let addr = listener.local_addr().unwrap();
3979
3980 tokio::spawn(async move {
3981 let (mut socket, _) = listener.accept().await.unwrap();
3982 let mut buf = [0u8; 4096];
3983 let _ = socket.read(&mut buf).await;
3984
3985 let declared_len = body_prefix.len() + 32;
3986 let response = format!(
3987 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
3988 );
3989 socket.write_all(response.as_bytes()).await.unwrap();
3990 socket.shutdown().await.unwrap();
3991 });
3992
3993 format!("http://{addr}")
3994 }
3995
3996 #[test]
3997 fn merge_pr_body_pins_the_head_sha() {
3998 let sha = "0123456789abcdef0123456789abcdef01234567";
3999 let body = merge_pr_body(sha);
4000 assert_eq!(body["merge_method"], "squash");
4001 assert_eq!(body["sha"], sha);
4002 assert!(is_full_commit_sha(sha));
4003 assert!(!is_full_commit_sha("abc"));
4004 assert!(!is_full_commit_sha(""));
4005 }
4006
4007 fn test_client() -> GhHttp {
4008 GhHttp {
4009 client: reqwest::Client::new(),
4010 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
4011 app: None,
4012 owner_only: false,
4013 cached_username: OnceLock::new(),
4014 api_base: None,
4015 }
4016 }
4017
4018 #[test]
4023 fn gh_http_new_returns_ok() {
4024 let result = GhHttp::new();
4025 assert!(
4026 result.is_ok(),
4027 "GhHttp::new() must not panic and must return Ok; got: {:?}",
4028 result.err()
4029 );
4030 }
4031
4032 #[test]
4033 fn batch_get_issue_states_query_includes_labels_pagination() {
4034 let issue_numbers = [1u64, 2, 3];
4035
4036 let aliases: String = issue_numbers
4037 .iter()
4038 .map(|n| {
4039 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
4040 })
4041 .collect::<Vec<_>>()
4042 .join(" ");
4043
4044 let query = format!(
4045 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
4046 );
4047 assert!(query.contains("$owner"));
4048 assert!(query.contains("$name"));
4049 assert!(query.contains("issue1: issue(number: 1)"));
4050 assert!(query.contains("issue3: issue(number: 3)"));
4051 assert!(query.contains("labels(first: 100)"));
4052 }
4053
4054 #[test]
4055 fn parse_positive_issue_number_accepts_positive_values() {
4056 assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
4057 }
4058
4059 #[test]
4060 fn parse_positive_issue_number_rejects_zero() {
4061 let err = parse_positive_issue_number("0").unwrap_err().to_string();
4062 assert!(err.contains("invalid issue number: 0"));
4063 }
4064
4065 #[test]
4066 fn parse_positive_issue_number_rejects_internal_ids() {
4067 let err = parse_positive_issue_number("internal:5")
4068 .unwrap_err()
4069 .to_string();
4070 assert!(err.contains("invalid issue number: internal:5"));
4071 }
4072
4073 #[test]
4074 fn pr_review_batch_query_includes_page_info_for_nested_connections() {
4075 let query = build_pr_review_alias(42);
4076
4077 assert!(query.contains("reviews(first: 50)"));
4078 assert!(query.contains("reviews(first: 50) {\n nodes"));
4079 assert!(query.contains("pageInfo { hasNextPage }"));
4080 assert!(query.contains("reviewThreads(first: 50)"));
4081 assert!(query.contains("comments(first: 20)"));
4082 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4083 assert!(query.contains("updatedAt"));
4084 }
4085
4086 #[test]
4087 fn pr_issue_comments_page_query_uses_cursor_pagination() {
4088 let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
4089
4090 assert!(query.contains("pullRequest(number: $number)"));
4091 assert!(query.contains("comments(first: 100, after: $after)"));
4092 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
4093 assert!(query.contains("updatedAt"));
4094 assert_eq!(vars["owner"], "owner");
4095 assert_eq!(vars["name"], "repo");
4096 assert_eq!(vars["number"], 42);
4097 assert_eq!(vars["after"], "cursor123");
4098 }
4099
4100 #[test]
4101 fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
4102 let page_info = serde_json::json!({
4103 "hasNextPage": true,
4104 "endCursor": "cursor123"
4105 });
4106 assert_eq!(
4107 connection_end_cursor(Some(&page_info)),
4108 Some("cursor123".to_string())
4109 );
4110
4111 let final_page = serde_json::json!({
4112 "hasNextPage": false,
4113 "endCursor": "cursor456"
4114 });
4115 assert_eq!(connection_end_cursor(Some(&final_page)), None);
4116 }
4117
4118 #[test]
4119 fn status_label_colors_match_bash_palette() {
4120 assert_eq!(status_label_color("status:new"), "0e8a16");
4121 assert_eq!(status_label_color("status:routed"), "1d76db");
4122 assert_eq!(status_label_color("status:in_progress"), "fbca04");
4123 assert_eq!(status_label_color("status:done"), "6f42c1");
4124 assert_eq!(status_label_color("status:blocked"), "d73a4a");
4125 assert_eq!(status_label_color("status:in_review"), "0075ca");
4126 assert_eq!(status_label_color("status:needs_review"), "e4e669");
4127 }
4128
4129 #[test]
4130 fn unknown_label_gets_default_color() {
4131 assert_eq!(status_label_color("agent:claude"), "c5def5");
4132 assert_eq!(status_label_color("enhancement"), "c5def5");
4133 assert_eq!(status_label_color(""), "c5def5");
4134 }
4135
4136 #[test]
4137 fn parse_link_next_finds_next_url() {
4138 let mut headers = header::HeaderMap::new();
4139 headers.insert(
4140 "link",
4141 "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
4142 .parse()
4143 .unwrap(),
4144 );
4145 assert_eq!(
4146 parse_link_next(&headers),
4147 Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
4148 );
4149 }
4150
4151 #[test]
4152 fn parse_link_next_none_when_no_next() {
4153 let mut headers = header::HeaderMap::new();
4154 headers.insert(
4155 "link",
4156 "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
4157 .parse()
4158 .unwrap(),
4159 );
4160 assert_eq!(parse_link_next(&headers), None);
4161 }
4162
4163 #[test]
4164 fn parse_link_next_none_when_missing() {
4165 let headers = header::HeaderMap::new();
4166 assert_eq!(parse_link_next(&headers), None);
4167 }
4168
4169 #[test]
4170 fn parse_link_next_none_when_malformed() {
4171 let mut headers = header::HeaderMap::new();
4172 headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
4174 assert_eq!(parse_link_next(&headers), None);
4175 }
4176
4177 #[test]
4178 fn parse_link_next_none_when_gt_before_lt() {
4179 let mut headers = header::HeaderMap::new();
4180 headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
4181 assert_eq!(parse_link_next(&headers), None); }
4183
4184 #[test]
4185 fn parse_link_next_none_when_empty_url() {
4186 let mut headers = header::HeaderMap::new();
4187 headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
4188 assert_eq!(parse_link_next(&headers), None);
4189 }
4190
4191 fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
4192 RateLimit {
4193 remaining: None,
4194 reset_at: None,
4195 backoff_until: None,
4196 backoff_delay: Duration::ZERO,
4197 backoff_base: Duration::from_secs(base_secs),
4198 backoff_max: Duration::from_secs(max_secs),
4199 throttle_threshold: 10,
4200 }
4201 }
4202
4203 #[test]
4204 fn rate_limit_inactive_by_default() {
4205 let rl = make_rl(30, 900);
4206 assert!(rl.is_active().is_none());
4207 }
4208
4209 #[test]
4210 fn rate_limit_backoff_activates() {
4211 let mut rl = make_rl(5, 60);
4212 rl.record_rate_limit();
4213 assert!(rl.is_active().is_some());
4214 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4215 }
4216
4217 #[test]
4218 fn rate_limit_backoff_doubles() {
4219 let mut rl = make_rl(5, 60);
4220 rl.record_rate_limit();
4221 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
4222 rl.record_rate_limit();
4223 assert_eq!(rl.backoff_delay, Duration::from_secs(10));
4224 rl.record_rate_limit();
4225 assert_eq!(rl.backoff_delay, Duration::from_secs(20));
4226 }
4227
4228 #[test]
4229 fn rate_limit_backoff_caps_at_max() {
4230 let mut rl = make_rl(30, 60);
4231 rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
4235 }
4236
4237 #[test]
4238 fn rate_limit_success_resets() {
4239 let mut rl = make_rl(5, 60);
4240 rl.record_rate_limit();
4241 assert!(rl.is_active().is_some());
4242 rl.record_success();
4243 assert!(rl.is_active().is_none());
4244 assert_eq!(rl.backoff_delay, Duration::ZERO);
4245 }
4246
4247 #[test]
4248 fn page_cache_key_separates_types_and_queries() {
4249 let a = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap();
4250 let b = page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "closed")])).unwrap();
4251 let c = page_cache_key::<serde_json::Value>("/issues", Some(&[("state", "open")])).unwrap();
4252 assert_ne!(a, b);
4253 assert_ne!(a, c);
4254 assert_eq!(
4255 a,
4256 page_cache_key::<GitHubIssue>("/issues", Some(&[("state", "open")])).unwrap()
4257 );
4258 }
4259
4260 #[test]
4262 fn page_cache_key_skips_since_filtered_requests() {
4263 assert!(page_cache_key::<GitHubIssue>(
4264 "/issues",
4265 Some(&[("state", "closed"), ("since", "2026-10-01T00:00:00Z")])
4266 )
4267 .is_none());
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 std::env::set_var("GH_TOKEN", "test_token");
4408 let gh = test_client();
4409 let result = gh
4410 .send_with_retries(
4411 || Err(anyhow::anyhow!("simulated request clone failure")),
4412 false,
4413 )
4414 .await;
4415 assert!(result.is_err());
4416 let err = result.unwrap_err().to_string();
4417 assert_eq!(err, "simulated request clone failure");
4418 match guard {
4419 Some(v) => std::env::set_var("GH_TOKEN", v),
4420 None => std::env::remove_var("GH_TOKEN"),
4421 }
4422 }
4423
4424 #[tokio::test]
4430 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
4431 let guard = std::env::var_os("GH_TOKEN");
4432 std::env::set_var("GH_TOKEN", "test_token");
4433
4434 let addr = {
4438 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4439 listener.local_addr().unwrap()
4440 };
4441 let url = format!("http://{addr}/");
4442
4443 let gh = test_client();
4444 let make_req = || Ok(gh.client.get(&url));
4445 let result = gh.send_with_retries(make_req, false).await;
4446
4447 assert!(result.is_err());
4448 assert!(
4449 !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
4450 "transport error must not trip the github:5xx circuit breaker"
4451 );
4452 assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
4453
4454 match guard {
4455 Some(v) => std::env::set_var("GH_TOKEN", v),
4456 None => std::env::remove_var("GH_TOKEN"),
4457 }
4458 }
4459
4460 #[tokio::test]
4461 async fn post_json_raw_propagates_body_read_errors_on_success() {
4462 let guard = std::env::var_os("GH_TOKEN");
4463 std::env::set_var("GH_TOKEN", "test_token");
4464 let server_url = spawn_truncated_body_server("{").await;
4465 let gh = test_client();
4466
4467 let result = gh
4468 .post_json_raw(
4469 &format!("{server_url}/post"),
4470 &serde_json::json!({"ok": true}),
4471 )
4472 .await;
4473
4474 let err = result.unwrap_err().to_string();
4475 assert!(
4476 !err.contains("EOF while parsing a value"),
4477 "should surface the body read error, got: {err}"
4478 );
4479 assert!(
4480 err.contains("error decoding response body")
4481 || err.contains("request or response body error")
4482 || err.contains("end of file before message length reached"),
4483 "expected a transport/body read error, got: {err}"
4484 );
4485
4486 match guard {
4487 Some(v) => std::env::set_var("GH_TOKEN", v),
4488 None => std::env::remove_var("GH_TOKEN"),
4489 }
4490 }
4491
4492 #[tokio::test]
4493 async fn patch_json_raw_propagates_body_read_errors_on_success() {
4494 let guard = std::env::var_os("GH_TOKEN");
4495 std::env::set_var("GH_TOKEN", "test_token");
4496 let server_url = spawn_truncated_body_server("{").await;
4497 let gh = test_client();
4498
4499 let result = gh
4500 .patch_json_raw(
4501 &format!("{server_url}/patch"),
4502 &serde_json::json!({"ok": true}),
4503 )
4504 .await;
4505
4506 let err = result.unwrap_err().to_string();
4507 assert!(
4508 !err.contains("EOF while parsing a value"),
4509 "should surface the body read error, got: {err}"
4510 );
4511 assert!(
4512 err.contains("error decoding response body")
4513 || err.contains("request or response body error")
4514 || err.contains("end of file before message length reached"),
4515 "expected a transport/body read error, got: {err}"
4516 );
4517
4518 match guard {
4519 Some(v) => std::env::set_var("GH_TOKEN", v),
4520 None => std::env::remove_var("GH_TOKEN"),
4521 }
4522 }
4523
4524 #[tokio::test]
4525 async fn graphql_request_propagates_body_read_errors_on_success() {
4526 let guard = std::env::var_os("GH_TOKEN");
4527 std::env::set_var("GH_TOKEN", "test_token");
4528 let server_url = spawn_truncated_body_server("{").await;
4529 let gh = test_client();
4530 let query = "query { viewer { login } }";
4531
4532 let result = gh
4533 .graphql_request_to_url(
4534 &format!("{server_url}/graphql"),
4535 query,
4536 &[],
4537 Some(serde_json::json!({"k": "v"})),
4538 )
4539 .await;
4540
4541 let err = result.unwrap_err().to_string();
4542 assert!(
4543 !err.contains("EOF while parsing a value"),
4544 "should surface the body read error, got: {err}"
4545 );
4546 assert!(
4547 err.contains("error decoding response body")
4548 || err.contains("request or response body error")
4549 || err.contains("end of file before message length reached"),
4550 "expected a transport/body read error, got: {err}"
4551 );
4552
4553 match guard {
4554 Some(v) => std::env::set_var("GH_TOKEN", v),
4555 None => std::env::remove_var("GH_TOKEN"),
4556 }
4557 }
4558
4559 #[cfg(test)]
4562 mod pagination {
4563 use super::super::*;
4564 use std::ffi::OsString;
4565 use std::sync::Arc;
4566 use wiremock::matchers::{method, path};
4567 use wiremock::{matchers, Match, Mock, MockServer, Request, ResponseTemplate};
4568
4569 struct NotMatcher<M>(M);
4572
4573 impl<M: Match> Match for NotMatcher<M> {
4574 fn matches(&self, request: &Request) -> bool {
4575 !self.0.matches(request)
4576 }
4577 }
4578
4579 struct EnvVarGuard {
4580 key: &'static str,
4581 previous: Option<OsString>,
4582 }
4583
4584 impl EnvVarGuard {
4585 fn set(key: &'static str, value: &str) -> Self {
4586 let previous = std::env::var_os(key);
4587 std::env::set_var(key, value);
4588 Self { key, previous }
4589 }
4590 }
4591
4592 impl Drop for EnvVarGuard {
4593 fn drop(&mut self) {
4594 match self.previous.take() {
4595 Some(value) => std::env::set_var(self.key, value),
4596 None => std::env::remove_var(self.key),
4597 }
4598 }
4599 }
4600
4601 fn make_client() -> (GhHttp, EnvVarGuard) {
4604 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
4606 let client = GhHttp {
4607 client: reqwest::Client::new(),
4608 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
4609 app: None,
4610 owner_only: false,
4611 cached_username: OnceLock::new(),
4612 api_base: None,
4613 };
4614 (client, guard)
4615 }
4616
4617 #[tokio::test]
4618 async fn get_all_pages_single_page() {
4619 let server = MockServer::start().await;
4620
4621 Mock::given(method("GET"))
4622 .and(path("/items"))
4623 .respond_with(
4624 ResponseTemplate::new(200)
4625 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
4626 )
4627 .expect(1)
4628 .mount(&server)
4629 .await;
4630
4631 let (gh, _guard) = make_client();
4632 let url = format!("{}/items", server.uri());
4633 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4634
4635 assert_eq!(result.len(), 2);
4636 assert_eq!(result[0]["id"], 1);
4637 assert_eq!(result[1]["id"], 2);
4638 }
4639
4640 #[tokio::test]
4641 async fn get_all_pages_follows_link_next() {
4642 let server = MockServer::start().await;
4643
4644 Mock::given(method("GET"))
4646 .and(path("/items"))
4647 .respond_with(
4648 ResponseTemplate::new(200)
4649 .set_body_json(serde_json::json!([{"id": 1}]))
4650 .insert_header(
4651 "Link",
4652 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4653 ),
4654 )
4655 .expect(1)
4656 .mount(&server)
4657 .await;
4658
4659 Mock::given(method("GET"))
4661 .and(path("/items/page2"))
4662 .respond_with(
4663 ResponseTemplate::new(200)
4664 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
4665 )
4666 .expect(1)
4667 .mount(&server)
4668 .await;
4669
4670 let (gh, _guard) = make_client();
4671 let url = format!("{}/items", server.uri());
4672 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4673
4674 assert_eq!(result.len(), 3);
4675 assert_eq!(result[0]["id"], 1);
4676 assert_eq!(result[1]["id"], 2);
4677 assert_eq!(result[2]["id"], 3);
4678 }
4679
4680 #[tokio::test]
4681 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4682 let server = MockServer::start().await;
4685
4686 Mock::given(method("GET"))
4687 .and(path("/items"))
4688 .respond_with(
4689 ResponseTemplate::new(200)
4690 .set_body_json(serde_json::json!([{"id": 1}]))
4691 .insert_header(
4692 "Link",
4693 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4694 ),
4695 )
4696 .expect(1)
4697 .mount(&server)
4698 .await;
4699
4700 Mock::given(method("GET"))
4702 .and(path("/items/page2"))
4703 .respond_with(
4704 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4705 )
4706 .expect(1)
4707 .mount(&server)
4708 .await;
4709
4710 let (gh, _guard) = make_client();
4711 let url = format!("{}/items", server.uri());
4712 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4713
4714 assert_eq!(result.len(), 2);
4716 }
4717
4718 #[tokio::test]
4719 async fn get_all_pages_stops_on_malformed_link_header() {
4720 let server = MockServer::start().await;
4723
4724 Mock::given(method("GET"))
4725 .and(path("/items"))
4726 .respond_with(
4727 ResponseTemplate::new(200)
4728 .set_body_json(serde_json::json!([{"id": 1}]))
4729 .insert_header("Link", "garbage; rel=\"next\""),
4730 )
4731 .expect(1)
4732 .mount(&server)
4733 .await;
4734
4735 let (gh, _guard) = make_client();
4736 let url = format!("{}/items", server.uri());
4737 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4738
4739 assert_eq!(result.len(), 1);
4741 }
4742
4743 #[tokio::test]
4744 async fn get_all_pages_returns_error_on_malformed_json_body() {
4745 let server = MockServer::start().await;
4746
4747 Mock::given(method("GET"))
4748 .and(path("/items"))
4749 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4750 .expect(3)
4751 .mount(&server)
4752 .await;
4753
4754 let (gh, _guard) = make_client();
4755 let url = format!("{}/items", server.uri());
4756 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4757
4758 assert!(result.is_err());
4759 let err = result.unwrap_err().to_string();
4760 assert!(
4761 err.contains("JSON decode failed") || err.contains("expected ident"),
4762 "error should mention JSON decode or serde error: {}",
4763 err
4764 );
4765 }
4766
4767 struct GlobalStateGuard;
4772
4773 impl Drop for GlobalStateGuard {
4774 fn drop(&mut self) {
4775 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
4776 *rl = RateLimit::new();
4777 }
4778 if let Ok(mut cache) = PAGE_CACHE.lock() {
4779 cache.clear();
4780 }
4781 }
4782 }
4783
4784 #[tokio::test]
4785 async fn get_all_pages_replays_body_on_304() {
4786 let _reset = GlobalStateGuard;
4787 let server = MockServer::start().await;
4788
4789 Mock::given(method("GET"))
4791 .and(path("/issues"))
4792 .and(matchers::header_exists("authorization"))
4793 .and(NotMatcher(matchers::header_exists("if-none-match")))
4794 .respond_with(
4795 ResponseTemplate::new(200)
4796 .set_body_json(serde_json::json!([{"number": 1}]))
4797 .insert_header("ETag", "\"abc123\""),
4798 )
4799 .expect(1)
4800 .mount(&server)
4801 .await;
4802
4803 Mock::given(method("GET"))
4805 .and(path("/issues"))
4806 .and(matchers::header("if-none-match", "\"abc123\""))
4807 .respond_with(ResponseTemplate::new(304))
4808 .expect(1)
4809 .mount(&server)
4810 .await;
4811
4812 let (gh, _guard) = make_client();
4813 let url = format!("{}/issues", server.uri());
4814
4815 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4816 let second: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4817
4818 assert_eq!(first, second);
4819 assert_eq!(second.len(), 1);
4820 assert_eq!(second[0]["number"], 1);
4821 }
4822
4823 #[tokio::test]
4824 async fn exhausted_quota_skips_further_calls_until_reset() {
4825 let _reset = GlobalStateGuard;
4826 let server = MockServer::start().await;
4827
4828 let reset_at = std::time::SystemTime::now()
4829 .duration_since(std::time::UNIX_EPOCH)
4830 .unwrap()
4831 .as_secs()
4832 + 600;
4833
4834 Mock::given(method("GET"))
4837 .and(path("/issues"))
4838 .respond_with(
4839 ResponseTemplate::new(200)
4840 .set_body_json(serde_json::json!([{"number": 1}]))
4841 .insert_header("x-ratelimit-remaining", "0")
4842 .insert_header("x-ratelimit-reset", reset_at.to_string().as_str()),
4843 )
4844 .expect(1)
4845 .mount(&server)
4846 .await;
4847
4848 let (gh, _guard) = make_client();
4849 let url = format!("{}/issues", server.uri());
4850
4851 let first: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4852 assert_eq!(first.len(), 1);
4853
4854 let start = Instant::now();
4856 let err = gh
4857 .get_all_pages::<serde_json::Value>(&url, &[("state", "open")])
4858 .await
4859 .unwrap_err();
4860 assert!(
4861 start.elapsed() < Duration::from_secs(1),
4862 "call blocked for {:?} instead of failing fast",
4863 start.elapsed()
4864 );
4865 assert!(err.to_string().contains("rate-limited"), "{err}");
4866
4867 let m = rate_limit_metrics();
4869 assert_eq!(m.rest_remaining, Some(0));
4870 assert_eq!(m.rest_reset_at, Some(reset_at));
4871 assert!(GhHttp::is_rate_limited().is_some());
4872
4873 }
4875
4876 #[tokio::test]
4879 async fn get_all_pages_from_wrapper_single_page() {
4880 let server = MockServer::start().await;
4883
4884 Mock::given(method("GET"))
4885 .and(path("/check-runs"))
4886 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4887 "total_count": 2,
4888 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4889 })))
4890 .expect(1)
4891 .mount(&server)
4892 .await;
4893
4894 let (gh, _guard) = make_client();
4895 let url = format!("{}/check-runs", server.uri());
4896 let result: Vec<serde_json::Value> = gh
4897 .get_all_pages_from_wrapper(&url, "check_runs")
4898 .await
4899 .unwrap();
4900
4901 assert_eq!(result.len(), 2);
4902 assert_eq!(result[0]["id"], 1);
4903 assert_eq!(result[1]["id"], 2);
4904 }
4905
4906 #[tokio::test]
4907 async fn get_all_pages_from_wrapper_follows_link_next() {
4908 let server = MockServer::start().await;
4910
4911 Mock::given(method("GET"))
4912 .and(path("/check-runs"))
4913 .respond_with(
4914 ResponseTemplate::new(200)
4915 .set_body_json(serde_json::json!({
4916 "total_count": 3,
4917 "check_runs": [{"id": 1, "name": "build"}]
4918 }))
4919 .insert_header(
4920 "Link",
4921 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4922 ),
4923 )
4924 .expect(1)
4925 .mount(&server)
4926 .await;
4927
4928 Mock::given(method("GET"))
4929 .and(path("/check-runs/page2"))
4930 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4931 "total_count": 3,
4932 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4933 })))
4934 .expect(1)
4935 .mount(&server)
4936 .await;
4937
4938 let (gh, _guard) = make_client();
4939 let url = format!("{}/check-runs", server.uri());
4940 let result: Vec<serde_json::Value> = gh
4941 .get_all_pages_from_wrapper(&url, "check_runs")
4942 .await
4943 .unwrap();
4944
4945 assert_eq!(result.len(), 3);
4946 assert_eq!(result[0]["id"], 1);
4947 assert_eq!(result[1]["id"], 2);
4948 assert_eq!(result[2]["id"], 3);
4949 }
4950
4951 #[tokio::test]
4952 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4953 let server = MockServer::start().await;
4956
4957 Mock::given(method("GET"))
4958 .and(path("/check-runs"))
4959 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4960 "total_count": 0,
4961 "check_runs": []
4962 })))
4963 .expect(1)
4964 .mount(&server)
4965 .await;
4966
4967 let (gh, _guard) = make_client();
4968 let url = format!("{}/check-runs", server.uri());
4969 let result: Vec<serde_json::Value> = gh
4970 .get_all_pages_from_wrapper(&url, "check_runs")
4971 .await
4972 .unwrap();
4973
4974 assert_eq!(result.len(), 0);
4975 }
4976
4977 #[tokio::test]
4978 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
4979 let server = MockServer::start().await;
4980
4981 Mock::given(method("GET"))
4982 .and(path("/check-runs"))
4983 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
4984 .expect(1)
4985 .mount(&server)
4986 .await;
4987
4988 let (gh, _guard) = make_client();
4989 let url = format!("{}/check-runs", server.uri());
4990 let result = gh
4991 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
4992 .await;
4993
4994 assert!(result.is_err());
4995 let err = result.unwrap_err().to_string();
4996 assert!(
4997 err.contains("422") || err.contains("failed"),
4998 "error should indicate failure: {err}"
4999 );
5000 }
5001 }
5002
5003 #[cfg(test)]
5006 mod auth_retry {
5007 use super::test_client;
5008 use std::sync::atomic::{AtomicUsize, Ordering};
5009 use std::sync::Arc;
5010 use wiremock::matchers::method;
5011 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
5012
5013 fn bad_credentials() -> ResponseTemplate {
5014 ResponseTemplate::new(401).set_body_json(serde_json::json!({
5015 "message": "Bad credentials",
5016 "status": 401
5017 }))
5018 }
5019
5020 #[allow(clippy::await_holding_lock)]
5021 #[tokio::test]
5022 async fn retries_once_with_fresh_token_after_401() {
5023 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5024 let prev = std::env::var_os("GH_TOKEN");
5025 std::env::set_var("GH_TOKEN", "bad_token");
5026
5027 let server = MockServer::start().await;
5028 let hits = Arc::new(AtomicUsize::new(0));
5029 let hits_in_handler = hits.clone();
5030 Mock::given(method("GET"))
5031 .respond_with(move |_req: &Request| {
5032 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
5033 std::env::set_var("GH_TOKEN", "good_token");
5036 bad_credentials()
5037 } else {
5038 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
5039 }
5040 })
5041 .mount(&server)
5042 .await;
5043
5044 let gh = test_client();
5045 let result: anyhow::Result<serde_json::Value> =
5046 gh.get_json(&format!("{}/x", server.uri())).await;
5047
5048 match prev {
5049 Some(v) => std::env::set_var("GH_TOKEN", v),
5050 None => std::env::remove_var("GH_TOKEN"),
5051 }
5052
5053 assert!(
5054 result.is_ok(),
5055 "retry with re-resolved token should succeed: {:?}",
5056 result.err()
5057 );
5058 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
5059 }
5060
5061 #[allow(clippy::await_holding_lock)]
5062 #[tokio::test]
5063 async fn double_401_bails_with_distinct_auth_error() {
5064 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5065 let prev = std::env::var_os("GH_TOKEN");
5066 std::env::set_var("GH_TOKEN", "bad_token");
5067
5068 let server = MockServer::start().await;
5069 let hits = Arc::new(AtomicUsize::new(0));
5070 let hits_in_handler = hits.clone();
5071 Mock::given(method("GET"))
5072 .respond_with(move |_req: &Request| {
5073 hits_in_handler.fetch_add(1, Ordering::SeqCst);
5074 bad_credentials()
5075 })
5076 .mount(&server)
5077 .await;
5078
5079 let gh = test_client();
5080 let result: anyhow::Result<serde_json::Value> =
5081 gh.get_json(&format!("{}/x", server.uri())).await;
5082
5083 match prev {
5084 Some(v) => std::env::set_var("GH_TOKEN", v),
5085 None => std::env::remove_var("GH_TOKEN"),
5086 }
5087
5088 let err = result.expect_err("persisting 401 must fail").to_string();
5089 assert!(
5090 err.contains("token re-resolved after cache clear but still rejected"),
5091 "expected distinct auth-failure error, got: {err}"
5092 );
5093 assert_eq!(
5094 hits.load(Ordering::SeqCst),
5095 2,
5096 "must retry exactly once, not loop"
5097 );
5098 }
5099 }
5100
5101 mod leak_redaction {
5104 use super::test_client;
5105 use super::GhHttp;
5106 use std::sync::{Arc, Mutex};
5107 use wiremock::matchers::{method, path};
5108 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
5109
5110 const TOKEN: &str = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
5111
5112 fn leaky_body() -> String {
5113 format!("agent notes mentioning {TOKEN}")
5114 }
5115
5116 fn client_for(server: &MockServer) -> GhHttp {
5117 let mut gh = test_client();
5118 gh.api_base = Some(server.uri());
5119 gh
5120 }
5121
5122 fn posted_json_body(raw: &str) -> String {
5123 let payload: serde_json::Value =
5124 serde_json::from_str(raw).expect("request body is JSON");
5125 payload["body"]
5126 .as_str()
5127 .expect("payload has a string body")
5128 .to_string()
5129 }
5130
5131 fn assert_token_redacted(raw: &str) {
5132 assert!(
5133 !raw.contains(TOKEN),
5134 "planted token must not appear in the request payload, got: {raw}"
5135 );
5136 let body = posted_json_body(raw);
5137 assert!(
5138 body.contains("[REDACTED:github_token]"),
5139 "redacted marker missing from posted body: {body}"
5140 );
5141 assert!(
5142 body.contains("potential secret(s) detected and redacted"),
5143 "security notice missing from posted body: {body}"
5144 );
5145 }
5146
5147 #[allow(clippy::await_holding_lock)]
5148 #[tokio::test]
5149 async fn add_comment_redacts_github_token() {
5150 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5151 let prev = std::env::var_os("GH_TOKEN");
5152 std::env::set_var("GH_TOKEN", "test_token");
5153
5154 let server = MockServer::start().await;
5155 let captured = Arc::new(Mutex::new(None::<String>));
5156 let captured_in_handler = captured.clone();
5157 Mock::given(method("POST"))
5158 .and(path("/repos/owner/repo/issues/1/comments"))
5159 .respond_with(move |req: &Request| {
5160 *captured_in_handler.lock().unwrap() =
5161 Some(String::from_utf8_lossy(&req.body).into_owned());
5162 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": 1 }))
5163 })
5164 .expect(1)
5165 .mount(&server)
5166 .await;
5167
5168 let gh = client_for(&server);
5169 gh.add_comment("owner/repo", "1", &leaky_body())
5170 .await
5171 .expect("add_comment should succeed");
5172
5173 match prev {
5174 Some(v) => std::env::set_var("GH_TOKEN", v),
5175 None => std::env::remove_var("GH_TOKEN"),
5176 }
5177
5178 let raw = captured
5179 .lock()
5180 .unwrap()
5181 .clone()
5182 .expect("add_comment must send a request body");
5183 assert_token_redacted(&raw);
5184 }
5185
5186 #[allow(clippy::await_holding_lock)]
5187 #[tokio::test]
5188 async fn create_pr_redacts_github_token() {
5189 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5190 let prev = std::env::var_os("GH_TOKEN");
5191 std::env::set_var("GH_TOKEN", "test_token");
5192
5193 let server = MockServer::start().await;
5194 let captured = Arc::new(Mutex::new(None::<String>));
5195 let captured_in_handler = captured.clone();
5196 Mock::given(method("POST"))
5197 .and(path("/repos/owner/repo/pulls"))
5198 .respond_with(move |req: &Request| {
5199 *captured_in_handler.lock().unwrap() =
5200 Some(String::from_utf8_lossy(&req.body).into_owned());
5201 ResponseTemplate::new(201).set_body_json(serde_json::json!({
5202 "html_url": "https://github.com/owner/repo/pull/1"
5203 }))
5204 })
5205 .expect(1)
5206 .mount(&server)
5207 .await;
5208
5209 let gh = client_for(&server);
5210 gh.create_pr("owner/repo", "title", &leaky_body(), "head", "main")
5211 .await
5212 .expect("create_pr should succeed");
5213
5214 match prev {
5215 Some(v) => std::env::set_var("GH_TOKEN", v),
5216 None => std::env::remove_var("GH_TOKEN"),
5217 }
5218
5219 let raw = captured
5220 .lock()
5221 .unwrap()
5222 .clone()
5223 .expect("create_pr must send a request body");
5224 assert_token_redacted(&raw);
5225 }
5226
5227 #[allow(clippy::await_holding_lock)]
5228 #[tokio::test]
5229 async fn update_pr_body_redacts_github_token() {
5230 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
5231 let prev = std::env::var_os("GH_TOKEN");
5232 std::env::set_var("GH_TOKEN", "test_token");
5233
5234 let server = MockServer::start().await;
5235 let captured = Arc::new(Mutex::new(None::<String>));
5236 let captured_in_handler = captured.clone();
5237 Mock::given(method("PATCH"))
5238 .and(path("/repos/owner/repo/pulls/7"))
5239 .respond_with(move |req: &Request| {
5240 *captured_in_handler.lock().unwrap() =
5241 Some(String::from_utf8_lossy(&req.body).into_owned());
5242 ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
5243 })
5244 .expect(1)
5245 .mount(&server)
5246 .await;
5247
5248 let gh = client_for(&server);
5249 gh.update_pr_body("owner/repo", 7, &leaky_body())
5250 .await
5251 .expect("update_pr_body should succeed");
5252
5253 match prev {
5254 Some(v) => std::env::set_var("GH_TOKEN", v),
5255 None => std::env::remove_var("GH_TOKEN"),
5256 }
5257
5258 let raw = captured
5259 .lock()
5260 .unwrap()
5261 .clone()
5262 .expect("update_pr_body must send a request body");
5263 assert_token_redacted(&raw);
5264 }
5265 }
5266}