diff --git a/src/apps/desktop/src/api/review_platform_api.rs b/src/apps/desktop/src/api/review_platform_api.rs index a951512c7a..a5cd56bc8e 100644 --- a/src/apps/desktop/src/api/review_platform_api.rs +++ b/src/apps/desktop/src/api/review_platform_api.rs @@ -4,7 +4,7 @@ use crate::api::app_state::AppState; use log::error; use openbitfun_core::service::review_platform::{ untrusted_repository_error_message, ReviewPlatformCiLog, ReviewPlatformDetailSection, - ReviewPlatformError, ReviewPlatformIssueEvidence, ReviewPlatformKind, + ReviewPlatformError, ReviewPlatformIssueEvidence, ReviewPlatformKind, ReviewPlatformListState, ReviewPlatformPullRequestDetail, ReviewPlatformPullRequestDetailPage, ReviewPlatformPullRequestReviewTarget, ReviewPlatformService, ReviewPlatformWorkspaceSnapshot, }; @@ -18,6 +18,8 @@ pub struct ReviewPlatformWorkspaceSnapshotRequest { pub remote_id: Option, pub page: Option, pub per_page: Option, + #[serde(default)] + pub state: ReviewPlatformListState, } #[derive(Debug, Deserialize)] @@ -76,11 +78,12 @@ pub async fn review_platform_get_workspace_snapshot( _state: State<'_, AppState>, request: ReviewPlatformWorkspaceSnapshotRequest, ) -> Result { - ReviewPlatformService::workspace_snapshot( + ReviewPlatformService::workspace_snapshot_with_state( &request.repository_path, request.remote_id.as_deref(), request.page, request.per_page, + request.state, ) .await .map_err(|error| { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs index 9b374d1c99..7c595690bb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -201,6 +201,7 @@ impl GetFileDiffTool { let platform = match pull_request.platform() { "github" => Some(ReviewPlatformKind::Github), "gitlab" => Some(ReviewPlatformKind::Gitlab), + "gitee" => Some(ReviewPlatformKind::Gitee), "gitcode" => None, value => { return Err(OpenBitFunError::tool(format!( @@ -2034,60 +2035,65 @@ mod tests { #[test] fn pull_request_diff_route_uses_prepared_provider_identity_not_remote_id() { - let mut context = prepared_context(); - context.custom_data.insert( - "deep_review_run_manifest".to_string(), - json!({ - "evidencePack": { - "reviewTarget": { - "version": 1, - "source": "pull_request", - "fingerprint": "provider-route-fingerprint", - "baseRevision": "1111111111111111111111111111111111111111", - "headRevision": "2222222222222222222222222222222222222222", - "completeness": "complete", - "workspaceBinding": "unavailable", - "pullRequest": { - "remoteId": "fabricated-remote-that-must-not-route", - "platform": "github", - "host": "github.com", - "projectPath": "exact/project", - "pullRequestId": "42", - "number": 42, - "webUrl": "https://github.com/exact/project/pull/42" - }, - "files": [{ - "path": "src/lib.rs", - "status": "modified", - "completeness": "complete" - }], - "limitations": [] + for (platform_name, platform, host) in [ + ("github", ReviewPlatformKind::Github, "github.com"), + ("gitee", ReviewPlatformKind::Gitee, "gitee.com"), + ] { + let mut context = prepared_context(); + context.custom_data.insert( + "deep_review_run_manifest".to_string(), + json!({ + "evidencePack": { + "reviewTarget": { + "version": 1, + "source": "pull_request", + "fingerprint": "provider-route-fingerprint", + "baseRevision": "1111111111111111111111111111111111111111", + "headRevision": "2222222222222222222222222222222222222222", + "completeness": "complete", + "workspaceBinding": "unavailable", + "pullRequest": { + "remoteId": "fabricated-remote-that-must-not-route", + "platform": platform_name, + "host": host, + "projectPath": "exact/project", + "pullRequestId": "42", + "number": 42, + "webUrl": format!("https://{host}/exact/project/pulls/42") + }, + "files": [{ + "path": "src/lib.rs", + "status": "modified", + "completeness": "complete" + }], + "limitations": [] + } } + }), + ); + let evidence = GetFileDiffTool::target_evidence(&context) + .expect("evidence should parse") + .expect("evidence should exist"); + + let route = + GetFileDiffTool::pull_request_file_diff_route(&context, &evidence, "src/lib.rs") + .expect("prepared provider route should be exact"); + + assert_eq!( + route, + ProviderFileDiffRoute::Identity { + platform, + host: host.to_string(), + project_path: "exact/project".to_string(), + pull_request_id: "42".to_string(), + base_revision: "1111111111111111111111111111111111111111".to_string(), + head_revision: "2222222222222222222222222222222222222222".to_string(), + file_path: "src/lib.rs".to_string(), + file_page_hint: Some(1), + repository_path: None, } - }), - ); - let evidence = GetFileDiffTool::target_evidence(&context) - .expect("evidence should parse") - .expect("evidence should exist"); - - let route = - GetFileDiffTool::pull_request_file_diff_route(&context, &evidence, "src/lib.rs") - .expect("prepared provider route should be exact"); - - assert_eq!( - route, - ProviderFileDiffRoute::Identity { - platform: ReviewPlatformKind::Github, - host: "github.com".to_string(), - project_path: "exact/project".to_string(), - pull_request_id: "42".to_string(), - base_revision: "1111111111111111111111111111111111111111".to_string(), - head_revision: "2222222222222222222222222222222222222222".to_string(), - file_path: "src/lib.rs".to_string(), - file_page_hint: Some(1), - repository_path: None, - } - ); + ); + } } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs index 15c4ace219..e100808a50 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -8,8 +8,8 @@ use crate::agentic::tools::framework::{ }; use crate::service::review_platform::{ ReviewPlatformApprovalRequest, ReviewPlatformCreatePullRequestRequest, - ReviewPlatformDetailSection, ReviewPlatformError, ReviewPlatformKind, ReviewPlatformRemote, - ReviewPlatformReplyToThreadRequest, ReviewPlatformRequestChangesRequest, + ReviewPlatformDetailSection, ReviewPlatformError, ReviewPlatformKind, ReviewPlatformListState, + ReviewPlatformRemote, ReviewPlatformReplyToThreadRequest, ReviewPlatformRequestChangesRequest, ReviewPlatformResolveThreadRequest, ReviewPlatformService, ReviewPlatformSubmitReviewRequest, ReviewSubmitEvent, }; @@ -130,6 +130,7 @@ impl ReviewPlatformTool { "github" => Ok(ReviewPlatformKind::Github), "gitlab" => Ok(ReviewPlatformKind::Gitlab), "gitcode" => Ok(ReviewPlatformKind::Gitcode), + "gitee" => Ok(ReviewPlatformKind::Gitee), "unknown" => Ok(ReviewPlatformKind::Unknown), other => Err(OpenBitFunError::tool(format!( "Unsupported review platform kind: {}", @@ -138,6 +139,15 @@ impl ReviewPlatformTool { } } + fn list_state(input: &Value) -> OpenBitFunResult { + match input.get("state") { + None | Some(Value::Null) => Ok(ReviewPlatformListState::All), + Some(state) => serde_json::from_value(state.clone()).map_err(|error| { + OpenBitFunError::tool(format!("Invalid pull request state filter: {error}")) + }), + } + } + async fn resolve_remote_id( action: &str, repository_path: &str, @@ -242,7 +252,7 @@ impl Tool for ReviewPlatformTool { Use this for remote review-platform operations such as discovering remotes, loading the workspace PR snapshot, counting pull requests, listing pull requests, opening full or paginated pull request detail, loading CI logs, creating a pull request, replying to review threads, submitting a comment review, approving, revoking approval, requesting changes, or resolving a review thread. Use ExecCommand for local repository state and branch/commit/push operations. -GitHub authentication is owned by the local `gh` CLI and must never use token actions. Authentication-token actions are only for GitLab and GitCode when the user explicitly provides a token or asks to clear a stored token. Never guess or expose token values. +GitHub authentication is owned by the local `gh` CLI and must never use token actions. Authentication-token actions are only for GitLab, GitCode, and Gitee when the user explicitly provides a token or asks to clear a stored token. Never guess or expose token values. When returning pull request results to the user, include the provider web URL so the chat UI can open the pull request detail panel naturally."#.to_string()) } @@ -301,6 +311,11 @@ When returning pull request results to the user, include the provider web URL so "type": "integer", "description": "Page size for list_pull_requests, get_workspace_snapshot, or get_pull_request_detail_page." }, + "state": { + "type": "string", + "enum": ["all", "open", "draft", "merged", "closed"], + "description": "Repository-wide PR state filter for list_pull_requests, count_pull_requests, or get_workspace_snapshot; defaults to all. Only use filters advertised in capabilities.supportedPullRequestStates." + }, "section": { "type": "string", "enum": ["overview", "ci", "files", "commits", "reviews"], @@ -316,8 +331,8 @@ When returning pull request results to the user, include the provider web URL so }, "platform": { "type": "string", - "enum": ["github", "gitlab", "gitcode", "unknown"], - "description": "GitLab or GitCode platform kind for update_auth_token or clear_auth_token. GitHub uses local gh authentication." + "enum": ["github", "gitlab", "gitcode", "gitee", "unknown"], + "description": "GitLab, GitCode, or Gitee platform kind for update_auth_token or clear_auth_token. GitHub uses local gh authentication." }, "host": { "type": "string", @@ -325,7 +340,7 @@ When returning pull request results to the user, include the provider web URL so }, "token": { "type": "string", - "description": "GitLab or GitCode personal access token for update_auth_token. Only provide this when the user explicitly asks to store that token. Never provide a GitHub token." + "description": "GitLab, GitCode, or Gitee personal access token for update_auth_token. Only provide this when the user explicitly asks to store that token. Never provide a GitHub token." }, "title": { "type": "string", @@ -782,11 +797,12 @@ When returning pull request results to the user, include the provider web URL so .and_then(Value::as_u64) .map(|value| value as u32); let remote_id = Self::optional_string_field(input, "remote_id"); - let snapshot = ReviewPlatformService::workspace_snapshot( + let snapshot = ReviewPlatformService::workspace_snapshot_with_state( &repository_path, remote_id.as_deref(), page, per_page, + Self::list_state(input)?, ) .await .map_err(|error| OpenBitFunError::tool(error.to_string()))?; @@ -820,11 +836,12 @@ When returning pull request results to the user, include the provider web URL so let remote_id = resolved_remote_id .clone() .expect("remote-bound action should resolve a remote"); - let snapshot = ReviewPlatformService::workspace_snapshot( + let snapshot = ReviewPlatformService::workspace_snapshot_with_state( &repository_path, Some(remote_id.as_str()), Some(1), Some(1), + Self::list_state(input)?, ) .await .map_err(|error| OpenBitFunError::tool(error.to_string()))?; @@ -864,11 +881,12 @@ When returning pull request results to the user, include the provider web URL so let remote_id = resolved_remote_id .clone() .expect("remote-bound action should resolve a remote"); - let snapshot = ReviewPlatformService::workspace_snapshot( + let snapshot = ReviewPlatformService::workspace_snapshot_with_state( &repository_path, Some(remote_id.as_str()), page, per_page, + Self::list_state(input)?, ) .await .map_err(|error| OpenBitFunError::tool(error.to_string()))?; @@ -1221,7 +1239,8 @@ fn canonical_supported_remotes(remotes: &[ReviewPlatformRemote]) -> Vec<&ReviewP ReviewPlatformKind::Github => 0, ReviewPlatformKind::Gitlab => 1, ReviewPlatformKind::Gitcode => 2, - ReviewPlatformKind::Unknown => 3, + ReviewPlatformKind::Gitee => 3, + ReviewPlatformKind::Unknown => 4, }; let normalized_host = remote.host.trim().to_ascii_lowercase(); let normalized_project = remote.project_path.trim_matches('/').to_ascii_lowercase(); @@ -1271,6 +1290,26 @@ fn remote_selection_result( mod tests { use super::*; + #[test] + fn gitee_uses_the_existing_write_permission_and_concurrency_boundary() { + let tool = ReviewPlatformTool::new(); + assert_eq!( + ReviewPlatformTool::platform_kind(&json!({"platform":"gitee"})).unwrap(), + ReviewPlatformKind::Gitee + ); + assert!(!tool.is_readonly()); + for action in [ + ACTION_CREATE, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_UPDATE_AUTH_TOKEN, + ] { + assert!(!tool.is_concurrency_safe(Some(&json!({"action":action,"platform":"gitee"})))); + } + assert!(tool.is_concurrency_safe(Some(&json!({"action":ACTION_LIST,"platform":"gitee"})))); + } + fn github_remote(id: &str, name: &str, project_path: &str) -> ReviewPlatformRemote { serde_json::from_value(json!({ "id": id, diff --git a/src/crates/assembly/core/src/service/review_platform/mod.rs b/src/crates/assembly/core/src/service/review_platform/mod.rs index 12e2fbb8c5..558c843d37 100644 --- a/src/crates/assembly/core/src/service/review_platform/mod.rs +++ b/src/crates/assembly/core/src/service/review_platform/mod.rs @@ -16,9 +16,9 @@ pub use openbitfun_services_integrations::review_platform::{ ReviewPlatformCapabilities, ReviewPlatformCiItem, ReviewPlatformCiLog, ReviewPlatformCommit, ReviewPlatformCreatePullRequestRequest, ReviewPlatformDetailSection, ReviewPlatformError, ReviewPlatformFile, ReviewPlatformIssueComment, ReviewPlatformIssueEvidence, - ReviewPlatformKind, ReviewPlatformPullRequest, ReviewPlatformPullRequestDetail, - ReviewPlatformPullRequestDetailPage, ReviewPlatformPullRequestFileDiff, - ReviewPlatformPullRequestReviewTarget, ReviewPlatformRemote, + ReviewPlatformKind, ReviewPlatformListState, ReviewPlatformPullRequest, + ReviewPlatformPullRequestDetail, ReviewPlatformPullRequestDetailPage, + ReviewPlatformPullRequestFileDiff, ReviewPlatformPullRequestReviewTarget, ReviewPlatformRemote, ReviewPlatformReplyToThreadRequest, ReviewPlatformRepositoryRef, ReviewPlatformRequestChangesRequest, ReviewPlatformResolveThreadRequest, ReviewPlatformSubmitReviewRequest, ReviewPlatformThread, ReviewPlatformThreadKind, @@ -159,6 +159,18 @@ impl ReviewPlatformService { .await } + pub async fn workspace_snapshot_with_state( + repository_path: &str, + remote_id: Option<&str>, + page: Option, + per_page: Option, + state: ReviewPlatformListState, + ) -> Result { + owner_service()? + .workspace_snapshot_with_state(repository_path, remote_id, page, per_page, state) + .await + } + pub async fn pull_request_detail( repository_path: &str, remote_id: &str, diff --git a/src/crates/execution/agent-runtime/src/deep_review/target_evidence.rs b/src/crates/execution/agent-runtime/src/deep_review/target_evidence.rs index db72edeeb2..3701efba98 100644 --- a/src/crates/execution/agent-runtime/src/deep_review/target_evidence.rs +++ b/src/crates/execution/agent-runtime/src/deep_review/target_evidence.rs @@ -643,7 +643,7 @@ fn parse_pull_request_identity( )); } let platform = required_string(value, &["platform"], "reviewTarget.pullRequest.platform")?; - if !matches!(platform.as_str(), "github" | "gitlab" | "gitcode") { + if !matches!(platform.as_str(), "github" | "gitlab" | "gitcode" | "gitee") { return Err(ReviewTargetEvidenceValidationError::invalid( "reviewTarget.pullRequest.platform", "unknown provider", @@ -1046,6 +1046,32 @@ mod tests { assert_eq!(evidence.diff_revisions_for_path("src/lib.rs"), None); } + #[test] + fn gitee_targets_keep_provider_identity_and_disallow_live_repository_fallback() { + let mut value = manifest(); + let target = &mut value["evidencePack"]["reviewTarget"]; + target["source"] = json!("pull_request"); + target["workspaceBinding"] = json!("unavailable"); + target["pullRequest"] = json!({ + "remoteId": "origin:gitee:example__repo", "platform": "gitee", "host": "gitee.com", + "projectPath": "example/repo", "pullRequestId": "69", "number": 69, + "webUrl": "https://gitee.com/example/repo/pulls/69" + }); + let evidence = ReviewTargetEvidence::from_manifest(&value) + .unwrap() + .unwrap(); + assert_eq!(evidence.pull_request().unwrap().platform(), "gitee"); + assert_eq!(evidence.pull_request().unwrap().pull_request_id(), "69"); + assert!(!evidence.allows_live_repository_context()); + assert_eq!(evidence.diff_revisions_for_path("src/lib.rs"), None); + value["evidencePack"]["reviewTarget"]["pullRequest"]["platform"] = json!("future-provider"); + assert!(ReviewTargetEvidence::from_manifest(&value).is_err()); + assert_eq!( + value["evidencePack"]["reviewTarget"]["pullRequest"]["platform"], + "future-provider" + ); + } + #[test] fn validates_evidence_and_packet_paths_against_the_target() { let value = scoped_manifest(); diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 5752924f0a..bcc3f4f373 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -115,6 +115,7 @@ cargo test -p openbitfun-services-integrations --no-default-features --features cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib remote_connect::relay_client::tests:: cargo test -p openbitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts cargo test --locked -p openbitfun-services-integrations --no-default-features --features deep-research --lib deep_research::tests:: +cargo test --locked -p openbitfun-services-integrations --no-default-features --features review-platform --lib review_platform pnpm run check:core-boundaries ``` diff --git a/src/crates/services/services-integrations/src/review_platform.rs b/src/crates/services/services-integrations/src/review_platform.rs index d2b7bec237..879b1a038f 100644 --- a/src/crates/services/services-integrations/src/review_platform.rs +++ b/src/crates/services/services-integrations/src/review_platform.rs @@ -25,6 +25,8 @@ use tokio::fs; use tokio::io::AsyncWriteExt; use tokio::sync::Mutex as AsyncMutex; +mod gitee; + pub const REVIEW_PLATFORM_TOKEN_FILE_NAME: &str = "review-platform-tokens.json"; const REVIEW_PLATFORM_TOKEN_SCHEMA_VERSION: u16 = 1; @@ -130,6 +132,7 @@ pub enum ReviewPlatformKind { Github, Gitlab, Gitcode, + Gitee, Unknown, } @@ -139,6 +142,7 @@ impl ReviewPlatformKind { Self::Github => "github", Self::Gitlab => "gitlab", Self::Gitcode => "gitcode", + Self::Gitee => "gitee", Self::Unknown => "unknown", } } @@ -267,6 +271,7 @@ pub struct ReviewPlatformCiItem { #[serde(rename_all = "camelCase")] pub struct ReviewPlatformPullRequest { pub id: String, + /// Remote binding for aggregated lists, not the provider's internal PR ID. pub provider_id: Option, pub number: i64, pub title: String, @@ -280,6 +285,10 @@ pub struct ReviewPlatformPullRequest { pub web_url: String, pub additions: i32, pub deletions: i32, + /// Whether line totals are complete and safe to present, including zero. + /// None preserves the behavior of providers and payloads predating this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line_stats_known: Option, pub changed_files: i32, /// Whether `changed_files` is safe to present as an actual count. /// Older payloads predate the unknown state and are treated as known. @@ -420,6 +429,8 @@ pub struct ReviewPlatformPullRequestDetail { pub files: Vec, pub commits: Vec, pub threads: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub limitations: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -444,6 +455,8 @@ pub struct ReviewPlatformPullRequestDetailPage { pub threads: Vec, pub section: ReviewPlatformDetailSection, pub pagination: ReviewPlatformPagination, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub limitations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -467,6 +480,21 @@ pub struct ReviewPlatformCapabilities { pub can_request_changes: bool, pub can_merge: bool, pub supports_draft_review: bool, + /// Repository-wide list filters supported by this host/provider. An absent + /// field on an older host must not be treated as server-side filtering. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub supported_pull_request_states: Vec, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformListState { + #[default] + All, + Open, + Draft, + Merged, + Closed, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -709,7 +737,11 @@ impl ProviderIssueIdentity { platform, host: normalize_provider_host(host)?, project_path: normalize_project_path(platform, project_path)?, - issue_id: normalize_provider_item_id(issue_id, "Issue")?, + issue_id: if platform == ReviewPlatformKind::Gitee { + gitee::normalize_issue_number(issue_id)? + } else { + normalize_provider_item_id(issue_id, "Issue")? + }, }) } } @@ -938,7 +970,25 @@ impl ReviewPlatformService { page: Option, per_page: Option, ) -> Result { - self.workspace_snapshot_internal(repository_path, remote_id, page, per_page, true) + self.workspace_snapshot_with_state( + repository_path, + remote_id, + page, + per_page, + ReviewPlatformListState::All, + ) + .await + } + + pub async fn workspace_snapshot_with_state( + &self, + repository_path: &str, + remote_id: Option<&str>, + page: Option, + per_page: Option, + state: ReviewPlatformListState, + ) -> Result { + self.workspace_snapshot_internal(repository_path, remote_id, page, per_page, true, state) .await } @@ -947,8 +997,15 @@ impl ReviewPlatformService { repository_path: &str, remote_id: Option<&str>, ) -> Result { - self.workspace_snapshot_internal(repository_path, remote_id, None, None, false) - .await + self.workspace_snapshot_internal( + repository_path, + remote_id, + None, + None, + false, + ReviewPlatformListState::All, + ) + .await } async fn workspace_snapshot_internal( @@ -958,6 +1015,7 @@ impl ReviewPlatformService { page: Option, per_page: Option, include_pull_requests: bool, + state: ReviewPlatformListState, ) -> Result { let pagination_request = PullRequestPagination::new(page, per_page); let auth_tokens = self.load_stored_tokens().await?; @@ -987,6 +1045,17 @@ impl ReviewPlatformService { )); } + if state != ReviewPlatformListState::All + && !capabilities_for_remote(&remote) + .supported_pull_request_states + .contains(&state) + { + return Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} does not support repository-wide pull request state filtering on this host", + platform_label(remote.platform) + ))); + } + if remote.platform == ReviewPlatformKind::Gitcode && token_for_remote(&remote, &auth_tokens).is_none() { @@ -1071,7 +1140,10 @@ impl ReviewPlatformService { auth_challenge: None, }); } - match provider.list_pull_requests(&ctx, pagination_request).await { + match provider + .list_pull_requests_with_state(&ctx, pagination_request, state) + .await + { Ok(page) => Ok(ReviewPlatformWorkspaceSnapshot { remotes, selected_remote_id: Some(remote.id.clone()), @@ -1455,7 +1527,7 @@ impl ReviewPlatformService { ) -> bool { if !matches!( platform, - ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitee ) { return false; } @@ -1497,8 +1569,9 @@ impl ReviewPlatformService { } let key = token_key(platform, host) .ok_or_else(|| ReviewPlatformError::UnsupportedPlatform(host.to_string()))?; - let _transaction = self.token_store_lock.lock().await; - let mut stored = self.load_stored_token_file_unlocked().await?; + let store = self.token_store_owner(platform, host)?; + let _transaction = store.token_store_lock.lock().await; + let mut stored = store.load_stored_token_file_unlocked().await?; stored.tokens.remove(&key); stored.tokens.insert( key, @@ -1507,7 +1580,7 @@ impl ReviewPlatformService { updated_at: chrono::Utc::now().to_rfc3339(), }, ); - self.save_stored_token_file_unlocked(&stored).await + store.save_stored_token_file_unlocked(&stored).await } pub async fn clear_auth_token( @@ -1523,10 +1596,30 @@ impl ReviewPlatformService { } let key = token_key(platform, host) .ok_or_else(|| ReviewPlatformError::UnsupportedPlatform(host.to_string()))?; - let _transaction = self.token_store_lock.lock().await; - let mut stored = self.load_stored_token_file_unlocked().await?; + let store = self.token_store_owner(platform, host)?; + let _transaction = store.token_store_lock.lock().await; + let mut stored = store.load_stored_token_file_unlocked().await?; stored.tokens.remove(&key); - self.save_stored_token_file_unlocked(&stored).await + store.save_stored_token_file_unlocked(&stored).await + } + + fn token_store_owner( + &self, + platform: ReviewPlatformKind, + host: &str, + ) -> Result { + let path = if platform == ReviewPlatformKind::Gitee { + if normalize_provider_host(host)? != "gitee.com" { + return Err(ReviewPlatformError::UnsupportedPlatform(host.to_string())); + } + // Older hosts reject unknown authority keys in the shared v1 file. + // Keep Gitee additive across upgrades and downgrades, using the same + // atomic persistence and per-path locking as existing providers. + self.token_store_path.with_extension("gitee.json") + } else { + self.token_store_path.clone() + }; + Ok(Self::new(path, self.workspace_classifier.clone())) } } @@ -1538,6 +1631,21 @@ trait ReviewProvider: Sync { pagination: PullRequestPagination, ) -> Result; + async fn list_pull_requests_with_state( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + state: ReviewPlatformListState, + ) -> Result { + if state != ReviewPlatformListState::All { + return Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} pull request state filtering", + platform_label(ctx.remote.platform) + ))); + } + self.list_pull_requests(ctx, pagination).await + } + async fn pull_request_detail( &self, ctx: &ProviderContext, @@ -1621,6 +1729,7 @@ trait ReviewProvider: Sync { ReviewPlatformDetailSection::Reviews => thread_total, }; Ok(ReviewPlatformPullRequestDetailPage { + limitations: detail.limitations, pull_request: detail.pull_request, body: detail.body, ci, @@ -1733,6 +1842,7 @@ fn provider_for(platform: ReviewPlatformKind) -> &'static dyn ReviewProvider { ReviewPlatformKind::Github => &GithubProvider, ReviewPlatformKind::Gitlab => &GitlabProvider, ReviewPlatformKind::Gitcode => &GitcodeProvider, + ReviewPlatformKind::Gitee => &gitee::GiteeProvider, ReviewPlatformKind::Unknown => &UnsupportedProvider, } } @@ -2006,6 +2116,7 @@ impl ReviewProvider for GithubProvider { pull_request.checks = checks; Ok(ReviewPlatformPullRequestDetail { + limitations: Vec::new(), body: value_string(&detail, "body"), pull_request, ci, @@ -2302,6 +2413,7 @@ async fn github_pull_request_detail_page( } Ok(ReviewPlatformPullRequestDetailPage { + limitations: Vec::new(), pull_request, body: value_string(&detail, "body"), ci, @@ -2544,6 +2656,7 @@ async fn gitlab_pull_request_detail( pull_request.checks = summarize_ci_items(&ci); Ok(ReviewPlatformPullRequestDetail { + limitations: Vec::new(), body: value_string(&detail, "description"), pull_request, ci, @@ -2669,6 +2782,7 @@ async fn gitlab_pull_request_detail_page( } Ok(ReviewPlatformPullRequestDetailPage { + limitations: Vec::new(), pull_request, body: value_string(&detail, "description"), ci, @@ -2978,6 +3092,7 @@ async fn gitcode_pull_request_detail_page( } Ok(ReviewPlatformPullRequestDetailPage { + limitations: Vec::new(), body: first_non_empty(&[ value_string(&detail, "body"), value_string(&detail, "description"), @@ -3110,6 +3225,7 @@ impl ReviewProvider for GitcodeProvider { } Ok(ReviewPlatformPullRequestDetail { + limitations: Vec::new(), body: first_non_empty(&[ value_string(&detail, "body"), value_string(&detail, "description"), @@ -3812,7 +3928,10 @@ fn normalize_project_path( }) }; if segments.len() < 2 - || (platform == ReviewPlatformKind::Github && segments.len() != 2) + || (matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitee + ) && segments.len() != 2) || segments.iter().any(|segment| segment_is_invalid(segment)) { return Err(ReviewPlatformError::Api( @@ -3821,7 +3940,7 @@ fn normalize_project_path( } if !matches!( platform, - ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitee ) { return Err(ReviewPlatformError::UnsupportedPlatform( platform_label(platform).to_string(), @@ -3872,7 +3991,9 @@ fn provider_context_for_identity_with_trust( .flatten(); let public_anonymous_host = matches!( (platform, host.as_str()), - (ReviewPlatformKind::Github, "github.com") | (ReviewPlatformKind::Gitlab, "gitlab.com") + (ReviewPlatformKind::Github, "github.com") + | (ReviewPlatformKind::Gitlab, "gitlab.com") + | (ReviewPlatformKind::Gitee, "gitee.com") ); if platform != ReviewPlatformKind::Github && !public_anonymous_host @@ -3919,6 +4040,7 @@ fn provider_context_for_identity_with_trust( (ReviewPlatformKind::Github, "github.com") => "https://api.github.com".to_string(), (ReviewPlatformKind::Github, _) => format!("https://{host}/api/v3"), (ReviewPlatformKind::Gitlab, _) => format!("https://{host}/api/v4"), + (ReviewPlatformKind::Gitee, "gitee.com") => "https://gitee.com/api/v5".to_string(), _ => return Err(ReviewPlatformError::UnsupportedPlatform(host)), }; Ok(ProviderContext { @@ -3974,6 +4096,20 @@ fn issue_request_plan( )); } let (issue_url, comments_url, comments_query) = match identity.platform { + ReviewPlatformKind::Gitee => { + let issue_url = format!( + "{}/repos/{}/{}/issues/{}", + context.api_base_url, + urlencoding::encode(&context.remote.owner), + urlencoding::encode(&context.remote.repository_name), + identity.issue_id + ); + ( + issue_url.clone(), + format!("{issue_url}/comments"), + Vec::new(), + ) + } ReviewPlatformKind::Github => { let issue_url = format!( "{}/repos/{}/{}/issues/{}", @@ -4025,6 +4161,7 @@ async fn acquire_issue_evidence( let page = plan.pagination.page.to_string(); let per_page = plan.pagination.per_page.to_string(); match identity.platform { + ReviewPlatformKind::Gitee => gitee::acquire_issue_evidence(context, identity, &plan).await, ReviewPlatformKind::Github => { let issue = github_api_get_json(context, &plan.issue_url, &[], MAX_ISSUE_RESPONSE_BYTES) @@ -4192,6 +4329,10 @@ fn provider_context( (ReviewPlatformKind::Github, host) => format!("https://{host}/api/v3"), (ReviewPlatformKind::Gitlab, host) => format!("https://{host}/api/v4"), (ReviewPlatformKind::Gitcode, _) => "https://api.gitcode.com/api/v5".to_string(), + (ReviewPlatformKind::Gitee, "gitee.com") => "https://gitee.com/api/v5".to_string(), + (ReviewPlatformKind::Gitee, _) => { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } (ReviewPlatformKind::Unknown, _) => { return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); } @@ -4222,6 +4363,7 @@ fn env_token_for_platform(platform: ReviewPlatformKind) -> Option { ReviewPlatformKind::Github => &[], ReviewPlatformKind::Gitlab => &["GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"], ReviewPlatformKind::Gitcode => &["GITCODE_TOKEN"], + ReviewPlatformKind::Gitee => &["GITEE_TOKEN"], ReviewPlatformKind::Unknown => &[], }; names.iter().find_map(|name| { @@ -4270,6 +4412,7 @@ fn normalize_stored_token_key(key: &str) -> Option { "github" => ReviewPlatformKind::Github, "gitlab" => ReviewPlatformKind::Gitlab, "gitcode" => ReviewPlatformKind::Gitcode, + "gitee" => ReviewPlatformKind::Gitee, _ => return None, }; token_key(platform, host) @@ -4718,6 +4861,7 @@ fn github_pull_request_from_gh_cli_value( additions: value_i64(value, "additions") as i32, deletions: value_i64(value, "deletions") as i32, changed_files: value_i64(value, "changedFiles") as i32, + line_stats_known: None, changed_file_count_known: true, comments: value .get("comments") @@ -5066,8 +5210,25 @@ fn normalize_repository_root(root: &str) -> String { impl ReviewPlatformService { async fn load_stored_tokens(&self) -> Result { - let _transaction = self.token_store_lock.lock().await; - let stored = self.load_stored_token_file_unlocked().await?; + let mut stored = { + let _transaction = self.token_store_lock.lock().await; + self.load_stored_token_file_unlocked().await? + }; + let gitee_store = self.token_store_owner(ReviewPlatformKind::Gitee, "gitee.com")?; + let gitee_tokens = { + let _transaction = gitee_store.token_store_lock.lock().await; + gitee_store.load_stored_token_file_unlocked().await? + }; + if gitee_tokens + .tokens + .keys() + .any(|key| key != "gitee:gitee.com") + { + return Err(ReviewPlatformError::Parse( + "Gitee token store contains an unexpected provider authority".to_string(), + )); + } + stored.tokens.extend(gitee_tokens.tokens); Ok(ReviewPlatformAuthTokens { tokens: stored .tokens @@ -5311,6 +5472,7 @@ fn empty_snapshot( can_request_changes: false, can_merge: false, supports_draft_review: false, + supported_pull_request_states: Vec::new(), }, message: if message.trim().is_empty() { None @@ -5395,11 +5557,17 @@ fn capabilities_for_remote(_remote: &ReviewPlatformRemote) -> ReviewPlatformCapa ReviewPlatformCapabilities { can_create_review: matches!( platform, - ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ReviewPlatformKind::Github + | ReviewPlatformKind::Gitlab + | ReviewPlatformKind::Gitcode + | ReviewPlatformKind::Gitee ), can_create_pull_request: matches!( platform, - ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ReviewPlatformKind::Github + | ReviewPlatformKind::Gitlab + | ReviewPlatformKind::Gitcode + | ReviewPlatformKind::Gitee ), can_reply_to_thread: matches!( platform, @@ -5408,12 +5576,29 @@ fn capabilities_for_remote(_remote: &ReviewPlatformRemote) -> ReviewPlatformCapa can_resolve_thread: matches!(platform, ReviewPlatformKind::Gitlab), can_approve: matches!( platform, - ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ReviewPlatformKind::Github + | ReviewPlatformKind::Gitlab + | ReviewPlatformKind::Gitcode + | ReviewPlatformKind::Gitee + ), + can_revoke_approval: matches!( + platform, + ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitee ), - can_revoke_approval: matches!(platform, ReviewPlatformKind::Gitlab), can_request_changes: matches!(platform, ReviewPlatformKind::Github), can_merge: false, supports_draft_review: matches!(platform, ReviewPlatformKind::Github), + supported_pull_request_states: if platform == ReviewPlatformKind::Gitee { + vec![ + ReviewPlatformListState::All, + ReviewPlatformListState::Open, + ReviewPlatformListState::Draft, + ReviewPlatformListState::Merged, + ReviewPlatformListState::Closed, + ] + } else { + Vec::new() + }, } } @@ -5422,6 +5607,7 @@ fn platform_label(platform: ReviewPlatformKind) -> &'static str { ReviewPlatformKind::Github => "GitHub", ReviewPlatformKind::Gitlab => "GitLab", ReviewPlatformKind::Gitcode => "GitCode", + ReviewPlatformKind::Gitee => "Gitee", ReviewPlatformKind::Unknown => "Git", } } @@ -5433,6 +5619,7 @@ fn required_scopes_for_platform(platform: ReviewPlatformKind) -> Vec { vec!["read_api".to_string(), "api for write actions".to_string()] } ReviewPlatformKind::Gitcode => vec!["pull_request".to_string()], + ReviewPlatformKind::Gitee => vec!["pull_requests".to_string(), "projects".to_string()], ReviewPlatformKind::Unknown => Vec::new(), } } @@ -6089,6 +6276,7 @@ fn parse_remote( "github.com" => ReviewPlatformKind::Github, "gitlab.com" => ReviewPlatformKind::Gitlab, "gitcode.com" => ReviewPlatformKind::Gitcode, + "gitee.com" => ReviewPlatformKind::Gitee, _ => auth_tokens .registered_platform_for_host(&host) .unwrap_or(ReviewPlatformKind::Unknown), @@ -6588,6 +6776,7 @@ fn github_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { additions: value_i64(value, "additions") as i32, deletions: value_i64(value, "deletions") as i32, changed_files: value_i64(value, "changed_files") as i32, + line_stats_known: None, changed_file_count_known: true, comments: (value_i64(value, "comments") + value_i64(value, "review_comments")) as i32, review_decision: ReviewDecision::Pending, @@ -6636,6 +6825,7 @@ fn gitlab_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { web_url: value_string(value, "web_url"), additions: 0, deletions: 0, + line_stats_known: None, changed_files, changed_file_count_known: true, comments: value_i64(value, "user_notes_count") as i32, @@ -6699,6 +6889,7 @@ fn gitcode_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { ]), additions: gitcode_pull_request_line_count(value, "added_lines", "additions"), deletions: gitcode_pull_request_line_count(value, "removed_lines", "deletions"), + line_stats_known: None, changed_files: changed_files.unwrap_or(0), changed_file_count_known, comments: value_i64(value, "comments") as i32, @@ -7827,7 +8018,10 @@ mod tests { assert!(error.to_string().contains("pre-OpenBitFun"), "{error}"); assert!(error.to_string().contains("migration tool"), "{error}"); - assert_eq!(fs::read(&path).await.expect("read unchanged store"), original); + assert_eq!( + fs::read(&path).await.expect("read unchanged store"), + original + ); let _ = fs::remove_file(path).await; } @@ -7926,18 +8120,18 @@ mod tests { async fn noncanonical_stored_token_authority_is_rejected_without_modification() { let path = temp_token_store_path("noncanonical-token-authority"); let original = serde_json::to_vec(&json!({ - "schemaVersion": 1, - "tokens": { - "gitlab:GitLab.COM.": { - "token": "noncanonical-token", - "updatedAt": "2026-07-11T00:00:00Z" - } + "schemaVersion": 1, + "tokens": { + "gitlab:GitLab.COM.": { + "token": "noncanonical-token", + "updatedAt": "2026-07-11T00:00:00Z" } - })) - .expect("noncanonical token fixture should serialize"); + } + })) + .expect("noncanonical token fixture should serialize"); fs::write(&path, &original) - .await - .expect("noncanonical token fixture should be written"); + .await + .expect("noncanonical token fixture should be written"); let service = ReviewPlatformService::new_local_only(path.clone()); let error = service @@ -7947,7 +8141,10 @@ mod tests { assert!(error.to_string().contains("not canonical"), "{error}"); assert!(error.to_string().contains("migration tool"), "{error}"); - assert_eq!(fs::read(&path).await.expect("read unchanged store"), original); + assert_eq!( + fs::read(&path).await.expect("read unchanged store"), + original + ); let _ = fs::remove_file(path).await; } @@ -7955,22 +8152,22 @@ mod tests { async fn canonical_and_noncanonical_token_conflict_is_not_rewritten() { let path = temp_token_store_path("noncanonical-token-conflict"); let original = serde_json::to_vec(&json!({ - "schemaVersion": 1, - "tokens": { - "gitlab:GitLab.COM.": { - "token": "noncanonical-token", - "updatedAt": "2026-07-12T00:00:00Z" - }, - "gitlab:gitlab.com": { - "token": "canonical-token", - "updatedAt": "2026-07-11T00:00:00Z" - } + "schemaVersion": 1, + "tokens": { + "gitlab:GitLab.COM.": { + "token": "noncanonical-token", + "updatedAt": "2026-07-12T00:00:00Z" + }, + "gitlab:gitlab.com": { + "token": "canonical-token", + "updatedAt": "2026-07-11T00:00:00Z" } - })) - .expect("conflicting token fixture should serialize"); + } + })) + .expect("conflicting token fixture should serialize"); fs::write(&path, &original) - .await - .expect("conflicting token fixture should be written"); + .await + .expect("conflicting token fixture should be written"); let service = ReviewPlatformService::new_local_only(path.clone()); let load_error = service @@ -7984,7 +8181,10 @@ mod tests { .await .expect_err("write operations must not repair a noncanonical store"); assert!(update_error.to_string().contains("not canonical")); - assert_eq!(fs::read(&path).await.expect("read unchanged store"), original); + assert_eq!( + fs::read(&path).await.expect("read unchanged store"), + original + ); let _ = fs::remove_file(path).await; } diff --git a/src/crates/services/services-integrations/src/review_platform/gitee.rs b/src/crates/services/services-integrations/src/review_platform/gitee.rs new file mode 100644 index 0000000000..5576472b5b --- /dev/null +++ b/src/crates/services/services-integrations/src/review_platform/gitee.rs @@ -0,0 +1,1031 @@ +//! Gitee Open API v5 adapter. Keep provider limits and wire shapes here. +//! +//! Reference: https://gitee.com/api/v5/swagger_doc.json +//! `/files` and `/commits` are capped collections, not paginated endpoints. +//! Public responses also differ from the schema: `patch` can be an object and +//! counts/line numbers can be strings. Never infer complete evidence from a cap. + +use super::*; + +#[cfg(test)] +mod tests; + +// Live large-PR responses stop at 200 entries and ignore page/per_page, below +// the schema's advertised 300. Treat that observed boundary as incomplete. +const FILE_LIMIT: usize = 200; +const COMMIT_LIMIT: usize = 250; +const FILE_RESPONSE_BYTES: usize = 16 * 1024 * 1024; + +pub(super) struct GiteeProvider; + +fn authenticate(request: ReviewHttpRequest, token: Option<&str>) -> ReviewHttpRequest { + let request = request + .header(USER_AGENT_HEADER, USER_AGENT_VALUE) + .header(ACCEPT_HEADER, "application/json"); + // Gitee documents access_token parameters, including on PR endpoints whose + // generated schema omits auth. Transport errors strip URLs before exposure. + match token { + Some(token) => request.query(&[("access_token", token)]), + None => request, + } +} + +fn get(ctx: &ProviderContext, url: &str) -> Result { + Ok(authenticate(http_client()?.get(url), ctx.token.as_deref())) +} + +fn repo_url(ctx: &ProviderContext) -> String { + format!( + "{}/repos/{}/{}", + ctx.api_base_url, + urlencoding::encode(&ctx.remote.owner), + urlencoding::encode(&ctx.remote.repository_name), + ) +} + +fn pull_url(ctx: &ProviderContext, number: &str) -> Result { + let number = normalize_provider_item_id(number, "Pull request")?; + Ok(format!("{}/pulls/{number}", repo_url(ctx))) +} + +fn array<'a>(value: &'a Value, resource: &str) -> Result<&'a [Value], ReviewPlatformError> { + value.as_array().map(Vec::as_slice).ok_or_else(|| { + ReviewPlatformError::Parse(format!("Gitee {resource} response was not an array")) + }) +} + +fn count(value: &Value, key: &str) -> i32 { + value_i64(value, key).clamp(0, i64::from(i32::MAX)) as i32 +} + +fn count_is_known(value: &Value, key: &str) -> bool { + value + .get(key) + .and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str()?.parse::().ok()) + }) + .is_some_and(|count| count >= 0 && count <= i64::from(i32::MAX)) +} + +fn pull_request(value: &Value) -> Result { + let number = normalize_provider_item_id(&value_string(value, "number"), "Pull request")? + .parse::() + .map_err(|_| { + ReviewPlatformError::Parse("Gitee pull request number overflow".to_string()) + })?; + let state = match value_string(value, "state").as_str() { + "merged" => ReviewItemState::Merged, + "closed" => ReviewItemState::Closed, + "open" if value_bool(value, "draft") => ReviewItemState::Draft, + "open" => ReviewItemState::Open, + other => { + return Err(ReviewPlatformError::Parse(format!( + "Unknown Gitee PR state: {other}" + ))) + } + }; + let assignees = array_items(value.get("assignees").unwrap_or(&Value::Null)); + let required = count(value, "assignees_number") as usize; + let accepted = assignees + .iter() + .filter(|user| value_bool(user, "accept")) + .count(); + // Zero required reviewers is not evidence that anyone approved. Testers and + // can_merge_check describe different gates, not code review decisions. + let approved = accepted > 0 + && if required > 0 { + accepted >= required + } else { + accepted == assignees.len() + }; + Ok(ReviewPlatformPullRequest { + id: number.to_string(), + provider_id: None, + number, + title: value_string(value, "title"), + state, + author: first_non_empty(&[ + nested_string(value, &["user", "login"]), + nested_string(value, &["user", "name"]), + ]), + source_branch: nested_string(value, &["head", "ref"]), + target_branch: nested_string(value, &["base", "ref"]), + base_revision: nested_optional_string(value, &["base", "sha"]), + head_revision: nested_optional_string(value, &["head", "sha"]), + updated_at: value_string(value, "updated_at"), + web_url: value_string(value, "html_url"), + additions: count(value, "additions"), + deletions: count(value, "deletions"), + line_stats_known: Some( + count_is_known(value, "additions") && count_is_known(value, "deletions"), + ), + changed_files: count(value, "changed_files"), + changed_file_count_known: count_is_known(value, "changed_files"), + comments: count(value, "comments"), + review_decision: if approved { + ReviewDecision::Approved + } else { + ReviewDecision::Pending + }, + checks: empty_checks(), + }) +} + +fn file(value: &Value) -> Result { + let patch = value.get("patch").unwrap_or(&Value::Null); + let path = first_non_empty(&[ + value_string(value, "filename"), + value_string(patch, "new_path"), + ]); + if path.is_empty() || path.contains(['\r', '\n']) { + return Err(ReviewPlatformError::Parse( + "Gitee returned an invalid file path".to_string(), + )); + } + let old_path = optional_string(patch, "old_path") + .or_else(|| optional_string(value, "previous_filename")) + .filter(|old| old != &path); + let status = if value_bool(patch, "new_file") || value_bool(value, "new_file") { + ReviewFileStatus::Added + } else if value_bool(patch, "deleted_file") || value_bool(value, "deleted_file") { + ReviewFileStatus::Deleted + } else if value_bool(patch, "renamed_file") + || value_bool(value, "renamed_file") + || old_path.is_some() + { + ReviewFileStatus::Renamed + } else { + file_status(&value_string(value, "status")) + }; + let diff = if value_bool(patch, "too_large") || value_bool(value, "too_large") { + None + } else { + optional_string(patch, "diff").or_else(|| optional_string(value, "patch")) + }; + Ok(ReviewPlatformFile { + path, + old_path, + status, + additions: count(value, "additions"), + deletions: count(value, "deletions"), + patch: diff, + }) +} + +fn thread(value: &Value) -> ReviewPlatformThread { + let id = value_string(value, "id"); + let inline = value_string(value, "comment_type") == "diff_comment" + || optional_string(value, "path").is_some(); + ReviewPlatformThread { + id: format!("gitee-comment:{id}"), + provider_thread_id: None, + provider_comment_id: non_empty_option(id), + kind: if inline { + ReviewPlatformThreadKind::Review + } else { + ReviewPlatformThreadKind::Comment + }, + reply_to_provider_comment_id: non_empty_option(value_string(value, "in_reply_to_id")), + file_path: optional_string(value, "path"), + // position is a diff offset, not a source line. Do not display it as one. + line: (value_i64(value, "new_line") > 0).then(|| value_i64(value, "new_line")), + resolved: false, + author: first_non_empty(&[ + nested_string(value, &["user", "login"]), + nested_string(value, &["user", "name"]), + ]), + body: value_string(value, "body"), + updated_at: first_non_empty(&[ + value_string(value, "updated_at"), + value_string(value, "created_at"), + ]), + } +} + +fn has_rel(headers: &ReviewHttpHeaders, rel: &str) -> bool { + header_string(headers, "link").is_some_and(|link| { + link.split(',').any(|part| { + part.split(';').skip(1).any(|parameter| { + let Some((name, value)) = parameter.trim().split_once('=') else { + return false; + }; + name.trim() == "rel" + && value + .trim() + .trim_matches(['\'', '"']) + .split_whitespace() + .any(|v| v == rel) + }) + }) + }) +} + +fn pagination( + response: &JsonResponse, + requested: PullRequestPagination, + len: usize, +) -> ReviewPlatformPagination { + let total = header_u64(&response.headers, "total_count") + .or_else(|| header_u64(&response.headers, "x-total")); + let has_next = total + .map(|total| u64::from(requested.page) * u64::from(requested.per_page) < total) + .unwrap_or_else(|| { + has_rel(&response.headers, "next") || len == requested.per_page as usize + }); + ReviewPlatformPagination { + page: requested.page, + per_page: requested.per_page, + total, + has_next, + } +} + +fn capped_pagination( + requested: PullRequestPagination, + len: usize, + limit: usize, +) -> ReviewPlatformPagination { + let mut result = pagination_from_total(requested, len); + if len >= limit { + result.total = None; + } + result +} + +async fn detail(ctx: &ProviderContext, number: &str) -> Result { + send_bounded_json(get(ctx, &pull_url(ctx, number)?)?).await +} + +struct GiteeFileList { + items: Vec, + line_stats_known: bool, +} + +fn file_list(values: &[Value]) -> Result { + Ok(GiteeFileList { + items: values.iter().map(file).collect::>()?, + line_stats_known: values + .iter() + .all(|value| count_is_known(value, "additions") && count_is_known(value, "deletions")), + }) +} + +async fn files(ctx: &ProviderContext, number: &str) -> Result { + let response = send_review_json_response_bounded( + get(ctx, &format!("{}/files", pull_url(ctx, number)?))?, + FILE_RESPONSE_BYTES, + ) + .await + .map_err(|error| review_evidence_http_error(error, "gitee_pull_request_files_response"))?; + file_list(array(&response.value, "files")?) +} + +fn apply_file_stats(pr: &mut ReviewPlatformPullRequest, files: &GiteeFileList) { + let additions = files + .items + .iter() + .try_fold(0_i32, |n, file| n.checked_add(file.additions)); + let deletions = files + .items + .iter() + .try_fold(0_i32, |n, file| n.checked_add(file.deletions)); + let complete = files.items.len() < FILE_LIMIT; + let known = complete && files.line_stats_known && additions.is_some() && deletions.is_some(); + let preserve_known = pr.line_stats_known == Some(true) + && (!files.line_stats_known + || (additions.is_some_and(|count| count <= pr.additions) + && deletions.is_some_and(|count| count <= pr.deletions))); + if known || !preserve_known { + pr.additions = additions.unwrap_or(i32::MAX); + pr.deletions = deletions.unwrap_or(i32::MAX); + pr.line_stats_known = Some(known); + } + let file_count = files.items.len().min(i32::MAX as usize) as i32; + if complete || !pr.changed_file_count_known || pr.changed_files < file_count { + pr.changed_files = file_count; + pr.changed_file_count_known = complete; + } +} + +fn require_revisions(pr: &ReviewPlatformPullRequest) -> Result<(), ReviewPlatformError> { + for revision in [&pr.base_revision, &pr.head_revision] { + if !revision.as_deref().is_some_and(|sha| { + matches!(sha.len(), 40 | 64) && sha.bytes().all(|b| b.is_ascii_hexdigit()) + }) { + return Err(ReviewPlatformError::Parse( + "Gitee PR has no immutable base/head revisions".to_string(), + )); + } + } + Ok(()) +} + +fn head_revision(pr: &ReviewPlatformPullRequest) -> Result<&str, ReviewPlatformError> { + pr.head_revision + .as_deref() + .filter(|sha| matches!(sha.len(), 40 | 64) && sha.bytes().all(|b| b.is_ascii_hexdigit())) + .ok_or_else(|| { + ReviewPlatformError::Parse( + "Gitee PR has no immutable head revision for CI checks".to_string(), + ) + }) +} + +async fn review_parts( + ctx: &ProviderContext, + number: &str, +) -> Result<(ReviewPlatformPullRequest, Vec), ReviewPlatformError> { + let initial = pull_request(&detail(ctx, number).await?)?; + require_revisions(&initial)?; + let files = files(ctx, number).await?; + let mut confirmed = pull_request(&detail(ctx, number).await?)?; + ensure_pull_request_revisions_stable(&initial, &confirmed)?; + apply_file_stats(&mut confirmed, &files); + Ok((confirmed, files.items)) +} + +fn review_target( + pr: ReviewPlatformPullRequest, + files: Vec, +) -> ReviewPlatformPullRequestReviewTarget { + let capped = files.len() >= FILE_LIMIT; + let mut target = review_target_from_parts(pr, files); + if capped { + // The provider supplies no reliable total beyond the cap. One is a + // lower-bound sentinel, as in the existing GitCode evidence contract. + target.omitted_file_count = target.omitted_file_count.max(1); + target + .limitations + .push("provider_file_list_incomplete".to_string()); + target.limitations.push("gitee_file_list_limit".to_string()); + } + target +} + +async fn array_page( + ctx: &ProviderContext, + url: &str, + requested: PullRequestPagination, +) -> Result<(Vec, ReviewPlatformPagination), ReviewPlatformError> { + let response = send_bounded_json_response( + get(ctx, url)?.query(&[("page", requested.page), ("per_page", requested.per_page)]), + ) + .await?; + let values = array(&response.value, "collection")?; + let page = pagination(&response, requested, values.len()); + Ok((values.to_vec(), page)) +} + +async fn checks_page( + ctx: &ProviderContext, + pr: &ReviewPlatformPullRequest, + global_pr_id: &str, + requested: PullRequestPagination, +) -> Result<(Vec, ReviewPlatformPagination), ReviewPlatformError> { + let sha = head_revision(pr)?; + let url = format!("{}/commits/{sha}/check-runs", repo_url(ctx)); + let request = get(ctx, &url)? + .query(&[("page", requested.page), ("per_page", requested.per_page)]) + .query(&[("filter", "latest")]); + // This filter uses the global ID, not the repository-local PR number. + let id = normalize_provider_item_id(global_pr_id, "Gitee pull request internal")?; + let request = request.query(&[("pull_request_id", id)]); + let response = send_bounded_json_response(request).await?; + let values = array( + response.value.get("check_runs").unwrap_or(&Value::Null), + "check runs", + )?; + let mut page = pagination(&response, requested, values.len()); + if let Some(total) = response.value.get("total_count").and_then(Value::as_u64) { + page.total = Some(total); + page.has_next = u64::from(requested.page) * u64::from(requested.per_page) < total; + } + let items = values.iter().map(ci_item).collect::, _>>()?; + Ok((items, page)) +} + +fn ci_item(value: &Value) -> Result { + let id = normalize_provider_item_id(&value_string(value, "id"), "Check run")?; + let output = value.get("output").unwrap_or(&Value::Null); + let text = value_string(output, "text"); + let (log, log_truncated) = ci_log_value(text); + Ok(ReviewPlatformCiItem { + id: format!("gitee-check:{id}"), + name: value_string(value, "name"), + status: value_string(value, "status"), + conclusion: optional_string(value, "conclusion"), + detail: optional_string(output, "summary").or_else(|| optional_string(output, "title")), + stage: None, + web_url: optional_string(value, "details_url") + .or_else(|| optional_string(value, "html_url")), + log, + log_truncated, + started_at: optional_string(value, "started_at"), + finished_at: optional_string(value, "completed_at"), + }) +} + +async fn add_comment( + ctx: &ProviderContext, + number: &str, + body: &str, +) -> Result { + let token = require_write_token(ctx, "Commenting on a Gitee pull request")?; + if body.trim().is_empty() { + return Err(ReviewPlatformError::Api( + "Comment body cannot be empty".to_string(), + )); + } + let value = send_json( + authenticate( + http_client()?.post(&format!("{}/comments", pull_url(ctx, number)?)), + Some(token), + ) + .json(&json!({ "body": body })), + ) + .await?; + normalize_provider_item_id(&value_string(&value, "id"), "Comment")?; + Ok(ReviewPlatformActionResult { + success: true, + message: "Commented on Gitee pull request".to_string(), + web_url: optional_string(&value, "html_url"), + pull_request: None, + thread: Some(thread(&value)), + }) +} + +async fn list_page( + ctx: &ProviderContext, + requested: PullRequestPagination, + state: &str, +) -> Result { + let response = send_bounded_json_response( + get(ctx, &format!("{}/pulls", repo_url(ctx)))? + .query(&[("state", state), ("sort", "updated"), ("direction", "desc")]) + .query(&[("page", requested.page), ("per_page", requested.per_page)]), + ) + .await?; + let values = array(&response.value, "pull requests")?; + Ok(ReviewPlatformPullRequestPage { + items: values + .iter() + .map(pull_request) + .collect::, _>>()?, + pagination: pagination(&response, requested, values.len()), + }) +} + +async fn list_open_or_draft( + ctx: &ProviderContext, + requested: PullRequestPagination, + state: ReviewPlatformListState, +) -> Result { + // Gitee's `state=open` contains both drafts and regular open PRs and offers + // no draft filter. Filter that collection before paginating, never just the + // current UI page. A lookahead match proves has_next without inventing a total. + let end = u64::from(requested.page) * u64::from(requested.per_page); + let mut matches = Vec::new(); + for page in 1..=MAX_REVIEW_TARGET_PAGES as u32 { + let incoming = list_page( + ctx, + PullRequestPagination { + page, + per_page: 100, + }, + "open", + ) + .await?; + if incoming.items.is_empty() && incoming.pagination.has_next { + return Err(ReviewPlatformError::Parse( + "Gitee returned an empty open PR page with more results pending".into(), + )); + } + matches.extend(incoming.items.into_iter().filter(|pr| match state { + ReviewPlatformListState::Draft => pr.state == ReviewItemState::Draft, + _ => pr.state == ReviewItemState::Open, + })); + let complete = !incoming.pagination.has_next; + if complete || matches.len() as u64 > end { + let total = complete.then_some(matches.len() as u64); + let has_next = matches.len() as u64 > end; + return Ok(ReviewPlatformPullRequestPage { + items: slice_page(matches, requested), + pagination: ReviewPlatformPagination { + page: requested.page, + per_page: requested.per_page, + total, + has_next, + }, + }); + } + } + Err(ReviewPlatformError::EvidenceTooLarge { + resource: "gitee_open_pull_request_filter_pages".to_string(), + limit: MAX_REVIEW_TARGET_PAGES, + }) +} + +async fn enrich_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let futures = pull_requests + .into_iter() + .map(|mut pull_request| async move { + if let Ok(files) = files(ctx, &pull_request.id).await { + apply_file_stats(&mut pull_request, &files); + } + pull_request + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +#[async_trait::async_trait] +impl ReviewProvider for GiteeProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + requested: PullRequestPagination, + ) -> Result { + self.list_pull_requests_with_state(ctx, requested, ReviewPlatformListState::All) + .await + } + + async fn list_pull_requests_with_state( + &self, + ctx: &ProviderContext, + requested: PullRequestPagination, + state: ReviewPlatformListState, + ) -> Result { + let mut page = match state { + ReviewPlatformListState::All => list_page(ctx, requested, "all").await, + ReviewPlatformListState::Merged => list_page(ctx, requested, "merged").await, + ReviewPlatformListState::Closed => list_page(ctx, requested, "closed").await, + ReviewPlatformListState::Open | ReviewPlatformListState::Draft => { + list_open_or_draft(ctx, requested, state).await + } + }?; + // Enrich only the visible page, after open/draft filtering and slicing. + // Match GitLab/GitCode: await bounded enrichment before returning rows. + page.items = enrich_pull_request_counts(ctx, page.items).await; + Ok(page) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + number: &str, + ) -> Result { + let initial = detail(ctx, number).await?; + let mut pr = pull_request(&initial)?; + let files = files(ctx, number).await?; + let commits_value = + send_bounded_json(get(ctx, &format!("{}/commits", pull_url(ctx, number)?))?).await?; + let commit_values = array(&commits_value, "commits")?; + let commits = commit_values.iter().map(github_commit_from_value).collect(); + let mut threads = Vec::new(); + let mut ci = Vec::new(); + let mut limitations = Vec::new(); + if files.items.len() >= FILE_LIMIT { + limitations.push("gitee_file_list_limit".to_string()); + } + if commit_values.len() >= COMMIT_LIMIT { + limitations.push("gitee_commit_list_limit".to_string()); + } + for page in 1..=MAX_REVIEW_TARGET_PAGES as u32 { + let requested = PullRequestPagination { + page, + per_page: 100, + }; + let (values, pagination) = array_page( + ctx, + &format!("{}/comments", pull_url(ctx, number)?), + requested, + ) + .await?; + threads.extend(values.iter().map(thread)); + if !pagination.has_next { + break; + } + if values.is_empty() || page == MAX_REVIEW_TARGET_PAGES as u32 { + limitations.push("provider_comment_list_incomplete".to_string()); + break; + } + } + for page in 1..=MAX_REVIEW_TARGET_PAGES as u32 { + if head_revision(&pr).is_err() { + limitations.push("provider_ci_head_unavailable".to_string()); + break; + } + let (items, pagination) = checks_page( + ctx, + &pr, + &value_string(&initial, "id"), + PullRequestPagination { + page, + per_page: 100, + }, + ) + .await?; + let empty = items.is_empty(); + ci.extend(items); + if !pagination.has_next { + break; + } + if empty || page == MAX_REVIEW_TARGET_PAGES as u32 { + limitations.push("provider_ci_list_incomplete".to_string()); + break; + } + } + let confirmed = pull_request(&detail(ctx, number).await?)?; + ensure_pull_request_revisions_stable(&pr, &confirmed)?; + pr = confirmed; + apply_file_stats(&mut pr, &files); + pr.checks = summarize_ci_items(&ci); + pr.comments = threads.len().min(i32::MAX as usize) as i32; + Ok(ReviewPlatformPullRequestDetail { + pull_request: pr, + body: value_string(&initial, "body"), + ci, + files: files.items, + commits, + threads, + limitations, + }) + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + number: &str, + section: ReviewPlatformDetailSection, + requested: PullRequestPagination, + ) -> Result { + let value = detail(ctx, number).await?; + let mut pr = pull_request(&value)?; + let mut result = ReviewPlatformPullRequestDetailPage { + pull_request: pr.clone(), + body: value_string(&value, "body"), + ci: Vec::new(), + files: Vec::new(), + commits: Vec::new(), + threads: Vec::new(), + section, + pagination: empty_detail_pagination(section, requested), + limitations: Vec::new(), + }; + match section { + ReviewPlatformDetailSection::Overview | ReviewPlatformDetailSection::Files => { + let files = files(ctx, number).await?; + apply_file_stats(&mut pr, &files); + if files.items.len() >= FILE_LIMIT { + result.limitations.push("gitee_file_list_limit".to_string()); + } + if section == ReviewPlatformDetailSection::Files { + result.pagination = capped_pagination(requested, files.items.len(), FILE_LIMIT); + result.files = slice_page(files.items, requested); + } else if head_revision(&pr).is_ok() { + let (checks, pagination) = checks_page( + ctx, + &pr, + &value_string(&value, "id"), + PullRequestPagination { + page: 1, + per_page: 100, + }, + ) + .await?; + pr.checks = summarize_ci_items(&checks); + if pagination.has_next { + result + .limitations + .push("provider_ci_list_incomplete".to_string()); + } + } else { + result + .limitations + .push("provider_ci_head_unavailable".to_string()); + } + } + ReviewPlatformDetailSection::Commits => { + let value = + send_bounded_json(get(ctx, &format!("{}/commits", pull_url(ctx, number)?))?) + .await?; + let values = array(&value, "commits")?; + if values.len() >= COMMIT_LIMIT { + result + .limitations + .push("gitee_commit_list_limit".to_string()); + } + result.pagination = capped_pagination(requested, values.len(), COMMIT_LIMIT); + result.commits = slice_page( + values.iter().map(github_commit_from_value).collect(), + requested, + ); + } + ReviewPlatformDetailSection::Reviews => { + let (values, pagination) = array_page( + ctx, + &format!("{}/comments", pull_url(ctx, number)?), + requested, + ) + .await?; + result.threads = values.iter().map(thread).collect(); + if let Some(total) = pagination.total { + pr.comments = total.min(i32::MAX as u64) as i32; + } + result.pagination = pagination; + } + ReviewPlatformDetailSection::Ci => { + let (items, pagination) = + checks_page(ctx, &pr, &value_string(&value, "id"), requested).await?; + pr.checks = summarize_ci_items(&items); + if pagination.has_next || requested.page > 1 { + result + .limitations + .push("provider_ci_list_incomplete".to_string()); + } + result.ci = items; + result.pagination = pagination; + } + } + let confirmed = pull_request(&detail(ctx, number).await?)?; + ensure_pull_request_revisions_stable(&pr, &confirmed)?; + result.pull_request = pr; + Ok(result) + } + + async fn pull_request_review_target( + &self, + ctx: &ProviderContext, + number: &str, + ) -> Result { + let (pr, files) = review_parts(ctx, number).await?; + Ok(review_target(pr, files)) + } + + async fn pull_request_file_diff( + &self, + ctx: &ProviderContext, + number: &str, + base: &str, + head: &str, + path: &str, + _file_page_hint: Option, + ) -> Result { + let (pr, files) = review_parts(ctx, number).await?; + file_diff_from_parts(pr, files, base, head, path) + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + number: &str, + ci_item_id: &str, + _name: &str, + ) -> Result { + let id = ci_item_id + .strip_prefix("gitee-check:") + .ok_or_else(|| ReviewPlatformError::Api("Invalid Gitee check run ID".to_string()))?; + let id = normalize_provider_item_id(id, "Check run")?; + let pr = pull_request(&detail(ctx, number).await?)?; + head_revision(&pr)?; + let check = + send_bounded_json(get(ctx, &format!("{}/check-runs/{id}", repo_url(ctx)))?).await?; + if optional_string(&check, "head_sha") != pr.head_revision { + return Err(ReviewPlatformError::StaleTarget( + "Gitee check run does not belong to the current PR head".to_string(), + )); + } + let item = ci_item(&check)?; + Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), log: item.log, truncated: item.log_truncated, + message: Some("Gitee exposes check output, not complete CI execution logs. Open the check details for the full run.".to_string()), + }) + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let token = require_write_token(ctx, "Creating a Gitee pull request")?; + let value = send_json(authenticate(http_client()?.post(&format!("{}/pulls", repo_url(ctx))), Some(token)).json(&json!({ + "title": request.title, "head": request.source_branch, "base": request.target_branch, + "body": request.body.clone().unwrap_or_default(), "draft": request.draft.unwrap_or(false), + }))).await?; + let pr = pull_request(&value)?; + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created Gitee pull request #{}", pr.number), + web_url: Some(pr.web_url.clone()), + pull_request: Some(pr), + thread: None, + }) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + match request.event { + ReviewSubmitEvent::Comment => { + add_comment(ctx, &request.pull_request_id, &request.body).await + } + ReviewSubmitEvent::Approve => { + self.approve_pull_request( + ctx, + &ReviewPlatformApprovalRequest { + repository_path: request.repository_path.clone(), + remote_id: request.remote_id.clone(), + pull_request_id: request.pull_request_id.clone(), + body: Some(request.body.clone()), + }, + ) + .await + } + ReviewSubmitEvent::RequestChanges => Err(ReviewPlatformError::UnsupportedPlatform( + "Gitee native change requests".to_string(), + )), + } + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + let token = require_write_token(ctx, "Approving a Gitee pull request")?; + crate::review_platform_http::send_success( + authenticate( + http_client()?.post(&format!( + "{}/review", + pull_url(ctx, &request.pull_request_id)? + )), + Some(token), + ) + .json(&json!({ "force": false })), + ) + .await + .map_err(review_http_error)?; + let mut result = ReviewPlatformActionResult { + success: true, + message: "Approved Gitee pull request".to_string(), + web_url: None, + pull_request: None, + thread: None, + }; + if let Some(body) = request + .body + .as_deref() + .filter(|body| !body.trim().is_empty()) + { + match add_comment(ctx, &request.pull_request_id, body).await { + Ok(comment) => { + result.thread = comment.thread; + result.web_url = comment.web_url; + } + Err(_) => { + result.success = false; + result.message = "Gitee approval succeeded, but the accompanying comment failed. Retry only submit_review with event=comment; do not repeat the approval.".to_string(); + } + } + } + Ok(result) + } + + async fn revoke_approval( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + let token = require_write_token(ctx, "Revoking a Gitee approval")?; + crate::review_platform_http::send_success( + authenticate( + http_client()?.patch(&format!( + "{}/assignees", + pull_url(ctx, &request.pull_request_id)? + )), + Some(token), + ) + .json(&json!({ "reset_all": false })), + ) + .await + .map_err(review_http_error)?; + Ok(ReviewPlatformActionResult { + success: true, + message: "Reset the current user's Gitee review approval".to_string(), + web_url: None, + pull_request: None, + thread: None, + }) + } +} + +pub(super) fn normalize_issue_number(number: &str) -> Result { + if !number.starts_with('I') + || number.len() < 2 + || number.len() > 32 + || !number + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) + { + return Err(ReviewPlatformError::Api( + "Gitee Issue number must be an uppercase I-prefixed identifier".to_string(), + )); + } + Ok(number.to_string()) +} + +pub(super) async fn acquire_issue_evidence( + ctx: &ProviderContext, + identity: &ProviderIssueIdentity, + plan: &IssueRequestPlan, +) -> Result { + let issue = + send_review_json_response_bounded(get(ctx, &plan.issue_url)?, MAX_ISSUE_RESPONSE_BYTES) + .await + .map_err(|error| review_evidence_http_error(error, "issue_response"))? + .value; + ensure_provider_item_identity(identity, &issue, "number")?; + let response = send_review_json_response_bounded( + get(ctx, &plan.comments_url)?.query(&[ + ("page", plan.pagination.page), + ("per_page", plan.pagination.per_page), + ]), + MAX_ISSUE_COMMENTS_RESPONSE_BYTES, + ) + .await + .map_err(|error| review_evidence_http_error(error, "issue_comments_response")); + let mut comments_limited = false; + let (values, has_next) = match response { + Ok(response) => { + let values = array(&response.value, "Issue comments")?.to_vec(); + let has_next = pagination( + &response, + PullRequestPagination { + page: plan.pagination.page, + per_page: plan.pagination.per_page, + }, + values.len(), + ) + .has_next; + (values, has_next) + } + Err(ReviewPlatformError::EvidenceTooLarge { .. }) => { + comments_limited = true; + (Vec::new(), false) + } + Err(error) => return Err(error), + }; + let comments = values + .iter() + .map(|comment| ReviewPlatformIssueComment { + id: value_string(comment, "id"), + web_url: optional_string(comment, "html_url"), + author: nested_optional_string(comment, &["user", "login"]), + body: value_string(comment, "body"), + created_at: optional_string(comment, "created_at"), + updated_at: optional_string(comment, "updated_at"), + }) + .collect(); + let labels = array_items(issue.get("labels").unwrap_or(&Value::Null)) + .iter() + .filter_map(|label| optional_string(label, "name")) + .collect(); + let mut evidence = finalize_issue_mapping( + identity, + first_non_empty(&[ + value_string(&issue, "html_url"), + format!( + "https://{}/{}/issues/{}", + identity.host, identity.project_path, identity.issue_id + ), + ]), + value_string(&issue, "title"), + bounded_issue_body(&issue, "body")?, + value_string(&issue, "state"), + nested_optional_string(&issue, &["user", "login"]), + labels, + optional_string(&issue, "created_at"), + optional_string(&issue, "updated_at"), + comments, + plan.pagination, + has_next, + has_next.then(|| plan.pagination.page.saturating_add(1).to_string()), + )?; + if comments_limited { + evidence.completeness = ReviewEvidenceCompleteness::Partial; + evidence + .limitations + .push("issue_comments_response_too_large".to_string()); + evidence.fingerprint = issue_fingerprint(&evidence, plan.pagination); + } + Ok(evidence) +} diff --git a/src/crates/services/services-integrations/src/review_platform/gitee/tests.rs b/src/crates/services/services-integrations/src/review_platform/gitee/tests.rs new file mode 100644 index 0000000000..05750b920e --- /dev/null +++ b/src/crates/services/services-integrations/src/review_platform/gitee/tests.rs @@ -0,0 +1,1387 @@ +use super::*; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +const BASE: &str = "1111111111111111111111111111111111111111"; +const HEAD: &str = "2222222222222222222222222222222222222222"; + +fn pr() -> Value { + json!({ + "id": 10031905, "number": 69, "state": "open", "draft": false, + "title": "Fix hooks", "body": "Review this change", "user": {"login": "author"}, + "html_url": "https://gitee.com/example/repo/pulls/69", + "base": {"ref": "main", "sha": BASE}, "head": {"ref": "fix/hooks", "sha": HEAD}, + "assignees_number": 1, "assignees": [{"login": "reviewer", "accept": false}], + "testers": [{"accept": true}], "can_merge_check": true, + }) +} + +fn changed_file() -> Value { + json!({ + "filename": "src/new.rs", "status": null, "additions": "1", "deletions": "1", + "patch": { + "diff": "@@ -1 +1 @@\n-old\n+new\n", "old_path": "src/old.rs", "new_path": "src/new.rs", + "renamed_file": true, "new_file": false, "deleted_file": false, "too_large": false, + }, + }) +} + +struct Response { + status: u16, + body: String, + headers: Vec<(&'static str, String)>, +} + +fn ok(value: Value) -> Response { + Response { + status: 200, + body: value.to_string(), + headers: Vec::new(), + } +} + +#[derive(Debug)] +struct Request { + method: String, + url: reqwest::Url, + body: Value, +} + +// Real local HTTP, including request bodies, so wire authentication, verbs and +// pagination are checked independently from the provider's mapping helpers. +fn server(responses: Vec) -> (ProviderContext, Receiver>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut requests = Vec::new(); + for response in responses { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("Missing mock request: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut bytes = Vec::new(); + let (header_end, length) = loop { + let mut buffer = [0; 2048]; + let read = stream.read(&mut buffer).unwrap(); + assert!(read > 0); + bytes.extend_from_slice(&buffer[..read]); + if let Some(index) = bytes.windows(4).position(|b| b == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..index]); + let length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::().unwrap()) + .unwrap_or(0); + break (index + 4, length); + } + }; + while bytes.len() < header_end + length { + let mut buffer = [0; 2048]; + let read = stream.read(&mut buffer).unwrap(); + assert!(read > 0); + bytes.extend_from_slice(&buffer[..read]); + } + let header = String::from_utf8_lossy(&bytes[..header_end]); + let mut line = header.lines().next().unwrap().split_whitespace(); + let method = line.next().unwrap().to_string(); + let path = line.next().unwrap(); + requests.push(Request { + method, + url: reqwest::Url::parse(&format!("http://{address}{path}")).unwrap(), + body: if length == 0 { + Value::Null + } else { + serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap() + }, + }); + let headers = response + .headers + .into_iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + write!(stream, "HTTP/1.1 {} Mock\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", response.status, response.body.len(), headers, response.body).unwrap(); + } + sender.send(requests).unwrap(); + }); + let remote = parse_remote( + "origin", + "git@gitee.com:example/repo.git", + &ReviewPlatformAuthTokens::default(), + ) + .unwrap(); + ( + ProviderContext { + remote, + api_base_url: format!("http://{address}/api/v5"), + token: Some("test-only-token".to_string()), + }, + receiver, + ) +} + +fn requests(receiver: Receiver>) -> Vec { + receiver.recv_timeout(Duration::from_secs(6)).unwrap() +} + +#[test] +fn detects_gitee_remotes_without_gh_or_stored_credentials() { + for url in [ + "git@gitee.com:example/repo.git", + "https://gitee.com/example/repo.git", + "ssh://git@gitee.com/example/repo.git", + ] { + let remote = parse_remote("origin", url, &ReviewPlatformAuthTokens::default()).unwrap(); + assert_eq!(remote.platform, ReviewPlatformKind::Gitee); + assert!(remote.supported); + assert_eq!(remote.project_path, "example/repo"); + assert!(matches!( + remote.auth_state, + ReviewAuthState::NotRequired | ReviewAuthState::Connected + )); + assert_eq!( + provider_context(remote, &ReviewPlatformAuthTokens::default()) + .unwrap() + .api_base_url, + "https://gitee.com/api/v5" + ); + } + let unknown = parse_remote( + "origin", + "https://gitee.example.com/example/repo.git", + &ReviewPlatformAuthTokens::default(), + ) + .unwrap(); + assert!(!unknown.supported); + assert!(provider_context_for_identity( + ReviewPlatformKind::Gitee, + "gitee.example.com", + "example/repo", + &ReviewPlatformAuthTokens::default() + ) + .is_err()); + let ctx = provider_context_for_identity( + ReviewPlatformKind::Gitee, + "gitee.com", + "example/repo", + &ReviewPlatformAuthTokens::default(), + ) + .unwrap(); + assert_eq!(ctx.remote.platform, ReviewPlatformKind::Gitee); +} + +#[test] +fn maps_real_nested_files_and_only_claims_available_diffs() { + let mut value = changed_file(); + let mapped = file(&value).unwrap(); + assert_eq!(mapped.status, ReviewFileStatus::Renamed); + assert_eq!(mapped.old_path.as_deref(), Some("src/old.rs")); + assert_eq!((mapped.additions, mapped.deletions), (1, 1)); + assert!(file_has_complete_patch(&mapped)); + value["patch"]["too_large"] = json!(true); + assert!(!file_has_complete_patch(&file(&value).unwrap())); + value["patch"]["new_file"] = json!(true); + assert_eq!(file(&value).unwrap().status, ReviewFileStatus::Added); + value["patch"]["new_file"] = json!(false); + value["patch"]["deleted_file"] = json!(true); + assert_eq!(file(&value).unwrap().status, ReviewFileStatus::Deleted); + let plain = file(&json!({"filename":"a.rs","status":"modified","additions":1,"deletions":1,"patch":"@@ -1 +1 @@\n-old\n+new\n"})).unwrap(); + assert!(file_has_complete_patch(&plain)); + assert!(file(&json!({"filename":""})).is_err()); +} + +#[test] +fn distinguishes_review_approval_from_test_and_merge_gates() { + let mut value = pr(); + let mapped = pull_request(&value).unwrap(); + assert_eq!( + mapped.provider_id, None, + "internal Gitee IDs must not replace the remote binding" + ); + assert_eq!(mapped.id, "69"); + assert!(!mapped.changed_file_count_known); + assert_eq!(mapped.review_decision, ReviewDecision::Pending); + value["assignees"][0]["accept"] = json!(true); + assert_eq!( + pull_request(&value).unwrap().review_decision, + ReviewDecision::Approved + ); + value["draft"] = json!(true); + assert_eq!(pull_request(&value).unwrap().state, ReviewItemState::Draft); + value["state"] = json!("merged"); + assert_eq!(pull_request(&value).unwrap().state, ReviewItemState::Merged); + value["state"] = json!("unexpected"); + assert!(pull_request(&value).is_err()); +} + +#[test] +fn distinguishes_unknown_line_totals_from_explicit_zero() { + let mut value = pr(); + assert_eq!(pull_request(&value).unwrap().line_stats_known, Some(false)); + value["additions"] = json!("0"); + value["deletions"] = json!(0); + let mapped = pull_request(&value).unwrap(); + assert_eq!(mapped.line_stats_known, Some(true)); + assert_eq!((mapped.additions, mapped.deletions), (0, 0)); + for unavailable in [Value::Null, json!("unknown"), json!(-1)] { + value["additions"] = unavailable; + assert_eq!(pull_request(&value).unwrap().line_stats_known, Some(false)); + } +} + +#[test] +fn file_statistics_require_a_complete_collection_and_preserve_zero() { + let mut mapped = pull_request(&pr()).unwrap(); + let files = file_list(&vec![changed_file(); 2]).unwrap(); + apply_file_stats(&mut mapped, &files); + assert_eq!( + (mapped.changed_files, mapped.additions, mapped.deletions), + (2, 2, 2) + ); + assert_eq!(mapped.line_stats_known, Some(true)); + apply_file_stats(&mut mapped, &file_list(&[]).unwrap()); + assert_eq!( + (mapped.changed_files, mapped.additions, mapped.deletions), + (0, 0, 0) + ); + assert_eq!(mapped.line_stats_known, Some(true)); + apply_file_stats( + &mut mapped, + &file_list(&vec![changed_file(); FILE_LIMIT]).unwrap(), + ); + assert!(!mapped.changed_file_count_known); + assert_eq!(mapped.line_stats_known, Some(false)); +} + +#[test] +fn line_statistics_flag_preserves_legacy_payload_round_trips() { + let mapped = pull_request(&pr()).unwrap(); + let mut payload = serde_json::to_value(mapped).unwrap(); + assert_eq!(payload["lineStatsKnown"], false); + let decoded: ReviewPlatformPullRequest = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(decoded.line_stats_known, Some(false)); + payload.as_object_mut().unwrap().remove("lineStatsKnown"); + let legacy: ReviewPlatformPullRequest = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(legacy.line_stats_known, None); + assert_eq!(serde_json::to_value(legacy).unwrap(), payload); +} + +#[test] +fn source_lines_are_not_diff_positions_and_replies_keep_identity() { + let mut comment = json!({"id":45,"comment_type":"diff_comment","path":"src/a.rs","position":"80","new_line":"12","in_reply_to_id":44}); + let mapped = thread(&comment); + assert_eq!(mapped.line, Some(12)); + assert_eq!(mapped.reply_to_provider_comment_id.as_deref(), Some("44")); + assert_eq!(mapped.kind, ReviewPlatformThreadKind::Review); + comment["new_line"] = Value::Null; + assert_eq!(thread(&comment).line, None); +} + +#[test] +fn capped_collections_do_not_report_an_exact_total_or_full_coverage() { + let files = file_list(&vec![changed_file(); FILE_LIMIT]).unwrap(); + let mut pr = pull_request(&pr()).unwrap(); + apply_file_stats(&mut pr, &files); + assert!(!pr.changed_file_count_known); + let target = review_target(pr, files.items); + assert!(target.omitted_file_count > 0); + assert!(target + .limitations + .contains(&"gitee_file_list_limit".to_string())); + let first = capped_pagination( + PullRequestPagination { + page: 1, + per_page: 100, + }, + FILE_LIMIT, + FILE_LIMIT, + ); + assert_eq!(first.total, None); + assert!(first.has_next); + let last = capped_pagination( + PullRequestPagination { + page: 3, + per_page: 100, + }, + FILE_LIMIT, + FILE_LIMIT, + ); + assert!(!last.has_next); + assert_eq!(last.total, None); + assert_eq!( + capped_pagination( + PullRequestPagination { + page: 1, + per_page: 100 + }, + 2, + COMMIT_LIMIT + ) + .total, + Some(2) + ); +} + +#[tokio::test] +async fn list_uses_gitee_headers_and_documented_auth_parameters() { + let mut response = ok(json!([pr()])); + response.headers.push(("total_count", "3".to_string())); + response.headers.push(( + "Link", + "; rel='next'".to_string(), + )); + let (ctx, receiver) = server(vec![response, ok(json!([changed_file()]))]); + let page = GiteeProvider + .list_pull_requests( + &ctx, + PullRequestPagination { + page: 1, + per_page: 1, + }, + ) + .await + .unwrap(); + assert_eq!(page.pagination.total, Some(3)); + assert!(page.pagination.has_next); + assert_eq!(page.items[0].changed_files, 1); + assert_eq!(page.items[0].additions, 1); + assert_eq!(page.items[0].deletions, 1); + assert!(page.items[0].changed_file_count_known); + assert_eq!(page.items[0].line_stats_known, Some(true)); + let requests = requests(receiver); + assert_stat_requests(&requests[1..], &["69"]); + let request = &requests[0]; + assert_eq!(request.method, "GET"); + assert_eq!(request.url.path(), "/api/v5/repos/example/repo/pulls"); + let query = request.url.query_pairs().collect::>(); + assert_eq!(query["access_token"], "test-only-token"); + assert_eq!(query["state"], "all"); + assert_eq!(query["per_page"], "1"); + let response = JsonResponse { + value: json!([]), + headers: ReviewHttpHeaders::from_pairs(&[("link", "; rel='next'")]), + }; + assert!( + pagination( + &response, + PullRequestPagination { + page: 1, + per_page: 20 + }, + 0 + ) + .has_next + ); +} + +fn numbered_pr(number: u32, state: &str, draft: bool) -> Value { + let mut value = pr(); + value["number"] = json!(number); + value["state"] = json!(state); + value["draft"] = json!(draft); + value +} + +fn counted_page(values: Vec, total: u32) -> Response { + let mut response = ok(json!(values)); + response.headers.push(("total_count", total.to_string())); + response +} + +fn assert_stat_requests(requests: &[Request], numbers: &[&str]) { + let mut paths = Vec::new(); + for request in requests { + assert_eq!(request.method, "GET"); + assert_eq!( + request.url.query_pairs().collect::>(), + [("access_token".into(), "test-only-token".into())] + ); + paths.push(request.url.path().to_string()); + } + let mut expected = numbers + .iter() + .map(|number| format!("/api/v5/repos/example/repo/pulls/{number}/files")) + .collect::>(); + paths.sort(); + expected.sort(); + assert_eq!(paths, expected); +} + +#[tokio::test] +async fn list_returns_statistics_for_every_row_without_reordering() { + let numbers = [9, 4, 8, 1, 7, 3, 6]; + let mut responses = vec![counted_page( + numbers + .iter() + .map(|number| numbered_pr(*number, "merged", false)) + .collect(), + 27, + )]; + responses.extend(numbers.iter().map(|_| ok(json!([changed_file()])))); + let (ctx, receiver) = server(responses); + let page = GiteeProvider + .list_pull_requests_with_state( + &ctx, + PullRequestPagination { + page: 2, + per_page: 7, + }, + ReviewPlatformListState::Merged, + ) + .await + .unwrap(); + assert_eq!( + page.items.iter().map(|pr| pr.number).collect::>(), + numbers.map(i64::from) + ); + for pr in &page.items { + assert_eq!((pr.changed_files, pr.additions, pr.deletions), (1, 1, 1)); + assert!(pr.changed_file_count_known); + assert_eq!(pr.line_stats_known, Some(true)); + } + assert_eq!(page.pagination.total, Some(27)); + assert!(page.pagination.has_next); + let requests = requests(receiver); + assert_stat_requests(&requests[1..], &["9", "4", "8", "1", "7", "3", "6"]); +} + +#[tokio::test] +async fn unavailable_list_statistics_preserve_the_original_row() { + for known in [false, true] { + for response in [ + Response { + status: 403, + body: json!({ "message": "Rate Limit Exceeded" }).to_string(), + headers: Vec::new(), + }, + ok(json!([{ "filename": "" }])), + ] { + let mut value = pr(); + if known { + value["changed_files"] = json!(2); + value["additions"] = json!(11); + value["deletions"] = json!(5); + } + let expected = serde_json::to_value(pull_request(&value).unwrap()).unwrap(); + let (ctx, receiver) = server(vec![counted_page(vec![value], 1), response]); + let page = GiteeProvider + .list_pull_requests( + &ctx, + PullRequestPagination { + page: 1, + per_page: 10, + }, + ) + .await + .unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!(serde_json::to_value(&page.items[0]).unwrap(), expected); + assert_eq!(page.pagination.total, Some(1)); + assert!(!page.pagination.has_next); + assert_stat_requests(&requests(receiver)[1..], &["69"]); + } + } +} + +#[tokio::test] +async fn list_does_not_certify_missing_or_invalid_file_line_counts() { + for invalid in [ + None, + Some(Value::Null), + Some(json!("unknown")), + Some(json!(-1)), + Some(json!(2147483648_i64)), + ] { + let mut value = changed_file(); + if let Some(invalid) = invalid { + value["additions"] = invalid; + } else { + value.as_object_mut().unwrap().remove("additions"); + } + let (ctx, receiver) = server(vec![counted_page(vec![pr()], 1), ok(json!([value]))]); + let page = GiteeProvider + .list_pull_requests( + &ctx, + PullRequestPagination { + page: 1, + per_page: 10, + }, + ) + .await + .unwrap(); + assert_eq!(page.items[0].changed_files, 1); + assert!(page.items[0].changed_file_count_known); + assert_eq!(page.items[0].line_stats_known, Some(false)); + assert_stat_requests(&requests(receiver)[1..], &["69"]); + } +} + +#[tokio::test] +async fn list_does_not_certify_overflowing_line_totals() { + let mut value = changed_file(); + value["additions"] = json!(i32::MAX); + let (ctx, receiver) = server(vec![ + counted_page(vec![pr()], 1), + ok(json!([value.clone(), value])), + ]); + let page = GiteeProvider + .list_pull_requests( + &ctx, + PullRequestPagination { + page: 1, + per_page: 10, + }, + ) + .await + .unwrap(); + assert_eq!(page.items[0].changed_files, 2); + assert!(page.items[0].changed_file_count_known); + assert_eq!(page.items[0].line_stats_known, Some(false)); + assert_stat_requests(&requests(receiver)[1..], &["69"]); +} + +#[tokio::test] +async fn unknown_line_counts_keep_files_and_available_diffs_readable() { + let mut value = changed_file(); + value.as_object_mut().unwrap().remove("additions"); + let (ctx, receiver) = server(vec![ok(pr()), ok(json!([value])), ok(pr())]); + let page = GiteeProvider + .pull_request_detail_page( + &ctx, + "69", + ReviewPlatformDetailSection::Files, + PullRequestPagination { + page: 1, + per_page: 10, + }, + ) + .await + .unwrap(); + assert_eq!(page.files.len(), 1); + assert_eq!(page.files[0].path, "src/new.rs"); + assert!(page.files[0].patch.as_deref().unwrap().contains("+new")); + assert_eq!(page.pull_request.changed_files, 1); + assert!(page.pull_request.changed_file_count_known); + assert_eq!(page.pull_request.line_stats_known, Some(false)); + assert_eq!(requests(receiver).len(), 3); +} + +#[test] +fn incomplete_files_do_not_erase_complete_provider_totals() { + let mut value = pr(); + value["changed_files"] = json!(300); + value["additions"] = json!(1000); + value["deletions"] = json!(500); + let mut mapped = pull_request(&value).unwrap(); + apply_file_stats( + &mut mapped, + &file_list(&vec![changed_file(); FILE_LIMIT]).unwrap(), + ); + assert_eq!( + (mapped.changed_files, mapped.additions, mapped.deletions), + (300, 1000, 500) + ); + assert!(mapped.changed_file_count_known); + assert_eq!(mapped.line_stats_known, Some(true)); + + let mut unavailable = changed_file(); + unavailable["additions"] = Value::Null; + apply_file_stats(&mut mapped, &file_list(&[unavailable]).unwrap()); + assert_eq!((mapped.additions, mapped.deletions), (1000, 500)); + assert_eq!(mapped.line_stats_known, Some(true)); +} + +#[tokio::test] +async fn list_statistics_distinguish_empty_changes_from_capped_collections() { + for length in [0, FILE_LIMIT] { + let (ctx, receiver) = server(vec![ + counted_page(vec![pr()], 1), + ok(json!(vec![changed_file(); length])), + ]); + let page = GiteeProvider + .list_pull_requests( + &ctx, + PullRequestPagination { + page: 1, + per_page: 10, + }, + ) + .await + .unwrap(); + let pr = &page.items[0]; + assert_eq!(pr.changed_files, length as i32); + assert_eq!(pr.additions, length as i32); + assert_eq!(pr.deletions, length as i32); + assert_eq!(pr.changed_file_count_known, length == 0); + assert_eq!(pr.line_stats_known, Some(length == 0)); + assert_stat_requests(&requests(receiver)[1..], &["69"]); + } +} + +#[tokio::test] +async fn repository_state_filters_keep_provider_pagination_and_totals() { + for (state, wire, total, item_state) in [ + ( + ReviewPlatformListState::All, + "all", + 376, + ReviewItemState::Open, + ), + ( + ReviewPlatformListState::Merged, + "merged", + 269, + ReviewItemState::Merged, + ), + ( + ReviewPlatformListState::Closed, + "closed", + 84, + ReviewItemState::Closed, + ), + ] { + let (ctx, receiver) = server(vec![ + counted_page( + vec![numbered_pr( + 42, + if wire == "all" { "open" } else { wire }, + false, + )], + total, + ), + ok(json!([changed_file()])), + ]); + let page = GiteeProvider + .list_pull_requests_with_state( + &ctx, + PullRequestPagination { + page: 2, + per_page: 10, + }, + state, + ) + .await + .unwrap(); + assert_eq!(page.items[0].state, item_state); + assert_eq!(page.pagination.page, 2); + assert_eq!(page.pagination.total, Some(u64::from(total))); + assert!(page.pagination.has_next); + assert_eq!(page.items[0].line_stats_known, Some(true)); + let requests = requests(receiver); + assert_stat_requests(&requests[1..], &["42"]); + let request = &requests[0]; + let query = request.url.query_pairs().collect::>(); + assert_eq!(query["state"], wire); + assert_eq!(query["page"], "2"); + assert_eq!(query["per_page"], "10"); + } +} + +#[tokio::test] +async fn open_and_draft_are_filtered_before_pagination_with_honest_totals() { + for (state, draft, expected) in [ + (ReviewPlatformListState::Open, false, vec!["1", "3"]), + (ReviewPlatformListState::Draft, true, vec!["2", "4"]), + ] { + let (ctx, receiver) = server(vec![ + counted_page( + (1..=100) + .map(|id| numbered_pr(id, "open", id % 2 == 0)) + .collect(), + 103, + ), + ok(json!([changed_file()])), + ok(json!([changed_file()])), + ]); + let page = GiteeProvider + .list_pull_requests_with_state( + &ctx, + PullRequestPagination { + page: 1, + per_page: 2, + }, + state, + ) + .await + .unwrap(); + assert_eq!( + page.items + .iter() + .map(|pr| pr.id.as_str()) + .collect::>(), + expected + ); + assert!(page + .items + .iter() + .all(|pr| (pr.state == ReviewItemState::Draft) == draft)); + assert_eq!( + page.pagination.total, None, + "an unscanned tail cannot supply an exact total" + ); + assert!(page.pagination.has_next); + assert!(page + .items + .iter() + .all(|pr| pr.line_stats_known == Some(true))); + let requests = requests(receiver); + assert_stat_requests(&requests[1..], &expected); + let request = &requests[0]; + let query = request.url.query_pairs().collect::>(); + assert_eq!(query["state"], "open"); + assert_eq!(query["per_page"], "100"); + } + let (ctx, receiver) = server(vec![ + counted_page( + (1..=100) + .map(|id| numbered_pr(id, "open", id % 2 == 0)) + .collect(), + 103, + ), + counted_page( + vec![ + numbered_pr(101, "open", true), + numbered_pr(102, "open", false), + numbered_pr(103, "open", true), + ], + 103, + ), + ok(json!([changed_file()])), + ok(json!([changed_file()])), + ]); + let page = GiteeProvider + .list_pull_requests_with_state( + &ctx, + PullRequestPagination { + page: 26, + per_page: 2, + }, + ReviewPlatformListState::Draft, + ) + .await + .unwrap(); + assert_eq!( + page.items + .iter() + .map(|pr| pr.id.as_str()) + .collect::>(), + ["101", "103"] + ); + assert_eq!(page.pagination.total, Some(52)); + assert!(!page.pagination.has_next); + let requests = requests(receiver); + assert_eq!(requests.len(), 4); + assert_stat_requests(&requests[2..], &["101", "103"]); + assert!(requests[1] + .url + .query_pairs() + .any(|(k, v)| k == "page" && v == "2")); +} + +#[tokio::test] +async fn draft_filter_scans_past_pages_without_matches_and_rejects_incomplete_empty_results() { + let (ctx, receiver) = server(vec![ + counted_page( + (1..=100).map(|id| numbered_pr(id, "open", false)).collect(), + 101, + ), + counted_page(vec![numbered_pr(101, "open", true)], 101), + ok(json!([changed_file()])), + ]); + let page = GiteeProvider + .list_pull_requests_with_state( + &ctx, + PullRequestPagination { + page: 1, + per_page: 10, + }, + ReviewPlatformListState::Draft, + ) + .await + .unwrap(); + assert_eq!(page.items[0].id, "101"); + assert_eq!(page.pagination.total, Some(1)); + assert!(!page.pagination.has_next); + let scanned_requests = requests(receiver); + assert_eq!(scanned_requests.len(), 3); + assert_stat_requests(&scanned_requests[2..], &["101"]); + + let (ctx, receiver) = server(vec![counted_page(Vec::new(), 101)]); + assert!(matches!( + GiteeProvider + .list_pull_requests_with_state( + &ctx, + PullRequestPagination { + page: 1, + per_page: 10 + }, + ReviewPlatformListState::Draft, + ) + .await, + Err(ReviewPlatformError::Parse(_)) + )); + assert_eq!(requests(receiver).len(), 1); +} + +#[test] +fn legacy_capabilities_round_trip_without_requiring_state_filters() { + let remote = parse_remote( + "origin", + "https://gitee.com/example/repo.git", + &ReviewPlatformAuthTokens::default(), + ) + .unwrap(); + let capabilities = capabilities_for_remote(&remote); + assert!(capabilities + .supported_pull_request_states + .contains(&ReviewPlatformListState::Merged)); + let mut legacy = serde_json::to_value(capabilities).unwrap(); + legacy + .as_object_mut() + .unwrap() + .remove("supportedPullRequestStates"); + let decoded: ReviewPlatformCapabilities = serde_json::from_value(legacy.clone()).unwrap(); + assert!(decoded.supported_pull_request_states.is_empty()); + assert_eq!(serde_json::to_value(decoded).unwrap(), legacy); +} + +#[tokio::test] +async fn draft_filter_reports_scan_exhaustion_instead_of_a_false_empty_repository() { + let (ctx, receiver) = server( + (0..MAX_REVIEW_TARGET_PAGES) + .map(|page| { + counted_page( + (1..=100) + .map(|id| numbered_pr(page as u32 * 100 + id, "open", false)) + .collect(), + 1001, + ) + }) + .collect(), + ); + assert!(matches!( + GiteeProvider + .list_pull_requests_with_state( + &ctx, + PullRequestPagination { + page: 1, + per_page: 10 + }, + ReviewPlatformListState::Draft, + ) + .await, + Err(ReviewPlatformError::EvidenceTooLarge { .. }) + )); + assert_eq!(requests(receiver).len(), MAX_REVIEW_TARGET_PAGES); +} + +#[tokio::test] +async fn prepared_diff_is_bound_to_stable_base_and_head() { + let (ctx, receiver) = server(vec![ok(pr()), ok(json!([changed_file()])), ok(pr())]); + let diff = GiteeProvider + .pull_request_file_diff(&ctx, "69", BASE, HEAD, "src/old.rs", Some(99)) + .await + .unwrap(); + assert!(diff + .diff + .starts_with("diff --git a/src/old.rs b/src/new.rs")); + assert_eq!(diff.head_revision, HEAD); + let captured = requests(receiver); + assert_eq!( + captured[1].url.path(), + "/api/v5/repos/example/repo/pulls/69/files" + ); + assert!(!captured[1].url.query_pairs().any(|(key, _)| key == "page")); + + let mut moved = pr(); + moved["head"]["sha"] = json!("3".repeat(40)); + let (ctx, receiver) = server(vec![ok(pr()), ok(json!([changed_file()])), ok(moved)]); + let error = GiteeProvider + .pull_request_review_target(&ctx, "69") + .await + .unwrap_err(); + assert!(matches!(error, ReviewPlatformError::StaleTarget(_))); + requests(receiver); +} + +#[tokio::test] +async fn section_failure_is_not_an_empty_successful_file_list() { + let (ctx, receiver) = server(vec![ + ok(pr()), + Response { + status: 403, + body: "{}".to_string(), + headers: vec![], + }, + ]); + let result = GiteeProvider + .pull_request_detail_page( + &ctx, + "69", + ReviewPlatformDetailSection::Files, + PullRequestPagination { + page: 1, + per_page: 20, + }, + ) + .await; + assert!(matches!( + result, + Err(ReviewPlatformError::Http { status: 403, .. }) + )); + requests(receiver); +} + +#[tokio::test] +async fn checks_use_global_pr_id_and_keep_output_separate_from_execution_logs() { + let check = json!({"id":12,"head_sha":HEAD,"name":"unit tests","status":"completed","conclusion":"failure","output":{"summary":"Tests failed","text":"error: check failed"}}); + let (ctx, receiver) = server(vec![ + ok(json!({"total_count":1,"check_runs":[check.clone()]})), + ok(pr()), + ok(check), + ]); + let (checks, page) = checks_page( + &ctx, + &pull_request(&pr()).unwrap(), + "10031905", + PullRequestPagination { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); + assert_eq!(page.total, Some(1)); + assert_eq!(summarize_ci_items(&checks).failed, 1); + let log = GiteeProvider + .pull_request_ci_log(&ctx, "69", "gitee-check:12", "unit tests") + .await + .unwrap(); + assert_eq!(log.log.as_deref(), Some("error: check failed")); + assert!(log + .message + .unwrap() + .contains("not complete CI execution logs")); + let requests = requests(receiver); + assert!(requests[0] + .url + .query_pairs() + .any(|(k, v)| k == "pull_request_id" && v == "10031905")); + assert!(requests[0].url.path().contains(HEAD)); +} + +#[tokio::test] +async fn create_preserves_fork_head_and_draft_without_extra_mutations() { + let (ctx, receiver) = server(vec![ok(pr())]); + let result = GiteeProvider + .create_pull_request( + &ctx, + &ReviewPlatformCreatePullRequestRequest { + repository_path: "unused".into(), + remote_id: None, + title: "A change".into(), + source_branch: "fork/repo:feature".into(), + target_branch: "main".into(), + body: Some("Details".into()), + draft: Some(true), + }, + ) + .await + .unwrap(); + assert!(result.success); + let request = requests(receiver).remove(0); + assert_eq!(request.method, "POST"); + assert_eq!( + request.body, + json!({"title":"A change","head":"fork/repo:feature","base":"main","body":"Details","draft":true}) + ); +} + +fn approval(body: Option<&str>) -> ReviewPlatformApprovalRequest { + ReviewPlatformApprovalRequest { + repository_path: "unused".into(), + remote_id: "origin".into(), + pull_request_id: "69".into(), + body: body.map(str::to_string), + } +} + +#[tokio::test] +async fn approval_reports_partial_success_and_never_uses_admin_force() { + let (ctx, receiver) = server(vec![ + Response { + status: 201, + body: String::new(), + headers: vec![], + }, + Response { + status: 403, + body: "{}".into(), + headers: vec![], + }, + ]); + let result = GiteeProvider + .approve_pull_request(&ctx, &approval(Some("Looks good"))) + .await + .unwrap(); + assert!(!result.success); + assert!(result.message.contains("approval succeeded")); + assert!(result.message.contains("Retry only submit_review")); + let requests = requests(receiver); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].body, json!({"force":false})); + assert_eq!(requests[1].body, json!({"body":"Looks good"})); +} + +#[tokio::test] +async fn revocation_resets_only_the_current_review_and_unsupported_writes_fail_closed() { + let (ctx, receiver) = server(vec![ok(json!({}))]); + assert!( + GiteeProvider + .revoke_approval(&ctx, &approval(None)) + .await + .unwrap() + .success + ); + let request = requests(receiver).remove(0); + assert_eq!(request.method, "PATCH"); + assert_eq!( + request.url.path(), + "/api/v5/repos/example/repo/pulls/69/assignees" + ); + assert_eq!(request.body, json!({"reset_all":false})); + let capabilities = capabilities_for_remote(&ctx.remote); + assert!( + capabilities.can_approve + && capabilities.can_revoke_approval + && capabilities.can_create_pull_request + ); + assert!( + !capabilities.can_reply_to_thread + && !capabilities.can_resolve_thread + && !capabilities.can_request_changes + && !capabilities.can_merge + && !capabilities.supports_draft_review + ); + assert!(GiteeProvider + .submit_review( + &ctx, + &ReviewPlatformSubmitReviewRequest { + repository_path: "unused".into(), + remote_id: "origin".into(), + pull_request_id: "69".into(), + event: ReviewSubmitEvent::RequestChanges, + body: "Needs changes".into(), + } + ) + .await + .is_err()); + assert!(pull_url(&ctx, "69/merge").is_err()); +} + +#[tokio::test] +async fn gitee_credentials_do_not_change_the_legacy_store_and_survive_other_updates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tokens.json"); + let service = ReviewPlatformService::new_local_only(path.clone()); + service + .update_auth_token(ReviewPlatformKind::Gitlab, "gitlab.com", "old-token") + .await + .unwrap(); + let legacy = fs::read(&path).await.unwrap(); + service + .update_auth_token(ReviewPlatformKind::Gitee, "GITEE.COM.", "new-token") + .await + .unwrap(); + assert_eq!(fs::read(&path).await.unwrap(), legacy); + let sidecar = fs::read(path.with_extension("gitee.json")).await.unwrap(); + service + .update_auth_token(ReviewPlatformKind::Gitcode, "gitcode.com", "another-token") + .await + .unwrap(); + assert_eq!( + fs::read(path.with_extension("gitee.json")).await.unwrap(), + sidecar + ); + let reopened = ReviewPlatformService::new_local_only(path.clone()); + let tokens = reopened.load_stored_tokens().await.unwrap(); + assert_eq!( + tokens.get(ReviewPlatformKind::Gitee, "gitee.com"), + Some("new-token") + ); + assert_eq!( + tokens.get(ReviewPlatformKind::Gitlab, "gitlab.com"), + Some("old-token") + ); + assert!(reopened + .update_auth_token(ReviewPlatformKind::Gitee, "untrusted.example", "never-send") + .await + .is_err()); + reopened + .clear_auth_token(ReviewPlatformKind::Gitee, "gitee.com") + .await + .unwrap(); + assert_eq!( + reopened + .load_stored_tokens() + .await + .unwrap() + .get(ReviewPlatformKind::Gitee, "gitee.com"), + None + ); + fs::write(path.with_extension("gitee.json"), b"unreadable") + .await + .unwrap(); + assert!(reopened.load_stored_tokens().await.is_err()); + assert_eq!( + fs::read(path.with_extension("gitee.json")).await.unwrap(), + b"unreadable" + ); +} + +#[tokio::test] +async fn issue_numbers_remain_strings_and_comments_keep_pagination_evidence() { + let identity = ProviderIssueIdentity::new( + ReviewPlatformKind::Gitee, + "gitee.com", + "example/repo", + "IABC12", + ) + .unwrap(); + assert!(ProviderIssueIdentity::new( + ReviewPlatformKind::Gitee, + "gitee.com", + "example/repo", + "IABC12/../user" + ) + .is_err()); + let mut comments = ok(json!([{"id":12,"body":"Comment","user":{"login":"reviewer"}}])); + comments.headers.push(("total_count", "2".into())); + let (ctx, receiver) = server(vec![ + ok(json!({"number":"IABC12","title":"Issue","body":"Description","state":"open"})), + comments, + ]); + let plan = issue_request_plan( + &ctx, + &identity, + IssuePagination { + page: 1, + per_page: 1, + }, + ) + .unwrap(); + let evidence = acquire_issue_evidence(&ctx, &identity, &plan) + .await + .unwrap(); + assert_eq!(evidence.issue_id, "IABC12"); + assert_eq!(evidence.next_cursor.as_deref(), Some("2")); + assert_eq!(evidence.completeness, ReviewEvidenceCompleteness::Partial); + assert_eq!(evidence.comments.len(), 1); + let requests = requests(receiver); + assert_eq!( + requests[0].url.path(), + "/api/v5/repos/example/repo/issues/IABC12" + ); +} + +#[tokio::test] +async fn transport_errors_do_not_expose_query_credentials() { + let request = authenticate( + http_client().unwrap().get("http://127.0.0.1:0/private"), + Some("sensitive-test-value"), + ); + let error = send_json(request).await.unwrap_err().to_string(); + assert!(!error.contains("sensitive-test-value")); + assert!(!error.contains("access_token")); +} + +#[test] +fn legacy_detail_payloads_default_limitations_and_round_trip_without_new_fields() { + let mut legacy = serde_json::to_value(pull_request(&pr()).unwrap()).unwrap(); + let object = legacy.as_object_mut().unwrap(); + object.insert("body".into(), json!("Description")); + for key in ["ci", "files", "commits", "threads"] { + object.insert(key.into(), json!([])); + } + let detail: ReviewPlatformPullRequestDetail = serde_json::from_value(legacy.clone()).unwrap(); + assert!(detail.limitations.is_empty()); + assert_eq!(serde_json::to_value(detail).unwrap(), legacy); +} + +#[tokio::test] +async fn missing_head_keeps_overview_readable_but_cannot_start_exact_review() { + let mut value = pr(); + value["head"] = Value::Null; + let (ctx, receiver) = server(vec![ + ok(value.clone()), + ok(json!([changed_file()])), + ok(value.clone()), + ]); + let overview = GiteeProvider + .pull_request_detail_page( + &ctx, + "69", + ReviewPlatformDetailSection::Overview, + PullRequestPagination { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); + assert_eq!(overview.pull_request.title, "Fix hooks"); + assert!(overview + .limitations + .contains(&"provider_ci_head_unavailable".to_string())); + assert_eq!(requests(receiver).len(), 3); + let (ctx, receiver) = server(vec![ok(value)]); + assert!(GiteeProvider + .pull_request_review_target(&ctx, "69") + .await + .is_err()); + assert_eq!(requests(receiver).len(), 1); +} + +#[tokio::test] +#[ignore = "Read-only smoke test against the public Gitee API; requires network access"] +async fn public_gitee_initial_list_statistics() { + let mut ctx = provider_context_for_identity( + ReviewPlatformKind::Gitee, + "gitee.com", + "dromara/sa-token", + &ReviewPlatformAuthTokens::default(), + ) + .unwrap(); + ctx.token = std::env::var("GITEE_TOKEN").ok(); + for state in [ + ReviewPlatformListState::All, + ReviewPlatformListState::Open, + ReviewPlatformListState::Draft, + ReviewPlatformListState::Merged, + ReviewPlatformListState::Closed, + ] { + let page = GiteeProvider + .list_pull_requests_with_state( + &ctx, + PullRequestPagination { + page: 1, + per_page: 2, + }, + state, + ) + .await + .unwrap(); + for pr in &page.items { + // Compare initial list data against an independent raw files read; + // do not call a detail loader or reuse the file/statistics mapper. + let response = send_bounded_json( + get(&ctx, &format!("{}/files", pull_url(&ctx, &pr.id).unwrap())).unwrap(), + ) + .await + .unwrap(); + let values = response.as_array().unwrap(); + if values.len() >= FILE_LIMIT { + assert!(!pr.changed_file_count_known); + assert_eq!(pr.line_stats_known, Some(false)); + println!( + "Initial Gitee list {state:?} #{}: capped file response, totals unknown", + pr.number + ); + continue; + } + let sum = |key: &str| -> i32 { + values + .iter() + .map(|file| { + let value = &file[key]; + value + .as_i64() + .unwrap_or_else(|| value.as_str().unwrap().parse().unwrap()) + }) + .sum::() + .try_into() + .unwrap() + }; + assert!(pr.changed_file_count_known); + assert_eq!(pr.line_stats_known, Some(true)); + assert_eq!(pr.changed_files as usize, values.len()); + assert_eq!(pr.additions, sum("additions")); + assert_eq!(pr.deletions, sum("deletions")); + println!( + "Initial Gitee list {state:?} #{}: {} files, +{}, -{}", + pr.number, pr.changed_files, pr.additions, pr.deletions + ); + } + } +} + +#[tokio::test] +#[ignore = "Read-only smoke test against the public Gitee API; requires network access"] +async fn public_gitee_readonly_smoke() { + let mut ctx = provider_context_for_identity( + ReviewPlatformKind::Gitee, + "gitee.com", + "openeuler/go-gitee", + &ReviewPlatformAuthTokens::default(), + ) + .unwrap(); + ctx.token = None; + let target = GiteeProvider + .pull_request_review_target(&ctx, "69") + .await + .unwrap(); + assert_eq!(target.pull_request.id, "69"); + assert_eq!(target.pull_request.provider_id, None); + let file = target + .files + .iter() + .find(|file| file.diff_available) + .expect("Public sample should contain a text diff"); + let diff = GiteeProvider + .pull_request_file_diff( + &ctx, + "69", + target.pull_request.base_revision.as_deref().unwrap(), + target.pull_request.head_revision.as_deref().unwrap(), + &file.path, + None, + ) + .await + .unwrap(); + assert!(diff.diff.starts_with("diff --git ")); + let ci = GiteeProvider + .pull_request_detail_page( + &ctx, + "69", + ReviewPlatformDetailSection::Ci, + PullRequestPagination { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); + assert_eq!(ci.pull_request.id, "69"); + let detail = GiteeProvider.pull_request_detail(&ctx, "69").await.unwrap(); + assert_eq!(detail.pull_request.id, "69"); + assert!(!detail.commits.is_empty()); + assert!(detail.commits.iter().all(|commit| !commit.hash.is_empty())); + assert_eq!(detail.files.len(), target.files.len()); +} diff --git a/src/crates/services/services-integrations/src/review_platform_http.rs b/src/crates/services/services-integrations/src/review_platform_http.rs index ce4537bdbe..ef6cf70a14 100644 --- a/src/crates/services/services-integrations/src/review_platform_http.rs +++ b/src/crates/services/services-integrations/src/review_platform_http.rs @@ -59,6 +59,12 @@ impl ReviewHttpClient { inner: self.inner.put(url), } } + + pub(crate) fn patch(&self, url: &str) -> ReviewHttpRequest { + ReviewHttpRequest { + inner: self.inner.patch(url), + } + } } fn review_redirect_policy() -> reqwest::redirect::Policy { @@ -154,6 +160,23 @@ impl ReviewHttpHeaders { } } +/// Mutation endpoints may return an empty successful response (including 204). +/// Do not misreport an applied mutation as a JSON parse failure and invite retry. +pub(crate) async fn send_success(request: ReviewHttpRequest) -> Result<(), ReviewHttpError> { + let response = request + .inner + .send() + .await + .map_err(|error| ReviewHttpError::Network(error.without_url().to_string()))?; + if !response.status().is_success() { + return Err(ReviewHttpError::Http { + status: response.status().as_u16(), + message: String::new(), + }); + } + Ok(()) +} + pub(crate) async fn send_json(request: ReviewHttpRequest) -> Result { send_json_response(request) .await @@ -174,7 +197,7 @@ pub(crate) async fn send_json_response_bounded( .inner .send() .await - .map_err(|error| ReviewHttpError::Network(error.to_string()))?; + .map_err(|error| ReviewHttpError::Network(error.without_url().to_string()))?; let status = response.status(); let headers = ReviewHttpHeaders::from_header_map(response.headers()); @@ -201,7 +224,8 @@ pub(crate) async fn send_json_response_bounded( let mut body = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| ReviewHttpError::Network(error.to_string()))?; + let chunk = + chunk.map_err(|error| ReviewHttpError::Network(error.without_url().to_string()))?; if let Err(error) = append_bounded_chunk(&mut body, &chunk, body_limit) { if status.is_success() { return Err(error); @@ -244,7 +268,7 @@ pub(crate) async fn send_text_bounded( .inner .send() .await - .map_err(|error| ReviewHttpError::Network(error.to_string()))?; + .map_err(|error| ReviewHttpError::Network(error.without_url().to_string()))?; let status = response.status(); if !status.is_success() { @@ -259,7 +283,8 @@ pub(crate) async fn send_text_bounded( let mut body = Vec::with_capacity(max_bytes.min(64 * 1024)); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| ReviewHttpError::Network(error.to_string()))?; + let chunk = + chunk.map_err(|error| ReviewHttpError::Network(error.without_url().to_string()))?; let remaining = max_bytes.saturating_sub(body.len()); if chunk.len() > remaining { body.extend_from_slice(&chunk[..remaining]); diff --git a/src/web-ui/README.md b/src/web-ui/README.md index 1a2c00012b..70308c43d6 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -108,6 +108,45 @@ failed subscription lookup shows an error instead of presenting preset models as an account result. Antigravity browser login requires the local desktop; device-code login can authorize the other providers from another browser. +## Gitee pull requests + +The Pull Requests panel recognizes HTTPS and SSH remotes on `gitee.com`. Public +repositories can be read anonymously. Add a Gitee personal access token from the +panel, or set `GITEE_TOKEN` on the OpenBitFun host, to access private repositories +and perform authorized write actions. A saved token takes precedence over the +environment. Grant the Gitee `pull_requests` and `projects` scopes for PR work; +Issue evidence additionally needs the corresponding `issues` scope. Repository +membership and reviewer permissions still apply. + +Gitee supports PR details, files/diffs, commits, comments, check runs, Deep Review, +PR creation (including drafts and fork branches), ordinary review comments, +approval, and resetting the current user's approval. Approval never uses the +administrator force option or resets other reviewers. If approval succeeds but +its accompanying comment fails, the action reports the applied approval and asks +to retry only the comment. + +Gitee fetches file and line counts for the current page before returning list rows, +using the same bounded concurrency as GitLab and GitCode. Filtering and pagination +run first; a failed statistics request preserves the PR and its known/unknown counts. + +The adapter conservatively treats responses of 200 files or 250 commits as +potentially incomplete. Public file responses have stopped at 200 and ignored +pagination parameters despite the schema's advertised 300-file limit. Deep Review +retains limited coverage instead of claiming a complete review. Diffs are bound to the PR's full +base/head revisions and become stale if the target changes while loading. Check +output/error excerpts are available; full CI execution logs remain at the check's +external details page. Native change requests, replies to a specific thread, +thread resolution, draft reviews, and merging are not exposed as Gitee actions. + +For an SSH workspace, repository discovery runs through the remote workspace +transport and Gitee API requests use the OpenBitFun host's network and credentials. +Peer mode uses the target host; both sides must support the provider. A CLI peer +does not expose the desktop PR panel. Headless Agent tools require credentials on +the executing host and report missing access without requiring a local GUI login. +Gitee credentials are stored separately from the legacy review-platform token +file so downgrading does not make existing GitLab/GitCode credentials unreadable. +Self-hosted Gitee installations are not inferred from arbitrary hostnames. + ## Related docs (within this package) - [Logging guide](LOGGING.md) diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.ts b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.ts index 7b1a28e4d9..076ccbf890 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.ts +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.ts @@ -5,6 +5,7 @@ import { currentPullRequestReviewStatusText, effectivePullRequestReviewFreshness, mergeChangedFileCount, + mergePullRequestDetailLimitations, mergeRevalidatedPullRequestOverview, pullRequestReviewFreshness, pullRequestReviewLaunchKey, @@ -127,6 +128,7 @@ describe('pull request Review linking', () => { files: [{ path: 'src/lib.rs' }], commits: [{ id: 'commit-1' }], threads: [{ id: 'thread-1' }], + limitations: ['gitee_commit_list_limit', 'provider_ci_head_unavailable'], } as ReviewPlatformPullRequestDetail; const overview = { baseRevision, @@ -144,6 +146,7 @@ describe('pull request Review linking', () => { expect(merged.files).toBe(current.files); expect(merged.commits).toBe(current.commits); expect(merged.threads).toBe(current.threads); + expect(merged.limitations).toEqual(['gitee_commit_list_limit']); }); it('drops cached sections when provider revisions change', () => { @@ -154,6 +157,7 @@ describe('pull request Review linking', () => { files: [{ path: 'src/lib.rs' }], commits: [{ id: 'commit-1' }], threads: [{ id: 'thread-1' }], + limitations: ['gitee_commit_list_limit'], } as ReviewPlatformPullRequestDetail; const overview = { baseRevision, @@ -167,6 +171,18 @@ describe('pull request Review linking', () => { const merged = mergeRevalidatedPullRequestOverview(current, overview); expect(merged).toBe(overview); + expect(merged.limitations).toBeUndefined(); + }); + + it('keeps coverage warnings for other tabs and clears only refreshed section warnings', () => { + const files = mergePullRequestDetailLimitations(undefined, ['gitee_file_list_limit'], 'files'); + const commits = mergePullRequestDetailLimitations(files, ['gitee_commit_list_limit'], 'commits'); + expect(commits).toEqual(['gitee_file_list_limit', 'gitee_commit_list_limit']); + expect(mergePullRequestDetailLimitations(commits, undefined, 'reviews')).toEqual(commits); + expect(mergePullRequestDetailLimitations(commits, [], 'commits')).toEqual(['gitee_file_list_limit']); + expect(mergePullRequestDetailLimitations(commits, ['gitee_file_list_limit'], 'files')).toEqual([ + 'gitee_commit_list_limit', 'gitee_file_list_limit', + ]); }); it('does not replace known change stats when overview enrichment fails', () => { @@ -185,8 +201,8 @@ describe('pull request Review linking', () => { const overview = { baseRevision, headRevision, - additions: 133, - deletions: 22, + additions: 100, + deletions: 10, changedFiles: 0, changedFileCountKnown: false, ci: [], @@ -199,6 +215,21 @@ describe('pull request Review linking', () => { expect(merged.changedFiles).toBe(13); expect(merged.changedFileCountKnown).toBe(true); + expect(merged.additions).toBe(133); + expect(merged.deletions).toBe(22); + }); + + it.each([undefined, true])('accepts zero line counts from a legacy overview with file-count flag %s', changedFileCountKnown => { + const current = { + baseRevision, headRevision, additions: 133, deletions: 22, + changedFiles: 13, changedFileCountKnown: true, + ci: [], files: [], commits: [], threads: [], + } as unknown as ReviewPlatformPullRequestDetail; + const overview = { ...current, additions: 0, deletions: 0, changedFiles: 0, changedFileCountKnown }; + const merged = mergeRevalidatedPullRequestOverview(current, overview); + expect(merged.additions).toBe(0); + expect(merged.deletions).toBe(0); + expect(merged.lineStatsKnown).toBeUndefined(); }); it('keeps an authoritative zero when merging file counts', () => { diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.tsx new file mode 100644 index 0000000000..46bee10cf7 --- /dev/null +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReviewPlatformPullRequest, ReviewPlatformPullRequestDetailPage, ReviewPlatformWorkspaceSnapshot } from '@/infrastructure/api/service-api/ReviewPlatformAPI'; +import { ReviewPlatformPanel } from './ReviewPlatformPanel'; + +const mocks = vi.hoisted(() => ({ snapshot: vi.fn(), detail: vi.fn(), t: (key: string) => key })); +vi.mock('@/infrastructure/api', () => ({ reviewPlatformAPI: { getWorkspaceSnapshot: mocks.snapshot, getPullRequestDetailPage: mocks.detail }, systemAPI: {} })); +vi.mock('@/infrastructure/markdown', () => ({ MarkdownRenderer: () => null })); +vi.mock('@/shared/notification-system', () => ({ notificationService: {} })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: mocks.t }), i18nService: { t: mocks.t, formatDate: () => '' } })); +vi.mock('@/flow_chat/services/sessionActivation', () => ({ openMainSession: vi.fn() })); +vi.mock('@/flow_chat/services/btwSessionPane', () => ({ openBtwSessionInAuxPane: vi.fn() })); +vi.mock('@/flow_chat/services/ReviewService', () => ({ launchPreparedReviewSession: vi.fn(), prepareReviewLaunchFromPullRequest: vi.fn() })); +vi.mock('@/flow_chat/components/DeepReviewConsentDialog', () => ({ useDeepReviewConsent: () => ({ confirmDeepReviewLaunch: vi.fn(), deepReviewConsentDialog: null }) })); +vi.mock('@/flow_chat/store/FlowChatStore', () => ({ flowChatStore: { getState: () => ({ sessions: new Map(), activeSessionId: null }), subscribe: () => () => {} } })); +vi.mock('@/shared/stores/contextStore', () => ({ useContextStore: {} })); +vi.mock('@/shared/services/ide-control', () => ({ quickActions: {} })); +vi.mock('@openbitfun/ui', async () => { + const { createElement } = await import('react'); + const control = (tag: string) => ({ children, onClick, disabled, ...props }: Record) => createElement(tag, { + onClick, disabled, ...Object.fromEntries(Object.entries(props).filter(([key]) => key.startsWith('data-') || key.startsWith('aria-') || key === 'className')), + }, children); + const box = control('div'); + return { + Button: control('button'), IconButton: control('button'), Input: control('input'), + Combobox: () => null, Icon: () => null, Field: box, ScrollArea: box, TabGroup: () => null, + Tooltip: box, Dialog: () => null, DialogBody: box, DialogClose: box, DialogHeader: box, + DialogHeading: box, DialogTitle: box, + }; +}); + +function pull(number: number, state: 'open' | 'merged'): ReviewPlatformPullRequest { + return { id: String(number), number, state, title: `PR ${number}`, author: 'author', sourceBranch: 'feature', targetBranch: 'main', + baseRevision: 'a'.repeat(40), headRevision: 'b'.repeat(40), updatedAt: '', webUrl: '', additions: 0, deletions: 0, + changedFiles: 0, changedFileCountKnown: false, lineStatsKnown: false, comments: 0, + reviewDecision: 'pending', checks: { total: 0, passed: 0, failed: 0, pending: 0 } }; +} +function snapshot(path: string, page = 1, state = 'all'): ReviewPlatformWorkspaceSnapshot { + const remote = { id: 'origin', name: 'origin', url: 'https://gitee.com/example/repo.git', platform: 'gitee' as const, + host: 'gitee.com', owner: 'example', repositoryName: 'repo', projectPath: 'example/repo', webUrl: '', supported: true, + authState: 'not_required' as const, authSource: 'none' as const }; + return { remotes: [remote], selectedRemoteId: 'origin', accounts: [], + repository: { providerId: 'origin', platform: 'gitee', host: 'gitee.com', owner: 'example', name: 'repo', projectPath: 'example/repo', + defaultBranch: 'main', workspacePath: path, webUrl: '' }, + pullRequests: Array.from({ length: 10 }, (_, index) => pull((state === 'merged' ? 100 : page * 10) + index, state === 'merged' ? 'merged' : 'open')), + pagination: { page, perPage: 10, total: state === 'merged' ? 269 : 376, hasNext: true }, + capabilities: { canCreateReview: true, canCreatePullRequest: false, canReplyToThread: false, canResolveThread: false, + canApprove: false, canRevokeApproval: false, canRequestChanges: false, canMerge: false, supportsDraftReview: false, + supportedPullRequestStates: ['all', 'open', 'draft', 'merged', 'closed'] } }; +} +function detail(number: number, section: ReviewPlatformPullRequestDetailPage['section']): ReviewPlatformPullRequestDetailPage { + return { ...pull(number, number >= 100 ? 'merged' : 'open'), section, body: '', ci: [], files: [], commits: [], threads: [], + ...(section === 'overview' ? { changedFiles: 2, changedFileCountKnown: true, additions: 4, deletions: 4, lineStatsKnown: true } : {}), + pagination: { page: 1, perPage: 20, total: 0, hasNext: false } }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +let root: Root; +let host: HTMLDivElement; +let testNumber = 0; +async function click(id: string) { + await act(async () => { host.querySelector(`[data-testid="${id}"]`)!.click(); }); +} +async function mount() { + const path = `/gitee-panel-test-${++testNumber}`; + await act(async () => { root.render(); }); + return path; +} + +describe('Gitee panel state and asynchronous request ordering', () => { + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + mocks.snapshot.mockReset().mockImplementation((path, _remote, page, _size, state) => Promise.resolve(snapshot(path, page, state))); + mocks.detail.mockReset().mockImplementation(() => new Promise(() => {})); + }); + afterEach(async () => { await act(async () => root.unmount()); host.remove(); }); + + it('renders statistics for every initial row while selected detail is still pending', async () => { + mocks.snapshot.mockImplementation((path, _remote, page, _size, state) => { + const result = snapshot(path, page, state); + result.pullRequests = result.pullRequests.map((pr, index) => ({ ...pr, + changedFiles: index, changedFileCountKnown: true, + additions: index * 4, deletions: index * 2, lineStatsKnown: true, + })); + return Promise.resolve(result); + }); + await mount(); + const rows = [...host.querySelectorAll('[data-testid="review-platform-pr-row"]')]; + expect(rows).toHaveLength(10); + for (const [index, row] of rows.entries()) { + expect(row.querySelector('[data-testid="review-platform-pr-files"]')?.textContent).toBe(`${index} files`); + expect(row.querySelector('[data-testid="review-platform-pr-additions"]')?.textContent).toBe(`+${index * 4}`); + expect(row.querySelector('[data-testid="review-platform-pr-deletions"]')?.textContent).toBe(`-${index * 2}`); + } + expect(mocks.detail.mock.calls.map(([request]) => request.pullRequestId)).toEqual(['10']); + }); + + it.each([true, false])('uses complete list statistics for unknown detail counts only at the same revisions (%s)', async sameRevisions => { + mocks.snapshot.mockImplementation((path, _remote, page, _size, state) => { + const result = snapshot(path, page, state); + result.pullRequests = result.pullRequests.map(pr => ({ ...pr, + changedFiles: 2, changedFileCountKnown: true, additions: 4, deletions: 3, lineStatsKnown: true, + })); + return Promise.resolve(result); + }); + mocks.detail.mockImplementation(({ pullRequestId, section }) => Promise.resolve({ + ...detail(Number(pullRequestId), section), + headRevision: (sameRevisions ? 'b' : 'c').repeat(40), + changedFiles: 0, changedFileCountKnown: false, additions: 0, deletions: 0, lineStatsKnown: false, + })); + await mount(); + expect(host.querySelector('[data-testid="review-platform-detail-files"]')?.textContent).toBe(sameRevisions ? '2 files' : '— files'); + expect(host.querySelector('[data-testid="review-platform-detail-additions"]')?.textContent).toBe(sameRevisions ? '+4' : '—'); + expect(host.querySelector('[data-testid="review-platform-detail-deletions"]')?.textContent).toBe(sameRevisions ? '-3' : '—'); + }); + + it('updates both row and detail statistics to zero for a legacy overview', async () => { + mocks.snapshot.mockImplementation((path, _remote, page, _size, state) => { + const result = snapshot(path, page, state); + result.pullRequests = result.pullRequests.map(pr => ({ ...pr, + changedFiles: 2, changedFileCountKnown: true, additions: 4, deletions: 3, lineStatsKnown: undefined, + })); + return Promise.resolve(result); + }); + mocks.detail.mockImplementation(({ pullRequestId, section }) => Promise.resolve({ + ...detail(Number(pullRequestId), section), + changedFiles: 0, changedFileCountKnown: section === 'overview', + additions: 0, deletions: 0, lineStatsKnown: undefined, + })); + await mount(); + const row = host.querySelector('[data-testid="review-platform-pr-row"][data-pr-number="10"]')!; + for (const [owner, prefix] of [[row, 'pr'], [host, 'detail']] as const) { + expect(owner.querySelector(`[data-testid="review-platform-${prefix}-files"]`)?.textContent).toBe('0 files'); + expect(owner.querySelector(`[data-testid="review-platform-${prefix}-additions"]`)?.textContent).toBe('+0'); + expect(owner.querySelector(`[data-testid="review-platform-${prefix}-deletions"]`)?.textContent).toBe('-0'); + } + }); + + it('requests Merged from page one after All page two and ignores a late All refresh', async () => { + const path = await mount(); + await click('review-platform-next-page'); + expect(host.querySelector('[data-testid="review-platform-pagination"]')?.textContent).toContain('11-20 of 376'); + const pending = deferred(); + mocks.snapshot.mockReturnValueOnce(pending.promise); + await click('review-platform-refresh'); + await click('review-platform-filter-merged'); + expect(mocks.snapshot).toHaveBeenLastCalledWith(path, null, 1, 10, 'merged'); + await act(async () => pending.resolve(snapshot(path, 2))); + expect(host.querySelector('[data-testid="review-platform-pagination"]')?.textContent).toContain('1-10 of 269'); + expect([...host.querySelectorAll('[data-testid="review-platform-pr-row"]')].map(row => row.getAttribute('data-pr-state'))).toEqual(Array(10).fill('merged')); + }); + + it('does not enqueue old PR reviews when its CI response arrives after switching state', async () => { + const pendingCi = deferred(); + mocks.detail.mockImplementation(({ pullRequestId, section }) => section === 'ci' && pullRequestId === '10' + ? pendingCi.promise : Promise.resolve(detail(Number(pullRequestId), section))); + await mount(); + expect(mocks.detail).toHaveBeenCalledWith(expect.objectContaining({ pullRequestId: '10', section: 'ci' })); + await click('review-platform-filter-merged'); + await act(async () => pendingCi.resolve(detail(10, 'ci'))); + expect(mocks.detail).not.toHaveBeenCalledWith(expect.objectContaining({ pullRequestId: '10', section: 'reviews' })); + expect(host.querySelector('[data-testid="review-platform-detail-state"]')?.textContent).toBe('Merged'); + expect(host.querySelector('[data-openbitfun-part="detailMeta"]')?.textContent).toContain('#100'); + }); + + it.each([4, 0])('shares verified statistics (%i lines) with the list and discards them for a new revision', async lines => { + const pendingOverview = deferred(); + mocks.detail.mockImplementation(({ pullRequestId, section }) => section === 'overview' + ? pendingOverview.promise : Promise.resolve(detail(Number(pullRequestId), section))); + const path = await mount(); + const row = () => host.querySelector('[data-testid="review-platform-pr-row"][data-pr-number="10"]')!; + const text = (owner: Element, id: string) => owner.querySelector(`[data-testid="review-platform-${id}"]`)?.textContent; + expect(text(row(), 'pr-files')).toBe('— files'); + expect(text(row(), 'pr-additions')).toBe('—'); + expect(text(row(), 'pr-deletions')).toBe('—'); + await act(async () => pendingOverview.resolve({ ...detail(10, 'overview'), additions: lines, deletions: lines })); + for (const [owner, prefix] of [[row(), 'pr'], [host, 'detail']] as const) { + expect(text(owner, `${prefix}-files`)).toBe('2 files'); + expect(text(owner, `${prefix}-additions`)).toBe(`+${lines}`); + expect(text(owner, `${prefix}-deletions`)).toBe(`-${lines}`); + } + expect(mocks.detail.mock.calls.map(([request]) => request.section)).toEqual(['overview', 'ci', 'reviews']); + + // Revalidating the same revision must accept an authoritative zero. + mocks.detail.mockImplementation(({ pullRequestId, section }) => Promise.resolve({ + ...detail(Number(pullRequestId), section), + ...(section === 'overview' ? { additions: 0, deletions: 0 } : {}), + })); + await click('review-platform-refresh'); + expect(text(row(), 'pr-additions')).toBe('+0'); + expect(text(host, 'detail-additions')).toBe('+0'); + + const next = snapshot(path); + next.pullRequests[0].headRevision = 'c'.repeat(40); + mocks.snapshot.mockResolvedValueOnce(next); + mocks.detail.mockImplementation(() => new Promise(() => {})); + await click('review-platform-refresh'); + expect(text(row(), 'pr-files')).toBe('— files'); + expect(text(row(), 'pr-additions')).toBe('—'); + expect(text(row(), 'pr-deletions')).toBe('—'); + }); +}); diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.trust.test.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.trust.test.tsx index 50ff711139..db54c477c1 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.trust.test.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.trust.test.tsx @@ -17,6 +17,7 @@ vi.mock('@/infrastructure/api', () => ({ vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmWarning: mocks.confirm })); vi.mock('@/infrastructure/i18n', () => ({ i18nService: { t: (key: string) => key }, + useI18n: () => ({ t: (key: string) => key }), })); vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx index f38316b583..42e73bc438 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -22,7 +22,7 @@ import { MarkdownRenderer } from '@/infrastructure/markdown'; import { reviewPlatformAPI, systemAPI, type ReviewPlatformAccount, type ReviewPlatformAuthChallenge, type ReviewPlatformCiItem, type ReviewPlatformCiLog, type ReviewPlatformCommit, type ReviewPlatformDetailSection, type ReviewPlatformFile, type ReviewPlatformPagination, type ReviewPlatformPullRequest, type ReviewPlatformPullRequestDetail, type ReviewPlatformPullRequestDetailPage, type ReviewPlatformRemote, type ReviewPlatformRepositoryRef, type ReviewPlatformThread, type ReviewPlatformWorkspaceSnapshot } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { notificationService } from '@/shared/notification-system'; -import { i18nService } from '@/infrastructure/i18n'; +import { i18nService, useI18n } from '@/infrastructure/i18n'; import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { openBtwSessionInAuxPane } from '@/flow_chat/services/btwSessionPane'; import { @@ -46,10 +46,14 @@ import { currentPullRequestReviewStatusText, effectivePullRequestReviewFreshness, mergeChangedFileCount, + mergeLineStats, + mergePullRequestDetailLimitations, mergeRevalidatedPullRequestOverview, pullRequestReviewFreshness, pullRequestReviewLaunchKey, resolvedChangedFileCount, + resolvedLineStats, + resolvedPullRequestStatistics, samePullRequestRevisions, samePullRequestIdentity, type PullRequestReviewFreshness, @@ -166,8 +170,8 @@ function detailPageInfo(pagination: ReviewPlatformPagination, itemCount: number) }; } -function snapshotCacheKey(workspacePath: string, remoteId: string | null, page: number, perPage: number, mode: 'list' | 'context'): string { - return `${workspacePath}::${remoteId ?? 'default'}::${page}::${perPage}::${mode}`; +function snapshotCacheKey(workspacePath: string, remoteId: string | null, page: number, perPage: number, mode: 'list' | 'context', state: ListStateFilter): string { + return `${workspacePath}::${remoteId ?? 'default'}::${page}::${perPage}::${mode}::${state}`; } function detailCacheKey(workspacePath: string, remoteId: string, pullRequestId: string): string { @@ -199,8 +203,8 @@ function mergeDetailPage( return { ...base, ...page, - additions: page.additions || base.additions, - deletions: page.deletions || base.deletions, + limitations: mergePullRequestDetailLimitations(base.limitations, page.limitations, page.section), + ...mergeLineStats(base, page), ...mergeChangedFileCount(base, page), ci: page.section === 'ci' ? page.ci : base.ci, files: page.section === 'files' ? page.files : base.files, @@ -304,6 +308,8 @@ function providerLabel(remote: ReviewPlatformRemote | ReviewPlatformAccount | nu return 'GitLab'; case 'gitcode': return 'GitCode'; + case 'gitee': + return 'Gitee'; default: return 'Git'; } @@ -658,6 +664,7 @@ export const ReviewPlatformPanel: React.FC = ({ initialPullRequestUrl, detailOnly = false, }) => { + const { t } = useI18n('panels/git'); const snapshotRequestSeq = useRef(0); const detailRequestSeq = useRef(0); const detailSectionRequestSeq = useRef(0); @@ -676,6 +683,7 @@ export const ReviewPlatformPanel: React.FC = ({ const [error, setError] = useState(null); const [query, setQuery] = useState(''); const [stateFilter, setStateFilter] = useState('all'); + const serverStateFilter = useRef('all'); const [pageIndex, setPageIndex] = useState(0); const [ciPageIndex, setCiPageIndex] = useState(0); const [changePageIndex, setChangePageIndex] = useState(0); @@ -767,9 +775,11 @@ export const ReviewPlatformPanel: React.FC = ({ const loadSnapshot = useCallback(async ( nextRemoteId?: string | null, - options?: { force?: boolean; page?: number; userInitiated?: boolean }, + options?: { force?: boolean; page?: number; state?: ListStateFilter; userInitiated?: boolean }, ) => { const requestSeq = ++snapshotRequestSeq.current; + detailRequestSeq.current += 1; + detailSectionRequestSeq.current += 1; if (!workspacePath) { setSnapshot(emptySnapshot()); setSelectedRemoteId(null); @@ -789,9 +799,10 @@ export const ReviewPlatformPanel: React.FC = ({ ? readRememberedRemote(workspacePath) : null; const requestedPage = Math.max(1, options?.page ?? 1); + const requestedState = detailOnly ? 'all' : options?.state ?? serverStateFilter.current; const snapshotMode = detailOnly ? 'context' : 'list'; setListRemoteId(requestedRemoteId ?? null); - const requestedCacheKey = snapshotCacheKey(workspacePath, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE, snapshotMode); + const requestedCacheKey = snapshotCacheKey(workspacePath, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE, snapshotMode, requestedState); const cached = snapshotCache.get(requestedCacheKey); const force = options?.force === true; @@ -809,7 +820,7 @@ export const ReviewPlatformPanel: React.FC = ({ setLoading(false); return; } else { - setSnapshot(emptySnapshot()); + setSnapshot(current => ({ ...current, pullRequests: [], pagination: emptyPagination(requestedPage, PR_PAGE_SIZE) })); setSelectedPrId(null); setDetail(null); setVerifiedDetailKey(null); @@ -827,6 +838,7 @@ export const ReviewPlatformPanel: React.FC = ({ requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE, + requestedState, ); const next = options?.userInitiated ? await withGitRepositoryTrustRecovery(fetchSnapshot, { userInitiated: true }) @@ -844,16 +856,13 @@ export const ReviewPlatformPanel: React.FC = ({ const entry = { snapshot: next, fetchedAt: Date.now() }; snapshotCache.set(requestedCacheKey, entry); if (remoteId) { - snapshotCache.set(snapshotCacheKey(workspacePath, remoteId, requestedPage, PR_PAGE_SIZE, snapshotMode), entry); + snapshotCache.set(snapshotCacheKey(workspacePath, remoteId, requestedPage, PR_PAGE_SIZE, snapshotMode, requestedState), entry); } setSnapshotCacheState('cached'); } catch (err) { if (snapshotRequestSeq.current !== requestSeq) return; const message = reviewPlatformErrorMessage(err, 'Failed to load pull requests'); setError(message); - if (!cached) { - setSnapshot(emptySnapshot()); - } log.error('Failed to load review platform snapshot', { workspacePath, error: err }); } finally { if (snapshotRequestSeq.current === requestSeq) { @@ -990,7 +999,10 @@ export const ReviewPlatformPanel: React.FC = ({ }, [applySectionPagination, loadDetail, workspacePath]); useEffect(() => { - void loadSnapshot(detailOnly && initialRemoteId ? initialRemoteId : undefined); + serverStateFilter.current = 'all'; + setStateFilter('all'); + setSnapshot(emptySnapshot()); + void loadSnapshot(detailOnly && initialRemoteId ? initialRemoteId : undefined, { state: 'all' }); }, [detailOnly, initialRemoteId, loadSnapshot]); useEffect(() => flowChatStore.subscribe(setFlowState), []); @@ -1084,16 +1096,24 @@ export const ReviewPlatformPanel: React.FC = ({ useEffect(() => { if (!hasDetail || !selectedRemoteId || !selectedPrId || (!repository && !workspacePath)) return; + if (verifiedDetailKey !== detailCacheKey(workspacePath || repository?.workspacePath || '', selectedRemoteId, selectedPrId)) return; + let disposed = false; if (activeTab === 'overview') { void (async () => { await loadDetailSection(repository, selectedRemoteId, selectedPrId, 'ci', ciPageIndex, CI_PAGE_SIZE); - await loadDetailSection(repository, selectedRemoteId, selectedPrId, 'reviews', reviewPageIndex, REVIEW_PAGE_SIZE); + if (!disposed) { + await loadDetailSection(repository, selectedRemoteId, selectedPrId, 'reviews', reviewPageIndex, REVIEW_PAGE_SIZE); + } })(); } else if (activeTab === 'changes') { void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'files', changePageIndex, CHANGE_PAGE_SIZE); } else if (activeTab === 'commits') { void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'commits', commitPageIndex, COMMIT_PAGE_SIZE); } + return () => { + disposed = true; + detailSectionRequestSeq.current += 1; + }; }, [ activeTab, ciPageIndex, @@ -1107,6 +1127,7 @@ export const ReviewPlatformPanel: React.FC = ({ reviewPageIndex, selectedPrId, selectedRemoteId, + verifiedDetailKey, workspacePath, ]); @@ -1268,11 +1289,22 @@ export const ReviewPlatformPanel: React.FC = ({ setDetail(null); setDetailError(null); setStateFilter('all'); + serverStateFilter.current = 'all'; setPageIndex(0); rememberRemote(workspacePath, remoteId || null); - void loadSnapshot(remoteId || null, { page: 1 }); + setSnapshot(emptySnapshot()); + void loadSnapshot(remoteId || null, { page: 1, state: 'all' }); }, [loadSnapshot, workspacePath]); + const handleStateChange = useCallback((state: ListStateFilter) => { + setStateFilter(state); + if (snapshot.capabilities.supportedPullRequestStates?.includes(state)) { + serverStateFilter.current = state; + setPageIndex(0); + void loadSnapshot(listRemoteId, { page: 1, state }); + } + }, [listRemoteId, loadSnapshot, snapshot.capabilities.supportedPullRequestStates]); + const handlePageChange = useCallback((nextPageIndex: number) => { const nextPage = Math.max(1, nextPageIndex + 1); setSelectedPrId(null); @@ -1822,6 +1854,10 @@ export const ReviewPlatformPanel: React.FC = ({ ? `${providerLabel(selectedRemote)} · ${authLabel(account)}` : 'No remote detected'; const displayPr = currentPullRequest; + const displayStatistics = selectedPrFromList && (!detail || samePullRequestRevisions(selectedPrFromList, detail)) + ? resolvedPullRequestStatistics(selectedPrFromList, detail) + : displayPr; + const displayLineStats = resolvedLineStats(displayStatistics); const checksText = displayPr && displayPr.checks.total > 0 ? `${displayPr.checks.passed}/${displayPr.checks.total}` : 'N/A'; @@ -1928,6 +1964,7 @@ export const ReviewPlatformPanel: React.FC = ({ void loadSnapshot(listRemoteId, { force: true, page: currentPageIndex + 1, userInitiated: true })} @@ -1983,23 +2020,29 @@ export const ReviewPlatformPanel: React.FC = ({ key={state} type="button" className={`review-platform__state-chip${stateFilter === state ? ' is-active' : ''}`} - onClick={() => setStateFilter(state)} + data-testid={`review-platform-filter-${state}`} + aria-pressed={stateFilter === state} + disabled={selectedRemote?.platform === 'gitee' && state !== 'all' && !snapshot.capabilities.supportedPullRequestStates?.includes(state)} + onClick={() => handleStateChange(state)} > {state === 'all' ? 'All' : stateLabel(state)} ))} )} + {selectedRemote?.platform === 'gitee' && !snapshot.capabilities.supportedPullRequestStates?.length && ( +
{t('reviewPlatform.stateFilterUnsupported')}
+ )} {loading && ( -
Loading pull requests...
+
Loading pull requests...
)} {error && (
- {error} + {error.includes('review_platform_state_filter_unsupported') ? t('reviewPlatform.stateFilterUnsupported') : error} @@ -2016,8 +2059,16 @@ export const ReviewPlatformPanel: React.FC = ({ const pullRequestRemote = pr.providerId ? snapshot.remotes.find(remote => remote.id === pr.providerId) : selectedRemote; + const cachedDetail = pullRequestRemote + ? detailCache.get(detailCacheKey(workspacePath || repository?.workspacePath || '', pullRequestRemote.id, pr.id)) + : undefined; + const statistics = resolvedPullRequestStatistics(pr, cachedDetail?.detail); + const lineStats = resolvedLineStats(statistics); return ( @@ -2056,10 +2107,11 @@ export const ReviewPlatformPanel: React.FC = ({ ))} {!loading && !error && (totalPages > 1 || pagination.hasNext) && ( -
+
= ({ = totalPages - 1} @@ -2161,7 +2214,7 @@ export const ReviewPlatformPanel: React.FC = ({
{getPrIcon(selectedPr)}

{selectedPr.title}

- + {stateLabel(displayPr?.state ?? selectedPr.state)}
@@ -2230,9 +2283,9 @@ export const ReviewPlatformPanel: React.FC = ({ {displayPr?.sourceBranch ?? selectedPr.sourceBranch} {displayPr?.targetBranch ?? selectedPr.targetBranch} - {resolvedChangedFileCount(displayPr, selectedPr) ?? '—'} files - +{displayPr?.additions ?? selectedPr.additions} - -{displayPr?.deletions ?? selectedPr.deletions} + {resolvedChangedFileCount(displayStatistics) ?? '—'} files + {displayLineStats ? `+${displayLineStats.additions}` : '—'} + {displayLineStats ? `-${displayLineStats.deletions}` : '—'}
@@ -2266,6 +2319,20 @@ export const ReviewPlatformPanel: React.FC = ({
+ {(detail?.limitations?.length ?? 0) > 0 && ( +
+ {detail!.limitations!.map(limitation => { + switch (limitation) { + case 'gitee_file_list_limit': return t('reviewPlatform.fileLimit'); + case 'gitee_commit_list_limit': return t('reviewPlatform.commitLimit'); + case 'provider_comment_list_incomplete': return t('reviewPlatform.commentsLimited'); + case 'provider_ci_list_incomplete': return t('reviewPlatform.checksLimited'); + case 'provider_ci_head_unavailable': return t('reviewPlatform.ciHeadUnavailable'); + default: return t('reviewPlatform.limited'); + } + }).join(' ')} +
+ )}
; +type PullRequestLineStats = Pick; function normalizeProviderHost(value: string): string { return value.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, ''); @@ -124,16 +126,10 @@ export function mergeRevalidatedPullRequestOverview( if (!current || !samePullRequestRevisions(current, overview)) { return overview; } - const preserveKnownLineStats = overview.changedFileCountKnown === false - && current.changedFileCountKnown !== false - ? { - additions: current.additions, - deletions: current.deletions, - } - : {}; return { ...overview, - ...preserveKnownLineStats, + limitations: mergePullRequestDetailLimitations(current.limitations, overview.limitations, 'overview'), + ...mergeOverviewLineStats(current, overview), ...mergeChangedFileCount(current, overview), ci: current.ci, files: current.files, @@ -142,6 +138,26 @@ export function mergeRevalidatedPullRequestOverview( }; } +export function mergePullRequestDetailLimitations( + current: string[] | undefined, + incoming: string[] | undefined, + section: ReviewPlatformDetailSection, +): string[] { + // A page refresh replaces only the coverage facts owned by that section. + // Preserve warnings for other loaded sections until they are refreshed too. + const refreshed = (limitation: string): boolean => { + switch (limitation) { + case 'gitee_file_list_limit': return section === 'files' || section === 'overview'; + case 'gitee_commit_list_limit': return section === 'commits'; + case 'provider_comment_list_incomplete': return section === 'reviews'; + case 'provider_ci_list_incomplete': + case 'provider_ci_head_unavailable': return section === 'ci' || section === 'overview'; + default: return false; + } + }; + return [...new Set([...(current ?? []).filter(limitation => !refreshed(limitation)), ...(incoming ?? [])])]; +} + export function mergeChangedFileCount( current: PullRequestChangedFileCount, incoming: PullRequestChangedFileCount, @@ -178,6 +194,46 @@ export function resolvedChangedFileCount( return null; } +export function mergeLineStats( + current: PullRequestLineStats, + incoming: PullRequestLineStats, +): PullRequestLineStats { + if (incoming.lineStatsKnown !== undefined) { + const source = incoming.lineStatsKnown || current.lineStatsKnown === false ? incoming : current; + return { additions: source.additions, deletions: source.deletions, lineStatsKnown: source.lineStatsKnown }; + } + // Older providers use zero as the missing-section placeholder. + return { + additions: incoming.additions || current.additions, + deletions: incoming.deletions || current.deletions, + lineStatsKnown: current.lineStatsKnown, + }; +} + +export function resolvedLineStats(value?: PullRequestLineStats | null): PullRequestLineStats | null { + return value && value.lineStatsKnown !== false ? value : null; +} + +function mergeOverviewLineStats( + current: PullRequestChangedFileCount & PullRequestLineStats, + overview: PullRequestChangedFileCount & PullRequestLineStats, +): PullRequestLineStats { + if (overview.lineStatsKnown !== undefined) return mergeLineStats(current, overview); + // Legacy overviews replace totals, including zero. Only section payloads use + // zero as a placeholder; keep that fallback inside mergeLineStats. + const source = overview.changedFileCountKnown === false && current.changedFileCountKnown !== false + ? current : overview; + return { additions: source.additions, deletions: source.deletions, lineStatsKnown: source.lineStatsKnown }; +} + +export function resolvedPullRequestStatistics( + pullRequest: ReviewPlatformPullRequest, + detail?: ReviewPlatformPullRequestDetail | null, +): PullRequestChangedFileCount & PullRequestLineStats { + if (!detail || detail.id !== pullRequest.id || !samePullRequestRevisions(pullRequest, detail)) return pullRequest; + return { ...mergeChangedFileCount(pullRequest, detail), ...mergeOverviewLineStats(pullRequest, detail) }; +} + export function samePullRequestRevisions( left: Pick, right: Pick, diff --git a/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.test.ts index 83f8203540..3f966236bd 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.test.ts @@ -21,6 +21,19 @@ describe('ReviewPlatformAPI identity evidence wire', () => { invokeMock.mockReset(); }); + it('keeps Gitee PR numbers and alphanumeric Issue identities in remote requests', async () => { + invokeMock.mockResolvedValue({}); + const identity = { platform: 'gitee' as const, host: 'gitee.com', projectPath: 'example/repo' }; + await reviewPlatformAPI.getPullRequestReviewTargetByIdentity({ ...identity, pullRequestId: '69' }); + await reviewPlatformAPI.getIssue({ ...identity, issueId: 'IABC12', page: 1, perPage: 100 }); + expect(invokeMock).toHaveBeenNthCalledWith(1, 'review_platform_get_pull_request_review_target_by_identity', { + request: { ...identity, pullRequestId: '69' }, + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, 'review_platform_get_issue', { + request: { ...identity, issueId: 'IABC12', page: 1, perPage: 100 }, + }); + }); + it('sends Issue identity and bounded pagination in a structured request', async () => { const evidence = { issueId: '42', comments: [] }; invokeMock.mockResolvedValueOnce(evidence); @@ -75,4 +88,26 @@ describe('ReviewPlatformAPI identity evidence wire', () => { }, }); }); + + it('sends a repository state filter together with its page', async () => { + const snapshot = { capabilities: { supportedPullRequestStates: ['all', 'merged'] } }; + invokeMock.mockResolvedValue(snapshot); + await expect(reviewPlatformAPI.getWorkspaceSnapshot('/repo', 'origin', 2, 10, 'merged')).resolves.toBe(snapshot); + expect(invokeMock).toHaveBeenCalledWith('review_platform_get_workspace_snapshot', { + request: { repositoryPath: '/repo', remoteId: 'origin', page: 2, perPage: 10, state: 'merged' }, + }); + }); + + it('preserves the legacy All request and accepts older hosts', async () => { + const snapshot = { capabilities: {} }; + invokeMock.mockResolvedValue(snapshot); + await expect(reviewPlatformAPI.getWorkspaceSnapshot('/repo', 'origin', 1, 10, 'all')).resolves.toBe(snapshot); + expect(invokeMock.mock.calls[0][1].request).not.toHaveProperty('state'); + }); + + it('rejects an older host that silently ignores a requested state', async () => { + invokeMock.mockResolvedValue({ capabilities: {}, pullRequests: [{ state: 'open' }] }); + await expect(reviewPlatformAPI.getWorkspaceSnapshot('/repo', 'origin', 1, 10, 'merged')) + .rejects.toThrow('review_platform_state_filter_unsupported'); + }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts index 5e54cd27d2..dfd7578f34 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts @@ -4,11 +4,12 @@ import { createLogger } from '@/shared/utils/logger'; const log = createLogger('ReviewPlatformAPI'); -export type ReviewPlatformKind = 'github' | 'gitlab' | 'gitcode' | 'unknown'; +export type ReviewPlatformKind = 'github' | 'gitlab' | 'gitcode' | 'gitee' | 'unknown'; export type ReviewAuthState = 'not_connected' | 'not_required' | 'connected' | 'expired' | 'error' | 'unsupported'; export type ReviewAuthSource = 'gh_cli' | 'env' | 'stored' | 'none' | 'unsupported'; export type ReviewAuthChallengeState = 'missing' | 'invalid' | 'insufficient_scope'; export type ReviewItemState = 'open' | 'merged' | 'closed' | 'draft'; +export type ReviewPlatformListState = 'all' | ReviewItemState; export type ReviewDecision = 'approved' | 'changes_requested' | 'commented' | 'pending'; export type ReviewFileStatus = 'added' | 'modified' | 'deleted' | 'renamed'; export type ReviewPlatformDetailSection = 'overview' | 'ci' | 'files' | 'commits' | 'reviews'; @@ -100,6 +101,7 @@ export interface ReviewPlatformPullRequest { webUrl: string; additions: number; deletions: number; + lineStatsKnown?: boolean; changedFiles: number; /** Missing on older backends; only an explicit false means the count is unknown. */ changedFileCountKnown?: boolean; @@ -186,6 +188,7 @@ export interface ReviewPlatformThread { } export interface ReviewPlatformPullRequestDetail extends ReviewPlatformPullRequest { + limitations?: string[]; body: string; ci: ReviewPlatformCiItem[]; files: ReviewPlatformFile[]; @@ -215,6 +218,7 @@ export interface ReviewPlatformCapabilities { canRequestChanges: boolean; canMerge: boolean; supportsDraftReview: boolean; + supportedPullRequestStates?: ReviewPlatformListState[]; } export interface ReviewPlatformPagination { @@ -241,6 +245,7 @@ export interface ReviewPlatformWorkspaceSnapshotRequest { remoteId?: string | null; page?: number; perPage?: number; + state?: ReviewPlatformListState; } export interface ReviewPlatformWorkspaceContextRequest { @@ -300,11 +305,18 @@ export class ReviewPlatformAPI { remoteId?: string | null, page?: number, perPage?: number, + state?: ReviewPlatformListState, ): Promise { try { - return await api.invoke('review_platform_get_workspace_snapshot', { - request: { repositoryPath, remoteId, page, perPage }, + const snapshot = await api.invoke('review_platform_get_workspace_snapshot', { + request: { repositoryPath, remoteId, page, perPage, ...(state && state !== 'all' ? { state } : {}) }, }); + // Older hosts may ignore a new optional request field. Require their + // advertised capability before accepting the returned page as filtered. + if (state && state !== 'all' && !snapshot.capabilities.supportedPullRequestStates?.includes(state)) { + throw new Error('review_platform_state_filter_unsupported'); + } + return snapshot; } catch (error) { log.error('Failed to load review platform snapshot', { repositoryPath, remoteId, page, perPage, error }); throw createTauriCommandError('review_platform_get_workspace_snapshot', error, { diff --git a/src/web-ui/src/locales/en-US/panels/git.json b/src/web-ui/src/locales/en-US/panels/git.json index a6a087eaef..8f38bb688e 100644 --- a/src/web-ui/src/locales/en-US/panels/git.json +++ b/src/web-ui/src/locales/en-US/panels/git.json @@ -1,4 +1,13 @@ { + "reviewPlatform": { + "stateFilterUnsupported": "This host does not support repository-wide Gitee state filters. Update the connected host to use these tabs.", + "fileLimit": "Gitee may have truncated the file list. File and change counts may be incomplete.", + "commitLimit": "Gitee returns at most 250 commits. The commit history may be incomplete.", + "commentsLimited": "Only part of the comments could be loaded.", + "checksLimited": "Only part of the CI checks could be loaded.", + "ciHeadUnavailable": "CI checks are unavailable because the pull request has no exact head revision.", + "limited": "Some provider data is incomplete." + }, "title": "Git", "init": { "title": "Initialize Git", diff --git a/src/web-ui/src/locales/zh-CN/panels/git.json b/src/web-ui/src/locales/zh-CN/panels/git.json index 320c991f7c..26adc36965 100644 --- a/src/web-ui/src/locales/zh-CN/panels/git.json +++ b/src/web-ui/src/locales/zh-CN/panels/git.json @@ -1,4 +1,13 @@ { + "reviewPlatform": { + "stateFilterUnsupported": "当前主机不支持 Gitee 全仓库状态筛选。请更新所连接的主机后使用这些页签。", + "fileLimit": "Gitee 返回的文件列表可能已被截断,文件数和变更统计可能不完整。", + "commitLimit": "Gitee 最多返回 250 条提交记录,提交历史可能不完整。", + "commentsLimited": "仅加载了部分评论。", + "checksLimited": "仅加载了部分 CI 检查项。", + "ciHeadUnavailable": "此 PR 未提供准确的源提交版本,无法加载 CI 检查项。", + "limited": "部分平台数据不完整。" + }, "title": "Git", "init": { "title": "初始化 Git", diff --git a/src/web-ui/src/locales/zh-TW/panels/git.json b/src/web-ui/src/locales/zh-TW/panels/git.json index 82f64aa0c7..19e0292dd3 100644 --- a/src/web-ui/src/locales/zh-TW/panels/git.json +++ b/src/web-ui/src/locales/zh-TW/panels/git.json @@ -1,4 +1,13 @@ { + "reviewPlatform": { + "stateFilterUnsupported": "目前主機不支援 Gitee 全儲存庫狀態篩選。請更新所連線的主機後使用這些頁籤。", + "fileLimit": "Gitee 回傳的檔案清單可能已被截斷,檔案數與變更統計可能不完整。", + "commitLimit": "Gitee 最多回傳 250 條提交紀錄,提交歷史可能不完整。", + "commentsLimited": "僅載入了部分評論。", + "checksLimited": "僅載入了部分 CI 檢查項目。", + "ciHeadUnavailable": "此 PR 未提供準確的來源提交版本,無法載入 CI 檢查項目。", + "limited": "部分平台資料不完整。" + }, "title": "Git", "init": { "title": "初始化 Git", diff --git a/src/web-ui/src/shared/services/review-team/targetEvidence.test.ts b/src/web-ui/src/shared/services/review-team/targetEvidence.test.ts index 322e66b122..57e2f9ae51 100644 --- a/src/web-ui/src/shared/services/review-team/targetEvidence.test.ts +++ b/src/web-ui/src/shared/services/review-team/targetEvidence.test.ts @@ -20,6 +20,28 @@ const CLEAN_STATUS = { }; describe('Review target evidence', () => { + it('preserves Gitee identity and capped provider coverage through the review target', () => { + const target = classifyReviewTargetFromFiles(['src/new.rs'], 'pull_request'); + const evidence = buildPullRequestReviewTargetEvidence({ + target, + baseRevision: '1'.repeat(40), + headRevision: '2'.repeat(40), + pullRequest: { + remoteId: 'origin:gitee:example__repo', platform: 'gitee', host: 'gitee.com', + projectPath: 'example/repo', pullRequestId: '69', number: 69, + webUrl: 'https://gitee.com/example/repo/pulls/69', + }, + files: [{ path: 'src/new.rs', oldPath: 'src/old.rs', status: 'renamed', diffAvailable: true }], + omittedFileCount: 1, + limitations: ['provider_file_list_incomplete', 'gitee_file_list_limit'], + }); + expect(evidence.completeness).toBe('partial'); + expect(evidence.pullRequest?.platform).toBe('gitee'); + expect(evidence.files[0]).toMatchObject({ path: 'src/new.rs', previousPath: 'src/old.rs', status: 'renamed' }); + expect(evidence.limitations).toContain('gitee_file_list_limit'); + expect(allowsReviewLiveRepositoryContext(evidence)).toBe(false); + }); + it('keeps large diffs explicitly partial instead of implying fully consumed evidence', () => { const target = classifyReviewTargetFromFiles(['src/lib.rs'], 'slash_command_git_ref'); const evidence = buildGitRangeReviewTargetEvidence({ diff --git a/src/web-ui/src/shared/services/review-team/types.ts b/src/web-ui/src/shared/services/review-team/types.ts index 63b3738848..1cc1a7a004 100644 --- a/src/web-ui/src/shared/services/review-team/types.ts +++ b/src/web-ui/src/shared/services/review-team/types.ts @@ -101,7 +101,7 @@ export interface ReviewTargetEvidenceFile { export interface ReviewTargetPullRequestIdentity { remoteId: string; - platform: 'github' | 'gitlab' | 'gitcode'; + platform: 'github' | 'gitlab' | 'gitcode' | 'gitee'; host: string; projectPath: string; pullRequestId: string; diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index 4019286834..2ad3edfb54 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -52,3 +52,13 @@ For the real desktop Markdown workflow, build the desktop and current frontend, then run `pnpm --dir tests/e2e exec wdio run ./config/wdio.markdown-native.ts` from the repository root. This focused runner uses packaged frontend assets and a fresh temporary application profile; it does not use another checkout's dev server. + +For Gitee list filters and pagination against the public `dromara/sa-token` +repository, build the desktop and current frontend, then run +`pnpm --dir tests/e2e exec wdio run ./config/wdio.gitee-native.ts`. +This read-only live test uses a temporary application profile and Git remote, +checks the actual UI against independent Gitee API responses, and retains +screenshots plus `result.json` under the printed temporary evidence directory. +Set `GITEE_TOKEN` in the runner environment to authenticate both the desktop +and independent API reads when anonymous quota is exhausted. Do not put tokens +in the test source, command arguments, or retained evidence. diff --git a/tests/e2e/config/embedded-driver.ts b/tests/e2e/config/embedded-driver.ts index 27a125b027..4aac4ceded 100644 --- a/tests/e2e/config/embedded-driver.ts +++ b/tests/e2e/config/embedded-driver.ts @@ -113,6 +113,7 @@ export function getApplicationPath(): string { } async function waitForDevServerIfNeeded(appPath: string): Promise { + if (process.env.OPENBITFUN_E2E_PACKAGED_FRONTEND === '1') return; if (!appPath.includes(`${path.sep}debug${path.sep}`)) { return; } @@ -432,6 +433,7 @@ async function startOpenBitFunApp(): Promise { openbitfunApp = spawn(appPath, [], { cwd: projectRoot(), + windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, diff --git a/tests/e2e/config/wdio.gitee-native.ts b/tests/e2e/config/wdio.gitee-native.ts new file mode 100644 index 0000000000..aebd6246e2 --- /dev/null +++ b/tests/e2e/config/wdio.gitee-native.ts @@ -0,0 +1,25 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createEmbeddedConfig } from './embedded-driver'; + +// The embedded driver is local even when public provider requests use a proxy. +process.env.NO_PROXY = [process.env.NO_PROXY, '127.0.0.1', 'localhost', '::1'].filter(Boolean).join(','); + +// Keep the isolated profile and evidence for inspection after a live failure. +const root = process.env.OPENBITFUN_GITEE_E2E_ROOT + ?? mkdtempSync(join(tmpdir(), 'openbitfun-gitee-e2e-')); +Object.assign(process.env, { + OPENBITFUN_GITEE_E2E_ROOT: root, + OPENBITFUN_E2E_STORAGE_ROOT: root, + OPENBITFUN_E2E_USER_ROOT: join(root, 'user'), + OPENBITFUN_USER_ROOT: join(root, 'user'), + OPENBITFUN_E2E_HOME: join(root, 'home'), + OPENBITFUN_HOME: join(root, 'home'), + OPENBITFUN_E2E_LOG_DIR: join(root, 'logs'), + OPENBITFUN_E2E_STORAGE_GUARD: '1', + OPENBITFUN_E2E_PACKAGED_FRONTEND: '1', + OPENBITFUN_E2E_FRONTEND_DIR: resolve(fileURLToPath(new URL('../../../dist', import.meta.url))), +}); +export const config = createEmbeddedConfig(['../specs/gitee-native.spec.ts'], 'Gitee native'); diff --git a/tests/e2e/specs/gitee-native.spec.ts b/tests/e2e/specs/gitee-native.spec.ts new file mode 100644 index 0000000000..ee3bcdd764 --- /dev/null +++ b/tests/e2e/specs/gitee-native.spec.ts @@ -0,0 +1,239 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { $, $$, browser, expect } from '@wdio/globals'; +import { openWorkspace } from '../helpers/workspace-helper'; +import { saveScreenshot } from '../helpers/screenshot-utils'; + +type State = 'all' | 'open' | 'draft' | 'merged' | 'closed'; +interface Pull { number: number; state: string; draft?: boolean } +interface Page { items: Pull[]; total: number } +interface Statistics { files: number; additions: number; deletions: number } +type TraceWindow = Window & { + __OPENBITFUN_STARTUP_TRACE__: { snapshot: () => { + api: { byCommand: Array<{ command: string; count: number; failureCount: number }> }; + } }; +}; +const root = process.env.OPENBITFUN_GITEE_E2E_ROOT!; +const workspace = join(root, 'sa-token'); +const evidence: object[] = []; +const expected = new Map(); +let expectedStatistics: Statistics; + +// Independent read-only API expectations, using the same optional token as the app. +async function publicResponse(path: string): Promise { + for (let attempt = 0; ; attempt++) { + await new Promise(resolve => setTimeout(resolve, 1500)); + const url = new URL(`https://gitee.com/api/v5/repos/dromara/sa-token${path}`); + if (process.env.GITEE_TOKEN) url.searchParams.set('access_token', process.env.GITEE_TOKEN); + const response = await fetch(url).catch(() => { + // Do not expose a URL containing credentials through a transport error. + throw new Error(`Gitee ${path}: request failed`); + }); + if (!response.ok) { + const message = await response.text(); + if (attempt < 3 && (response.status === 429 || (response.status === 403 && message.includes('Rate Limit Exceeded')))) { + console.log(`Gitee rate limit on ${path}; waiting before retry`); + await new Promise(resolve => setTimeout(resolve, 20000)); + continue; + } + throw new Error(`Gitee ${path}: HTTP ${response.status}`); + } + return response; + } +} + +async function publicPage(state: string, page: number, perPage = 10): Promise { + const response = await publicResponse(`/pulls?state=${state}&sort=updated&direction=desc&page=${page}&per_page=${perPage}`); + const total = response.headers.get('total_count'); + if (total === null) throw new Error('Gitee did not supply a total_count header'); + return { items: await response.json() as Pull[], total: Number(total) }; +} + +async function publicStatistics(number: number): Promise { + const response = await publicResponse(`/pulls/${number}/files`); + const files = await response.json() as Array<{ additions: string | number; deletions: string | number }>; + if (!Array.isArray(files) || files.length >= 200) throw new Error('Live statistics require a complete file collection'); + const sum = (key: 'additions' | 'deletions') => files.reduce((total, file) => { + if (file[key] === undefined || file[key] === null) throw new Error(`Missing live ${key} count`); + const count = Number(file[key]); + if (!Number.isSafeInteger(count) || count < 0) throw new Error(`Invalid live ${key} count`); + return total + count; + }, 0); + return { files: files.length, additions: sum('additions'), deletions: sum('deletions') }; +} + +class NativeGiteePage { + async open() { + expect(await openWorkspace(workspace)).toBe(true); + const workspaceItem = $('[data-testid="nav-workspace-item"][data-workspace-active="true"]'); + await workspaceItem.$('[data-testid="nav-workspace-name-btn"]').click(); + const menuButton = workspaceItem.$('[data-testid="nav-workspace-menu-btn"]'); + await menuButton.waitForDisplayed({ timeout: 10000 }); + await menuButton.click(); + const menu = $('[data-testid="nav-workspace-item-menu"]'); + await menu.waitForDisplayed({ timeout: 10000 }); + await expect(menu).toHaveAttribute('data-workspace-id', await workspaceItem.getAttribute('data-workspace-id')); + await menu.$('[data-testid="nav-workspace-menu-create-session"]').click(); + await $('[data-testid="chat-input-container"]').waitForDisplayed({ timeout: 15000 }); + await $('[data-testid="flowchat-header-session-overview"]').click(); + await $('[data-testid="flowchat-header-pull-requests"] button').click(); + await this.ready(); + } + filter(state: State) { return $(`[data-testid="review-platform-filter-${state}"]`); } + get rows() { return $$('[data-testid="review-platform-pr-row"]'); } + get next() { return $('[data-testid="review-platform-next-page"]'); } + get previous() { return $('[data-testid="review-platform-previous-page"]'); } + get pagination() { return $('[data-testid="review-platform-pagination"]'); } + async snapshotRequests() { + return browser.execute(() => (window as TraceWindow).__OPENBITFUN_STARTUP_TRACE__.snapshot().api.byCommand + .find(item => item.command === 'review_platform_get_workspace_snapshot')); + } + async ready() { + await this.filter('all').waitForDisplayed({ timeout: 45000 }); + await $('[data-testid="review-platform-list-loading"]').waitForExist({ reverse: true, timeout: 45000 }); + const error = $('[data-openbitfun-component="review-platform"][data-openbitfun-part="errorState"]'); + expect(await error.isExisting()).toBe(false); + } + async select(state: State) { + await this.filter(state).click(); + await expect(this.filter(state)).toHaveAttribute('aria-pressed', 'true'); + await this.ready(); + } + async assertPage(state: State, number = 1) { + await this.ready(); + const reference = expected.get(`${state}:${number}`)!; + const actual = await this.rows.map(async row => ({ + number: Number(await row.getAttribute('data-pr-number')), + state: await row.getAttribute('data-pr-state'), + })); + expect(actual.map(item => item.number)).toEqual(reference.items.map(item => item.number)); + expect(actual.map(item => item.state)).toEqual(reference.items.map(item => + item.state === 'open' && item.draft ? 'draft' : item.state)); + if (reference.total > 10) { + const start = (number - 1) * 10 + 1; + await expect(this.pagination).toHaveText(`${start}-${start + actual.length - 1} of ${reference.total}`); + if (number === 1) await expect(this.previous).toBeDisabled(); + } + evidence.push({ checkedAt: new Date().toISOString(), state, page: number, total: reference.total, actual }); + } + async capture(name: string) { + await saveScreenshot(name, { directory: join(root, 'screenshots'), includeTimestamp: false }); + } + async assertInitialStatistics() { + const second = (await this.rows)[1]; + expect(Number(await second.getAttribute('data-pr-number'))).toBe(expected.get('merged:1')!.items[1].number); + await expect(second).not.toHaveAttribute('data-openbitfun-state', 'selected'); + const values = [`${expectedStatistics.files} files`, `+${expectedStatistics.additions}`, `-${expectedStatistics.deletions}`]; + const actual: string[] = []; + for (const [index, field] of ['files', 'additions', 'deletions'].entries()) { + const value = second.$(`[data-testid="review-platform-pr-${field}"]`); + await expect(value).toHaveText(values[index]); + actual.push(await value.getText()); + } + evidence.push({ checkedAt: new Date().toISOString(), stage: 'initial-list-before-selection', + number: expected.get('merged:1')!.items[1].number, expectedStatistics, actual }); + } +} +const page = new NativeGiteePage(); + +describe('Gitee repository filters in the native desktop', () => { + before(async () => { + await mkdir(workspace, { recursive: true }); + const git = (...args: string[]) => execFileSync('git', args, { cwd: workspace, windowsHide: true, stdio: 'pipe' }); + git('init', '--initial-branch=main'); + git('remote', 'add', 'origin', 'https://gitee.com/dromara/sa-token.git'); + for (const [state, number] of [['all', 1], ['all', 2], ['merged', 1], ['merged', 2], ['closed', 1]] as const) { + expected.set(`${state}:${number}`, await publicPage(state, number)); + } + const open: Pull[] = []; + for (let number = 1; ; number++) { + if (number > 10) throw new Error('Live fixture exceeded bounded open collection'); + const incoming = await publicPage('open', number, 100); + open.push(...incoming.items); + if (open.length >= incoming.total) break; + } + for (const state of ['open', 'draft'] as const) { + const items = open.filter(item => Boolean(item.draft) === (state === 'draft')); + expected.set(`${state}:1`, { items: items.slice(0, 10), total: items.length }); + } + expect(expected.get('merged:1')!.total).toBeGreaterThan(0); + expectedStatistics = await publicStatistics(expected.get('merged:1')!.items[1].number); + await page.open(); + }); + + after(async () => { + const transportSummary = await browser.execute(() => (window as TraceWindow).__OPENBITFUN_STARTUP_TRACE__.snapshot().api.byCommand + .filter(item => item.command.startsWith('review_platform_'))); + await writeFile(join(root, 'result.json'), JSON.stringify({ repository: 'dromara/sa-token', evidence, transportSummary }, null, 2)); + console.log(`Gitee E2E evidence: ${root}`); + }); + + // Keep independent scenarios within Gitee's anonymous public API quota. + afterEach(async () => { + if (!process.env.GITEE_TOKEN) await new Promise(resolve => setTimeout(resolve, 30000)); + }); + + it('loads repository-wide merged PRs after visiting All page two, then paginates and refreshes', async () => { + await page.select('all'); + await page.assertPage('all'); + await page.next.click(); + await page.assertPage('all', 2); + await page.capture('all-page-2'); + await page.select('merged'); + await page.assertPage('merged'); + await page.assertInitialStatistics(); + await page.capture('merged-page-1'); + await page.next.click(); + await page.assertPage('merged', 2); + await $('[data-testid="review-platform-refresh"]').click(); + await page.assertPage('merged', 2); + await page.capture('merged-page-2-refreshed'); + }); + + it('loads Closed, Open and Draft with the corresponding repository totals', async () => { + for (const state of ['closed', 'open', 'draft'] as const) { + await page.select(state); + await page.assertPage(state); + await page.capture(state); + } + }); + + it('keeps the selected PR detail and verified statistics consistent after rapid switching', async () => { + for (const state of ['closed', 'merged', 'all'] as const) await page.select(state); + const beforeRefresh = (await page.snapshotRequests())!; + await $('[data-testid="review-platform-refresh"]').click(); + // Issue the clicks without waiting for network responses between them. + for (const state of ['merged', 'closed', 'all', 'merged'] as const) await page.filter(state).click(); + await page.assertPage('merged'); + const second = (await page.rows)[1]; + await page.assertInitialStatistics(); + await page.capture('merged-statistics-before-selection'); + await second.click(); + await expect(second).toHaveAttribute('data-openbitfun-state', 'selected'); + await $('[data-openbitfun-component="review-platform"][data-openbitfun-part="loadingState"]') + .waitForExist({ reverse: true, timeout: 45000 }); + expect(await $('.review-platform__detail-error').isExisting()).toBe(false); + await expect($('[data-testid="review-platform-detail-state"]')).toHaveText('Merged'); + await expect($('[data-openbitfun-part="detailMeta"]')).toHaveText( + expect.stringContaining(`#${expected.get('merged:1')!.items[1].number}`)); + // Let the earlier real All refresh finish, then check that it did not replace Merged. + await browser.waitUntil(async () => (await page.snapshotRequests())!.count > beforeRefresh.count, { timeout: 45000 }); + expect((await page.snapshotRequests())!.failureCount).toBe(beforeRefresh.failureCount); + await page.assertPage('merged'); + const actualStatistics: Record = {}; + for (const prefix of ['pr', 'detail']) { + const owner = prefix === 'pr' ? second : $('[data-openbitfun-component="review-platform"]'); + const values = [`${expectedStatistics.files} files`, `+${expectedStatistics.additions}`, `-${expectedStatistics.deletions}`]; + actualStatistics[prefix] = []; + for (const [index, field] of ['files', 'additions', 'deletions'].entries()) { + const value = owner.$(`[data-testid="review-platform-${prefix}-${field}"]`); + await expect(value).toHaveText(values[index]); + actualStatistics[prefix].push(await value.getText()); + } + } + evidence.push({ checkedAt: new Date().toISOString(), number: expected.get('merged:1')!.items[1].number, + expectedStatistics, actualStatistics }); + await page.capture('merged-selected-detail'); + }); +});