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