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
34fn parse_positive_issue_number(number: &str) -> anyhow::Result<i64> {
35 let parsed = number
36 .parse::<i64>()
37 .with_context(|| format!("invalid issue number: {number}"))?;
38 if parsed <= 0 {
39 anyhow::bail!("invalid issue number: {number}");
40 }
41 Ok(parsed)
42}
43
44struct RateLimit {
48 remaining: Option<u32>,
50 reset_at: Option<u64>,
52 backoff_until: Option<Instant>,
54 backoff_delay: Duration,
55 backoff_base: Duration,
56 backoff_max: Duration,
57 throttle_threshold: u32,
59}
60
61impl RateLimit {
62 fn new() -> Self {
63 let base = crate::config::get("gh.backoff.base_seconds")
64 .ok()
65 .and_then(|v| v.parse::<u64>().ok())
66 .unwrap_or(30);
67 let max = crate::config::get("gh.backoff.max_seconds")
68 .ok()
69 .and_then(|v| v.parse::<u64>().ok())
70 .unwrap_or(900);
71 let threshold = crate::config::get("gh.rate_limit.throttle_threshold")
72 .ok()
73 .and_then(|v| v.parse::<u32>().ok())
74 .unwrap_or(10);
75
76 Self {
77 remaining: None,
78 reset_at: None,
79 backoff_until: None,
80 backoff_delay: Duration::ZERO,
81 backoff_base: Duration::from_secs(base),
82 backoff_max: Duration::from_secs(max),
83 throttle_threshold: threshold,
84 }
85 }
86
87 fn update_from_headers(&mut self, headers: &header::HeaderMap) {
89 if let Some(v) = headers.get("x-ratelimit-remaining") {
90 self.remaining = v.to_str().ok().and_then(|s| s.parse().ok());
91 }
92 if let Some(v) = headers.get("x-ratelimit-reset") {
93 self.reset_at = v.to_str().ok().and_then(|s| s.parse().ok());
94 }
95 }
96
97 fn record_success(&mut self) {
99 if self.backoff_delay > Duration::ZERO {
100 tracing::info!("GitHub backoff cleared after successful API call");
101 }
102 self.backoff_delay = Duration::ZERO;
103 self.backoff_until = None;
104 }
105
106 fn record_rate_limit(&mut self) {
108 METRIC_RATE_LIMIT_HITS.fetch_add(1, Ordering::Relaxed);
109
110 if let Some(reset_epoch) = self.reset_at {
112 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
113 Ok(since_epoch) => {
114 let now_epoch = since_epoch.as_secs();
115 if reset_epoch > now_epoch {
116 let wait_secs = reset_epoch - now_epoch + 1;
117 self.backoff_delay = Duration::from_secs(wait_secs);
118 self.backoff_until = Some(Instant::now() + self.backoff_delay);
119 tracing::warn!(
120 wait_secs,
121 "GitHub rate limit hit, waiting until reset time"
122 );
123 return;
124 }
125 }
126 Err(e) => {
127 tracing::error!(
128 error = %e,
129 "System clock is before UNIX epoch; ignoring reset_at and falling back to exponential backoff"
130 );
131 }
132 }
133 }
134
135 self.backoff_delay = if self.backoff_delay.is_zero() {
137 self.backoff_base
138 } else {
139 (self.backoff_delay * 2).min(self.backoff_max)
140 };
141 self.backoff_until = Some(Instant::now() + self.backoff_delay);
142 tracing::warn!(
143 delay_secs = self.backoff_delay.as_secs(),
144 "GitHub rate limit hit, backing off (no reset header)"
145 );
146 }
147
148 fn backoff_remaining(&self) -> Option<Duration> {
150 if let Some(until) = self.backoff_until {
151 let now = Instant::now();
152 if now < until {
153 return Some(until - now);
154 }
155 }
156 None
157 }
158
159 fn proactive_wait_duration(&self) -> Option<Duration> {
166 if self.backoff_remaining().is_some() {
168 return None;
169 }
170 if let Some(remaining) = self.remaining {
171 if remaining < self.throttle_threshold {
172 if let Some(reset_epoch) = self.reset_at {
173 let now_epoch = std::time::SystemTime::now()
174 .duration_since(std::time::UNIX_EPOCH)
175 .unwrap_or_default()
176 .as_secs();
177 if now_epoch < reset_epoch {
178 return Some(Duration::from_secs(reset_epoch - now_epoch + 1));
179 }
180 }
181 }
182 }
183 None
184 }
185
186 fn is_active(&self) -> Option<Duration> {
188 self.backoff_remaining()
189 .or_else(|| self.proactive_wait_duration())
190 }
191
192 fn clear_proactive_state(&mut self) {
194 self.remaining = None;
195 self.reset_at = None;
196 }
197}
198
199static REST_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
202 std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
203
204static GRAPHQL_RATE_LIMIT: std::sync::LazyLock<Mutex<RateLimit>> =
206 std::sync::LazyLock::new(|| Mutex::new(RateLimit::new()));
207
208static METRIC_RATE_LIMIT_HITS: AtomicU64 = AtomicU64::new(0);
212static METRIC_PROACTIVE_THROTTLES: AtomicU64 = AtomicU64::new(0);
214static METRIC_WAIT_SECS_TOTAL: AtomicU64 = AtomicU64::new(0);
216static METRIC_5XX_HITS: AtomicU64 = AtomicU64::new(0);
218
219type RateLimitCell = std::sync::LazyLock<Mutex<RateLimit>>;
222
223fn check_rate_limit_backoff(cell: &'static RateLimitCell, api: &str) -> anyhow::Result<()> {
225 if let Some(remaining) = cell.lock().ok().and_then(|rl| rl.backoff_remaining()) {
226 anyhow::bail!(
227 "GitHub {} API rate-limited, backoff active for {}s",
228 api,
229 remaining.as_secs()
230 );
231 }
232 Ok(())
233}
234
235async fn proactive_throttle_limiter(cell: &'static RateLimitCell, api: &str) {
238 let wait = cell.lock().ok().and_then(|rl| rl.proactive_wait_duration());
239 if let Some(d) = wait {
240 let secs = d.as_secs().max(1);
241 METRIC_PROACTIVE_THROTTLES.fetch_add(1, Ordering::Relaxed);
242 METRIC_WAIT_SECS_TOTAL.fetch_add(secs, Ordering::Relaxed);
243 tracing::warn!(
244 wait_secs = secs,
245 "approaching GitHub {} rate limit, throttling until reset",
246 api
247 );
248 tokio::time::sleep(d).await;
249 if let Ok(mut rl) = cell.lock() {
250 rl.clear_proactive_state();
251 }
252 }
253}
254
255fn maybe_record_body_rate_limit(cell: &'static RateLimitCell, status: StatusCode, body: &str) {
258 if status == StatusCode::FORBIDDEN {
259 let lower = body.to_lowercase();
260 if lower.contains("rate limit")
261 || lower.contains("abuse detection")
262 || lower.contains("secondary rate")
263 {
264 if let Ok(mut rl) = cell.lock() {
265 rl.record_rate_limit();
266 }
267 }
268 }
269}
270
271fn record_response_for_limiter(cell: &'static RateLimitCell, resp: &Response) {
273 if let Ok(mut rl) = cell.lock() {
274 rl.update_from_headers(resp.headers());
275 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
278 rl.record_rate_limit();
279 } else if resp.status().is_success() {
280 rl.record_success();
281 }
282 }
283 let status = resp.status();
285 if status.is_server_error() {
286 METRIC_5XX_HITS.fetch_add(1, Ordering::Relaxed);
287 if tokio::runtime::Handle::try_current().is_ok() {
288 tokio::spawn(async move {
289 crate::engine::cooldown::record_github_5xx().await;
290 });
291 }
292 }
293}
294
295#[derive(Clone)]
302pub struct GhHttp {
303 client: Client,
304 token_resolver: Arc<token::TokenResolver>,
305 cached_username: OnceLock<String>,
306}
307
308impl GhHttp {
309 pub fn new() -> anyhow::Result<Self> {
317 let client = Client::builder()
318 .user_agent("orch/0.1 (reqwest)")
319 .pool_max_idle_per_host(4)
320 .timeout(Duration::from_secs(30))
321 .build()
322 .map_err(|e| anyhow::anyhow!("failed to build HTTP client (TLS init): {e}"))?;
323
324 Ok(Self {
325 client,
326 token_resolver: token::shared(),
327 cached_username: OnceLock::new(),
328 })
329 }
330
331 pub fn is_rate_limited() -> Option<Duration> {
336 REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.is_active())
337 }
338
339 fn check_backoff() -> anyhow::Result<()> {
341 check_rate_limit_backoff(&REST_RATE_LIMIT, "REST")
342 }
343
344 fn check_graphql_backoff() -> anyhow::Result<()> {
346 check_rate_limit_backoff(&GRAPHQL_RATE_LIMIT, "GraphQL")
347 }
348
349 async fn proactive_throttle_rest() {
351 proactive_throttle_limiter(&REST_RATE_LIMIT, "REST").await;
352 }
353
354 async fn proactive_throttle_graphql() {
356 proactive_throttle_limiter(&GRAPHQL_RATE_LIMIT, "GraphQL").await;
357 }
358
359 fn record_response(resp: &Response) {
360 record_response_for_limiter(&REST_RATE_LIMIT, resp);
361 }
362
363 fn record_graphql_response(resp: &Response) {
364 record_response_for_limiter(&GRAPHQL_RATE_LIMIT, resp);
365 }
366
367 fn maybe_record_rate_limit_from_body(status: StatusCode, body: &str) {
369 maybe_record_body_rate_limit(&REST_RATE_LIMIT, status, body);
370 }
371
372 fn maybe_record_graphql_rate_limit_from_body(status: StatusCode, body: &str) {
374 maybe_record_body_rate_limit(&GRAPHQL_RATE_LIMIT, status, body);
375 }
376
377 fn record_success() {
378 if let Ok(mut rl) = REST_RATE_LIMIT.lock() {
379 rl.record_success();
380 }
381 }
382
383 async fn auth_header(&self) -> anyhow::Result<String> {
386 match self.token_resolver.get_token().await? {
387 Some(token) if !token.is_empty() => Ok(format!("Bearer {token}")),
388 _ => anyhow::bail!("No GitHub token available — run `gh auth login` or set GH_TOKEN"),
389 }
390 }
391
392 async fn send_with_retries<F>(
395 &self,
396 mut make_req: F,
397 is_graphql: bool,
398 ) -> anyhow::Result<Response>
399 where
400 F: FnMut() -> anyhow::Result<reqwest::RequestBuilder>,
401 {
402 if engine_cooldown::is_agent_in_cooldown("github:5xx") {
404 if let Some(until) = engine_cooldown::cooldown_until("github:5xx") {
405 let now = chrono::Utc::now().timestamp();
406 let remaining = if until > now { until - now } else { 0 };
407 anyhow::bail!(
408 "GitHub API transient 5xx circuit-breaker active for {}s",
409 remaining
410 );
411 } else {
412 anyhow::bail!("GitHub API transient 5xx circuit-breaker active");
413 }
414 }
415
416 let attempts = crate::config::get("gh.5xx.retry_attempts")
418 .ok()
419 .and_then(|v| v.parse::<u32>().ok())
420 .unwrap_or(3);
421 let base_secs = crate::config::get("gh.5xx.base_backoff_seconds")
422 .ok()
423 .and_then(|v| v.parse::<u64>().ok())
424 .unwrap_or(1);
425 let max_secs = crate::config::get("gh.5xx.max_backoff_seconds")
426 .ok()
427 .and_then(|v| v.parse::<u64>().ok())
428 .unwrap_or(30);
429 let circuit_cooldown_secs = crate::config::get("gh.5xx.circuit_breaker_cooldown_seconds")
430 .ok()
431 .and_then(|v| v.parse::<u64>().ok())
432 .unwrap_or(300);
433
434 let mut last_err: Option<anyhow::Error> = None;
435 let mut auth_override: Option<String> = None;
438 let mut auth_retried = false;
439 for attempt in 0..attempts {
440 let mut req = make_req()?;
441 if let Some(auth) = &auth_override {
442 req = req.header(header::AUTHORIZATION, auth.clone());
443 }
444 let resp_result = req.send().await;
445
446 match resp_result {
447 Ok(resp) => {
448 if is_graphql {
450 Self::record_graphql_response(&resp);
451 } else {
452 Self::record_response(&resp);
453 }
454
455 if resp.status() == StatusCode::UNAUTHORIZED {
456 let status = resp.status();
457 let body = resp.text().await.unwrap_or_default();
458 if body.contains("Bad credentials") && !auth_retried {
459 tracing::warn!(
460 "GitHub API 401 Bad credentials — clearing cached token and re-resolving"
461 );
462 self.token_resolver.clear_cache().await;
463 match self.auth_header().await {
464 Ok(new_auth) => {
465 auth_retried = true;
466 auth_override = Some(new_auth);
467 }
468 Err(e) => {
469 last_err = Some(
470 e.context("GitHub token re-resolution failed after 401"),
471 );
472 break;
473 }
474 }
475 continue;
477 }
478 if auth_retried {
479 self.token_resolver.clear_cache().await;
482 anyhow::bail!(
483 "GitHub authentication failed ({status}): token re-resolved after cache clear but still rejected"
484 );
485 }
486 anyhow::bail!("GitHub API request failed ({status}): {body}");
487 }
488
489 if resp.status().is_server_error() {
491 let status = resp.status();
492 let headers = resp.headers().clone();
493 let body = resp.text().await.unwrap_or_default();
494 let req_id = headers
495 .get("x-github-request-id")
496 .and_then(|v| v.to_str().ok())
497 .unwrap_or("<none>");
498 let snippet: String = body.chars().take(400).collect();
499 tracing::warn!(
500 status = %status,
501 request_id = %req_id,
502 attempt,
503 "GitHub server error (5xx) — attempt {attempt}: {snippet}"
504 );
505
506 last_err = Some(anyhow::anyhow!(
507 "GitHub API server error ({status}) request_id={}: {}",
508 req_id,
509 snippet
510 ));
511
512 if attempt + 1 < attempts {
513 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
516 let mut backoff = base_secs.saturating_mul(exp);
517 if backoff > max_secs {
518 backoff = max_secs;
519 }
520 let jitter_ms = attempt as u64 * 100;
522 let sleep_dur = Duration::from_secs(backoff)
523 + Duration::from_millis(jitter_ms.min(1000));
524 tokio::time::sleep(sleep_dur).await;
525 continue;
526 }
527 tracing::warn!(
529 "GitHub API 5xx persisted after {} attempts — setting circuit-breaker for {}s",
530 attempts,
531 circuit_cooldown_secs
532 );
533 engine_cooldown::set_agent_cooldown("github:5xx", circuit_cooldown_secs)
534 .await;
535 break;
536 }
537
538 return Ok(resp);
540 }
541 Err(e) => {
542 tracing::warn!(err = %e, attempt, "HTTP request failed to send (transport error, GitHub not reached), will retry if attempts remain");
549 last_err = Some(anyhow::anyhow!(
550 "HTTP request failed to send (transport error): {}",
551 e
552 ));
553 if attempt + 1 < attempts {
554 let exp = 1u64.checked_shl(attempt.min(63)).unwrap_or(u64::MAX);
556 let backoff = base_secs.saturating_mul(exp);
557 let sleep_dur = Duration::from_secs(backoff)
558 + Duration::from_millis((attempt as u64 * 100).min(1000));
559 tokio::time::sleep(sleep_dur).await;
560 continue;
561 }
562 tracing::warn!(
566 "HTTP request failed to send after {} attempts (transport error, GitHub not reached) — not tripping the github:5xx circuit-breaker",
567 attempts
568 );
569 break;
570 }
571 }
572 }
573
574 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unexpected send_with_retries failure")))
575 }
576
577 async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> anyhow::Result<T> {
579 Self::proactive_throttle_rest().await;
580 Self::check_backoff()?;
581 let auth = self.auth_header().await?;
582 let make_req = || {
584 Ok(self
585 .client
586 .get(url)
587 .header(header::AUTHORIZATION, auth.clone())
588 .header(header::ACCEPT, "application/vnd.github+json")
589 .header("X-GitHub-Api-Version", "2022-11-28"))
590 };
591 let resp = self.send_with_retries(make_req, false).await?;
592 let status = resp.status();
593 if !status.is_success() {
594 let body = resp.text().await.unwrap_or_default();
595 Self::maybe_record_rate_limit_from_body(status, &body);
596 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
597 }
598 let body = resp.text().await?;
599 serde_json::from_str(&body)
600 .with_context(|| format!("failed to parse GitHub API response from GET {url}: {body}"))
601 }
602
603 async fn get_bytes(&self, url: &str) -> anyhow::Result<Vec<u8>> {
605 Self::proactive_throttle_rest().await;
606 Self::check_backoff()?;
607 let auth = self.auth_header().await?;
608 let make_req = || {
609 Ok(self
610 .client
611 .get(url)
612 .header(header::AUTHORIZATION, auth.clone())
613 .header(header::ACCEPT, "application/vnd.github+json")
614 .header("X-GitHub-Api-Version", "2022-11-28"))
615 };
616 let resp = self.send_with_retries(make_req, false).await?;
617 let status = resp.status();
618 if !status.is_success() {
619 let body = resp.text().await.unwrap_or_default();
620 Self::maybe_record_rate_limit_from_body(status, &body);
621 anyhow::bail!("GitHub API GET {url} failed ({status}): {body}");
622 }
623 Ok(resp.bytes().await?.to_vec())
624 }
625
626 async fn get_with_query<T: serde::de::DeserializeOwned>(
628 &self,
629 url: &str,
630 query: &[(&str, &str)],
631 ) -> anyhow::Result<T> {
632 Self::proactive_throttle_rest().await;
633 Self::check_backoff()?;
634 let auth = self.auth_header().await?;
635 let make_req = || {
636 Ok(self
637 .client
638 .get(url)
639 .query(query)
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 post_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
658 Self::proactive_throttle_rest().await;
659 Self::check_backoff()?;
660 let auth = self.auth_header().await?;
661 let make_req = || {
662 Ok(self
663 .client
664 .post(url)
665 .json(body)
666 .header(header::AUTHORIZATION, auth.clone())
667 .header(header::ACCEPT, "application/vnd.github+json")
668 .header("X-GitHub-Api-Version", "2022-11-28"))
669 };
670 let resp = self.send_with_retries(make_req, false).await?;
671 let status = resp.status();
672 let text_res = resp.text().await;
673 if !status.is_success() {
674 let text = text_res.unwrap_or_default();
675 Self::maybe_record_rate_limit_from_body(status, &text);
676 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
677 }
678 let text = text_res?;
679 Ok(text)
680 }
681
682 async fn post_json<T: serde::de::DeserializeOwned>(
684 &self,
685 url: &str,
686 body: &serde_json::Value,
687 ) -> anyhow::Result<T> {
688 let text = self.post_json_raw(url, body).await?;
689 Ok(serde_json::from_str(&text)?)
690 }
691
692 async fn patch_json_raw(&self, url: &str, body: &serde_json::Value) -> anyhow::Result<String> {
694 Self::proactive_throttle_rest().await;
695 Self::check_backoff()?;
696 let auth = self.auth_header().await?;
697 let make_req = || {
698 Ok(self
699 .client
700 .patch(url)
701 .json(body)
702 .header(header::AUTHORIZATION, auth.clone())
703 .header(header::ACCEPT, "application/vnd.github+json")
704 .header("X-GitHub-Api-Version", "2022-11-28"))
705 };
706 let resp = self.send_with_retries(make_req, false).await?;
707 let status = resp.status();
708 let text_res = resp.text().await;
709 if !status.is_success() {
710 let text = text_res.unwrap_or_default();
711 Self::maybe_record_rate_limit_from_body(status, &text);
712 anyhow::bail!("GitHub API PATCH {url} failed ({status}): {text}");
713 }
714 let text = text_res?;
715 Ok(text)
716 }
717
718 async fn delete(&self, url: &str) -> anyhow::Result<StatusCode> {
720 Self::proactive_throttle_rest().await;
721 Self::check_backoff()?;
722 let auth = self.auth_header().await?;
723 let make_req = || {
724 Ok(self
725 .client
726 .delete(url)
727 .header(header::AUTHORIZATION, auth.clone())
728 .header(header::ACCEPT, "application/vnd.github+json")
729 .header("X-GitHub-Api-Version", "2022-11-28"))
730 };
731 let resp = self.send_with_retries(make_req, false).await?;
732 let status = resp.status();
733 if !status.is_success() && status != StatusCode::NOT_FOUND {
734 let body = resp.text().await.unwrap_or_default();
735 Self::maybe_record_rate_limit_from_body(status, &body);
736 anyhow::bail!("GitHub API DELETE {url} failed ({status}): {body}");
737 }
738 Ok(status)
739 }
740
741 async fn get_all_pages<T: serde::de::DeserializeOwned>(
743 &self,
744 url: &str,
745 query: &[(&str, &str)],
746 ) -> anyhow::Result<Vec<T>> {
747 Self::proactive_throttle_rest().await;
748 Self::check_backoff()?;
749 let mut all: Vec<T> = Vec::new();
750 let mut next_url: Option<String> = None;
751 let mut is_first = true;
752 const MAX_JSON_DECODE_RETRIES: u32 = 2;
753 const JSON_DECODE_RETRY_BACKOFF_MS: u64 = 200;
754
755 loop {
756 let (request_url, request_query) = if is_first {
757 is_first = false;
758 (url, Some(query))
759 } else {
760 let Some(u) = next_url.as_ref() else { break };
761 (u.as_str(), None)
762 };
763
764 let mut decode_attempt = 0;
765 let (page, next) = loop {
766 let auth = self.auth_header().await?;
767 let mut req = self
768 .client
769 .get(request_url)
770 .header(header::AUTHORIZATION, auth)
771 .header(header::ACCEPT, "application/vnd.github+json")
772 .header("X-GitHub-Api-Version", "2022-11-28");
773 if let Some(q) = request_query {
774 req = req.query(q);
775 }
776
777 let make_req = || {
778 req.try_clone()
779 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
780 };
781 let resp = self.send_with_retries(make_req, false).await?;
782 let status = resp.status();
783 let next_from_headers = parse_link_next(resp.headers());
784 let content_type = resp
785 .headers()
786 .get(header::CONTENT_TYPE)
787 .and_then(|v| v.to_str().ok())
788 .map(|v| v.to_string())
789 .unwrap_or_else(|| "<missing>".to_string());
790
791 if !status.is_success() {
792 let body = resp.text().await.unwrap_or_default();
793 Self::maybe_record_rate_limit_from_body(status, &body);
794 anyhow::bail!("GitHub API GET (paginated) failed ({status}): {body}");
795 }
796
797 let text = resp.text().await?;
798 match serde_json::from_str::<Vec<T>>(&text) {
799 Ok(page) => break (page, next_from_headers),
800 Err(err) => {
801 let snippet: String = text.chars().take(200).collect();
802 let decode_error = anyhow::anyhow!(
803 "GitHub API GET (paginated) JSON decode failed ({status}, content-type={content_type}): {snippet}"
804 )
805 .context(err);
806 if decode_attempt < MAX_JSON_DECODE_RETRIES {
807 decode_attempt += 1;
808 tracing::warn!(
809 attempt = decode_attempt,
810 max_attempts = MAX_JSON_DECODE_RETRIES,
811 status = %status,
812 content_type,
813 err = %decode_error,
814 "json decode failed; retrying paginated GET"
815 );
816 tokio::time::sleep(Duration::from_millis(
817 JSON_DECODE_RETRY_BACKOFF_MS * u64::from(decode_attempt),
818 ))
819 .await;
820 continue;
821 }
822 return Err(decode_error);
823 }
824 }
825 };
826
827 next_url = next;
828 all.extend(page);
829
830 if next_url.is_none() {
831 break;
832 }
833 }
834
835 Ok(all)
836 }
837
838 async fn get_all_pages_from_wrapper<T: serde::de::DeserializeOwned>(
849 &self,
850 url: &str,
851 array_field: &str,
852 ) -> anyhow::Result<Vec<T>> {
853 Self::proactive_throttle_rest().await;
854 Self::check_backoff()?;
855 let mut all: Vec<T> = Vec::new();
856 let mut current_url: String = url.to_string();
857
858 loop {
859 let auth = self.auth_header().await?;
860 let make_req = || {
861 self.client
862 .get(¤t_url)
863 .header(header::AUTHORIZATION, auth.clone())
864 .header(header::ACCEPT, "application/vnd.github+json")
865 .header("X-GitHub-Api-Version", "2022-11-28")
866 .try_clone()
867 .ok_or_else(|| anyhow::anyhow!("request clone failed"))
868 };
869 let resp = self.send_with_retries(make_req, false).await?;
870 let status = resp.status();
871 let next = parse_link_next(resp.headers());
872
873 if !status.is_success() {
874 let body = resp.text().await.unwrap_or_default();
875 Self::maybe_record_rate_limit_from_body(status, &body);
876 anyhow::bail!(
877 "GitHub API GET (wrapped paginated) {current_url} failed ({status}): {body}"
878 );
879 }
880
881 let body = resp.text().await?;
882 let wrapper: serde_json::Value = serde_json::from_str(&body).with_context(|| {
883 format!("failed to parse wrapper response from {current_url}: {body}")
884 })?;
885 let items = wrapper
886 .get(array_field)
887 .and_then(|v| v.as_array())
888 .cloned()
889 .unwrap_or_default();
890
891 for item in items {
892 match serde_json::from_value::<T>(item) {
893 Ok(v) => all.push(v),
894 Err(e) => {
895 tracing::warn!(
896 field = array_field,
897 err = %e,
898 "skipping item that failed to deserialize in wrapped pagination"
899 );
900 }
901 }
902 }
903
904 match next {
905 Some(next_url) => current_url = next_url,
906 None => break,
907 }
908 }
909
910 Ok(all)
911 }
912
913 async fn graphql_request(
915 &self,
916 query: &str,
917 extra_headers: &[(&str, &str)],
918 variables: Option<serde_json::Value>,
919 ) -> anyhow::Result<serde_json::Value> {
920 self.graphql_request_to_url(
921 &format!("{GITHUB_API}/graphql"),
922 query,
923 extra_headers,
924 variables,
925 )
926 .await
927 }
928
929 async fn graphql_request_to_url(
930 &self,
931 url: &str,
932 query: &str,
933 extra_headers: &[(&str, &str)],
934 variables: Option<serde_json::Value>,
935 ) -> anyhow::Result<serde_json::Value> {
936 Self::proactive_throttle_graphql().await;
937 Self::check_graphql_backoff()?;
938 let body = if let Some(vars) = variables {
939 serde_json::json!({ "query": query, "variables": vars })
940 } else {
941 serde_json::json!({ "query": query })
942 };
943 let auth = self.auth_header().await?;
944 let mut req = self
945 .client
946 .post(url)
947 .json(&body)
948 .header(header::AUTHORIZATION, auth)
949 .header(header::ACCEPT, "application/vnd.github+json")
950 .header("X-GitHub-Api-Version", "2022-11-28");
951
952 for (k, v) in extra_headers {
953 req = req.header(*k, *v);
954 }
955
956 let make_req = || {
958 req.try_clone()
959 .ok_or_else(|| anyhow::anyhow!("graphql request clone failed"))
960 };
961 let resp = self.send_with_retries(make_req, true).await?;
962 let status = resp.status();
963 let text_res = resp.text().await;
964 if !status.is_success() {
965 let text = text_res.unwrap_or_default();
966 Self::maybe_record_graphql_rate_limit_from_body(status, &text);
967 anyhow::bail!("GitHub GraphQL failed ({status}): {text}");
968 }
969 let text = text_res?;
970 let json: serde_json::Value = serde_json::from_str(&text)?;
971 if let Some(errors) = json.get("errors").and_then(|e| e.as_array()) {
973 if !errors.is_empty() {
974 let messages: Vec<&str> = errors
975 .iter()
976 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
977 .collect();
978 if json.get("data").is_none() || json["data"].is_null() {
979 anyhow::bail!("GitHub GraphQL errors: {}", messages.join("; "));
981 }
982 tracing::warn!(errors = ?messages, "GitHub GraphQL returned partial errors");
984 }
985 }
986 Ok(json)
987 }
988
989 pub async fn graphql_with_vars(
992 &self,
993 query: &str,
994 variables: serde_json::Value,
995 ) -> anyhow::Result<serde_json::Value> {
996 self.graphql_request(query, &[], Some(variables)).await
997 }
998
999 pub async fn graphql_with_headers(
1000 &self,
1001 query: &str,
1002 headers: &[&str],
1003 ) -> anyhow::Result<serde_json::Value> {
1004 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1006 self.graphql_request(query, &pairs, None).await
1007 }
1008
1009 pub async fn graphql_with_headers_and_vars(
1010 &self,
1011 query: &str,
1012 headers: &[&str],
1013 variables: serde_json::Value,
1014 ) -> anyhow::Result<serde_json::Value> {
1015 let pairs: Vec<(&str, &str)> = headers.iter().filter_map(|h| h.split_once(':')).collect();
1016 self.graphql_request(query, &pairs, Some(variables)).await
1017 }
1018
1019 pub async fn auth_status(&self) -> anyhow::Result<()> {
1024 match self
1025 .get_json::<serde_json::Value>(&format!("{GITHUB_API}/user"))
1026 .await
1027 {
1028 Ok(_) => Ok(()),
1029 Err(e) => {
1030 let err_str = e.to_string();
1031
1032 if err_str.contains("401") || err_str.contains("Bad credentials") {
1034 anyhow::bail!(
1035 "GitHub authentication failed: Invalid or expired token. \
1036 Check your GH_TOKEN/GITHUB_TOKEN environment variables, \
1037 or gh.auth configuration in ~/.orch/config.yml. \
1038 Run `orch auth check` to verify your setup."
1039 );
1040 }
1041
1042 if err_str.contains("403") && err_str.contains("rate limit") {
1043 anyhow::bail!(
1044 "GitHub API rate limited. Wait before retrying, \
1045 or check your token's rate limit quota."
1046 );
1047 }
1048
1049 Err(e)
1050 }
1051 }
1052 }
1053
1054 pub async fn create_issue(
1056 &self,
1057 repo: &str,
1058 title: &str,
1059 body: &str,
1060 labels: &[String],
1061 ) -> anyhow::Result<GitHubIssue> {
1062 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1063 let payload = serde_json::json!({
1064 "title": title,
1065 "body": body,
1066 "labels": labels,
1067 });
1068 self.post_json(&url, &payload).await
1069 }
1070
1071 pub async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
1073 self.get_json(&format!("{GITHUB_API}/repos/{repo}/issues/{number}"))
1074 .await
1075 }
1076
1077 pub async fn is_pull_request(&self, repo: &str, number: &str) -> bool {
1079 self.get_issue(repo, number)
1080 .await
1081 .map(|i| i.pull_request.is_some())
1082 .unwrap_or(false)
1083 }
1084
1085 #[allow(dead_code)]
1087 pub async fn list_issues(&self, repo: &str, label: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1088 self.list_issues_with_state(repo, label, "open").await
1089 }
1090
1091 pub async fn list_issues_with_state(
1095 &self,
1096 repo: &str,
1097 label: &str,
1098 state: &str,
1099 ) -> anyhow::Result<Vec<GitHubIssue>> {
1100 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1101 let all: Vec<GitHubIssue> = self
1102 .get_all_pages(
1103 &url,
1104 &[("labels", label), ("state", state), ("per_page", "100")],
1105 )
1106 .await?;
1107 Ok(all
1109 .into_iter()
1110 .filter(|i| i.pull_request.is_none())
1111 .collect())
1112 }
1113
1114 #[allow(dead_code)]
1118 pub async fn list_issues_closed_since(
1119 &self,
1120 repo: &str,
1121 label: &str,
1122 since: &str,
1123 ) -> anyhow::Result<Vec<GitHubIssue>> {
1124 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1125 let all: Vec<GitHubIssue> = self
1126 .get_all_pages(
1127 &url,
1128 &[
1129 ("labels", label),
1130 ("state", "closed"),
1131 ("since", since),
1132 ("per_page", "100"),
1133 ],
1134 )
1135 .await?;
1136 Ok(all
1137 .into_iter()
1138 .filter(|i| i.pull_request.is_none())
1139 .collect())
1140 }
1141
1142 pub async fn list_closed_issues_since(
1144 &self,
1145 repo: &str,
1146 since: &str,
1147 ) -> anyhow::Result<Vec<GitHubIssue>> {
1148 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1149 let all: Vec<GitHubIssue> = self
1150 .get_all_pages(
1151 &url,
1152 &[("state", "closed"), ("since", since), ("per_page", "100")],
1153 )
1154 .await?;
1155 Ok(all
1156 .into_iter()
1157 .filter(|i| i.pull_request.is_none())
1158 .collect())
1159 }
1160
1161 pub async fn list_all_open_issues(
1166 &self,
1167 repo: &str,
1168 since: Option<&str>,
1169 ) -> anyhow::Result<Vec<GitHubIssue>> {
1170 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1171 let params = match since {
1172 Some(ts) => vec![("state", "open"), ("since", ts), ("per_page", "100")],
1173 None => vec![("state", "open"), ("per_page", "100")],
1174 };
1175 let all: Vec<GitHubIssue> = self.get_all_pages(&url, ¶ms).await?;
1176 Ok(all
1178 .into_iter()
1179 .filter(|i| i.pull_request.is_none())
1180 .collect())
1181 }
1182
1183 pub async fn list_open_pulls(&self, repo: &str) -> anyhow::Result<Vec<serde_json::Value>> {
1186 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1187 let all: Vec<serde_json::Value> = self
1188 .get_all_pages(&url, &[("state", "open"), ("per_page", "100")])
1189 .await?;
1190 Ok(all)
1191 }
1192
1193 pub async fn list_all_issues(&self, repo: &str) -> anyhow::Result<Vec<GitHubIssue>> {
1197 let url = format!("{GITHUB_API}/repos/{repo}/issues");
1198 let all: Vec<GitHubIssue> = self
1199 .get_all_pages(&url, &[("state", "all"), ("per_page", "100")])
1200 .await?;
1201 Ok(all
1203 .into_iter()
1204 .filter(|i| i.pull_request.is_none())
1205 .collect())
1206 }
1207
1208 pub async fn add_labels(
1210 &self,
1211 repo: &str,
1212 number: &str,
1213 labels: &[String],
1214 ) -> anyhow::Result<()> {
1215 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels");
1216 let payload = serde_json::json!({ "labels": labels });
1217 self.post_json_raw(&url, &payload).await?;
1218 Ok(())
1219 }
1220
1221 pub async fn ensure_label(
1223 &self,
1224 repo: &str,
1225 name: &str,
1226 color: &str,
1227 description: &str,
1228 ) -> anyhow::Result<()> {
1229 let encoded = urlencoding::encode(name);
1230 let get_url = format!("{GITHUB_API}/repos/{repo}/labels/{encoded}");
1231
1232 match self.get_bytes(&get_url).await {
1234 Ok(_) => return Ok(()),
1235 Err(e) if e.to_string().contains("404") => {
1236 tracing::debug!(repo, name, "ensure_label: label not found, creating");
1237 }
1238 Err(e) => {
1239 tracing::warn!(repo, name, err = %e, "ensure_label: GET failed, attempting create");
1240 }
1241 }
1242
1243 let create_url = format!("{GITHUB_API}/repos/{repo}/labels");
1245 let payload = serde_json::json!({
1246 "name": name,
1247 "color": color,
1248 "description": description,
1249 });
1250
1251 match self.post_json_raw(&create_url, &payload).await {
1252 Ok(_) => {
1253 Self::record_success();
1254 }
1255 Err(e) => {
1256 let msg = e.to_string();
1257 if msg.contains("422")
1258 || msg.contains("already_exists")
1259 || msg.contains("Unprocessable")
1260 {
1261 tracing::debug!(repo, name, "ensure_label: label already exists (race)");
1262 return Ok(());
1263 }
1264 tracing::warn!(repo, name, err = %msg, "ensure_label: create failed, continuing");
1266 }
1267 }
1268 Ok(())
1269 }
1270
1271 pub async fn remove_label(&self, repo: &str, number: &str, label: &str) -> anyhow::Result<()> {
1273 let encoded = urlencoding::encode(label);
1274 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/labels/{encoded}");
1275 match self.delete(&url).await {
1276 Ok(_) => Ok(()),
1277 Err(e) if e.to_string().contains("404") => {
1278 tracing::debug!(repo, number, label, "label already removed (404)");
1279 Ok(())
1280 }
1281 Err(e) => Err(e),
1282 }
1283 }
1284
1285 pub async fn add_comment(&self, repo: &str, number: &str, body: &str) -> anyhow::Result<()> {
1287 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1288 let payload = serde_json::json!({ "body": body });
1289 self.post_json_raw(&url, &payload).await?;
1290 Ok(())
1291 }
1292
1293 pub async fn add_reaction(
1297 &self,
1298 repo: &str,
1299 number: &str,
1300 content: &str,
1301 ) -> anyhow::Result<()> {
1302 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1303 let payload = serde_json::json!({ "content": content });
1304 self.post_json_raw(&url, &payload).await?;
1305 Ok(())
1306 }
1307
1308 pub async fn add_comment_reaction(
1312 &self,
1313 repo: &str,
1314 comment_id: &str,
1315 content: &str,
1316 ) -> anyhow::Result<()> {
1317 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1318 let payload = serde_json::json!({ "content": content });
1319 self.post_json_raw(&url, &payload).await?;
1320 Ok(())
1321 }
1322
1323 pub async fn list_comments(
1325 &self,
1326 repo: &str,
1327 number: &str,
1328 ) -> anyhow::Result<Vec<GitHubComment>> {
1329 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1330 self.get_all_pages(&url, &[("per_page", "100")]).await
1331 }
1332
1333 pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1335 let owner = repo
1336 .split('/')
1337 .next()
1338 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1339 let head = format!("{}:{}", owner, branch);
1340
1341 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1342 let prs: Vec<serde_json::Value> = self
1343 .get_with_query(
1344 &url,
1345 &[("head", &head), ("state", "closed"), ("per_page", "1")],
1346 )
1347 .await?;
1348
1349 if prs.is_empty() {
1350 return Ok(false);
1351 }
1352 let merged_at = prs[0].get("merged_at");
1353 Ok(merged_at.map(|v| !v.is_null()).unwrap_or(false))
1354 }
1355
1356 pub async fn get_closed_pr_state(
1369 &self,
1370 repo: &str,
1371 branch: &str,
1372 ) -> anyhow::Result<Option<(u64, bool, String)>> {
1373 let owner = repo
1374 .split('/')
1375 .next()
1376 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1377 let head = format!("{}:{}", owner, branch);
1378
1379 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1380 let prs: Vec<serde_json::Value> = self
1381 .get_with_query(
1382 &url,
1383 &[("head", &head), ("state", "closed"), ("per_page", "5")],
1384 )
1385 .await?;
1386
1387 if prs.is_empty() {
1388 return Ok(None);
1389 }
1390
1391 for pr in prs {
1394 let pr_branch = pr
1395 .get("head")
1396 .and_then(|h| h.get("ref"))
1397 .and_then(|r| r.as_str())
1398 .unwrap_or("");
1399
1400 if pr_branch != branch {
1401 tracing::debug!(
1402 repo,
1403 branch,
1404 pr_branch,
1405 "skipping PR with mismatched head ref"
1406 );
1407 continue;
1408 }
1409
1410 let pr_number = pr
1411 .get("number")
1412 .and_then(|n| n.as_u64())
1413 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1414
1415 let merged_at = pr.get("merged_at");
1416 let merged = merged_at.map(|v| !v.is_null()).unwrap_or(false);
1417 let state = pr
1418 .get("state")
1419 .and_then(|v| v.as_str())
1420 .unwrap_or("closed")
1421 .to_string();
1422
1423 return Ok(Some((pr_number, merged, state)));
1424 }
1425
1426 Ok(None)
1428 }
1429
1430 pub async fn batch_is_pr_merged_by_branch(
1435 &self,
1436 repo: &str,
1437 branches: &[String],
1438 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1439 if branches.is_empty() {
1440 return Ok(std::collections::HashMap::new());
1441 }
1442
1443 let (owner, name) = repo
1444 .split_once('/')
1445 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1446
1447 let mut var_defs = String::new();
1451 let mut aliases = String::new();
1452 let mut vars = serde_json::json!({ "owner": owner, "name": name });
1453 for (i, branch) in branches.iter().enumerate() {
1454 var_defs.push_str(&format!(", $b{i}: String!"));
1455 aliases.push_str(&format!(
1456 r#"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "#
1457 ));
1458 vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1459 }
1460
1461 let query = format!(
1462 r#"query($owner: String!, $name: String!{var_defs}) {{
1463 repository(owner: $owner, name: $name) {{ {aliases} }}
1464 }}"#
1465 );
1466
1467 let resp = self.graphql_with_vars(&query, vars).await?;
1468
1469 if let Some(errors) = resp.get("errors").and_then(|e| e.as_array()) {
1471 if !errors.is_empty() {
1472 let messages: Vec<&str> = errors
1473 .iter()
1474 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1475 .collect();
1476 tracing::warn!(
1477 repo,
1478 errors = ?messages,
1479 "GraphQL returned errors in batch PR merge check"
1480 );
1481 }
1482 }
1483
1484 Self::parse_batch_pr_merged_response(&resp, branches)
1485 }
1486
1487 fn parse_batch_pr_merged_response(
1488 resp: &serde_json::Value,
1489 branches: &[String],
1490 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1491 let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1492 let error_msg = resp
1494 .get("errors")
1495 .and_then(|e| e.as_array())
1496 .and_then(|arr| arr.first())
1497 .and_then(|e| e.get("message"))
1498 .and_then(|m| m.as_str())
1499 .unwrap_or("no error details");
1500 anyhow::anyhow!(
1501 "missing /data/repository in GraphQL response: {}",
1502 error_msg
1503 )
1504 })?;
1505
1506 let mut result = std::collections::HashMap::new();
1507 for (i, branch) in branches.iter().enumerate() {
1508 let alias = format!("b{i}");
1509 let merged = repo_data
1510 .get(&alias)
1511 .and_then(|v| v.get("nodes"))
1512 .and_then(|nodes| nodes.as_array())
1513 .and_then(|arr| arr.first())
1514 .and_then(|node| node.get("merged"))
1515 .and_then(|v| v.as_bool())
1516 .unwrap_or(false);
1517 result.insert(branch.clone(), merged);
1518 }
1519
1520 Ok(result)
1521 }
1522
1523 pub async fn get_mentions(
1525 &self,
1526 repo: &str,
1527 since: &str,
1528 ) -> anyhow::Result<Vec<GitHubComment>> {
1529 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments");
1530 self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1531 .await
1532 }
1533
1534 pub async fn get_issue_comments(
1536 &self,
1537 repo: &str,
1538 issue_number: u64,
1539 ) -> anyhow::Result<Vec<GitHubComment>> {
1540 let url = format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/comments");
1541 self.get_all_pages(&url, &[("per_page", "100")]).await
1542 }
1543
1544 pub async fn get_whoami(&self) -> anyhow::Result<String> {
1546 if let Some(cached) = self.cached_username.get() {
1547 return Ok(cached.clone());
1548 }
1549
1550 let user: serde_json::Value = self.get_json(&format!("{GITHUB_API}/user")).await?;
1551 let username = user
1552 .get("login")
1553 .and_then(|v| v.as_str())
1554 .map(String::from)
1555 .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1556
1557 let _ = self.cached_username.set(username.clone());
1558 Ok(username)
1559 }
1560
1561 pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1563 let owner = repo
1564 .split('/')
1565 .next()
1566 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1567 let head = format!("{}:{}", owner, branch);
1568
1569 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1570 let prs: Vec<serde_json::Value> = self
1571 .get_with_query(
1572 &url,
1573 &[("head", &head), ("state", "open"), ("per_page", "1")],
1574 )
1575 .await?;
1576
1577 if prs.is_empty() {
1578 return Ok(None);
1579 }
1580 prs[0]
1581 .get("number")
1582 .and_then(|n| n.as_u64())
1583 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1584 .map(Some)
1585 }
1586
1587 pub async fn create_pr(
1598 &self,
1599 repo: &str,
1600 title: &str,
1601 body: &str,
1602 head: &str,
1603 base: &str,
1604 ) -> anyhow::Result<String> {
1605 use reqwest::header;
1606
1607 Self::proactive_throttle_rest().await;
1608 Self::check_backoff()?;
1609 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1610
1611 #[derive(Serialize)]
1612 struct CreatePullRequest<'a> {
1613 title: &'a str,
1614 body: &'a str,
1615 head: &'a str,
1616 base: &'a str,
1617 }
1618
1619 let payload = CreatePullRequest {
1620 title,
1621 body,
1622 head,
1623 base,
1624 };
1625
1626 let auth = self.auth_header().await?;
1627 let make_req = || {
1628 Ok(self
1629 .client
1630 .post(&url)
1631 .header(header::AUTHORIZATION, auth.clone())
1632 .header(header::ACCEPT, "application/vnd.github+json")
1633 .header(header::USER_AGENT, "orch")
1634 .header("X-GitHub-Api-Version", "2022-11-28")
1635 .json(&payload))
1636 };
1637 let response = self.send_with_retries(make_req, false).await?;
1638 let status = response.status();
1639 let text = response.text().await.unwrap_or_default();
1640 if !status.is_success() {
1641 Self::maybe_record_rate_limit_from_body(status, &text);
1642 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
1643 }
1644
1645 let pr: serde_json::Value = serde_json::from_str(&text)?;
1646 pr.get("html_url")
1647 .and_then(|v| v.as_str())
1648 .map(String::from)
1649 .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {:?}", pr))
1650 }
1651
1652 pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
1657 let url = format!("{GITHUB_API}/repos/{repo}");
1658 let resp: serde_json::Value = self.get_json(&url).await?;
1659 resp.get("default_branch")
1660 .and_then(|v| v.as_str())
1661 .map(String::from)
1662 .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
1663 }
1664
1665 pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
1668 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
1669 self.get_json(&url).await
1670 }
1671
1672 pub async fn batch_get_pr_states(
1677 &self,
1678 repo: &str,
1679 pr_numbers: &[u64],
1680 ) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
1681 if pr_numbers.is_empty() {
1682 return Ok(std::collections::HashMap::new());
1683 }
1684
1685 let (owner, name) = repo
1686 .split_once('/')
1687 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1688
1689 const CHUNK_SIZE: usize = 50;
1691 let mut result = std::collections::HashMap::new();
1692
1693 for chunk in pr_numbers.chunks(CHUNK_SIZE) {
1694 let aliases: String = chunk
1695 .iter()
1696 .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
1697 .collect::<Vec<_>>()
1698 .join("\n ");
1699
1700 let query = format!(
1701 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1702 );
1703
1704 let resp = self
1705 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1706 .await?;
1707 let repo_data = resp
1708 .pointer("/data/repository")
1709 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
1710
1711 for n in chunk {
1712 if let Some(pr) = repo_data.get(format!("pr{n}")) {
1713 let merged = pr.get("merged").and_then(|v| v.as_bool()).unwrap_or(false);
1714 let state = pr
1715 .get("state")
1716 .and_then(|v| v.as_str())
1717 .unwrap_or("UNKNOWN")
1718 .to_lowercase();
1719 result.insert(*n, (merged, state));
1720 }
1721 }
1722 }
1723
1724 Ok(result)
1725 }
1726
1727 pub async fn batch_get_issue_states(
1732 &self,
1733 repo: &str,
1734 issue_numbers: &[u64],
1735 caller_context: &str,
1736 ) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
1737 if issue_numbers.is_empty() {
1738 return Ok(std::collections::HashMap::new());
1739 }
1740
1741 let (valid_issue_numbers, invalid_issue_numbers) =
1742 sanitize_github_issue_numbers(issue_numbers);
1743 if !invalid_issue_numbers.is_empty() {
1744 tracing::warn!(
1745 repo,
1746 caller_context,
1747 invalid_issue_numbers = ?invalid_issue_numbers,
1748 total_supplied = issue_numbers.len(),
1749 "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
1750 );
1751 }
1752 if valid_issue_numbers.is_empty() {
1753 return Ok(std::collections::HashMap::new());
1754 }
1755
1756 let (owner, name) = repo
1757 .split_once('/')
1758 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1759
1760 const CHUNK_SIZE: usize = 50;
1762 let mut result = std::collections::HashMap::new();
1763
1764 for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
1765 let aliases: String = chunk
1766 .iter()
1767 .map(|n| {
1768 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
1769 })
1770 .collect::<Vec<_>>()
1771 .join(" ");
1772
1773 let query = format!(
1774 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1775 );
1776
1777 let resp = self
1778 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1779 .await?;
1780 let repo_data = resp
1781 .pointer("/data/repository")
1782 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
1783
1784 for n in chunk {
1785 if let Some(issue) = repo_data.get(format!("issue{n}")) {
1786 let state = issue
1787 .get("state")
1788 .and_then(|v| v.as_str())
1789 .unwrap_or("UNKNOWN")
1790 .to_lowercase();
1791 let labels: Vec<String> = issue
1792 .pointer("/labels/nodes")
1793 .and_then(|v| v.as_array())
1794 .map(|arr| {
1795 arr.iter()
1796 .filter_map(|l| {
1797 l.get("name").and_then(|nm| nm.as_str()).map(String::from)
1798 })
1799 .collect()
1800 })
1801 .unwrap_or_default();
1802 result.insert(n.to_string(), (state, labels));
1803 }
1804 }
1805 }
1806 Ok(result)
1807 }
1808
1809 pub async fn get_required_status_check_contexts(
1811 &self,
1812 repo: &str,
1813 branch: &str,
1814 ) -> anyhow::Result<Vec<String>> {
1815 let branch = urlencoding::encode(branch);
1816 let url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
1817 let resp = self.get_json::<serde_json::Value>(&url).await;
1818 match resp {
1819 Ok(value) => Ok(value
1820 .pointer("/required_status_checks/contexts")
1821 .and_then(|v| v.as_array())
1822 .map(|contexts| {
1823 contexts
1824 .iter()
1825 .filter_map(|context| context.as_str().map(|s| s.to_string()))
1826 .collect()
1827 })
1828 .unwrap_or_default()),
1829 Err(e) => {
1830 let err = e.to_string();
1831 if err.contains("404") {
1832 Ok(vec![])
1833 } else {
1834 Err(e)
1835 }
1836 }
1837 }
1838 }
1839
1840 pub async fn get_commit_status_contexts(
1842 &self,
1843 repo: &str,
1844 sha: &str,
1845 ) -> anyhow::Result<Vec<(String, String)>> {
1846 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
1847 let statuses: Vec<serde_json::Value> =
1852 self.get_all_pages_from_wrapper(&url, "statuses").await?;
1853
1854 Ok(statuses
1855 .into_iter()
1856 .filter_map(|status| {
1857 let context = status.get("context").and_then(|v| v.as_str())?;
1858 let state = status.get("state").and_then(|v| v.as_str())?;
1859 Some((context.to_string(), state.to_string()))
1860 })
1861 .collect())
1862 }
1863
1864 fn combined_status_state(
1865 total: u64,
1866 failing: u64,
1867 pending: u64,
1868 has_workflows: bool,
1869 mergeable_state: Option<&str>,
1870 ) -> String {
1871 if failing > 0 {
1872 "failure".to_string()
1873 } else if pending > 0 {
1874 "pending".to_string()
1875 } else if total == 0 && has_workflows {
1876 if mergeable_state == Some("clean") {
1880 "success".to_string()
1881 } else {
1882 "pending".to_string()
1883 }
1884 } else {
1885 "success".to_string()
1886 }
1887 }
1888
1889 pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
1894 let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
1895 let resp = self.get_json::<serde_json::Value>(&url).await;
1896 match resp {
1897 Ok(value) => Ok(value
1898 .get("workflows")
1899 .and_then(|v| v.as_array())
1900 .map(|workflows| !workflows.is_empty())
1901 .unwrap_or(false)),
1902 Err(e) => {
1903 let err = e.to_string();
1904 if err.contains("404") {
1905 Ok(false)
1906 } else {
1907 Err(e)
1908 }
1909 }
1910 }
1911 }
1912
1913 pub async fn get_check_runs(
1915 &self,
1916 repo: &str,
1917 sha: &str,
1918 ) -> anyhow::Result<Vec<GitHubCheckRun>> {
1919 let url = format!(
1930 "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
1931 );
1932 self.get_all_pages_from_wrapper(&url, "check_runs").await
1933 }
1934
1935 pub async fn get_check_run_annotations(
1942 &self,
1943 repo: &str,
1944 check_run_id: u64,
1945 ) -> anyhow::Result<Vec<GitHubAnnotation>> {
1946 let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
1947 self.get_all_pages(&url, &[("per_page", "100")]).await
1948 }
1949
1950 pub async fn get_pr_reviews(
1952 &self,
1953 repo: &str,
1954 pr_number: u64,
1955 ) -> anyhow::Result<Vec<GitHubReview>> {
1956 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
1957 self.get_all_pages(&url, &[("per_page", "100")]).await
1958 }
1959
1960 pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
1965 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
1966 let value: serde_json::Value = self.get_json(&url).await?;
1967 value
1968 .pointer("/commit/committer/date")
1969 .and_then(|v| v.as_str())
1970 .map(|s| s.to_string())
1971 .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
1972 }
1973
1974 pub async fn update_pr_body(
1976 &self,
1977 repo: &str,
1978 pr_number: u64,
1979 body: &str,
1980 ) -> anyhow::Result<()> {
1981 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
1982 let payload = serde_json::json!({ "body": body });
1983 self.patch_json_raw(&url, &payload).await?;
1984 Ok(())
1985 }
1986
1987 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
1989 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
1990 let payload = serde_json::json!({ "state": "closed" });
1991 self.patch_json_raw(&url, &payload).await?;
1992 Ok(())
1993 }
1994
1995 pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
1997 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
1998 let payload = serde_json::json!({ "state": "open" });
1999 self.patch_json_raw(&url, &payload).await?;
2000 Ok(())
2001 }
2002
2003 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2005 Self::proactive_throttle_rest().await;
2006 Self::check_backoff()?;
2007 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2008 let auth = self.auth_header().await?;
2009 let make_req = || {
2010 Ok(self
2011 .client
2012 .get(&url)
2013 .header(header::AUTHORIZATION, auth.clone())
2014 .header(header::ACCEPT, "application/vnd.github+json")
2015 .header("X-GitHub-Api-Version", "2022-11-28"))
2016 };
2017 let resp = self.send_with_retries(make_req, false).await?;
2018 let status = resp.status();
2019 if status == StatusCode::NO_CONTENT {
2020 Ok(true)
2021 } else if status == StatusCode::NOT_FOUND {
2022 Ok(false)
2023 } else {
2024 let body = resp.text().await.unwrap_or_default();
2025 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2026 }
2027 }
2028
2029 pub async fn add_sub_issue(
2031 &self,
2032 parent_node_id: &str,
2033 child_node_id: &str,
2034 ) -> anyhow::Result<()> {
2035 let query = format!(
2036 r#"mutation {{
2037 addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
2038 issue {{ number }}
2039 subIssue {{ number }}
2040 }}
2041 }}"#,
2042 parent_node_id, child_node_id
2043 );
2044 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2045 .await?;
2046 Ok(())
2047 }
2048
2049 pub async fn link_issue_to_branch(
2061 &self,
2062 repo: &str,
2063 issue_number: u64,
2064 branch: &str,
2065 ) -> anyhow::Result<String> {
2066 let parts: Vec<&str> = repo.split('/').collect();
2067 if parts.len() != 2 {
2068 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2069 }
2070 let (owner, repo_name) = (parts[0], parts[1]);
2071
2072 let query = r#"query($owner: String!, $name: String!, $number: Int!) {
2074 repository(owner: $owner, name: $name) {
2075 id
2076 issue(number: $number) {
2077 id
2078 }
2079 }
2080 }"#;
2081 let vars = serde_json::json!({
2082 "owner": owner,
2083 "name": repo_name,
2084 "number": issue_number,
2085 });
2086
2087 let result = self.graphql_with_vars(query, vars).await?;
2088
2089 let repo_id = result
2090 .pointer("/data/repository/id")
2091 .and_then(|v| v.as_str())
2092 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2093
2094 let issue_id = result
2095 .pointer("/data/repository/issue/id")
2096 .and_then(|v| v.as_str())
2097 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2098
2099 let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
2101 repository(owner: $owner, name: $name) {
2102 ref(qualifiedName: $ref) {
2103 target {
2104 oid
2105 }
2106 }
2107 }
2108 }"#;
2109 let branch_vars = serde_json::json!({
2110 "owner": owner,
2111 "name": repo_name,
2112 "ref": format!("refs/heads/{}", branch),
2113 });
2114
2115 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2116 let branch_oid = branch_result
2117 .pointer("/data/repository/ref/target/oid")
2118 .and_then(|v| v.as_str());
2119
2120 let branch_oid = match branch_oid {
2123 Some(oid) => oid.to_string(),
2124 None => {
2125 let default_query = r#"query($owner: String!, $name: String!) {
2127 repository(owner: $owner, name: $name) {
2128 defaultBranchRef {
2129 target {
2130 oid
2131 }
2132 }
2133 }
2134 }"#;
2135 let default_vars = serde_json::json!({
2136 "owner": owner,
2137 "name": repo_name,
2138 });
2139 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2140 match default_result
2141 .pointer("/data/repository/defaultBranchRef/target/oid")
2142 .and_then(|v| v.as_str())
2143 {
2144 Some(oid) => {
2145 tracing::debug!(
2146 repo,
2147 issue_number,
2148 branch,
2149 "branch not found, using default branch OID to create and link"
2150 );
2151 oid.to_string()
2152 }
2153 None => {
2154 return Ok(format!("unlinked:{}", branch));
2155 }
2156 }
2157 }
2158 };
2159
2160 let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2163 createLinkedBranch(input: {
2164 issueId: $issueId
2165 repositoryId: $repositoryId
2166 name: $name
2167 oid: $oid
2168 }) {
2169 linkedBranch {
2170 id
2171 ref {
2172 name
2173 }
2174 }
2175 }
2176 }"#;
2177 let mutation_vars = serde_json::json!({
2178 "issueId": issue_id,
2179 "repositoryId": repo_id,
2180 "name": branch,
2181 "oid": branch_oid,
2182 });
2183
2184 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2185
2186 match link_result {
2187 Ok(result) => {
2188 let linked_branch_id = result
2189 .pointer("/data/createLinkedBranch/linkedBranch/id")
2190 .and_then(|v| v.as_str())
2191 .map(String::from);
2192
2193 if let Some(id) = linked_branch_id {
2194 tracing::info!(
2195 repo,
2196 issue_number,
2197 branch,
2198 "linked issue to branch via GraphQL API"
2199 );
2200 Ok(id)
2201 } else {
2202 let errors = result
2204 .get("errors")
2205 .and_then(|e| e.as_array())
2206 .cloned()
2207 .unwrap_or_default();
2208
2209 if errors.iter().any(|e| {
2210 e.get("message")
2211 .and_then(|m| m.as_str())
2212 .map(|m| m.contains("already"))
2213 .unwrap_or(false)
2214 }) {
2215 tracing::debug!(
2216 repo,
2217 issue_number,
2218 branch,
2219 "issue already linked to branch"
2220 );
2221 Ok(format!("already_linked:{}", branch))
2222 } else {
2223 anyhow::bail!("failed to link issue to branch: {:?}", result)
2224 }
2225 }
2226 }
2227 Err(e) => {
2228 let err_str = format!("{}", e);
2230 if err_str.contains("already") || err_str.contains("existing") {
2231 tracing::debug!(
2232 repo,
2233 issue_number,
2234 branch,
2235 "issue already linked to branch (error check)"
2236 );
2237 Ok(format!("already_linked:{}", branch))
2238 } else {
2239 Err(e)
2240 }
2241 }
2242 }
2243 }
2244
2245 pub async fn merge_pr(
2247 &self,
2248 repo: &str,
2249 pr_number: u64,
2250 delete_branch: bool,
2251 ) -> anyhow::Result<()> {
2252 Self::proactive_throttle_rest().await;
2253 Self::check_backoff()?;
2254 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2255 let payload = serde_json::json!({ "merge_method": "squash" });
2256 let auth = self.auth_header().await?;
2257 let make_req = || {
2258 Ok(self
2259 .client
2260 .put(&url)
2261 .json(&payload)
2262 .header(header::AUTHORIZATION, auth.clone())
2263 .header(header::ACCEPT, "application/vnd.github+json")
2264 .header("X-GitHub-Api-Version", "2022-11-28"))
2265 };
2266 let resp = self.send_with_retries(make_req, false).await?;
2267 let status = resp.status();
2268 if !status.is_success() {
2269 let body = resp.text().await.unwrap_or_default();
2270 anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2271 }
2272 Self::record_success();
2273
2274 if delete_branch {
2276 let pr: serde_json::Value = self
2278 .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2279 .await?;
2280 if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2281 let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2282 if let Err(e) = self.delete(&del_url).await {
2283 let err_str = e.to_string();
2284 if err_str.contains("Reference does not exist") {
2285 tracing::debug!(
2286 ref_name,
2287 "branch already gone (GitHub auto-deleted on merge)"
2288 );
2289 } else {
2290 tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2291 }
2292 }
2293 }
2294 }
2295 Ok(())
2296 }
2297
2298 pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2304 Self::proactive_throttle_rest().await;
2305 Self::check_backoff()?;
2306 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2307 let auth = self.auth_header().await?;
2308 let make_req = || {
2309 Ok(self
2310 .client
2311 .put(&url)
2312 .header(header::AUTHORIZATION, auth.clone())
2313 .header(header::ACCEPT, "application/vnd.github+json")
2314 .header("X-GitHub-Api-Version", "2022-11-28"))
2315 };
2316 let resp = self.send_with_retries(make_req, false).await?;
2317 let status = resp.status();
2318 if !status.is_success() {
2319 let body = resp.text().await.unwrap_or_default();
2320 Self::maybe_record_rate_limit_from_body(status, &body);
2321 anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2322 }
2323 Self::record_success();
2324 Ok(())
2325 }
2326
2327 pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2331 let parts: Vec<&str> = repo.split('/').collect();
2332 if parts.len() != 2 {
2333 anyhow::bail!("invalid repo format: {repo}");
2334 }
2335 let (owner, repo_name) = (parts[0], parts[1]);
2336
2337 let pr_query = r#"query($owner: String!, $name: String!, $number: Int!) {
2339 repository(owner: $owner, name: $name) {
2340 pullRequest(number: $number) { id }
2341 }
2342 }"#;
2343 let body = self
2344 .graphql_with_vars(
2345 pr_query,
2346 serde_json::json!({
2347 "owner": owner,
2348 "name": repo_name,
2349 "number": pr_number,
2350 }),
2351 )
2352 .await?;
2353 let pr_id = body
2354 .pointer("/data/repository/pullRequest/id")
2355 .and_then(|v| v.as_str())
2356 .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2357 .to_string();
2358
2359 let mutation = r#"mutation($prId: ID!) {
2361 enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2362 pullRequest { autoMergeRequest { enabledAt } }
2363 }
2364 }"#;
2365 let body = self
2366 .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2367 .await?;
2368 if let Some(errors) = body.get("errors") {
2369 anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2370 }
2371
2372 tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2373 Ok(())
2374 }
2375
2376 pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2378 let parts: Vec<&str> = repo.split('/').collect();
2379 if parts.len() != 2 {
2380 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2381 }
2382 let (owner, repo_name) = (parts[0], parts[1]);
2383 let number = match parse_positive_issue_number(number) {
2384 Ok(number) => number,
2385 Err(e) => {
2386 tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2387 return Ok(Vec::new());
2388 }
2389 };
2390
2391 let mut all_numbers: Vec<u64> = Vec::new();
2392 let mut cursor: Option<String> = None;
2393 let page_size = 100;
2394 let max_pages = 50;
2395 let mut page_count = 0;
2396
2397 loop {
2398 page_count += 1;
2399 if page_count > max_pages {
2400 tracing::warn!(
2401 repo,
2402 number,
2403 "get_sub_issues hit max page limit ({max_pages})"
2404 );
2405 break;
2406 }
2407
2408 let (query, vars) = if let Some(ref c) = cursor {
2409 (
2410 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2411 repository(owner: $owner, name: $name) {
2412 issue(number: $number) {
2413 subIssues(first: $first, after: $after) {
2414 nodes { number }
2415 pageInfo { hasNextPage endCursor }
2416 }
2417 }
2418 }
2419 }"#,
2420 serde_json::json!({
2421 "owner": owner,
2422 "name": repo_name,
2423 "number": number,
2424 "first": page_size,
2425 "after": c,
2426 }),
2427 )
2428 } else {
2429 (
2430 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2431 repository(owner: $owner, name: $name) {
2432 issue(number: $number) {
2433 subIssues(first: $first) {
2434 nodes { number }
2435 pageInfo { hasNextPage endCursor }
2436 }
2437 }
2438 }
2439 }"#,
2440 serde_json::json!({
2441 "owner": owner,
2442 "name": repo_name,
2443 "number": number,
2444 "first": page_size,
2445 }),
2446 )
2447 };
2448
2449 let result = self
2450 .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2451 .await?;
2452
2453 let sub_issues_data = result
2454 .get("data")
2455 .and_then(|d| d.get("repository"))
2456 .and_then(|r| r.get("issue"))
2457 .and_then(|i| i.get("subIssues"));
2458
2459 let nodes = sub_issues_data
2460 .and_then(|s| s.get("nodes"))
2461 .and_then(|n| n.as_array());
2462
2463 match nodes {
2464 Some(nodes) => {
2465 let numbers: Vec<u64> = nodes
2466 .iter()
2467 .filter_map(|n| n.get("number").and_then(|num| num.as_u64()))
2468 .collect();
2469 all_numbers.extend(numbers);
2470 }
2471 None => break,
2472 }
2473
2474 let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2475 let has_next = page_info
2476 .and_then(|p| p.get("hasNextPage"))
2477 .and_then(|h| h.as_bool())
2478 .unwrap_or(false);
2479
2480 if !has_next {
2481 break;
2482 }
2483
2484 cursor = page_info
2485 .and_then(|p| p.get("endCursor"))
2486 .and_then(|c| c.as_str())
2487 .map(|s| s.to_string());
2488 }
2489
2490 Ok(all_numbers)
2491 }
2492
2493 pub async fn get_combined_status(
2495 &self,
2496 repo: &str,
2497 git_ref: &str,
2498 has_workflows: bool,
2499 mergeable_state: Option<&str>,
2500 ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2501 let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2502 let resp: serde_json::Value = self.get_json(&url).await?;
2503
2504 let runs = resp
2505 .get("check_runs")
2506 .and_then(|v| v.as_array())
2507 .cloned()
2508 .unwrap_or_default();
2509
2510 let mut passing = 0u64;
2511 let mut failing = 0u64;
2512 let mut pending = 0u64;
2513
2514 for run in &runs {
2515 let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2516 let status = run
2517 .get("status")
2518 .and_then(|v| v.as_str())
2519 .unwrap_or("queued");
2520
2521 match status {
2522 "completed" => {
2523 if conclusion.is_empty() {
2524 pending += 1;
2525 } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2526 passing += 1;
2527 } else {
2528 failing += 1;
2529 }
2530 }
2531 _ => pending += 1,
2532 }
2533 }
2534
2535 let total = runs.len() as u64;
2536 let state =
2537 Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
2538
2539 Ok((state, total, passing, failing, pending))
2540 }
2541
2542 pub async fn rerun_failed_workflow(
2548 &self,
2549 repo: &str,
2550 workflow_name: &str,
2551 branch: &str,
2552 ) -> anyhow::Result<()> {
2553 let url = format!(
2555 "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
2556 );
2557 let resp: serde_json::Value = self.get_json(&url).await?;
2558 let runs = resp["workflow_runs"]
2559 .as_array()
2560 .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
2561
2562 let run_id = runs
2564 .iter()
2565 .find(|r| {
2566 r["name"].as_str() == Some(workflow_name)
2567 && matches!(
2568 r["conclusion"].as_str(),
2569 Some("failure") | Some("cancelled")
2570 )
2571 })
2572 .and_then(|r| r["id"].as_u64())
2573 .ok_or_else(|| {
2574 anyhow::anyhow!(
2575 "no failed run found for workflow '{}' on branch '{}'",
2576 workflow_name,
2577 branch
2578 )
2579 })?;
2580
2581 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
2583 let auth = self.auth_header().await?;
2584 let make_req = || {
2585 Ok(self
2586 .client
2587 .post(&rerun_url)
2588 .header(header::AUTHORIZATION, auth.clone())
2589 .header(header::ACCEPT, "application/vnd.github+json")
2590 .header("X-GitHub-Api-Version", "2022-11-28"))
2591 };
2592 let resp = self.send_with_retries(make_req, false).await?;
2593 let status = resp.status();
2594 if !status.is_success() {
2595 let body = resp.text().await.unwrap_or_default();
2596 Self::maybe_record_rate_limit_from_body(status, &body);
2597 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
2598 }
2599
2600 Ok(())
2601 }
2602
2603 pub async fn batch_fetch_pr_review_data(
2609 &self,
2610 repo: &str,
2611 pr_numbers: &[u64],
2612 ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
2613 if pr_numbers.is_empty() {
2614 return Ok(std::collections::HashMap::new());
2615 }
2616
2617 let (owner, name) = repo
2618 .split_once('/')
2619 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2620
2621 let aliases: String = pr_numbers
2623 .iter()
2624 .map(|&n| build_pr_review_alias(n))
2625 .collect::<Vec<_>>()
2626 .join("\n");
2627
2628 let query = format!(
2629 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2630 );
2631
2632 let resp = self
2633 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2634 .await?;
2635 let repo_data = resp
2636 .pointer("/data/repository")
2637 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2638
2639 let mut result = std::collections::HashMap::new();
2640 for &n in pr_numbers {
2641 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
2642 continue;
2643 };
2644
2645 let merged = pr_data
2646 .get("merged")
2647 .and_then(|v| v.as_bool())
2648 .unwrap_or(false);
2649 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
2650 Some("MERGEABLE") => Some(true),
2651 Some("CONFLICTING") => Some(false),
2652 _ => None, };
2654
2655 let mut reviews: Vec<GitHubReview> = pr_data
2656 .pointer("/reviews/nodes")
2657 .and_then(|v| v.as_array())
2658 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
2659 .unwrap_or_default();
2660
2661 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
2662 tracing::warn!(
2663 pr_number = n,
2664 fetched = reviews.len(),
2665 "batch review fetch truncated reviews connection at first page"
2666 );
2667 }
2668
2669 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
2673
2674 let mut review_comments: Vec<GitHubReviewComment> = pr_data
2675 .pointer("/reviewThreads/nodes")
2676 .and_then(|v| v.as_array())
2677 .map(|threads| {
2678 threads
2679 .iter()
2680 .filter_map(|thread| {
2681 thread
2682 .pointer("/comments/nodes")
2683 .and_then(|v| v.as_array())
2684 .map(|nodes| {
2685 nodes
2686 .iter()
2687 .filter_map(parse_graphql_review_comment)
2688 .collect::<Vec<_>>()
2689 })
2690 })
2691 .flatten()
2692 .collect()
2693 })
2694 .unwrap_or_default();
2695
2696 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
2697 tracing::warn!(
2698 pr_number = n,
2699 fetched_threads = pr_data
2700 .pointer("/reviewThreads/nodes")
2701 .and_then(|v| v.as_array())
2702 .map_or(0, |threads| threads.len()),
2703 "batch review fetch truncated reviewThreads connection at first page"
2704 );
2705 }
2706 if pr_data
2707 .pointer("/reviewThreads/nodes")
2708 .and_then(|v| v.as_array())
2709 .is_some_and(|threads| {
2710 threads.iter().any(|thread| {
2711 connection_has_next_page(thread.pointer("/comments/pageInfo"))
2712 })
2713 })
2714 {
2715 tracing::warn!(
2716 pr_number = n,
2717 "batch review fetch truncated one or more review thread comment connections"
2718 );
2719 }
2720
2721 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2724
2725 let mut issue_comments: Vec<GitHubComment> = pr_data
2726 .pointer("/comments/nodes")
2727 .and_then(|v| v.as_array())
2728 .map(|nodes| {
2729 nodes
2730 .iter()
2731 .filter_map(parse_graphql_issue_comment)
2732 .collect()
2733 })
2734 .unwrap_or_default();
2735
2736 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
2737 issue_comments.extend(
2738 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
2739 .await?,
2740 );
2741 }
2742
2743 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2748
2749 result.insert(
2750 n,
2751 PrReviewBatchData {
2752 merged,
2753 mergeable,
2754 reviews,
2755 review_comments,
2756 issue_comments,
2757 },
2758 );
2759 }
2760
2761 Ok(result)
2762 }
2763
2764 async fn fetch_pr_issue_comments_after(
2765 &self,
2766 owner: &str,
2767 name: &str,
2768 pr_number: u64,
2769 mut cursor: Option<String>,
2770 ) -> anyhow::Result<Vec<GitHubComment>> {
2771 let mut comments = Vec::new();
2772 let mut page_count = 0;
2773 let max_pages = 50;
2774
2775 while let Some(current_cursor) = cursor.take() {
2776 page_count += 1;
2777 if page_count > max_pages {
2778 tracing::warn!(
2779 pr_number,
2780 fetched = comments.len(),
2781 "stopping PR issue comment pagination after max page limit"
2782 );
2783 break;
2784 }
2785
2786 let (query, vars) =
2787 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
2788 let resp = self.graphql_with_vars(&query, vars).await?;
2789 let comments_data = resp
2790 .pointer("/data/repository/pullRequest/comments")
2791 .ok_or_else(|| {
2792 anyhow::anyhow!(
2793 "missing /data/repository/pullRequest/comments in GraphQL response"
2794 )
2795 })?;
2796
2797 comments.extend(
2798 comments_data
2799 .get("nodes")
2800 .and_then(|v| v.as_array())
2801 .into_iter()
2802 .flatten()
2803 .filter_map(parse_graphql_issue_comment),
2804 );
2805
2806 cursor = connection_end_cursor(comments_data.get("pageInfo"));
2807 }
2808
2809 Ok(comments)
2810 }
2811
2812 pub async fn get_automated_review_status(
2814 &self,
2815 repo: &str,
2816 pr_number: u64,
2817 ) -> anyhow::Result<Option<String>> {
2818 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
2819
2820 let mut newest: Option<&crate::github::types::GitHubComment> = None;
2822 for c in &comments {
2823 if !c.body.starts_with("## Automated Review") {
2824 continue;
2825 }
2826 match self.is_collaborator(repo, &c.user.login).await {
2827 Ok(true) => match newest {
2828 None => newest = Some(c),
2829 Some(prev) => {
2830 if c.created_at > prev.created_at {
2831 newest = Some(c);
2832 }
2833 }
2834 },
2835 Ok(false) => {
2836 tracing::warn!(
2837 user = %c.user.login,
2838 pr_number,
2839 "ignoring automated review comment from non-collaborator"
2840 );
2841 continue;
2842 }
2843 Err(e) => {
2844 tracing::debug!(
2845 user = %c.user.login,
2846 error = %e,
2847 "failed to check collaborator status, skipping comment"
2848 );
2849 continue;
2850 }
2851 }
2852 }
2853
2854 match newest {
2855 Some(c) => {
2856 let first_line = c.body.lines().next().unwrap_or("");
2857 if first_line.contains("Automated Review \u{2014} Approve") {
2858 Ok(Some("approve".to_string()))
2859 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
2860 Ok(Some("changes_requested".to_string()))
2861 } else {
2862 Ok(None)
2863 }
2864 }
2865 None => Ok(None),
2866 }
2867 }
2868}
2869
2870fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
2871 let mut valid = Vec::with_capacity(issue_numbers.len());
2872 let mut invalid = Vec::new();
2873 for &issue_number in issue_numbers {
2874 if issue_number == 0 {
2875 invalid.push(issue_number);
2876 } else {
2877 valid.push(issue_number);
2878 }
2879 }
2880 (valid, invalid)
2881}
2882
2883#[derive(Debug, Clone)]
2887pub struct RateLimitMetrics {
2888 pub hits: u64,
2890 pub proactive_throttles: u64,
2892 pub wait_secs: u64,
2894 pub rest_remaining: Option<u32>,
2896 pub graphql_remaining: Option<u32>,
2898 pub five_xx_hits: u64,
2900}
2901
2902pub fn rate_limit_metrics() -> RateLimitMetrics {
2904 let rest_remaining = REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
2905 let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
2906 RateLimitMetrics {
2907 hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
2908 proactive_throttles: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
2909 wait_secs: METRIC_WAIT_SECS_TOTAL.load(Ordering::Relaxed),
2910 rest_remaining,
2911 graphql_remaining,
2912 five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
2913 }
2914}
2915
2916fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
2920 let link = headers.get("link")?.to_str().ok()?;
2921 for part in link.split(',') {
2922 let part = part.trim();
2923 if part.contains("rel=\"next\"") {
2924 let start = part.find('<')? + 1;
2926 let end = part.find('>')?;
2927 if start >= end {
2928 return None;
2929 }
2930 return Some(part[start..end].to_string());
2931 }
2932 }
2933 None
2934}
2935
2936pub fn status_label_color(label: &str) -> &'static str {
2938 match label {
2939 "status:new" => "0e8a16",
2940 "status:routed" => "1d76db",
2941 "status:in_progress" => "fbca04",
2942 "status:done" => "6f42c1",
2943 "status:blocked" => "d73a4a",
2944 "status:in_review" => "0075ca",
2945 "status:needs_review" => "e4e669",
2946 _ => "c5def5",
2947 }
2948}
2949
2950#[derive(Debug, Clone, Default)]
2954pub struct PrReviewBatchData {
2955 pub merged: bool,
2957 pub mergeable: Option<bool>,
2959 pub reviews: Vec<GitHubReview>,
2961 pub review_comments: Vec<GitHubReviewComment>,
2963 pub issue_comments: Vec<GitHubComment>,
2965}
2966
2967fn build_pr_review_alias(n: u64) -> String {
2970 format!(
2971 r#"pr{n}: pullRequest(number: {n}) {{
2972 merged
2973 mergeable
2974 reviews(first: 50) {{
2975 nodes {{
2976 databaseId
2977 author {{ login }}
2978 body
2979 state
2980 url
2981 submittedAt
2982 commit {{ oid }}
2983 }}
2984 pageInfo {{ hasNextPage }}
2985 }}
2986 reviewThreads(first: 50) {{
2987 nodes {{
2988 comments(first: 20) {{
2989 nodes {{
2990 databaseId
2991 author {{ login }}
2992 body
2993 path
2994 line
2995 originalLine
2996 commit {{ oid }}
2997 originalCommit {{ oid }}
2998 url
2999 createdAt
3000 updatedAt
3001 replyTo {{ databaseId }}
3002 diffHunk
3003 }}
3004 pageInfo {{ hasNextPage }}
3005 }}
3006 }}
3007 pageInfo {{ hasNextPage }}
3008 }}
3009 comments(first: 50) {{
3010 nodes {{
3011 databaseId
3012 author {{ login }}
3013 body
3014 createdAt
3015 updatedAt
3016 url
3017 }}
3018 pageInfo {{ hasNextPage endCursor }}
3019 }}
3020}}"#
3021 )
3022}
3023
3024fn build_pr_issue_comments_page_query(
3025 owner: &str,
3026 name: &str,
3027 pr_number: u64,
3028 after_cursor: &str,
3029) -> (String, serde_json::Value) {
3030 let query = r#"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3031 repository(owner: $owner, name: $name) {
3032 pullRequest(number: $number) {
3033 comments(first: 100, after: $after) {
3034 nodes {
3035 databaseId
3036 author { login }
3037 body
3038 createdAt
3039 updatedAt
3040 url
3041 }
3042 pageInfo { hasNextPage endCursor }
3043 }
3044 }
3045 }
3046}"#
3047 .to_string();
3048 let vars = serde_json::json!({
3049 "owner": owner,
3050 "name": name,
3051 "number": pr_number,
3052 "after": after_cursor,
3053 });
3054 (query, vars)
3055}
3056
3057fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3058 page_info
3059 .and_then(|v| v.get("hasNextPage"))
3060 .and_then(|v| v.as_bool())
3061 .unwrap_or(false)
3062}
3063
3064fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3065 if !connection_has_next_page(page_info) {
3066 return None;
3067 }
3068
3069 page_info
3070 .and_then(|v| v.get("endCursor"))
3071 .and_then(|v| v.as_str())
3072 .map(str::to_owned)
3073}
3074
3075fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3076 use super::types::GitHubUser;
3077 Some(GitHubReview {
3078 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3079 user: GitHubUser {
3080 login: node
3081 .pointer("/author/login")
3082 .and_then(|v| v.as_str())?
3083 .to_string(),
3084 },
3085 body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3086 state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3087 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3088 submitted_at: node
3089 .get("submittedAt")
3090 .and_then(|v| v.as_str())
3091 .unwrap_or("")
3092 .to_string(),
3093 commit_id: node
3094 .pointer("/commit/oid")
3095 .and_then(|v| v.as_str())
3096 .map(String::from),
3097 })
3098}
3099
3100fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3101 use super::types::GitHubUser;
3102 Some(GitHubReviewComment {
3103 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3104 user: GitHubUser {
3105 login: node
3106 .pointer("/author/login")
3107 .and_then(|v| v.as_str())?
3108 .to_string(),
3109 },
3110 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3111 path: node
3112 .get("path")
3113 .and_then(|v| v.as_str())
3114 .unwrap_or("")
3115 .to_string(),
3116 line: node.get("line").and_then(|v| v.as_u64()).map(|v| v as u32),
3117 original_line: node
3118 .get("originalLine")
3119 .and_then(|v| v.as_u64())
3120 .map(|v| v as u32),
3121 commit_id: node
3122 .pointer("/commit/oid")
3123 .and_then(|v| v.as_str())
3124 .unwrap_or("")
3125 .to_string(),
3126 original_commit_id: node
3127 .pointer("/originalCommit/oid")
3128 .and_then(|v| v.as_str())
3129 .unwrap_or("")
3130 .to_string(),
3131 html_url: node
3132 .get("url")
3133 .and_then(|v| v.as_str())
3134 .unwrap_or("")
3135 .to_string(),
3136 created_at: node
3137 .get("createdAt")
3138 .and_then(|v| v.as_str())
3139 .unwrap_or("")
3140 .to_string(),
3141 updated_at: node
3142 .get("updatedAt")
3143 .and_then(|v| v.as_str())
3144 .unwrap_or("")
3145 .to_string(),
3146 in_reply_to_id: node.pointer("/replyTo/databaseId").and_then(|v| v.as_u64()),
3147 diff_hunk: node
3148 .get("diffHunk")
3149 .and_then(|v| v.as_str())
3150 .map(String::from),
3151 })
3152}
3153
3154fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3155 use super::types::GitHubUser;
3156 Some(GitHubComment {
3157 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3158 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3159 user: GitHubUser {
3160 login: node
3161 .pointer("/author/login")
3162 .and_then(|v| v.as_str())?
3163 .to_string(),
3164 },
3165 created_at: node
3166 .get("createdAt")
3167 .and_then(|v| v.as_str())
3168 .unwrap_or("")
3169 .to_string(),
3170 updated_at: node
3171 .get("updatedAt")
3172 .and_then(|v| v.as_str())
3173 .map(String::from),
3174 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3175 issue_url: None,
3176 author_association: None,
3177 })
3178}
3179
3180#[cfg(test)]
3181mod tests {
3182 use super::*;
3183 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3184 use tokio::net::TcpListener;
3185
3186 async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3187 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3188 let addr = listener.local_addr().unwrap();
3189
3190 tokio::spawn(async move {
3191 let (mut socket, _) = listener.accept().await.unwrap();
3192 let mut buf = [0u8; 4096];
3193 let _ = socket.read(&mut buf).await;
3194
3195 let declared_len = body_prefix.len() + 32;
3196 let response = format!(
3197 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
3198 );
3199 socket.write_all(response.as_bytes()).await.unwrap();
3200 socket.shutdown().await.unwrap();
3201 });
3202
3203 format!("http://{addr}")
3204 }
3205
3206 fn test_client() -> GhHttp {
3207 GhHttp {
3208 client: reqwest::Client::new(),
3209 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3210 cached_username: OnceLock::new(),
3211 }
3212 }
3213
3214 #[test]
3219 fn gh_http_new_returns_ok() {
3220 let result = GhHttp::new();
3221 assert!(
3222 result.is_ok(),
3223 "GhHttp::new() must not panic and must return Ok; got: {:?}",
3224 result.err()
3225 );
3226 }
3227
3228 #[test]
3229 fn batch_get_issue_states_query_includes_labels_pagination() {
3230 let issue_numbers = [1u64, 2, 3];
3231
3232 let aliases: String = issue_numbers
3233 .iter()
3234 .map(|n| {
3235 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
3236 })
3237 .collect::<Vec<_>>()
3238 .join(" ");
3239
3240 let query = format!(
3241 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
3242 );
3243 assert!(query.contains("$owner"));
3244 assert!(query.contains("$name"));
3245 assert!(query.contains("issue1: issue(number: 1)"));
3246 assert!(query.contains("issue3: issue(number: 3)"));
3247 assert!(query.contains("labels(first: 100)"));
3248 }
3249
3250 #[test]
3251 fn parse_positive_issue_number_accepts_positive_values() {
3252 assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
3253 }
3254
3255 #[test]
3256 fn parse_positive_issue_number_rejects_zero() {
3257 let err = parse_positive_issue_number("0").unwrap_err().to_string();
3258 assert!(err.contains("invalid issue number: 0"));
3259 }
3260
3261 #[test]
3262 fn parse_positive_issue_number_rejects_internal_ids() {
3263 let err = parse_positive_issue_number("internal:5")
3264 .unwrap_err()
3265 .to_string();
3266 assert!(err.contains("invalid issue number: internal:5"));
3267 }
3268
3269 #[test]
3270 fn pr_review_batch_query_includes_page_info_for_nested_connections() {
3271 let query = build_pr_review_alias(42);
3272
3273 assert!(query.contains("reviews(first: 50)"));
3274 assert!(query.contains("reviews(first: 50) {\n nodes"));
3275 assert!(query.contains("pageInfo { hasNextPage }"));
3276 assert!(query.contains("reviewThreads(first: 50)"));
3277 assert!(query.contains("comments(first: 20)"));
3278 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3279 assert!(query.contains("updatedAt"));
3280 }
3281
3282 #[test]
3283 fn pr_issue_comments_page_query_uses_cursor_pagination() {
3284 let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
3285
3286 assert!(query.contains("pullRequest(number: $number)"));
3287 assert!(query.contains("comments(first: 100, after: $after)"));
3288 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3289 assert!(query.contains("updatedAt"));
3290 assert_eq!(vars["owner"], "owner");
3291 assert_eq!(vars["name"], "repo");
3292 assert_eq!(vars["number"], 42);
3293 assert_eq!(vars["after"], "cursor123");
3294 }
3295
3296 #[test]
3297 fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
3298 let page_info = serde_json::json!({
3299 "hasNextPage": true,
3300 "endCursor": "cursor123"
3301 });
3302 assert_eq!(
3303 connection_end_cursor(Some(&page_info)),
3304 Some("cursor123".to_string())
3305 );
3306
3307 let final_page = serde_json::json!({
3308 "hasNextPage": false,
3309 "endCursor": "cursor456"
3310 });
3311 assert_eq!(connection_end_cursor(Some(&final_page)), None);
3312 }
3313
3314 #[test]
3315 fn status_label_colors_match_bash_palette() {
3316 assert_eq!(status_label_color("status:new"), "0e8a16");
3317 assert_eq!(status_label_color("status:routed"), "1d76db");
3318 assert_eq!(status_label_color("status:in_progress"), "fbca04");
3319 assert_eq!(status_label_color("status:done"), "6f42c1");
3320 assert_eq!(status_label_color("status:blocked"), "d73a4a");
3321 assert_eq!(status_label_color("status:in_review"), "0075ca");
3322 assert_eq!(status_label_color("status:needs_review"), "e4e669");
3323 }
3324
3325 #[test]
3326 fn unknown_label_gets_default_color() {
3327 assert_eq!(status_label_color("agent:claude"), "c5def5");
3328 assert_eq!(status_label_color("enhancement"), "c5def5");
3329 assert_eq!(status_label_color(""), "c5def5");
3330 }
3331
3332 #[test]
3333 fn parse_link_next_finds_next_url() {
3334 let mut headers = header::HeaderMap::new();
3335 headers.insert(
3336 "link",
3337 "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
3338 .parse()
3339 .unwrap(),
3340 );
3341 assert_eq!(
3342 parse_link_next(&headers),
3343 Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
3344 );
3345 }
3346
3347 #[test]
3348 fn parse_link_next_none_when_no_next() {
3349 let mut headers = header::HeaderMap::new();
3350 headers.insert(
3351 "link",
3352 "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
3353 .parse()
3354 .unwrap(),
3355 );
3356 assert_eq!(parse_link_next(&headers), None);
3357 }
3358
3359 #[test]
3360 fn parse_link_next_none_when_missing() {
3361 let headers = header::HeaderMap::new();
3362 assert_eq!(parse_link_next(&headers), None);
3363 }
3364
3365 #[test]
3366 fn parse_link_next_none_when_malformed() {
3367 let mut headers = header::HeaderMap::new();
3368 headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
3370 assert_eq!(parse_link_next(&headers), None);
3371 }
3372
3373 #[test]
3374 fn parse_link_next_none_when_gt_before_lt() {
3375 let mut headers = header::HeaderMap::new();
3376 headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
3377 assert_eq!(parse_link_next(&headers), None); }
3379
3380 #[test]
3381 fn parse_link_next_none_when_empty_url() {
3382 let mut headers = header::HeaderMap::new();
3383 headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
3384 assert_eq!(parse_link_next(&headers), None);
3385 }
3386
3387 fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
3388 RateLimit {
3389 remaining: None,
3390 reset_at: None,
3391 backoff_until: None,
3392 backoff_delay: Duration::ZERO,
3393 backoff_base: Duration::from_secs(base_secs),
3394 backoff_max: Duration::from_secs(max_secs),
3395 throttle_threshold: 10,
3396 }
3397 }
3398
3399 #[test]
3400 fn rate_limit_inactive_by_default() {
3401 let rl = make_rl(30, 900);
3402 assert!(rl.is_active().is_none());
3403 }
3404
3405 #[test]
3406 fn rate_limit_backoff_activates() {
3407 let mut rl = make_rl(5, 60);
3408 rl.record_rate_limit();
3409 assert!(rl.is_active().is_some());
3410 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3411 }
3412
3413 #[test]
3414 fn rate_limit_backoff_doubles() {
3415 let mut rl = make_rl(5, 60);
3416 rl.record_rate_limit();
3417 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3418 rl.record_rate_limit();
3419 assert_eq!(rl.backoff_delay, Duration::from_secs(10));
3420 rl.record_rate_limit();
3421 assert_eq!(rl.backoff_delay, Duration::from_secs(20));
3422 }
3423
3424 #[test]
3425 fn rate_limit_backoff_caps_at_max() {
3426 let mut rl = make_rl(30, 60);
3427 rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
3431 }
3432
3433 #[test]
3434 fn rate_limit_success_resets() {
3435 let mut rl = make_rl(5, 60);
3436 rl.record_rate_limit();
3437 assert!(rl.is_active().is_some());
3438 rl.record_success();
3439 assert!(rl.is_active().is_none());
3440 assert_eq!(rl.backoff_delay, Duration::ZERO);
3441 }
3442
3443 #[test]
3444 fn rate_limit_proactive_pause() {
3445 let mut rl = make_rl(30, 900);
3446 rl.remaining = Some(0);
3448 let future_epoch = std::time::SystemTime::now()
3449 .duration_since(std::time::UNIX_EPOCH)
3450 .unwrap()
3451 .as_secs()
3452 + 60;
3453 rl.reset_at = Some(future_epoch);
3454 let pause = rl.is_active();
3455 assert!(pause.is_some());
3456 assert!(pause.unwrap().as_secs() <= 62);
3458 }
3459
3460 #[test]
3461 fn rate_limit_proactive_pause_threshold() {
3462 let mut rl = make_rl(30, 900);
3463 rl.remaining = Some(15);
3465 let future_epoch = std::time::SystemTime::now()
3466 .duration_since(std::time::UNIX_EPOCH)
3467 .unwrap()
3468 .as_secs()
3469 + 60;
3470 rl.reset_at = Some(future_epoch);
3471 assert!(rl.is_active().is_none());
3472
3473 rl.remaining = Some(5);
3475 assert!(rl.is_active().is_some());
3476 }
3477
3478 #[test]
3479 fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
3480 assert_eq!(
3482 GhHttp::combined_status_state(0, 0, 0, true, None),
3483 "pending"
3484 );
3485 assert_eq!(
3487 GhHttp::combined_status_state(0, 0, 0, false, None),
3488 "success"
3489 );
3490 assert_eq!(
3492 GhHttp::combined_status_state(3, 1, 0, true, None),
3493 "failure"
3494 );
3495 assert_eq!(
3497 GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
3498 "success"
3499 );
3500 assert_eq!(
3502 GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
3503 "pending"
3504 );
3505 }
3506
3507 #[test]
3508 fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
3509 let resp = serde_json::json!({
3510 "data": {},
3511 "errors": [{ "message": "Something went wrong" }],
3512 });
3513
3514 let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
3515 .unwrap_err()
3516 .to_string();
3517 assert!(err_text.contains("missing /data/repository in GraphQL response"));
3518 assert!(err_text.contains("Something went wrong"));
3519 }
3520
3521 #[test]
3522 fn rate_limit_uses_reset_at_for_backoff() {
3523 let mut rl = make_rl(30, 900);
3524 let future_epoch = std::time::SystemTime::now()
3526 .duration_since(std::time::UNIX_EPOCH)
3527 .unwrap()
3528 .as_secs()
3529 + 45;
3530 rl.reset_at = Some(future_epoch);
3531 rl.record_rate_limit();
3532 assert!(rl.backoff_delay.as_secs() >= 45);
3534 assert!(rl.backoff_delay.as_secs() <= 47);
3535 }
3536
3537 #[tokio::test]
3538 async fn send_with_retries_propagates_make_req_error() {
3539 let guard = std::env::var_os("GH_TOKEN");
3540 std::env::set_var("GH_TOKEN", "test_token");
3541 let gh = test_client();
3542 let result = gh
3543 .send_with_retries(
3544 || Err(anyhow::anyhow!("simulated request clone failure")),
3545 false,
3546 )
3547 .await;
3548 assert!(result.is_err());
3549 let err = result.unwrap_err().to_string();
3550 assert_eq!(err, "simulated request clone failure");
3551 match guard {
3552 Some(v) => std::env::set_var("GH_TOKEN", v),
3553 None => std::env::remove_var("GH_TOKEN"),
3554 }
3555 }
3556
3557 #[tokio::test]
3563 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
3564 let guard = std::env::var_os("GH_TOKEN");
3565 std::env::set_var("GH_TOKEN", "test_token");
3566
3567 let addr = {
3571 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3572 listener.local_addr().unwrap()
3573 };
3574 let url = format!("http://{addr}/");
3575
3576 let gh = test_client();
3577 let make_req = || Ok(gh.client.get(&url));
3578 let result = gh.send_with_retries(make_req, false).await;
3579
3580 assert!(result.is_err());
3581 assert!(
3582 !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
3583 "transport error must not trip the github:5xx circuit breaker"
3584 );
3585 assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
3586
3587 match guard {
3588 Some(v) => std::env::set_var("GH_TOKEN", v),
3589 None => std::env::remove_var("GH_TOKEN"),
3590 }
3591 }
3592
3593 #[tokio::test]
3594 async fn post_json_raw_propagates_body_read_errors_on_success() {
3595 let guard = std::env::var_os("GH_TOKEN");
3596 std::env::set_var("GH_TOKEN", "test_token");
3597 let server_url = spawn_truncated_body_server("{").await;
3598 let gh = test_client();
3599
3600 let result = gh
3601 .post_json_raw(
3602 &format!("{server_url}/post"),
3603 &serde_json::json!({"ok": true}),
3604 )
3605 .await;
3606
3607 let err = result.unwrap_err().to_string();
3608 assert!(
3609 !err.contains("EOF while parsing a value"),
3610 "should surface the body read error, got: {err}"
3611 );
3612 assert!(
3613 err.contains("error decoding response body")
3614 || err.contains("request or response body error")
3615 || err.contains("end of file before message length reached"),
3616 "expected a transport/body read error, got: {err}"
3617 );
3618
3619 match guard {
3620 Some(v) => std::env::set_var("GH_TOKEN", v),
3621 None => std::env::remove_var("GH_TOKEN"),
3622 }
3623 }
3624
3625 #[tokio::test]
3626 async fn patch_json_raw_propagates_body_read_errors_on_success() {
3627 let guard = std::env::var_os("GH_TOKEN");
3628 std::env::set_var("GH_TOKEN", "test_token");
3629 let server_url = spawn_truncated_body_server("{").await;
3630 let gh = test_client();
3631
3632 let result = gh
3633 .patch_json_raw(
3634 &format!("{server_url}/patch"),
3635 &serde_json::json!({"ok": true}),
3636 )
3637 .await;
3638
3639 let err = result.unwrap_err().to_string();
3640 assert!(
3641 !err.contains("EOF while parsing a value"),
3642 "should surface the body read error, got: {err}"
3643 );
3644 assert!(
3645 err.contains("error decoding response body")
3646 || err.contains("request or response body error")
3647 || err.contains("end of file before message length reached"),
3648 "expected a transport/body read error, got: {err}"
3649 );
3650
3651 match guard {
3652 Some(v) => std::env::set_var("GH_TOKEN", v),
3653 None => std::env::remove_var("GH_TOKEN"),
3654 }
3655 }
3656
3657 #[tokio::test]
3658 async fn graphql_request_propagates_body_read_errors_on_success() {
3659 let guard = std::env::var_os("GH_TOKEN");
3660 std::env::set_var("GH_TOKEN", "test_token");
3661 let server_url = spawn_truncated_body_server("{").await;
3662 let gh = test_client();
3663 let query = "query { viewer { login } }";
3664
3665 let result = gh
3666 .graphql_request_to_url(
3667 &format!("{server_url}/graphql"),
3668 query,
3669 &[],
3670 Some(serde_json::json!({"k": "v"})),
3671 )
3672 .await;
3673
3674 let err = result.unwrap_err().to_string();
3675 assert!(
3676 !err.contains("EOF while parsing a value"),
3677 "should surface the body read error, got: {err}"
3678 );
3679 assert!(
3680 err.contains("error decoding response body")
3681 || err.contains("request or response body error")
3682 || err.contains("end of file before message length reached"),
3683 "expected a transport/body read error, got: {err}"
3684 );
3685
3686 match guard {
3687 Some(v) => std::env::set_var("GH_TOKEN", v),
3688 None => std::env::remove_var("GH_TOKEN"),
3689 }
3690 }
3691
3692 #[cfg(test)]
3695 mod pagination {
3696 use super::super::*;
3697 use std::ffi::OsString;
3698 use std::sync::Arc;
3699 use wiremock::matchers::{method, path};
3700 use wiremock::{Mock, MockServer, ResponseTemplate};
3701
3702 struct EnvVarGuard {
3703 key: &'static str,
3704 previous: Option<OsString>,
3705 }
3706
3707 impl EnvVarGuard {
3708 fn set(key: &'static str, value: &str) -> Self {
3709 let previous = std::env::var_os(key);
3710 std::env::set_var(key, value);
3711 Self { key, previous }
3712 }
3713 }
3714
3715 impl Drop for EnvVarGuard {
3716 fn drop(&mut self) {
3717 match self.previous.take() {
3718 Some(value) => std::env::set_var(self.key, value),
3719 None => std::env::remove_var(self.key),
3720 }
3721 }
3722 }
3723
3724 fn make_client() -> (GhHttp, EnvVarGuard) {
3727 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
3729 let client = GhHttp {
3730 client: reqwest::Client::new(),
3731 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3732 cached_username: OnceLock::new(),
3733 };
3734 (client, guard)
3735 }
3736
3737 #[tokio::test]
3738 async fn get_all_pages_single_page() {
3739 let server = MockServer::start().await;
3740
3741 Mock::given(method("GET"))
3742 .and(path("/items"))
3743 .respond_with(
3744 ResponseTemplate::new(200)
3745 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
3746 )
3747 .expect(1)
3748 .mount(&server)
3749 .await;
3750
3751 let (gh, _guard) = make_client();
3752 let url = format!("{}/items", server.uri());
3753 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3754
3755 assert_eq!(result.len(), 2);
3756 assert_eq!(result[0]["id"], 1);
3757 assert_eq!(result[1]["id"], 2);
3758 }
3759
3760 #[tokio::test]
3761 async fn get_all_pages_follows_link_next() {
3762 let server = MockServer::start().await;
3763
3764 Mock::given(method("GET"))
3766 .and(path("/items"))
3767 .respond_with(
3768 ResponseTemplate::new(200)
3769 .set_body_json(serde_json::json!([{"id": 1}]))
3770 .insert_header(
3771 "Link",
3772 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
3773 ),
3774 )
3775 .expect(1)
3776 .mount(&server)
3777 .await;
3778
3779 Mock::given(method("GET"))
3781 .and(path("/items/page2"))
3782 .respond_with(
3783 ResponseTemplate::new(200)
3784 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
3785 )
3786 .expect(1)
3787 .mount(&server)
3788 .await;
3789
3790 let (gh, _guard) = make_client();
3791 let url = format!("{}/items", server.uri());
3792 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3793
3794 assert_eq!(result.len(), 3);
3795 assert_eq!(result[0]["id"], 1);
3796 assert_eq!(result[1]["id"], 2);
3797 assert_eq!(result[2]["id"], 3);
3798 }
3799
3800 #[tokio::test]
3801 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
3802 let server = MockServer::start().await;
3805
3806 Mock::given(method("GET"))
3807 .and(path("/items"))
3808 .respond_with(
3809 ResponseTemplate::new(200)
3810 .set_body_json(serde_json::json!([{"id": 1}]))
3811 .insert_header(
3812 "Link",
3813 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
3814 ),
3815 )
3816 .expect(1)
3817 .mount(&server)
3818 .await;
3819
3820 Mock::given(method("GET"))
3822 .and(path("/items/page2"))
3823 .respond_with(
3824 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
3825 )
3826 .expect(1)
3827 .mount(&server)
3828 .await;
3829
3830 let (gh, _guard) = make_client();
3831 let url = format!("{}/items", server.uri());
3832 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3833
3834 assert_eq!(result.len(), 2);
3836 }
3837
3838 #[tokio::test]
3839 async fn get_all_pages_stops_on_malformed_link_header() {
3840 let server = MockServer::start().await;
3843
3844 Mock::given(method("GET"))
3845 .and(path("/items"))
3846 .respond_with(
3847 ResponseTemplate::new(200)
3848 .set_body_json(serde_json::json!([{"id": 1}]))
3849 .insert_header("Link", "garbage; rel=\"next\""),
3850 )
3851 .expect(1)
3852 .mount(&server)
3853 .await;
3854
3855 let (gh, _guard) = make_client();
3856 let url = format!("{}/items", server.uri());
3857 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3858
3859 assert_eq!(result.len(), 1);
3861 }
3862
3863 #[tokio::test]
3864 async fn get_all_pages_returns_error_on_malformed_json_body() {
3865 let server = MockServer::start().await;
3866
3867 Mock::given(method("GET"))
3868 .and(path("/items"))
3869 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
3870 .expect(3)
3871 .mount(&server)
3872 .await;
3873
3874 let (gh, _guard) = make_client();
3875 let url = format!("{}/items", server.uri());
3876 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
3877
3878 assert!(result.is_err());
3879 let err = result.unwrap_err().to_string();
3880 assert!(
3881 err.contains("JSON decode failed") || err.contains("expected ident"),
3882 "error should mention JSON decode or serde error: {}",
3883 err
3884 );
3885 }
3886
3887 #[tokio::test]
3890 async fn get_all_pages_from_wrapper_single_page() {
3891 let server = MockServer::start().await;
3894
3895 Mock::given(method("GET"))
3896 .and(path("/check-runs"))
3897 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3898 "total_count": 2,
3899 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
3900 })))
3901 .expect(1)
3902 .mount(&server)
3903 .await;
3904
3905 let (gh, _guard) = make_client();
3906 let url = format!("{}/check-runs", server.uri());
3907 let result: Vec<serde_json::Value> = gh
3908 .get_all_pages_from_wrapper(&url, "check_runs")
3909 .await
3910 .unwrap();
3911
3912 assert_eq!(result.len(), 2);
3913 assert_eq!(result[0]["id"], 1);
3914 assert_eq!(result[1]["id"], 2);
3915 }
3916
3917 #[tokio::test]
3918 async fn get_all_pages_from_wrapper_follows_link_next() {
3919 let server = MockServer::start().await;
3921
3922 Mock::given(method("GET"))
3923 .and(path("/check-runs"))
3924 .respond_with(
3925 ResponseTemplate::new(200)
3926 .set_body_json(serde_json::json!({
3927 "total_count": 3,
3928 "check_runs": [{"id": 1, "name": "build"}]
3929 }))
3930 .insert_header(
3931 "Link",
3932 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
3933 ),
3934 )
3935 .expect(1)
3936 .mount(&server)
3937 .await;
3938
3939 Mock::given(method("GET"))
3940 .and(path("/check-runs/page2"))
3941 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3942 "total_count": 3,
3943 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
3944 })))
3945 .expect(1)
3946 .mount(&server)
3947 .await;
3948
3949 let (gh, _guard) = make_client();
3950 let url = format!("{}/check-runs", server.uri());
3951 let result: Vec<serde_json::Value> = gh
3952 .get_all_pages_from_wrapper(&url, "check_runs")
3953 .await
3954 .unwrap();
3955
3956 assert_eq!(result.len(), 3);
3957 assert_eq!(result[0]["id"], 1);
3958 assert_eq!(result[1]["id"], 2);
3959 assert_eq!(result[2]["id"], 3);
3960 }
3961
3962 #[tokio::test]
3963 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
3964 let server = MockServer::start().await;
3967
3968 Mock::given(method("GET"))
3969 .and(path("/check-runs"))
3970 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3971 "total_count": 0,
3972 "check_runs": []
3973 })))
3974 .expect(1)
3975 .mount(&server)
3976 .await;
3977
3978 let (gh, _guard) = make_client();
3979 let url = format!("{}/check-runs", server.uri());
3980 let result: Vec<serde_json::Value> = gh
3981 .get_all_pages_from_wrapper(&url, "check_runs")
3982 .await
3983 .unwrap();
3984
3985 assert_eq!(result.len(), 0);
3986 }
3987
3988 #[tokio::test]
3989 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
3990 let server = MockServer::start().await;
3991
3992 Mock::given(method("GET"))
3993 .and(path("/check-runs"))
3994 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
3995 .expect(1)
3996 .mount(&server)
3997 .await;
3998
3999 let (gh, _guard) = make_client();
4000 let url = format!("{}/check-runs", server.uri());
4001 let result = gh
4002 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
4003 .await;
4004
4005 assert!(result.is_err());
4006 let err = result.unwrap_err().to_string();
4007 assert!(
4008 err.contains("422") || err.contains("failed"),
4009 "error should indicate failure: {err}"
4010 );
4011 }
4012 }
4013
4014 #[cfg(test)]
4017 mod auth_retry {
4018 use super::test_client;
4019 use std::sync::atomic::{AtomicUsize, Ordering};
4020 use std::sync::Arc;
4021 use wiremock::matchers::method;
4022 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
4023
4024 fn bad_credentials() -> ResponseTemplate {
4025 ResponseTemplate::new(401).set_body_json(serde_json::json!({
4026 "message": "Bad credentials",
4027 "status": 401
4028 }))
4029 }
4030
4031 #[allow(clippy::await_holding_lock)]
4032 #[tokio::test]
4033 async fn retries_once_with_fresh_token_after_401() {
4034 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4035 let prev = std::env::var_os("GH_TOKEN");
4036 std::env::set_var("GH_TOKEN", "bad_token");
4037
4038 let server = MockServer::start().await;
4039 let hits = Arc::new(AtomicUsize::new(0));
4040 let hits_in_handler = hits.clone();
4041 Mock::given(method("GET"))
4042 .respond_with(move |_req: &Request| {
4043 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
4044 std::env::set_var("GH_TOKEN", "good_token");
4047 bad_credentials()
4048 } else {
4049 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
4050 }
4051 })
4052 .mount(&server)
4053 .await;
4054
4055 let gh = test_client();
4056 let result: anyhow::Result<serde_json::Value> =
4057 gh.get_json(&format!("{}/x", server.uri())).await;
4058
4059 match prev {
4060 Some(v) => std::env::set_var("GH_TOKEN", v),
4061 None => std::env::remove_var("GH_TOKEN"),
4062 }
4063
4064 assert!(
4065 result.is_ok(),
4066 "retry with re-resolved token should succeed: {:?}",
4067 result.err()
4068 );
4069 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
4070 }
4071
4072 #[allow(clippy::await_holding_lock)]
4073 #[tokio::test]
4074 async fn double_401_bails_with_distinct_auth_error() {
4075 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4076 let prev = std::env::var_os("GH_TOKEN");
4077 std::env::set_var("GH_TOKEN", "bad_token");
4078
4079 let server = MockServer::start().await;
4080 let hits = Arc::new(AtomicUsize::new(0));
4081 let hits_in_handler = hits.clone();
4082 Mock::given(method("GET"))
4083 .respond_with(move |_req: &Request| {
4084 hits_in_handler.fetch_add(1, Ordering::SeqCst);
4085 bad_credentials()
4086 })
4087 .mount(&server)
4088 .await;
4089
4090 let gh = test_client();
4091 let result: anyhow::Result<serde_json::Value> =
4092 gh.get_json(&format!("{}/x", server.uri())).await;
4093
4094 match prev {
4095 Some(v) => std::env::set_var("GH_TOKEN", v),
4096 None => std::env::remove_var("GH_TOKEN"),
4097 }
4098
4099 let err = result.expect_err("persisting 401 must fail").to_string();
4100 assert!(
4101 err.contains("token re-resolved after cache clear but still rejected"),
4102 "expected distinct auth-failure error, got: {err}"
4103 );
4104 assert_eq!(
4105 hits.load(Ordering::SeqCst),
4106 2,
4107 "must retry exactly once, not loop"
4108 );
4109 }
4110 }
4111}