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 mut query = vec![("state", state), ("per_page", "100")];
1289 if !label.is_empty() {
1290 query.push(("labels", label));
1291 }
1292 let all: Vec<GitHubIssue> = self.get_all_pages(&url, &query).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 post_commit_status(
1481 &self,
1482 repo: &str,
1483 sha: &str,
1484 state: &str,
1485 context: &str,
1486 description: &str,
1487 ) -> anyhow::Result<()> {
1488 let url = format!("{GITHUB_API}/repos/{repo}/statuses/{sha}");
1489 let payload = serde_json::json!({
1490 "state": state,
1491 "context": context,
1492 "description": description,
1493 });
1494 self.post_json_raw(&url, &payload).await?;
1495 Ok(())
1496 }
1497
1498 pub async fn add_reaction(
1502 &self,
1503 repo: &str,
1504 number: &str,
1505 content: &str,
1506 ) -> anyhow::Result<()> {
1507 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/reactions");
1508 let payload = serde_json::json!({ "content": content });
1509 self.post_json_raw(&url, &payload).await?;
1510 Ok(())
1511 }
1512
1513 pub async fn add_comment_reaction(
1517 &self,
1518 repo: &str,
1519 comment_id: &str,
1520 content: &str,
1521 ) -> anyhow::Result<()> {
1522 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments/{comment_id}/reactions");
1523 let payload = serde_json::json!({ "content": content });
1524 self.post_json_raw(&url, &payload).await?;
1525 Ok(())
1526 }
1527
1528 pub async fn list_comments(
1530 &self,
1531 repo: &str,
1532 number: &str,
1533 ) -> anyhow::Result<Vec<GitHubComment>> {
1534 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}/comments");
1535 self.get_all_pages(&url, &[("per_page", "100")]).await
1536 }
1537
1538 pub async fn is_pr_merged(&self, repo: &str, branch: &str) -> anyhow::Result<bool> {
1540 let owner = repo
1541 .split('/')
1542 .next()
1543 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1544 let head = format!("{}:{}", owner, branch);
1545
1546 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1547 let prs: Vec<serde_json::Value> = self
1548 .get_with_query(
1549 &url,
1550 &[("head", &head), ("state", "closed"), ("per_page", "1")],
1551 )
1552 .await?;
1553
1554 if prs.is_empty() {
1555 return Ok(false);
1556 }
1557 let merged_at = prs[0].get("merged_at");
1558 Ok(merged_at.map(|v| !v.is_null()).unwrap_or(false))
1559 }
1560
1561 pub async fn get_closed_pr_state(
1574 &self,
1575 repo: &str,
1576 branch: &str,
1577 ) -> anyhow::Result<Option<(u64, bool, String)>> {
1578 let owner = repo
1579 .split('/')
1580 .next()
1581 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1582 let head = format!("{}:{}", owner, branch);
1583
1584 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1585 let prs: Vec<serde_json::Value> = self
1586 .get_with_query(
1587 &url,
1588 &[("head", &head), ("state", "closed"), ("per_page", "5")],
1589 )
1590 .await?;
1591
1592 if prs.is_empty() {
1593 return Ok(None);
1594 }
1595
1596 for pr in prs {
1599 let pr_branch = pr
1600 .get("head")
1601 .and_then(|h| h.get("ref"))
1602 .and_then(|r| r.as_str())
1603 .unwrap_or("");
1604
1605 if pr_branch != branch {
1606 tracing::debug!(
1607 repo,
1608 branch,
1609 pr_branch,
1610 "skipping PR with mismatched head ref"
1611 );
1612 continue;
1613 }
1614
1615 let pr_number = pr
1616 .get("number")
1617 .and_then(|n| n.as_u64())
1618 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))?;
1619
1620 let merged_at = pr.get("merged_at");
1621 let merged = merged_at.map(|v| !v.is_null()).unwrap_or(false);
1622 let state = pr
1623 .get("state")
1624 .and_then(|v| v.as_str())
1625 .unwrap_or("closed")
1626 .to_string();
1627
1628 return Ok(Some((pr_number, merged, state)));
1629 }
1630
1631 Ok(None)
1633 }
1634
1635 pub async fn batch_is_pr_merged_by_branch(
1640 &self,
1641 repo: &str,
1642 branches: &[String],
1643 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1644 if branches.is_empty() {
1645 return Ok(std::collections::HashMap::new());
1646 }
1647
1648 let (owner, name) = repo
1649 .split_once('/')
1650 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1651
1652 let mut var_defs = String::new();
1656 let mut aliases = String::new();
1657 let mut vars = serde_json::json!({ "owner": owner, "name": name });
1658 for (i, branch) in branches.iter().enumerate() {
1659 var_defs.push_str(&format!(", $b{i}: String!"));
1660 aliases.push_str(&format!(
1661 r#"b{i}: pullRequests(headRefName: $b{i}, states: [MERGED], last: 1) {{ nodes {{ merged }} }} "#
1662 ));
1663 vars[format!("b{i}")] = serde_json::Value::String(branch.clone());
1664 }
1665
1666 let query = format!(
1667 r#"query($owner: String!, $name: String!{var_defs}) {{
1668 repository(owner: $owner, name: $name) {{ {aliases} }}
1669 }}"#
1670 );
1671
1672 let resp = self.graphql_with_vars(&query, vars).await?;
1673
1674 if let Some(errors) = resp.get("errors").and_then(|e| e.as_array()) {
1676 if !errors.is_empty() {
1677 let messages: Vec<&str> = errors
1678 .iter()
1679 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
1680 .collect();
1681 tracing::warn!(
1682 repo,
1683 errors = ?messages,
1684 "GraphQL returned errors in batch PR merge check"
1685 );
1686 }
1687 }
1688
1689 Self::parse_batch_pr_merged_response(&resp, branches)
1690 }
1691
1692 fn parse_batch_pr_merged_response(
1693 resp: &serde_json::Value,
1694 branches: &[String],
1695 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
1696 let repo_data = resp.pointer("/data/repository").ok_or_else(|| {
1697 let error_msg = resp
1699 .get("errors")
1700 .and_then(|e| e.as_array())
1701 .and_then(|arr| arr.first())
1702 .and_then(|e| e.get("message"))
1703 .and_then(|m| m.as_str())
1704 .unwrap_or("no error details");
1705 anyhow::anyhow!(
1706 "missing /data/repository in GraphQL response: {}",
1707 error_msg
1708 )
1709 })?;
1710
1711 let mut result = std::collections::HashMap::new();
1712 for (i, branch) in branches.iter().enumerate() {
1713 let alias = format!("b{i}");
1714 let merged = repo_data
1715 .get(&alias)
1716 .and_then(|v| v.get("nodes"))
1717 .and_then(|nodes| nodes.as_array())
1718 .and_then(|arr| arr.first())
1719 .and_then(|node| node.get("merged"))
1720 .and_then(|v| v.as_bool())
1721 .unwrap_or(false);
1722 result.insert(branch.clone(), merged);
1723 }
1724
1725 Ok(result)
1726 }
1727
1728 pub async fn get_mentions(
1730 &self,
1731 repo: &str,
1732 since: &str,
1733 ) -> anyhow::Result<Vec<GitHubComment>> {
1734 let url = format!("{GITHUB_API}/repos/{repo}/issues/comments");
1735 self.get_all_pages(&url, &[("since", since), ("per_page", "100")])
1736 .await
1737 }
1738
1739 pub async fn get_issue_comments(
1741 &self,
1742 repo: &str,
1743 issue_number: u64,
1744 ) -> anyhow::Result<Vec<GitHubComment>> {
1745 let url = format!("{GITHUB_API}/repos/{repo}/issues/{issue_number}/comments");
1746 self.get_all_pages(&url, &[("per_page", "100")]).await
1747 }
1748
1749 pub async fn get_whoami(&self) -> anyhow::Result<String> {
1751 if let Some(cached) = self.cached_username.get() {
1752 return Ok(cached.clone());
1753 }
1754
1755 let user: serde_json::Value = self.get_json(&format!("{GITHUB_API}/user")).await?;
1756 let username = user
1757 .get("login")
1758 .and_then(|v| v.as_str())
1759 .map(String::from)
1760 .ok_or_else(|| anyhow::anyhow!("failed to get current user"))?;
1761
1762 let _ = self.cached_username.set(username.clone());
1763 Ok(username)
1764 }
1765
1766 pub async fn get_pr_number(&self, repo: &str, branch: &str) -> anyhow::Result<Option<u64>> {
1768 let owner = repo
1769 .split('/')
1770 .next()
1771 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1772 let head = format!("{}:{}", owner, branch);
1773
1774 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1775 let prs: Vec<serde_json::Value> = self
1776 .get_with_query(
1777 &url,
1778 &[("head", &head), ("state", "open"), ("per_page", "1")],
1779 )
1780 .await?;
1781
1782 if prs.is_empty() {
1783 return Ok(None);
1784 }
1785 prs[0]
1786 .get("number")
1787 .and_then(|n| n.as_u64())
1788 .ok_or_else(|| anyhow::anyhow!("PR missing number field"))
1789 .map(Some)
1790 }
1791
1792 pub async fn create_pr(
1803 &self,
1804 repo: &str,
1805 title: &str,
1806 body: &str,
1807 head: &str,
1808 base: &str,
1809 ) -> anyhow::Result<String> {
1810 use reqwest::header;
1811
1812 Self::proactive_throttle_rest().await;
1813 Self::check_backoff()?;
1814 let url = format!("{GITHUB_API}/repos/{repo}/pulls");
1815
1816 #[derive(Serialize)]
1817 struct CreatePullRequest<'a> {
1818 title: &'a str,
1819 body: &'a str,
1820 head: &'a str,
1821 base: &'a str,
1822 }
1823
1824 let payload = CreatePullRequest {
1825 title,
1826 body,
1827 head,
1828 base,
1829 };
1830
1831 let auth = self.auth_header().await?;
1832 let make_req = || {
1833 Ok(self
1834 .client
1835 .post(&url)
1836 .header(header::AUTHORIZATION, auth.clone())
1837 .header(header::ACCEPT, "application/vnd.github+json")
1838 .header(header::USER_AGENT, "orch")
1839 .header("X-GitHub-Api-Version", "2022-11-28")
1840 .json(&payload))
1841 };
1842 let response = self.send_with_retries(make_req, false).await?;
1843 let status = response.status();
1844 let text = response.text().await.unwrap_or_default();
1845 if !status.is_success() {
1846 Self::maybe_record_rate_limit_from_body(status, &text);
1847 anyhow::bail!("GitHub API POST {url} failed ({status}): {text}");
1848 }
1849
1850 let pr: serde_json::Value = serde_json::from_str(&text)?;
1851 pr.get("html_url")
1852 .and_then(|v| v.as_str())
1853 .map(String::from)
1854 .ok_or_else(|| anyhow::anyhow!("failed to get PR URL from response: {:?}", pr))
1855 }
1856
1857 pub async fn get_default_branch(&self, repo: &str) -> anyhow::Result<String> {
1862 let url = format!("{GITHUB_API}/repos/{repo}");
1863 let resp: serde_json::Value = self.get_json(&url).await?;
1864 resp.get("default_branch")
1865 .and_then(|v| v.as_str())
1866 .map(String::from)
1867 .ok_or_else(|| anyhow::anyhow!("default_branch missing from repo response"))
1868 }
1869
1870 pub async fn get_pr(&self, repo: &str, pr_number: u64) -> anyhow::Result<GitHubPullRequest> {
1873 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
1874 self.get_json(&url).await
1875 }
1876
1877 pub async fn batch_get_pr_states(
1882 &self,
1883 repo: &str,
1884 pr_numbers: &[u64],
1885 ) -> anyhow::Result<std::collections::HashMap<u64, (bool, String)>> {
1886 if pr_numbers.is_empty() {
1887 return Ok(std::collections::HashMap::new());
1888 }
1889
1890 let (owner, name) = repo
1891 .split_once('/')
1892 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1893
1894 const CHUNK_SIZE: usize = 50;
1896 let mut result = std::collections::HashMap::new();
1897
1898 for chunk in pr_numbers.chunks(CHUNK_SIZE) {
1899 let aliases: String = chunk
1900 .iter()
1901 .map(|n| format!("pr{n}: pullRequest(number: {n}) {{ merged state }}"))
1902 .collect::<Vec<_>>()
1903 .join("\n ");
1904
1905 let query = format!(
1906 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1907 );
1908
1909 let resp = self
1910 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1911 .await?;
1912 let repo_data = resp
1913 .pointer("/data/repository")
1914 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
1915
1916 for n in chunk {
1917 if let Some(pr) = repo_data.get(format!("pr{n}")) {
1918 let merged = pr.get("merged").and_then(|v| v.as_bool()).unwrap_or(false);
1919 let state = pr
1920 .get("state")
1921 .and_then(|v| v.as_str())
1922 .unwrap_or("UNKNOWN")
1923 .to_lowercase();
1924 result.insert(*n, (merged, state));
1925 }
1926 }
1927 }
1928
1929 Ok(result)
1930 }
1931
1932 pub async fn batch_get_issue_states(
1937 &self,
1938 repo: &str,
1939 issue_numbers: &[u64],
1940 caller_context: &str,
1941 ) -> anyhow::Result<std::collections::HashMap<String, (String, Vec<String>)>> {
1942 if issue_numbers.is_empty() {
1943 return Ok(std::collections::HashMap::new());
1944 }
1945
1946 let (valid_issue_numbers, invalid_issue_numbers) =
1947 sanitize_github_issue_numbers(issue_numbers);
1948 if !invalid_issue_numbers.is_empty() {
1949 tracing::warn!(
1950 repo,
1951 caller_context,
1952 invalid_issue_numbers = ?invalid_issue_numbers,
1953 total_supplied = issue_numbers.len(),
1954 "dropping invalid GitHub issue numbers before batch issue-state GraphQL query"
1955 );
1956 }
1957 if valid_issue_numbers.is_empty() {
1958 return Ok(std::collections::HashMap::new());
1959 }
1960
1961 let (owner, name) = repo
1962 .split_once('/')
1963 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
1964
1965 const CHUNK_SIZE: usize = 50;
1967 let mut result = std::collections::HashMap::new();
1968
1969 for chunk in valid_issue_numbers.chunks(CHUNK_SIZE) {
1970 let aliases: String = chunk
1971 .iter()
1972 .map(|n| {
1973 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
1974 })
1975 .collect::<Vec<_>>()
1976 .join(" ");
1977
1978 let query = format!(
1979 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
1980 );
1981
1982 let resp = self
1983 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
1984 .await?;
1985 let repo_data = resp
1986 .pointer("/data/repository")
1987 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
1988
1989 for n in chunk {
1990 if let Some(issue) = repo_data.get(format!("issue{n}")) {
1991 let state = issue
1992 .get("state")
1993 .and_then(|v| v.as_str())
1994 .unwrap_or("UNKNOWN")
1995 .to_lowercase();
1996 let labels: Vec<String> = issue
1997 .pointer("/labels/nodes")
1998 .and_then(|v| v.as_array())
1999 .map(|arr| {
2000 arr.iter()
2001 .filter_map(|l| {
2002 l.get("name").and_then(|nm| nm.as_str()).map(String::from)
2003 })
2004 .collect()
2005 })
2006 .unwrap_or_default();
2007 result.insert(n.to_string(), (state, labels));
2008 }
2009 }
2010 }
2011 Ok(result)
2012 }
2013
2014 pub async fn get_required_status_check_contexts(
2016 &self,
2017 repo: &str,
2018 branch: &str,
2019 ) -> anyhow::Result<Vec<String>> {
2020 let branch = urlencoding::encode(branch);
2021 let url = format!("{GITHUB_API}/repos/{repo}/branches/{branch}/protection");
2022 let resp = self.get_json::<serde_json::Value>(&url).await;
2023 match resp {
2024 Ok(value) => Ok(value
2025 .pointer("/required_status_checks/contexts")
2026 .and_then(|v| v.as_array())
2027 .map(|contexts| {
2028 contexts
2029 .iter()
2030 .filter_map(|context| context.as_str().map(|s| s.to_string()))
2031 .collect()
2032 })
2033 .unwrap_or_default()),
2034 Err(e) => {
2035 let err = e.to_string();
2036 if err.contains("404") {
2037 Ok(vec![])
2038 } else {
2039 Err(e)
2040 }
2041 }
2042 }
2043 }
2044
2045 pub async fn get_commit_status_contexts(
2047 &self,
2048 repo: &str,
2049 sha: &str,
2050 ) -> anyhow::Result<Vec<(String, String)>> {
2051 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}/status?per_page=100");
2052 let statuses: Vec<serde_json::Value> =
2057 self.get_all_pages_from_wrapper(&url, "statuses").await?;
2058
2059 Ok(statuses
2060 .into_iter()
2061 .filter_map(|status| {
2062 let context = status.get("context").and_then(|v| v.as_str())?;
2063 let state = status.get("state").and_then(|v| v.as_str())?;
2064 Some((context.to_string(), state.to_string()))
2065 })
2066 .collect())
2067 }
2068
2069 fn combined_status_state(
2070 total: u64,
2071 failing: u64,
2072 pending: u64,
2073 has_workflows: bool,
2074 mergeable_state: Option<&str>,
2075 ) -> String {
2076 if failing > 0 {
2077 "failure".to_string()
2078 } else if pending > 0 {
2079 "pending".to_string()
2080 } else if total == 0 && has_workflows {
2081 if mergeable_state == Some("clean") {
2085 "success".to_string()
2086 } else {
2087 "pending".to_string()
2088 }
2089 } else {
2090 "success".to_string()
2091 }
2092 }
2093
2094 pub async fn has_workflows(&self, repo: &str) -> anyhow::Result<bool> {
2099 let url = format!("{GITHUB_API}/repos/{repo}/actions/workflows?per_page=1");
2100 let resp = self.get_json::<serde_json::Value>(&url).await;
2101 match resp {
2102 Ok(value) => Ok(value
2103 .get("workflows")
2104 .and_then(|v| v.as_array())
2105 .map(|workflows| !workflows.is_empty())
2106 .unwrap_or(false)),
2107 Err(e) => {
2108 let err = e.to_string();
2109 if err.contains("404") {
2110 Ok(false)
2111 } else {
2112 Err(e)
2113 }
2114 }
2115 }
2116 }
2117
2118 pub async fn get_check_runs(
2120 &self,
2121 repo: &str,
2122 sha: &str,
2123 ) -> anyhow::Result<Vec<GitHubCheckRun>> {
2124 let url = format!(
2135 "{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs?filter=latest&per_page=100"
2136 );
2137 self.get_all_pages_from_wrapper(&url, "check_runs").await
2138 }
2139
2140 pub async fn get_check_run_annotations(
2147 &self,
2148 repo: &str,
2149 check_run_id: u64,
2150 ) -> anyhow::Result<Vec<GitHubAnnotation>> {
2151 let url = format!("{GITHUB_API}/repos/{repo}/check-runs/{check_run_id}/annotations");
2152 self.get_all_pages(&url, &[("per_page", "100")]).await
2153 }
2154
2155 pub async fn get_pr_reviews(
2157 &self,
2158 repo: &str,
2159 pr_number: u64,
2160 ) -> anyhow::Result<Vec<GitHubReview>> {
2161 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews");
2162 self.get_all_pages(&url, &[("per_page", "100")]).await
2163 }
2164
2165 pub async fn get_commit_timestamp(&self, repo: &str, sha: &str) -> anyhow::Result<String> {
2170 let url = format!("{GITHUB_API}/repos/{repo}/commits/{sha}");
2171 let value: serde_json::Value = self.get_json(&url).await?;
2172 value
2173 .pointer("/commit/committer/date")
2174 .and_then(|v| v.as_str())
2175 .map(|s| s.to_string())
2176 .ok_or_else(|| anyhow::anyhow!("commit {sha} has no committer date"))
2177 }
2178
2179 pub async fn update_pr_body(
2181 &self,
2182 repo: &str,
2183 pr_number: u64,
2184 body: &str,
2185 ) -> anyhow::Result<()> {
2186 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}");
2187 let payload = serde_json::json!({ "body": body });
2188 self.patch_json_raw(&url, &payload).await?;
2189 Ok(())
2190 }
2191
2192 pub async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2194 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2195 let payload = serde_json::json!({ "state": "closed" });
2196 self.patch_json_raw(&url, &payload).await?;
2197 Ok(())
2198 }
2199
2200 pub async fn reopen_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
2202 let url = format!("{GITHUB_API}/repos/{repo}/issues/{number}");
2203 let payload = serde_json::json!({ "state": "open" });
2204 self.patch_json_raw(&url, &payload).await?;
2205 Ok(())
2206 }
2207
2208 pub async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
2210 Self::proactive_throttle_rest().await;
2211 Self::check_backoff()?;
2212 let url = format!("{GITHUB_API}/repos/{repo}/collaborators/{username}");
2213 let auth = self.auth_header().await?;
2214 let make_req = || {
2215 Ok(self
2216 .client
2217 .get(&url)
2218 .header(header::AUTHORIZATION, auth.clone())
2219 .header(header::ACCEPT, "application/vnd.github+json")
2220 .header("X-GitHub-Api-Version", "2022-11-28"))
2221 };
2222 let resp = self.send_with_retries(make_req, false).await?;
2223 let status = resp.status();
2224 if status == StatusCode::NO_CONTENT {
2225 Ok(true)
2226 } else if status == StatusCode::NOT_FOUND {
2227 Ok(false)
2228 } else {
2229 let body = resp.text().await.unwrap_or_default();
2230 anyhow::bail!("GitHub API collaborator check failed ({status}): {body}");
2231 }
2232 }
2233
2234 pub async fn add_sub_issue(
2236 &self,
2237 parent_node_id: &str,
2238 child_node_id: &str,
2239 ) -> anyhow::Result<()> {
2240 let query = format!(
2241 r#"mutation {{
2242 addSubIssue(input: {{issueId: "{}", subIssueId: "{}"}}) {{
2243 issue {{ number }}
2244 subIssue {{ number }}
2245 }}
2246 }}"#,
2247 parent_node_id, child_node_id
2248 );
2249 self.graphql_with_headers(&query, &["GraphQL-Features:sub_issues"])
2250 .await?;
2251 Ok(())
2252 }
2253
2254 pub async fn link_issue_to_branch(
2266 &self,
2267 repo: &str,
2268 issue_number: u64,
2269 branch: &str,
2270 ) -> anyhow::Result<String> {
2271 let parts: Vec<&str> = repo.split('/').collect();
2272 if parts.len() != 2 {
2273 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2274 }
2275 let (owner, repo_name) = (parts[0], parts[1]);
2276
2277 let query = r#"query($owner: String!, $name: String!, $number: Int!) {
2279 repository(owner: $owner, name: $name) {
2280 id
2281 issue(number: $number) {
2282 id
2283 }
2284 }
2285 }"#;
2286 let vars = serde_json::json!({
2287 "owner": owner,
2288 "name": repo_name,
2289 "number": issue_number,
2290 });
2291
2292 let result = self.graphql_with_vars(query, vars).await?;
2293
2294 let repo_id = result
2295 .pointer("/data/repository/id")
2296 .and_then(|v| v.as_str())
2297 .ok_or_else(|| anyhow::anyhow!("failed to get repository node ID"))?;
2298
2299 let issue_id = result
2300 .pointer("/data/repository/issue/id")
2301 .and_then(|v| v.as_str())
2302 .ok_or_else(|| anyhow::anyhow!("failed to get issue node ID"))?;
2303
2304 let branch_query = r#"query($owner: String!, $name: String!, $ref: String!) {
2306 repository(owner: $owner, name: $name) {
2307 ref(qualifiedName: $ref) {
2308 target {
2309 oid
2310 }
2311 }
2312 }
2313 }"#;
2314 let branch_vars = serde_json::json!({
2315 "owner": owner,
2316 "name": repo_name,
2317 "ref": format!("refs/heads/{}", branch),
2318 });
2319
2320 let branch_result = self.graphql_with_vars(branch_query, branch_vars).await?;
2321 let branch_oid = branch_result
2322 .pointer("/data/repository/ref/target/oid")
2323 .and_then(|v| v.as_str());
2324
2325 let branch_oid = match branch_oid {
2328 Some(oid) => oid.to_string(),
2329 None => {
2330 let default_query = r#"query($owner: String!, $name: String!) {
2332 repository(owner: $owner, name: $name) {
2333 defaultBranchRef {
2334 target {
2335 oid
2336 }
2337 }
2338 }
2339 }"#;
2340 let default_vars = serde_json::json!({
2341 "owner": owner,
2342 "name": repo_name,
2343 });
2344 let default_result = self.graphql_with_vars(default_query, default_vars).await?;
2345 match default_result
2346 .pointer("/data/repository/defaultBranchRef/target/oid")
2347 .and_then(|v| v.as_str())
2348 {
2349 Some(oid) => {
2350 tracing::debug!(
2351 repo,
2352 issue_number,
2353 branch,
2354 "branch not found, using default branch OID to create and link"
2355 );
2356 oid.to_string()
2357 }
2358 None => {
2359 return Ok(format!("unlinked:{}", branch));
2360 }
2361 }
2362 }
2363 };
2364
2365 let mutation = r#"mutation($issueId: ID!, $repositoryId: ID!, $name: String!, $oid: GitObjectID!) {
2368 createLinkedBranch(input: {
2369 issueId: $issueId
2370 repositoryId: $repositoryId
2371 name: $name
2372 oid: $oid
2373 }) {
2374 linkedBranch {
2375 id
2376 ref {
2377 name
2378 }
2379 }
2380 }
2381 }"#;
2382 let mutation_vars = serde_json::json!({
2383 "issueId": issue_id,
2384 "repositoryId": repo_id,
2385 "name": branch,
2386 "oid": branch_oid,
2387 });
2388
2389 let link_result = self.graphql_with_vars(mutation, mutation_vars).await;
2390
2391 match link_result {
2392 Ok(result) => {
2393 let linked_branch_id = result
2394 .pointer("/data/createLinkedBranch/linkedBranch/id")
2395 .and_then(|v| v.as_str())
2396 .map(String::from);
2397
2398 if let Some(id) = linked_branch_id {
2399 tracing::info!(
2400 repo,
2401 issue_number,
2402 branch,
2403 "linked issue to branch via GraphQL API"
2404 );
2405 Ok(id)
2406 } else {
2407 let errors = result
2409 .get("errors")
2410 .and_then(|e| e.as_array())
2411 .cloned()
2412 .unwrap_or_default();
2413
2414 if errors.iter().any(|e| {
2415 e.get("message")
2416 .and_then(|m| m.as_str())
2417 .map(|m| m.contains("already"))
2418 .unwrap_or(false)
2419 }) {
2420 tracing::debug!(
2421 repo,
2422 issue_number,
2423 branch,
2424 "issue already linked to branch"
2425 );
2426 Ok(format!("already_linked:{}", branch))
2427 } else {
2428 anyhow::bail!("failed to link issue to branch: {:?}", result)
2429 }
2430 }
2431 }
2432 Err(e) => {
2433 let err_str = format!("{}", e);
2435 if err_str.contains("already") || err_str.contains("existing") {
2436 tracing::debug!(
2437 repo,
2438 issue_number,
2439 branch,
2440 "issue already linked to branch (error check)"
2441 );
2442 Ok(format!("already_linked:{}", branch))
2443 } else {
2444 Err(e)
2445 }
2446 }
2447 }
2448 }
2449
2450 pub async fn merge_pr(
2452 &self,
2453 repo: &str,
2454 pr_number: u64,
2455 delete_branch: bool,
2456 ) -> anyhow::Result<()> {
2457 Self::proactive_throttle_rest().await;
2458 Self::check_backoff()?;
2459 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/merge");
2460 let payload = serde_json::json!({ "merge_method": "squash" });
2461 let auth = self.auth_header().await?;
2462 let make_req = || {
2463 Ok(self
2464 .client
2465 .put(&url)
2466 .json(&payload)
2467 .header(header::AUTHORIZATION, auth.clone())
2468 .header(header::ACCEPT, "application/vnd.github+json")
2469 .header("X-GitHub-Api-Version", "2022-11-28"))
2470 };
2471 let resp = self.send_with_retries(make_req, false).await?;
2472 let status = resp.status();
2473 if !status.is_success() {
2474 let body = resp.text().await.unwrap_or_default();
2475 anyhow::bail!("GitHub PR merge failed ({status}): {body}");
2476 }
2477 Self::record_success();
2478
2479 if delete_branch {
2481 let pr: serde_json::Value = self
2483 .get_json(&format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}"))
2484 .await?;
2485 if let Some(ref_name) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
2486 let del_url = format!("{GITHUB_API}/repos/{repo}/git/refs/heads/{ref_name}");
2487 if let Err(e) = self.delete(&del_url).await {
2488 let err_str = e.to_string();
2489 if err_str.contains("Reference does not exist") {
2490 tracing::debug!(
2491 ref_name,
2492 "branch already gone (GitHub auto-deleted on merge)"
2493 );
2494 } else {
2495 tracing::warn!(ref_name, err = %e, "failed to delete branch after merge");
2496 }
2497 }
2498 }
2499 }
2500 Ok(())
2501 }
2502
2503 pub async fn update_pr_branch(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2509 Self::proactive_throttle_rest().await;
2510 Self::check_backoff()?;
2511 let url = format!("{GITHUB_API}/repos/{repo}/pulls/{pr_number}/update-branch");
2512 let auth = self.auth_header().await?;
2513 let make_req = || {
2514 Ok(self
2515 .client
2516 .put(&url)
2517 .header(header::AUTHORIZATION, auth.clone())
2518 .header(header::ACCEPT, "application/vnd.github+json")
2519 .header("X-GitHub-Api-Version", "2022-11-28"))
2520 };
2521 let resp = self.send_with_retries(make_req, false).await?;
2522 let status = resp.status();
2523 if !status.is_success() {
2524 let body = resp.text().await.unwrap_or_default();
2525 Self::maybe_record_rate_limit_from_body(status, &body);
2526 anyhow::bail!("GitHub PR update-branch failed ({status}): {body}");
2527 }
2528 Self::record_success();
2529 Ok(())
2530 }
2531
2532 pub async fn enable_auto_merge(&self, repo: &str, pr_number: u64) -> anyhow::Result<()> {
2536 let parts: Vec<&str> = repo.split('/').collect();
2537 if parts.len() != 2 {
2538 anyhow::bail!("invalid repo format: {repo}");
2539 }
2540 let (owner, repo_name) = (parts[0], parts[1]);
2541
2542 let pr_query = r#"query($owner: String!, $name: String!, $number: Int!) {
2544 repository(owner: $owner, name: $name) {
2545 pullRequest(number: $number) { id }
2546 }
2547 }"#;
2548 let body = self
2549 .graphql_with_vars(
2550 pr_query,
2551 serde_json::json!({
2552 "owner": owner,
2553 "name": repo_name,
2554 "number": pr_number,
2555 }),
2556 )
2557 .await?;
2558 let pr_id = body
2559 .pointer("/data/repository/pullRequest/id")
2560 .and_then(|v| v.as_str())
2561 .ok_or_else(|| anyhow::anyhow!("could not get PR node ID"))?
2562 .to_string();
2563
2564 let mutation = r#"mutation($prId: ID!) {
2566 enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: SQUASH}) {
2567 pullRequest { autoMergeRequest { enabledAt } }
2568 }
2569 }"#;
2570 let body = self
2571 .graphql_with_vars(mutation, serde_json::json!({ "prId": pr_id }))
2572 .await?;
2573 if let Some(errors) = body.get("errors") {
2574 anyhow::bail!("enable auto-merge GraphQL error: {errors}");
2575 }
2576
2577 tracing::info!(repo, pr_number, "auto-merge enabled on PR");
2578 Ok(())
2579 }
2580
2581 pub async fn get_sub_issues(&self, repo: &str, number: &str) -> anyhow::Result<Vec<u64>> {
2583 let parts: Vec<&str> = repo.split('/').collect();
2584 if parts.len() != 2 {
2585 anyhow::bail!("invalid repo format: expected 'owner/repo', got '{}'", repo);
2586 }
2587 let (owner, repo_name) = (parts[0], parts[1]);
2588 let number = match parse_positive_issue_number(number) {
2589 Ok(number) => number,
2590 Err(e) => {
2591 tracing::debug!(repo, issue = number, ?e, "skipping sub-issues lookup");
2592 return Ok(Vec::new());
2593 }
2594 };
2595
2596 let mut all_numbers: Vec<u64> = Vec::new();
2597 let mut cursor: Option<String> = None;
2598 let page_size = 100;
2599 let max_pages = 50;
2600 let mut page_count = 0;
2601
2602 loop {
2603 page_count += 1;
2604 if page_count > max_pages {
2605 tracing::warn!(
2606 repo,
2607 number,
2608 "get_sub_issues hit max page limit ({max_pages})"
2609 );
2610 break;
2611 }
2612
2613 let (query, vars) = if let Some(ref c) = cursor {
2614 (
2615 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String!) {
2616 repository(owner: $owner, name: $name) {
2617 issue(number: $number) {
2618 subIssues(first: $first, after: $after) {
2619 nodes { number }
2620 pageInfo { hasNextPage endCursor }
2621 }
2622 }
2623 }
2624 }"#,
2625 serde_json::json!({
2626 "owner": owner,
2627 "name": repo_name,
2628 "number": number,
2629 "first": page_size,
2630 "after": c,
2631 }),
2632 )
2633 } else {
2634 (
2635 r#"query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
2636 repository(owner: $owner, name: $name) {
2637 issue(number: $number) {
2638 subIssues(first: $first) {
2639 nodes { number }
2640 pageInfo { hasNextPage endCursor }
2641 }
2642 }
2643 }
2644 }"#,
2645 serde_json::json!({
2646 "owner": owner,
2647 "name": repo_name,
2648 "number": number,
2649 "first": page_size,
2650 }),
2651 )
2652 };
2653
2654 let result = self
2655 .graphql_with_headers_and_vars(query, &["GraphQL-Features:sub_issues"], vars)
2656 .await?;
2657
2658 let sub_issues_data = result
2659 .get("data")
2660 .and_then(|d| d.get("repository"))
2661 .and_then(|r| r.get("issue"))
2662 .and_then(|i| i.get("subIssues"));
2663
2664 let nodes = sub_issues_data
2665 .and_then(|s| s.get("nodes"))
2666 .and_then(|n| n.as_array());
2667
2668 match nodes {
2669 Some(nodes) => {
2670 let numbers: Vec<u64> = nodes
2671 .iter()
2672 .filter_map(|n| n.get("number").and_then(|num| num.as_u64()))
2673 .collect();
2674 all_numbers.extend(numbers);
2675 }
2676 None => break,
2677 }
2678
2679 let page_info = sub_issues_data.and_then(|s| s.get("pageInfo"));
2680 let has_next = page_info
2681 .and_then(|p| p.get("hasNextPage"))
2682 .and_then(|h| h.as_bool())
2683 .unwrap_or(false);
2684
2685 if !has_next {
2686 break;
2687 }
2688
2689 cursor = page_info
2690 .and_then(|p| p.get("endCursor"))
2691 .and_then(|c| c.as_str())
2692 .map(|s| s.to_string());
2693 }
2694
2695 Ok(all_numbers)
2696 }
2697
2698 pub async fn get_combined_status(
2700 &self,
2701 repo: &str,
2702 git_ref: &str,
2703 has_workflows: bool,
2704 mergeable_state: Option<&str>,
2705 ) -> anyhow::Result<(String, u64, u64, u64, u64)> {
2706 let url = format!("{GITHUB_API}/repos/{repo}/commits/{git_ref}/check-runs");
2707 let resp: serde_json::Value = self.get_json(&url).await?;
2708
2709 let runs = resp
2710 .get("check_runs")
2711 .and_then(|v| v.as_array())
2712 .cloned()
2713 .unwrap_or_default();
2714
2715 let mut passing = 0u64;
2716 let mut failing = 0u64;
2717 let mut pending = 0u64;
2718
2719 for run in &runs {
2720 let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("");
2721 let status = run
2722 .get("status")
2723 .and_then(|v| v.as_str())
2724 .unwrap_or("queued");
2725
2726 match status {
2727 "completed" => {
2728 if conclusion.is_empty() {
2729 pending += 1;
2730 } else if matches!(conclusion, "success" | "neutral" | "skipped") {
2731 passing += 1;
2732 } else {
2733 failing += 1;
2734 }
2735 }
2736 _ => pending += 1,
2737 }
2738 }
2739
2740 let total = runs.len() as u64;
2741 let state =
2742 Self::combined_status_state(total, failing, pending, has_workflows, mergeable_state);
2743
2744 Ok((state, total, passing, failing, pending))
2745 }
2746
2747 pub async fn rerun_failed_workflow(
2753 &self,
2754 repo: &str,
2755 workflow_name: &str,
2756 branch: &str,
2757 ) -> anyhow::Result<()> {
2758 let url = format!(
2760 "{GITHUB_API}/repos/{repo}/actions/runs?branch={branch}&event=push&per_page=10"
2761 );
2762 let resp: serde_json::Value = self.get_json(&url).await?;
2763 let runs = resp["workflow_runs"]
2764 .as_array()
2765 .ok_or_else(|| anyhow::anyhow!("no workflow_runs array in response"))?;
2766
2767 let run_id = runs
2769 .iter()
2770 .find(|r| {
2771 r["name"].as_str() == Some(workflow_name)
2772 && matches!(
2773 r["conclusion"].as_str(),
2774 Some("failure") | Some("cancelled")
2775 )
2776 })
2777 .and_then(|r| r["id"].as_u64())
2778 .ok_or_else(|| {
2779 anyhow::anyhow!(
2780 "no failed run found for workflow '{}' on branch '{}'",
2781 workflow_name,
2782 branch
2783 )
2784 })?;
2785
2786 let rerun_url = format!("{GITHUB_API}/repos/{repo}/actions/runs/{run_id}/rerun");
2788 let auth = self.auth_header().await?;
2789 let make_req = || {
2790 Ok(self
2791 .client
2792 .post(&rerun_url)
2793 .header(header::AUTHORIZATION, auth.clone())
2794 .header(header::ACCEPT, "application/vnd.github+json")
2795 .header("X-GitHub-Api-Version", "2022-11-28"))
2796 };
2797 let resp = self.send_with_retries(make_req, false).await?;
2798 let status = resp.status();
2799 if !status.is_success() {
2800 let body = resp.text().await.unwrap_or_default();
2801 Self::maybe_record_rate_limit_from_body(status, &body);
2802 anyhow::bail!("GitHub API POST rerun {rerun_url} failed ({status}): {body}");
2803 }
2804
2805 Ok(())
2806 }
2807
2808 pub async fn batch_fetch_pr_review_data(
2814 &self,
2815 repo: &str,
2816 pr_numbers: &[u64],
2817 ) -> anyhow::Result<std::collections::HashMap<u64, PrReviewBatchData>> {
2818 if pr_numbers.is_empty() {
2819 return Ok(std::collections::HashMap::new());
2820 }
2821
2822 let (owner, name) = repo
2823 .split_once('/')
2824 .ok_or_else(|| anyhow::anyhow!("invalid repo format: {}", repo))?;
2825
2826 let aliases: String = pr_numbers
2828 .iter()
2829 .map(|&n| build_pr_review_alias(n))
2830 .collect::<Vec<_>>()
2831 .join("\n");
2832
2833 let query = format!(
2834 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
2835 );
2836
2837 let resp = self
2838 .graphql_with_vars(&query, serde_json::json!({ "owner": owner, "name": name }))
2839 .await?;
2840 let repo_data = resp
2841 .pointer("/data/repository")
2842 .ok_or_else(|| anyhow::anyhow!("missing /data/repository in GraphQL response"))?;
2843
2844 let mut result = std::collections::HashMap::new();
2845 for &n in pr_numbers {
2846 let Some(pr_data) = repo_data.get(format!("pr{n}")) else {
2847 continue;
2848 };
2849
2850 let merged = pr_data
2851 .get("merged")
2852 .and_then(|v| v.as_bool())
2853 .unwrap_or(false);
2854 let mergeable = match pr_data.get("mergeable").and_then(|v| v.as_str()) {
2855 Some("MERGEABLE") => Some(true),
2856 Some("CONFLICTING") => Some(false),
2857 _ => None, };
2859
2860 let mut reviews: Vec<GitHubReview> = pr_data
2861 .pointer("/reviews/nodes")
2862 .and_then(|v| v.as_array())
2863 .map(|nodes| nodes.iter().filter_map(parse_graphql_review).collect())
2864 .unwrap_or_default();
2865
2866 if connection_has_next_page(pr_data.pointer("/reviews/pageInfo")) {
2867 tracing::warn!(
2868 pr_number = n,
2869 fetched = reviews.len(),
2870 "batch review fetch truncated reviews connection at first page"
2871 );
2872 }
2873
2874 reviews.sort_by(|a, b| a.submitted_at.cmp(&b.submitted_at));
2878
2879 let mut review_comments: Vec<GitHubReviewComment> = pr_data
2880 .pointer("/reviewThreads/nodes")
2881 .and_then(|v| v.as_array())
2882 .map(|threads| {
2883 threads
2884 .iter()
2885 .filter_map(|thread| {
2886 thread
2887 .pointer("/comments/nodes")
2888 .and_then(|v| v.as_array())
2889 .map(|nodes| {
2890 nodes
2891 .iter()
2892 .filter_map(parse_graphql_review_comment)
2893 .collect::<Vec<_>>()
2894 })
2895 })
2896 .flatten()
2897 .collect()
2898 })
2899 .unwrap_or_default();
2900
2901 if connection_has_next_page(pr_data.pointer("/reviewThreads/pageInfo")) {
2902 tracing::warn!(
2903 pr_number = n,
2904 fetched_threads = pr_data
2905 .pointer("/reviewThreads/nodes")
2906 .and_then(|v| v.as_array())
2907 .map_or(0, |threads| threads.len()),
2908 "batch review fetch truncated reviewThreads connection at first page"
2909 );
2910 }
2911 if pr_data
2912 .pointer("/reviewThreads/nodes")
2913 .and_then(|v| v.as_array())
2914 .is_some_and(|threads| {
2915 threads.iter().any(|thread| {
2916 connection_has_next_page(thread.pointer("/comments/pageInfo"))
2917 })
2918 })
2919 {
2920 tracing::warn!(
2921 pr_number = n,
2922 "batch review fetch truncated one or more review thread comment connections"
2923 );
2924 }
2925
2926 review_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2929
2930 let mut issue_comments: Vec<GitHubComment> = pr_data
2931 .pointer("/comments/nodes")
2932 .and_then(|v| v.as_array())
2933 .map(|nodes| {
2934 nodes
2935 .iter()
2936 .filter_map(parse_graphql_issue_comment)
2937 .collect()
2938 })
2939 .unwrap_or_default();
2940
2941 if let Some(cursor) = connection_end_cursor(pr_data.pointer("/comments/pageInfo")) {
2942 issue_comments.extend(
2943 self.fetch_pr_issue_comments_after(owner, name, n, Some(cursor))
2944 .await?,
2945 );
2946 }
2947
2948 issue_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2953
2954 result.insert(
2955 n,
2956 PrReviewBatchData {
2957 merged,
2958 mergeable,
2959 reviews,
2960 review_comments,
2961 issue_comments,
2962 },
2963 );
2964 }
2965
2966 Ok(result)
2967 }
2968
2969 async fn fetch_pr_issue_comments_after(
2970 &self,
2971 owner: &str,
2972 name: &str,
2973 pr_number: u64,
2974 mut cursor: Option<String>,
2975 ) -> anyhow::Result<Vec<GitHubComment>> {
2976 let mut comments = Vec::new();
2977 let mut page_count = 0;
2978 let max_pages = 50;
2979
2980 while let Some(current_cursor) = cursor.take() {
2981 page_count += 1;
2982 if page_count > max_pages {
2983 tracing::warn!(
2984 pr_number,
2985 fetched = comments.len(),
2986 "stopping PR issue comment pagination after max page limit"
2987 );
2988 break;
2989 }
2990
2991 let (query, vars) =
2992 build_pr_issue_comments_page_query(owner, name, pr_number, ¤t_cursor);
2993 let resp = self.graphql_with_vars(&query, vars).await?;
2994 let comments_data = resp
2995 .pointer("/data/repository/pullRequest/comments")
2996 .ok_or_else(|| {
2997 anyhow::anyhow!(
2998 "missing /data/repository/pullRequest/comments in GraphQL response"
2999 )
3000 })?;
3001
3002 comments.extend(
3003 comments_data
3004 .get("nodes")
3005 .and_then(|v| v.as_array())
3006 .into_iter()
3007 .flatten()
3008 .filter_map(parse_graphql_issue_comment),
3009 );
3010
3011 cursor = connection_end_cursor(comments_data.get("pageInfo"));
3012 }
3013
3014 Ok(comments)
3015 }
3016
3017 pub async fn get_automated_review_status(
3019 &self,
3020 repo: &str,
3021 pr_number: u64,
3022 ) -> anyhow::Result<Option<String>> {
3023 let comments = self.list_comments(repo, &pr_number.to_string()).await?;
3024
3025 let mut newest: Option<&crate::github::types::GitHubComment> = None;
3027 for c in &comments {
3028 if !c.body.starts_with("## Automated Review") {
3029 continue;
3030 }
3031 match self.is_collaborator(repo, &c.user.login).await {
3032 Ok(true) => match newest {
3033 None => newest = Some(c),
3034 Some(prev) => {
3035 if c.created_at > prev.created_at {
3036 newest = Some(c);
3037 }
3038 }
3039 },
3040 Ok(false) => {
3041 tracing::warn!(
3042 user = %c.user.login,
3043 pr_number,
3044 "ignoring automated review comment from non-collaborator"
3045 );
3046 continue;
3047 }
3048 Err(e) => {
3049 tracing::debug!(
3050 user = %c.user.login,
3051 error = %e,
3052 "failed to check collaborator status, skipping comment"
3053 );
3054 continue;
3055 }
3056 }
3057 }
3058
3059 match newest {
3060 Some(c) => {
3061 let first_line = c.body.lines().next().unwrap_or("");
3062 if first_line.contains("Automated Review \u{2014} Approve") {
3063 Ok(Some("approve".to_string()))
3064 } else if first_line.contains("Automated Review \u{2014} Changes Requested") {
3065 Ok(Some("changes_requested".to_string()))
3066 } else {
3067 Ok(None)
3068 }
3069 }
3070 None => Ok(None),
3071 }
3072 }
3073}
3074
3075fn sanitize_github_issue_numbers(issue_numbers: &[u64]) -> (Vec<u64>, Vec<u64>) {
3076 let mut valid = Vec::with_capacity(issue_numbers.len());
3077 let mut invalid = Vec::new();
3078 for &issue_number in issue_numbers {
3079 if issue_number == 0 {
3080 invalid.push(issue_number);
3081 } else {
3082 valid.push(issue_number);
3083 }
3084 }
3085 (valid, invalid)
3086}
3087
3088#[derive(Debug, Clone)]
3092pub struct RateLimitMetrics {
3093 pub hits: u64,
3095 pub proactive_throttles: u64,
3097 pub wait_secs: u64,
3099 pub rest_remaining: Option<u32>,
3101 pub graphql_remaining: Option<u32>,
3103 pub five_xx_hits: u64,
3105}
3106
3107pub fn rate_limit_metrics() -> RateLimitMetrics {
3109 let rest_remaining = REST_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3110 let graphql_remaining = GRAPHQL_RATE_LIMIT.lock().ok().and_then(|rl| rl.remaining);
3111 RateLimitMetrics {
3112 hits: METRIC_RATE_LIMIT_HITS.load(Ordering::Relaxed),
3113 proactive_throttles: METRIC_PROACTIVE_THROTTLES.load(Ordering::Relaxed),
3114 wait_secs: METRIC_WAIT_SECS_TOTAL.load(Ordering::Relaxed),
3115 rest_remaining,
3116 graphql_remaining,
3117 five_xx_hits: METRIC_5XX_HITS.load(Ordering::Relaxed),
3118 }
3119}
3120
3121fn parse_link_next(headers: &header::HeaderMap) -> Option<String> {
3125 let link = headers.get("link")?.to_str().ok()?;
3126 for part in link.split(',') {
3127 let part = part.trim();
3128 if part.contains("rel=\"next\"") {
3129 let start = part.find('<')? + 1;
3131 let end = part.find('>')?;
3132 if start >= end {
3133 return None;
3134 }
3135 return Some(part[start..end].to_string());
3136 }
3137 }
3138 None
3139}
3140
3141pub fn status_label_color(label: &str) -> &'static str {
3143 match label {
3144 "status:new" => "0e8a16",
3145 "status:routed" => "1d76db",
3146 "status:in_progress" => "fbca04",
3147 "status:done" => "6f42c1",
3148 "status:blocked" => "d73a4a",
3149 "status:in_review" => "0075ca",
3150 "status:needs_review" => "e4e669",
3151 "status:waiting" => "5319e7",
3152 _ => "c5def5",
3153 }
3154}
3155
3156#[derive(Debug, Clone, Default)]
3160pub struct PrReviewBatchData {
3161 pub merged: bool,
3163 pub mergeable: Option<bool>,
3165 pub reviews: Vec<GitHubReview>,
3167 pub review_comments: Vec<GitHubReviewComment>,
3169 pub issue_comments: Vec<GitHubComment>,
3171}
3172
3173fn build_pr_review_alias(n: u64) -> String {
3176 format!(
3177 r#"pr{n}: pullRequest(number: {n}) {{
3178 merged
3179 mergeable
3180 reviews(first: 50) {{
3181 nodes {{
3182 databaseId
3183 author {{ login }}
3184 body
3185 state
3186 url
3187 submittedAt
3188 commit {{ oid }}
3189 }}
3190 pageInfo {{ hasNextPage }}
3191 }}
3192 reviewThreads(first: 50) {{
3193 nodes {{
3194 comments(first: 20) {{
3195 nodes {{
3196 databaseId
3197 author {{ login }}
3198 body
3199 path
3200 line
3201 originalLine
3202 commit {{ oid }}
3203 originalCommit {{ oid }}
3204 url
3205 createdAt
3206 updatedAt
3207 replyTo {{ databaseId }}
3208 diffHunk
3209 }}
3210 pageInfo {{ hasNextPage }}
3211 }}
3212 }}
3213 pageInfo {{ hasNextPage }}
3214 }}
3215 comments(first: 50) {{
3216 nodes {{
3217 databaseId
3218 author {{ login }}
3219 body
3220 createdAt
3221 updatedAt
3222 url
3223 }}
3224 pageInfo {{ hasNextPage endCursor }}
3225 }}
3226}}"#
3227 )
3228}
3229
3230fn build_pr_issue_comments_page_query(
3231 owner: &str,
3232 name: &str,
3233 pr_number: u64,
3234 after_cursor: &str,
3235) -> (String, serde_json::Value) {
3236 let query = r#"query($owner: String!, $name: String!, $number: Int!, $after: String!) {
3237 repository(owner: $owner, name: $name) {
3238 pullRequest(number: $number) {
3239 comments(first: 100, after: $after) {
3240 nodes {
3241 databaseId
3242 author { login }
3243 body
3244 createdAt
3245 updatedAt
3246 url
3247 }
3248 pageInfo { hasNextPage endCursor }
3249 }
3250 }
3251 }
3252}"#
3253 .to_string();
3254 let vars = serde_json::json!({
3255 "owner": owner,
3256 "name": name,
3257 "number": pr_number,
3258 "after": after_cursor,
3259 });
3260 (query, vars)
3261}
3262
3263fn connection_has_next_page(page_info: Option<&serde_json::Value>) -> bool {
3264 page_info
3265 .and_then(|v| v.get("hasNextPage"))
3266 .and_then(|v| v.as_bool())
3267 .unwrap_or(false)
3268}
3269
3270fn connection_end_cursor(page_info: Option<&serde_json::Value>) -> Option<String> {
3271 if !connection_has_next_page(page_info) {
3272 return None;
3273 }
3274
3275 page_info
3276 .and_then(|v| v.get("endCursor"))
3277 .and_then(|v| v.as_str())
3278 .map(str::to_owned)
3279}
3280
3281fn parse_graphql_review(node: &serde_json::Value) -> Option<GitHubReview> {
3282 use super::types::GitHubUser;
3283 Some(GitHubReview {
3284 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3285 user: GitHubUser {
3286 login: node
3287 .pointer("/author/login")
3288 .and_then(|v| v.as_str())?
3289 .to_string(),
3290 },
3291 body: node.get("body").and_then(|v| v.as_str()).map(String::from),
3292 state: node.get("state").and_then(|v| v.as_str())?.to_string(),
3293 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3294 submitted_at: node
3295 .get("submittedAt")
3296 .and_then(|v| v.as_str())
3297 .unwrap_or("")
3298 .to_string(),
3299 commit_id: node
3300 .pointer("/commit/oid")
3301 .and_then(|v| v.as_str())
3302 .map(String::from),
3303 })
3304}
3305
3306fn parse_graphql_review_comment(node: &serde_json::Value) -> Option<GitHubReviewComment> {
3307 use super::types::GitHubUser;
3308 Some(GitHubReviewComment {
3309 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3310 user: GitHubUser {
3311 login: node
3312 .pointer("/author/login")
3313 .and_then(|v| v.as_str())?
3314 .to_string(),
3315 },
3316 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3317 path: node
3318 .get("path")
3319 .and_then(|v| v.as_str())
3320 .unwrap_or("")
3321 .to_string(),
3322 line: node.get("line").and_then(|v| v.as_u64()).map(|v| v as u32),
3323 original_line: node
3324 .get("originalLine")
3325 .and_then(|v| v.as_u64())
3326 .map(|v| v as u32),
3327 commit_id: node
3328 .pointer("/commit/oid")
3329 .and_then(|v| v.as_str())
3330 .unwrap_or("")
3331 .to_string(),
3332 original_commit_id: node
3333 .pointer("/originalCommit/oid")
3334 .and_then(|v| v.as_str())
3335 .unwrap_or("")
3336 .to_string(),
3337 html_url: node
3338 .get("url")
3339 .and_then(|v| v.as_str())
3340 .unwrap_or("")
3341 .to_string(),
3342 created_at: node
3343 .get("createdAt")
3344 .and_then(|v| v.as_str())
3345 .unwrap_or("")
3346 .to_string(),
3347 updated_at: node
3348 .get("updatedAt")
3349 .and_then(|v| v.as_str())
3350 .unwrap_or("")
3351 .to_string(),
3352 in_reply_to_id: node.pointer("/replyTo/databaseId").and_then(|v| v.as_u64()),
3353 diff_hunk: node
3354 .get("diffHunk")
3355 .and_then(|v| v.as_str())
3356 .map(String::from),
3357 })
3358}
3359
3360fn parse_graphql_issue_comment(node: &serde_json::Value) -> Option<GitHubComment> {
3361 use super::types::GitHubUser;
3362 Some(GitHubComment {
3363 id: node.get("databaseId").and_then(|v| v.as_u64())?,
3364 body: node.get("body").and_then(|v| v.as_str())?.to_string(),
3365 user: GitHubUser {
3366 login: node
3367 .pointer("/author/login")
3368 .and_then(|v| v.as_str())?
3369 .to_string(),
3370 },
3371 created_at: node
3372 .get("createdAt")
3373 .and_then(|v| v.as_str())
3374 .unwrap_or("")
3375 .to_string(),
3376 updated_at: node
3377 .get("updatedAt")
3378 .and_then(|v| v.as_str())
3379 .map(String::from),
3380 html_url: node.get("url").and_then(|v| v.as_str()).map(String::from),
3381 issue_url: None,
3382 author_association: None,
3383 })
3384}
3385
3386#[cfg(test)]
3387mod tests {
3388 use super::*;
3389 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3390 use tokio::net::TcpListener;
3391
3392 async fn spawn_truncated_body_server(body_prefix: &'static str) -> String {
3393 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3394 let addr = listener.local_addr().unwrap();
3395
3396 tokio::spawn(async move {
3397 let (mut socket, _) = listener.accept().await.unwrap();
3398 let mut buf = [0u8; 4096];
3399 let _ = socket.read(&mut buf).await;
3400
3401 let declared_len = body_prefix.len() + 32;
3402 let response = format!(
3403 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {declared_len}\r\nconnection: close\r\n\r\n{body_prefix}"
3404 );
3405 socket.write_all(response.as_bytes()).await.unwrap();
3406 socket.shutdown().await.unwrap();
3407 });
3408
3409 format!("http://{addr}")
3410 }
3411
3412 fn test_client() -> GhHttp {
3413 GhHttp {
3414 client: reqwest::Client::new(),
3415 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3416 cached_username: OnceLock::new(),
3417 }
3418 }
3419
3420 #[test]
3425 fn gh_http_new_returns_ok() {
3426 let result = GhHttp::new();
3427 assert!(
3428 result.is_ok(),
3429 "GhHttp::new() must not panic and must return Ok; got: {:?}",
3430 result.err()
3431 );
3432 }
3433
3434 #[test]
3435 fn batch_get_issue_states_query_includes_labels_pagination() {
3436 let issue_numbers = [1u64, 2, 3];
3437
3438 let aliases: String = issue_numbers
3439 .iter()
3440 .map(|n| {
3441 format!("issue{n}: issue(number: {n}) {{ state labels(first: 100) {{ nodes {{ name }} }} }}")
3442 })
3443 .collect::<Vec<_>>()
3444 .join(" ");
3445
3446 let query = format!(
3447 r#"query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {aliases} }} }}"#
3448 );
3449 assert!(query.contains("$owner"));
3450 assert!(query.contains("$name"));
3451 assert!(query.contains("issue1: issue(number: 1)"));
3452 assert!(query.contains("issue3: issue(number: 3)"));
3453 assert!(query.contains("labels(first: 100)"));
3454 }
3455
3456 #[test]
3457 fn parse_positive_issue_number_accepts_positive_values() {
3458 assert_eq!(parse_positive_issue_number("42").unwrap(), 42);
3459 }
3460
3461 #[test]
3462 fn parse_positive_issue_number_rejects_zero() {
3463 let err = parse_positive_issue_number("0").unwrap_err().to_string();
3464 assert!(err.contains("invalid issue number: 0"));
3465 }
3466
3467 #[test]
3468 fn parse_positive_issue_number_rejects_internal_ids() {
3469 let err = parse_positive_issue_number("internal:5")
3470 .unwrap_err()
3471 .to_string();
3472 assert!(err.contains("invalid issue number: internal:5"));
3473 }
3474
3475 #[test]
3476 fn pr_review_batch_query_includes_page_info_for_nested_connections() {
3477 let query = build_pr_review_alias(42);
3478
3479 assert!(query.contains("reviews(first: 50)"));
3480 assert!(query.contains("reviews(first: 50) {\n nodes"));
3481 assert!(query.contains("pageInfo { hasNextPage }"));
3482 assert!(query.contains("reviewThreads(first: 50)"));
3483 assert!(query.contains("comments(first: 20)"));
3484 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3485 assert!(query.contains("updatedAt"));
3486 }
3487
3488 #[test]
3489 fn pr_issue_comments_page_query_uses_cursor_pagination() {
3490 let (query, vars) = build_pr_issue_comments_page_query("owner", "repo", 42, "cursor123");
3491
3492 assert!(query.contains("pullRequest(number: $number)"));
3493 assert!(query.contains("comments(first: 100, after: $after)"));
3494 assert!(query.contains("pageInfo { hasNextPage endCursor }"));
3495 assert!(query.contains("updatedAt"));
3496 assert_eq!(vars["owner"], "owner");
3497 assert_eq!(vars["name"], "repo");
3498 assert_eq!(vars["number"], 42);
3499 assert_eq!(vars["after"], "cursor123");
3500 }
3501
3502 #[test]
3503 fn connection_end_cursor_only_returns_cursor_when_more_pages_exist() {
3504 let page_info = serde_json::json!({
3505 "hasNextPage": true,
3506 "endCursor": "cursor123"
3507 });
3508 assert_eq!(
3509 connection_end_cursor(Some(&page_info)),
3510 Some("cursor123".to_string())
3511 );
3512
3513 let final_page = serde_json::json!({
3514 "hasNextPage": false,
3515 "endCursor": "cursor456"
3516 });
3517 assert_eq!(connection_end_cursor(Some(&final_page)), None);
3518 }
3519
3520 #[test]
3521 fn status_label_colors_match_bash_palette() {
3522 assert_eq!(status_label_color("status:new"), "0e8a16");
3523 assert_eq!(status_label_color("status:routed"), "1d76db");
3524 assert_eq!(status_label_color("status:in_progress"), "fbca04");
3525 assert_eq!(status_label_color("status:done"), "6f42c1");
3526 assert_eq!(status_label_color("status:blocked"), "d73a4a");
3527 assert_eq!(status_label_color("status:in_review"), "0075ca");
3528 assert_eq!(status_label_color("status:needs_review"), "e4e669");
3529 }
3530
3531 #[test]
3532 fn unknown_label_gets_default_color() {
3533 assert_eq!(status_label_color("agent:claude"), "c5def5");
3534 assert_eq!(status_label_color("enhancement"), "c5def5");
3535 assert_eq!(status_label_color(""), "c5def5");
3536 }
3537
3538 #[test]
3539 fn parse_link_next_finds_next_url() {
3540 let mut headers = header::HeaderMap::new();
3541 headers.insert(
3542 "link",
3543 "<https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\""
3544 .parse()
3545 .unwrap(),
3546 );
3547 assert_eq!(
3548 parse_link_next(&headers),
3549 Some("https://api.github.com/repos/foo/bar/issues?page=2".to_string())
3550 );
3551 }
3552
3553 #[test]
3554 fn parse_link_next_none_when_no_next() {
3555 let mut headers = header::HeaderMap::new();
3556 headers.insert(
3557 "link",
3558 "<https://api.github.com/repos/foo/bar/issues?page=1>; rel=\"prev\""
3559 .parse()
3560 .unwrap(),
3561 );
3562 assert_eq!(parse_link_next(&headers), None);
3563 }
3564
3565 #[test]
3566 fn parse_link_next_none_when_missing() {
3567 let headers = header::HeaderMap::new();
3568 assert_eq!(parse_link_next(&headers), None);
3569 }
3570
3571 #[test]
3572 fn parse_link_next_none_when_malformed() {
3573 let mut headers = header::HeaderMap::new();
3574 headers.insert("link", "garbage; rel=\"next\"".parse().unwrap());
3576 assert_eq!(parse_link_next(&headers), None);
3577 }
3578
3579 #[test]
3580 fn parse_link_next_none_when_gt_before_lt() {
3581 let mut headers = header::HeaderMap::new();
3582 headers.insert("link", ">junk; rel=\"next\"; <url>".parse().unwrap());
3583 assert_eq!(parse_link_next(&headers), None); }
3585
3586 #[test]
3587 fn parse_link_next_none_when_empty_url() {
3588 let mut headers = header::HeaderMap::new();
3589 headers.insert("link", "<>; rel=\"next\"".parse().unwrap());
3590 assert_eq!(parse_link_next(&headers), None);
3591 }
3592
3593 fn make_rl(base_secs: u64, max_secs: u64) -> RateLimit {
3594 RateLimit {
3595 remaining: None,
3596 reset_at: None,
3597 backoff_until: None,
3598 backoff_delay: Duration::ZERO,
3599 backoff_base: Duration::from_secs(base_secs),
3600 backoff_max: Duration::from_secs(max_secs),
3601 throttle_threshold: 10,
3602 }
3603 }
3604
3605 #[test]
3606 fn rate_limit_inactive_by_default() {
3607 let rl = make_rl(30, 900);
3608 assert!(rl.is_active().is_none());
3609 }
3610
3611 #[test]
3612 fn rate_limit_backoff_activates() {
3613 let mut rl = make_rl(5, 60);
3614 rl.record_rate_limit();
3615 assert!(rl.is_active().is_some());
3616 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3617 }
3618
3619 #[test]
3620 fn rate_limit_backoff_doubles() {
3621 let mut rl = make_rl(5, 60);
3622 rl.record_rate_limit();
3623 assert_eq!(rl.backoff_delay, Duration::from_secs(5));
3624 rl.record_rate_limit();
3625 assert_eq!(rl.backoff_delay, Duration::from_secs(10));
3626 rl.record_rate_limit();
3627 assert_eq!(rl.backoff_delay, Duration::from_secs(20));
3628 }
3629
3630 #[test]
3631 fn rate_limit_backoff_caps_at_max() {
3632 let mut rl = make_rl(30, 60);
3633 rl.record_rate_limit(); rl.record_rate_limit(); rl.record_rate_limit(); assert_eq!(rl.backoff_delay, Duration::from_secs(60));
3637 }
3638
3639 #[test]
3640 fn rate_limit_success_resets() {
3641 let mut rl = make_rl(5, 60);
3642 rl.record_rate_limit();
3643 assert!(rl.is_active().is_some());
3644 rl.record_success();
3645 assert!(rl.is_active().is_none());
3646 assert_eq!(rl.backoff_delay, Duration::ZERO);
3647 }
3648
3649 #[test]
3650 fn rate_limit_proactive_pause() {
3651 let mut rl = make_rl(30, 900);
3652 rl.remaining = Some(0);
3654 let future_epoch = std::time::SystemTime::now()
3655 .duration_since(std::time::UNIX_EPOCH)
3656 .unwrap()
3657 .as_secs()
3658 + 60;
3659 rl.reset_at = Some(future_epoch);
3660 let pause = rl.is_active();
3661 assert!(pause.is_some());
3662 assert!(pause.unwrap().as_secs() <= 62);
3664 }
3665
3666 #[test]
3667 fn rate_limit_proactive_pause_threshold() {
3668 let mut rl = make_rl(30, 900);
3669 rl.remaining = Some(15);
3671 let future_epoch = std::time::SystemTime::now()
3672 .duration_since(std::time::UNIX_EPOCH)
3673 .unwrap()
3674 .as_secs()
3675 + 60;
3676 rl.reset_at = Some(future_epoch);
3677 assert!(rl.is_active().is_none());
3678
3679 rl.remaining = Some(5);
3681 assert!(rl.is_active().is_some());
3682 }
3683
3684 #[test]
3685 fn combined_status_state_treats_empty_checks_as_pending_when_workflows_exist() {
3686 assert_eq!(
3688 GhHttp::combined_status_state(0, 0, 0, true, None),
3689 "pending"
3690 );
3691 assert_eq!(
3693 GhHttp::combined_status_state(0, 0, 0, false, None),
3694 "success"
3695 );
3696 assert_eq!(
3698 GhHttp::combined_status_state(3, 1, 0, true, None),
3699 "failure"
3700 );
3701 assert_eq!(
3703 GhHttp::combined_status_state(0, 0, 0, true, Some("clean")),
3704 "success"
3705 );
3706 assert_eq!(
3708 GhHttp::combined_status_state(0, 0, 0, true, Some("dirty")),
3709 "pending"
3710 );
3711 }
3712
3713 #[test]
3714 fn batch_is_pr_merged_by_branch_errors_when_repository_data_missing() {
3715 let resp = serde_json::json!({
3716 "data": {},
3717 "errors": [{ "message": "Something went wrong" }],
3718 });
3719
3720 let err_text = GhHttp::parse_batch_pr_merged_response(&resp, &["branch".to_string()])
3721 .unwrap_err()
3722 .to_string();
3723 assert!(err_text.contains("missing /data/repository in GraphQL response"));
3724 assert!(err_text.contains("Something went wrong"));
3725 }
3726
3727 #[test]
3728 fn rate_limit_uses_reset_at_for_backoff() {
3729 let mut rl = make_rl(30, 900);
3730 let future_epoch = std::time::SystemTime::now()
3732 .duration_since(std::time::UNIX_EPOCH)
3733 .unwrap()
3734 .as_secs()
3735 + 45;
3736 rl.reset_at = Some(future_epoch);
3737 rl.record_rate_limit();
3738 assert!(rl.backoff_delay.as_secs() >= 45);
3740 assert!(rl.backoff_delay.as_secs() <= 47);
3741 }
3742
3743 #[tokio::test]
3744 async fn send_with_retries_propagates_make_req_error() {
3745 let guard = std::env::var_os("GH_TOKEN");
3746 std::env::set_var("GH_TOKEN", "test_token");
3747 let gh = test_client();
3748 let result = gh
3749 .send_with_retries(
3750 || Err(anyhow::anyhow!("simulated request clone failure")),
3751 false,
3752 )
3753 .await;
3754 assert!(result.is_err());
3755 let err = result.unwrap_err().to_string();
3756 assert_eq!(err, "simulated request clone failure");
3757 match guard {
3758 Some(v) => std::env::set_var("GH_TOKEN", v),
3759 None => std::env::remove_var("GH_TOKEN"),
3760 }
3761 }
3762
3763 #[tokio::test]
3769 async fn send_with_retries_transport_error_does_not_trip_5xx_breaker() {
3770 let guard = std::env::var_os("GH_TOKEN");
3771 std::env::set_var("GH_TOKEN", "test_token");
3772
3773 let addr = {
3777 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3778 listener.local_addr().unwrap()
3779 };
3780 let url = format!("http://{addr}/");
3781
3782 let gh = test_client();
3783 let make_req = || Ok(gh.client.get(&url));
3784 let result = gh.send_with_retries(make_req, false).await;
3785
3786 assert!(result.is_err());
3787 assert!(
3788 !crate::engine::cooldown::is_agent_in_cooldown("github:5xx"),
3789 "transport error must not trip the github:5xx circuit breaker"
3790 );
3791 assert!(crate::engine::cooldown::cooldown_until("github:5xx").is_none());
3792
3793 match guard {
3794 Some(v) => std::env::set_var("GH_TOKEN", v),
3795 None => std::env::remove_var("GH_TOKEN"),
3796 }
3797 }
3798
3799 #[tokio::test]
3800 async fn post_json_raw_propagates_body_read_errors_on_success() {
3801 let guard = std::env::var_os("GH_TOKEN");
3802 std::env::set_var("GH_TOKEN", "test_token");
3803 let server_url = spawn_truncated_body_server("{").await;
3804 let gh = test_client();
3805
3806 let result = gh
3807 .post_json_raw(
3808 &format!("{server_url}/post"),
3809 &serde_json::json!({"ok": true}),
3810 )
3811 .await;
3812
3813 let err = result.unwrap_err().to_string();
3814 assert!(
3815 !err.contains("EOF while parsing a value"),
3816 "should surface the body read error, got: {err}"
3817 );
3818 assert!(
3819 err.contains("error decoding response body")
3820 || err.contains("request or response body error")
3821 || err.contains("end of file before message length reached"),
3822 "expected a transport/body read error, got: {err}"
3823 );
3824
3825 match guard {
3826 Some(v) => std::env::set_var("GH_TOKEN", v),
3827 None => std::env::remove_var("GH_TOKEN"),
3828 }
3829 }
3830
3831 #[tokio::test]
3832 async fn patch_json_raw_propagates_body_read_errors_on_success() {
3833 let guard = std::env::var_os("GH_TOKEN");
3834 std::env::set_var("GH_TOKEN", "test_token");
3835 let server_url = spawn_truncated_body_server("{").await;
3836 let gh = test_client();
3837
3838 let result = gh
3839 .patch_json_raw(
3840 &format!("{server_url}/patch"),
3841 &serde_json::json!({"ok": true}),
3842 )
3843 .await;
3844
3845 let err = result.unwrap_err().to_string();
3846 assert!(
3847 !err.contains("EOF while parsing a value"),
3848 "should surface the body read error, got: {err}"
3849 );
3850 assert!(
3851 err.contains("error decoding response body")
3852 || err.contains("request or response body error")
3853 || err.contains("end of file before message length reached"),
3854 "expected a transport/body read error, got: {err}"
3855 );
3856
3857 match guard {
3858 Some(v) => std::env::set_var("GH_TOKEN", v),
3859 None => std::env::remove_var("GH_TOKEN"),
3860 }
3861 }
3862
3863 #[tokio::test]
3864 async fn graphql_request_propagates_body_read_errors_on_success() {
3865 let guard = std::env::var_os("GH_TOKEN");
3866 std::env::set_var("GH_TOKEN", "test_token");
3867 let server_url = spawn_truncated_body_server("{").await;
3868 let gh = test_client();
3869 let query = "query { viewer { login } }";
3870
3871 let result = gh
3872 .graphql_request_to_url(
3873 &format!("{server_url}/graphql"),
3874 query,
3875 &[],
3876 Some(serde_json::json!({"k": "v"})),
3877 )
3878 .await;
3879
3880 let err = result.unwrap_err().to_string();
3881 assert!(
3882 !err.contains("EOF while parsing a value"),
3883 "should surface the body read error, got: {err}"
3884 );
3885 assert!(
3886 err.contains("error decoding response body")
3887 || err.contains("request or response body error")
3888 || err.contains("end of file before message length reached"),
3889 "expected a transport/body read error, got: {err}"
3890 );
3891
3892 match guard {
3893 Some(v) => std::env::set_var("GH_TOKEN", v),
3894 None => std::env::remove_var("GH_TOKEN"),
3895 }
3896 }
3897
3898 #[cfg(test)]
3901 mod pagination {
3902 use super::super::*;
3903 use std::ffi::OsString;
3904 use std::sync::Arc;
3905 use wiremock::matchers::{method, path};
3906 use wiremock::{Mock, MockServer, ResponseTemplate};
3907
3908 struct EnvVarGuard {
3909 key: &'static str,
3910 previous: Option<OsString>,
3911 }
3912
3913 impl EnvVarGuard {
3914 fn set(key: &'static str, value: &str) -> Self {
3915 let previous = std::env::var_os(key);
3916 std::env::set_var(key, value);
3917 Self { key, previous }
3918 }
3919 }
3920
3921 impl Drop for EnvVarGuard {
3922 fn drop(&mut self) {
3923 match self.previous.take() {
3924 Some(value) => std::env::set_var(self.key, value),
3925 None => std::env::remove_var(self.key),
3926 }
3927 }
3928 }
3929
3930 fn make_client() -> (GhHttp, EnvVarGuard) {
3933 let guard = EnvVarGuard::set("GH_TOKEN", "test_token");
3935 let client = GhHttp {
3936 client: reqwest::Client::new(),
3937 token_resolver: Arc::new(crate::github::token::TokenResolver::default_env()),
3938 cached_username: OnceLock::new(),
3939 };
3940 (client, guard)
3941 }
3942
3943 #[tokio::test]
3944 async fn get_all_pages_single_page() {
3945 let server = MockServer::start().await;
3946
3947 Mock::given(method("GET"))
3948 .and(path("/items"))
3949 .respond_with(
3950 ResponseTemplate::new(200)
3951 .set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
3952 )
3953 .expect(1)
3954 .mount(&server)
3955 .await;
3956
3957 let (gh, _guard) = make_client();
3958 let url = format!("{}/items", server.uri());
3959 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3960
3961 assert_eq!(result.len(), 2);
3962 assert_eq!(result[0]["id"], 1);
3963 assert_eq!(result[1]["id"], 2);
3964 }
3965
3966 #[tokio::test]
3967 async fn get_all_pages_follows_link_next() {
3968 let server = MockServer::start().await;
3969
3970 Mock::given(method("GET"))
3972 .and(path("/items"))
3973 .respond_with(
3974 ResponseTemplate::new(200)
3975 .set_body_json(serde_json::json!([{"id": 1}]))
3976 .insert_header(
3977 "Link",
3978 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
3979 ),
3980 )
3981 .expect(1)
3982 .mount(&server)
3983 .await;
3984
3985 Mock::given(method("GET"))
3987 .and(path("/items/page2"))
3988 .respond_with(
3989 ResponseTemplate::new(200)
3990 .set_body_json(serde_json::json!([{"id": 2}, {"id": 3}])),
3991 )
3992 .expect(1)
3993 .mount(&server)
3994 .await;
3995
3996 let (gh, _guard) = make_client();
3997 let url = format!("{}/items", server.uri());
3998 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
3999
4000 assert_eq!(result.len(), 3);
4001 assert_eq!(result[0]["id"], 1);
4002 assert_eq!(result[1]["id"], 2);
4003 assert_eq!(result[2]["id"], 3);
4004 }
4005
4006 #[tokio::test]
4007 async fn get_all_pages_stops_when_link_header_absent_on_subsequent_page() {
4008 let server = MockServer::start().await;
4011
4012 Mock::given(method("GET"))
4013 .and(path("/items"))
4014 .respond_with(
4015 ResponseTemplate::new(200)
4016 .set_body_json(serde_json::json!([{"id": 1}]))
4017 .insert_header(
4018 "Link",
4019 format!("<{}/items/page2>; rel=\"next\"", server.uri()),
4020 ),
4021 )
4022 .expect(1)
4023 .mount(&server)
4024 .await;
4025
4026 Mock::given(method("GET"))
4028 .and(path("/items/page2"))
4029 .respond_with(
4030 ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 2}])),
4031 )
4032 .expect(1)
4033 .mount(&server)
4034 .await;
4035
4036 let (gh, _guard) = make_client();
4037 let url = format!("{}/items", server.uri());
4038 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4039
4040 assert_eq!(result.len(), 2);
4042 }
4043
4044 #[tokio::test]
4045 async fn get_all_pages_stops_on_malformed_link_header() {
4046 let server = MockServer::start().await;
4049
4050 Mock::given(method("GET"))
4051 .and(path("/items"))
4052 .respond_with(
4053 ResponseTemplate::new(200)
4054 .set_body_json(serde_json::json!([{"id": 1}]))
4055 .insert_header("Link", "garbage; rel=\"next\""),
4056 )
4057 .expect(1)
4058 .mount(&server)
4059 .await;
4060
4061 let (gh, _guard) = make_client();
4062 let url = format!("{}/items", server.uri());
4063 let result: Vec<serde_json::Value> = gh.get_all_pages(&url, &[]).await.unwrap();
4064
4065 assert_eq!(result.len(), 1);
4067 }
4068
4069 #[tokio::test]
4070 async fn get_all_pages_returns_error_on_malformed_json_body() {
4071 let server = MockServer::start().await;
4072
4073 Mock::given(method("GET"))
4074 .and(path("/items"))
4075 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
4076 .expect(3)
4077 .mount(&server)
4078 .await;
4079
4080 let (gh, _guard) = make_client();
4081 let url = format!("{}/items", server.uri());
4082 let result = gh.get_all_pages::<serde_json::Value>(&url, &[]).await;
4083
4084 assert!(result.is_err());
4085 let err = result.unwrap_err().to_string();
4086 assert!(
4087 err.contains("JSON decode failed") || err.contains("expected ident"),
4088 "error should mention JSON decode or serde error: {}",
4089 err
4090 );
4091 }
4092
4093 #[tokio::test]
4096 async fn get_all_pages_from_wrapper_single_page() {
4097 let server = MockServer::start().await;
4100
4101 Mock::given(method("GET"))
4102 .and(path("/check-runs"))
4103 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4104 "total_count": 2,
4105 "check_runs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]
4106 })))
4107 .expect(1)
4108 .mount(&server)
4109 .await;
4110
4111 let (gh, _guard) = make_client();
4112 let url = format!("{}/check-runs", server.uri());
4113 let result: Vec<serde_json::Value> = gh
4114 .get_all_pages_from_wrapper(&url, "check_runs")
4115 .await
4116 .unwrap();
4117
4118 assert_eq!(result.len(), 2);
4119 assert_eq!(result[0]["id"], 1);
4120 assert_eq!(result[1]["id"], 2);
4121 }
4122
4123 #[tokio::test]
4124 async fn get_all_pages_from_wrapper_follows_link_next() {
4125 let server = MockServer::start().await;
4127
4128 Mock::given(method("GET"))
4129 .and(path("/check-runs"))
4130 .respond_with(
4131 ResponseTemplate::new(200)
4132 .set_body_json(serde_json::json!({
4133 "total_count": 3,
4134 "check_runs": [{"id": 1, "name": "build"}]
4135 }))
4136 .insert_header(
4137 "Link",
4138 format!("<{}/check-runs/page2>; rel=\"next\"", server.uri()),
4139 ),
4140 )
4141 .expect(1)
4142 .mount(&server)
4143 .await;
4144
4145 Mock::given(method("GET"))
4146 .and(path("/check-runs/page2"))
4147 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4148 "total_count": 3,
4149 "check_runs": [{"id": 2, "name": "lint"}, {"id": 3, "name": "test"}]
4150 })))
4151 .expect(1)
4152 .mount(&server)
4153 .await;
4154
4155 let (gh, _guard) = make_client();
4156 let url = format!("{}/check-runs", server.uri());
4157 let result: Vec<serde_json::Value> = gh
4158 .get_all_pages_from_wrapper(&url, "check_runs")
4159 .await
4160 .unwrap();
4161
4162 assert_eq!(result.len(), 3);
4163 assert_eq!(result[0]["id"], 1);
4164 assert_eq!(result[1]["id"], 2);
4165 assert_eq!(result[2]["id"], 3);
4166 }
4167
4168 #[tokio::test]
4169 async fn get_all_pages_from_wrapper_empty_field_returns_empty_vec() {
4170 let server = MockServer::start().await;
4173
4174 Mock::given(method("GET"))
4175 .and(path("/check-runs"))
4176 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4177 "total_count": 0,
4178 "check_runs": []
4179 })))
4180 .expect(1)
4181 .mount(&server)
4182 .await;
4183
4184 let (gh, _guard) = make_client();
4185 let url = format!("{}/check-runs", server.uri());
4186 let result: Vec<serde_json::Value> = gh
4187 .get_all_pages_from_wrapper(&url, "check_runs")
4188 .await
4189 .unwrap();
4190
4191 assert_eq!(result.len(), 0);
4192 }
4193
4194 #[tokio::test]
4195 async fn get_all_pages_from_wrapper_error_on_non_success_status() {
4196 let server = MockServer::start().await;
4197
4198 Mock::given(method("GET"))
4199 .and(path("/check-runs"))
4200 .respond_with(ResponseTemplate::new(422).set_body_string("Unprocessable Entity"))
4201 .expect(1)
4202 .mount(&server)
4203 .await;
4204
4205 let (gh, _guard) = make_client();
4206 let url = format!("{}/check-runs", server.uri());
4207 let result = gh
4208 .get_all_pages_from_wrapper::<serde_json::Value>(&url, "check_runs")
4209 .await;
4210
4211 assert!(result.is_err());
4212 let err = result.unwrap_err().to_string();
4213 assert!(
4214 err.contains("422") || err.contains("failed"),
4215 "error should indicate failure: {err}"
4216 );
4217 }
4218 }
4219
4220 #[cfg(test)]
4223 mod auth_retry {
4224 use super::test_client;
4225 use std::sync::atomic::{AtomicUsize, Ordering};
4226 use std::sync::Arc;
4227 use wiremock::matchers::method;
4228 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
4229
4230 fn bad_credentials() -> ResponseTemplate {
4231 ResponseTemplate::new(401).set_body_json(serde_json::json!({
4232 "message": "Bad credentials",
4233 "status": 401
4234 }))
4235 }
4236
4237 #[allow(clippy::await_holding_lock)]
4238 #[tokio::test]
4239 async fn retries_once_with_fresh_token_after_401() {
4240 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4241 let prev = std::env::var_os("GH_TOKEN");
4242 std::env::set_var("GH_TOKEN", "bad_token");
4243
4244 let server = MockServer::start().await;
4245 let hits = Arc::new(AtomicUsize::new(0));
4246 let hits_in_handler = hits.clone();
4247 Mock::given(method("GET"))
4248 .respond_with(move |_req: &Request| {
4249 if hits_in_handler.fetch_add(1, Ordering::SeqCst) == 0 {
4250 std::env::set_var("GH_TOKEN", "good_token");
4253 bad_credentials()
4254 } else {
4255 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))
4256 }
4257 })
4258 .mount(&server)
4259 .await;
4260
4261 let gh = test_client();
4262 let result: anyhow::Result<serde_json::Value> =
4263 gh.get_json(&format!("{}/x", server.uri())).await;
4264
4265 match prev {
4266 Some(v) => std::env::set_var("GH_TOKEN", v),
4267 None => std::env::remove_var("GH_TOKEN"),
4268 }
4269
4270 assert!(
4271 result.is_ok(),
4272 "retry with re-resolved token should succeed: {:?}",
4273 result.err()
4274 );
4275 assert_eq!(hits.load(Ordering::SeqCst), 2, "exactly one retry expected");
4276 }
4277
4278 #[allow(clippy::await_holding_lock)]
4279 #[tokio::test]
4280 async fn double_401_bails_with_distinct_auth_error() {
4281 let _env_guard = crate::github::token::ENV_LOCK.lock().unwrap();
4282 let prev = std::env::var_os("GH_TOKEN");
4283 std::env::set_var("GH_TOKEN", "bad_token");
4284
4285 let server = MockServer::start().await;
4286 let hits = Arc::new(AtomicUsize::new(0));
4287 let hits_in_handler = hits.clone();
4288 Mock::given(method("GET"))
4289 .respond_with(move |_req: &Request| {
4290 hits_in_handler.fetch_add(1, Ordering::SeqCst);
4291 bad_credentials()
4292 })
4293 .mount(&server)
4294 .await;
4295
4296 let gh = test_client();
4297 let result: anyhow::Result<serde_json::Value> =
4298 gh.get_json(&format!("{}/x", server.uri())).await;
4299
4300 match prev {
4301 Some(v) => std::env::set_var("GH_TOKEN", v),
4302 None => std::env::remove_var("GH_TOKEN"),
4303 }
4304
4305 let err = result.expect_err("persisting 401 must fail").to_string();
4306 assert!(
4307 err.contains("token re-resolved after cache clear but still rejected"),
4308 "expected distinct auth-failure error, got: {err}"
4309 );
4310 assert_eq!(
4311 hits.load(Ordering::SeqCst),
4312 2,
4313 "must retry exactly once, not loop"
4314 );
4315 }
4316 }
4317}