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