Skip to main content

code_system_graph_core/
pull_requests.rs

1//! Explicitly enabled, transport-independent pull-request inspection.
2//!
3//! Provider clients in this module only speak documented public REST contracts. They never
4//! perform network I/O themselves and never retain source patches or raw HTTP responses.
5
6use 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/// Pull-request service understood by the public provider adapter.
26#[derive(
27    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
28)]
29#[serde(rename_all = "snake_case")]
30pub enum PullRequestProviderKind {
31    /// GitHub's public REST API.
32    GitHub,
33    /// Bitbucket Cloud's public REST API 2.0.
34    BitbucketCloud,
35    /// Bitbucket Data Center, represented for configuration compatibility but not implemented.
36    BitbucketDataCenter,
37}
38
39/// Stable provider coordinates for one pull request.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41pub struct PullRequestCoordinates {
42    /// Provider that owns the pull request.
43    pub provider: PullRequestProviderKind,
44    /// GitHub owner or Bitbucket workspace.
45    pub owner: String,
46    /// GitHub repository name or Bitbucket repository slug.
47    pub repository: String,
48    /// Provider-native pull-request number.
49    pub number: u64,
50}
51
52/// Secret bearer token whose debug representation is always redacted.
53#[derive(Clone, Default, PartialEq, Eq)]
54pub struct PrAuthToken(String);
55
56impl PrAuthToken {
57    /// Wraps a token without validating or persisting it.
58    #[must_use]
59    pub fn new(token: impl Into<String>) -> Self {
60        Self(token.into())
61    }
62
63    /// Exposes the token only to an injected transport implementation.
64    #[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/// Explicit provider configuration and hard request bounds.
81#[derive(Clone, Serialize, Deserialize, JsonSchema)]
82pub struct PullRequestProviderConfig {
83    /// Provider contract selected for this client.
84    pub provider: PullRequestProviderKind,
85    /// Master opt-in switch. The default is `false`.
86    pub enabled: bool,
87    /// Provider API base URL.
88    pub api_base_url: String,
89    /// Exact allowed API base URLs.
90    pub api_base_url_allowlist: Vec<String>,
91    /// Allows plain HTTP only for loopback test servers.
92    pub allow_loopback_http: bool,
93    /// Ephemeral bearer token, excluded from serialization and schemas.
94    #[serde(skip)]
95    #[schemars(skip)]
96    pub auth_token: PrAuthToken,
97    /// Ephemeral Atlassian account email for Bitbucket API-token Basic authentication.
98    #[serde(skip)]
99    #[schemars(skip)]
100    pub basic_auth_username: Option<String>,
101    /// Per-request timeout in milliseconds.
102    pub request_timeout_ms: u64,
103    /// Maximum cumulative response bytes accepted by one inspection.
104    pub max_output_bytes: usize,
105    /// Maximum changed files, checks, or reviews retained per collection.
106    pub max_items: usize,
107    /// Maximum pages fetched per paginated endpoint.
108    pub max_pages: usize,
109    /// Structured result cache lifetime in seconds.
110    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    /// Returns a disabled Bitbucket Cloud configuration with the public endpoint allowlisted.
157    #[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    /// Validates URL policy and non-zero request bounds.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`PullRequestError::InvalidConfiguration`] for an unsafe URL or empty bound.
172    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/// One opt-in inspection request.
216#[derive(Debug, Clone)]
217pub struct PullRequestInspectRequest {
218    /// Provider coordinates to inspect.
219    pub coordinates: PullRequestCoordinates,
220    /// Per-call confirmation that remote access is allowed.
221    pub consent_to_remote_access: bool,
222    /// Cooperative cancellation signal.
223    pub cancellation: CancellationToken,
224}
225
226/// Provider-native lifecycle filter for pull-request listings.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
228#[serde(rename_all = "snake_case")]
229pub enum PullRequestListState {
230    /// Return only pull requests that remain open.
231    Open,
232    /// Return provider-native closed pull requests, including merged pull requests.
233    Closed,
234    /// Return pull requests in every provider-native state.
235    #[default]
236    All,
237}
238
239/// One explicitly authorized request for a bounded pull-request list page.
240#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
241pub struct PullRequestListRequest {
242    /// Provider contract used for the request.
243    pub provider: PullRequestProviderKind,
244    /// GitHub owner or Bitbucket workspace.
245    pub owner: String,
246    /// GitHub repository name or Bitbucket repository slug.
247    pub repository: String,
248    /// Provider-native lifecycle filter.
249    #[serde(default)]
250    pub state: PullRequestListState,
251    /// Opaque positive page cursor returned by a previous list response.
252    #[serde(default)]
253    pub cursor: Option<String>,
254    /// Maximum number of summaries to return, from 1 through 100.
255    #[schemars(range(min = 1, max = 100))]
256    pub limit: usize,
257    /// Per-call confirmation that remote access is allowed.
258    pub consent_to_remote_access: bool,
259    /// Cooperative cancellation signal, excluded from serialized requests and schemas.
260    #[serde(skip, default)]
261    #[schemars(skip)]
262    pub cancellation: CancellationToken,
263}
264
265/// Source-free pull-request metadata returned by list operations.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
267pub struct PullRequestSummary {
268    /// Stable provider coordinates.
269    pub coordinates: PullRequestCoordinates,
270    /// Pull-request title.
271    pub title: String,
272    /// Human-facing provider URL.
273    pub url: String,
274    /// Normalized lifecycle state.
275    pub state: PullRequestState,
276    /// Whether the provider marks the pull request as draft.
277    pub draft: bool,
278    /// Provider-native author identity.
279    pub author: String,
280    /// Merge destination branch.
281    pub base_branch: String,
282    /// Proposed branch name, without source content.
283    pub head_branch: String,
284    /// Provider creation timestamp retained as an RFC 3339 string.
285    pub created_at: String,
286    /// Provider update timestamp retained as an RFC 3339 string.
287    pub updated_at: String,
288}
289
290/// One bounded, deterministically ordered pull-request list page.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
292pub struct PullRequestListPage {
293    /// Source-free summaries sorted by provider-native pull-request number.
294    pub items: Vec<PullRequestSummary>,
295    /// Cursor for the next provider page, or `None` when no next page was reported.
296    pub next_cursor: Option<String>,
297    /// Whether the provider reported another page.
298    pub has_more: bool,
299    /// Whether a configured item bound reduced the caller's requested page size.
300    pub truncated: bool,
301    /// Last observed provider rate-limit metadata.
302    pub rate_limit: PullRequestRateLimit,
303    /// Non-fatal truncation or compatibility warnings.
304    pub warnings: Vec<PullRequestWarning>,
305}
306
307/// Repository identity attached to a pull-request ref.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
309pub struct PullRequestRepository {
310    /// Provider-native owner/name identity.
311    pub full_name: String,
312    /// Public repository URL when supplied by the provider.
313    pub url: Option<String>,
314}
315
316/// Base or head ref metadata.
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
318pub struct PullRequestRef {
319    /// Branch or named ref.
320    pub name: String,
321    /// Commit SHA or hash.
322    pub sha: String,
323    /// Repository containing the ref.
324    pub repository: PullRequestRepository,
325}
326
327/// Provider-neutral pull-request state.
328#[derive(
329    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
330)]
331#[serde(rename_all = "snake_case")]
332pub enum PullRequestState {
333    /// Open for work or review.
334    Open,
335    /// Closed without a confirmed merge.
336    Closed,
337    /// Merged.
338    Merged,
339    /// Provider returned an unrecognized state.
340    Unknown,
341}
342
343/// Common pull-request metadata.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
345pub struct PullRequestMetadata {
346    /// Provider-native identifier.
347    pub id: String,
348    /// Pull-request title.
349    pub title: String,
350    /// Human-facing URL.
351    pub url: String,
352    /// Normalized lifecycle state.
353    pub state: PullRequestState,
354    /// Whether the provider marks the pull request as draft.
355    pub draft: bool,
356    /// Provider-native author identity.
357    pub author: String,
358    /// Merge destination.
359    pub base: PullRequestRef,
360    /// Proposed source.
361    pub head: PullRequestRef,
362    /// Provider timestamp, retained as its RFC 3339 string.
363    pub created_at: String,
364    /// Provider timestamp, retained as its RFC 3339 string.
365    pub updated_at: String,
366}
367
368/// Normalized changed-file status.
369#[derive(
370    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
371)]
372#[serde(rename_all = "snake_case")]
373pub enum ChangedFileStatus {
374    /// Newly added path.
375    Added,
376    /// Modified path.
377    Modified,
378    /// Removed path.
379    Removed,
380    /// Renamed path.
381    Renamed,
382    /// Copied path.
383    Copied,
384    /// Provider status was not recognized.
385    Unknown,
386}
387
388/// Source-free changed-file summary.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
390pub struct PullRequestChangedFile {
391    /// Normalized provider status.
392    pub status: ChangedFileStatus,
393    /// Previous path for renames or deletions.
394    pub old_path: Option<String>,
395    /// Current path for additions, modifications, or renames.
396    pub new_path: Option<String>,
397    /// Added line count.
398    pub additions: u64,
399    /// Deleted line count.
400    pub deletions: u64,
401    /// Whether provider metadata identifies or strongly implies a binary file.
402    pub binary: bool,
403    /// Whether a provider patch was present but intentionally discarded.
404    pub patch_truncated: bool,
405}
406
407/// Normalized CI or check outcome.
408#[derive(
409    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
410)]
411#[serde(rename_all = "snake_case")]
412pub enum CheckState {
413    /// All reported checks completed successfully.
414    Success,
415    /// At least one check failed.
416    Failure,
417    /// Checks are queued or running.
418    Pending,
419    /// No usable status was reported.
420    Unknown,
421}
422
423/// One provider check or commit status.
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
425pub struct PullRequestCheck {
426    /// Check name or key.
427    pub name: String,
428    /// Normalized check state.
429    pub state: CheckState,
430    /// Provider details URL.
431    pub url: Option<String>,
432}
433
434/// Aggregate CI state and bounded checks.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
436pub struct PullRequestCi {
437    /// Aggregate state.
438    pub state: CheckState,
439    /// Individual checks or statuses.
440    pub checks: Vec<PullRequestCheck>,
441}
442
443/// One review or participant decision.
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
445pub struct PullRequestReview {
446    /// Provider-native review or participant identifier.
447    pub id: String,
448    /// Reviewer identity.
449    pub author: String,
450    /// Provider review state.
451    pub state: String,
452    /// Submission timestamp when available.
453    pub submitted_at: Option<String>,
454}
455
456/// Aggregate review readiness.
457#[derive(
458    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
459)]
460#[serde(rename_all = "snake_case")]
461pub enum ReviewState {
462    /// At least one current approval and no current change request.
463    Approved,
464    /// A current review requests changes.
465    ChangesRequested,
466    /// Reviews exist but do not establish readiness.
467    Pending,
468    /// No usable review information was reported.
469    Unknown,
470}
471
472/// Aggregate approvals and bounded review decisions.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
474pub struct PullRequestReviewSummary {
475    /// Aggregate review state.
476    pub state: ReviewState,
477    /// Number of current approving reviewers.
478    pub approvals: usize,
479    /// Reviews or participants retained under the item limit.
480    pub reviews: Vec<PullRequestReview>,
481}
482
483/// Provider rate-limit metadata.
484#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
485pub struct PullRequestRateLimit {
486    /// Request quota limit.
487    pub limit: Option<u64>,
488    /// Remaining requests.
489    pub remaining: Option<u64>,
490    /// Provider reset timestamp or duration string.
491    pub reset: Option<String>,
492    /// Retry delay in seconds supplied by `Retry-After`.
493    pub retry_after_seconds: Option<u64>,
494}
495
496/// Non-fatal provider degradation.
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
498pub struct PullRequestWarning {
499    /// Stable machine-readable warning code.
500    pub code: String,
501    /// Secret-free explanation.
502    pub message: String,
503}
504
505/// Complete source-free inspection result.
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
507pub struct PullRequestInspection {
508    /// Provider coordinates.
509    pub coordinates: PullRequestCoordinates,
510    /// Common metadata.
511    pub metadata: PullRequestMetadata,
512    /// Bounded changed-file summaries.
513    pub changed_files: Vec<PullRequestChangedFile>,
514    /// CI/check state.
515    pub ci: PullRequestCi,
516    /// Review/approval state.
517    pub review: PullRequestReviewSummary,
518    /// Last observed rate-limit metadata.
519    pub rate_limit: PullRequestRateLimit,
520    /// Non-fatal truncation or compatibility warnings.
521    pub warnings: Vec<PullRequestWarning>,
522    /// Deterministic fingerprint of metadata and changed-file summaries.
523    pub fingerprint: String,
524    /// Whether this result came directly from the structured cache.
525    pub from_cache: bool,
526}
527
528/// Bounded HTTP method set needed by provider adapters.
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
530pub enum PrHttpMethod {
531    /// HTTP GET.
532    Get,
533}
534
535/// Ephemeral provider authentication passed only to the HTTP transport.
536#[derive(Clone, Default)]
537pub enum PrHttpAuthentication {
538    /// Anonymous public API request.
539    #[default]
540    None,
541    /// OAuth or repository/project/workspace access token.
542    Bearer(PrAuthToken),
543    /// Atlassian account email and API token.
544    Basic {
545        /// Atlassian account email.
546        username: String,
547        /// Ephemeral API token used as the Basic-auth password.
548        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/// HTTP request passed to an injected transport.
563#[derive(Clone)]
564pub struct PrHttpRequest {
565    /// Request method.
566    pub method: PrHttpMethod,
567    /// Fully validated provider URL.
568    pub url: String,
569    /// Non-secret request headers.
570    pub headers: BTreeMap<String, String>,
571    /// Ephemeral provider authentication sent separately from ordinary headers.
572    pub authentication: PrHttpAuthentication,
573    /// Maximum accepted response body size.
574    pub max_response_bytes: usize,
575    /// Request timeout.
576    pub timeout: Duration,
577    /// Cooperative cancellation signal.
578    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/// Bounded HTTP response returned by an injected transport.
596#[derive(Clone, PartialEq, Eq)]
597pub struct PrHttpResponse {
598    /// HTTP status code.
599    pub status: u16,
600    /// Response headers.
601    pub headers: BTreeMap<String, String>,
602    /// Response bytes, bounded by the request contract.
603    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/// Secret-free transport failure.
618#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
619#[error("pull-request transport failed: {message}")]
620pub struct PrHttpTransportError {
621    /// Transport-supplied message, sanitized again by the provider.
622    pub message: String,
623}
624
625/// Injectable asynchronous HTTP boundary.
626#[async_trait]
627pub trait PrHttpTransport: Send + Sync {
628    /// Sends one bounded request.
629    ///
630    /// # Errors
631    ///
632    /// Returns [`PrHttpTransportError`] for transport failures.
633    async fn send(&self, request: PrHttpRequest) -> Result<PrHttpResponse, PrHttpTransportError>;
634}
635
636/// Production HTTPS transport with redirects disabled and bounded streaming responses.
637#[derive(Debug, Clone)]
638pub struct ReqwestPrHttpTransport {
639    client: reqwest::Client,
640}
641
642impl ReqwestPrHttpTransport {
643    /// Creates a reusable provider HTTP client.
644    ///
645    /// # Errors
646    ///
647    /// Returns [`PrHttpTransportError`] when the client cannot be constructed.
648    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/// Pull-request provider failure.
746#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
747pub enum PullRequestError {
748    /// Provider use was not explicitly enabled.
749    #[error("pull-request provider is disabled")]
750    Disabled,
751    /// The caller did not consent to remote access.
752    #[error("remote pull-request access requires explicit per-request consent")]
753    ConsentRequired,
754    /// Configuration violates URL or budget policy.
755    #[error("invalid pull-request provider configuration: {0}")]
756    InvalidConfiguration(String),
757    /// Request coordinates do not match the configured provider.
758    #[error("pull-request coordinates do not match the configured provider")]
759    ProviderMismatch,
760    /// The selected provider contract is represented but intentionally unsupported.
761    #[error("pull-request provider is not supported")]
762    UnsupportedProvider,
763    /// Operation was cancelled.
764    #[error("pull-request inspection was cancelled")]
765    Cancelled,
766    /// Provider request exceeded its timeout.
767    #[error("pull-request provider request timed out")]
768    Timeout,
769    /// Response exceeded the configured byte budget.
770    #[error("pull-request provider response exceeded the configured output limit")]
771    OutputLimitExceeded,
772    /// Provider requested caller-managed backoff.
773    #[error("pull-request provider rate limited the request")]
774    RateLimited {
775        /// HTTP response status.
776        status: u16,
777        /// Provider rate-limit metadata.
778        metadata: PullRequestRateLimit,
779    },
780    /// Provider returned an unsuccessful API response.
781    #[error("pull-request provider returned HTTP {status}: {message}")]
782    Api {
783        /// HTTP response status.
784        status: u16,
785        /// Bounded, token-redacted provider message.
786        message: String,
787    },
788    /// Provider returned malformed or contract-incompatible JSON.
789    #[error("malformed pull-request provider response: {0}")]
790    MalformedResponse(String),
791    /// Injected transport failed.
792    #[error("pull-request transport failed: {0}")]
793    Transport(String),
794    /// Internal cache mutex was poisoned.
795    #[error("pull-request cache is unavailable")]
796    CacheUnavailable,
797}
798
799/// Asynchronous provider interface.
800#[async_trait]
801pub trait PullRequestProvider: Send + Sync {
802    /// Returns the provider contract implemented by this client.
803    fn kind(&self) -> PullRequestProviderKind;
804
805    /// Inspects one pull request after checking both opt-in gates.
806    ///
807    /// # Errors
808    ///
809    /// Returns [`PullRequestError`] for policy, transport, API, cancellation, or parse failures.
810    async fn inspect(
811        &self,
812        request: PullRequestInspectRequest,
813    ) -> Result<PullRequestInspection, PullRequestError>;
814
815    /// Lists one bounded, source-free page after checking both opt-in gates.
816    ///
817    /// # Errors
818    ///
819    /// Returns [`PullRequestError`] for policy, bounds, transport, API, cancellation, or parse
820    /// failures.
821    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
1136/// GitHub public REST pull-request adapter.
1137pub struct GitHubProvider {
1138    core: ProviderCore,
1139}
1140
1141impl GitHubProvider {
1142    /// Creates a GitHub provider over an injected transport.
1143    ///
1144    /// # Errors
1145    ///
1146    /// Returns [`PullRequestError`] when configuration is invalid or selects another provider.
1147    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
1444/// Bitbucket Cloud public REST 2.0 pull-request adapter.
1445pub struct BitbucketProvider {
1446    core: ProviderCore,
1447}
1448
1449impl BitbucketProvider {
1450    /// Creates a Bitbucket Cloud provider over an injected transport.
1451    ///
1452    /// # Errors
1453    ///
1454    /// Returns [`PullRequestError`] when configuration is invalid or selects another provider.
1455    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/// Contract role used by semantic overlap and ordering.
2430#[derive(
2431    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2432)]
2433#[serde(rename_all = "snake_case")]
2434pub enum ContractRole {
2435    /// Defines or serves the contract.
2436    Provider,
2437    /// Consumes the contract.
2438    Consumer,
2439}
2440
2441/// Contract touched by a pull request.
2442#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
2443pub struct PullRequestContractChange {
2444    /// Stable contract identity.
2445    pub contract: String,
2446    /// Relationship of the changed repository to the contract.
2447    pub role: ContractRole,
2448    /// Whether compatibility analysis classified the change as breaking.
2449    pub breaking: bool,
2450}
2451
2452/// Readiness freshness for conservative ordering.
2453#[derive(
2454    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2455)]
2456#[serde(rename_all = "snake_case")]
2457pub enum PullRequestReadiness {
2458    /// CI and review data establish readiness.
2459    Ready,
2460    /// CI or review data establishes a blocker.
2461    Blocked,
2462    /// Required readiness data is unavailable.
2463    Unknown,
2464    /// Readiness data is stale.
2465    Stale,
2466}
2467
2468/// Provider-neutral semantic inputs for one pull request.
2469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2470pub struct PullRequestSemanticInput {
2471    /// Stable fingerprint used as an ordering identity.
2472    pub fingerprint: String,
2473    /// Changed paths.
2474    pub files: BTreeSet<String>,
2475    /// Contracts touched by the change.
2476    pub contracts: BTreeSet<PullRequestContractChange>,
2477    /// Stable service identities touched by the change.
2478    pub services: BTreeSet<String>,
2479    /// Stable community identities touched by the change.
2480    pub communities: BTreeSet<String>,
2481    /// Fingerprints that must be merged before this change.
2482    pub depends_on: BTreeSet<String>,
2483    /// Current CI and review readiness.
2484    pub readiness: PullRequestReadiness,
2485}
2486
2487/// Strongest deterministic semantic overlap classification.
2488#[derive(
2489    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2490)]
2491#[serde(rename_all = "snake_case")]
2492pub enum PullRequestOverlapKind {
2493    /// No common semantic input.
2494    Disjoint,
2495    /// At least one path overlaps.
2496    File,
2497    /// At least one contract overlaps.
2498    Contract,
2499    /// At least one service overlaps.
2500    Service,
2501    /// At least one graph community overlaps.
2502    Community,
2503    /// Shared migration or breaking-contract changes require coordination.
2504    Conflicting,
2505}
2506
2507/// Deterministic semantic overlap result.
2508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2509pub struct PullRequestOverlap {
2510    /// Strongest overlap classification.
2511    pub kind: PullRequestOverlapKind,
2512    /// Shared paths in lexical order.
2513    pub files: Vec<String>,
2514    /// Shared contract identities in lexical order.
2515    pub contracts: Vec<String>,
2516    /// Shared service identities in lexical order.
2517    pub services: Vec<String>,
2518    /// Shared community identities in lexical order.
2519    pub communities: Vec<String>,
2520}
2521
2522/// Computes deterministic overlap without treating missing evidence as safety.
2523#[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/// Suggested relationship between two review or merge operations.
2583#[derive(
2584    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2585)]
2586#[serde(rename_all = "snake_case")]
2587pub enum PullRequestOrder {
2588    /// Review or merge the left input first.
2589    LeftFirst,
2590    /// Review or merge the right input first.
2591    RightFirst,
2592    /// Changes should be coordinated rather than linearly ordered.
2593    Coordinated,
2594    /// Evidence does not justify a definitive order.
2595    NoDefinitiveOrder,
2596}
2597
2598/// Deterministic review/merge ordering suggestion.
2599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2600pub struct PullRequestOrderSuggestion {
2601    /// Suggested relationship.
2602    pub order: PullRequestOrder,
2603    /// Stable machine-readable reasons in lexical order.
2604    pub reasons: Vec<String>,
2605}
2606
2607/// Suggests a conservative review/merge order from dependencies, contracts, and readiness.
2608#[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}