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