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