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