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