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