1use std::collections::{BTreeMap, BTreeSet};
7use std::fmt;
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10
11use async_trait::async_trait;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use tokio_util::sync::CancellationToken;
16use url::Url;
17
18const REDACTED: &str = "[REDACTED]";
19const DEFAULT_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
20const DEFAULT_MAX_ITEMS: usize = 500;
21const DEFAULT_MAX_PAGES: usize = 20;
22const MAX_SAME_ORIGIN_REDIRECTS: usize = 3;
23const MAX_PULL_REQUEST_LIST_LIMIT: usize = 100;
24
25#[derive(
27 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
28)]
29#[serde(rename_all = "snake_case")]
30pub enum PullRequestProviderKind {
31 GitHub,
33 BitbucketCloud,
35 BitbucketDataCenter,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41pub struct PullRequestCoordinates {
42 pub provider: PullRequestProviderKind,
44 pub owner: String,
46 pub repository: String,
48 pub number: u64,
50}
51
52#[derive(Clone, Default, PartialEq, Eq)]
54pub struct PrAuthToken(String);
55
56impl PrAuthToken {
57 #[must_use]
59 pub fn new(token: impl Into<String>) -> Self {
60 Self(token.into())
61 }
62
63 #[must_use]
65 pub fn expose_secret(&self) -> &str {
66 &self.0
67 }
68
69 fn is_empty(&self) -> bool {
70 self.0.is_empty()
71 }
72}
73
74impl fmt::Debug for PrAuthToken {
75 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76 formatter.write_str("PrAuthToken([REDACTED])")
77 }
78}
79
80#[derive(Clone, Serialize, Deserialize, JsonSchema)]
82pub struct PullRequestProviderConfig {
83 pub provider: PullRequestProviderKind,
85 pub enabled: bool,
87 pub api_base_url: String,
89 pub api_base_url_allowlist: Vec<String>,
91 pub allow_loopback_http: bool,
93 #[serde(skip)]
95 #[schemars(skip)]
96 pub auth_token: PrAuthToken,
97 #[serde(skip)]
99 #[schemars(skip)]
100 pub basic_auth_username: Option<String>,
101 pub request_timeout_ms: u64,
103 pub max_output_bytes: usize,
105 pub max_items: usize,
107 pub max_pages: usize,
109 pub cache_ttl_seconds: u64,
111}
112
113impl fmt::Debug for PullRequestProviderConfig {
114 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115 formatter
116 .debug_struct("PullRequestProviderConfig")
117 .field("provider", &self.provider)
118 .field("enabled", &self.enabled)
119 .field("api_base_url", &self.api_base_url)
120 .field("api_base_url_allowlist", &self.api_base_url_allowlist)
121 .field("allow_loopback_http", &self.allow_loopback_http)
122 .field("auth_token", &REDACTED)
123 .field(
124 "basic_auth_username",
125 &self.basic_auth_username.as_ref().map(|_| REDACTED),
126 )
127 .field("request_timeout_ms", &self.request_timeout_ms)
128 .field("max_output_bytes", &self.max_output_bytes)
129 .field("max_items", &self.max_items)
130 .field("max_pages", &self.max_pages)
131 .field("cache_ttl_seconds", &self.cache_ttl_seconds)
132 .finish()
133 }
134}
135
136impl Default for PullRequestProviderConfig {
137 fn default() -> Self {
138 Self {
139 provider: PullRequestProviderKind::GitHub,
140 enabled: false,
141 api_base_url: "https://api.github.com".to_owned(),
142 api_base_url_allowlist: vec!["https://api.github.com".to_owned()],
143 allow_loopback_http: false,
144 auth_token: PrAuthToken::default(),
145 basic_auth_username: None,
146 request_timeout_ms: 10_000,
147 max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
148 max_items: DEFAULT_MAX_ITEMS,
149 max_pages: DEFAULT_MAX_PAGES,
150 cache_ttl_seconds: 60,
151 }
152 }
153}
154
155impl PullRequestProviderConfig {
156 #[must_use]
158 pub fn bitbucket_cloud() -> Self {
159 Self {
160 provider: PullRequestProviderKind::BitbucketCloud,
161 api_base_url: "https://api.bitbucket.org/2.0".to_owned(),
162 api_base_url_allowlist: vec!["https://api.bitbucket.org/2.0".to_owned()],
163 ..Self::default()
164 }
165 }
166
167 pub fn validate(&self) -> Result<(), PullRequestError> {
173 if self.request_timeout_ms == 0
174 || self.max_output_bytes == 0
175 || self.max_items == 0
176 || self.max_pages == 0
177 {
178 return Err(PullRequestError::InvalidConfiguration(
179 "request bounds must be greater than zero".to_owned(),
180 ));
181 }
182 if self.basic_auth_username.as_ref().is_some_and(|username| {
183 username.trim().is_empty() || username.chars().any(char::is_control)
184 }) {
185 return Err(PullRequestError::InvalidConfiguration(
186 "Basic-auth username must be non-empty and control-free".to_owned(),
187 ));
188 }
189 let base = parse_base_url(&self.api_base_url)?;
190 if is_loopback(&base) && self.allow_loopback_http {
191 return Ok(());
192 }
193 if base.scheme() != "https" {
194 return Err(PullRequestError::InvalidConfiguration(
195 "provider API base URL must use HTTPS".to_owned(),
196 ));
197 }
198 let normalized = normalize_base_url(base);
199 let allowed = self
200 .api_base_url_allowlist
201 .iter()
202 .filter_map(|candidate| Url::parse(candidate).ok())
203 .any(|candidate| {
204 candidate.scheme() == "https" && normalize_base_url(candidate) == normalized
205 });
206 if !allowed {
207 return Err(PullRequestError::InvalidConfiguration(
208 "provider API base URL is not allowlisted".to_owned(),
209 ));
210 }
211 Ok(())
212 }
213}
214
215#[derive(Debug, Clone)]
217pub struct PullRequestInspectRequest {
218 pub coordinates: PullRequestCoordinates,
220 pub consent_to_remote_access: bool,
222 pub cancellation: CancellationToken,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
228#[serde(rename_all = "snake_case")]
229pub enum PullRequestListState {
230 Open,
232 Closed,
234 #[default]
236 All,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
241pub struct PullRequestListRequest {
242 pub provider: PullRequestProviderKind,
244 pub owner: String,
246 pub repository: String,
248 #[serde(default)]
250 pub state: PullRequestListState,
251 #[serde(default)]
253 pub cursor: Option<String>,
254 #[schemars(range(min = 1, max = 100))]
256 pub limit: usize,
257 pub consent_to_remote_access: bool,
259 #[serde(skip, default)]
261 #[schemars(skip)]
262 pub cancellation: CancellationToken,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
267pub struct PullRequestSummary {
268 pub coordinates: PullRequestCoordinates,
270 pub title: String,
272 pub url: String,
274 pub state: PullRequestState,
276 pub draft: bool,
278 pub author: String,
280 pub base_branch: String,
282 pub head_branch: String,
284 pub created_at: String,
286 pub updated_at: String,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
292pub struct PullRequestListPage {
293 pub items: Vec<PullRequestSummary>,
295 pub next_cursor: Option<String>,
297 pub has_more: bool,
299 pub truncated: bool,
301 pub rate_limit: PullRequestRateLimit,
303 pub warnings: Vec<PullRequestWarning>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
309pub struct PullRequestRepository {
310 pub full_name: String,
312 pub url: Option<String>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
318pub struct PullRequestRef {
319 pub name: String,
321 pub sha: String,
323 pub repository: PullRequestRepository,
325}
326
327#[derive(
329 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
330)]
331#[serde(rename_all = "snake_case")]
332pub enum PullRequestState {
333 Open,
335 Closed,
337 Merged,
339 Unknown,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
345pub struct PullRequestMetadata {
346 pub id: String,
348 pub title: String,
350 pub url: String,
352 pub state: PullRequestState,
354 pub draft: bool,
356 pub author: String,
358 pub base: PullRequestRef,
360 pub head: PullRequestRef,
362 pub created_at: String,
364 pub updated_at: String,
366}
367
368#[derive(
370 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
371)]
372#[serde(rename_all = "snake_case")]
373pub enum ChangedFileStatus {
374 Added,
376 Modified,
378 Removed,
380 Renamed,
382 Copied,
384 Unknown,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
390pub struct PullRequestChangedFile {
391 pub status: ChangedFileStatus,
393 pub old_path: Option<String>,
395 pub new_path: Option<String>,
397 pub additions: u64,
399 pub deletions: u64,
401 pub binary: bool,
403 pub patch_truncated: bool,
405}
406
407#[derive(
409 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
410)]
411#[serde(rename_all = "snake_case")]
412pub enum CheckState {
413 Success,
415 Failure,
417 Pending,
419 Unknown,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
425pub struct PullRequestCheck {
426 pub name: String,
428 pub state: CheckState,
430 pub url: Option<String>,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
436pub struct PullRequestCi {
437 pub state: CheckState,
439 pub checks: Vec<PullRequestCheck>,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
445pub struct PullRequestReview {
446 pub id: String,
448 pub author: String,
450 pub state: String,
452 pub submitted_at: Option<String>,
454}
455
456#[derive(
458 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
459)]
460#[serde(rename_all = "snake_case")]
461pub enum ReviewState {
462 Approved,
464 ChangesRequested,
466 Pending,
468 Unknown,
470}
471
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
474pub struct PullRequestReviewSummary {
475 pub state: ReviewState,
477 pub approvals: usize,
479 pub reviews: Vec<PullRequestReview>,
481}
482
483#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
485pub struct PullRequestRateLimit {
486 pub limit: Option<u64>,
488 pub remaining: Option<u64>,
490 pub reset: Option<String>,
492 pub retry_after_seconds: Option<u64>,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
498pub struct PullRequestWarning {
499 pub code: String,
501 pub message: String,
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
507pub struct PullRequestInspection {
508 pub coordinates: PullRequestCoordinates,
510 pub metadata: PullRequestMetadata,
512 pub changed_files: Vec<PullRequestChangedFile>,
514 pub ci: PullRequestCi,
516 pub review: PullRequestReviewSummary,
518 pub rate_limit: PullRequestRateLimit,
520 pub warnings: Vec<PullRequestWarning>,
522 pub fingerprint: String,
524 pub from_cache: bool,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
530pub enum PrHttpMethod {
531 Get,
533}
534
535#[derive(Clone, Default)]
537pub enum PrHttpAuthentication {
538 #[default]
540 None,
541 Bearer(PrAuthToken),
543 Basic {
545 username: String,
547 token: PrAuthToken,
549 },
550}
551
552impl fmt::Debug for PrHttpAuthentication {
553 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
554 match self {
555 Self::None => formatter.write_str("PrHttpAuthentication::None"),
556 Self::Bearer(_) => formatter.write_str("PrHttpAuthentication::Bearer([REDACTED])"),
557 Self::Basic { .. } => formatter.write_str("PrHttpAuthentication::Basic([REDACTED])"),
558 }
559 }
560}
561
562#[derive(Clone)]
564pub struct PrHttpRequest {
565 pub method: PrHttpMethod,
567 pub url: String,
569 pub headers: BTreeMap<String, String>,
571 pub authentication: PrHttpAuthentication,
573 pub max_response_bytes: usize,
575 pub timeout: Duration,
577 pub cancellation: CancellationToken,
579}
580
581impl fmt::Debug for PrHttpRequest {
582 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
583 formatter
584 .debug_struct("PrHttpRequest")
585 .field("method", &self.method)
586 .field("url", &self.url)
587 .field("headers", &self.headers)
588 .field("authentication", &self.authentication)
589 .field("max_response_bytes", &self.max_response_bytes)
590 .field("timeout", &self.timeout)
591 .finish_non_exhaustive()
592 }
593}
594
595#[derive(Clone, PartialEq, Eq)]
597pub struct PrHttpResponse {
598 pub status: u16,
600 pub headers: BTreeMap<String, String>,
602 pub body: Vec<u8>,
604}
605
606impl fmt::Debug for PrHttpResponse {
607 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
608 formatter
609 .debug_struct("PrHttpResponse")
610 .field("status", &self.status)
611 .field("headers", &self.headers)
612 .field("body_bytes", &self.body.len())
613 .finish()
614 }
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
619#[error("pull-request transport failed: {message}")]
620pub struct PrHttpTransportError {
621 pub message: String,
623}
624
625#[async_trait]
627pub trait PrHttpTransport: Send + Sync {
628 async fn send(&self, request: PrHttpRequest) -> Result<PrHttpResponse, PrHttpTransportError>;
634}
635
636#[derive(Debug, Clone)]
638pub struct ReqwestPrHttpTransport {
639 client: reqwest::Client,
640}
641
642impl ReqwestPrHttpTransport {
643 pub fn new() -> Result<Self, PrHttpTransportError> {
649 reqwest::Client::builder()
650 .redirect(reqwest::redirect::Policy::none())
651 .user_agent(concat!("code-system-graph/", env!("CARGO_PKG_VERSION")))
652 .build()
653 .map(|client| Self { client })
654 .map_err(|error| PrHttpTransportError {
655 message: error.to_string(),
656 })
657 }
658}
659
660#[async_trait]
661impl PrHttpTransport for ReqwestPrHttpTransport {
662 async fn send(&self, request: PrHttpRequest) -> Result<PrHttpResponse, PrHttpTransportError> {
663 if request.cancellation.is_cancelled() {
664 return Err(PrHttpTransportError {
665 message: "request was cancelled".to_owned(),
666 });
667 }
668 let mut builder = match request.method {
669 PrHttpMethod::Get => self.client.get(&request.url),
670 }
671 .timeout(request.timeout);
672 for (name, value) in &request.headers {
673 builder = builder.header(name, value);
674 }
675 match &request.authentication {
676 PrHttpAuthentication::None => {}
677 PrHttpAuthentication::Bearer(token) => {
678 if !token.is_empty() {
679 builder = builder.bearer_auth(token.expose_secret());
680 }
681 }
682 PrHttpAuthentication::Basic { username, token } => {
683 builder = builder.basic_auth(username, Some(token.expose_secret()));
684 }
685 }
686 let mut response = tokio::select! {
687 () = request.cancellation.cancelled() => {
688 return Err(PrHttpTransportError {
689 message: "request was cancelled".to_owned(),
690 });
691 }
692 result = builder.send() => result.map_err(|error| PrHttpTransportError {
693 message: error.to_string(),
694 })?,
695 };
696 if response
697 .content_length()
698 .is_some_and(|length| length > request.max_response_bytes as u64)
699 {
700 return Err(PrHttpTransportError {
701 message: "response exceeded the configured output limit".to_owned(),
702 });
703 }
704 let status = response.status().as_u16();
705 let headers = response
706 .headers()
707 .iter()
708 .filter_map(|(name, value)| {
709 value
710 .to_str()
711 .ok()
712 .map(|value| (name.as_str().to_owned(), value.to_owned()))
713 })
714 .collect();
715 let mut body = Vec::new();
716 loop {
717 let chunk = tokio::select! {
718 () = request.cancellation.cancelled() => {
719 return Err(PrHttpTransportError {
720 message: "request was cancelled".to_owned(),
721 });
722 }
723 result = response.chunk() => result.map_err(|error| PrHttpTransportError {
724 message: error.to_string(),
725 })?,
726 };
727 let Some(chunk) = chunk else {
728 break;
729 };
730 if body.len().saturating_add(chunk.len()) > request.max_response_bytes {
731 return Err(PrHttpTransportError {
732 message: "response exceeded the configured output limit".to_owned(),
733 });
734 }
735 body.extend_from_slice(&chunk);
736 }
737 Ok(PrHttpResponse {
738 status,
739 headers,
740 body,
741 })
742 }
743}
744
745#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
747pub enum PullRequestError {
748 #[error("pull-request provider is disabled")]
750 Disabled,
751 #[error("remote pull-request access requires explicit per-request consent")]
753 ConsentRequired,
754 #[error("invalid pull-request provider configuration: {0}")]
756 InvalidConfiguration(String),
757 #[error("pull-request coordinates do not match the configured provider")]
759 ProviderMismatch,
760 #[error("pull-request provider is not supported")]
762 UnsupportedProvider,
763 #[error("pull-request inspection was cancelled")]
765 Cancelled,
766 #[error("pull-request provider request timed out")]
768 Timeout,
769 #[error("pull-request provider response exceeded the configured output limit")]
771 OutputLimitExceeded,
772 #[error("pull-request provider rate limited the request")]
774 RateLimited {
775 status: u16,
777 metadata: PullRequestRateLimit,
779 },
780 #[error("pull-request provider returned HTTP {status}: {message}")]
782 Api {
783 status: u16,
785 message: String,
787 },
788 #[error("malformed pull-request provider response: {0}")]
790 MalformedResponse(String),
791 #[error("pull-request transport failed: {0}")]
793 Transport(String),
794 #[error("pull-request cache is unavailable")]
796 CacheUnavailable,
797}
798
799#[async_trait]
801pub trait PullRequestProvider: Send + Sync {
802 fn kind(&self) -> PullRequestProviderKind;
804
805 async fn inspect(
811 &self,
812 request: PullRequestInspectRequest,
813 ) -> Result<PullRequestInspection, PullRequestError>;
814
815 async fn list(
822 &self,
823 request: PullRequestListRequest,
824 ) -> Result<PullRequestListPage, PullRequestError>;
825}
826
827#[derive(Clone)]
828struct CacheEntry {
829 inspection: PullRequestInspection,
830 etag: Option<String>,
831 expires_at: Instant,
832}
833
834struct ProviderCore {
835 config: PullRequestProviderConfig,
836 transport: Arc<dyn PrHttpTransport>,
837 cache: Mutex<BTreeMap<PullRequestCoordinatesKey, CacheEntry>>,
838}
839
840#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
841struct PullRequestCoordinatesKey {
842 owner: String,
843 repository: String,
844 number: u64,
845}
846
847impl From<&PullRequestCoordinates> for PullRequestCoordinatesKey {
848 fn from(value: &PullRequestCoordinates) -> Self {
849 Self {
850 owner: value.owner.clone(),
851 repository: value.repository.clone(),
852 number: value.number,
853 }
854 }
855}
856
857impl ProviderCore {
858 fn new(
859 config: PullRequestProviderConfig,
860 expected: PullRequestProviderKind,
861 transport: Arc<dyn PrHttpTransport>,
862 ) -> Result<Self, PullRequestError> {
863 config.validate()?;
864 if config.provider != expected {
865 return Err(PullRequestError::ProviderMismatch);
866 }
867 Ok(Self {
868 config,
869 transport,
870 cache: Mutex::new(BTreeMap::new()),
871 })
872 }
873
874 fn authorize(
875 &self,
876 request: &PullRequestInspectRequest,
877 expected: PullRequestProviderKind,
878 ) -> Result<(), PullRequestError> {
879 if !self.config.enabled {
880 return Err(PullRequestError::Disabled);
881 }
882 if !request.consent_to_remote_access {
883 return Err(PullRequestError::ConsentRequired);
884 }
885 if request.coordinates.provider != expected {
886 return Err(PullRequestError::ProviderMismatch);
887 }
888 if request.cancellation.is_cancelled() {
889 return Err(PullRequestError::Cancelled);
890 }
891 Ok(())
892 }
893
894 fn authorize_list(
895 &self,
896 request: &PullRequestListRequest,
897 expected: PullRequestProviderKind,
898 ) -> Result<(), PullRequestError> {
899 if !self.config.enabled {
900 return Err(PullRequestError::Disabled);
901 }
902 if !request.consent_to_remote_access {
903 return Err(PullRequestError::ConsentRequired);
904 }
905 if request.provider == PullRequestProviderKind::BitbucketDataCenter {
906 return Err(PullRequestError::UnsupportedProvider);
907 }
908 if request.provider != expected {
909 return Err(PullRequestError::ProviderMismatch);
910 }
911 if request.cancellation.is_cancelled() {
912 return Err(PullRequestError::Cancelled);
913 }
914 validate_list_request(request)
915 }
916
917 fn authentication(&self) -> PrHttpAuthentication {
918 if self.config.auth_token.is_empty() {
919 PrHttpAuthentication::None
920 } else if let Some(username) = &self.config.basic_auth_username {
921 PrHttpAuthentication::Basic {
922 username: username.clone(),
923 token: self.config.auth_token.clone(),
924 }
925 } else {
926 PrHttpAuthentication::Bearer(self.config.auth_token.clone())
927 }
928 }
929
930 fn fresh_cache(
931 &self,
932 coordinates: &PullRequestCoordinates,
933 ) -> Result<Option<PullRequestInspection>, PullRequestError> {
934 let cache = self
935 .cache
936 .lock()
937 .map_err(|_| PullRequestError::CacheUnavailable)?;
938 Ok(cache
939 .get(&PullRequestCoordinatesKey::from(coordinates))
940 .filter(|entry| Instant::now() < entry.expires_at)
941 .map(|entry| {
942 let mut inspection = entry.inspection.clone();
943 inspection.from_cache = true;
944 inspection
945 }))
946 }
947
948 fn stale_cache(
949 &self,
950 coordinates: &PullRequestCoordinates,
951 ) -> Result<Option<CacheEntry>, PullRequestError> {
952 self.cache
953 .lock()
954 .map_err(|_| PullRequestError::CacheUnavailable)
955 .map(|cache| {
956 cache
957 .get(&PullRequestCoordinatesKey::from(coordinates))
958 .cloned()
959 })
960 }
961
962 fn store_cache(
963 &self,
964 coordinates: &PullRequestCoordinates,
965 inspection: &PullRequestInspection,
966 etag: Option<String>,
967 ) -> Result<(), PullRequestError> {
968 let mut cached = inspection.clone();
969 cached.from_cache = false;
970 let entry = CacheEntry {
971 inspection: cached,
972 etag,
973 expires_at: Instant::now() + Duration::from_secs(self.config.cache_ttl_seconds),
974 };
975 self.cache
976 .lock()
977 .map_err(|_| PullRequestError::CacheUnavailable)?
978 .insert(PullRequestCoordinatesKey::from(coordinates), entry);
979 Ok(())
980 }
981
982 async fn get_json(
983 &self,
984 url: Url,
985 etag: Option<&str>,
986 cancellation: &CancellationToken,
987 budget: &mut ResponseBudget,
988 ) -> Result<FetchJson, PullRequestError> {
989 let response = self
990 .send_json_request(url, etag, cancellation, budget)
991 .await?;
992 if response.status == 304 {
993 return Ok(FetchJson::NotModified);
994 }
995 let rate_limited = response.status == 429
996 || (response.status == 403
997 && (budget.rate_limit.remaining == Some(0)
998 || budget.rate_limit.retry_after_seconds.is_some()));
999 if rate_limited {
1000 return Err(PullRequestError::RateLimited {
1001 status: response.status,
1002 metadata: budget.rate_limit.clone(),
1003 });
1004 }
1005 if !(200..300).contains(&response.status) {
1006 let message = api_error_message(&response.body, &self.config.auth_token);
1007 return Err(PullRequestError::Api {
1008 status: response.status,
1009 message,
1010 });
1011 }
1012 let value = serde_json::from_slice(&response.body)
1013 .map_err(|error| PullRequestError::MalformedResponse(error.to_string()))?;
1014 Ok(FetchJson::Value {
1015 value,
1016 etag: header(&response.headers, "etag").map(str::to_owned),
1017 headers: response.headers,
1018 })
1019 }
1020
1021 async fn send_json_request(
1022 &self,
1023 mut url: Url,
1024 etag: Option<&str>,
1025 cancellation: &CancellationToken,
1026 budget: &mut ResponseBudget,
1027 ) -> Result<PrHttpResponse, PullRequestError> {
1028 let mut redirects = 0;
1029 loop {
1030 if cancellation.is_cancelled() {
1031 return Err(PullRequestError::Cancelled);
1032 }
1033 let remaining = self
1034 .config
1035 .max_output_bytes
1036 .saturating_sub(budget.output_bytes);
1037 if remaining == 0 {
1038 return Err(PullRequestError::OutputLimitExceeded);
1039 }
1040 let mut headers = BTreeMap::from([
1041 ("accept".to_owned(), "application/json".to_owned()),
1042 ("user-agent".to_owned(), "code-system-graph".to_owned()),
1043 ]);
1044 if let Some(etag) = etag {
1045 headers.insert("if-none-match".to_owned(), etag.to_owned());
1046 }
1047 let transport_request = PrHttpRequest {
1048 method: PrHttpMethod::Get,
1049 url: url.to_string(),
1050 headers,
1051 authentication: self.authentication(),
1052 max_response_bytes: remaining,
1053 timeout: Duration::from_millis(self.config.request_timeout_ms),
1054 cancellation: cancellation.clone(),
1055 };
1056 let timeout = tokio::time::sleep(Duration::from_millis(self.config.request_timeout_ms));
1057 tokio::pin!(timeout);
1058 let response = tokio::select! {
1059 () = cancellation.cancelled() => return Err(PullRequestError::Cancelled),
1060 () = &mut timeout => return Err(PullRequestError::Timeout),
1061 result = self.transport.send(transport_request) => {
1062 result.map_err(|error| {
1063 PullRequestError::Transport(redact_message(
1064 &error.message,
1065 &self.config.auth_token,
1066 ))
1067 })?
1068 }
1069 };
1070 if response.body.len() > remaining {
1071 return Err(PullRequestError::OutputLimitExceeded);
1072 }
1073 budget.output_bytes += response.body.len();
1074 budget.rate_limit = rate_limit_from_headers(&response.headers);
1075 if matches!(response.status, 301 | 302 | 303 | 307 | 308) {
1076 if redirects >= MAX_SAME_ORIGIN_REDIRECTS {
1077 return Err(PullRequestError::Api {
1078 status: response.status,
1079 message: "provider redirect limit exceeded".to_owned(),
1080 });
1081 }
1082 let location = header(&response.headers, "location").ok_or_else(|| {
1083 PullRequestError::MalformedResponse(
1084 "provider redirect omitted the location header".to_owned(),
1085 )
1086 })?;
1087 let redirected = url.join(location).map_err(|_| {
1088 PullRequestError::MalformedResponse(
1089 "provider returned an invalid redirect URL".to_owned(),
1090 )
1091 })?;
1092 if redirected.origin() != url.origin() {
1093 return Err(PullRequestError::Api {
1094 status: response.status,
1095 message: "provider redirect crossed the configured origin".to_owned(),
1096 });
1097 }
1098 url = redirected;
1099 redirects += 1;
1100 continue;
1101 }
1102 return Ok(response);
1103 }
1104 }
1105
1106 fn url(&self, segments: &[&str]) -> Result<Url, PullRequestError> {
1107 let mut url = parse_base_url(&self.config.api_base_url)?;
1108 {
1109 let mut path = url.path_segments_mut().map_err(|()| {
1110 PullRequestError::InvalidConfiguration(
1111 "provider API base URL cannot be a base".to_owned(),
1112 )
1113 })?;
1114 path.pop_if_empty();
1115 path.extend(segments);
1116 }
1117 Ok(url)
1118 }
1119}
1120
1121#[derive(Default)]
1122struct ResponseBudget {
1123 output_bytes: usize,
1124 rate_limit: PullRequestRateLimit,
1125}
1126
1127enum FetchJson {
1128 NotModified,
1129 Value {
1130 value: Value,
1131 etag: Option<String>,
1132 headers: BTreeMap<String, String>,
1133 },
1134}
1135
1136pub struct GitHubProvider {
1138 core: ProviderCore,
1139}
1140
1141impl GitHubProvider {
1142 pub fn new(
1148 config: PullRequestProviderConfig,
1149 transport: Arc<dyn PrHttpTransport>,
1150 ) -> Result<Self, PullRequestError> {
1151 ProviderCore::new(config, PullRequestProviderKind::GitHub, transport)
1152 .map(|core| Self { core })
1153 }
1154}
1155
1156#[async_trait]
1157impl PullRequestProvider for GitHubProvider {
1158 fn kind(&self) -> PullRequestProviderKind {
1159 PullRequestProviderKind::GitHub
1160 }
1161
1162 async fn inspect(
1163 &self,
1164 request: PullRequestInspectRequest,
1165 ) -> Result<PullRequestInspection, PullRequestError> {
1166 self.core
1167 .authorize(&request, PullRequestProviderKind::GitHub)?;
1168 if let Some(cached) = self.core.fresh_cache(&request.coordinates)? {
1169 return Ok(cached);
1170 }
1171 let stale = self.core.stale_cache(&request.coordinates)?;
1172 let mut budget = ResponseBudget::default();
1173 let number = request.coordinates.number.to_string();
1174 let metadata_url = self.core.url(&[
1175 "repos",
1176 &request.coordinates.owner,
1177 &request.coordinates.repository,
1178 "pulls",
1179 &number,
1180 ])?;
1181 let metadata_fetch = self
1182 .core
1183 .get_json(
1184 metadata_url,
1185 stale.as_ref().and_then(|entry| entry.etag.as_deref()),
1186 &request.cancellation,
1187 &mut budget,
1188 )
1189 .await?;
1190 let (metadata_json, etag) = match metadata_fetch {
1191 FetchJson::NotModified => {
1192 let mut cached = stale
1193 .ok_or_else(|| {
1194 PullRequestError::MalformedResponse(
1195 "provider returned 304 without a cached representation".to_owned(),
1196 )
1197 })?
1198 .inspection;
1199 cached.from_cache = true;
1200 self.core.store_cache(
1201 &request.coordinates,
1202 &cached,
1203 self.core
1204 .stale_cache(&request.coordinates)?
1205 .and_then(|entry| entry.etag),
1206 )?;
1207 return Ok(cached);
1208 }
1209 FetchJson::Value { value, etag, .. } => (value, etag),
1210 };
1211 let metadata = parse_github_metadata(&metadata_json)?;
1212 let mut warnings = Vec::new();
1213 let files = self
1214 .github_files(&request, &number, &mut budget, &mut warnings)
1215 .await?;
1216 let checks = self
1217 .github_checks(&request, &metadata.head.sha, &mut budget, &mut warnings)
1218 .await?;
1219 let reviews = self
1220 .github_reviews(&request, &number, &mut budget, &mut warnings)
1221 .await?;
1222 let fingerprint = fingerprint(&metadata, &files)?;
1223 let inspection = PullRequestInspection {
1224 coordinates: request.coordinates.clone(),
1225 metadata,
1226 changed_files: files,
1227 ci: summarize_checks(checks),
1228 review: summarize_github_reviews(reviews),
1229 rate_limit: budget.rate_limit,
1230 warnings,
1231 fingerprint,
1232 from_cache: false,
1233 };
1234 self.core
1235 .store_cache(&request.coordinates, &inspection, etag)?;
1236 Ok(inspection)
1237 }
1238
1239 async fn list(
1240 &self,
1241 request: PullRequestListRequest,
1242 ) -> Result<PullRequestListPage, PullRequestError> {
1243 self.core
1244 .authorize_list(&request, PullRequestProviderKind::GitHub)?;
1245 let page_limit = request
1246 .limit
1247 .min(self.core.config.max_items)
1248 .min(MAX_PULL_REQUEST_LIST_LIMIT);
1249 let mut url = self
1250 .core
1251 .url(&["repos", &request.owner, &request.repository, "pulls"])?;
1252 let expected_path = url.path().to_owned();
1253 let page_limit_text = page_limit.to_string();
1254 {
1255 let mut query = url.query_pairs_mut();
1256 query
1257 .append_pair("state", github_list_state(request.state))
1258 .append_pair("per_page", &page_limit_text)
1259 .append_pair("sort", "created")
1260 .append_pair("direction", "asc");
1261 if let Some(cursor) = &request.cursor {
1262 query.append_pair("page", cursor);
1263 }
1264 }
1265 let mut budget = ResponseBudget::default();
1266 let fetch = self
1267 .core
1268 .get_json(url, None, &request.cancellation, &mut budget)
1269 .await?;
1270 let FetchJson::Value { value, headers, .. } = fetch else {
1271 return Err(PullRequestError::MalformedResponse(
1272 "unexpected 304 for uncached pull-request list".to_owned(),
1273 ));
1274 };
1275 let values = required_array(&value, "pull requests")?;
1276 if values.len() > page_limit {
1277 return Err(PullRequestError::MalformedResponse(
1278 "GitHub returned more pull requests than the requested page size".to_owned(),
1279 ));
1280 }
1281 let mut items = values
1282 .iter()
1283 .map(|value| parse_github_summary(value, &request.owner, &request.repository))
1284 .collect::<Result<Vec<_>, _>>()?;
1285 items.sort_by_key(|summary| summary.coordinates.number);
1286 let base = parse_base_url(&self.core.config.api_base_url)?;
1287 let next_cursor = github_next_url(&headers, &base)?
1288 .map(|next| pagination_cursor(&next, &base, &expected_path, "per_page"))
1289 .transpose()?;
1290 Ok(list_page(
1291 items,
1292 next_cursor,
1293 request.limit,
1294 page_limit,
1295 budget.rate_limit,
1296 ))
1297 }
1298}
1299
1300impl GitHubProvider {
1301 async fn github_files(
1302 &self,
1303 request: &PullRequestInspectRequest,
1304 number: &str,
1305 budget: &mut ResponseBudget,
1306 warnings: &mut Vec<PullRequestWarning>,
1307 ) -> Result<Vec<PullRequestChangedFile>, PullRequestError> {
1308 let initial = self.core.url(&[
1309 "repos",
1310 &request.coordinates.owner,
1311 &request.coordinates.repository,
1312 "pulls",
1313 number,
1314 "files",
1315 ])?;
1316 self.github_paginated(initial, &request.cancellation, budget, warnings, |value| {
1317 parse_github_file(value)
1318 })
1319 .await
1320 }
1321
1322 async fn github_checks(
1323 &self,
1324 request: &PullRequestInspectRequest,
1325 sha: &str,
1326 budget: &mut ResponseBudget,
1327 warnings: &mut Vec<PullRequestWarning>,
1328 ) -> Result<Vec<PullRequestCheck>, PullRequestError> {
1329 let initial = self.core.url(&[
1330 "repos",
1331 &request.coordinates.owner,
1332 &request.coordinates.repository,
1333 "commits",
1334 sha,
1335 "check-runs",
1336 ])?;
1337 self.github_paginated_key(
1338 initial,
1339 "check_runs",
1340 &request.cancellation,
1341 budget,
1342 warnings,
1343 parse_github_check,
1344 )
1345 .await
1346 }
1347
1348 async fn github_reviews(
1349 &self,
1350 request: &PullRequestInspectRequest,
1351 number: &str,
1352 budget: &mut ResponseBudget,
1353 warnings: &mut Vec<PullRequestWarning>,
1354 ) -> Result<Vec<PullRequestReview>, PullRequestError> {
1355 let initial = self.core.url(&[
1356 "repos",
1357 &request.coordinates.owner,
1358 &request.coordinates.repository,
1359 "pulls",
1360 number,
1361 "reviews",
1362 ])?;
1363 self.github_paginated(initial, &request.cancellation, budget, warnings, |value| {
1364 parse_github_review(value)
1365 })
1366 .await
1367 }
1368
1369 async fn github_paginated<T, F>(
1370 &self,
1371 initial: Url,
1372 cancellation: &CancellationToken,
1373 budget: &mut ResponseBudget,
1374 warnings: &mut Vec<PullRequestWarning>,
1375 parse: F,
1376 ) -> Result<Vec<T>, PullRequestError>
1377 where
1378 F: Fn(&Value) -> Result<T, PullRequestError>,
1379 {
1380 self.github_paginated_impl(initial, None, cancellation, budget, warnings, parse)
1381 .await
1382 }
1383
1384 async fn github_paginated_key<T, F>(
1385 &self,
1386 initial: Url,
1387 key: &str,
1388 cancellation: &CancellationToken,
1389 budget: &mut ResponseBudget,
1390 warnings: &mut Vec<PullRequestWarning>,
1391 parse: F,
1392 ) -> Result<Vec<T>, PullRequestError>
1393 where
1394 F: Fn(&Value) -> Result<T, PullRequestError>,
1395 {
1396 self.github_paginated_impl(initial, Some(key), cancellation, budget, warnings, parse)
1397 .await
1398 }
1399
1400 async fn github_paginated_impl<T, F>(
1401 &self,
1402 initial: Url,
1403 key: Option<&str>,
1404 cancellation: &CancellationToken,
1405 budget: &mut ResponseBudget,
1406 warnings: &mut Vec<PullRequestWarning>,
1407 parse: F,
1408 ) -> Result<Vec<T>, PullRequestError>
1409 where
1410 F: Fn(&Value) -> Result<T, PullRequestError>,
1411 {
1412 let mut output = Vec::new();
1413 let mut next = Some(initial);
1414 let mut pages = 0;
1415 while let Some(url) = next {
1416 if pages >= self.core.config.max_pages || output.len() >= self.core.config.max_items {
1417 warnings.push(limit_warning("pagination"));
1418 break;
1419 }
1420 pages += 1;
1421 let response = self.core.get_json(url, None, cancellation, budget).await?;
1422 let FetchJson::Value { value, headers, .. } = response else {
1423 return Err(PullRequestError::MalformedResponse(
1424 "unexpected 304 for uncached page".to_owned(),
1425 ));
1426 };
1427 let values = match key {
1428 Some(key) => required_array(required_field(&value, key)?, key)?,
1429 None => required_array(&value, "page")?,
1430 };
1431 for item in values {
1432 if output.len() >= self.core.config.max_items {
1433 warnings.push(limit_warning("items"));
1434 break;
1435 }
1436 output.push(parse(item)?);
1437 }
1438 next = github_next_url(&headers, &parse_base_url(&self.core.config.api_base_url)?)?;
1439 }
1440 Ok(output)
1441 }
1442}
1443
1444pub struct BitbucketProvider {
1446 core: ProviderCore,
1447}
1448
1449impl BitbucketProvider {
1450 pub fn new(
1456 config: PullRequestProviderConfig,
1457 transport: Arc<dyn PrHttpTransport>,
1458 ) -> Result<Self, PullRequestError> {
1459 ProviderCore::new(config, PullRequestProviderKind::BitbucketCloud, transport)
1460 .map(|core| Self { core })
1461 }
1462}
1463
1464#[async_trait]
1465impl PullRequestProvider for BitbucketProvider {
1466 fn kind(&self) -> PullRequestProviderKind {
1467 PullRequestProviderKind::BitbucketCloud
1468 }
1469
1470 async fn inspect(
1471 &self,
1472 request: PullRequestInspectRequest,
1473 ) -> Result<PullRequestInspection, PullRequestError> {
1474 self.core
1475 .authorize(&request, PullRequestProviderKind::BitbucketCloud)?;
1476 if let Some(cached) = self.core.fresh_cache(&request.coordinates)? {
1477 return Ok(cached);
1478 }
1479 let stale = self.core.stale_cache(&request.coordinates)?;
1480 let mut budget = ResponseBudget::default();
1481 let number = request.coordinates.number.to_string();
1482 let metadata_url = self.core.url(&[
1483 "repositories",
1484 &request.coordinates.owner,
1485 &request.coordinates.repository,
1486 "pullrequests",
1487 &number,
1488 ])?;
1489 let metadata_fetch = self
1490 .core
1491 .get_json(
1492 metadata_url,
1493 stale.as_ref().and_then(|entry| entry.etag.as_deref()),
1494 &request.cancellation,
1495 &mut budget,
1496 )
1497 .await?;
1498 let (metadata_json, etag) = match metadata_fetch {
1499 FetchJson::NotModified => {
1500 let entry = stale.ok_or_else(|| {
1501 PullRequestError::MalformedResponse(
1502 "provider returned 304 without a cached representation".to_owned(),
1503 )
1504 })?;
1505 let mut cached = entry.inspection;
1506 cached.from_cache = true;
1507 self.core
1508 .store_cache(&request.coordinates, &cached, entry.etag)?;
1509 return Ok(cached);
1510 }
1511 FetchJson::Value { value, etag, .. } => (value, etag),
1512 };
1513 let metadata = parse_bitbucket_metadata(&metadata_json)?;
1514 let mut warnings = Vec::new();
1515 let files_url = self.core.url(&[
1516 "repositories",
1517 &request.coordinates.owner,
1518 &request.coordinates.repository,
1519 "pullrequests",
1520 &number,
1521 "diffstat",
1522 ])?;
1523 let files = self
1524 .bitbucket_optional_paginated(
1525 files_url,
1526 &request.cancellation,
1527 &mut budget,
1528 &mut warnings,
1529 "changed files",
1530 parse_bitbucket_file,
1531 )
1532 .await?;
1533 let checks_url = self.core.url(&[
1534 "repositories",
1535 &request.coordinates.owner,
1536 &request.coordinates.repository,
1537 "commit",
1538 &metadata.head.sha,
1539 "statuses",
1540 ])?;
1541 let checks = self
1542 .bitbucket_optional_paginated(
1543 checks_url,
1544 &request.cancellation,
1545 &mut budget,
1546 &mut warnings,
1547 "commit statuses",
1548 parse_bitbucket_check,
1549 )
1550 .await?;
1551 let review = parse_bitbucket_reviews(&metadata_json, self.core.config.max_items)?;
1552 let fingerprint = fingerprint(&metadata, &files)?;
1553 let inspection = PullRequestInspection {
1554 coordinates: request.coordinates.clone(),
1555 metadata,
1556 changed_files: files,
1557 ci: summarize_checks(checks),
1558 review,
1559 rate_limit: budget.rate_limit,
1560 warnings,
1561 fingerprint,
1562 from_cache: false,
1563 };
1564 self.core
1565 .store_cache(&request.coordinates, &inspection, etag)?;
1566 Ok(inspection)
1567 }
1568
1569 async fn list(
1570 &self,
1571 request: PullRequestListRequest,
1572 ) -> Result<PullRequestListPage, PullRequestError> {
1573 self.core
1574 .authorize_list(&request, PullRequestProviderKind::BitbucketCloud)?;
1575 let page_limit = request
1576 .limit
1577 .min(self.core.config.max_items)
1578 .min(MAX_PULL_REQUEST_LIST_LIMIT);
1579 let mut url = self.core.url(&[
1580 "repositories",
1581 &request.owner,
1582 &request.repository,
1583 "pullrequests",
1584 ])?;
1585 let expected_path = url.path().to_owned();
1586 let page_limit_text = page_limit.to_string();
1587 let page = request
1588 .cursor
1589 .as_deref()
1590 .map(parse_list_cursor)
1591 .transpose()?;
1592 let page_text = page.map(|page| page.to_string());
1593 {
1594 let mut query = url.query_pairs_mut();
1595 query
1596 .append_pair("pagelen", &page_limit_text)
1597 .append_pair("sort", "created_on");
1598 match request.state {
1599 PullRequestListState::Open => {
1600 query.append_pair("state", "OPEN");
1601 }
1602 PullRequestListState::Closed => {
1603 query
1604 .append_pair("state", "MERGED")
1605 .append_pair("state", "FULFILLED")
1606 .append_pair("state", "DECLINED")
1607 .append_pair("state", "SUPERSEDED");
1608 }
1609 PullRequestListState::All => {}
1610 }
1611 if let Some(page) = &page_text {
1612 query.append_pair("page", page);
1613 }
1614 }
1615 let mut budget = ResponseBudget::default();
1616 let fetch = self
1617 .core
1618 .get_json(url, None, &request.cancellation, &mut budget)
1619 .await?;
1620 let FetchJson::Value { value, .. } = fetch else {
1621 return Err(PullRequestError::MalformedResponse(
1622 "unexpected 304 for uncached pull-request list".to_owned(),
1623 ));
1624 };
1625 let values = required_array(required_field(&value, "values")?, "values")?;
1626 if values.len() > page_limit {
1627 return Err(PullRequestError::MalformedResponse(
1628 "Bitbucket returned more pull requests than the requested page size".to_owned(),
1629 ));
1630 }
1631 let mut items = values
1632 .iter()
1633 .map(|value| parse_bitbucket_summary(value, &request.owner, &request.repository))
1634 .collect::<Result<Vec<_>, _>>()?;
1635 items.sort_by_key(|summary| summary.coordinates.number);
1636 let base = parse_base_url(&self.core.config.api_base_url)?;
1637 let next_cursor = optional_string(&value, "next")
1638 .map(Url::parse)
1639 .transpose()
1640 .map_err(|_| {
1641 PullRequestError::MalformedResponse(
1642 "Bitbucket returned an invalid pagination URL".to_owned(),
1643 )
1644 })?
1645 .map(|next| pagination_cursor(&next, &base, &expected_path, "pagelen"))
1646 .transpose()?;
1647 Ok(list_page(
1648 items,
1649 next_cursor,
1650 request.limit,
1651 page_limit,
1652 budget.rate_limit,
1653 ))
1654 }
1655}
1656
1657impl BitbucketProvider {
1658 async fn bitbucket_optional_paginated<T, F>(
1659 &self,
1660 initial: Url,
1661 cancellation: &CancellationToken,
1662 budget: &mut ResponseBudget,
1663 warnings: &mut Vec<PullRequestWarning>,
1664 subject: &str,
1665 parse: F,
1666 ) -> Result<Vec<T>, PullRequestError>
1667 where
1668 F: Fn(&Value) -> Result<T, PullRequestError>,
1669 {
1670 match self
1671 .bitbucket_paginated(initial, cancellation, budget, warnings, parse)
1672 .await
1673 {
1674 Ok(items) => Ok(items),
1675 Err(PullRequestError::Api {
1676 status: 401 | 403, ..
1677 }) => {
1678 warnings.push(provider_scope_warning(subject));
1679 Ok(Vec::new())
1680 }
1681 Err(error) => Err(error),
1682 }
1683 }
1684
1685 async fn bitbucket_paginated<T, F>(
1686 &self,
1687 initial: Url,
1688 cancellation: &CancellationToken,
1689 budget: &mut ResponseBudget,
1690 warnings: &mut Vec<PullRequestWarning>,
1691 parse: F,
1692 ) -> Result<Vec<T>, PullRequestError>
1693 where
1694 F: Fn(&Value) -> Result<T, PullRequestError>,
1695 {
1696 let mut output = Vec::new();
1697 let mut next = Some(initial);
1698 let mut pages = 0;
1699 while let Some(url) = next {
1700 if pages >= self.core.config.max_pages || output.len() >= self.core.config.max_items {
1701 warnings.push(limit_warning("pagination"));
1702 break;
1703 }
1704 pages += 1;
1705 let response = self.core.get_json(url, None, cancellation, budget).await?;
1706 let FetchJson::Value { value, .. } = response else {
1707 return Err(PullRequestError::MalformedResponse(
1708 "unexpected 304 for uncached page".to_owned(),
1709 ));
1710 };
1711 for item in required_array(required_field(&value, "values")?, "values")? {
1712 if output.len() >= self.core.config.max_items {
1713 warnings.push(limit_warning("items"));
1714 break;
1715 }
1716 output.push(parse(item)?);
1717 }
1718 next = optional_string(&value, "next")
1719 .map(Url::parse)
1720 .transpose()
1721 .map_err(|_| {
1722 PullRequestError::MalformedResponse(
1723 "Bitbucket returned an invalid pagination URL".to_owned(),
1724 )
1725 })?;
1726 if let Some(candidate) = &next {
1727 validate_pagination_origin(
1728 &parse_base_url(&self.core.config.api_base_url)?,
1729 candidate,
1730 )?;
1731 }
1732 }
1733 Ok(output)
1734 }
1735}
1736
1737fn github_next_url(
1738 headers: &BTreeMap<String, String>,
1739 base: &Url,
1740) -> Result<Option<Url>, PullRequestError> {
1741 let Some(link) = header(headers, "link") else {
1742 return Ok(None);
1743 };
1744 for part in link.split(',') {
1745 let mut sections = part.trim().split(';');
1746 let Some(target) = sections.next() else {
1747 continue;
1748 };
1749 let is_next = sections.any(|section| section.trim() == r#"rel="next""#);
1750 if !is_next {
1751 continue;
1752 }
1753 let raw = target.trim().trim_start_matches('<').trim_end_matches('>');
1754 let candidate = Url::parse(raw).map_err(|_| {
1755 PullRequestError::MalformedResponse(
1756 "GitHub returned an invalid pagination URL".to_owned(),
1757 )
1758 })?;
1759 validate_pagination_origin(base, &candidate)?;
1760 return Ok(Some(candidate));
1761 }
1762 Ok(None)
1763}
1764
1765fn validate_list_request(request: &PullRequestListRequest) -> Result<(), PullRequestError> {
1766 if !(1..=MAX_PULL_REQUEST_LIST_LIMIT).contains(&request.limit) {
1767 return Err(PullRequestError::InvalidConfiguration(
1768 "pull-request list limit must be between 1 and 100".to_owned(),
1769 ));
1770 }
1771 for (name, value) in [
1772 ("owner", request.owner.as_str()),
1773 ("repository", request.repository.as_str()),
1774 ] {
1775 if value.trim().is_empty()
1776 || matches!(value, "." | "..")
1777 || value.chars().any(char::is_control)
1778 {
1779 return Err(PullRequestError::InvalidConfiguration(format!(
1780 "pull-request list {name} must be non-empty and control-free"
1781 )));
1782 }
1783 }
1784 if let Some(cursor) = request.cursor.as_deref() {
1785 parse_list_cursor(cursor)?;
1786 }
1787 Ok(())
1788}
1789
1790fn parse_list_cursor(cursor: &str) -> Result<u64, PullRequestError> {
1791 cursor
1792 .parse::<u64>()
1793 .ok()
1794 .filter(|page| *page > 0)
1795 .ok_or_else(|| {
1796 PullRequestError::InvalidConfiguration(
1797 "pull-request list cursor must be a positive page number".to_owned(),
1798 )
1799 })
1800}
1801
1802fn github_list_state(state: PullRequestListState) -> &'static str {
1803 match state {
1804 PullRequestListState::Open => "open",
1805 PullRequestListState::Closed => "closed",
1806 PullRequestListState::All => "all",
1807 }
1808}
1809
1810fn pagination_cursor(
1811 candidate: &Url,
1812 base: &Url,
1813 expected_path: &str,
1814 page_size_parameter: &str,
1815) -> Result<String, PullRequestError> {
1816 validate_pagination_origin(base, candidate)?;
1817 if !candidate.username().is_empty()
1818 || candidate.password().is_some()
1819 || candidate.fragment().is_some()
1820 || candidate.path() != expected_path
1821 {
1822 return Err(PullRequestError::MalformedResponse(
1823 "pagination URL escaped the pull-request list endpoint".to_owned(),
1824 ));
1825 }
1826 if let Some(page_size) = candidate
1827 .query_pairs()
1828 .find(|(name, _)| name == page_size_parameter)
1829 .map(|(_, value)| value)
1830 {
1831 let valid = page_size
1832 .parse::<usize>()
1833 .is_ok_and(|value| (1..=MAX_PULL_REQUEST_LIST_LIMIT).contains(&value));
1834 if !valid {
1835 return Err(PullRequestError::MalformedResponse(
1836 "provider pagination exceeded the pull-request list limit".to_owned(),
1837 ));
1838 }
1839 }
1840 let mut pages = candidate
1841 .query_pairs()
1842 .filter(|(name, _)| name == "page")
1843 .map(|(_, value)| value);
1844 let page = pages.next().ok_or_else(|| {
1845 PullRequestError::MalformedResponse(
1846 "provider pagination omitted a positive page cursor".to_owned(),
1847 )
1848 })?;
1849 if pages.next().is_some() {
1850 return Err(PullRequestError::MalformedResponse(
1851 "provider pagination returned multiple page cursors".to_owned(),
1852 ));
1853 }
1854 let page = page
1855 .parse::<u64>()
1856 .ok()
1857 .filter(|page| *page > 0)
1858 .ok_or_else(|| {
1859 PullRequestError::MalformedResponse(
1860 "provider pagination returned an invalid page cursor".to_owned(),
1861 )
1862 })?;
1863 Ok(page.to_string())
1864}
1865
1866fn list_page(
1867 items: Vec<PullRequestSummary>,
1868 next_cursor: Option<String>,
1869 requested_limit: usize,
1870 effective_limit: usize,
1871 rate_limit: PullRequestRateLimit,
1872) -> PullRequestListPage {
1873 let has_more = next_cursor.is_some();
1874 let truncated = requested_limit > effective_limit && has_more;
1875 let warnings = truncated
1876 .then(|| PullRequestWarning {
1877 code: "configured_limit_applied".to_owned(),
1878 message: format!(
1879 "Pull-request list page size was reduced to the configured limit of \
1880 {effective_limit}"
1881 ),
1882 })
1883 .into_iter()
1884 .collect();
1885 PullRequestListPage {
1886 items,
1887 next_cursor,
1888 has_more,
1889 truncated,
1890 rate_limit,
1891 warnings,
1892 }
1893}
1894
1895fn parse_base_url(value: &str) -> Result<Url, PullRequestError> {
1896 let url = Url::parse(value).map_err(|_| {
1897 PullRequestError::InvalidConfiguration("provider API base URL is invalid".to_owned())
1898 })?;
1899 if url.cannot_be_a_base()
1900 || url.host_str().is_none()
1901 || !url.username().is_empty()
1902 || url.password().is_some()
1903 || url.query().is_some()
1904 || url.fragment().is_some()
1905 {
1906 return Err(PullRequestError::InvalidConfiguration(
1907 "provider API base URL must be an absolute credential-free URL".to_owned(),
1908 ));
1909 }
1910 Ok(url)
1911}
1912
1913fn normalize_base_url(mut url: Url) -> String {
1914 url.set_fragment(None);
1915 url.set_query(None);
1916 let trimmed = url.path().trim_end_matches('/').to_owned();
1917 url.set_path(&trimmed);
1918 url.to_string().trim_end_matches('/').to_owned()
1919}
1920
1921fn is_loopback(url: &Url) -> bool {
1922 url.host_str().is_some_and(|host| {
1923 host.eq_ignore_ascii_case("localhost")
1924 || host
1925 .parse::<std::net::IpAddr>()
1926 .is_ok_and(|ip| ip.is_loopback())
1927 })
1928}
1929
1930fn validate_pagination_origin(base: &Url, candidate: &Url) -> Result<(), PullRequestError> {
1931 if base.scheme() != candidate.scheme()
1932 || base.host_str() != candidate.host_str()
1933 || base.port_or_known_default() != candidate.port_or_known_default()
1934 {
1935 return Err(PullRequestError::MalformedResponse(
1936 "pagination URL escaped the configured provider origin".to_owned(),
1937 ));
1938 }
1939 Ok(())
1940}
1941
1942fn header<'a>(headers: &'a BTreeMap<String, String>, name: &str) -> Option<&'a str> {
1943 headers
1944 .iter()
1945 .find(|(key, _)| key.eq_ignore_ascii_case(name))
1946 .map(|(_, value)| value.as_str())
1947}
1948
1949fn rate_limit_from_headers(headers: &BTreeMap<String, String>) -> PullRequestRateLimit {
1950 PullRequestRateLimit {
1951 limit: header(headers, "x-ratelimit-limit").and_then(|value| value.parse().ok()),
1952 remaining: header(headers, "x-ratelimit-remaining").and_then(|value| value.parse().ok()),
1953 reset: header(headers, "x-ratelimit-reset").map(str::to_owned),
1954 retry_after_seconds: header(headers, "retry-after").and_then(|value| value.parse().ok()),
1955 }
1956}
1957
1958fn api_error_message(body: &[u8], token: &PrAuthToken) -> String {
1959 let parsed = serde_json::from_slice::<Value>(body).ok();
1960 let message = parsed
1961 .as_ref()
1962 .and_then(|value| {
1963 optional_string(value, "message").or_else(|| {
1964 value
1965 .get("error")
1966 .and_then(|error| optional_string(error, "message"))
1967 })
1968 })
1969 .unwrap_or("provider returned an error");
1970 redact_message(message, token)
1971}
1972
1973fn redact_message(message: &str, token: &PrAuthToken) -> String {
1974 let bounded: String = message.chars().take(512).collect();
1975 if token.is_empty() {
1976 bounded
1977 } else {
1978 bounded.replace(token.expose_secret(), REDACTED)
1979 }
1980}
1981
1982fn required_field<'a>(value: &'a Value, field: &str) -> Result<&'a Value, PullRequestError> {
1983 value.get(field).ok_or_else(|| {
1984 PullRequestError::MalformedResponse(format!("missing required field `{field}`"))
1985 })
1986}
1987
1988fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, PullRequestError> {
1989 required_field(value, field)?
1990 .as_str()
1991 .filter(|text| !text.is_empty())
1992 .ok_or_else(|| {
1993 PullRequestError::MalformedResponse(format!("field `{field}` must be a string"))
1994 })
1995}
1996
1997fn optional_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
1998 value.get(field).and_then(Value::as_str)
1999}
2000
2001fn required_array<'a>(value: &'a Value, field: &str) -> Result<&'a [Value], PullRequestError> {
2002 value.as_array().map(Vec::as_slice).ok_or_else(|| {
2003 PullRequestError::MalformedResponse(format!("field `{field}` must be an array"))
2004 })
2005}
2006
2007fn nested<'a>(value: &'a Value, fields: &[&str]) -> Result<&'a Value, PullRequestError> {
2008 fields
2009 .iter()
2010 .try_fold(value, |current, field| required_field(current, field))
2011}
2012
2013fn nested_string<'a>(value: &'a Value, fields: &[&str]) -> Result<&'a str, PullRequestError> {
2014 let (last, parents) = fields
2015 .split_last()
2016 .ok_or_else(|| PullRequestError::MalformedResponse("empty JSON field path".to_owned()))?;
2017 required_string(nested(value, parents)?, last)
2018}
2019
2020fn value_id(value: &Value, field: &str) -> Result<String, PullRequestError> {
2021 let id = required_field(value, field)?;
2022 id.as_str()
2023 .map(str::to_owned)
2024 .or_else(|| id.as_u64().map(|number| number.to_string()))
2025 .ok_or_else(|| {
2026 PullRequestError::MalformedResponse(format!(
2027 "field `{field}` must be a string or integer"
2028 ))
2029 })
2030}
2031
2032fn required_u64(value: &Value, field: &str) -> Result<u64, PullRequestError> {
2033 required_field(value, field)?.as_u64().ok_or_else(|| {
2034 PullRequestError::MalformedResponse(format!(
2035 "field `{field}` must be a non-negative integer"
2036 ))
2037 })
2038}
2039
2040fn parse_github_summary(
2041 value: &Value,
2042 owner: &str,
2043 repository: &str,
2044) -> Result<PullRequestSummary, PullRequestError> {
2045 let state = if value.get("merged_at").is_some_and(|value| !value.is_null()) {
2046 PullRequestState::Merged
2047 } else {
2048 match required_string(value, "state")? {
2049 "open" => PullRequestState::Open,
2050 "closed" => PullRequestState::Closed,
2051 _ => PullRequestState::Unknown,
2052 }
2053 };
2054 Ok(PullRequestSummary {
2055 coordinates: PullRequestCoordinates {
2056 provider: PullRequestProviderKind::GitHub,
2057 owner: owner.to_owned(),
2058 repository: repository.to_owned(),
2059 number: required_u64(value, "number")?,
2060 },
2061 title: required_string(value, "title")?.to_owned(),
2062 url: required_string(value, "html_url")?.to_owned(),
2063 state,
2064 draft: value.get("draft").and_then(Value::as_bool).unwrap_or(false),
2065 author: nested_string(value, &["user", "login"])?.to_owned(),
2066 base_branch: nested_string(value, &["base", "ref"])?.to_owned(),
2067 head_branch: nested_string(value, &["head", "ref"])?.to_owned(),
2068 created_at: required_string(value, "created_at")?.to_owned(),
2069 updated_at: required_string(value, "updated_at")?.to_owned(),
2070 })
2071}
2072
2073fn parse_github_metadata(value: &Value) -> Result<PullRequestMetadata, PullRequestError> {
2074 let merged = value
2075 .get("merged")
2076 .and_then(Value::as_bool)
2077 .unwrap_or(false);
2078 let state = if merged {
2079 PullRequestState::Merged
2080 } else {
2081 match required_string(value, "state")? {
2082 "open" => PullRequestState::Open,
2083 "closed" => PullRequestState::Closed,
2084 _ => PullRequestState::Unknown,
2085 }
2086 };
2087 Ok(PullRequestMetadata {
2088 id: value_id(value, "id")?,
2089 title: required_string(value, "title")?.to_owned(),
2090 url: required_string(value, "html_url")?.to_owned(),
2091 state,
2092 draft: value.get("draft").and_then(Value::as_bool).unwrap_or(false),
2093 author: nested_string(value, &["user", "login"])?.to_owned(),
2094 base: parse_github_ref(required_field(value, "base")?)?,
2095 head: parse_github_ref(required_field(value, "head")?)?,
2096 created_at: required_string(value, "created_at")?.to_owned(),
2097 updated_at: required_string(value, "updated_at")?.to_owned(),
2098 })
2099}
2100
2101fn parse_github_ref(value: &Value) -> Result<PullRequestRef, PullRequestError> {
2102 Ok(PullRequestRef {
2103 name: required_string(value, "ref")?.to_owned(),
2104 sha: required_string(value, "sha")?.to_owned(),
2105 repository: PullRequestRepository {
2106 full_name: nested_string(value, &["repo", "full_name"])?.to_owned(),
2107 url: nested(value, &["repo"])
2108 .ok()
2109 .and_then(|repo| optional_string(repo, "html_url"))
2110 .map(str::to_owned),
2111 },
2112 })
2113}
2114
2115fn parse_github_file(value: &Value) -> Result<PullRequestChangedFile, PullRequestError> {
2116 let filename = required_string(value, "filename")?.to_owned();
2117 let provider_status = required_string(value, "status")?;
2118 let status = match provider_status {
2119 "added" => ChangedFileStatus::Added,
2120 "modified" | "changed" => ChangedFileStatus::Modified,
2121 "removed" => ChangedFileStatus::Removed,
2122 "renamed" => ChangedFileStatus::Renamed,
2123 "copied" => ChangedFileStatus::Copied,
2124 _ => ChangedFileStatus::Unknown,
2125 };
2126 let old_path = if matches!(status, ChangedFileStatus::Renamed) {
2127 optional_string(value, "previous_filename").map(str::to_owned)
2128 } else if matches!(status, ChangedFileStatus::Removed) {
2129 Some(filename.clone())
2130 } else {
2131 None
2132 };
2133 let new_path = (!matches!(status, ChangedFileStatus::Removed)).then_some(filename);
2134 let additions = value.get("additions").and_then(Value::as_u64).unwrap_or(0);
2135 let deletions = value.get("deletions").and_then(Value::as_u64).unwrap_or(0);
2136 let patch_present = value.get("patch").and_then(Value::as_str).is_some();
2137 Ok(PullRequestChangedFile {
2138 status,
2139 old_path,
2140 new_path,
2141 additions,
2142 deletions,
2143 binary: !patch_present && additions.saturating_add(deletions) > 0,
2144 patch_truncated: patch_present,
2145 })
2146}
2147
2148fn parse_github_check(value: &Value) -> Result<PullRequestCheck, PullRequestError> {
2149 let status = optional_string(value, "status").unwrap_or("unknown");
2150 let conclusion = optional_string(value, "conclusion").unwrap_or("unknown");
2151 let state = if status == "completed" {
2152 match conclusion {
2153 "success" | "neutral" | "skipped" => CheckState::Success,
2154 "failure" | "timed_out" | "cancelled" | "action_required" => CheckState::Failure,
2155 _ => CheckState::Unknown,
2156 }
2157 } else {
2158 CheckState::Pending
2159 };
2160 Ok(PullRequestCheck {
2161 name: required_string(value, "name")?.to_owned(),
2162 state,
2163 url: optional_string(value, "details_url").map(str::to_owned),
2164 })
2165}
2166
2167fn parse_github_review(value: &Value) -> Result<PullRequestReview, PullRequestError> {
2168 Ok(PullRequestReview {
2169 id: value_id(value, "id")?,
2170 author: nested_string(value, &["user", "login"])?.to_owned(),
2171 state: required_string(value, "state")?.to_ascii_lowercase(),
2172 submitted_at: optional_string(value, "submitted_at").map(str::to_owned),
2173 })
2174}
2175
2176fn parse_bitbucket_metadata(value: &Value) -> Result<PullRequestMetadata, PullRequestError> {
2177 let state = match required_string(value, "state")? {
2178 "OPEN" => PullRequestState::Open,
2179 "MERGED" | "FULFILLED" => PullRequestState::Merged,
2180 "DECLINED" | "SUPERSEDED" => PullRequestState::Closed,
2181 _ => PullRequestState::Unknown,
2182 };
2183 Ok(PullRequestMetadata {
2184 id: value_id(value, "id")?,
2185 title: required_string(value, "title")?.to_owned(),
2186 url: nested_string(value, &["links", "html", "href"])?.to_owned(),
2187 state,
2188 draft: value.get("draft").and_then(Value::as_bool).unwrap_or(false),
2189 author: nested_string(value, &["author", "display_name"])?.to_owned(),
2190 base: parse_bitbucket_ref(required_field(value, "destination")?)?,
2191 head: parse_bitbucket_ref(required_field(value, "source")?)?,
2192 created_at: required_string(value, "created_on")?.to_owned(),
2193 updated_at: required_string(value, "updated_on")?.to_owned(),
2194 })
2195}
2196
2197fn parse_bitbucket_summary(
2198 value: &Value,
2199 owner: &str,
2200 repository: &str,
2201) -> Result<PullRequestSummary, PullRequestError> {
2202 let state = match required_string(value, "state")? {
2203 "OPEN" => PullRequestState::Open,
2204 "MERGED" | "FULFILLED" => PullRequestState::Merged,
2205 "DECLINED" | "SUPERSEDED" => PullRequestState::Closed,
2206 _ => PullRequestState::Unknown,
2207 };
2208 Ok(PullRequestSummary {
2209 coordinates: PullRequestCoordinates {
2210 provider: PullRequestProviderKind::BitbucketCloud,
2211 owner: owner.to_owned(),
2212 repository: repository.to_owned(),
2213 number: required_u64(value, "id")?,
2214 },
2215 title: required_string(value, "title")?.to_owned(),
2216 url: nested_string(value, &["links", "html", "href"])?.to_owned(),
2217 state,
2218 draft: value.get("draft").and_then(Value::as_bool).unwrap_or(false),
2219 author: nested_string(value, &["author", "display_name"])?.to_owned(),
2220 base_branch: nested_string(value, &["destination", "branch", "name"])?.to_owned(),
2221 head_branch: nested_string(value, &["source", "branch", "name"])?.to_owned(),
2222 created_at: required_string(value, "created_on")?.to_owned(),
2223 updated_at: required_string(value, "updated_on")?.to_owned(),
2224 })
2225}
2226
2227fn parse_bitbucket_ref(value: &Value) -> Result<PullRequestRef, PullRequestError> {
2228 let repository = required_field(value, "repository")?;
2229 Ok(PullRequestRef {
2230 name: nested_string(value, &["branch", "name"])?.to_owned(),
2231 sha: nested_string(value, &["commit", "hash"])?.to_owned(),
2232 repository: PullRequestRepository {
2233 full_name: required_string(repository, "full_name")?.to_owned(),
2234 url: nested(repository, &["links", "html"])
2235 .ok()
2236 .and_then(|html| optional_string(html, "href"))
2237 .map(str::to_owned),
2238 },
2239 })
2240}
2241
2242fn parse_bitbucket_file(value: &Value) -> Result<PullRequestChangedFile, PullRequestError> {
2243 let status_text = required_string(value, "status")?;
2244 let status = match status_text {
2245 "added" => ChangedFileStatus::Added,
2246 "modified" => ChangedFileStatus::Modified,
2247 "removed" => ChangedFileStatus::Removed,
2248 "renamed" => ChangedFileStatus::Renamed,
2249 _ => ChangedFileStatus::Unknown,
2250 };
2251 let old_path = value
2252 .get("old")
2253 .and_then(|old| optional_string(old, "path"))
2254 .map(str::to_owned);
2255 let new_path = value
2256 .get("new")
2257 .and_then(|new| optional_string(new, "path"))
2258 .map(str::to_owned);
2259 let binary = value
2260 .get("new")
2261 .or_else(|| value.get("old"))
2262 .and_then(|entry| optional_string(entry, "type"))
2263 .is_some_and(|kind| kind.eq_ignore_ascii_case("binary"));
2264 Ok(PullRequestChangedFile {
2265 status,
2266 old_path,
2267 new_path,
2268 additions: value
2269 .get("lines_added")
2270 .and_then(Value::as_u64)
2271 .unwrap_or(0),
2272 deletions: value
2273 .get("lines_removed")
2274 .and_then(Value::as_u64)
2275 .unwrap_or(0),
2276 binary,
2277 patch_truncated: false,
2278 })
2279}
2280
2281fn parse_bitbucket_check(value: &Value) -> Result<PullRequestCheck, PullRequestError> {
2282 let state = match required_string(value, "state")? {
2283 "SUCCESSFUL" => CheckState::Success,
2284 "FAILED" | "STOPPED" => CheckState::Failure,
2285 "INPROGRESS" => CheckState::Pending,
2286 _ => CheckState::Unknown,
2287 };
2288 Ok(PullRequestCheck {
2289 name: required_string(value, "key")?.to_owned(),
2290 state,
2291 url: optional_string(value, "url").map(str::to_owned),
2292 })
2293}
2294
2295fn parse_bitbucket_reviews(
2296 metadata: &Value,
2297 max_items: usize,
2298) -> Result<PullRequestReviewSummary, PullRequestError> {
2299 let Some(participants) = metadata.get("participants") else {
2300 return Ok(PullRequestReviewSummary {
2301 state: ReviewState::Unknown,
2302 approvals: 0,
2303 reviews: Vec::new(),
2304 });
2305 };
2306 let mut reviews = Vec::new();
2307 let mut approvals = 0;
2308 let mut changes_requested = false;
2309 for participant in required_array(participants, "participants")?
2310 .iter()
2311 .take(max_items)
2312 {
2313 let approved = participant
2314 .get("approved")
2315 .and_then(Value::as_bool)
2316 .unwrap_or(false);
2317 let state = optional_string(participant, "state")
2318 .unwrap_or(if approved { "approved" } else { "pending" })
2319 .to_ascii_lowercase();
2320 approvals += usize::from(approved);
2321 changes_requested |= state.contains("changes_requested");
2322 let user = required_field(participant, "user")?;
2323 let author = optional_string(user, "display_name")
2324 .or_else(|| optional_string(user, "nickname"))
2325 .ok_or_else(|| {
2326 PullRequestError::MalformedResponse(
2327 "Bitbucket participant has no identity".to_owned(),
2328 )
2329 })?;
2330 let id = optional_string(user, "uuid").unwrap_or(author);
2331 reviews.push(PullRequestReview {
2332 id: id.to_owned(),
2333 author: author.to_owned(),
2334 state,
2335 submitted_at: None,
2336 });
2337 }
2338 let state = if changes_requested {
2339 ReviewState::ChangesRequested
2340 } else if approvals > 0 {
2341 ReviewState::Approved
2342 } else if reviews.is_empty() {
2343 ReviewState::Unknown
2344 } else {
2345 ReviewState::Pending
2346 };
2347 Ok(PullRequestReviewSummary {
2348 state,
2349 approvals,
2350 reviews,
2351 })
2352}
2353
2354fn summarize_checks(checks: Vec<PullRequestCheck>) -> PullRequestCi {
2355 let state = if checks.is_empty() {
2356 CheckState::Unknown
2357 } else if checks
2358 .iter()
2359 .any(|check| check.state == CheckState::Failure)
2360 {
2361 CheckState::Failure
2362 } else if checks
2363 .iter()
2364 .any(|check| check.state == CheckState::Pending)
2365 {
2366 CheckState::Pending
2367 } else if checks
2368 .iter()
2369 .all(|check| check.state == CheckState::Success)
2370 {
2371 CheckState::Success
2372 } else {
2373 CheckState::Unknown
2374 };
2375 PullRequestCi { state, checks }
2376}
2377
2378fn summarize_github_reviews(reviews: Vec<PullRequestReview>) -> PullRequestReviewSummary {
2379 let mut latest = BTreeMap::new();
2380 for review in &reviews {
2381 latest.insert(review.author.as_str(), review.state.as_str());
2382 }
2383 let approvals = latest
2384 .values()
2385 .filter(|state| **state == "approved")
2386 .count();
2387 let changes_requested = latest.values().any(|state| *state == "changes_requested");
2388 let state = if changes_requested {
2389 ReviewState::ChangesRequested
2390 } else if approvals > 0 {
2391 ReviewState::Approved
2392 } else if reviews.is_empty() {
2393 ReviewState::Unknown
2394 } else {
2395 ReviewState::Pending
2396 };
2397 PullRequestReviewSummary {
2398 state,
2399 approvals,
2400 reviews,
2401 }
2402}
2403
2404fn fingerprint(
2405 metadata: &PullRequestMetadata,
2406 files: &[PullRequestChangedFile],
2407) -> Result<String, PullRequestError> {
2408 serde_json::to_vec(&(metadata, files))
2409 .map(|bytes| blake3::hash(&bytes).to_hex().to_string())
2410 .map_err(|error| PullRequestError::MalformedResponse(error.to_string()))
2411}
2412
2413fn limit_warning(subject: &str) -> PullRequestWarning {
2414 PullRequestWarning {
2415 code: "limit_reached".to_owned(),
2416 message: format!("{subject} stopped at the configured limit"),
2417 }
2418}
2419
2420fn provider_scope_warning(subject: &str) -> PullRequestWarning {
2421 PullRequestWarning {
2422 code: "provider_scope_missing".to_owned(),
2423 message: format!(
2424 "Provider credentials cannot read {subject}; the corresponding result is unknown"
2425 ),
2426 }
2427}
2428
2429#[derive(
2431 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2432)]
2433#[serde(rename_all = "snake_case")]
2434pub enum ContractRole {
2435 Provider,
2437 Consumer,
2439}
2440
2441#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
2443pub struct PullRequestContractChange {
2444 pub contract: String,
2446 pub role: ContractRole,
2448 pub breaking: bool,
2450}
2451
2452#[derive(
2454 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2455)]
2456#[serde(rename_all = "snake_case")]
2457pub enum PullRequestReadiness {
2458 Ready,
2460 Blocked,
2462 Unknown,
2464 Stale,
2466}
2467
2468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2470pub struct PullRequestSemanticInput {
2471 pub fingerprint: String,
2473 pub files: BTreeSet<String>,
2475 pub contracts: BTreeSet<PullRequestContractChange>,
2477 pub services: BTreeSet<String>,
2479 pub communities: BTreeSet<String>,
2481 pub depends_on: BTreeSet<String>,
2483 pub readiness: PullRequestReadiness,
2485}
2486
2487#[derive(
2489 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2490)]
2491#[serde(rename_all = "snake_case")]
2492pub enum PullRequestOverlapKind {
2493 Disjoint,
2495 File,
2497 Contract,
2499 Service,
2501 Community,
2503 Conflicting,
2505}
2506
2507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2509pub struct PullRequestOverlap {
2510 pub kind: PullRequestOverlapKind,
2512 pub files: Vec<String>,
2514 pub contracts: Vec<String>,
2516 pub services: Vec<String>,
2518 pub communities: Vec<String>,
2520}
2521
2522#[must_use]
2524pub fn semantic_pull_request_overlap(
2525 left: &PullRequestSemanticInput,
2526 right: &PullRequestSemanticInput,
2527) -> PullRequestOverlap {
2528 let files = intersection(&left.files, &right.files);
2529 let left_contracts: BTreeSet<_> = left
2530 .contracts
2531 .iter()
2532 .map(|change| change.contract.clone())
2533 .collect();
2534 let right_contracts: BTreeSet<_> = right
2535 .contracts
2536 .iter()
2537 .map(|change| change.contract.clone())
2538 .collect();
2539 let contracts = intersection(&left_contracts, &right_contracts);
2540 let services = intersection(&left.services, &right.services);
2541 let communities = intersection(&left.communities, &right.communities);
2542 let migration_conflict = files.iter().any(|path| is_migration_path(path));
2543 let breaking_contract_conflict = contracts.iter().any(|contract| {
2544 left.contracts
2545 .iter()
2546 .chain(&right.contracts)
2547 .any(|change| change.contract == *contract && change.breaking)
2548 });
2549 let kind = if migration_conflict || breaking_contract_conflict {
2550 PullRequestOverlapKind::Conflicting
2551 } else if !contracts.is_empty() {
2552 PullRequestOverlapKind::Contract
2553 } else if !services.is_empty() {
2554 PullRequestOverlapKind::Service
2555 } else if !communities.is_empty() {
2556 PullRequestOverlapKind::Community
2557 } else if !files.is_empty() {
2558 PullRequestOverlapKind::File
2559 } else {
2560 PullRequestOverlapKind::Disjoint
2561 };
2562 PullRequestOverlap {
2563 kind,
2564 files,
2565 contracts,
2566 services,
2567 communities,
2568 }
2569}
2570
2571fn intersection(left: &BTreeSet<String>, right: &BTreeSet<String>) -> Vec<String> {
2572 left.intersection(right).cloned().collect()
2573}
2574
2575fn is_migration_path(path: &str) -> bool {
2576 let normalized = path.replace('\\', "/").to_ascii_lowercase();
2577 normalized.contains("/migrations/")
2578 || normalized.starts_with("migrations/")
2579 || normalized.ends_with(".migration.sql")
2580}
2581
2582#[derive(
2584 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2585)]
2586#[serde(rename_all = "snake_case")]
2587pub enum PullRequestOrder {
2588 LeftFirst,
2590 RightFirst,
2592 Coordinated,
2594 NoDefinitiveOrder,
2596}
2597
2598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2600pub struct PullRequestOrderSuggestion {
2601 pub order: PullRequestOrder,
2603 pub reasons: Vec<String>,
2605}
2606
2607#[must_use]
2609pub fn suggest_pull_request_order(
2610 left: &PullRequestSemanticInput,
2611 right: &PullRequestSemanticInput,
2612) -> PullRequestOrderSuggestion {
2613 if matches!(
2614 left.readiness,
2615 PullRequestReadiness::Unknown | PullRequestReadiness::Stale
2616 ) || matches!(
2617 right.readiness,
2618 PullRequestReadiness::Unknown | PullRequestReadiness::Stale
2619 ) {
2620 return suggestion(
2621 PullRequestOrder::NoDefinitiveOrder,
2622 ["readiness_unknown_or_stale"],
2623 );
2624 }
2625 let overlap = semantic_pull_request_overlap(left, right);
2626 if overlap.kind == PullRequestOverlapKind::Conflicting {
2627 return suggestion(
2628 PullRequestOrder::Coordinated,
2629 ["shared_migration_or_breaking_contract"],
2630 );
2631 }
2632 let left_depends_on_right = left.depends_on.contains(&right.fingerprint);
2633 let right_depends_on_left = right.depends_on.contains(&left.fingerprint);
2634 match (left_depends_on_right, right_depends_on_left) {
2635 (true, true) => {
2636 return suggestion(PullRequestOrder::Coordinated, ["dependency_cycle"]);
2637 }
2638 (true, false) => {
2639 return suggestion(PullRequestOrder::RightFirst, ["dependency_direction"]);
2640 }
2641 (false, true) => {
2642 return suggestion(PullRequestOrder::LeftFirst, ["dependency_direction"]);
2643 }
2644 (false, false) => {}
2645 }
2646 if let Some(order) = breaking_provider_order(left, right) {
2647 return suggestion(order, ["breaking_contract_provider_before_consumer"]);
2648 }
2649 match (left.readiness, right.readiness) {
2650 (PullRequestReadiness::Ready, PullRequestReadiness::Blocked) => {
2651 suggestion(PullRequestOrder::LeftFirst, ["ci_review_readiness"])
2652 }
2653 (PullRequestReadiness::Blocked, PullRequestReadiness::Ready) => {
2654 suggestion(PullRequestOrder::RightFirst, ["ci_review_readiness"])
2655 }
2656 _ => suggestion(
2657 PullRequestOrder::NoDefinitiveOrder,
2658 ["no_ordering_evidence"],
2659 ),
2660 }
2661}
2662
2663fn breaking_provider_order(
2664 left: &PullRequestSemanticInput,
2665 right: &PullRequestSemanticInput,
2666) -> Option<PullRequestOrder> {
2667 for left_change in &left.contracts {
2668 for right_change in &right.contracts {
2669 if left_change.contract != right_change.contract {
2670 continue;
2671 }
2672 if left_change.breaking && left_change.role == ContractRole::Provider {
2673 return Some(PullRequestOrder::LeftFirst);
2674 }
2675 if right_change.breaking && right_change.role == ContractRole::Provider {
2676 return Some(PullRequestOrder::RightFirst);
2677 }
2678 }
2679 }
2680 None
2681}
2682
2683fn suggestion(
2684 order: PullRequestOrder,
2685 reasons: impl IntoIterator<Item = &'static str>,
2686) -> PullRequestOrderSuggestion {
2687 let mut reasons: Vec<_> = reasons.into_iter().map(str::to_owned).collect();
2688 reasons.sort();
2689 reasons.dedup();
2690 PullRequestOrderSuggestion { order, reasons }
2691}
2692
2693#[cfg(test)]
2694mod tests {
2695 use std::collections::VecDeque;
2696 use std::io::{Read, Write};
2697 use std::net::TcpListener;
2698
2699 use serde_json::json;
2700
2701 use super::*;
2702
2703 fn one_response_server(response: &'static [u8]) -> String {
2704 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
2705 let address = listener.local_addr().expect("test server address");
2706 std::thread::spawn(move || {
2707 let (mut stream, _) = listener.accept().expect("accept test request");
2708 let mut request = [0_u8; 4096];
2709 let _ = stream.read(&mut request).expect("read test request");
2710 stream.write_all(response).expect("write test response");
2711 });
2712 format!("http://{address}/test")
2713 }
2714
2715 #[tokio::test]
2716 async fn production_transport_streams_bounded_responses_without_redirects() {
2717 let url = one_response_server(b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/blocked\r\nContent-Length: 4\r\nConnection: close\r\n\r\nbody");
2718 let transport = ReqwestPrHttpTransport::new().expect("production transport");
2719 let response = transport
2720 .send(PrHttpRequest {
2721 method: PrHttpMethod::Get,
2722 url,
2723 headers: BTreeMap::new(),
2724 authentication: PrHttpAuthentication::None,
2725 max_response_bytes: 4,
2726 timeout: Duration::from_secs(2),
2727 cancellation: CancellationToken::new(),
2728 })
2729 .await
2730 .expect("bounded response");
2731 assert_eq!((response.status, response.body), (302, b"body".to_vec()));
2732 }
2733
2734 #[tokio::test]
2735 async fn production_transport_rejects_declared_oversized_responses() {
2736 let url = one_response_server(
2737 b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\noversize",
2738 );
2739 let transport = ReqwestPrHttpTransport::new().expect("production transport");
2740 let error = transport
2741 .send(PrHttpRequest {
2742 method: PrHttpMethod::Get,
2743 url,
2744 headers: BTreeMap::new(),
2745 authentication: PrHttpAuthentication::None,
2746 max_response_bytes: 4,
2747 timeout: Duration::from_secs(2),
2748 cancellation: CancellationToken::new(),
2749 })
2750 .await
2751 .expect_err("oversized response");
2752 assert!(error.message.contains("output limit"));
2753 }
2754
2755 #[derive(Default)]
2756 struct MockTransport {
2757 responses: Mutex<VecDeque<Result<PrHttpResponse, PrHttpTransportError>>>,
2758 requests: Mutex<Vec<PrHttpRequest>>,
2759 }
2760
2761 impl MockTransport {
2762 fn with(responses: Vec<PrHttpResponse>) -> Arc<Self> {
2763 Arc::new(Self {
2764 responses: Mutex::new(responses.into_iter().map(Ok).collect()),
2765 requests: Mutex::default(),
2766 })
2767 }
2768
2769 fn request_count(&self) -> usize {
2770 self.requests.lock().expect("requests lock").len()
2771 }
2772
2773 fn request_header(&self, index: usize, name: &str) -> Option<String> {
2774 self.requests
2775 .lock()
2776 .expect("requests lock")
2777 .get(index)
2778 .and_then(|request| request.headers.get(name))
2779 .cloned()
2780 }
2781
2782 fn request_url(&self, index: usize) -> Option<String> {
2783 self.requests
2784 .lock()
2785 .expect("requests lock")
2786 .get(index)
2787 .map(|request| request.url.clone())
2788 }
2789
2790 fn request_authentication(&self, index: usize) -> Option<PrHttpAuthentication> {
2791 self.requests
2792 .lock()
2793 .expect("requests lock")
2794 .get(index)
2795 .map(|request| request.authentication.clone())
2796 }
2797 }
2798
2799 #[async_trait]
2800 impl PrHttpTransport for MockTransport {
2801 async fn send(
2802 &self,
2803 request: PrHttpRequest,
2804 ) -> Result<PrHttpResponse, PrHttpTransportError> {
2805 self.requests.lock().expect("requests lock").push(request);
2806 self.responses
2807 .lock()
2808 .expect("responses lock")
2809 .pop_front()
2810 .expect("mock response")
2811 }
2812 }
2813
2814 fn response(status: u16, value: &Value) -> PrHttpResponse {
2815 PrHttpResponse {
2816 status,
2817 headers: BTreeMap::new(),
2818 body: serde_json::to_vec(value).expect("JSON"),
2819 }
2820 }
2821
2822 fn github_metadata() -> Value {
2823 json!({
2824 "id": 10, "title": "Change", "html_url": "https://example/pr/1",
2825 "state": "open", "draft": false, "user": {"login": "alice"},
2826 "base": {"ref": "main", "sha": "base", "repo": {"full_name": "o/r", "html_url": "https://example/o/r"}},
2827 "head": {"ref": "feature", "sha": "head", "repo": {"full_name": "o/r", "html_url": "https://example/o/r"}},
2828 "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z"
2829 })
2830 }
2831
2832 fn bitbucket_metadata() -> Value {
2833 json!({
2834 "id": 7, "title": "Change", "state": "OPEN", "draft": false,
2835 "links": {"html": {"href": "https://example/pr/7"}},
2836 "author": {"display_name": "Alice"},
2837 "destination": {"branch": {"name": "main"}, "commit": {"hash": "base"},
2838 "repository": {"full_name": "w/r", "links": {"html": {"href": "https://example/w/r"}}}},
2839 "source": {"branch": {"name": "feature"}, "commit": {"hash": "head"},
2840 "repository": {"full_name": "w/r", "links": {"html": {"href": "https://example/w/r"}}}},
2841 "created_on": "2026-01-01T00:00:00Z", "updated_on": "2026-01-02T00:00:00Z",
2842 "participants": [{"approved": true, "state": "approved",
2843 "user": {"display_name": "Bob", "uuid": "{bob}"}}]
2844 })
2845 }
2846
2847 fn github_summary(number: u64, state: &str) -> Value {
2848 json!({
2849 "number": number,
2850 "title": format!("Change {number}"),
2851 "html_url": format!("https://github.example/o/r/pull/{number}"),
2852 "state": state,
2853 "merged_at": null,
2854 "draft": false,
2855 "user": {"login": "alice"},
2856 "base": {"ref": "main"},
2857 "head": {"ref": format!("feature-{number}")},
2858 "created_at": "2026-01-01T00:00:00Z",
2859 "updated_at": "2026-01-02T00:00:00Z"
2860 })
2861 }
2862
2863 fn bitbucket_summary(number: u64, state: &str) -> Value {
2864 json!({
2865 "id": number,
2866 "title": format!("Change {number}"),
2867 "state": state,
2868 "draft": false,
2869 "links": {"html": {"href": format!("https://bitbucket.example/w/r/pull-requests/{number}")}},
2870 "author": {"display_name": "Alice"},
2871 "destination": {"branch": {"name": "main"}},
2872 "source": {"branch": {"name": format!("feature-{number}")}},
2873 "created_on": "2026-01-01T00:00:00Z",
2874 "updated_on": "2026-01-02T00:00:00Z"
2875 })
2876 }
2877
2878 fn github_config() -> PullRequestProviderConfig {
2879 PullRequestProviderConfig {
2880 enabled: true,
2881 cache_ttl_seconds: 60,
2882 ..PullRequestProviderConfig::default()
2883 }
2884 }
2885
2886 fn bitbucket_config() -> PullRequestProviderConfig {
2887 PullRequestProviderConfig {
2888 enabled: true,
2889 cache_ttl_seconds: 60,
2890 ..PullRequestProviderConfig::bitbucket_cloud()
2891 }
2892 }
2893
2894 fn request(kind: PullRequestProviderKind) -> PullRequestInspectRequest {
2895 PullRequestInspectRequest {
2896 coordinates: PullRequestCoordinates {
2897 provider: kind,
2898 owner: "o".to_owned(),
2899 repository: "r".to_owned(),
2900 number: 1,
2901 },
2902 consent_to_remote_access: true,
2903 cancellation: CancellationToken::new(),
2904 }
2905 }
2906
2907 fn list_request(kind: PullRequestProviderKind) -> PullRequestListRequest {
2908 PullRequestListRequest {
2909 provider: kind,
2910 owner: "o".to_owned(),
2911 repository: "r".to_owned(),
2912 state: PullRequestListState::All,
2913 cursor: None,
2914 limit: 10,
2915 consent_to_remote_access: true,
2916 cancellation: CancellationToken::new(),
2917 }
2918 }
2919
2920 fn github_success_responses() -> Vec<PrHttpResponse> {
2921 vec![
2922 response(200, &github_metadata()),
2923 response(
2924 200,
2925 &json!([{"filename":"src/lib.rs","status":"modified","additions":2,"deletions":1,"patch":"secret source"}]),
2926 ),
2927 response(
2928 200,
2929 &json!({"check_runs":[{"name":"test","status":"completed","conclusion":"success"}]}),
2930 ),
2931 response(
2932 200,
2933 &json!([{"id":1,"user":{"login":"bob"},"state":"APPROVED","submitted_at":"2026-01-02T00:00:00Z"}]),
2934 ),
2935 ]
2936 }
2937
2938 fn semantic(name: &str) -> PullRequestSemanticInput {
2939 PullRequestSemanticInput {
2940 fingerprint: name.to_owned(),
2941 files: BTreeSet::new(),
2942 contracts: BTreeSet::new(),
2943 services: BTreeSet::new(),
2944 communities: BTreeSet::new(),
2945 depends_on: BTreeSet::new(),
2946 readiness: PullRequestReadiness::Ready,
2947 }
2948 }
2949
2950 #[test]
2951 fn default_configuration_is_disabled() {
2952 assert!(!PullRequestProviderConfig::default().enabled);
2953 }
2954
2955 #[test]
2956 fn configuration_rejects_non_https_remote_url() {
2957 let config = PullRequestProviderConfig {
2958 api_base_url: "http://example.com".to_owned(),
2959 api_base_url_allowlist: vec!["http://example.com".to_owned()],
2960 ..PullRequestProviderConfig::default()
2961 };
2962 assert!(matches!(
2963 config.validate(),
2964 Err(PullRequestError::InvalidConfiguration(_))
2965 ));
2966 }
2967
2968 #[test]
2969 fn configuration_allows_explicit_loopback_mock() {
2970 let config = PullRequestProviderConfig {
2971 api_base_url: "http://127.0.0.1:8080".to_owned(),
2972 allow_loopback_http: true,
2973 ..PullRequestProviderConfig::default()
2974 };
2975 assert_eq!(config.validate(), Ok(()));
2976 }
2977
2978 #[test]
2979 fn token_is_redacted_from_debug_and_serialization() {
2980 let config = PullRequestProviderConfig {
2981 auth_token: PrAuthToken::new("top-secret"),
2982 ..PullRequestProviderConfig::default()
2983 };
2984 let rendered = format!(
2985 "{config:?} {}",
2986 serde_json::to_string(&config).expect("JSON")
2987 );
2988 assert!(!rendered.contains("top-secret"));
2989 }
2990
2991 #[tokio::test]
2992 async fn github_rejects_disabled_provider_without_transport() {
2993 let transport = MockTransport::default();
2994 let provider =
2995 GitHubProvider::new(PullRequestProviderConfig::default(), Arc::new(transport))
2996 .expect("provider");
2997 let error = provider
2998 .inspect(request(PullRequestProviderKind::GitHub))
2999 .await
3000 .expect_err("disabled");
3001 assert_eq!(error, PullRequestError::Disabled);
3002 }
3003
3004 #[tokio::test]
3005 async fn github_requires_per_request_consent() {
3006 let transport = Arc::new(MockTransport::default());
3007 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3008 let mut input = request(PullRequestProviderKind::GitHub);
3009 input.consent_to_remote_access = false;
3010 let error = provider.inspect(input).await.expect_err("consent");
3011 assert_eq!(
3012 (error, transport.request_count()),
3013 (PullRequestError::ConsentRequired, 0)
3014 );
3015 }
3016
3017 #[tokio::test]
3018 async fn github_parses_success_without_retaining_patch() {
3019 let transport = MockTransport::with(github_success_responses());
3020 let provider = GitHubProvider::new(github_config(), transport).expect("provider");
3021 let result = provider
3022 .inspect(request(PullRequestProviderKind::GitHub))
3023 .await
3024 .expect("inspection");
3025 let serialized = serde_json::to_string(&result).expect("JSON");
3026 assert_eq!(
3027 (
3028 result.changed_files[0].patch_truncated,
3029 result.ci.state,
3030 result.review.state,
3031 serialized.contains("secret source"),
3032 ),
3033 (true, CheckState::Success, ReviewState::Approved, false)
3034 );
3035 }
3036
3037 #[tokio::test]
3038 async fn github_uses_fresh_structured_cache() {
3039 let transport = MockTransport::with(github_success_responses());
3040 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3041 provider
3042 .inspect(request(PullRequestProviderKind::GitHub))
3043 .await
3044 .expect("first");
3045 let second = provider
3046 .inspect(request(PullRequestProviderKind::GitHub))
3047 .await
3048 .expect("second");
3049 assert_eq!((second.from_cache, transport.request_count()), (true, 4));
3050 }
3051
3052 #[tokio::test]
3053 async fn github_reports_rate_limit_without_sleeping() {
3054 let mut limited = response(429, &json!({"message":"slow down"}));
3055 limited
3056 .headers
3057 .insert("retry-after".to_owned(), "12".to_owned());
3058 let transport = MockTransport::with(vec![limited]);
3059 let provider = GitHubProvider::new(github_config(), transport).expect("provider");
3060 let error = provider
3061 .inspect(request(PullRequestProviderKind::GitHub))
3062 .await
3063 .expect_err("rate limit");
3064 assert!(matches!(
3065 error,
3066 PullRequestError::RateLimited {
3067 metadata: PullRequestRateLimit {
3068 retry_after_seconds: Some(12),
3069 ..
3070 },
3071 ..
3072 }
3073 ));
3074 }
3075
3076 #[tokio::test]
3077 async fn github_redacts_token_from_api_error() {
3078 let transport = MockTransport::with(vec![response(
3079 401,
3080 &json!({"message":"token top-secret is invalid"}),
3081 )]);
3082 let mut config = github_config();
3083 config.auth_token = PrAuthToken::new("top-secret");
3084 let provider = GitHubProvider::new(config, transport).expect("provider");
3085 let error = provider
3086 .inspect(request(PullRequestProviderKind::GitHub))
3087 .await
3088 .expect_err("API error");
3089 assert!(!error.to_string().contains("top-secret"));
3090 }
3091
3092 #[tokio::test]
3093 async fn github_rejects_malformed_metadata() {
3094 let transport = MockTransport::with(vec![response(200, &json!({"id": 1}))]);
3095 let provider = GitHubProvider::new(github_config(), transport).expect("provider");
3096 assert!(matches!(
3097 provider
3098 .inspect(request(PullRequestProviderKind::GitHub))
3099 .await,
3100 Err(PullRequestError::MalformedResponse(_))
3101 ));
3102 }
3103
3104 #[tokio::test]
3105 async fn github_follows_link_header_pagination() {
3106 let mut first_page = response(
3107 200,
3108 &json!([{"filename":"a.rs","status":"added","additions":1,"deletions":0}]),
3109 );
3110 first_page.headers.insert(
3111 "link".to_owned(),
3112 r#"<https://api.github.com/repos/o/r/pulls/1/files?page=2>; rel="next""#.to_owned(),
3113 );
3114 let transport = MockTransport::with(vec![
3115 response(200, &github_metadata()),
3116 first_page,
3117 response(
3118 200,
3119 &json!([{"filename":"b.rs","status":"added","additions":1,"deletions":0}]),
3120 ),
3121 response(200, &json!({"check_runs":[]})),
3122 response(200, &json!([])),
3123 ]);
3124 let provider = GitHubProvider::new(github_config(), transport).expect("provider");
3125 let result = provider
3126 .inspect(request(PullRequestProviderKind::GitHub))
3127 .await
3128 .expect("inspection");
3129 assert_eq!(result.changed_files.len(), 2);
3130 }
3131
3132 #[tokio::test]
3133 async fn github_revalidates_stale_cache_with_etag() {
3134 let mut responses = github_success_responses();
3135 responses[0]
3136 .headers
3137 .insert("etag".to_owned(), r#""version-1""#.to_owned());
3138 responses.push(PrHttpResponse {
3139 status: 304,
3140 headers: BTreeMap::new(),
3141 body: Vec::new(),
3142 });
3143 let transport = MockTransport::with(responses);
3144 let mut config = github_config();
3145 config.cache_ttl_seconds = 0;
3146 let provider = GitHubProvider::new(config, transport.clone()).expect("provider");
3147 provider
3148 .inspect(request(PullRequestProviderKind::GitHub))
3149 .await
3150 .expect("first");
3151 let second = provider
3152 .inspect(request(PullRequestProviderKind::GitHub))
3153 .await
3154 .expect("revalidated");
3155 assert_eq!(
3156 (
3157 second.from_cache,
3158 transport.request_count(),
3159 transport.request_header(4, "if-none-match"),
3160 ),
3161 (true, 5, Some(r#""version-1""#.to_owned()))
3162 );
3163 }
3164
3165 #[tokio::test]
3166 async fn github_honors_cancellation_before_transport() {
3167 let transport = Arc::new(MockTransport::default());
3168 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3169 let input = request(PullRequestProviderKind::GitHub);
3170 input.cancellation.cancel();
3171 let error = provider.inspect(input).await.expect_err("cancelled");
3172 assert_eq!(
3173 (error, transport.request_count()),
3174 (PullRequestError::Cancelled, 0)
3175 );
3176 }
3177
3178 #[tokio::test]
3179 async fn github_list_returns_source_free_summaries_in_provider_number_order() {
3180 let mut page = response(
3181 200,
3182 &json!([github_summary(9, "open"), github_summary(2, "open")]),
3183 );
3184 page.headers
3185 .insert("x-ratelimit-remaining".to_owned(), "42".to_owned());
3186 let transport = MockTransport::with(vec![page]);
3187 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3188 let result = provider
3189 .list(list_request(PullRequestProviderKind::GitHub))
3190 .await
3191 .expect("list page");
3192 let serialized = serde_json::to_string(&result).expect("JSON");
3193 assert_eq!(
3194 (
3195 result
3196 .items
3197 .iter()
3198 .map(|summary| summary.coordinates.number)
3199 .collect::<Vec<_>>(),
3200 result.rate_limit.remaining,
3201 serialized.contains("patch"),
3202 transport
3203 .request_url(0)
3204 .is_some_and(|url| url.contains("state=all") && url.contains("per_page=10")),
3205 ),
3206 (vec![2, 9], Some(42), false, true)
3207 );
3208 }
3209
3210 #[tokio::test]
3211 async fn github_list_requires_consent_before_transport() {
3212 let transport = Arc::new(MockTransport::default());
3213 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3214 let mut input = list_request(PullRequestProviderKind::GitHub);
3215 input.consent_to_remote_access = false;
3216 let error = provider.list(input).await.expect_err("consent");
3217 assert_eq!(
3218 (error, transport.request_count()),
3219 (PullRequestError::ConsentRequired, 0)
3220 );
3221 }
3222
3223 #[tokio::test]
3224 async fn github_list_rejects_disabled_provider_before_transport() {
3225 let transport = Arc::new(MockTransport::default());
3226 let provider = GitHubProvider::new(PullRequestProviderConfig::default(), transport.clone())
3227 .expect("provider");
3228 let error = provider
3229 .list(list_request(PullRequestProviderKind::GitHub))
3230 .await
3231 .expect_err("disabled");
3232 assert_eq!(
3233 (error, transport.request_count()),
3234 (PullRequestError::Disabled, 0)
3235 );
3236 }
3237
3238 #[tokio::test]
3239 async fn github_list_rejects_zero_limit_before_transport() {
3240 let transport = Arc::new(MockTransport::default());
3241 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3242 let mut input = list_request(PullRequestProviderKind::GitHub);
3243 input.limit = 0;
3244 let error = provider.list(input).await.expect_err("invalid limit");
3245 assert!(matches!(
3246 (error, transport.request_count()),
3247 (PullRequestError::InvalidConfiguration(_), 0)
3248 ));
3249 }
3250
3251 #[tokio::test]
3252 async fn github_list_rejects_limit_above_one_hundred_before_transport() {
3253 let transport = Arc::new(MockTransport::default());
3254 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3255 let mut input = list_request(PullRequestProviderKind::GitHub);
3256 input.limit = 101;
3257 let error = provider.list(input).await.expect_err("invalid limit");
3258 assert!(matches!(
3259 (error, transport.request_count()),
3260 (PullRequestError::InvalidConfiguration(_), 0)
3261 ));
3262 }
3263
3264 #[tokio::test]
3265 async fn github_list_round_trips_same_origin_page_cursor() {
3266 let mut first = response(200, &json!([github_summary(1, "open")]));
3267 first.headers.insert(
3268 "link".to_owned(),
3269 r#"<https://api.github.com/repos/o/r/pulls?state=all&per_page=10&page=2>; rel="next""#
3270 .to_owned(),
3271 );
3272 let transport = MockTransport::with(vec![
3273 first,
3274 response(200, &json!([github_summary(2, "open")])),
3275 ]);
3276 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3277 let first_page = provider
3278 .list(list_request(PullRequestProviderKind::GitHub))
3279 .await
3280 .expect("first page");
3281 let mut second_request = list_request(PullRequestProviderKind::GitHub);
3282 second_request.cursor = first_page.next_cursor;
3283 let second_page = provider.list(second_request).await.expect("second page");
3284 assert_eq!(
3285 (
3286 first_page.has_more,
3287 second_page.items[0].coordinates.number,
3288 transport
3289 .request_url(1)
3290 .is_some_and(|url| url.contains("page=2")),
3291 ),
3292 (true, 2, true)
3293 );
3294 }
3295
3296 #[tokio::test]
3297 async fn github_list_rejects_cross_origin_pagination() {
3298 let mut page = response(200, &json!([github_summary(1, "open")]));
3299 page.headers.insert(
3300 "link".to_owned(),
3301 r#"<https://attacker.invalid/repos/o/r/pulls?page=2>; rel="next""#.to_owned(),
3302 );
3303 let transport = MockTransport::with(vec![page]);
3304 let provider = GitHubProvider::new(github_config(), transport).expect("provider");
3305 assert!(matches!(
3306 provider
3307 .list(list_request(PullRequestProviderKind::GitHub))
3308 .await,
3309 Err(PullRequestError::MalformedResponse(_))
3310 ));
3311 }
3312
3313 #[tokio::test]
3314 async fn github_list_reports_missing_scope_as_api_error() {
3315 let transport = MockTransport::with(vec![response(
3316 403,
3317 &json!({"message":"resource not accessible by token"}),
3318 )]);
3319 let provider = GitHubProvider::new(github_config(), transport).expect("provider");
3320 assert!(matches!(
3321 provider
3322 .list(list_request(PullRequestProviderKind::GitHub))
3323 .await,
3324 Err(PullRequestError::Api { status: 403, .. })
3325 ));
3326 }
3327
3328 #[tokio::test]
3329 async fn github_list_redacts_token_from_api_error() {
3330 let transport = MockTransport::with(vec![response(
3331 401,
3332 &json!({"message":"token list-secret is invalid"}),
3333 )]);
3334 let mut config = github_config();
3335 config.auth_token = PrAuthToken::new("list-secret");
3336 let provider = GitHubProvider::new(config, transport).expect("provider");
3337 let error = provider
3338 .list(list_request(PullRequestProviderKind::GitHub))
3339 .await
3340 .expect_err("API error");
3341 assert!(!error.to_string().contains("list-secret"));
3342 }
3343
3344 #[tokio::test]
3345 async fn github_list_marks_configured_page_reduction_as_truncated() {
3346 let mut page = response(200, &json!([github_summary(1, "open")]));
3347 page.headers.insert(
3348 "link".to_owned(),
3349 r#"<https://api.github.com/repos/o/r/pulls?state=all&per_page=1&page=2>; rel="next""#
3350 .to_owned(),
3351 );
3352 let transport = MockTransport::with(vec![page]);
3353 let mut config = github_config();
3354 config.max_items = 1;
3355 let provider = GitHubProvider::new(config, transport).expect("provider");
3356 let result = provider
3357 .list(list_request(PullRequestProviderKind::GitHub))
3358 .await
3359 .expect("bounded page");
3360 assert_eq!(
3361 (
3362 result.truncated,
3363 result.warnings.first().map(|warning| warning.code.as_str()),
3364 ),
3365 (true, Some("configured_limit_applied"))
3366 );
3367 }
3368
3369 #[tokio::test]
3370 async fn list_reports_bitbucket_data_center_as_unsupported_without_transport() {
3371 let transport = Arc::new(MockTransport::default());
3372 let provider = GitHubProvider::new(github_config(), transport.clone()).expect("provider");
3373 let error = provider
3374 .list(list_request(PullRequestProviderKind::BitbucketDataCenter))
3375 .await
3376 .expect_err("unsupported provider");
3377 assert_eq!(
3378 (error, transport.request_count()),
3379 (PullRequestError::UnsupportedProvider, 0)
3380 );
3381 }
3382
3383 #[tokio::test]
3384 async fn bitbucket_parses_success_and_participant_approval() {
3385 let transport = MockTransport::with(vec![
3386 response(200, &bitbucket_metadata()),
3387 response(
3388 200,
3389 &json!({"values":[{"status":"modified","old":{"path":"a"},"new":{"path":"a"},"lines_added":3,"lines_removed":1}]}),
3390 ),
3391 response(
3392 200,
3393 &json!({"values":[{"key":"build","state":"SUCCESSFUL"}]}),
3394 ),
3395 ]);
3396 let provider = BitbucketProvider::new(bitbucket_config(), transport).expect("provider");
3397 let result = provider
3398 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3399 .await
3400 .expect("inspection");
3401 assert_eq!(
3402 (result.review.approvals, result.ci.state),
3403 (1, CheckState::Success)
3404 );
3405 }
3406
3407 #[tokio::test]
3408 async fn bitbucket_api_token_should_use_basic_auth_with_atlassian_email() {
3409 let transport = MockTransport::with(vec![
3410 response(200, &bitbucket_metadata()),
3411 response(200, &json!({"values":[]})),
3412 response(200, &json!({"values":[]})),
3413 ]);
3414 let mut config = bitbucket_config();
3415 config.auth_token = PrAuthToken::new("api-token");
3416 config.basic_auth_username = Some("user@example.com".to_owned());
3417 let provider =
3418 BitbucketProvider::new(config, transport.clone()).expect("Bitbucket provider");
3419 provider
3420 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3421 .await
3422 .expect("inspection");
3423 assert!(matches!(
3424 transport.request_authentication(0),
3425 Some(PrHttpAuthentication::Basic { username, token })
3426 if username == "user@example.com" && token.expose_secret() == "api-token"
3427 ));
3428 }
3429
3430 #[tokio::test]
3431 async fn bitbucket_should_follow_same_origin_diffstat_redirect_and_degrade_missing_scopes() {
3432 let transport = MockTransport::with(vec![
3433 response(200, &bitbucket_metadata()),
3434 PrHttpResponse {
3435 status: 302,
3436 headers: BTreeMap::from([(
3437 "location".to_owned(),
3438 "https://api.bitbucket.org/2.0/repositories/o/r/diffstat/base..head".to_owned(),
3439 )]),
3440 body: Vec::new(),
3441 },
3442 response(
3443 403,
3444 &json!({"error":{"message":"missing repository scope"}}),
3445 ),
3446 response(
3447 403,
3448 &json!({"error":{"message":"missing repository scope"}}),
3449 ),
3450 ]);
3451 let provider =
3452 BitbucketProvider::new(bitbucket_config(), transport.clone()).expect("provider");
3453 let result = provider
3454 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3455 .await
3456 .expect("degraded inspection");
3457 assert_eq!(
3458 (
3459 result.changed_files.len(),
3460 result.ci.state,
3461 result.warnings.len(),
3462 transport.request_count(),
3463 ),
3464 (0, CheckState::Unknown, 2, 4)
3465 );
3466 assert!(
3467 result
3468 .warnings
3469 .iter()
3470 .all(|warning| warning.code == "provider_scope_missing")
3471 );
3472 }
3473
3474 #[tokio::test]
3475 async fn bitbucket_should_reject_cross_origin_redirect_without_forwarding_credentials() {
3476 let transport = MockTransport::with(vec![
3477 response(200, &bitbucket_metadata()),
3478 PrHttpResponse {
3479 status: 302,
3480 headers: BTreeMap::from([(
3481 "location".to_owned(),
3482 "https://attacker.invalid/diffstat".to_owned(),
3483 )]),
3484 body: Vec::new(),
3485 },
3486 ]);
3487 let provider =
3488 BitbucketProvider::new(bitbucket_config(), transport.clone()).expect("provider");
3489 let error = provider
3490 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3491 .await
3492 .expect_err("cross-origin redirect");
3493 assert!(matches!(
3494 error,
3495 PullRequestError::Api { message, .. } if message.contains("crossed")
3496 ));
3497 assert_eq!(transport.request_count(), 2);
3498 }
3499
3500 #[tokio::test]
3501 async fn bitbucket_follows_diffstat_pagination() {
3502 let transport = MockTransport::with(vec![
3503 response(200, &bitbucket_metadata()),
3504 response(
3505 200,
3506 &json!({"values":[], "next":"https://api.bitbucket.org/2.0/next"}),
3507 ),
3508 response(
3509 200,
3510 &json!({"values":[{"status":"added","new":{"path":"a"},"lines_added":1,"lines_removed":0}]}),
3511 ),
3512 response(200, &json!({"values":[]})),
3513 ]);
3514 let provider =
3515 BitbucketProvider::new(bitbucket_config(), transport.clone()).expect("provider");
3516 let result = provider
3517 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3518 .await
3519 .expect("inspection");
3520 assert_eq!(
3521 (result.changed_files.len(), transport.request_count()),
3522 (1, 4)
3523 );
3524 }
3525
3526 #[tokio::test]
3527 async fn bitbucket_uses_fresh_structured_cache() {
3528 let transport = MockTransport::with(vec![
3529 response(200, &bitbucket_metadata()),
3530 response(200, &json!({"values":[]})),
3531 response(200, &json!({"values":[]})),
3532 ]);
3533 let provider =
3534 BitbucketProvider::new(bitbucket_config(), transport.clone()).expect("provider");
3535 provider
3536 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3537 .await
3538 .expect("first");
3539 let second = provider
3540 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3541 .await
3542 .expect("second");
3543 assert_eq!((second.from_cache, transport.request_count()), (true, 3));
3544 }
3545
3546 #[tokio::test]
3547 async fn bitbucket_reports_rate_limit_metadata() {
3548 let mut limited = response(403, &json!({"error":{"message":"quota"}}));
3549 limited
3550 .headers
3551 .insert("x-ratelimit-remaining".to_owned(), "0".to_owned());
3552 let transport = MockTransport::with(vec![limited]);
3553 let provider = BitbucketProvider::new(bitbucket_config(), transport).expect("provider");
3554 let error = provider
3555 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3556 .await
3557 .expect_err("rate limit");
3558 assert!(matches!(
3559 error,
3560 PullRequestError::RateLimited {
3561 metadata: PullRequestRateLimit {
3562 remaining: Some(0),
3563 ..
3564 },
3565 ..
3566 }
3567 ));
3568 }
3569
3570 #[tokio::test]
3571 async fn bitbucket_requires_per_request_consent() {
3572 let transport = Arc::new(MockTransport::default());
3573 let provider =
3574 BitbucketProvider::new(bitbucket_config(), transport.clone()).expect("provider");
3575 let mut input = request(PullRequestProviderKind::BitbucketCloud);
3576 input.consent_to_remote_access = false;
3577 let error = provider.inspect(input).await.expect_err("consent");
3578 assert_eq!(
3579 (error, transport.request_count()),
3580 (PullRequestError::ConsentRequired, 0)
3581 );
3582 }
3583
3584 #[tokio::test]
3585 async fn bitbucket_redacts_token_from_api_error() {
3586 let transport = MockTransport::with(vec![response(
3587 400,
3588 &json!({"error":{"message":"credential bb-secret rejected"}}),
3589 )]);
3590 let mut config = bitbucket_config();
3591 config.auth_token = PrAuthToken::new("bb-secret");
3592 let provider = BitbucketProvider::new(config, transport).expect("provider");
3593 let error = provider
3594 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3595 .await
3596 .expect_err("API error");
3597 assert!(!error.to_string().contains("bb-secret"));
3598 }
3599
3600 #[tokio::test]
3601 async fn bitbucket_rejects_cross_origin_pagination() {
3602 let transport = MockTransport::with(vec![
3603 response(200, &bitbucket_metadata()),
3604 response(
3605 200,
3606 &json!({"values":[], "next":"https://evil.example/next"}),
3607 ),
3608 ]);
3609 let provider = BitbucketProvider::new(bitbucket_config(), transport).expect("provider");
3610 assert!(matches!(
3611 provider
3612 .inspect(request(PullRequestProviderKind::BitbucketCloud))
3613 .await,
3614 Err(PullRequestError::MalformedResponse(_))
3615 ));
3616 }
3617
3618 #[tokio::test]
3619 async fn bitbucket_list_returns_source_free_summaries_in_provider_number_order() {
3620 let transport = MockTransport::with(vec![response(
3621 200,
3622 &json!({
3623 "values": [
3624 bitbucket_summary(8, "OPEN"),
3625 bitbucket_summary(3, "DECLINED")
3626 ]
3627 }),
3628 )]);
3629 let provider =
3630 BitbucketProvider::new(bitbucket_config(), transport.clone()).expect("provider");
3631 let mut input = list_request(PullRequestProviderKind::BitbucketCloud);
3632 input.state = PullRequestListState::Open;
3633 let result = provider.list(input).await.expect("list page");
3634 assert_eq!(
3635 (
3636 result
3637 .items
3638 .iter()
3639 .map(|summary| summary.coordinates.number)
3640 .collect::<Vec<_>>(),
3641 transport.request_url(0).is_some_and(|url| {
3642 url.contains("pagelen=10") && url.contains("state=OPEN")
3643 }),
3644 ),
3645 (vec![3, 8], true)
3646 );
3647 }
3648
3649 #[tokio::test]
3650 async fn bitbucket_list_requires_consent_before_transport() {
3651 let transport = Arc::new(MockTransport::default());
3652 let provider =
3653 BitbucketProvider::new(bitbucket_config(), transport.clone()).expect("provider");
3654 let mut input = list_request(PullRequestProviderKind::BitbucketCloud);
3655 input.consent_to_remote_access = false;
3656 let error = provider.list(input).await.expect_err("consent");
3657 assert_eq!(
3658 (error, transport.request_count()),
3659 (PullRequestError::ConsentRequired, 0)
3660 );
3661 }
3662
3663 #[tokio::test]
3664 async fn bitbucket_list_round_trips_same_origin_page_cursor() {
3665 let transport = MockTransport::with(vec![
3666 response(
3667 200,
3668 &json!({
3669 "values": [bitbucket_summary(1, "OPEN")],
3670 "next": "https://api.bitbucket.org/2.0/repositories/o/r/pullrequests?pagelen=10&page=2"
3671 }),
3672 ),
3673 response(200, &json!({"values": [bitbucket_summary(2, "OPEN")]})),
3674 ]);
3675 let provider =
3676 BitbucketProvider::new(bitbucket_config(), transport.clone()).expect("provider");
3677 let first_page = provider
3678 .list(list_request(PullRequestProviderKind::BitbucketCloud))
3679 .await
3680 .expect("first page");
3681 let mut second_request = list_request(PullRequestProviderKind::BitbucketCloud);
3682 second_request.cursor = first_page.next_cursor;
3683 let second_page = provider.list(second_request).await.expect("second page");
3684 assert_eq!(
3685 (
3686 first_page.has_more,
3687 second_page.items[0].coordinates.number,
3688 transport
3689 .request_url(1)
3690 .is_some_and(|url| url.contains("page=2")),
3691 ),
3692 (true, 2, true)
3693 );
3694 }
3695
3696 #[tokio::test]
3697 async fn bitbucket_list_rejects_cross_origin_pagination() {
3698 let transport = MockTransport::with(vec![response(
3699 200,
3700 &json!({
3701 "values": [bitbucket_summary(1, "OPEN")],
3702 "next": "https://attacker.invalid/repositories/o/r/pullrequests?page=2"
3703 }),
3704 )]);
3705 let provider = BitbucketProvider::new(bitbucket_config(), transport).expect("provider");
3706 assert!(matches!(
3707 provider
3708 .list(list_request(PullRequestProviderKind::BitbucketCloud))
3709 .await,
3710 Err(PullRequestError::MalformedResponse(_))
3711 ));
3712 }
3713
3714 #[tokio::test]
3715 async fn bitbucket_list_reports_missing_scope_as_api_error() {
3716 let transport = MockTransport::with(vec![response(
3717 403,
3718 &json!({"error":{"message":"missing pullrequest scope"}}),
3719 )]);
3720 let provider = BitbucketProvider::new(bitbucket_config(), transport).expect("provider");
3721 assert!(matches!(
3722 provider
3723 .list(list_request(PullRequestProviderKind::BitbucketCloud))
3724 .await,
3725 Err(PullRequestError::Api { status: 403, .. })
3726 ));
3727 }
3728
3729 #[tokio::test]
3730 async fn bitbucket_list_redacts_token_from_api_error() {
3731 let transport = MockTransport::with(vec![response(
3732 401,
3733 &json!({"error":{"message":"credential bb-list-secret rejected"}}),
3734 )]);
3735 let mut config = bitbucket_config();
3736 config.auth_token = PrAuthToken::new("bb-list-secret");
3737 let provider = BitbucketProvider::new(config, transport).expect("provider");
3738 let error = provider
3739 .list(list_request(PullRequestProviderKind::BitbucketCloud))
3740 .await
3741 .expect_err("API error");
3742 assert!(!error.to_string().contains("bb-list-secret"));
3743 }
3744
3745 #[test]
3746 fn overlap_is_disjoint_for_unrelated_inputs() {
3747 assert_eq!(
3748 semantic_pull_request_overlap(&semantic("a"), &semantic("b")).kind,
3749 PullRequestOverlapKind::Disjoint
3750 );
3751 }
3752
3753 #[test]
3754 fn overlap_classifies_shared_file_deterministically() {
3755 let mut left = semantic("a");
3756 let mut right = semantic("b");
3757 left.files.extend(["z.rs".to_owned(), "a.rs".to_owned()]);
3758 right.files.extend(["a.rs".to_owned(), "z.rs".to_owned()]);
3759 assert_eq!(
3760 semantic_pull_request_overlap(&left, &right).files,
3761 vec!["a.rs".to_owned(), "z.rs".to_owned()]
3762 );
3763 }
3764
3765 #[test]
3766 fn overlap_classifies_shared_service() {
3767 let mut left = semantic("a");
3768 let mut right = semantic("b");
3769 left.services.insert("billing".to_owned());
3770 right.services.insert("billing".to_owned());
3771 assert_eq!(
3772 semantic_pull_request_overlap(&left, &right).kind,
3773 PullRequestOverlapKind::Service
3774 );
3775 }
3776
3777 #[test]
3778 fn overlap_classifies_shared_community() {
3779 let mut left = semantic("a");
3780 let mut right = semantic("b");
3781 left.communities.insert("community-1".to_owned());
3782 right.communities.insert("community-1".to_owned());
3783 assert_eq!(
3784 semantic_pull_request_overlap(&left, &right).kind,
3785 PullRequestOverlapKind::Community
3786 );
3787 }
3788
3789 #[test]
3790 fn overlap_marks_shared_migration_conflicting() {
3791 let mut left = semantic("a");
3792 let mut right = semantic("b");
3793 left.files.insert("migrations/001.sql".to_owned());
3794 right.files.insert("migrations/001.sql".to_owned());
3795 assert_eq!(
3796 semantic_pull_request_overlap(&left, &right).kind,
3797 PullRequestOverlapKind::Conflicting
3798 );
3799 }
3800
3801 #[test]
3802 fn order_follows_dependency_direction() {
3803 let left = semantic("a");
3804 let mut right = semantic("b");
3805 right.depends_on.insert("a".to_owned());
3806 assert_eq!(
3807 suggest_pull_request_order(&left, &right).order,
3808 PullRequestOrder::LeftFirst
3809 );
3810 }
3811
3812 #[test]
3813 fn order_coordinates_dependency_cycle() {
3814 let mut left = semantic("a");
3815 let mut right = semantic("b");
3816 left.depends_on.insert("b".to_owned());
3817 right.depends_on.insert("a".to_owned());
3818 assert_eq!(
3819 suggest_pull_request_order(&left, &right).order,
3820 PullRequestOrder::Coordinated
3821 );
3822 }
3823
3824 #[test]
3825 fn order_is_indefinite_for_stale_readiness() {
3826 let mut left = semantic("a");
3827 left.readiness = PullRequestReadiness::Stale;
3828 assert_eq!(
3829 suggest_pull_request_order(&left, &semantic("b")).order,
3830 PullRequestOrder::NoDefinitiveOrder
3831 );
3832 }
3833
3834 #[test]
3835 fn order_prefers_ready_change_when_other_is_blocked() {
3836 let left = semantic("a");
3837 let mut right = semantic("b");
3838 right.readiness = PullRequestReadiness::Blocked;
3839 assert_eq!(
3840 suggest_pull_request_order(&left, &right).order,
3841 PullRequestOrder::LeftFirst
3842 );
3843 }
3844}