From 7a28300aab48564bd155a5091f465d5052c1d40a Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Wed, 13 May 2026 10:33:33 +0800 Subject: [PATCH] feat(review-platform): add review platform panel with core service and Tauri API feat(review-platform): link pull requests with review chat sessions feat(review-platform): support inner git platform fix(review-platform): fix: improve PR review panel reliability and launch context feat(review-platform): open PR links in dedicated detail panel feat: add review platform agent tool feat(review-platform): refine pull request detail context actions fix(review-platform): propagate remoteId through PR context and auto-resolve write actions feat(review-platform): unify PR review/comment timeline perf(review-platform): load PR detail sections on demand feat(review-platform): change User-Agent to ReviewPlatform feat(review-platform): add lazy CI log details for pull requests feat(review-platform): add auth challenge flow for token setup feat(review-platform): replace inner CI data source with MR-level APIs feat(review-platform): integrate PR panel APIs into ReviewPlatform tool --- Cargo.toml | 2 +- src/apps/cli/src/ui/theme.rs | 2 +- src/apps/desktop/src/api/mod.rs | 1 + .../desktop/src/api/review_platform_api.rs | 201 + src/apps/desktop/src/lib.rs | 7 + src/crates/ai-adapters/src/client/http.rs | 1 + .../src/agentic/tools/implementations/mod.rs | 2 + .../implementations/review_platform_tool.rs | 1256 +++++ src/crates/core/src/agentic/tools/registry.rs | 3 + .../src/agentic/tools/static_providers.rs | 1 + src/crates/core/src/service/mod.rs | 10 + .../core/src/service/review_platform/mod.rs | 4863 +++++++++++++++++ src/crates/tool-packs/src/lib.rs | 2 + .../components/panels/base/FlexiblePanel.tsx | 24 + .../src/app/components/panels/base/types.ts | 2 + .../src/app/components/panels/base/utils.ts | 17 + .../review-platform/ReviewPlatformPanel.scss | 1679 ++++++ .../review-platform/ReviewPlatformPanel.tsx | 2306 ++++++++ .../components/Markdown/Markdown.tsx | 6 + .../flow_chat/components/FlowTextBlock.tsx | 3 +- .../flow_chat/components/RichTextInput.tsx | 10 + .../src/flow_chat/components/UserMessage.tsx | 7 +- .../components/modern/FlowChatContext.tsx | 2 + .../components/modern/FlowChatHeader.scss | 1 + .../components/modern/FlowChatHeader.tsx | 20 +- .../modern/ModernFlowChatContainer.tsx | 18 + .../deep-review/launch/DeepReviewService.ts | 42 + .../deep-review/launch/launchPrompt.test.ts | 15 + .../deep-review/launch/launchPrompt.ts | 31 + .../src/flow_chat/hooks/useMessageSender.ts | 42 +- src/web-ui/src/infrastructure/api/index.ts | 5 +- .../api/service-api/ReviewPlatformAPI.ts | 341 ++ src/web-ui/src/shared/services/DragManager.ts | 2 + .../shared/services/reviewTargetClassifier.ts | 1 + src/web-ui/src/shared/stores/contextStore.ts | 2 +- src/web-ui/src/shared/types/context.ts | 17 + .../src/shared/utils/contextPrompt.test.ts | 49 + src/web-ui/src/shared/utils/contextPrompt.ts | 52 + .../src/shared/utils/pullRequestLinks.ts | 93 + src/web-ui/src/shared/utils/tabUtils.ts | 76 + 40 files changed, 11165 insertions(+), 49 deletions(-) create mode 100644 src/apps/desktop/src/api/review_platform_api.rs create mode 100644 src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs create mode 100644 src/crates/core/src/service/review_platform/mod.rs create mode 100644 src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss create mode 100644 src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx create mode 100644 src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts create mode 100644 src/web-ui/src/shared/utils/contextPrompt.test.ts create mode 100644 src/web-ui/src/shared/utils/contextPrompt.ts create mode 100644 src/web-ui/src/shared/utils/pullRequestLinks.ts diff --git a/Cargo.toml b/Cargo.toml index 4cef76448..01cbc04ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ indexmap = "2" include_dir = "0.7" # HTTP client -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "json", "stream", "multipart"] } +reqwest = { version = "0.12", default-features = false, features = ["native-tls", "rustls-tls-native-roots", "json", "stream", "multipart"] } # Debug Log HTTP Server axum = { version = "0.7", features = ["json", "ws"] } diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index 044e2c613..2973f2535 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,10 +1,10 @@ +use once_cell::sync::Lazy; /// Theme and style definitions use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::path::Path; use std::time::{Duration, Instant}; -use once_cell::sync::Lazy; use ratatui::style::{Color, Modifier, Style}; #[cfg(unix)] diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 707305041..982122f42 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -28,6 +28,7 @@ pub mod miniapp_api; pub mod path_target; pub mod project_context_api; pub mod remote_connect_api; +pub mod review_platform_api; pub mod runtime_api; pub mod search_api; pub mod session_api; diff --git a/src/apps/desktop/src/api/review_platform_api.rs b/src/apps/desktop/src/api/review_platform_api.rs new file mode 100644 index 000000000..4ac268432 --- /dev/null +++ b/src/apps/desktop/src/api/review_platform_api.rs @@ -0,0 +1,201 @@ +//! Review platform Tauri commands. + +use crate::api::app_state::AppState; +use bitfun_core::service::review_platform::{ + ReviewPlatformCiLog, ReviewPlatformDetailSection, ReviewPlatformKind, + ReviewPlatformPullRequestDetail, ReviewPlatformPullRequestDetailPage, ReviewPlatformService, + ReviewPlatformWorkspaceSnapshot, +}; +use log::error; +use serde::Deserialize; +use tauri::State; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformWorkspaceSnapshotRequest { + pub repository_path: String, + pub remote_id: Option, + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailPageRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub section: ReviewPlatformDetailSection, + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestCiLogRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub ci_item_id: String, + pub ci_item_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformUpdateAuthTokenRequest { + pub platform: ReviewPlatformKind, + pub host: String, + pub token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformClearAuthTokenRequest { + pub platform: ReviewPlatformKind, + pub host: String, +} + +#[tauri::command] +pub async fn review_platform_get_workspace_snapshot( + _state: State<'_, AppState>, + request: ReviewPlatformWorkspaceSnapshotRequest, +) -> Result { + ReviewPlatformService::workspace_snapshot( + &request.repository_path, + request.remote_id.as_deref(), + request.page, + request.per_page, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform workspace snapshot: path={}, remote_id={:?}, error={}", + request.repository_path, request.remote_id, error + ); + format!( + "Failed to get review platform workspace snapshot: {}", + error + ) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_detail( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestDetailRequest, +) -> Result { + ReviewPlatformService::pull_request_detail( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform pull request detail: path={}, remote_id={}, pull_request_id={}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + error + ); + format!("Failed to get review platform pull request detail: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_detail_page( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestDetailPageRequest, +) -> Result { + ReviewPlatformService::pull_request_detail_page( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + request.section, + request.page, + request.per_page, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform pull request detail page: path={}, remote_id={}, pull_request_id={}, section={:?}, page={:?}, per_page={:?}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + request.section, + request.page, + request.per_page, + error + ); + format!( + "Failed to get review platform pull request detail page: {}", + error + ) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_ci_log( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestCiLogRequest, +) -> Result { + ReviewPlatformService::pull_request_ci_log( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + &request.ci_item_id, + &request.ci_item_name, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform CI log: path={}, remote_id={}, pull_request_id={}, ci_item_id={}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + request.ci_item_id, + error + ); + format!("Failed to get review platform CI log: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_update_auth_token( + _state: State<'_, AppState>, + request: ReviewPlatformUpdateAuthTokenRequest, +) -> Result<(), String> { + ReviewPlatformService::update_auth_token(request.platform, &request.host, &request.token) + .await + .map_err(|error| { + error!( + "Failed to update review platform auth token: platform={:?}, host={}, error={}", + request.platform, request.host, error + ); + format!("Failed to update review platform auth token: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_clear_auth_token( + _state: State<'_, AppState>, + request: ReviewPlatformClearAuthTokenRequest, +) -> Result<(), String> { + ReviewPlatformService::clear_auth_token(request.platform, &request.host) + .await + .map_err(|error| { + error!( + "Failed to clear review platform auth token: platform={:?}, host={}, error={}", + request.platform, request.host, error + ); + format!("Failed to clear review platform auth token: {}", error) + }) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 23ec40cb4..f9632bf9f 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -41,6 +41,7 @@ use api::i18n_api::*; use api::lsp_api::*; use api::lsp_workspace_api::*; use api::mcp_api::*; +use api::review_platform_api::*; use api::runtime_api::*; use api::search_api::*; use api::session_api::*; @@ -713,6 +714,12 @@ pub async fn run() { git_is_repository, git_get_repository_basic, git_get_repository, + review_platform_get_workspace_snapshot, + review_platform_get_pull_request_detail, + review_platform_get_pull_request_detail_page, + review_platform_get_pull_request_ci_log, + review_platform_update_auth_token, + review_platform_clear_auth_token, git_get_status, git_get_branches, git_get_enhanced_branches, diff --git a/src/crates/ai-adapters/src/client/http.rs b/src/crates/ai-adapters/src/client/http.rs index 545e3f066..826ac82b5 100644 --- a/src/crates/ai-adapters/src/client/http.rs +++ b/src/crates/ai-adapters/src/client/http.rs @@ -9,6 +9,7 @@ pub(crate) fn create_http_client( skip_ssl_verify: bool, ) -> Client { let mut builder = Client::builder() + .use_rustls_tls() .connect_timeout(std::time::Duration::from_secs( AIClient::STREAM_CONNECT_TIMEOUT_SECS, )) diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 498dd7f61..a0fa25e80 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -30,6 +30,7 @@ pub mod ls_tool; pub mod mcp_tools; pub mod miniapp_init_tool; pub mod playbook_tool; +pub mod review_platform_tool; pub mod session_control_tool; pub mod session_history_tool; pub mod session_message_tool; @@ -68,6 +69,7 @@ pub use mcp_tools::{ }; pub use miniapp_init_tool::InitMiniAppTool; pub use playbook_tool::PlaybookTool; +pub use review_platform_tool::ReviewPlatformTool; pub use session_control_tool::SessionControlTool; pub use session_history_tool::SessionHistoryTool; pub use session_message_tool::SessionMessageTool; diff --git a/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs new file mode 100644 index 000000000..60211d107 --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -0,0 +1,1256 @@ +//! Pull request / review platform tool. +//! +//! This tool exposes hosted review-platform operations to the agent while +//! keeping provider-specific HTTP behavior inside `ReviewPlatformService`. + +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::service::review_platform::{ + ReviewPlatformApprovalRequest, ReviewPlatformCreatePullRequestRequest, + ReviewPlatformDetailSection, ReviewPlatformError, ReviewPlatformKind, ReviewPlatformRemote, + ReviewPlatformReplyToThreadRequest, ReviewPlatformRequestChangesRequest, + ReviewPlatformResolveThreadRequest, ReviewPlatformService, ReviewPlatformSubmitReviewRequest, + ReviewSubmitEvent, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +const ACTION_WORKSPACE_SNAPSHOT: &str = "get_workspace_snapshot"; +const ACTION_LIST_REMOTES: &str = "list_remotes"; +const ACTION_LIST: &str = "list_pull_requests"; +const ACTION_COUNT: &str = "count_pull_requests"; +const ACTION_GET: &str = "get_pull_request"; +const ACTION_GET_DETAIL_PAGE: &str = "get_pull_request_detail_page"; +const ACTION_GET_CI_LOG: &str = "get_pull_request_ci_log"; +const ACTION_CREATE: &str = "create_pull_request"; +const ACTION_REPLY: &str = "reply_to_thread"; +const ACTION_SUBMIT_REVIEW: &str = "submit_review"; +const ACTION_APPROVE: &str = "approve_pull_request"; +const ACTION_REVOKE_APPROVAL: &str = "revoke_approval"; +const ACTION_REQUEST_CHANGES: &str = "request_changes"; +const ACTION_RESOLVE: &str = "resolve_thread"; +const ACTION_UPDATE_AUTH_TOKEN: &str = "update_auth_token"; +const ACTION_CLEAR_AUTH_TOKEN: &str = "clear_auth_token"; + +const WRITE_ACTIONS: &[&str] = &[ + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN, +]; + +pub struct ReviewPlatformTool; + +impl ReviewPlatformTool { + pub fn new() -> Self { + Self + } + + fn repository_path(input: &Value, context: &ToolUseContext) -> BitFunResult { + let requested = input + .get("repository_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + + if let Some(path) = requested { + return context.resolve_workspace_tool_path(path); + } + + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path_string()) + .ok_or_else(|| BitFunError::tool("repository_path is required".to_string())) + } + + fn string_field(input: &Value, key: &str) -> BitFunResult { + input + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| BitFunError::tool(format!("{} is required", key))) + } + + fn optional_string_field(input: &Value, key: &str) -> Option { + input + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + } + + fn submit_event(input: &Value) -> BitFunResult { + match input + .get("event") + .and_then(Value::as_str) + .unwrap_or("comment") + { + "comment" => Ok(ReviewSubmitEvent::Comment), + "approve" => Ok(ReviewSubmitEvent::Approve), + "request_changes" => Ok(ReviewSubmitEvent::RequestChanges), + other => Err(BitFunError::tool(format!( + "Unsupported review event: {}", + other + ))), + } + } + + fn detail_section(input: &Value) -> BitFunResult { + match input + .get("section") + .and_then(Value::as_str) + .unwrap_or("overview") + { + "overview" => Ok(ReviewPlatformDetailSection::Overview), + "ci" => Ok(ReviewPlatformDetailSection::Ci), + "files" => Ok(ReviewPlatformDetailSection::Files), + "commits" => Ok(ReviewPlatformDetailSection::Commits), + "reviews" => Ok(ReviewPlatformDetailSection::Reviews), + other => Err(BitFunError::tool(format!( + "Unsupported pull request detail section: {}", + other + ))), + } + } + + fn platform_kind(input: &Value) -> BitFunResult { + match Self::string_field(input, "platform")?.as_str() { + "github" => Ok(ReviewPlatformKind::Github), + "gitlab" => Ok(ReviewPlatformKind::Gitlab), + "gitcode" => Ok(ReviewPlatformKind::Gitcode), + "unknown" => Ok(ReviewPlatformKind::Unknown), + other => Err(BitFunError::tool(format!( + "Unsupported review platform kind: {}", + other + ))), + } + } + + async fn resolve_remote_id(repository_path: &str, input: &Value) -> BitFunResult { + if let Some(remote_id) = Self::optional_string_field(input, "remote_id") { + return Ok(remote_id); + } + + let remotes = ReviewPlatformService::discover_remotes(repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let supported = supported_remotes(&remotes); + match supported.as_slice() { + [] => Err(BitFunError::tool( + "No supported review platform remote found".to_string(), + )), + [remote] => Ok(remote.id.clone()), + _ => Err(BitFunError::tool(remote_ambiguity_message(&supported))), + } + } + + async fn resolve_remote_id_for_list( + repository_path: &str, + input: &Value, + ) -> BitFunResult> { + if let Some(remote_id) = Self::optional_string_field(input, "remote_id") { + return Ok(Ok(remote_id)); + } + + let remotes = ReviewPlatformService::discover_remotes(repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let supported = supported_remotes(&remotes); + match supported.as_slice() { + [] => Err(BitFunError::tool( + "No supported review platform remote found".to_string(), + )), + [remote] => Ok(Ok(remote.id.clone())), + _ => Ok(Err(json!({ + "action": ACTION_LIST, + "repositoryPath": repository_path, + "status": "needs_remote_selection", + "message": "Multiple supported review platform remotes were found. Provide remote_id explicitly.", + "candidateRemotes": supported, + }))), + } + } + + fn action(input: &Value) -> Option<&str> { + input.get("action").and_then(Value::as_str) + } + + fn auth_required_result( + action: &str, + repository_path: &str, + remote_id: &str, + error: &ReviewPlatformError, + ) -> Option { + let status = match error { + ReviewPlatformError::Http { status, .. } if *status == 401 || *status == 403 => *status, + _ => return None, + }; + let state = if status == 403 { + "insufficient_scope" + } else { + "invalid" + }; + Some(json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": { + "state": state, + "message": if status == 403 { + "Review platform token is missing required permissions. Update the token in the pull request panel, then retry." + } else { + "Review platform authentication is required or the configured token was rejected. Add or update the token in the pull request panel, then retry." + }, + }, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + })) + } + + fn render_action_result(output: &Value) -> Option { + let result = output.get("result")?; + let message = result + .get("message") + .and_then(Value::as_str) + .unwrap_or("Review platform action completed"); + let web_url = result.get("webUrl").and_then(Value::as_str); + let pr = result.get("pullRequest"); + + let mut lines = vec![message.to_string()]; + if let Some(pr) = pr { + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Pull request"); + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let url = pr.get("webUrl").and_then(Value::as_str).or(web_url); + if let Some(url) = url { + lines.push(format!("[#{} {}]({})", number, title, url)); + } + } else if let Some(url) = web_url { + lines.push(url.to_string()); + } + Some(lines.join("\n")) + } +} + +#[async_trait] +impl Tool for ReviewPlatformTool { + fn name(&self) -> &str { + "ReviewPlatform" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Read and operate on hosted pull requests / merge requests. + +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 the Git tool for local repository state and branch/commit/push operations. + +Authentication-token actions are available only 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()) + } + + fn short_description(&self) -> String { + "Inspect and operate on hosted pull requests / merge requests.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + ACTION_WORKSPACE_SNAPSHOT, + ACTION_LIST_REMOTES, + ACTION_LIST, + ACTION_COUNT, + ACTION_GET, + ACTION_GET_DETAIL_PAGE, + ACTION_GET_CI_LOG, + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN + ], + "description": "Review platform action to perform." + }, + "repository_path": { + "type": "string", + "description": "Repository path. Omit to use the current workspace." + }, + "remote_id": { + "type": "string", + "description": "Review platform remote id. Omit to use the only supported remote; provide it explicitly when the repository has multiple supported review-platform remotes." + }, + "pull_request_id": { + "type": "string", + "description": "Pull request or merge request number/id." + }, + "page": { + "type": "integer", + "description": "Page number for list_pull_requests, get_workspace_snapshot, or get_pull_request_detail_page." + }, + "per_page": { + "type": "integer", + "description": "Page size for list_pull_requests, get_workspace_snapshot, or get_pull_request_detail_page." + }, + "section": { + "type": "string", + "enum": ["overview", "ci", "files", "commits", "reviews"], + "description": "Detail section for get_pull_request_detail_page." + }, + "ci_item_id": { + "type": "string", + "description": "CI item id for get_pull_request_ci_log." + }, + "ci_item_name": { + "type": "string", + "description": "CI item display name for get_pull_request_ci_log; used by providers that need a job name fallback." + }, + "platform": { + "type": "string", + "enum": ["github", "gitlab", "gitcode", "unknown"], + "description": "Review platform kind for update_auth_token or clear_auth_token." + }, + "host": { + "type": "string", + "description": "Review platform host for update_auth_token or clear_auth_token." + }, + "token": { + "type": "string", + "description": "Personal access token for update_auth_token. Only provide this when the user explicitly asks to store that token." + }, + "title": { + "type": "string", + "description": "Pull request title for create_pull_request." + }, + "source_branch": { + "type": "string", + "description": "Source/head branch for create_pull_request." + }, + "target_branch": { + "type": "string", + "description": "Target/base branch for create_pull_request." + }, + "body": { + "type": "string", + "description": "Pull request body, review body, or comment body depending on action." + }, + "draft": { + "type": "boolean", + "description": "Create a draft pull request when the provider supports it." + }, + "thread_id": { + "type": "string", + "description": "Thread id returned by get_pull_request for reply_to_thread or resolve_thread." + }, + "event": { + "type": "string", + "enum": ["comment", "approve", "request_changes"], + "description": "Review event for submit_review." + }, + "resolved": { + "type": "boolean", + "description": "Whether resolve_thread should mark the thread resolved or reopened." + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, input: Option<&Value>) -> bool { + input + .and_then(Self::action) + .is_some_and(|action| !WRITE_ACTIONS.contains(&action)) + } + + fn needs_permissions(&self, input: Option<&Value>) -> bool { + input + .and_then(Self::action) + .map(|action| WRITE_ACTIONS.contains(&action)) + .unwrap_or(true) + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let Some(action) = Self::action(input) else { + return ValidationResult { + result: false, + message: Some("action is required".to_string()), + error_code: Some(400), + meta: None, + }; + }; + let valid = [ + ACTION_WORKSPACE_SNAPSHOT, + ACTION_LIST_REMOTES, + ACTION_LIST, + ACTION_COUNT, + ACTION_GET, + ACTION_GET_DETAIL_PAGE, + ACTION_GET_CI_LOG, + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN, + ]; + if !valid.contains(&action) { + return ValidationResult { + result: false, + message: Some(format!("Unsupported ReviewPlatform action: {}", action)), + error_code: Some(400), + meta: None, + }; + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = Self::action(input).unwrap_or("unknown"); + match action { + ACTION_WORKSPACE_SNAPSHOT => "Load review platform workspace snapshot".to_string(), + ACTION_LIST_REMOTES => "List review platform remotes".to_string(), + ACTION_LIST => "List pull requests".to_string(), + ACTION_COUNT => "Count pull requests".to_string(), + ACTION_GET => format!( + "Open pull request {}", + input + .get("pull_request_id") + .and_then(Value::as_str) + .unwrap_or("detail") + ), + ACTION_GET_DETAIL_PAGE => "Load pull request detail page".to_string(), + ACTION_GET_CI_LOG => "Load pull request CI log".to_string(), + ACTION_CREATE => "Create pull request".to_string(), + ACTION_REPLY => "Reply to pull request thread".to_string(), + ACTION_SUBMIT_REVIEW => "Submit pull request review".to_string(), + ACTION_APPROVE => "Approve pull request".to_string(), + ACTION_REVOKE_APPROVAL => "Revoke pull request approval".to_string(), + ACTION_REQUEST_CHANGES => "Request pull request changes".to_string(), + ACTION_RESOLVE => "Resolve pull request thread".to_string(), + ACTION_UPDATE_AUTH_TOKEN => "Update review platform auth token".to_string(), + ACTION_CLEAR_AUTH_TOKEN => "Clear review platform auth token".to_string(), + _ => format!("Review platform action: {}", action), + } + } + + fn render_result_for_assistant(&self, output: &Value) -> String { + let action = output.get("action").and_then(Value::as_str).unwrap_or(""); + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_auth") + { + let message = output + .pointer("/authChallenge/message") + .and_then(Value::as_str) + .unwrap_or("Review platform authentication is required."); + return format!("{} Ask the user to configure the token in the pull request panel, then retry this action.", message); + } + if let Some(action_result) = Self::render_action_result(output) { + return action_result; + } + + match action { + ACTION_LIST_REMOTES => { + let remotes = output + .get("remotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![format!("Found {} review platform remotes.", remotes.len())]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + lines.join("\n") + } + ACTION_WORKSPACE_SNAPSHOT => { + let snapshot = output.get("snapshot"); + let Some(snapshot) = snapshot else { + return "Review platform workspace snapshot loaded.".to_string(); + }; + let remotes = snapshot + .get("remotes") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let prs = snapshot + .get("pullRequests") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let selected = snapshot + .get("selectedRemoteId") + .and_then(Value::as_str) + .unwrap_or("none"); + let message = snapshot.get("message").and_then(Value::as_str); + match message { + Some(message) if !message.is_empty() => format!( + "Loaded review platform snapshot: selected remote {}, {} remotes, {} pull requests. {}", + selected, remotes, prs, message + ), + _ => format!( + "Loaded review platform snapshot: selected remote {}, {} remotes, {} pull requests.", + selected, remotes, prs + ), + } + } + ACTION_COUNT => { + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_remote_selection") + { + let remotes = output + .get("candidateRemotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![ + "Multiple review platform remotes were found. Ask the user which remote to use, then retry with remote_id.".to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + return lines.join("\n"); + } + + let remote_id = output.get("remoteId").and_then(Value::as_str).unwrap_or(""); + let total = output.get("total").and_then(Value::as_u64); + match total { + Some(total) => format!("Remote {} has {} pull requests.", remote_id, total), + None => format!( + "Remote {} did not return an exact pull request count.", + remote_id + ), + } + } + ACTION_LIST => { + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_remote_selection") + { + let remotes = output + .get("candidateRemotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![ + "Multiple review platform remotes were found. Ask the user which remote to use, then retry with remote_id.".to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + return lines.join("\n"); + } + + let prs = output + .pointer("/snapshot/pullRequests") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let pagination = output + .get("snapshot") + .and_then(|snapshot| snapshot.get("pagination")); + let page = pagination + .and_then(|value| value.get("page")) + .and_then(Value::as_u64) + .unwrap_or(1); + let per_page = pagination + .and_then(|value| value.get("perPage")) + .and_then(Value::as_u64) + .unwrap_or(prs.len() as u64); + let total = pagination + .and_then(|value| value.get("total")) + .and_then(Value::as_u64); + let has_next = pagination + .and_then(|value| value.get("hasNext")) + .and_then(Value::as_bool) + .unwrap_or(false); + let remote_id = output.get("remoteId").and_then(Value::as_str).unwrap_or(""); + + let mut lines = vec![match total { + Some(total) => format!( + "Remote {} has {} pull requests. Showing {} from page {} (page size {}).", + remote_id, + total, + prs.len(), + page, + per_page + ), + None => format!( + "Remote {} returned {} pull requests on page {} (page size {}).{}", + remote_id, + prs.len(), + page, + per_page, + if has_next { + " More pages are available; this is not the total count." + } else { + "" + } + ), + }]; + if prs.is_empty() { + return lines.join("\n"); + } + lines.extend(prs.iter().take(10).map(|pr| { + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled"); + let state = pr.get("state").and_then(Value::as_str).unwrap_or("unknown"); + let url = pr.get("webUrl").and_then(Value::as_str).unwrap_or(""); + if url.is_empty() { + format!("#{} {} ({})", number, title, state) + } else { + format!("[#{} {}]({}) ({})", number, title, url, state) + } + })); + lines.join("\n") + } + ACTION_GET => { + let pr = output.get("pullRequest"); + let Some(pr) = pr else { + return "Pull request detail loaded.".to_string(); + }; + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled"); + let url = pr.get("webUrl").and_then(Value::as_str).unwrap_or(""); + let files = output + .get("files") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let threads = output + .get("threads") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + if url.is_empty() { + format!( + "Loaded PR #{} {} ({} files, {} threads)", + number, title, files, threads + ) + } else { + format!( + "Loaded [#{} {}]({}) ({} files, {} threads)", + number, title, url, files, threads + ) + } + } + ACTION_GET_DETAIL_PAGE => { + let section = output + .get("section") + .and_then(Value::as_str) + .unwrap_or("detail"); + let items = output + .get("items") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let pagination = output.get("pagination"); + let page = pagination + .and_then(|value| value.get("page")) + .and_then(Value::as_u64) + .unwrap_or(1); + let has_next = pagination + .and_then(|value| value.get("hasNext")) + .and_then(Value::as_bool) + .unwrap_or(false); + format!( + "Loaded pull request {} page {} with {} items.{}", + section, + page, + items, + if has_next { + " More pages are available." + } else { + "" + } + ) + } + ACTION_GET_CI_LOG => { + let ci_item_id = output.get("ciItemId").and_then(Value::as_str).unwrap_or(""); + let truncated = output + .get("truncated") + .and_then(Value::as_bool) + .unwrap_or(false); + let log_chars = output + .get("log") + .and_then(Value::as_str) + .map(str::len) + .unwrap_or(0); + format!( + "Loaded CI log for {} ({} characters).{}", + ci_item_id, + log_chars, + if truncated { + " The log was truncated." + } else { + "" + } + ) + } + ACTION_UPDATE_AUTH_TOKEN => "Review platform auth token updated.".to_string(), + ACTION_CLEAR_AUTH_TOKEN => "Review platform auth token cleared.".to_string(), + _ => "Review platform action completed.".to_string(), + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let action = Self::string_field(input, "action")?; + let repository_path = match action.as_str() { + ACTION_UPDATE_AUTH_TOKEN | ACTION_CLEAR_AUTH_TOKEN => { + Self::optional_string_field(input, "repository_path") + .map(|path| context.resolve_workspace_tool_path(&path)) + .transpose()? + .or_else(|| { + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path_string()) + }) + .unwrap_or_default() + } + _ => Self::repository_path(input, context)?, + }; + + let data = match action.as_str() { + ACTION_LIST_REMOTES => { + let remotes = ReviewPlatformService::discover_remotes(&repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "remotes": remotes, + }) + } + ACTION_WORKSPACE_SNAPSHOT => { + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .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( + &repository_path, + remote_id.as_deref(), + page, + per_page, + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let status = if snapshot.auth_challenge.is_some() { + "needs_auth" + } else { + "ok" + }; + let auth_challenge = snapshot.auth_challenge.clone(); + let selected_remote_id = snapshot.selected_remote_id.clone(); + let panel_remote_id = selected_remote_id.clone(); + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": selected_remote_id, + "status": status, + "authChallenge": auth_challenge, + "snapshot": snapshot, + "openPanel": if status == "needs_auth" { + json!({ + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": panel_remote_id, + }) + } else { + Value::Null + }, + }) + } + ACTION_COUNT => { + let remote_id = + match Self::resolve_remote_id_for_list(&repository_path, input).await? { + Ok(remote_id) => remote_id, + Err(mut selection_result) => { + if let Some(obj) = selection_result.as_object_mut() { + obj.insert("action".to_string(), json!(ACTION_COUNT)); + } + let result_for_assistant = + self.render_result_for_assistant(&selection_result); + return Ok(vec![ToolResult::Result { + data: selection_result, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + }; + let snapshot = ReviewPlatformService::workspace_snapshot( + &repository_path, + Some(remote_id.as_str()), + Some(1), + Some(1), + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + if snapshot.auth_challenge.is_some() { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": snapshot.auth_challenge, + "snapshot": snapshot, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + }) + } else { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "total": snapshot.pagination.total, + "hasNext": snapshot.pagination.has_next, + }) + } + } + ACTION_LIST => { + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let remote_id = + match Self::resolve_remote_id_for_list(&repository_path, input).await? { + Ok(remote_id) => remote_id, + Err(selection_result) => { + let result_for_assistant = + self.render_result_for_assistant(&selection_result); + return Ok(vec![ToolResult::Result { + data: selection_result, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + }; + let snapshot = ReviewPlatformService::workspace_snapshot( + &repository_path, + Some(remote_id.as_str()), + page, + per_page, + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + if snapshot.auth_challenge.is_some() { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": snapshot.auth_challenge, + "snapshot": snapshot, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + }) + } else { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "snapshot": snapshot, + }) + } + } + ACTION_GET => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + match ReviewPlatformService::pull_request_detail( + &repository_path, + &remote_id, + &pull_request_id, + ) + .await + { + Ok(detail) => json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequest": detail.pull_request, + "body": detail.body, + "ci": detail.ci, + "files": detail.files, + "commits": detail.commits, + "threads": detail.threads, + }), + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_GET_DETAIL_PAGE => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let section = Self::detail_section(input)?; + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .and_then(Value::as_u64) + .map(|value| value as u32); + match ReviewPlatformService::pull_request_detail_page( + &repository_path, + &remote_id, + &pull_request_id, + section, + page, + per_page, + ) + .await + { + Ok(detail) => { + let items = match section { + ReviewPlatformDetailSection::Overview => json!([]), + ReviewPlatformDetailSection::Ci => json!(detail.ci), + ReviewPlatformDetailSection::Files => json!(detail.files), + ReviewPlatformDetailSection::Commits => json!(detail.commits), + ReviewPlatformDetailSection::Reviews => json!(detail.threads), + }; + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequest": detail.pull_request, + "body": detail.body, + "section": detail.section, + "pagination": detail.pagination, + "items": items, + "detailPage": detail, + }) + } + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_GET_CI_LOG => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let ci_item_id = Self::string_field(input, "ci_item_id")?; + let ci_item_name = Self::string_field(input, "ci_item_name")?; + match ReviewPlatformService::pull_request_ci_log( + &repository_path, + &remote_id, + &pull_request_id, + &ci_item_id, + &ci_item_name, + ) + .await + { + Ok(ci_log) => json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequestId": pull_request_id, + "ciItemId": ci_log.ci_item_id, + "log": ci_log.log, + "truncated": ci_log.truncated, + "message": ci_log.message, + }), + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_CREATE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformCreatePullRequestRequest { + repository_path: repository_path.clone(), + remote_id: Some(remote_id), + title: Self::string_field(input, "title")?, + source_branch: Self::string_field(input, "source_branch")?, + target_branch: Self::string_field(input, "target_branch")?, + body: Self::optional_string_field(input, "body"), + draft: input.get("draft").and_then(Value::as_bool), + }; + let result = ReviewPlatformService::create_pull_request(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REPLY => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformReplyToThreadRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + thread_id: Self::string_field(input, "thread_id")?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::reply_to_thread(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_SUBMIT_REVIEW => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformSubmitReviewRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + event: Self::submit_event(input)?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::submit_review(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_APPROVE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformApprovalRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: Self::optional_string_field(input, "body"), + }; + let result = ReviewPlatformService::approve_pull_request(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REVOKE_APPROVAL => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformApprovalRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: None, + }; + let result = ReviewPlatformService::revoke_approval(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REQUEST_CHANGES => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformRequestChangesRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::request_changes(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_RESOLVE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformResolveThreadRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + thread_id: Self::string_field(input, "thread_id")?, + resolved: input + .get("resolved") + .and_then(Value::as_bool) + .unwrap_or(true), + }; + let result = ReviewPlatformService::resolve_thread(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_UPDATE_AUTH_TOKEN => { + let platform = Self::platform_kind(input)?; + let host = Self::string_field(input, "host")?; + let token = Self::string_field(input, "token")?; + ReviewPlatformService::update_auth_token(platform, &host, &token) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "platform": platform, + "host": host, + "status": "ok", + }) + } + ACTION_CLEAR_AUTH_TOKEN => { + let platform = Self::platform_kind(input)?; + let host = Self::string_field(input, "host")?; + ReviewPlatformService::clear_auth_token(platform, &host) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "platform": platform, + "host": host, + "status": "ok", + }) + } + _ => return Err(BitFunError::tool(format!("Unsupported action: {}", action))), + }; + + let result_for_assistant = self.render_result_for_assistant(&data); + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +impl Default for ReviewPlatformTool { + fn default() -> Self { + Self::new() + } +} + +fn supported_remotes(remotes: &[ReviewPlatformRemote]) -> Vec<&ReviewPlatformRemote> { + remotes.iter().filter(|remote| remote.supported).collect() +} + +fn remote_ambiguity_message(remotes: &[&ReviewPlatformRemote]) -> String { + let mut lines = vec![ + "Multiple supported review platform remotes were found. Provide remote_id explicitly." + .to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + format!( + "- remote_id: {} | name: {} | platform: {:?} | project: {} | url: {}", + remote.id, remote.name, remote.platform, remote.project_path, remote.web_url + ) + })); + lines.join("\n") +} diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 7aa456eab..85d9a22dd 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -369,6 +369,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", @@ -526,6 +527,7 @@ mod tests { assert!(registry.is_tool_collapsed("GetFileDiff")); assert!(!registry.is_tool_collapsed("GetToolSpec")); assert!(registry.is_tool_collapsed("Git")); + assert!(registry.is_tool_collapsed("ReviewPlatform")); } #[test] @@ -550,6 +552,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", diff --git a/src/crates/core/src/agentic/tools/static_providers.rs b/src/crates/core/src/agentic/tools/static_providers.rs index ae22d2a65..180534cd2 100644 --- a/src/crates/core/src/agentic/tools/static_providers.rs +++ b/src/crates/core/src/agentic/tools/static_providers.rs @@ -54,6 +54,7 @@ fn materialize_tool(tool_name: &str) -> Arc { "GetMCPPrompt" => Arc::new(GetMCPPromptTool::new()), "GenerativeUI" => Arc::new(GenerativeUITool::new()), "Git" => Arc::new(GitTool::new()), + "ReviewPlatform" => Arc::new(ReviewPlatformTool::new()), "InitMiniApp" => Arc::new(InitMiniAppTool::new()), "ControlHub" => Arc::new(ControlHubTool::new()), "ComputerUse" => Arc::new(ComputerUseTool::new()), diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 98657b44f..6eae26fe3 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -17,6 +17,7 @@ pub mod mcp; // MCP (Model Context Protocol) system pub mod project_context; // Project context management pub mod remote_connect; // Remote Connect (phone → desktop) pub mod remote_ssh; // Remote SSH (desktop → server) +pub mod review_platform; // Pull request review platform adapters pub mod runtime; // Managed runtime and capability management pub mod search; // Workspace search via managed flashgrep daemon pub mod session; // Session persistence @@ -53,6 +54,15 @@ pub use i18n::{get_global_i18n_service, I18nConfig, I18nService, LocaleId, Local pub use lsp::LspManager; pub use mcp::MCPService; pub use project_context::{ContextDocumentStatus, ProjectContextConfig, ProjectContextService}; +pub use review_platform::{ + ReviewAuthSource, ReviewAuthState, ReviewChecks, ReviewDecision, ReviewFileStatus, + ReviewItemState, ReviewPlatformAccount, ReviewPlatformAuthChallenge, + ReviewPlatformAuthChallengeState, ReviewPlatformCapabilities, ReviewPlatformCiLog, + ReviewPlatformCommit, ReviewPlatformError, ReviewPlatformFile, ReviewPlatformKind, + ReviewPlatformPullRequest, ReviewPlatformPullRequestDetail, ReviewPlatformRemote, + ReviewPlatformRepositoryRef, ReviewPlatformService, ReviewPlatformThread, + ReviewPlatformWorkspaceSnapshot, +}; pub use runtime::{ResolvedCommand, RuntimeCommandCapability, RuntimeManager, RuntimeSource}; pub use search::{ get_global_workspace_search_service, set_global_workspace_search_service, ContentSearchRequest, diff --git a/src/crates/core/src/service/review_platform/mod.rs b/src/crates/core/src/service/review_platform/mod.rs new file mode 100644 index 000000000..19907b712 --- /dev/null +++ b/src/crates/core/src/service/review_platform/mod.rs @@ -0,0 +1,4863 @@ +//! Platform-neutral pull request review data service. +//! +//! This module owns provider detection, token handling, and provider-specific +//! HTTP calls. UI and desktop adapters consume only the common DTOs below. + +use crate::infrastructure::try_get_path_manager_arc; +use crate::service::git::{execute_git_command, get_repository_root}; +use futures::{stream, StreamExt}; +use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::Duration; +use tokio::fs; + +const USER_AGENT_VALUE: &str = "ReviewPlatform"; +const DEFAULT_PR_PAGE: u32 = 1; +const DEFAULT_PR_PAGE_SIZE: u32 = 10; +const MAX_PR_PAGE_SIZE: u32 = 50; +const PROVIDER_ENRICH_CONCURRENCY: usize = 4; +const MAX_CI_LOG_CHARS: usize = 80_000; + +#[derive(Debug, thiserror::Error)] +pub enum ReviewPlatformError { + #[error("Invalid repository path: {0}")] + InvalidRepository(String), + #[error("Remote not found: {0}")] + RemoteNotFound(String), + #[error("Unsupported review platform: {0}")] + UnsupportedPlatform(String), + #[error("Provider API failed: {0}")] + Api(String), + #[error("Provider API failed: HTTP {status}{message}")] + Http { status: u16, message: String }, + #[error("Network error: {0}")] + Network(String), + #[error("Parse error: {0}")] + Parse(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformKind { + Github, + Gitlab, + Gitcode, + Unknown, +} + +impl ReviewPlatformKind { + fn as_str(self) -> &'static str { + match self { + Self::Github => "github", + Self::Gitlab => "gitlab", + Self::Gitcode => "gitcode", + Self::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewAuthState { + NotConnected, + NotRequired, + Connected, + Expired, + Error, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewAuthSource { + Env, + Stored, + None, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewItemState { + Open, + Merged, + Closed, + Draft, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewDecision { + Approved, + ChangesRequested, + Commented, + Pending, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewFileStatus { + Added, + Modified, + Deleted, + Renamed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformAccount { + pub id: String, + pub platform: ReviewPlatformKind, + pub label: String, + pub username: Option, + pub host: String, + pub auth_state: ReviewAuthState, + pub auth_source: ReviewAuthSource, + pub scopes: Vec, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRepositoryRef { + pub provider_id: String, + pub platform: ReviewPlatformKind, + pub host: String, + pub owner: String, + pub name: String, + pub project_path: String, + pub default_branch: String, + pub workspace_path: Option, + pub web_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRemote { + pub id: String, + pub name: String, + pub url: String, + pub platform: ReviewPlatformKind, + pub host: String, + pub owner: String, + pub repository_name: String, + pub project_path: String, + pub web_url: String, + pub supported: bool, + pub auth_state: ReviewAuthState, + pub auth_source: ReviewAuthSource, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewChecks { + pub total: i32, + pub passed: i32, + pub failed: i32, + pub pending: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCiItem { + pub id: String, + pub name: String, + pub status: String, + pub conclusion: Option, + pub detail: Option, + pub stage: Option, + pub web_url: Option, + pub log: Option, + pub log_truncated: bool, + pub started_at: Option, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequest { + pub id: String, + pub number: i64, + pub title: String, + pub state: ReviewItemState, + pub author: String, + pub source_branch: String, + pub target_branch: String, + pub updated_at: String, + pub web_url: String, + pub additions: i32, + pub deletions: i32, + pub changed_files: i32, + pub comments: i32, + pub review_decision: ReviewDecision, + pub checks: ReviewChecks, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformFile { + pub path: String, + pub old_path: Option, + pub status: ReviewFileStatus, + pub additions: i32, + pub deletions: i32, + pub patch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCommit { + pub hash: String, + pub short_hash: String, + pub title: String, + pub author: String, + pub committed_at: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformThreadKind { + Review, + Comment, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformThread { + pub id: String, + pub provider_thread_id: Option, + pub provider_comment_id: Option, + pub kind: ReviewPlatformThreadKind, + pub reply_to_provider_comment_id: Option, + pub file_path: Option, + pub line: Option, + pub resolved: bool, + pub author: String, + pub body: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetail { + #[serde(flatten)] + pub pull_request: ReviewPlatformPullRequest, + pub body: String, + pub ci: Vec, + pub files: Vec, + pub commits: Vec, + pub threads: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformDetailSection { + Overview, + Ci, + Files, + Commits, + Reviews, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailPage { + #[serde(flatten)] + pub pull_request: ReviewPlatformPullRequest, + pub body: String, + pub ci: Vec, + pub files: Vec, + pub commits: Vec, + pub threads: Vec, + pub section: ReviewPlatformDetailSection, + pub pagination: ReviewPlatformPagination, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCiLog { + pub ci_item_id: String, + pub log: Option, + pub truncated: bool, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCapabilities { + pub can_create_review: bool, + pub can_create_pull_request: bool, + pub can_reply_to_thread: bool, + pub can_resolve_thread: bool, + pub can_approve: bool, + pub can_revoke_approval: bool, + pub can_request_changes: bool, + pub can_merge: bool, + pub supports_draft_review: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewSubmitEvent { + Comment, + Approve, + RequestChanges, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCreatePullRequestRequest { + pub repository_path: String, + pub remote_id: Option, + pub title: String, + pub source_branch: String, + pub target_branch: String, + pub body: Option, + pub draft: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformReplyToThreadRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub thread_id: String, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformSubmitReviewRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub event: ReviewSubmitEvent, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformResolveThreadRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub thread_id: String, + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformApprovalRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRequestChangesRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformActionResult { + pub success: bool, + pub message: String, + pub web_url: Option, + pub pull_request: Option, + pub thread: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformAuthChallengeState { + Missing, + Invalid, + InsufficientScope, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformAuthChallenge { + pub platform: ReviewPlatformKind, + pub host: String, + pub remote_id: String, + pub project_path: String, + pub state: ReviewPlatformAuthChallengeState, + pub message: String, + pub required_scopes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformWorkspaceSnapshot { + pub remotes: Vec, + pub selected_remote_id: Option, + pub accounts: Vec, + pub repository: Option, + pub pull_requests: Vec, + pub pagination: ReviewPlatformPagination, + pub capabilities: ReviewPlatformCapabilities, + pub message: Option, + pub auth_challenge: Option, +} + +pub struct ReviewPlatformService; + +#[derive(Debug, Clone, Copy)] +struct PullRequestPagination { + page: u32, + per_page: u32, +} + +impl PullRequestPagination { + fn new(page: Option, per_page: Option) -> Self { + Self { + page: page.unwrap_or(DEFAULT_PR_PAGE).max(1), + per_page: per_page + .unwrap_or(DEFAULT_PR_PAGE_SIZE) + .clamp(1, MAX_PR_PAGE_SIZE), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPagination { + pub page: u32, + pub per_page: u32, + pub total: Option, + pub has_next: bool, +} + +#[derive(Debug, Clone)] +struct ReviewPlatformPullRequestPage { + items: Vec, + pagination: ReviewPlatformPagination, +} + +#[derive(Debug, Clone)] +struct ProviderContext { + remote: ReviewPlatformRemote, + api_base_url: String, + token: Option, +} + +#[derive(Debug, Clone, Default)] +struct ReviewPlatformAuthTokens { + tokens: HashMap, +} + +impl ReviewPlatformAuthTokens { + fn get(&self, platform: ReviewPlatformKind, host: &str) -> Option<&str> { + token_key(platform, host).and_then(|key| self.tokens.get(&key).map(String::as_str)) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredReviewPlatformTokens { + #[serde(default)] + tokens: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredReviewPlatformToken { + token: String, + updated_at: String, +} + +impl ReviewPlatformService { + pub async fn discover_remotes( + repository_path: &str, + ) -> Result, ReviewPlatformError> { + let auth_tokens = load_stored_tokens().await?; + Self::discover_remotes_with_tokens(repository_path, &auth_tokens).await + } + + async fn discover_remotes_with_tokens( + repository_path: &str, + auth_tokens: &ReviewPlatformAuthTokens, + ) -> Result, ReviewPlatformError> { + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let output = execute_git_command(&root, &["remote", "-v"]) + .await + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + + let mut seen = HashSet::new(); + let mut remotes = Vec::new(); + + for line in output.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + if parts.get(2).is_some_and(|kind| *kind != "(fetch)") { + continue; + } + let remote_name = parts[0]; + let remote_url = parts[1]; + let key = format!("{}|{}", remote_name, remote_url); + if !seen.insert(key) { + continue; + } + if let Some(remote) = parse_remote(remote_name, remote_url, auth_tokens) { + remotes.push(remote); + } + } + + Ok(remotes) + } + + pub async fn workspace_snapshot( + repository_path: &str, + remote_id: Option<&str>, + page: Option, + per_page: Option, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Ok(empty_snapshot( + Vec::new(), + None, + None, + "Pull request browsing is not available for remote SSH workspaces yet.", + )); + } + + let pagination_request = PullRequestPagination::new(page, per_page); + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let selected_remote = select_remote(&remotes, remote_id).cloned(); + + let Some(remote) = selected_remote else { + return Ok(empty_snapshot( + remotes, + None, + None, + "No Git remotes were found", + )); + }; + + if !remote.supported { + return Ok(empty_snapshot( + remotes, + Some(remote.id.clone()), + Some(account_for_remote(&remote)), + remote + .message + .as_deref() + .unwrap_or("Unsupported remote provider"), + )); + } + + if remote.platform == ReviewPlatformKind::Gitcode + && token_for_remote(&remote, &auth_tokens).is_none() + { + return Ok(empty_snapshot( + remotes, + Some(remote.id.clone()), + Some(account_for_remote(&remote)), + "GitCode pull request APIs require a Personal Access Token. Add a token for this remote and refresh.", + )); + } + + let ctx = provider_context(remote.clone(), &auth_tokens)?; + let provider = provider_for(ctx.remote.platform); + let repository = Some(repository_ref(&ctx.remote, Some(root))); + let account = account_for_remote(&ctx.remote); + let capabilities = capabilities_for_remote(&remote); + match provider.list_pull_requests(&ctx, pagination_request).await { + Ok(page) => Ok(ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id: Some(remote.id.clone()), + accounts: vec![account], + repository, + pull_requests: page.items, + pagination: page.pagination, + capabilities, + message: None, + auth_challenge: None, + }), + Err(error) if is_auth_http_error(&error) => { + let challenge = auth_challenge_for_remote( + &remote, + &error, + token_for_remote(&remote, &auth_tokens).is_some(), + ); + let mut account = account; + account.auth_state = auth_state_for_challenge(challenge.state); + account.auth_source = + if matches!(challenge.state, ReviewPlatformAuthChallengeState::Missing) { + ReviewAuthSource::None + } else { + account.auth_source + }; + account.message = Some(challenge.message.clone()); + Ok(auth_required_snapshot( + remotes, + remote, + repository, + account, + capabilities, + challenge, + )) + } + Err(error) => Err(error), + } + } + + pub async fn pull_request_detail( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Err(ReviewPlatformError::UnsupportedPlatform( + "remote SSH workspace".to_string(), + )); + } + + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let remote = remotes + .into_iter() + .find(|remote| remote.id == remote_id) + .ok_or_else(|| ReviewPlatformError::RemoteNotFound(remote_id.to_string()))?; + if !remote.supported { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + let ctx = provider_context(remote, &auth_tokens)?; + provider_for(ctx.remote.platform) + .pull_request_detail(&ctx, pull_request_id) + .await + } + + pub async fn pull_request_detail_page( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + page: Option, + per_page: Option, + ) -> Result { + let ctx = Self::provider_context_for_repository(repository_path, Some(remote_id)).await?; + provider_for(ctx.remote.platform) + .pull_request_detail_page( + &ctx, + pull_request_id, + section, + PullRequestPagination::new(page, per_page), + ) + .await + } + + pub async fn pull_request_ci_log( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + let ctx = Self::provider_context_for_repository(repository_path, Some(remote_id)).await?; + provider_for(ctx.remote.platform) + .pull_request_ci_log(&ctx, pull_request_id, ci_item_id, ci_item_name) + .await + } + + pub async fn create_pull_request( + request: ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + request.remote_id.as_deref(), + ) + .await?; + provider_for(ctx.remote.platform) + .create_pull_request(&ctx, &request) + .await + } + + pub async fn reply_to_thread( + request: ReviewPlatformReplyToThreadRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .reply_to_thread(&ctx, &request) + .await + } + + pub async fn submit_review( + request: ReviewPlatformSubmitReviewRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .submit_review(&ctx, &request) + .await + } + + pub async fn resolve_thread( + request: ReviewPlatformResolveThreadRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .resolve_thread(&ctx, &request) + .await + } + + pub async fn approve_pull_request( + request: ReviewPlatformApprovalRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .approve_pull_request(&ctx, &request) + .await + } + + pub async fn revoke_approval( + request: ReviewPlatformApprovalRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .revoke_approval(&ctx, &request) + .await + } + + pub async fn request_changes( + request: ReviewPlatformRequestChangesRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .request_changes(&ctx, &request) + .await + } + + async fn provider_context_for_repository( + repository_path: &str, + remote_id: Option<&str>, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Err(ReviewPlatformError::UnsupportedPlatform( + "remote SSH workspace".to_string(), + )); + } + + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let remote = select_remote_for_action(&remotes, remote_id)?.clone(); + if !remote.supported { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + provider_context(remote, &auth_tokens) + } + + pub async fn update_auth_token( + platform: ReviewPlatformKind, + host: &str, + token: &str, + ) -> Result<(), ReviewPlatformError> { + let token = token.trim(); + if token.is_empty() { + return Err(ReviewPlatformError::Api( + "Token cannot be empty".to_string(), + )); + } + let key = token_key(platform, host) + .ok_or_else(|| ReviewPlatformError::UnsupportedPlatform(host.to_string()))?; + let mut stored = load_stored_token_file().await?; + stored.tokens.insert( + key, + StoredReviewPlatformToken { + token: token.to_string(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + ); + save_stored_token_file(&stored).await + } + + pub async fn clear_auth_token( + platform: ReviewPlatformKind, + host: &str, + ) -> Result<(), ReviewPlatformError> { + let key = token_key(platform, host) + .ok_or_else(|| ReviewPlatformError::UnsupportedPlatform(host.to_string()))?; + let mut stored = load_stored_token_file().await?; + stored.tokens.remove(&key); + save_stored_token_file(&stored).await + } +} + +#[async_trait::async_trait] +trait ReviewProvider: Sync { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result; + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result; + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + let detail = self.pull_request_detail(ctx, pull_request_id).await?; + let ci_total = detail.ci.len(); + let file_total = detail.files.len(); + let commit_total = detail.commits.len(); + let thread_total = detail.threads.len(); + let (ci, files, commits, threads) = match section { + ReviewPlatformDetailSection::Overview => { + (Vec::new(), Vec::new(), Vec::new(), Vec::new()) + } + ReviewPlatformDetailSection::Ci => ( + slice_page(detail.ci, pagination), + Vec::new(), + Vec::new(), + Vec::new(), + ), + ReviewPlatformDetailSection::Files => ( + Vec::new(), + slice_page(detail.files, pagination), + Vec::new(), + Vec::new(), + ), + ReviewPlatformDetailSection::Commits => ( + Vec::new(), + Vec::new(), + slice_page(detail.commits, pagination), + Vec::new(), + ), + ReviewPlatformDetailSection::Reviews => ( + Vec::new(), + Vec::new(), + Vec::new(), + slice_page(detail.threads, pagination), + ), + }; + let total = match section { + ReviewPlatformDetailSection::Overview => 0, + ReviewPlatformDetailSection::Ci => ci_total, + ReviewPlatformDetailSection::Files => file_total, + ReviewPlatformDetailSection::Commits => commit_total, + ReviewPlatformDetailSection::Reviews => thread_total, + }; + Ok(ReviewPlatformPullRequestDetailPage { + pull_request: detail.pull_request, + body: detail.body, + ci, + files, + commits, + threads, + section, + pagination: pagination_from_total(pagination, total), + }) + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + _pull_request_id: &str, + _ci_item_id: &str, + _ci_item_name: &str, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} CI logs", + platform_label(ctx.remote.platform) + ))) + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} pull request creation", + platform_label(ctx.remote.platform) + ))) + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} thread replies", + platform_label(ctx.remote.platform) + ))) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} review submission", + platform_label(ctx.remote.platform) + ))) + } + + async fn resolve_thread( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformResolveThreadRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} thread resolution", + platform_label(ctx.remote.platform) + ))) + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformApprovalRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} pull request approval", + platform_label(ctx.remote.platform) + ))) + } + + async fn revoke_approval( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformApprovalRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} approval revocation", + platform_label(ctx.remote.platform) + ))) + } + + async fn request_changes( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformRequestChangesRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} native change requests", + platform_label(ctx.remote.platform) + ))) + } +} + +struct GithubProvider; +struct GitlabProvider; +struct GitcodeProvider; +struct UnsupportedProvider; + +fn provider_for(platform: ReviewPlatformKind) -> &'static dyn ReviewProvider { + match platform { + ReviewPlatformKind::Github => &GithubProvider, + ReviewPlatformKind::Gitlab => &GitlabProvider, + ReviewPlatformKind::Gitcode => &GitcodeProvider, + ReviewPlatformKind::Unknown => &UnsupportedProvider, + } +} + +#[async_trait::async_trait] +impl ReviewProvider for GithubProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + github_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitHub pull response was not an array".to_string()) + })?; + let total = pagination_total_from_links(&response.headers, pagination, items.len()); + let has_next = link_header_has_rel(&response.headers, "next"); + + let pull_requests = items + .iter() + .map(github_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_github_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let client = http_client()?; + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let token = ctx.token.clone(); + let files_url = format!("{}/files", base); + let files = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &files_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let reviews_url = format!("{}/reviews", base); + let reviews = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &reviews_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let review_comments_url = format!("{}/comments", base); + let review_comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &review_comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let issue_comments_url = format!( + "{}/repos/{}/{}/issues/{}/comments", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let issue_comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &issue_comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + + let mut pull_request = github_pull_request_from_value(&detail); + pull_request.review_decision = github_review_decision(&reviews); + let (checks, ci) = github_checks_and_ci(ctx, &client, &detail).await; + pull_request.checks = checks; + + Ok(ReviewPlatformPullRequestDetail { + body: value_string(&detail, "body"), + pull_request, + ci, + files: array_items(&files) + .iter() + .map(github_file_from_value) + .collect(), + commits: array_items(&commits) + .iter() + .map(github_commit_from_value) + .collect(), + threads: github_threads(&reviews, &review_comments, &issue_comments), + }) + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + github_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + if ci_item_id.starts_with("status-") { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some( + "GitHub commit statuses do not expose logs; use the linked target URL instead." + .to_string(), + ), + }); + } + + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let sha = nested_string(&detail, &["head", "sha"]); + if sha.trim().is_empty() { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some("GitHub pull request head SHA was not available.".to_string()), + }); + } + + let check_run_id = ci_item_id.strip_prefix("check-run-").unwrap_or(ci_item_id); + github_actions_log_for_check_run_item(ctx, &client, check_run_id, ci_item_name, &sha).await + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let token = require_write_token(ctx, "Creating a pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let payload = 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), + }); + let value = + send_json(github_post_request(http_client()?, &url, Some(token)).json(&payload)) + .await?; + let pull_request = github_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created pull request #{}", pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + let token = require_write_token(ctx, "Replying to a pull request thread")?; + let comment_id = parse_provider_comment_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "GitHub replies require a review comment thread id such as comment-123".to_string(), + ) + })?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/comments/{}/replies", + ctx.api_base_url, + ctx.remote.owner, + ctx.remote.repository_name, + request.pull_request_id, + comment_id + ); + let value = send_json( + github_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "body": request.body })), + ) + .await?; + let thread = github_thread_from_review_comment(&value); + Ok(ReviewPlatformActionResult { + success: true, + message: "Replied to pull request thread".to_string(), + web_url: value + .get("html_url") + .and_then(Value::as_str) + .map(str::to_string), + pull_request: None, + thread: Some(thread), + }) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + let event = match request.event { + ReviewSubmitEvent::Comment => "COMMENT", + ReviewSubmitEvent::Approve => "APPROVE", + ReviewSubmitEvent::RequestChanges => "REQUEST_CHANGES", + }; + github_submit_review(ctx, &request.pull_request_id, event, &request.body).await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + github_submit_review( + ctx, + &request.pull_request_id, + "APPROVE", + request.body.as_deref().unwrap_or(""), + ) + .await + } + + async fn request_changes( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformRequestChangesRequest, + ) -> Result { + github_submit_review( + ctx, + &request.pull_request_id, + "REQUEST_CHANGES", + &request.body, + ) + .await + } +} + +async fn github_submit_review( + ctx: &ProviderContext, + pull_request_id: &str, + event: &str, + body: &str, +) -> Result { + let token = require_write_token(ctx, "Submitting a pull request review")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/reviews", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let value = send_json( + github_post_request(http_client()?, &url, Some(token)).json(&json!({ + "body": body, + "event": event, + })), + ) + .await?; + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Submitted GitHub review with event {}", event), + web_url: value + .get("html_url") + .and_then(Value::as_str) + .map(str::to_string), + pull_request: None, + thread: None, + }) +} + +async fn github_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut pull_request = github_pull_request_from_value(&detail); + let (checks, ci_all) = github_checks_and_ci(ctx, &client, &detail).await; + pull_request.checks = checks; + + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut ci = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + section_pagination = pagination_from_total(pagination, ci_all.len()); + ci = slice_page(ci_all, pagination); + } + ReviewPlatformDetailSection::Files => { + let response = fetch_array_page( + github_request( + client.clone(), + &format!("{}/files", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + files = array_items(&response.value) + .iter() + .map(github_file_from_value) + .collect(); + } + ReviewPlatformDetailSection::Commits => { + let response = fetch_array_page( + github_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(github_commit_from_value) + .collect(); + } + ReviewPlatformDetailSection::Reviews => { + let reviews_url = format!("{}/reviews", base); + let reviews = fetch_array_page( + github_request(client.clone(), &reviews_url, ctx.token.as_deref()), + pagination, + ) + .await?; + let review_comments = fetch_array_page( + github_request( + client.clone(), + &format!("{}/comments", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + let issue_comments = fetch_array_page( + github_request( + client.clone(), + &format!( + "{}/repos/{}/{}/issues/{}/comments", + ctx.api_base_url, + ctx.remote.owner, + ctx.remote.repository_name, + pull_request_id + ), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + pull_request.review_decision = github_review_decision(&reviews.value); + section_pagination = combine_page_pagination( + pagination, + &[ + pagination_from_response(&reviews, pagination), + pagination_from_response(&review_comments, pagination), + pagination_from_response(&issue_comments, pagination), + ], + ); + threads = github_threads( + &reviews.value, + &review_comments.value, + &issue_comments.value, + ); + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + pull_request, + body: value_string(&detail, "body"), + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +#[async_trait::async_trait] +impl ReviewProvider for GitlabProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + gitlab_list_pull_requests(ctx, pagination).await + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + gitlab_pull_request_detail(ctx, pull_request_id).await + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + gitlab_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + gitlab_pull_request_ci_log(ctx, pull_request_id, ci_item_id, ci_item_name).await + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + gitlab_create_pull_request(ctx, request, "merge request").await + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + gitlab_reply_to_thread(ctx, request, "merge request").await + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + if request.event != ReviewSubmitEvent::Comment { + return Err(ReviewPlatformError::UnsupportedPlatform( + "GitLab submit_review supports comments only; use approve_pull_request for approvals" + .to_string(), + )); + } + gitlab_add_merge_request_note( + ctx, + &request.pull_request_id, + &request.body, + "Added merge request comment", + ) + .await + } + + async fn resolve_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformResolveThreadRequest, + ) -> Result { + gitlab_resolve_thread(ctx, request, "merge request").await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + gitlab_approve_pull_request(ctx, request, "merge request").await + } + + async fn revoke_approval( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + gitlab_revoke_approval(ctx, request, "merge request").await + } +} + +async fn gitlab_list_pull_requests( + ctx: &ProviderContext, + pagination: PullRequestPagination, +) -> Result { + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!("{}/projects/{}/merge_requests", ctx.api_base_url, project); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + gitlab_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitLab merge request response was not an array".to_string()) + })?; + let total = header_u64(&response.headers, "x-total"); + let has_next = header_string(&response.headers, "x-next-page") + .is_some_and(|value| !value.trim().is_empty()) + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(false); + + let pull_requests = items + .iter() + .map(gitlab_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_gitlab_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) +} + +async fn gitlab_pull_request_detail( + ctx: &ProviderContext, + pull_request_id: &str, +) -> Result { + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path); + let base = format!( + "{}/projects/{}/merge_requests/{}", + ctx.api_base_url, project, pull_request_id + ); + let detail = send_json(gitlab_request(client.clone(), &base, ctx.token.as_deref())).await?; + let changes = send_json(gitlab_request( + client.clone(), + &format!("{}/changes", base), + ctx.token.as_deref(), + )) + .await?; + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + let token = ctx.token.clone(); + let discussions_url = format!("{}/discussions", base); + let discussions = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &discussions_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + let token = ctx.token.clone(); + let notes_url = format!("{}/notes", base); + let notes = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), ¬es_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + + let mut pull_request = gitlab_pull_request_from_value(&detail); + let files = gitlab_files(&changes); + apply_files_stats(&mut pull_request, &files); + let ci = gitlab_pipeline_summary_item(&detail) + .into_iter() + .collect::>(); + pull_request.checks = summarize_ci_items(&ci); + + Ok(ReviewPlatformPullRequestDetail { + body: value_string(&detail, "description"), + pull_request, + ci, + files, + commits: array_items(&commits) + .iter() + .map(gitlab_commit_from_value) + .collect(), + threads: gitlab_threads(&discussions, ¬es), + }) +} + +async fn gitlab_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path); + let base = format!( + "{}/projects/{}/merge_requests/{}", + ctx.api_base_url, project, pull_request_id + ); + let detail = send_json(gitlab_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut pull_request = gitlab_pull_request_from_value(&detail); + let changes = send_json(gitlab_request( + client.clone(), + &format!("{}/changes", base), + ctx.token.as_deref(), + )) + .await?; + let all_files = gitlab_files(&changes); + apply_files_stats(&mut pull_request, &all_files); + let mut ci = gitlab_pipeline_summary_item(&detail) + .into_iter() + .collect::>(); + pull_request.checks = summarize_ci_items(&ci); + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + if let Some(pipeline_id) = detail + .get("head_pipeline") + .and_then(|value| value.get("id")) + .and_then(Value::as_i64) + .map(|id| id.to_string()) + .or_else(|| { + detail + .get("head_pipeline") + .and_then(|value| value.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + }) + { + let jobs = gitlab_pipeline_jobs( + ctx, + client.clone(), + &urlencoding::encode(&ctx.remote.project_path), + &pipeline_id, + ) + .await; + if !jobs.is_empty() { + ci = jobs; + pull_request.checks = summarize_ci_items(&ci); + } + } + section_pagination = pagination_from_total(pagination, ci.len()); + ci = slice_page(ci, pagination); + } + ReviewPlatformDetailSection::Files => { + section_pagination = pagination_from_total(pagination, all_files.len()); + files = slice_page(all_files, pagination); + } + ReviewPlatformDetailSection::Commits => { + let response = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(gitlab_commit_from_value) + .collect(); + } + ReviewPlatformDetailSection::Reviews => { + let discussions = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/discussions", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + let notes = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/notes", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = combine_page_pagination( + pagination, + &[ + pagination_from_response(&discussions, pagination), + pagination_from_response(¬es, pagination), + ], + ); + threads = gitlab_threads(&discussions.value, ¬es.value); + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + pull_request, + body: value_string(&detail, "description"), + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +async fn gitlab_create_pull_request( + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Creating a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!("{}/projects/{}/merge_requests", ctx.api_base_url, project); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)).json(&json!({ + "title": request.title, + "source_branch": request.source_branch, + "target_branch": request.target_branch, + "description": request.body.clone().unwrap_or_default(), + })), + ) + .await?; + let pull_request = gitlab_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created {} !{}", label, pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) +} + +async fn gitlab_reply_to_thread( + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Replying to a {} thread", label))?; + let discussion_id = parse_provider_thread_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "Replies require a discussion thread id from pull request detail".to_string(), + ) + })?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/discussions/{}/notes", + ctx.api_base_url, project, request.pull_request_id, discussion_id + ); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "body": request.body })), + ) + .await?; + let thread = gitlab_thread_from_note( + &value, + Some(discussion_id.to_string()), + false, + ReviewPlatformThreadKind::Comment, + None, + ); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Replied to {} discussion", label), + web_url: None, + pull_request: None, + thread: Some(thread), + }) +} + +async fn gitlab_add_merge_request_note( + ctx: &ProviderContext, + pull_request_id: &str, + body: &str, + message: &str, +) -> Result { + let token = require_write_token(ctx, "Adding a merge request comment")?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/notes", + ctx.api_base_url, project, pull_request_id + ); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)).json(&json!({ "body": body })), + ) + .await?; + let thread = + gitlab_thread_from_note(&value, None, false, ReviewPlatformThreadKind::Comment, None); + Ok(ReviewPlatformActionResult { + success: true, + message: message.to_string(), + web_url: None, + pull_request: None, + thread: Some(thread), + }) +} + +async fn gitlab_resolve_thread( + ctx: &ProviderContext, + request: &ReviewPlatformResolveThreadRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Resolving a {} thread", label))?; + let discussion_id = parse_provider_thread_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "Thread resolution requires a discussion thread id from pull request detail" + .to_string(), + ) + })?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/discussions/{}", + ctx.api_base_url, project, request.pull_request_id, discussion_id + ); + send_json( + gitlab_put_request(http_client()?, &url, Some(token)) + .json(&json!({ "resolved": request.resolved })), + ) + .await?; + Ok(ReviewPlatformActionResult { + success: true, + message: if request.resolved { + format!("Resolved {} discussion", label) + } else { + format!("Reopened {} discussion", label) + }, + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitlab_approve_pull_request( + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Approving a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/approve", + ctx.api_base_url, project, request.pull_request_id + ); + send_json(gitlab_post_request(http_client()?, &url, Some(token))).await?; + if let Some(body) = request + .body + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let _ = gitlab_add_merge_request_note( + ctx, + &request.pull_request_id, + body, + "Added approval note", + ) + .await; + } + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Approved {}", label), + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitlab_revoke_approval( + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Revoking approval for a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/unapprove", + ctx.api_base_url, project, request.pull_request_id + ); + send_json(gitlab_post_request(http_client()?, &url, Some(token))).await?; + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Revoked approval for {}", label), + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitcode_add_pull_request_comment( + ctx: &ProviderContext, + pull_request_id: &str, + body: &str, +) -> Result { + let token = require_write_token(ctx, "Adding a GitCode pull request comment")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/comments", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let value = send_json( + gitcode_post_request(http_client()?, &url, Some(token)).json(&json!({ "body": body })), + ) + .await?; + let thread = gitcode_threads(&Value::Array(vec![value])) + .into_iter() + .next(); + Ok(ReviewPlatformActionResult { + success: true, + message: "Added GitCode pull request comment".to_string(), + web_url: None, + pull_request: None, + thread, + }) +} + +async fn gitcode_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(gitcode_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut ci = gitcode_ci_items(&detail); + let mut pull_request = gitcode_pull_request_from_value(&detail); + pull_request.checks = summarize_ci_items(&ci); + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + section_pagination = pagination_from_total(pagination, ci.len()); + ci = slice_page(ci, pagination); + } + ReviewPlatformDetailSection::Files => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/files", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + files = array_items(&response.value) + .iter() + .map(gitcode_file_from_value) + .collect(); + } + } + ReviewPlatformDetailSection::Commits => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(gitcode_commit_from_value) + .collect(); + } + } + ReviewPlatformDetailSection::Reviews => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/comments", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + threads = gitcode_threads(&response.value); + } + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + body: first_non_empty(&[ + value_string(&detail, "body"), + value_string(&detail, "description"), + ]), + pull_request, + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +#[async_trait::async_trait] +impl ReviewProvider for GitcodeProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + gitcode_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitCode pull response was not an array".to_string()) + })?; + let total = header_u64(&response.headers, "x-total").or_else(|| { + link_header_last_page(&response.headers).map(|last_page| { + if last_page == pagination.page { + (u64::from(last_page.saturating_sub(1)) * u64::from(pagination.per_page)) + + items.len() as u64 + } else { + u64::from(last_page) * u64::from(pagination.per_page) + } + }) + }); + let has_next = link_header_has_rel(&response.headers, "next") + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(items.len() == pagination.per_page as usize); + + let pull_requests = items + .iter() + .map(gitcode_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_gitcode_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = + send_json(gitcode_request(client.clone(), &base, ctx.token.as_deref())).await?; + let token = ctx.token.clone(); + let files_url = format!("{}/files", base); + let files = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &files_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let token = ctx.token.clone(); + let comments_url = format!("{}/comments", base); + let comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let ci = gitcode_ci_items(&detail); + let mut pull_request = gitcode_pull_request_from_value(&detail); + pull_request.checks = summarize_ci_items(&ci); + + Ok(ReviewPlatformPullRequestDetail { + body: first_non_empty(&[ + value_string(&detail, "body"), + value_string(&detail, "description"), + ]), + pull_request, + ci, + files: array_items(&files) + .iter() + .map(gitcode_file_from_value) + .collect(), + commits: array_items(&commits) + .iter() + .map(gitcode_commit_from_value) + .collect(), + threads: gitcode_threads(&comments), + }) + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + gitcode_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + _ctx: &ProviderContext, + _pull_request_id: &str, + ci_item_id: &str, + _ci_item_name: &str, + ) -> Result { + Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some( + "GitCode CI log retrieval is not available through a documented API.".to_string(), + ), + }) + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let token = require_write_token(ctx, "Creating a GitCode pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let value = send_json( + gitcode_post_request(http_client()?, &url, 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 pull_request = gitcode_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created GitCode pull request #{}", pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + if request.event != ReviewSubmitEvent::Comment { + return Err(ReviewPlatformError::UnsupportedPlatform( + "GitCode submit_review supports comments only; use approve_pull_request for review processing" + .to_string(), + )); + } + gitcode_add_pull_request_comment(ctx, &request.pull_request_id, &request.body).await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + let token = require_write_token(ctx, "Approving a GitCode pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/review", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, request.pull_request_id + ); + send_json( + gitcode_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "force": false })), + ) + .await?; + if let Some(body) = request + .body + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let _ = gitcode_add_pull_request_comment(ctx, &request.pull_request_id, body).await; + } + Ok(ReviewPlatformActionResult { + success: true, + message: "Approved GitCode pull request".to_string(), + web_url: None, + pull_request: None, + thread: None, + }) + } +} + +#[async_trait::async_trait] +impl ReviewProvider for UnsupportedProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + _pagination: PullRequestPagination, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform( + ctx.remote.host.clone(), + )) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + _pull_request_id: &str, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform( + ctx.remote.host.clone(), + )) + } +} + +fn http_client() -> Result { + reqwest::Client::builder() + .use_native_tls() + .timeout(Duration::from_secs(25)) + .build() + .map_err(|error| ReviewPlatformError::Network(error.to_string())) +} + +struct JsonResponse { + value: Value, + headers: HeaderMap, +} + +async fn send_json(request: reqwest::RequestBuilder) -> Result { + send_json_response(request) + .await + .map(|response| response.value) +} + +async fn send_json_response( + request: reqwest::RequestBuilder, +) -> Result { + let response = request + .send() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let preview = body.chars().take(280).collect::(); + return Err(ReviewPlatformError::Http { + status: status.as_u16(), + message: preview, + }); + } + let headers = response.headers().clone(); + let value = response + .json::() + .await + .map_err(|error| ReviewPlatformError::Parse(error.to_string()))?; + Ok(JsonResponse { value, headers }) +} + +async fn send_text(request: reqwest::RequestBuilder) -> Result { + let response = request + .send() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + if !status.is_success() { + let preview = text.chars().take(280).collect::(); + return Err(ReviewPlatformError::Http { + status: status.as_u16(), + message: preview, + }); + } + Ok(text) +} + +async fn fetch_paginated_array( + mut build_request: F, + next_page: fn(&HeaderMap, u32) -> Option, +) -> Result +where + F: FnMut(u32) -> reqwest::RequestBuilder, +{ + let mut page = 1; + let mut values = Vec::new(); + + loop { + let response = send_json_response(build_request(page)).await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("Provider paginated response was not an array".to_string()) + })?; + values.extend(items.iter().cloned()); + + let Some(next) = next_page(&response.headers, page).filter(|next| *next > page) else { + break; + }; + page = next; + } + + Ok(Value::Array(values)) +} + +async fn fetch_array_page( + request: reqwest::RequestBuilder, + pagination: PullRequestPagination, +) -> Result { + let page = pagination.page.to_string(); + let per_page = pagination.per_page.to_string(); + let response = + send_json_response(request.query(&[("per_page", &per_page), ("page", &page)])).await?; + response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("Provider paginated response was not an array".to_string()) + })?; + Ok(response) +} + +fn pagination_from_response( + response: &JsonResponse, + pagination: PullRequestPagination, +) -> ReviewPlatformPagination { + let item_count = response.value.as_array().map(Vec::len).unwrap_or(0); + let total = header_u64(&response.headers, "x-total") + .or_else(|| pagination_total_from_links(&response.headers, pagination, item_count)); + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next: link_header_has_rel(&response.headers, "next") + || header_string(&response.headers, "x-next-page") + .is_some_and(|value| !value.trim().is_empty()) + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(false), + } +} + +fn combine_page_pagination( + pagination: PullRequestPagination, + pages: &[ReviewPlatformPagination], +) -> ReviewPlatformPagination { + let totals = if pages.iter().any(|page| page.has_next) { + None + } else { + pages + .iter() + .map(|page| page.total) + .collect::>>() + .map(|values| values.into_iter().sum()) + }; + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: totals, + has_next: pages.iter().any(|page| page.has_next), + } +} + +fn header_string(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + +fn header_u64(headers: &HeaderMap, name: &str) -> Option { + header_string(headers, name).and_then(|value| value.parse::().ok()) +} + +fn link_header_has_rel(headers: &HeaderMap, rel: &str) -> bool { + header_string(headers, "link") + .as_deref() + .is_some_and(|value| { + value + .split(',') + .any(|part| part.contains(&format!("rel=\"{}\"", rel))) + }) +} + +fn link_header_last_page(headers: &HeaderMap) -> Option { + let link = header_string(headers, "link")?; + for part in link.split(',') { + if !part.contains("rel=\"last\"") { + continue; + } + let url = part + .split(';') + .next()? + .trim() + .trim_start_matches('<') + .trim_end_matches('>'); + return query_param_u32(url, "page"); + } + None +} + +fn pagination_total_from_links( + headers: &HeaderMap, + pagination: PullRequestPagination, + item_count: usize, +) -> Option { + if let Some(last_page) = link_header_last_page(headers) { + if pagination.per_page == 1 { + return Some(u64::from(last_page)); + } + if last_page == pagination.page { + return Some( + u64::from(pagination.page.saturating_sub(1)) * u64::from(pagination.per_page) + + item_count as u64, + ); + } + return None; + } + + Some( + u64::from(pagination.page.saturating_sub(1)) * u64::from(pagination.per_page) + + item_count as u64, + ) +} + +fn pagination_from_total( + pagination: PullRequestPagination, + total: usize, +) -> ReviewPlatformPagination { + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: Some(total as u64), + has_next: usize::try_from(pagination.page) + .ok() + .is_some_and(|page| page * (pagination.per_page as usize) < total), + } +} + +fn slice_page(items: Vec, pagination: PullRequestPagination) -> Vec { + let start = pagination + .page + .saturating_sub(1) + .saturating_mul(pagination.per_page) as usize; + items + .into_iter() + .skip(start) + .take(pagination.per_page as usize) + .collect() +} + +fn empty_detail_pagination( + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> ReviewPlatformPagination { + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: if section == ReviewPlatformDetailSection::Overview { + Some(0) + } else { + None + }, + has_next: false, + } +} + +fn github_next_page(headers: &HeaderMap, current_page: u32) -> Option { + if link_header_has_rel(headers, "next") { + Some(current_page.saturating_add(1)) + } else { + None + } +} + +fn gitlab_next_page(headers: &HeaderMap, _current_page: u32) -> Option { + header_string(headers, "x-next-page").and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + trimmed.parse::().ok() + } + }) +} + +fn query_param_u32(url: &str, name: &str) -> Option { + let query = url.split_once('?')?.1; + for pair in query.split('&') { + if let Some((key, value)) = pair.split_once('=') { + if key == name { + return value.parse::().ok(); + } + } + } + None +} + +async fn enrich_github_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(github_request(client, &url, token.as_deref())).await { + pull_request.additions = value_i64(&value, "additions") as i32; + pull_request.deletions = value_i64(&value, "deletions") as i32; + pull_request.changed_files = value_i64(&value, "changed_files") as i32; + pull_request.comments = + (value_i64(&value, "comments") + value_i64(&value, "review_comments")) as i32; + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +async fn enrich_gitlab_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let project = urlencoding::encode(&ctx.remote.project_path).to_string(); + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/projects/{}/merge_requests/{}/changes", + ctx.api_base_url, project, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(gitlab_request(client, &url, token.as_deref())).await { + let files = gitlab_files(&value); + apply_files_stats(&mut pull_request, &files); + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +async fn enrich_gitcode_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(gitcode_request(client, &url, token.as_deref())).await { + let detail = gitcode_pull_request_from_value(&value); + pull_request.additions = detail.additions; + pull_request.deletions = detail.deletions; + pull_request.changed_files = detail.changed_files; + pull_request.comments = detail.comments; + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +fn github_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = token { + request = request.header(AUTHORIZATION, format!("Bearer {}", token)); + } + request +} + +fn github_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = token { + request = request.header(AUTHORIZATION, format!("Bearer {}", token)); + } + request +} + +fn gitlab_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitlab_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitlab_put_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .put(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitcode_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request + .header("PRIVATE-TOKEN", token) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .query(&[("access_token", token)]); + } + request +} + +fn gitcode_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request + .header("PRIVATE-TOKEN", token) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .query(&[("access_token", token)]); + } + request +} + +fn require_write_token<'a>( + ctx: &'a ProviderContext, + action: &str, +) -> Result<&'a str, ReviewPlatformError> { + ctx.token.as_deref().ok_or_else(|| { + ReviewPlatformError::Api(format!( + "{} requires a {} token for {}", + action, + platform_label(ctx.remote.platform), + ctx.remote.host + )) + }) +} + +fn provider_context( + remote: ReviewPlatformRemote, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Result { + let api_base_url = match remote.platform { + ReviewPlatformKind::Github => "https://api.github.com".to_string(), + ReviewPlatformKind::Gitlab => format!("https://{}/api/v4", remote.host), + ReviewPlatformKind::Gitcode => "https://api.gitcode.com/api/v5".to_string(), + ReviewPlatformKind::Unknown => { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + }; + let token = token_for_remote(&remote, auth_tokens); + Ok(ProviderContext { + remote, + api_base_url, + token, + }) +} + +fn token_for_remote( + remote: &ReviewPlatformRemote, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Option { + auth_tokens + .get(remote.platform, &remote.host) + .map(str::to_string) + .or_else(|| env_token_for_platform(remote.platform)) +} + +fn env_token_for_platform(platform: ReviewPlatformKind) -> Option { + let names: &[&str] = match platform { + ReviewPlatformKind::Github => &["GITHUB_TOKEN", "GH_TOKEN"], + ReviewPlatformKind::Gitlab => &["GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"], + ReviewPlatformKind::Gitcode => &["GITCODE_TOKEN"], + ReviewPlatformKind::Unknown => &[], + }; + names.iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn auth_for_platform_host( + platform: ReviewPlatformKind, + host: &str, + auth_tokens: &ReviewPlatformAuthTokens, +) -> (ReviewAuthState, ReviewAuthSource) { + if platform == ReviewPlatformKind::Unknown { + return (ReviewAuthState::Unsupported, ReviewAuthSource::Unsupported); + } + if auth_tokens.get(platform, host).is_some() { + return (ReviewAuthState::Connected, ReviewAuthSource::Stored); + } + if env_token_for_platform(platform).is_some() { + return (ReviewAuthState::Connected, ReviewAuthSource::Env); + } + if platform == ReviewPlatformKind::Gitcode { + (ReviewAuthState::NotConnected, ReviewAuthSource::None) + } else { + (ReviewAuthState::NotRequired, ReviewAuthSource::None) + } +} + +fn token_key(platform: ReviewPlatformKind, host: &str) -> Option { + if platform == ReviewPlatformKind::Unknown { + return None; + } + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() { + return None; + } + Some(format!("{}:{}", platform.as_str(), host)) +} + +fn stored_token_file_path() -> Result { + let path_manager = + try_get_path_manager_arc().map_err(|error| ReviewPlatformError::Api(error.to_string()))?; + Ok(path_manager + .user_data_dir() + .join("review-platform-tokens.json")) +} + +async fn load_stored_tokens() -> Result { + let stored = load_stored_token_file().await?; + Ok(ReviewPlatformAuthTokens { + tokens: stored + .tokens + .into_iter() + .filter_map(|(key, entry)| { + let token = entry.token.trim().to_string(); + if token.is_empty() { + None + } else { + Some((key, token)) + } + }) + .collect(), + }) +} + +async fn load_stored_token_file() -> Result { + let path = stored_token_file_path()?; + match fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str::(&content) + .map_err(|error| ReviewPlatformError::Parse(error.to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(StoredReviewPlatformTokens::default()) + } + Err(error) => Err(ReviewPlatformError::Api(format!( + "Failed to read review platform token store: {}", + error + ))), + } +} + +async fn save_stored_token_file( + stored: &StoredReviewPlatformTokens, +) -> Result<(), ReviewPlatformError> { + let path = stored_token_file_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await.map_err(|error| { + ReviewPlatformError::Api(format!( + "Failed to create review platform token store directory: {}", + error + )) + })?; + } + let content = serde_json::to_string_pretty(stored) + .map_err(|error| ReviewPlatformError::Parse(error.to_string()))?; + fs::write(&path, content).await.map_err(|error| { + ReviewPlatformError::Api(format!( + "Failed to write review platform token store: {}", + error + )) + }) +} + +fn select_remote<'a>( + remotes: &'a [ReviewPlatformRemote], + remote_id: Option<&str>, +) -> Option<&'a ReviewPlatformRemote> { + if let Some(remote_id) = remote_id { + if let Some(remote) = remotes.iter().find(|remote| remote.id == remote_id) { + return Some(remote); + } + } + remotes + .iter() + .find(|remote| remote.supported) + .or_else(|| remotes.first()) +} + +fn select_remote_for_action<'a>( + remotes: &'a [ReviewPlatformRemote], + remote_id: Option<&str>, +) -> Result<&'a ReviewPlatformRemote, ReviewPlatformError> { + if let Some(remote_id) = remote_id { + return remotes + .iter() + .find(|remote| remote.id == remote_id) + .ok_or_else(|| ReviewPlatformError::RemoteNotFound(remote_id.to_string())); + } + + let supported = remotes + .iter() + .filter(|remote| remote.supported) + .collect::>(); + match supported.as_slice() { + [] => remotes + .first() + .ok_or_else(|| ReviewPlatformError::RemoteNotFound("default".to_string())), + [remote] => Ok(remote), + _ => Err(ReviewPlatformError::Api(format!( + "Multiple supported review platform remotes were found. Provide remote_id explicitly. Candidate remotes:\n{}", + supported + .iter() + .map(|remote| format!( + "- remote_id: {} | name: {} | platform: {:?} | project: {} | url: {}", + remote.id, remote.name, remote.platform, remote.project_path, remote.web_url + )) + .collect::>() + .join("\n") + ))), + } +} + +fn empty_snapshot( + remotes: Vec, + selected_remote_id: Option, + account: Option, + message: &str, +) -> ReviewPlatformWorkspaceSnapshot { + let mut accounts = account.into_iter().collect::>(); + if let Some(account) = accounts.first_mut() { + if account.message.is_none() && !message.trim().is_empty() { + account.message = Some(message.to_string()); + } + } + + ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id, + accounts, + repository: None, + pull_requests: Vec::new(), + pagination: ReviewPlatformPagination { + page: DEFAULT_PR_PAGE, + per_page: DEFAULT_PR_PAGE_SIZE, + total: Some(0), + has_next: false, + }, + capabilities: ReviewPlatformCapabilities { + can_create_review: false, + can_create_pull_request: false, + can_reply_to_thread: false, + can_resolve_thread: false, + can_approve: false, + can_revoke_approval: false, + can_request_changes: false, + can_merge: false, + supports_draft_review: false, + }, + message: if message.trim().is_empty() { + None + } else { + Some(message.to_string()) + }, + auth_challenge: None, + } +} + +fn auth_required_snapshot( + remotes: Vec, + remote: ReviewPlatformRemote, + repository: Option, + account: ReviewPlatformAccount, + capabilities: ReviewPlatformCapabilities, + challenge: ReviewPlatformAuthChallenge, +) -> ReviewPlatformWorkspaceSnapshot { + ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id: Some(remote.id), + accounts: vec![account], + repository, + pull_requests: Vec::new(), + pagination: ReviewPlatformPagination { + page: DEFAULT_PR_PAGE, + per_page: DEFAULT_PR_PAGE_SIZE, + total: Some(0), + has_next: false, + }, + capabilities, + message: Some(challenge.message.clone()), + auth_challenge: Some(challenge), + } +} + +fn repository_ref( + remote: &ReviewPlatformRemote, + workspace_path: Option, +) -> ReviewPlatformRepositoryRef { + ReviewPlatformRepositoryRef { + provider_id: remote.id.clone(), + platform: remote.platform, + host: remote.host.clone(), + owner: remote.owner.clone(), + name: remote.repository_name.clone(), + project_path: remote.project_path.clone(), + default_branch: "main".to_string(), + workspace_path, + web_url: remote.web_url.clone(), + } +} + +fn account_for_remote(remote: &ReviewPlatformRemote) -> ReviewPlatformAccount { + ReviewPlatformAccount { + id: remote.id.clone(), + platform: remote.platform, + label: format!("{} ({})", platform_label(remote.platform), remote.host), + username: None, + host: remote.host.clone(), + auth_state: remote.auth_state, + auth_source: remote.auth_source, + scopes: if matches!( + remote.auth_source, + ReviewAuthSource::Env | ReviewAuthSource::Stored + ) { + vec!["pull_request:read".to_string()] + } else { + Vec::new() + }, + message: remote.message.clone(), + } +} + +fn capabilities_for_remote(_remote: &ReviewPlatformRemote) -> ReviewPlatformCapabilities { + let platform = _remote.platform; + ReviewPlatformCapabilities { + can_create_review: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_create_pull_request: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_reply_to_thread: matches!(platform, ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab), + can_resolve_thread: matches!(platform, ReviewPlatformKind::Gitlab), + can_approve: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_revoke_approval: matches!(platform, ReviewPlatformKind::Gitlab), + can_request_changes: matches!(platform, ReviewPlatformKind::Github), + can_merge: false, + supports_draft_review: matches!(platform, ReviewPlatformKind::Github), + } +} + +fn platform_label(platform: ReviewPlatformKind) -> &'static str { + match platform { + ReviewPlatformKind::Github => "GitHub", + ReviewPlatformKind::Gitlab => "GitLab", + ReviewPlatformKind::Gitcode => "GitCode", + ReviewPlatformKind::Unknown => "Git", + } +} + +fn required_scopes_for_platform(platform: ReviewPlatformKind) -> Vec { + match platform { + ReviewPlatformKind::Github => vec!["repo".to_string(), "pull_requests:read".to_string()], + ReviewPlatformKind::Gitlab => { + vec!["read_api".to_string(), "api for write actions".to_string()] + } + ReviewPlatformKind::Gitcode => vec!["pull_request".to_string()], + ReviewPlatformKind::Unknown => Vec::new(), + } +} + +fn auth_state_for_challenge(state: ReviewPlatformAuthChallengeState) -> ReviewAuthState { + match state { + ReviewPlatformAuthChallengeState::Missing => ReviewAuthState::NotConnected, + ReviewPlatformAuthChallengeState::Invalid => ReviewAuthState::Expired, + ReviewPlatformAuthChallengeState::InsufficientScope => ReviewAuthState::Error, + } +} + +fn is_auth_http_error(error: &ReviewPlatformError) -> bool { + matches!( + error, + ReviewPlatformError::Http { + status: 401 | 403, + .. + } + ) +} + +fn auth_challenge_for_remote( + remote: &ReviewPlatformRemote, + error: &ReviewPlatformError, + has_token: bool, +) -> ReviewPlatformAuthChallenge { + let status = match error { + ReviewPlatformError::Http { status, .. } => *status, + _ => 0, + }; + let state = if !has_token { + ReviewPlatformAuthChallengeState::Missing + } else if status == 403 { + ReviewPlatformAuthChallengeState::InsufficientScope + } else { + ReviewPlatformAuthChallengeState::Invalid + }; + let action = match state { + ReviewPlatformAuthChallengeState::Missing => "Add", + ReviewPlatformAuthChallengeState::Invalid => "Update", + ReviewPlatformAuthChallengeState::InsufficientScope => "Update", + }; + let reason = match state { + ReviewPlatformAuthChallengeState::Missing => { + "a token is required to access this repository" + } + ReviewPlatformAuthChallengeState::Invalid => "the saved or environment token was rejected", + ReviewPlatformAuthChallengeState::InsufficientScope => { + "the token does not have enough permissions" + } + }; + ReviewPlatformAuthChallenge { + platform: remote.platform, + host: remote.host.clone(), + remote_id: remote.id.clone(), + project_path: remote.project_path.clone(), + state, + message: format!( + "{} {} token for {}: {}.", + action, + platform_label(remote.platform), + remote.host, + reason + ), + required_scopes: required_scopes_for_platform(remote.platform), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CiOutcome { + Passed, + Failed, + Pending, +} + +fn summarize_ci_items(items: &[ReviewPlatformCiItem]) -> ReviewChecks { + let mut checks = empty_checks(); + for item in items { + match ci_item_outcome(item) { + CiOutcome::Passed => checks.passed += 1, + CiOutcome::Failed => checks.failed += 1, + CiOutcome::Pending => checks.pending += 1, + } + } + checks.total = checks.passed + checks.failed + checks.pending; + checks +} + +fn ci_item_outcome(item: &ReviewPlatformCiItem) -> CiOutcome { + let status = item.status.trim().to_ascii_lowercase(); + let conclusion = item + .conclusion + .as_deref() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + + if conclusion.is_empty() { + return ci_status_outcome(&status); + } + + match conclusion.as_str() { + "success" | "neutral" | "skipped" | "passed" => CiOutcome::Passed, + "failure" | "timed_out" | "timed-out" | "cancelled" | "canceled" | "action_required" + | "error" => CiOutcome::Failed, + "queued" + | "pending" + | "running" + | "in_progress" + | "in progress" + | "created" + | "manual" + | "scheduled" + | "waiting_for_resource" + | "preparing" + | "requested" => CiOutcome::Pending, + _ => ci_status_outcome(&status), + } +} + +fn ci_status_outcome(status: &str) -> CiOutcome { + match status.trim().to_ascii_lowercase().as_str() { + "success" | "passed" | "pass" | "skipped" | "ok" | "available" | "can_be_merged" + | "mergeable" | "true" | "enabled" | "active" => CiOutcome::Passed, + "failure" | "failed" | "fail" | "error" | "cancelled" | "canceled" | "cannot_be_merged" + | "conflict" | "blocked" | "false" | "disabled" | "inactive" => CiOutcome::Failed, + "pending" + | "queued" + | "running" + | "in_progress" + | "in progress" + | "created" + | "manual" + | "scheduled" + | "waiting_for_resource" + | "preparing" + | "requested" + | "checking" + | "unchecked" + | "completed" => CiOutcome::Pending, + _ => CiOutcome::Pending, + } +} + +fn ci_log_value(text: String) -> (Option, bool) { + let extracted = ci_error_excerpt(&text); + let Some(excerpt) = extracted else { + return (None, false); + }; + let char_count = excerpt.chars().count(); + if char_count <= MAX_CI_LOG_CHARS { + return (Some(excerpt), false); + } + + ( + Some(format!( + "[Error excerpt truncated: showing first {} of {} chars]\n{}", + MAX_CI_LOG_CHARS, + char_count, + excerpt.chars().take(MAX_CI_LOG_CHARS).collect::() + )), + true, + ) +} + +fn empty_ci_log() -> (Option, bool) { + (None, false) +} + +fn ci_error_excerpt(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return None; + } + + let mut ranges: Vec<(usize, usize)> = Vec::new(); + for (index, line) in lines.iter().enumerate() { + if !is_ci_error_line(line) { + continue; + } + + let start = index.saturating_sub(2); + let mut end = (index + 6).min(lines.len()); + while end < lines.len() && lines[end].trim().is_empty() { + end += 1; + } + ranges.push((start, end)); + } + + if ranges.is_empty() { + return None; + } + + ranges.sort_unstable_by_key(|range| range.0); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (start, end) in ranges { + if let Some(last) = merged.last_mut() { + if start <= last.1.saturating_add(1) { + last.1 = last.1.max(end); + continue; + } + } + merged.push((start, end)); + } + + let mut output = String::new(); + for (index, (start, end)) in merged.iter().enumerate() { + if index > 0 { + output.push_str("\n...\n"); + } + for line in &lines[*start..*end] { + output.push_str(line); + output.push('\n'); + } + } + + let output = output.trim_end_matches('\n').trim().to_string(); + if output.is_empty() { + None + } else { + Some(output) + } +} + +fn is_ci_error_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("##[error]") + || lower.contains("error:") + || lower.contains(" failed") + || lower.contains("failure") + || lower.contains("fatal") + || lower.contains("exception") + || lower.contains("traceback") + || lower.contains("panic") + || lower.contains("assertion failed") + || lower.contains("command failed") + || lower.contains("exited with code") + || lower.contains("return code") + || lower.contains("build failed") + || lower.contains("test failed") +} + +async fn github_checks_and_ci( + ctx: &ProviderContext, + client: &reqwest::Client, + pull_detail: &Value, +) -> (ReviewChecks, Vec) { + let sha = nested_string(pull_detail, &["head", "sha"]); + if sha.trim().is_empty() { + return (empty_checks(), Vec::new()); + } + + let mut ci_items = Vec::new(); + let status_url = format!( + "{}/repos/{}/{}/commits/{}/status", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, sha + ); + if let Ok(status) = send_json(github_request( + client.clone(), + &status_url, + ctx.token.as_deref(), + )) + .await + { + let statuses = status + .get("statuses") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + for (index, item) in statuses.iter().enumerate() { + ci_items.push(ReviewPlatformCiItem { + id: format!( + "status-{}", + first_non_empty(&[value_string(item, "id"), index.to_string()]) + ), + name: first_non_empty(&[ + value_string(item, "context"), + value_string(item, "description"), + "Status".to_string(), + ]), + status: value_string(item, "state"), + conclusion: None, + detail: optional_string(item, "description"), + stage: None, + web_url: optional_string(item, "target_url"), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + } + + let check_runs_url = format!( + "{}/repos/{}/{}/commits/{}/check-runs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, sha + ); + if let Ok(check_runs) = send_json( + github_request(client.clone(), &check_runs_url, ctx.token.as_deref()) + .query(&[("per_page", "100")]), + ) + .await + { + for (index, item) in check_runs + .get("check_runs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + .iter() + .enumerate() + { + ci_items.push(ReviewPlatformCiItem { + id: format!( + "check-run-{}", + first_non_empty(&[value_string(item, "id"), index.to_string()]) + ), + name: first_non_empty(&[value_string(item, "name"), "Check run".to_string()]), + status: value_string(item, "status"), + conclusion: optional_string(item, "conclusion"), + detail: nested_optional_string(item, &["output", "summary"]) + .or_else(|| nested_optional_string(item, &["output", "text"])) + .or_else(|| optional_string(item, "details_url")), + stage: None, + web_url: optional_string(item, "html_url") + .or_else(|| optional_string(item, "details_url")), + log: None, + log_truncated: false, + started_at: optional_string(item, "started_at"), + finished_at: optional_string(item, "completed_at"), + }); + } + } + + let checks = summarize_ci_items(&ci_items); + (checks, ci_items) +} + +async fn github_actions_jobs_for_head_sha( + ctx: &ProviderContext, + client: &reqwest::Client, + sha: &str, +) -> Vec { + let runs_url = format!( + "{}/repos/{}/{}/actions/runs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let runs = match send_json( + github_request(client.clone(), &runs_url, ctx.token.as_deref()) + .query(&[("head_sha", sha), ("per_page", "100")]), + ) + .await + { + Ok(value) => value, + Err(_) => return Vec::new(), + }; + + let mut jobs = Vec::new(); + for run in runs + .get("workflow_runs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + { + let run_id = value_string(run, "id"); + if run_id.trim().is_empty() { + continue; + } + let jobs_url = format!( + "{}/repos/{}/{}/actions/runs/{}/jobs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, run_id + ); + if let Ok(value) = send_json( + github_request(client.clone(), &jobs_url, ctx.token.as_deref()) + .query(&[("per_page", "100")]), + ) + .await + { + jobs.extend( + value + .get("jobs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + .iter() + .cloned(), + ); + } + } + + jobs +} + +async fn github_actions_log_for_check_run_item( + ctx: &ProviderContext, + client: &reqwest::Client, + check_run_id: &str, + check_run_name: &str, + head_sha: &str, +) -> Result { + let action_jobs = github_actions_jobs_for_head_sha(ctx, client, head_sha).await; + let check_run = action_jobs + .iter() + .find(|job| { + let check_run_url = value_string(job, "check_run_url"); + check_run_url.ends_with(&format!("/check-runs/{}", check_run_id)) + || value_string(job, "name") == check_run_name + }) + .cloned(); + + let Some(job) = check_run else { + return Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log: None, + truncated: false, + message: Some( + "No matching GitHub Actions job was found for this check run.".to_string(), + ), + }); + }; + + let job_id = value_string(&job, "id"); + if job_id.trim().is_empty() { + return Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log: None, + truncated: false, + message: Some("The matching GitHub Actions job does not expose a job id.".to_string()), + }); + } + + let logs_url = format!( + "{}/repos/{}/{}/actions/jobs/{}/logs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, job_id + ); + let text = send_text(github_request( + client.clone(), + &logs_url, + ctx.token.as_deref(), + )) + .await?; + let (log, truncated) = ci_log_value(text); + let message = log + .as_ref() + .is_none() + .then_some("No error lines were detected in the GitHub Actions job log.".to_string()); + Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log, + truncated, + message, + }) +} + +fn gitlab_pipeline_summary_item(detail: &Value) -> Option { + let pipeline = detail.get("head_pipeline")?; + let status = value_string(pipeline, "status"); + if status.trim().is_empty() { + return None; + } + Some(ReviewPlatformCiItem { + id: first_non_empty(&[ + value_string(pipeline, "id"), + value_string(pipeline, "iid"), + "head-pipeline".to_string(), + ]), + name: "Pipeline".to_string(), + status, + conclusion: None, + detail: nested_optional_string(pipeline, &["detailed_status", "text"]) + .or_else(|| nested_optional_string(pipeline, &["detailed_status", "label"])), + stage: None, + web_url: optional_string(pipeline, "web_url"), + log: None, + log_truncated: false, + started_at: optional_string(pipeline, "started_at"), + finished_at: optional_string(pipeline, "finished_at"), + }) +} + +async fn gitlab_pipeline_jobs( + ctx: &ProviderContext, + client: reqwest::Client, + project: &str, + pipeline_id: &str, +) -> Vec { + let jobs_url = format!( + "{}/projects/{}/pipelines/{}/jobs", + ctx.api_base_url, project, pipeline_id + ); + if let Ok(response) = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &jobs_url, ctx.token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await + { + let mut jobs = Vec::new(); + for (index, job) in array_items(&response).iter().enumerate() { + let provider_id = value_string(job, "id"); + let id = first_non_empty(&[provider_id.clone(), index.to_string()]); + jobs.push(ReviewPlatformCiItem { + id, + name: first_non_empty(&[value_string(job, "name"), "Job".to_string()]), + status: value_string(job, "status"), + conclusion: None, + detail: optional_string(job, "failure_reason"), + stage: optional_string(job, "stage"), + web_url: optional_string(job, "web_url"), + log: None, + log_truncated: false, + started_at: optional_string(job, "started_at"), + finished_at: optional_string(job, "finished_at"), + }); + } + return jobs; + } + Vec::new() +} + +async fn gitlab_job_trace( + ctx: &ProviderContext, + client: reqwest::Client, + project: &str, + job_id: &str, +) -> (Option, bool) { + if job_id.trim().is_empty() { + return empty_ci_log(); + } + let trace_url = format!( + "{}/projects/{}/jobs/{}/trace", + ctx.api_base_url, project, job_id + ); + match send_text(gitlab_request(client, &trace_url, ctx.token.as_deref())).await { + Ok(text) => ci_log_value(text), + Err(_) => empty_ci_log(), + } +} + +async fn gitlab_pull_request_ci_log( + ctx: &ProviderContext, + _pull_request_id: &str, + ci_item_id: &str, + _ci_item_name: &str, +) -> Result { + if ci_item_id == "head-pipeline" || ci_item_id == "pipeline" { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some("Pipeline summaries do not expose a separate job trace.".to_string()), + }); + } + + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path).to_string(); + let (log, truncated) = gitlab_job_trace(ctx, client, &project, ci_item_id).await; + let message = log + .as_ref() + .is_none() + .then_some("No error lines were detected in the job trace.".to_string()); + Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log, + truncated, + message, + }) +} + +fn gitcode_ci_items(detail: &Value) -> Vec { + let mut items = Vec::new(); + let pipeline_status = first_non_empty(&[ + value_string(detail, "pipeline_status"), + value_string(detail, "pipeline_status_with_code_quality"), + ]); + if !pipeline_status.trim().is_empty() { + items.push(ReviewPlatformCiItem { + id: first_non_empty(&[ + value_string(detail, "head_pipeline_id"), + "pipeline".to_string(), + ]), + name: "Pipeline".to_string(), + status: pipeline_status, + conclusion: None, + detail: optional_string(detail, "pipeline_status_with_code_quality"), + stage: None, + web_url: optional_string(detail, "web_url") + .or_else(|| optional_string(detail, "html_url")), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + + let codequality_status = value_string(detail, "codequality_status"); + if !codequality_status.trim().is_empty() { + items.push(ReviewPlatformCiItem { + id: first_non_empty(&[ + format!("{}-codequality", value_string(detail, "head_pipeline_id")), + "codequality".to_string(), + ]), + name: "Code quality".to_string(), + status: codequality_status, + conclusion: None, + detail: None, + stage: None, + web_url: optional_string(detail, "web_url") + .or_else(|| optional_string(detail, "html_url")), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + + items +} + +fn parse_remote( + remote_name: &str, + remote_url: &str, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Option { + let parsed = parse_remote_url(remote_url)?; + let host_lower = parsed.host.to_ascii_lowercase(); + let platform = if host_lower.contains("github.com") { + ReviewPlatformKind::Github + } else if host_lower.contains("gitlab") { + ReviewPlatformKind::Gitlab + } else if host_lower.contains("gitcode") { + ReviewPlatformKind::Gitcode + } else { + ReviewPlatformKind::Unknown + }; + + let segments: Vec<&str> = parsed + .path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + if segments.len() < 2 { + return None; + } + let owner = segments.first()?.to_string(); + let repository_name = segments.last()?.trim_end_matches(".git").to_string(); + let project_path = segments + .iter() + .map(|segment| segment.trim_end_matches(".git")) + .collect::>() + .join("/"); + + let supported = platform != ReviewPlatformKind::Unknown; + let (auth_state, auth_source) = auth_for_platform_host(platform, &parsed.host, auth_tokens); + let web_url = format!("{}://{}/{}", parsed.scheme, parsed.host, project_path); + + Some(ReviewPlatformRemote { + id: format!( + "{}:{}:{}", + remote_name, + platform.as_str(), + project_path.replace('/', "__") + ), + name: remote_name.to_string(), + url: sanitize_remote_url(remote_url), + platform, + host: parsed.host, + owner, + repository_name, + project_path, + web_url, + supported, + auth_state, + auth_source, + message: if !supported { + Some("This remote is detected, but no provider adapter is available yet.".to_string()) + } else if platform == ReviewPlatformKind::Gitcode + && auth_state == ReviewAuthState::NotConnected + { + Some("Add a GitCode token to load pull requests.".to_string()) + } else { + None + }, + }) +} + +#[derive(Debug)] +struct ParsedRemoteUrl { + scheme: String, + host: String, + path: String, +} + +fn parse_remote_url(remote_url: &str) -> Option { + if let Some(scheme_end) = remote_url.find("://") { + let scheme = &remote_url[..scheme_end]; + let rest = &remote_url[scheme_end + 3..]; + let slash = rest.find('/')?; + let authority = &rest[..slash]; + let host_part = authority.rsplit('@').next().unwrap_or(authority); + let host = host_part.split(':').next().unwrap_or(host_part); + let path = rest[slash + 1..].trim_end_matches(".git").to_string(); + return Some(ParsedRemoteUrl { + scheme: if scheme == "ssh" { "https" } else { scheme }.to_string(), + host: host.to_string(), + path, + }); + } + + if let Some((user_host, path)) = remote_url.split_once(':') { + if user_host.contains('@') && !path.contains('\\') { + let host = user_host.rsplit('@').next()?.to_string(); + return Some(ParsedRemoteUrl { + scheme: "https".to_string(), + host, + path: path.trim_end_matches(".git").to_string(), + }); + } + } + + None +} + +fn sanitize_remote_url(remote_url: &str) -> String { + if let Some(scheme_end) = remote_url.find("://") { + let scheme = &remote_url[..scheme_end]; + let rest = &remote_url[scheme_end + 3..]; + if let Some(slash) = rest.find('/') { + let authority = &rest[..slash]; + if authority.contains('@') { + let host = authority.rsplit('@').next().unwrap_or(authority); + return format!("{}://{}/{}", scheme, host, &rest[slash + 1..]); + } + } + } + remote_url.to_string() +} + +fn github_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = value_i64(value, "number"); + let state = if value_bool(value, "draft") { + ReviewItemState::Draft + } else if !value_string(value, "merged_at").is_empty() { + ReviewItemState::Merged + } else { + match value_string(value, "state").as_str() { + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + } + }; + + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: nested_string(value, &["user", "login"]), + source_branch: nested_string(value, &["head", "ref"]), + target_branch: nested_string(value, &["base", "ref"]), + updated_at: value_string(value, "updated_at"), + web_url: value_string(value, "html_url"), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + changed_files: value_i64(value, "changed_files") as i32, + comments: (value_i64(value, "comments") + value_i64(value, "review_comments")) as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn gitlab_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = value_i64(value, "iid"); + let state = if value_bool(value, "draft") || value_bool(value, "work_in_progress") { + ReviewItemState::Draft + } else { + match value_string(value, "state").as_str() { + "merged" => ReviewItemState::Merged, + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + } + }; + let changed_files = value_string(value, "changes_count") + .parse::() + .unwrap_or(0); + + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: first_non_empty(&[ + nested_string(value, &["author", "username"]), + nested_string(value, &["author", "name"]), + ]), + source_branch: value_string(value, "source_branch"), + target_branch: value_string(value, "target_branch"), + updated_at: value_string(value, "updated_at"), + web_url: value_string(value, "web_url"), + additions: 0, + deletions: 0, + changed_files, + comments: value_i64(value, "user_notes_count") as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn gitcode_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = first_non_zero(&[value_i64(value, "number"), value_i64(value, "id")]); + let state = match value_string(value, "state").as_str() { + "merged" => ReviewItemState::Merged, + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + }; + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: first_non_empty(&[ + nested_string(value, &["user", "login"]), + nested_string(value, &["user", "name"]), + nested_string(value, &["author", "login"]), + ]), + source_branch: first_non_empty(&[ + nested_string(value, &["head", "ref"]), + value_string(value, "head_branch"), + ]), + target_branch: first_non_empty(&[ + nested_string(value, &["base", "ref"]), + value_string(value, "base_branch"), + ]), + updated_at: value_string(value, "updated_at"), + web_url: first_non_empty(&[ + value_string(value, "html_url"), + value_string(value, "web_url"), + ]), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + changed_files: value_i64(value, "changed_files") as i32, + comments: value_i64(value, "comments") as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn github_file_from_value(value: &Value) -> ReviewPlatformFile { + ReviewPlatformFile { + path: value_string(value, "filename"), + old_path: value + .get("previous_filename") + .and_then(Value::as_str) + .map(str::to_string), + status: file_status(&value_string(value, "status")), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + patch: optional_string(value, "patch"), + } +} + +fn gitcode_file_from_value(value: &Value) -> ReviewPlatformFile { + ReviewPlatformFile { + path: first_non_empty(&[ + value_string(value, "filename"), + value_string(value, "new_path"), + ]), + old_path: value + .get("previous_filename") + .and_then(Value::as_str) + .map(str::to_string), + status: file_status(&value_string(value, "status")), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + patch: optional_string(value, "patch").or_else(|| optional_string(value, "diff")), + } +} + +fn gitlab_files(value: &Value) -> Vec { + value + .get("changes") + .and_then(Value::as_array) + .unwrap_or(&Vec::new()) + .iter() + .map(|change| { + let diff = value_string(change, "diff"); + let (additions, deletions) = count_diff_lines(&diff); + let status = if value_bool(change, "new_file") { + ReviewFileStatus::Added + } else if value_bool(change, "deleted_file") { + ReviewFileStatus::Deleted + } else if value_bool(change, "renamed_file") { + ReviewFileStatus::Renamed + } else { + ReviewFileStatus::Modified + }; + ReviewPlatformFile { + path: value_string(change, "new_path"), + old_path: change + .get("old_path") + .and_then(Value::as_str) + .map(str::to_string), + status, + additions, + deletions, + patch: Some(diff), + } + }) + .collect() +} + +fn github_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = value_string(value, "sha"); + ReviewPlatformCommit { + short_hash: short_hash(&hash), + hash, + title: first_line(&nested_string(value, &["commit", "message"])), + author: first_non_empty(&[ + nested_string(value, &["author", "login"]), + nested_string(value, &["commit", "author", "name"]), + ]), + committed_at: nested_string(value, &["commit", "author", "date"]), + } +} + +fn gitlab_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = value_string(value, "id"); + ReviewPlatformCommit { + short_hash: first_non_empty(&[value_string(value, "short_id"), short_hash(&hash)]), + hash, + title: first_non_empty(&[ + value_string(value, "title"), + first_line(&value_string(value, "message")), + ]), + author: value_string(value, "author_name"), + committed_at: first_non_empty(&[ + value_string(value, "committed_date"), + value_string(value, "created_at"), + ]), + } +} + +fn gitcode_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = first_non_empty(&[value_string(value, "sha"), value_string(value, "id")]); + ReviewPlatformCommit { + short_hash: short_hash(&hash), + hash, + title: first_non_empty(&[ + nested_string(value, &["commit", "message"]) + .lines() + .next() + .unwrap_or_default() + .to_string(), + value_string(value, "message"), + ]), + author: first_non_empty(&[ + nested_string(value, &["author", "login"]), + nested_string(value, &["commit", "author", "name"]), + ]), + committed_at: first_non_empty(&[ + nested_string(value, &["commit", "author", "date"]), + value_string(value, "created_at"), + ]), + } +} + +fn github_review_decision(reviews: &Value) -> ReviewDecision { + let mut latest_by_author: HashMap = HashMap::new(); + let mut anonymous_states = Vec::new(); + for review in array_items(reviews) { + let state = value_string(review, "state"); + if state == "DISMISSED" || state.trim().is_empty() { + continue; + } + let author = nested_string(review, &["user", "login"]); + if author.trim().is_empty() { + anonymous_states.push(state); + } else { + latest_by_author.insert(author, state); + } + } + + let states = latest_by_author + .values() + .chain(anonymous_states.iter()) + .map(String::as_str) + .collect::>(); + + if states.iter().any(|state| *state == "CHANGES_REQUESTED") { + return ReviewDecision::ChangesRequested; + } + if states.iter().any(|state| *state == "APPROVED") { + return ReviewDecision::Approved; + } + if states.iter().any(|state| *state == "COMMENTED") { + return ReviewDecision::Commented; + } + ReviewDecision::Pending +} + +fn github_threads( + reviews: &Value, + review_comments: &Value, + issue_comments: &Value, +) -> Vec { + let mut threads = Vec::new(); + for review in array_items(reviews) { + let body = github_review_body(review); + threads.push(ReviewPlatformThread { + id: format!("review-{}", value_i64(review, "id")), + provider_thread_id: None, + provider_comment_id: value_i64(review, "id") + .checked_abs() + .map(|id| id.to_string()), + kind: ReviewPlatformThreadKind::Review, + reply_to_provider_comment_id: None, + file_path: None, + line: None, + resolved: false, + author: nested_string(review, &["user", "login"]), + body, + updated_at: first_non_empty(&[ + value_string(review, "submitted_at"), + value_string(review, "updated_at"), + ]), + }); + } + for comment in array_items(review_comments) { + threads.push(github_thread_from_review_comment(comment)); + } + for comment in array_items(issue_comments) { + threads.push(github_thread_from_issue_comment(comment)); + } + threads +} + +fn github_review_body(review: &Value) -> String { + let body = value_string(review, "body"); + if !body.trim().is_empty() { + return body; + } + match value_string(review, "state").as_str() { + "APPROVED" => "Approved this pull request.".to_string(), + "CHANGES_REQUESTED" => "Requested changes.".to_string(), + "COMMENTED" => "Submitted a pull request review.".to_string(), + state if !state.trim().is_empty() => format!("Submitted a {} review.", state), + _ => "Submitted a pull request review.".to_string(), + } +} + +fn github_thread_from_review_comment(comment: &Value) -> ReviewPlatformThread { + let comment_id = first_non_empty(&[ + value_string(comment, "id"), + value_i64(comment, "id").to_string(), + ]); + ReviewPlatformThread { + id: format!("comment-{}", comment_id), + provider_thread_id: None, + provider_comment_id: Some(comment_id), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: value_i64(comment, "in_reply_to_id") + .checked_abs() + .map(|id| id.to_string()) + .or_else(|| { + comment + .get("in_reply_to_id") + .and_then(Value::as_str) + .map(str::to_string) + }), + file_path: comment + .get("path") + .and_then(Value::as_str) + .map(str::to_string), + line: comment + .get("line") + .and_then(Value::as_i64) + .or_else(|| comment.get("original_line").and_then(Value::as_i64)), + resolved: false, + author: nested_string(comment, &["user", "login"]), + body: value_string(comment, "body"), + updated_at: value_string(comment, "updated_at"), + } +} + +fn github_thread_from_issue_comment(comment: &Value) -> ReviewPlatformThread { + let comment_id = first_non_empty(&[ + value_string(comment, "id"), + value_i64(comment, "id").to_string(), + ]); + ReviewPlatformThread { + id: format!("issue-comment-{}", comment_id), + provider_thread_id: None, + provider_comment_id: Some(comment_id), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: None, + file_path: None, + line: None, + resolved: false, + author: nested_string(comment, &["user", "login"]), + body: value_string(comment, "body"), + updated_at: value_string(comment, "updated_at"), + } +} + +fn gitlab_threads(discussions: &Value, notes: &Value) -> Vec { + let mut threads = Vec::new(); + let mut seen_comment_ids = HashSet::new(); + for discussion in array_items(discussions) { + let discussion_id = value_string(discussion, "id"); + let resolved = value_bool(discussion, "resolved"); + let discussion_notes = discussion + .get("notes") + .and_then(Value::as_array) + .map(|notes| notes.as_slice()) + .unwrap_or(&[]); + let mut root_comment_id: Option = None; + for (index, note) in discussion_notes.iter().enumerate() { + let kind = if index == 0 { + ReviewPlatformThreadKind::Review + } else { + ReviewPlatformThreadKind::Comment + }; + let reply_to = if index == 0 { + None + } else { + root_comment_id.clone() + }; + let thread = gitlab_thread_from_note( + note, + Some(discussion_id.clone()), + resolved, + kind, + reply_to, + ); + if root_comment_id.is_none() { + root_comment_id = thread.provider_comment_id.clone(); + } + if let Some(comment_id) = thread.provider_comment_id.clone() { + seen_comment_ids.insert(comment_id); + } + threads.push(thread); + } + } + for note in array_items(notes) { + let thread = + gitlab_thread_from_note(note, None, false, ReviewPlatformThreadKind::Comment, None); + if let Some(comment_id) = thread.provider_comment_id.as_ref() { + if seen_comment_ids.contains(comment_id) { + continue; + } + seen_comment_ids.insert(comment_id.clone()); + } + threads.push(thread); + } + threads +} + +fn gitlab_thread_from_note( + note: &Value, + discussion_id: Option, + discussion_resolved: bool, + kind: ReviewPlatformThreadKind, + reply_to_provider_comment_id: Option, +) -> ReviewPlatformThread { + let note_id = value_string(note, "id"); + let id = match discussion_id.as_deref() { + Some(discussion_id) if !discussion_id.trim().is_empty() => { + format!("discussion-{}:note-{}", discussion_id, note_id) + } + _ => format!("note-{}", note_id), + }; + + ReviewPlatformThread { + id, + provider_thread_id: discussion_id, + provider_comment_id: Some(note_id), + kind, + reply_to_provider_comment_id, + file_path: nested_optional_string(note, &["position", "new_path"]) + .or_else(|| nested_optional_string(note, &["position", "old_path"])), + line: note + .pointer("/position/new_line") + .and_then(Value::as_i64) + .or_else(|| note.pointer("/position/old_line").and_then(Value::as_i64)), + resolved: discussion_resolved || value_bool(note, "resolved"), + author: first_non_empty(&[ + nested_string(note, &["author", "username"]), + nested_string(note, &["author", "name"]), + ]), + body: value_string(note, "body"), + updated_at: first_non_empty(&[ + value_string(note, "updated_at"), + value_string(note, "created_at"), + ]), + } +} + +fn parse_provider_comment_id(thread_id: &str) -> Option<&str> { + let trimmed = thread_id.trim(); + trimmed + .strip_prefix("comment-") + .or_else(|| trimmed.strip_prefix("note-")) + .or_else(|| trimmed.split_once(":note-").map(|(_, note_id)| note_id)) + .or_else(|| { + if trimmed.chars().all(|ch| ch.is_ascii_digit()) { + Some(trimmed) + } else { + None + } + }) + .filter(|value| !value.trim().is_empty()) +} + +fn parse_provider_thread_id(thread_id: &str) -> Option<&str> { + let trimmed = thread_id.trim(); + trimmed + .strip_prefix("discussion-") + .map(|value| { + value + .split_once(":note-") + .map(|(id, _)| id) + .unwrap_or(value) + }) + .or_else(|| { + if trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + Some(trimmed) + } else { + None + } + }) + .filter(|value| !value.trim().is_empty()) +} + +fn gitcode_threads(value: &Value) -> Vec { + array_items(value) + .iter() + .map(|comment| ReviewPlatformThread { + id: value_string(comment, "id"), + provider_thread_id: None, + provider_comment_id: Some(value_string(comment, "id")), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: comment + .get("in_reply_to_id") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + comment + .get("in_reply_to_id") + .and_then(Value::as_i64) + .map(|id| id.to_string()) + }), + file_path: comment + .get("path") + .and_then(Value::as_str) + .map(str::to_string), + line: comment.get("line").and_then(Value::as_i64), + resolved: false, + author: first_non_empty(&[ + nested_string(comment, &["user", "login"]), + nested_string(comment, &["user", "name"]), + ]), + body: value_string(comment, "body"), + updated_at: first_non_empty(&[ + value_string(comment, "updated_at"), + value_string(comment, "created_at"), + ]), + }) + .collect() +} + +fn empty_checks() -> ReviewChecks { + ReviewChecks { + total: 0, + passed: 0, + failed: 0, + pending: 0, + } +} + +fn file_status(status: &str) -> ReviewFileStatus { + match status { + "added" | "new" => ReviewFileStatus::Added, + "removed" | "deleted" => ReviewFileStatus::Deleted, + "renamed" => ReviewFileStatus::Renamed, + _ => ReviewFileStatus::Modified, + } +} + +fn count_diff_lines(diff: &str) -> (i32, i32) { + let mut additions = 0; + let mut deletions = 0; + for line in diff.lines() { + if line.starts_with("+++") || line.starts_with("---") { + continue; + } + if line.starts_with('+') { + additions += 1; + } else if line.starts_with('-') { + deletions += 1; + } + } + (additions, deletions) +} + +fn apply_files_stats(pull_request: &mut ReviewPlatformPullRequest, files: &[ReviewPlatformFile]) { + pull_request.changed_files = files.len() as i32; + let (additions, deletions) = files.iter().fold((0, 0), |acc, file| { + (acc.0 + file.additions, acc.1 + file.deletions) + }); + pull_request.additions = additions; + pull_request.deletions = deletions; +} + +fn array_items<'a>(value: &'a Value) -> &'a [Value] { + value + .as_array() + .map(|items| items.as_slice()) + .unwrap_or(&[]) +} + +fn value_string(value: &Value, key: &str) -> String { + match value.get(key) { + Some(Value::String(text)) => text.clone(), + Some(Value::Number(number)) => number.to_string(), + Some(Value::Bool(flag)) => flag.to_string(), + _ => String::new(), + } +} + +fn optional_string(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|value| !value.trim().is_empty()) +} + +fn nested_string(value: &Value, path: &[&str]) -> String { + nested_optional_string(value, path).unwrap_or_default() +} + +fn nested_optional_string(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + match current { + Value::String(text) => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +fn value_i64(value: &Value, key: &str) -> i64 { + value + .get(key) + .and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str()?.parse::().ok()) + }) + .unwrap_or(0) +} + +fn value_bool(value: &Value, key: &str) -> bool { + value + .get(key) + .and_then(|value| { + value + .as_bool() + .or_else(|| value.as_str().map(|text| text.eq_ignore_ascii_case("true"))) + }) + .unwrap_or(false) +} + +fn first_non_empty(values: &[String]) -> String { + values + .iter() + .find(|value| !value.trim().is_empty()) + .cloned() + .unwrap_or_default() +} + +fn first_non_zero(values: &[i64]) -> i64 { + values + .iter() + .copied() + .find(|value| *value != 0) + .unwrap_or(0) +} + +fn first_line(value: &str) -> String { + value.lines().next().unwrap_or_default().to_string() +} + +fn short_hash(hash: &str) -> String { + hash.chars().take(7).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn github_review_decision_uses_latest_review_per_author() { + let reviews = json!([ + { + "id": 1, + "state": "CHANGES_REQUESTED", + "user": { "login": "alice" } + }, + { + "id": 2, + "state": "APPROVED", + "user": { "login": "alice" } + } + ]); + + assert_eq!(github_review_decision(&reviews), ReviewDecision::Approved); + } + + #[test] + fn github_review_decision_keeps_active_change_request_from_any_reviewer() { + let reviews = json!([ + { + "id": 1, + "state": "APPROVED", + "user": { "login": "alice" } + }, + { + "id": 2, + "state": "CHANGES_REQUESTED", + "user": { "login": "bob" } + } + ]); + + assert_eq!( + github_review_decision(&reviews), + ReviewDecision::ChangesRequested + ); + } + + #[test] + fn github_threads_include_issue_comments_and_review_comments() { + let reviews = json!([]); + let review_comments = json!([ + { + "id": 10, + "path": "src/lib.rs", + "line": 8, + "user": { "login": "alice" }, + "body": "Inline comment", + "updated_at": "2026-05-18T01:00:00Z" + } + ]); + let issue_comments = json!([ + { + "id": 20, + "user": { "login": "bob" }, + "body": "Conversation comment", + "updated_at": "2026-05-18T02:00:00Z" + } + ]); + + let threads = github_threads(&reviews, &review_comments, &issue_comments); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].id, "comment-10"); + assert_eq!(threads[0].file_path.as_deref(), Some("src/lib.rs")); + assert_eq!(threads[1].id, "issue-comment-20"); + assert_eq!(threads[1].file_path, None); + assert_eq!(threads[1].body, "Conversation comment"); + } + + #[test] + fn github_threads_keep_empty_body_reviews_visible() { + let reviews = json!([ + { + "id": 30, + "state": "APPROVED", + "user": { "login": "alice" }, + "body": "", + "submitted_at": "2026-05-18T03:00:00Z" + } + ]); + + let threads = github_threads(&reviews, &json!([]), &json!([])); + + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].id, "review-30"); + assert_eq!(threads[0].body, "Approved this pull request."); + } + + #[test] + fn github_review_comment_replies_track_parent_comment() { + let threads = github_threads( + &json!([]), + &json!([ + { + "id": 40, + "in_reply_to_id": 10, + "user": { "login": "alice" }, + "body": "Reply", + "updated_at": "2026-05-18T04:30:00Z" + } + ]), + &json!([]), + ); + + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].kind, ReviewPlatformThreadKind::Comment); + assert_eq!( + threads[0].reply_to_provider_comment_id.as_deref(), + Some("10") + ); + } + + #[test] + fn gitlab_threads_include_top_level_notes_without_duplication() { + let discussions = json!([ + { + "id": "discussion-1", + "resolved": false, + "notes": [ + { + "id": "100", + "author": { "username": "alice" }, + "body": "Inline note", + "updated_at": "2026-05-18T04:00:00Z", + "position": { "new_path": "src/lib.rs", "new_line": 12 } + } + ] + } + ]); + let notes = json!([ + { + "id": "100", + "author": { "username": "alice" }, + "body": "Inline note", + "updated_at": "2026-05-18T04:00:00Z", + "position": { "new_path": "src/lib.rs", "new_line": 12 } + }, + { + "id": "200", + "author": { "username": "bob" }, + "body": "Top-level note", + "updated_at": "2026-05-18T05:00:00Z" + } + ]); + + let threads = gitlab_threads(&discussions, ¬es); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].id, "discussion-discussion-1:note-100"); + assert_eq!(threads[1].id, "note-200"); + assert_eq!(threads[1].file_path, None); + assert_eq!(threads[1].body, "Top-level note"); + } + + #[test] + fn gitlab_discussion_threads_mark_root_as_review_and_replies_as_comments() { + let discussions = json!([ + { + "id": "discussion-2", + "resolved": false, + "notes": [ + { + "id": "300", + "author": { "username": "alice" }, + "body": "Root note", + "updated_at": "2026-05-18T06:00:00Z" + }, + { + "id": "301", + "author": { "username": "bob" }, + "body": "Reply note", + "updated_at": "2026-05-18T06:05:00Z" + } + ] + } + ]); + + let threads = gitlab_threads(&discussions, &json!([])); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].kind, ReviewPlatformThreadKind::Review); + assert_eq!(threads[0].reply_to_provider_comment_id, None); + assert_eq!(threads[1].kind, ReviewPlatformThreadKind::Comment); + assert_eq!( + threads[1].reply_to_provider_comment_id.as_deref(), + Some("300") + ); + } + + #[test] + fn summarize_ci_items_counts_provider_outcomes() { + let items = vec![ + ReviewPlatformCiItem { + id: "build".to_string(), + name: "Build".to_string(), + status: "completed".to_string(), + conclusion: Some("success".to_string()), + detail: None, + stage: Some("build".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ReviewPlatformCiItem { + id: "test".to_string(), + name: "Test".to_string(), + status: "failed".to_string(), + conclusion: None, + detail: None, + stage: Some("test".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ReviewPlatformCiItem { + id: "deploy".to_string(), + name: "Deploy".to_string(), + status: "running".to_string(), + conclusion: None, + detail: None, + stage: Some("deploy".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ]; + + let checks = summarize_ci_items(&items); + + assert_eq!(checks.total, 3); + assert_eq!(checks.passed, 1); + assert_eq!(checks.failed, 1); + assert_eq!(checks.pending, 1); + } + + #[test] + fn ci_log_value_extracts_error_excerpt_only() { + let text = [ + "running setup", + "downloading dependencies", + "cargo test failed with exit code 101", + "thread 'main' panicked at src/lib.rs:4", + "uploading artifacts", + ] + .join("\n"); + + let (log, truncated) = ci_log_value(text); + + let log = log.expect("log should be present"); + assert!(!truncated); + assert!(log.contains("cargo test failed")); + assert!(log.contains("panicked at src/lib.rs")); + } + + #[test] + fn ci_log_value_reports_when_no_error_lines_match() { + let (log, truncated) = ci_log_value("all checks passed".to_string()); + + assert!(!truncated); + assert!(log.is_none()); + } +} diff --git a/src/crates/tool-packs/src/lib.rs b/src/crates/tool-packs/src/lib.rs index aff05d94d..1ec31ec48 100644 --- a/src/crates/tool-packs/src/lib.rs +++ b/src/crates/tool-packs/src/lib.rs @@ -153,6 +153,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", @@ -302,6 +303,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index a046bced2..9aa569784 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -101,6 +101,10 @@ const SessionUsagePanel = React.lazy(() => })) ); +const ReviewPlatformPanel = React.lazy(() => + import('@/app/components/panels/review-platform/ReviewPlatformPanel') +); + // CodePreview, ChartRenderer and CodeNode removed - visualization features disabled import { FlexiblePanelProps @@ -743,6 +747,26 @@ const FlexiblePanel: React.FC = memo(({ ); + case 'review-platform': + return ( + Loading pull requests...}> + + + ); + + case 'review-platform-pr-detail': + return ( + Loading pull request...}> + + + ); + case 'browser': return ( {t('flexiblePanel.loading.terminal')}}> diff --git a/src/web-ui/src/app/components/panels/base/types.ts b/src/web-ui/src/app/components/panels/base/types.ts index 3e1745b26..0888041ee 100644 --- a/src/web-ui/src/app/components/panels/base/types.ts +++ b/src/web-ui/src/app/components/panels/base/types.ts @@ -27,6 +27,8 @@ export type PanelContentType = | 'plan-viewer' | 'btw-session' | 'session-usage' + | 'review-platform' + | 'review-platform-pr-detail' | 'terminal' | 'generative-widget' | 'browser'; diff --git a/src/web-ui/src/app/components/panels/base/utils.ts b/src/web-ui/src/app/components/panels/base/utils.ts index a3693f75e..cb846342b 100644 --- a/src/web-ui/src/app/components/panels/base/utils.ts +++ b/src/web-ui/src/app/components/panels/base/utils.ts @@ -18,6 +18,7 @@ import { MessageSquareQuote, Globe, Activity, + GitPullRequest, } from 'lucide-react'; import { PanelContentType, PanelContentConfig } from './types'; @@ -207,6 +208,22 @@ export const PANEL_CONTENT_CONFIGS: Record supportsDownload: false, showHeader: false }, + 'review-platform': { + type: 'review-platform', + displayName: 'Pull Requests', + icon: GitPullRequest, + supportsCopy: false, + supportsDownload: false, + showHeader: false + }, + 'review-platform-pr-detail': { + type: 'review-platform-pr-detail', + displayName: 'Pull Request', + icon: GitPullRequest, + supportsCopy: false, + supportsDownload: false, + showHeader: false + }, 'terminal': { type: 'terminal', displayName: 'Terminal', diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss new file mode 100644 index 000000000..859e26e7b --- /dev/null +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss @@ -0,0 +1,1679 @@ +@use '../../../../component-library/styles/tokens' as *; + +.review-platform { + container: review-platform / inline-size; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + color: var(--color-text-primary); + background: + linear-gradient(180deg, color-mix(in srgb, var(--color-bg-elevated) 52%, transparent), transparent 120px), + var(--color-bg-primary); + + &__topbar, + &__subbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 44px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-base); + } + + &__topbar { + flex-wrap: wrap; + } + + &__topbar { + background: color-mix(in srgb, var(--color-bg-elevated) 86%, transparent); + } + + &__subbar { + min-height: 34px; + padding-block: 6px; + background: color-mix(in srgb, var(--color-bg-elevated) 42%, transparent); + } + + &__brand, + &__topbar-actions, + &__status-line, + &__detail-meta, + &__detail-actions, + &__agent-link-actions, + &__summary-strip, + &__summary-grid span, + &__timeline-item, + &__thread-head, + &__account, + &__capability-row, + &__pagination { + display: flex; + align-items: center; + } + + &__brand { + min-width: 0; + gap: 10px; + } + + &__brand-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 25%, var(--border-base)); + border-radius: 6px; + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 10%, transparent); + } + + &__brand-copy, + &__pr-main, + &__detail-title-block, + &__timeline-main { + min-width: 0; + display: flex; + flex-direction: column; + } + + &__title { + font-size: 13px; + font-weight: 650; + letter-spacing: 0; + } + + &__subtitle, + &__pr-meta, + &__detail-meta, + &__timeline-main span, + &__thread-anchor, + &__status-line, + &__summary-label { + color: var(--color-text-secondary); + font-size: 11px; + } + + &__topbar-actions { + gap: 8px; + min-width: 0; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + } + + &__remote-select { + width: min(320px, 100%); + min-width: 180px; + } + + &__account { + max-width: 176px; + gap: 5px; + padding: 4px 8px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 64%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__account--connected { + color: color-mix(in srgb, #3fb950 92%, var(--color-text-secondary)); + } + + &__account--not_required { + color: color-mix(in srgb, #58a6ff 90%, var(--color-text-secondary)); + } + + &__account--expired, + &__account--error { + color: #f85149; + } + + &__account--unsupported { + color: #d29922; + } + + &__icon-button.icon-btn { + border-radius: 6px; + color: var(--color-text-secondary); + background: transparent; + + &:hover:not(:disabled) { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-soft) 72%, transparent); + } + } + + &__auth-form { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; + } + + &__auth-target { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 58%, transparent); + + span, + strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + span { + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 500; + } + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + } + + &__auth-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + + &__auth-gate { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 10px 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, #d29922 42%, var(--border-base)); + border-radius: 10px; + background: color-mix(in srgb, #d29922 10%, var(--color-bg-elevated)); + } + + &__auth-gate--detail { + max-width: 720px; + margin: 0; + } + + &__auth-gate-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, #d29922 42%, var(--border-base)); + border-radius: 8px; + color: #d29922; + background: color-mix(in srgb, #d29922 14%, transparent); + } + + &__auth-gate-copy { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + + strong { + color: var(--color-text-primary); + font-size: 13px; + font-weight: 650; + } + + span { + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.4; + overflow-wrap: anywhere; + } + } + + &__auth-gate-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + } + + &__status-line { + gap: 12px; + flex-wrap: wrap; + + span { + display: inline-flex; + align-items: center; + gap: 5px; + } + } + + &__loading-inline { + color: var(--accent-primary); + display: inline-block; + transform-origin: center; + } + + &__loading-inline--icon { + animation: review-platform-spin 0.9s linear infinite; + } + + &__loading-status { + color: var(--color-text-secondary); + } + + &__cache-label { + color: var(--color-text-secondary); + } + + &__body { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: minmax(260px, clamp(260px, 32cqi, 360px)) minmax(0, 1fr); + } + + &--detail-only &__body { + grid-template-columns: minmax(0, 1fr); + } + + &__list { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-base); + background: color-mix(in srgb, var(--color-bg-elevated) 48%, transparent); + } + + &__list-toolbar { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 10px 8px; + border-bottom: 1px solid var(--border-base); + } + + &__state-filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + &__state-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 9px; + border: 1px solid var(--border-base); + border-radius: 999px; + background: transparent; + color: var(--color-text-secondary); + font-size: 11px; + cursor: pointer; + transition: border-color $motion-base $easing-standard, background $motion-base $easing-standard, color $motion-base $easing-standard; + + &:hover { + border-color: color-mix(in srgb, var(--accent-primary) 32%, var(--border-base)); + color: var(--color-text-primary); + } + + &.is-active { + border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-base)); + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 11%, transparent); + } + } + + &__list-scroll { + min-height: 0; + flex: 1; + overflow-y: auto; + padding: 8px; + } + + &__pagination { + justify-content: space-between; + gap: 8px; + min-height: 34px; + padding: 6px 8px; + border-top: 1px solid var(--border-base); + color: var(--color-text-secondary); + background: color-mix(in srgb, var(--color-bg-elevated) 62%, transparent); + font-size: 11px; + font-variant-numeric: tabular-nums; + } + + &__detail-pagination { + flex: 0 0 auto; + margin-top: 4px; + border: 1px solid var(--border-base); + border-radius: 7px; + } + + &__empty-state, + &__error-state, + &__detail-empty { + min-height: 160px; + padding: 20px 16px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.5; + } + + &__empty-state, + &__detail-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-direction: column; + text-align: center; + } + + &__detail-empty-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + } + + &__error-state { + display: grid; + justify-items: center; + gap: 10px; + color: #f85149; + } + + &__pr-row { + width: 100%; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 8px; + padding: 10px 9px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + transition: border-color $motion-base $easing-standard, background $motion-base $easing-standard; + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 78%, transparent); + } + + &.is-selected { + border-color: color-mix(in srgb, var(--accent-primary) 28%, var(--border-base)); + background: linear-gradient(90deg, color-mix(in srgb, var(--accent-primary) 11%, transparent), color-mix(in srgb, var(--element-bg-soft) 86%, transparent)); + } + } + + &__pr-icon { + padding-top: 1px; + } + + &__pr-title, + &__file-path, + &__timeline-main strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__pr-title { + font-size: 12px; + font-weight: 600; + } + + &__pr-meta--secondary { + margin-top: 2px; + } + + &__pr-stats { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + gap: 8px; + padding-top: 1px; + } + + &__counts { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--color-text-secondary); + } + + &__decision, + &__file-status, + &__capability-chip { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 6px; + border-radius: 5px; + font-size: 10px; + font-weight: 650; + line-height: 1.2; + } + + &__decision--approved { + color: #3fb950; + background: rgba(63, 185, 80, 0.12); + } + + &__decision--changes_requested { + color: #f85149; + background: rgba(248, 81, 73, 0.12); + } + + &__decision--commented, + &__decision--pending { + color: #d29922; + background: rgba(210, 153, 34, 0.13); + } + + &__state-icon--open { + color: #3fb950; + } + + &__state-icon--merged { + color: #a371f7; + } + + &__state-icon--closed { + color: #f85149; + } + + &__detail { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + &__detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + min-width: 0; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-base); + } + + &__detail-title-row { + display: flex; + align-items: center; + gap: 7px; + + h3 { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + font-weight: 650; + letter-spacing: 0; + } + } + + &__detail-meta { + flex-wrap: wrap; + gap: 8px; + margin-top: 5px; + + span { + display: inline-flex; + align-items: center; + gap: 4px; + } + } + + &__detail-actions { + gap: 6px; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + + .btn { + gap: 5px; + } + } + + &__panel-button.btn { + min-height: 28px; + border-radius: 6px; + border-color: color-mix(in srgb, var(--border-base) 82%, transparent); + background: color-mix(in srgb, var(--element-bg-soft) 54%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 600; + box-shadow: none; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent-primary) 34%, var(--border-base)); + background: color-mix(in srgb, var(--accent-primary) 9%, var(--element-bg-soft)); + color: var(--color-text-primary); + } + + &:disabled { + opacity: 0.48; + cursor: default; + } + } + + &__summary-strip { + gap: 8px; + padding: 12px 14px 0; + flex-wrap: wrap; + } + + &__agent-link-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 10px 14px 0; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, var(--border-base)); + border-radius: 7px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--accent-primary) 8%, transparent), transparent), + color-mix(in srgb, var(--color-bg-elevated) 70%, transparent); + } + + &__agent-link-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; + + strong, + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + span { + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__agent-link-label { + color: var(--accent-primary) !important; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0; + } + + &__agent-link-actions { + gap: 6px; + flex-shrink: 0; + } + + &__summary-card { + min-width: 106px; + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 12px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-elevated) 74%, transparent); + + strong { + font-size: 14px; + font-weight: 650; + } + } + + &__capability-row { + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; + } + + &__section-heading { + width: 100%; + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + + > span { + min-width: 0; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + .btn { + flex-shrink: 0; + gap: 5px; + } + } + + &__section-count { + flex: 0 0 auto; + color: var(--color-text-secondary); + font-size: 11px; + } + + &__card-heading { + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: -2px 0 8px; + + > span { + min-width: 0; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + .btn { + flex-shrink: 0; + gap: 5px; + } + } + + &__capability-chip { + color: var(--color-text-secondary); + background: color-mix(in srgb, var(--element-bg-soft) 76%, transparent); + border: 1px solid var(--border-base); + } + + &__tabs { + min-height: 0; + flex: 1; + + &.bitfun-tabs, + .bitfun-tabs__content, + .bitfun-tab-pane { + min-height: 0; + height: 100%; + } + + .bitfun-tabs__nav { + padding: 8px 12px 0; + border-bottom: 1px solid var(--border-base); + background: transparent; + } + + .bitfun-tabs__nav-list { + gap: 2px; + padding: 0; + border-radius: 0; + background: transparent; + } + + .bitfun-tabs__tab { + position: relative; + min-height: 30px; + padding: 0 10px 8px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 600; + letter-spacing: 0; + box-shadow: none; + + &::after { + content: ''; + position: absolute; + right: 10px; + bottom: -1px; + left: 10px; + height: 2px; + border-radius: 2px; + background: transparent; + } + + &:hover { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-soft) 56%, transparent); + } + + &.active, + &.bitfun-tabs__tab--active, + &[aria-selected='true'] { + color: var(--color-text-primary); + background: transparent; + + &::after { + background: color-mix(in srgb, var(--accent-primary) 72%, var(--color-text-primary)); + } + } + } + } + + &__tab-content { + box-sizing: border-box; + height: 100%; + overflow-y: auto; + padding: 12px 14px; + } + + &__body-copy, + &__body-markdown { + width: min(100%, 1120px); + max-width: 100%; + margin: 0 0 14px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.55; + } + + &__review-sessions { + max-width: 860px; + margin-bottom: 12px; + border: 1px solid var(--border-base); + border-radius: 7px; + overflow: hidden; + background: color-mix(in srgb, var(--color-bg-elevated) 62%, transparent); + } + + &__review-sessions-head { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 10px; + border-bottom: 1px solid var(--border-base); + background: color-mix(in srgb, var(--element-bg-soft) 42%, transparent); + + span:first-child { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + span:last-child { + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__review-session-list { + display: flex; + flex-direction: column; + } + + &__review-session { + width: 100%; + min-height: 42px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 18px; + align-items: center; + gap: 8px; + padding: 7px 10px; + border: 0; + border-bottom: 1px solid var(--border-base); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 58%, transparent); + } + + > svg { + color: var(--color-text-secondary); + } + } + + &__review-session--running &__review-session-icon { + color: var(--accent-primary); + } + + &__review-session--completed &__review-session-icon { + color: #3fb950; + } + + &__review-session--error &__review-session-icon { + color: #f85149; + } + + &__review-session-icon { + display: inline-flex; + align-items: center; + justify-content: center; + } + + &__review-session-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + strong, + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + span { + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__review-session-empty { + padding: 10px; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.5; + } + + &__body-markdown { + padding: 10px 12px; + border: 1px solid var(--border-base); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg-elevated) 64%, transparent); + + .markdown-renderer { + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.6; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + h1, + h2, + h3, + h4 { + margin: 12px 0 8px; + color: var(--color-text-primary); + font-size: 13px; + letter-spacing: 0; + } + + p, + ul, + ol, + blockquote, + pre { + margin: 8px 0; + } + } + } + + &__summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 8px; + + span { + min-height: 34px; + gap: 6px; + padding: 0 10px; + border: 1px solid var(--border-base); + border-radius: 7px; + background: color-mix(in srgb, var(--element-bg-soft) 66%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__additions { + color: #3fb950 !important; + } + + &__deletions { + color: #f85149 !important; + } + + &__file-list, + &__timeline, + &__threads { + display: flex; + flex-direction: column; + gap: 6px; + align-items: stretch; + } + + &__file-card { + flex: 0 0 auto; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg-elevated) 72%, transparent); + } + + &__file-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + min-height: 34px; + padding: 0 8px; + border-bottom: 1px solid var(--border-base); + background: color-mix(in srgb, var(--element-bg-soft) 42%, transparent); + } + + &__file-main { + min-width: 0; + display: grid; + grid-template-columns: 18px 72px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + min-height: 34px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 62%, transparent); + } + } + + &__file-add-button.btn { + min-height: 24px; + padding-inline: 8px; + flex-shrink: 0; + } + + &__file-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); + } + + &__file-status--added { + color: #3fb950; + background: rgba(63, 185, 80, 0.12); + } + + &__file-status--modified, + &__file-status--renamed { + color: #58a6ff; + background: rgba(88, 166, 255, 0.12); + } + + &__file-status--deleted { + color: #f85149; + background: rgba(248, 81, 73, 0.12); + } + + &__file-delta { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-variant-numeric: tabular-nums; + } + + &__diff-block { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + max-height: 360px; + overflow: auto; + padding: 8px 0; + background: color-mix(in srgb, var(--color-bg-primary) 72%, transparent); + color: var(--color-text-secondary); + font-family: var(--font-family-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', monospace); + font-size: 11px; + line-height: 1.55; + tab-size: 2; + } + + &__diff-line { + display: block; + min-height: 17px; + padding: 0 12px; + white-space: pre; + } + + &__diff-line--hunk { + color: #a371f7; + background: rgba(163, 113, 247, 0.1); + } + + &__diff-line--add { + color: #7ee787; + background: rgba(63, 185, 80, 0.1); + } + + &__diff-line--delete { + color: #ffa198; + background: rgba(248, 81, 73, 0.1); + } + + &__diff-line--meta { + color: #79c0ff; + } + + &__diff-empty { + padding: 10px 12px; + color: var(--color-text-secondary); + font-size: 11px; + } + + &__detail-error { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, #f85149 34%, var(--border-base)); + border-radius: 7px; + background: rgba(248, 81, 73, 0.08); + color: #f85149; + font-size: 11px; + + span { + min-width: 0; + flex: 1; + } + } + + &__timeline-item { + gap: 9px; + min-height: 42px; + padding: 7px 8px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-elevated) 72%, transparent); + + svg { + color: var(--color-text-secondary); + flex-shrink: 0; + } + + code { + margin-left: auto; + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__ci-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__ci-item { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 11px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-elevated) 72%, transparent); + } + + &__ci-item--passed { + border-color: color-mix(in srgb, #3fb950 28%, var(--border-base)); + background: color-mix(in srgb, #3fb950 8%, var(--color-bg-elevated)); + } + + &__ci-item--failed { + border-color: color-mix(in srgb, #f85149 30%, var(--border-base)); + background: color-mix(in srgb, #f85149 8%, var(--color-bg-elevated)); + } + + &__ci-item--pending { + border-color: color-mix(in srgb, #d29922 24%, var(--border-base)); + background: color-mix(in srgb, #d29922 7%, var(--color-bg-elevated)); + } + + &__ci-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + } + + &__ci-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + strong, + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + white-space: nowrap; + } + + span { + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.45; + white-space: normal; + } + } + + &__ci-status { + flex-shrink: 0; + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 6px; + border-radius: 5px; + font-size: 10px; + font-weight: 650; + line-height: 1.2; + } + + &__ci-log-chip { + flex-shrink: 0; + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 26%, var(--border-base)); + border-radius: 5px; + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 9%, transparent); + font-size: 10px; + font-weight: 650; + line-height: 1.2; + } + + &__ci-actions { + flex-shrink: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + } + + &__ci-action.icon-btn { + border-radius: 6px; + } + + &__ci-status--passed { + color: #3fb950; + background: rgba(63, 185, 80, 0.12); + } + + &__ci-status--failed { + color: #f85149; + background: rgba(248, 81, 73, 0.12); + } + + &__ci-status--pending { + color: #d29922; + background: rgba(210, 153, 34, 0.13); + } + + &__ci-meta { + display: flex; + flex-direction: column; + gap: 3px; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.4; + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__ci-log-panel { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, var(--border-base) 72%, transparent); + } + + &__ci-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + + div { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 48%, transparent); + } + + span { + display: block; + margin-bottom: 3px; + color: var(--color-text-tertiary); + font-size: 10px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + strong { + display: block; + overflow-wrap: anywhere; + color: var(--color-text-primary); + font-size: 11px; + font-weight: 500; + line-height: 1.45; + } + } + + &__ci-log-block { + max-height: 320px; + min-height: 90px; + overflow: auto; + margin: 0; + padding: 10px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-primary) 86%, transparent); + color: var(--color-text-primary); + font-family: var(--font-family-mono, ui-monospace, SFMono-Regular, Consolas, monospace); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + } + + &__ci-log-empty { + padding: 8px 10px; + border: 1px dashed var(--border-base); + border-radius: 6px; + color: var(--color-text-secondary); + background: color-mix(in srgb, var(--element-bg-soft) 48%, transparent); + font-size: 11px; + line-height: 1.45; + } + + &__thread { + padding: 10px; + border: 1px solid var(--border-base); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg-elevated) 76%, transparent); + + &.is-resolved { + opacity: 0.72; + } + + p { + margin: 8px 0; + color: var(--color-text-primary); + font-size: 12px; + line-height: 1.5; + } + } + + &__thread-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--border-base); + border-radius: 7px; + color: var(--color-text-secondary); + font-size: 12px; + background: color-mix(in srgb, var(--color-bg-elevated) 66%, transparent); + + svg { + animation: review-platform-spin 0.9s linear infinite; + } + } + + &__thread-loading--refreshing { + margin-bottom: 2px; + } + + &__thread--reply { + margin-left: 18px; + border-left-width: 3px; + border-left-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-base)); + } + + &__thread--review { + background: color-mix(in srgb, var(--accent-primary) 7%, var(--color-bg-elevated)); + } + + &__thread--comment { + background: color-mix(in srgb, var(--color-bg-elevated) 78%, transparent); + } + + &__thread-head { + justify-content: space-between; + gap: 12px; + color: var(--color-text-secondary); + font-size: 11px; + } + + &__thread-tags { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: wrap; + } + + &__thread-tag { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 7px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + text-transform: uppercase; + } + + &__thread-tag--review { + color: color-mix(in srgb, var(--accent-primary) 92%, var(--color-text-primary)); + border-color: color-mix(in srgb, var(--accent-primary) 20%, var(--border-base)); + background: color-mix(in srgb, var(--accent-primary) 10%, transparent); + } + + &__thread-tag--comment { + color: var(--color-text-secondary); + border-color: var(--border-base); + background: color-mix(in srgb, var(--element-bg-soft) 62%, transparent); + } + + &__thread-tag--resolved { + color: color-mix(in srgb, #3fb950 90%, var(--color-text-secondary)); + border-color: color-mix(in srgb, #3fb950 24%, var(--border-base)); + background: color-mix(in srgb, #3fb950 10%, transparent); + } + + &__thread-tag--open { + color: color-mix(in srgb, #f1c40f 82%, var(--color-text-secondary)); + border-color: color-mix(in srgb, #f1c40f 24%, var(--border-base)); + background: color-mix(in srgb, #f1c40f 10%, transparent); + } + + &__thread-meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + margin-top: 8px; + + strong { + min-width: 0; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + } + + &__thread-reply-block { + margin-top: 10px; + border: 1px solid var(--border-base); + border-radius: 6px; + overflow: hidden; + background: color-mix(in srgb, var(--element-bg-soft) 64%, transparent); + } + + &__thread-reply-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-base); + background: color-mix(in srgb, var(--color-bg-elevated) 76%, transparent); + } + + &__thread-reply-label { + color: var(--color-text-secondary); + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + text-transform: uppercase; + } + + &__thread-reply-author { + color: var(--color-text-primary); + font-size: 11px; + font-weight: 650; + } + + &__thread-reply-body { + min-width: 0; + padding: 10px 12px; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.45; + } + + &__thread-body { + min-width: 0; + margin-top: 8px; + color: var(--color-text-primary); + font-size: 12px; + line-height: 1.5; + } + + &__thread-reply-body, + &__thread-body { + :where(p, ul, ol, pre, blockquote) { + margin-top: 0; + } + + :where(p, ul, ol, pre, blockquote):last-child { + margin-bottom: 0; + } + + :where(pre) { + max-width: 100%; + overflow: auto; + border-radius: 6px; + } + } + + &__thread-anchor { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__loading { + padding: 18px; + color: var(--color-text-secondary); + font-size: 12px; + } + + @keyframes review-platform-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } + } + + @media (max-width: 980px) { + &__body { + grid-template-columns: 1fr; + } + + &__list { + max-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--border-base); + } + + &__summary-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + &__summary-strip { + gap: 6px; + } + + &__agent-link-panel { + align-items: flex-start; + flex-direction: column; + } + + &__agent-link-actions { + width: 100%; + flex-wrap: wrap; + } + } + + @container review-platform (max-width: 980px) { + &__body { + grid-template-columns: 1fr; + } + + &__list { + max-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--border-base); + } + + &__summary-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + &__summary-strip { + gap: 6px; + } + + &__agent-link-panel, + &__detail-header { + align-items: flex-start; + flex-direction: column; + } + + &__agent-link-actions, + &__detail-actions { + width: 100%; + flex-wrap: wrap; + justify-content: flex-start; + } + } + + @container review-platform (max-width: 560px) { + &__topbar-actions, + &__remote-select { + width: 100%; + } + + &__auth-gate { + flex-direction: column; + } + + &__auth-gate-actions { + width: 100%; + justify-content: flex-start; + } + + &__summary-grid { + grid-template-columns: 1fr; + } + + &__file-row { + align-items: stretch; + grid-template-columns: 1fr; + gap: 0; + padding-block: 4px; + } + + &__file-main { + grid-template-columns: 18px 68px minmax(0, 1fr); + min-height: 30px; + } + + &__file-delta { + grid-column: 3; + justify-self: start; + } + + &__file-add-button.btn { + justify-self: flex-start; + margin-left: 98px; + } + } +} 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 new file mode 100644 index 000000000..25080ac1b --- /dev/null +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -0,0 +1,2306 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ChevronDown, + ChevronLeft, + ChevronRight, + CheckCircle2, + CircleDot, + Clock3, + Code2, + GitCommitHorizontal, + GitPullRequest, + GitPullRequestClosed, + KeyRound, + Link2, + Loader2, + MessageSquareText, + RefreshCw, + Search, + ShieldCheck, + Sparkles, + Trash2, + UserRound, + XCircle, +} from 'lucide-react'; +import { Button, IconButton, Input, MarkdownRenderer, Modal, Select, Tabs, TabPane, Tooltip, type SelectOption } from '@/component-library'; +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 { openMainSession } from '@/flow_chat/services/openBtwSession'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { FlowToolItem, Session } from '@/flow_chat/types/flow-chat'; +import { findLatestCodeReviewResult, summarizeCodeReviewResult } from '@/flow_chat/utils/reviewSessionSummary'; +import { parsePullRequestUrl, remoteMatchesPullRequestLink } from '@/shared/utils/pullRequestLinks'; +import { useContextStore } from '@/shared/stores/contextStore'; +import type { PullRequestContext } from '@/shared/types/context'; +import './ReviewPlatformPanel.scss'; + +const log = createLogger('ReviewPlatformPanel'); + +interface ReviewPlatformPanelProps { + workspacePath?: string; + initialRemoteId?: string; + initialPullRequestId?: string; + initialPullRequestUrl?: string; + detailOnly?: boolean; +} + +type DetailTab = 'overview' | 'ci' | 'changes' | 'commits' | 'reviews'; +type ListStateFilter = 'all' | 'open' | 'draft' | 'merged' | 'closed'; +type SnapshotCacheState = 'none' | 'cached' | 'refreshing'; + +const PR_PAGE_SIZE = 10; +const CI_PAGE_SIZE = 20; +const CHANGE_PAGE_SIZE = 15; +const COMMIT_PAGE_SIZE = 30; +const REVIEW_PAGE_SIZE = 20; +const REMOTE_STORAGE_PREFIX = 'bitfun:review-platform:last-remote:'; +const MAX_LINKED_REVIEW_SESSIONS = 6; + +interface SnapshotCacheEntry { + snapshot: ReviewPlatformWorkspaceSnapshot; + fetchedAt: number; +} + +interface DetailCacheEntry { + detail: ReviewPlatformPullRequestDetail; + fetchedAt: number; +} + +interface DetailPageCacheEntry { + detail: ReviewPlatformPullRequestDetailPage; + fetchedAt: number; +} + +interface PageInfo { + pageIndex: number; + totalPages: number; + start: number; + end: number; + totalLabel: string; + hasNext: boolean; +} + +interface ReviewSessionMarkerInput { + childSessionId?: string; + parentSessionId?: string; + kind?: 'review' | 'deep_review'; + title?: string; + requestedFiles?: string[]; +} + +interface ReviewSessionMarker { + childSessionId: string; + parentSessionId?: string; + kind: 'review' | 'deep_review'; + title?: string; + requestedFiles: string[]; +} + +interface LinkedReviewSession { + childSession: Session; + parentSession?: Session; + marker?: ReviewSessionMarker; + kind: 'review' | 'deep_review'; + title: string; + requestedFiles: string[]; + issueCount: number; + riskLevel?: string; + lifecycle: 'running' | 'completed' | 'error' | 'idle'; + updatedAt: number; +} + +const snapshotCache = new Map(); +const detailCache = new Map(); +const detailPageCache = new Map(); +const EMPTY_REVIEW_THREADS: ReviewPlatformThread[] = []; + +function detailPageInfo(pagination: ReviewPlatformPagination, itemCount: number): PageInfo { + const pageIndex = Math.max(0, (pagination.page || 1) - 1); + const perPage = Math.max(1, pagination.perPage || itemCount || 1); + const total = pagination.total ?? null; + const totalPages = total !== null + ? Math.max(1, Math.ceil(total / perPage)) + : pageIndex + (pagination.hasNext ? 2 : 1); + const start = itemCount === 0 ? 0 : pageIndex * perPage + 1; + const end = total !== null + ? Math.min(total, pageIndex * perPage + itemCount) + : pageIndex * perPage + itemCount; + return { + pageIndex, + totalPages, + start, + end, + totalLabel: total !== null ? String(total) : `${end}+`, + hasNext: pagination.hasNext, + }; +} + +function snapshotCacheKey(workspacePath: string, remoteId: string | null, page: number, perPage: number): string { + return `${workspacePath}::${remoteId ?? 'default'}::${page}::${perPage}`; +} + +function detailCacheKey(workspacePath: string, remoteId: string, pullRequestId: string): string { + return `${workspacePath}::${remoteId}::${pullRequestId}`; +} + +function detailPageCacheKey(workspacePath: string, remoteId: string, pullRequestId: string, section: ReviewPlatformDetailSection, page: number, perPage: number): string { + return `${workspacePath}::${remoteId}::${pullRequestId}::${section}::${page}::${perPage}`; +} + +function clearDetailPageCacheForPullRequest(workspacePath: string, remoteId: string, pullRequestId: string): void { + const prefix = `${workspacePath}::${remoteId}::${pullRequestId}::`; + for (const key of detailPageCache.keys()) { + if (key.startsWith(prefix)) { + detailPageCache.delete(key); + } + } +} + +function emptyPagination(page: number, perPage: number): ReviewPlatformPagination { + return { page, perPage, total: null, hasNext: false }; +} + +function mergeDetailPage( + current: ReviewPlatformPullRequestDetail | null, + page: ReviewPlatformPullRequestDetailPage, +): ReviewPlatformPullRequestDetail { + const base = current ?? page; + return { + ...base, + ...page, + additions: page.additions || base.additions, + deletions: page.deletions || base.deletions, + changedFiles: page.changedFiles || base.changedFiles, + ci: page.section === 'ci' ? page.ci : base.ci, + files: page.section === 'files' ? page.files : base.files, + commits: page.section === 'commits' ? page.commits : base.commits, + threads: page.section === 'reviews' ? page.threads : base.threads, + }; +} + +function remotePreferenceKey(workspacePath: string): string { + return `${REMOTE_STORAGE_PREFIX}${workspacePath}`; +} + +function readRememberedRemote(workspacePath?: string): string | null { + if (!workspacePath || typeof window === 'undefined') return null; + try { + return window.localStorage.getItem(remotePreferenceKey(workspacePath)); + } catch { + return null; + } +} + +function rememberRemote(workspacePath: string | undefined, remoteId: string | null): void { + if (!workspacePath || typeof window === 'undefined') return; + try { + const key = remotePreferenceKey(workspacePath); + if (remoteId) { + window.localStorage.setItem(key, remoteId); + } else { + window.localStorage.removeItem(key); + } + } catch { + // Ignore storage failures; the selector still works for the current session. + } +} + +function formatRelativeTime(value: string): string { + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) return ''; + const diffMs = Date.now() - time; + const minutes = Math.max(1, Math.floor(diffMs / 60000)); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function formatAbsoluteTime(value: string): string { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +function getPrIcon(pr: ReviewPlatformPullRequest) { + if (pr.state === 'merged') return ; + if (pr.state === 'closed') return ; + return ; +} + +function decisionLabel(decision: ReviewPlatformPullRequest['reviewDecision']): string { + switch (decision) { + case 'approved': + return 'Approved'; + case 'changes_requested': + return 'Changes requested'; + case 'commented': + return 'Commented'; + default: + return 'Pending review'; + } +} + +function stateLabel(state: ReviewPlatformPullRequest['state']): string { + switch (state) { + case 'open': + return 'Open'; + case 'draft': + return 'Draft'; + case 'merged': + return 'Merged'; + case 'closed': + return 'Closed'; + default: + return state; + } +} + +function providerLabel(remote: ReviewPlatformRemote | ReviewPlatformAccount | null): string { + if (!remote) return 'No provider'; + switch (remote.platform) { + case 'github': + return 'GitHub'; + case 'gitlab': + return 'GitLab'; + case 'gitcode': + return 'GitCode'; + default: + return 'Git'; + } +} + +function remoteLabel(remote: ReviewPlatformRemote): string { + return `${providerLabel(remote)} · ${remote.name} · ${remote.projectPath}`; +} + +function authLabel(account: ReviewPlatformAccount | null): string { + if (!account) return 'Disconnected'; + switch (account.authState) { + case 'connected': + return 'Connected'; + case 'not_required': + return 'Public'; + case 'unsupported': + return 'Unsupported'; + case 'expired': + return 'Expired'; + case 'error': + return 'Auth error'; + default: + return 'Not connected'; + } +} + +function authSourceLabel(source: ReviewPlatformAccount['authSource'] | undefined): string { + switch (source) { + case 'stored': + return 'Saved token'; + case 'env': + return 'Environment token'; + case 'unsupported': + return 'Unsupported'; + default: + return 'No token'; + } +} + +function authChallengeTitle(challenge: ReviewPlatformAuthChallenge): string { + switch (challenge.state) { + case 'missing': + return 'Token required'; + case 'insufficient_scope': + return 'Token permissions required'; + default: + return 'Token update required'; + } +} + +function authChallengeScopes(challenge: ReviewPlatformAuthChallenge): string { + return challenge.requiredScopes.length ? challenge.requiredScopes.join(', ') : 'Provider API access'; +} + +function emptySnapshot(): ReviewPlatformWorkspaceSnapshot { + return { + remotes: [], + selectedRemoteId: null, + accounts: [], + repository: null, + pullRequests: [], + pagination: { + page: 1, + perPage: PR_PAGE_SIZE, + total: 0, + hasNext: false, + }, + capabilities: { + canCreateReview: false, + canCreatePullRequest: false, + canReplyToThread: false, + canResolveThread: false, + canApprove: false, + canRevokeApproval: false, + canRequestChanges: false, + canMerge: false, + supportsDraftReview: false, + }, + message: null, + authChallenge: null, + }; +} + +function diffLineClass(line: string): string { + if (line.startsWith('+++') || line.startsWith('---')) return 'review-platform__diff-line review-platform__diff-line--meta'; + if (line.startsWith('@@')) return 'review-platform__diff-line review-platform__diff-line--hunk'; + if (line.startsWith('+')) return 'review-platform__diff-line review-platform__diff-line--add'; + if (line.startsWith('-')) return 'review-platform__diff-line review-platform__diff-line--delete'; + return 'review-platform__diff-line'; +} + +function fileKey(file: { path: string; oldPath?: string | null }): string { + return `${file.oldPath ?? ''}->${file.path}`; +} + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/').trim(); +} + +function uniquePaths(paths: string[]): string[] { + const seen = new Set(); + const next: string[] = []; + for (const path of paths) { + const normalized = normalizePath(path); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + next.push(normalized); + } + return next; +} + +function pathsOverlap(left: string[], right: string[]): boolean { + if (!left.length || !right.length) return false; + const rightSet = new Set(right.map(normalizePath)); + return left.some(path => rightSet.has(normalizePath(path))); +} + +function isReviewSessionRunning(session: Session): boolean { + const turn = session.dialogTurns[session.dialogTurns.length - 1]; + return turn?.status === 'pending' || + turn?.status === 'image_analyzing' || + turn?.status === 'processing' || + turn?.status === 'finishing'; +} + +function reviewSessionLifecycle(session: Session): LinkedReviewSession['lifecycle'] { + const turn = session.dialogTurns[session.dialogTurns.length - 1]; + if (session.error || turn?.status === 'error') return 'error'; + if (isReviewSessionRunning(session)) return 'running'; + if (turn?.status === 'completed') return 'completed'; + return 'idle'; +} + +function getSessionTitle(session?: Session, fallback = 'Review session'): string { + return session?.title?.trim() || fallback; +} + +function extractReviewSessionMarkers(session: Session): ReviewSessionMarker[] { + const markers: ReviewSessionMarker[] = []; + for (const turn of session.dialogTurns) { + for (const round of turn.modelRounds) { + for (const item of round.items) { + if (item.type !== 'tool') continue; + const toolItem = item as FlowToolItem; + if (toolItem.toolName !== 'ReviewSessionSummary') continue; + const input = (toolItem.toolCall?.input ?? {}) as ReviewSessionMarkerInput; + if (!input.childSessionId) continue; + markers.push({ + childSessionId: input.childSessionId, + parentSessionId: input.parentSessionId ?? session.sessionId, + kind: input.kind === 'deep_review' ? 'deep_review' : 'review', + title: input.title, + requestedFiles: uniquePaths(input.requestedFiles ?? []), + }); + } + } + } + return markers; +} + +function buildPrChatPrompt(params: { + pr: ReviewPlatformPullRequest; + remote: ReviewPlatformRemote | null; + repository: ReviewPlatformRepositoryRef | null; + filePaths: string[]; + webUrl?: string; +}): string { + const fileList = params.filePaths.length + ? params.filePaths.map(path => `- ${path}`).join('\n') + : '- No file list is loaded yet'; + const provider = params.remote ? providerLabel(params.remote) : 'review platform'; + const repository = params.repository?.projectPath ?? params.remote?.projectPath ?? 'current repository'; + + return [ + `Review PR #${params.pr.number}: ${params.pr.title}`, + '', + `Provider: ${provider}`, + `Repository: ${repository}`, + `Branch: ${params.pr.sourceBranch} -> ${params.pr.targetBranch}`, + params.webUrl ? `URL: ${params.webUrl}` : null, + '', + 'Changed files:', + fileList, + '', + 'Please use this PR context with the current conversation. Focus on risks, review findings, and concrete fixes.', + ].filter(Boolean).join('\n'); +} + +function createContextId(prefix: string): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function formatChecksText(pr: ReviewPlatformPullRequest): string { + return pr.checks.total > 0 + ? `${pr.checks.passed}/${pr.checks.total} passed, ${pr.checks.failed} failed, ${pr.checks.pending} pending` + : 'No checks reported'; +} + +function buildPrOverviewContext(params: { + pr: ReviewPlatformPullRequest; + detail: ReviewPlatformPullRequestDetail | null; + remote: ReviewPlatformRemote | null; + repository: ReviewPlatformRepositoryRef | null; + filePaths: string[]; + reviewItemCount: number; + webUrl?: string; +}): string { + const body = params.detail?.body?.trim() || 'No pull request description was returned by the provider.'; + return [ + buildPrChatPrompt(params), + '', + 'Overview:', + body, + '', + `State: ${stateLabel(params.pr.state)}`, + `Review decision: ${decisionLabel(params.pr.reviewDecision)}`, + `Checks: ${formatChecksText(params.pr)}`, + `Comments: ${params.reviewItemCount}`, + ].join('\n'); +} + +function buildPrFileDiffContext(pr: ReviewPlatformPullRequest, file: ReviewPlatformFile): string { + return [ + `Pull request file diff: PR #${pr.number} ${pr.title}`, + `File: ${file.path}`, + file.oldPath && file.oldPath !== file.path ? `Old path: ${file.oldPath}` : null, + `Status: ${file.status}`, + `Delta: +${file.additions} -${file.deletions}`, + '', + 'Diff:', + file.patch?.trim() || 'No inline diff is available for this file.', + ].filter(Boolean).join('\n'); +} + +function buildPrCommitsContext(pr: ReviewPlatformPullRequest, commits: ReviewPlatformCommit[]): string { + if (!commits.length) { + return `Pull request commits: PR #${pr.number} ${pr.title}\n\nNo commits were returned by the provider.`; + } + return [ + `Pull request commits: PR #${pr.number} ${pr.title}`, + '', + ...commits.map(commit => [ + `- ${commit.shortHash} ${commit.title}`, + ` Author: ${commit.author}`, + ` Committed: ${formatAbsoluteTime(commit.committedAt) || commit.committedAt}`, + ` Hash: ${commit.hash}`, + ].join('\n')), + ].join('\n'); +} + +function buildPrReviewsContext(pr: ReviewPlatformPullRequest, threads: ReviewPlatformThread[]): string { + if (!threads.length) { + return `Pull request reviews: PR #${pr.number} ${pr.title}\n\nNo review threads were returned by the provider.`; + } + const threadByCommentId = new Map( + threads + .filter(thread => thread.providerCommentId) + .map(thread => [thread.providerCommentId as string, thread]), + ); + return [ + `Pull request reviews: PR #${pr.number} ${pr.title}`, + '', + ...threads.map(thread => [ + `- [${thread.kind === 'review' ? 'Review' : 'Comment'}] ${thread.resolved ? 'Resolved' : 'Open'} thread by ${thread.author}`, + thread.replyToProviderCommentId + ? ` Reply to: ${threadByCommentId.get(thread.replyToProviderCommentId)?.author ?? thread.replyToProviderCommentId}` + : null, + thread.filePath ? ` Location: ${thread.filePath}${thread.line ? `:${thread.line}` : ''}` : null, + ` Updated: ${formatAbsoluteTime(thread.updatedAt) || thread.updatedAt}`, + ` Body: ${thread.body}`, + ].filter(Boolean).join('\n')), + ].join('\n'); +} + +function ciItemTone(item: ReviewPlatformCiItem): 'passed' | 'failed' | 'pending' { + const raw = `${item.conclusion ?? item.status}`.trim().toLowerCase(); + if (['success', 'neutral', 'skipped', 'passed', 'pass'].includes(raw)) return 'passed'; + if (['failure', 'failed', 'error', 'timed_out', 'timed-out', 'cancelled', 'canceled', 'action_required'].includes(raw)) return 'failed'; + return 'pending'; +} + +function ciItemStatusText(item: ReviewPlatformCiItem): string { + const status = item.status.trim(); + const conclusion = item.conclusion?.trim(); + if (!conclusion || conclusion.toLowerCase() === status.toLowerCase()) { + return status || 'unknown'; + } + return `${status || 'unknown'} · ${conclusion}`; +} + +function buildPrCiContext(pr: ReviewPlatformPullRequest, ciItems: ReviewPlatformCiItem[]): string { + if (!ciItems.length) { + return `Pull request CI: PR #${pr.number} ${pr.title}\n\nNo CI entries were returned by the provider.`; + } + return [ + `Pull request CI page: PR #${pr.number} ${pr.title}`, + '', + `Checks: ${formatChecksText(pr)}`, + '', + ...ciItems.map(item => [ + `- ${item.name}`, + ` Status: ${ciItemStatusText(item)}`, + item.stage ? ` Stage: ${item.stage}` : null, + item.detail ? ` Detail: ${item.detail}` : null, + item.webUrl ? ` URL: ${item.webUrl}` : null, + item.startedAt ? ` Started: ${formatAbsoluteTime(item.startedAt) || item.startedAt}` : null, + item.finishedAt ? ` Finished: ${formatAbsoluteTime(item.finishedAt) || item.finishedAt}` : null, + ].filter(Boolean).join('\n')), + ].join('\n'); +} + +function buildPrCiItemContext(pr: ReviewPlatformPullRequest, item: ReviewPlatformCiItem, ciLog?: ReviewPlatformCiLog | null): string { + const hasLog = Boolean(ciLog?.log); + return [ + `Pull request CI result: PR #${pr.number} ${pr.title}`, + '', + `Checks: ${formatChecksText(pr)}`, + '', + `Name: ${item.name}`, + `Status: ${ciItemStatusText(item)}`, + item.conclusion ? `Conclusion: ${item.conclusion}` : null, + item.stage ? `Stage: ${item.stage}` : null, + item.detail ? `Detail: ${item.detail}` : null, + item.webUrl ? `URL: ${item.webUrl}` : null, + item.startedAt ? `Started: ${formatAbsoluteTime(item.startedAt) || item.startedAt}` : null, + item.finishedAt ? `Finished: ${formatAbsoluteTime(item.finishedAt) || item.finishedAt}` : null, + '', + hasLog ? 'Error log excerpt:' : 'Provider detail:', + hasLog + ? `${ciLog?.truncated ? '[Truncated error excerpt]\n' : ''}${ciLog?.log ?? ''}` + : ciLog?.message || item.detail || 'No additional provider detail has been loaded for this CI result.', + ].filter(Boolean).join('\n'); +} + +function canLoadCiLog(remote: ReviewPlatformRemote | null, _item: ReviewPlatformCiItem): boolean { + return Boolean(remote); +} + +function canExpandCiItem(remote: ReviewPlatformRemote | null, item: ReviewPlatformCiItem): boolean { + return canLoadCiLog(remote, item) || Boolean(item.log || item.detail || item.stage || item.webUrl || item.startedAt || item.finishedAt); +} + +export const ReviewPlatformPanel: React.FC = ({ + workspacePath, + initialRemoteId, + initialPullRequestId, + initialPullRequestUrl, + detailOnly = false, +}) => { + const snapshotRequestSeq = useRef(0); + const detailRequestSeq = useRef(0); + const detailSectionRequestSeq = useRef(0); + const [snapshot, setSnapshot] = useState(emptySnapshot); + const [selectedRemoteId, setSelectedRemoteId] = useState(null); + const [selectedPrId, setSelectedPrId] = useState(null); + const [detail, setDetail] = useState(null); + const [flowState, setFlowState] = useState(() => flowChatStore.getState()); + const [activeTab, setActiveTab] = useState('overview'); + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [error, setError] = useState(null); + const [query, setQuery] = useState(''); + const [stateFilter, setStateFilter] = useState('all'); + const [pageIndex, setPageIndex] = useState(0); + const [ciPageIndex, setCiPageIndex] = useState(0); + const [changePageIndex, setChangePageIndex] = useState(0); + const [commitPageIndex, setCommitPageIndex] = useState(0); + const [reviewPageIndex, setReviewPageIndex] = useState(0); + const [ciPagination, setCiPagination] = useState(() => emptyPagination(1, CI_PAGE_SIZE)); + const [changePagination, setChangePagination] = useState(() => emptyPagination(1, CHANGE_PAGE_SIZE)); + const [commitPagination, setCommitPagination] = useState(() => emptyPagination(1, COMMIT_PAGE_SIZE)); + const [reviewPagination, setReviewPagination] = useState(() => emptyPagination(1, REVIEW_PAGE_SIZE)); + const [expandedFileKeys, setExpandedFileKeys] = useState>(() => new Set()); + const [expandedCiItemIds, setExpandedCiItemIds] = useState>(() => new Set()); + const [ciLogById, setCiLogById] = useState>({}); + const [ciLogErrorById, setCiLogErrorById] = useState>({}); + const [ciLogLoadingIds, setCiLogLoadingIds] = useState>(() => new Set()); + const [snapshotCacheState, setSnapshotCacheState] = useState('none'); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [authToken, setAuthToken] = useState(''); + const [authSaving, setAuthSaving] = useState(false); + const [authError, setAuthError] = useState(null); + + const repository = snapshot.repository; + const account = snapshot.accounts[0] ?? null; + const selectedRemote = useMemo( + () => snapshot.remotes.find(remote => remote.id === selectedRemoteId) ?? snapshot.remotes[0] ?? null, + [selectedRemoteId, snapshot.remotes], + ); + const authChallenge = snapshot.authChallenge ?? null; + const selectedPrFromList = useMemo( + () => snapshot.pullRequests.find(pr => pr.id === selectedPrId) ?? null, + [selectedPrId, snapshot.pullRequests], + ); + const selectedPr = detail ?? selectedPrFromList; + const hasDetail = detail !== null; + const initialPullRequestTarget = useMemo( + () => initialPullRequestUrl ? parsePullRequestUrl(initialPullRequestUrl) : null, + [initialPullRequestUrl], + ); + const prFilePaths = useMemo( + () => uniquePaths((detail?.files ?? []).map(file => file.path)), + [detail?.files], + ); + const ciItems = useMemo(() => detail?.ci ?? [], [detail?.ci]); + const changedFiles = useMemo(() => detail?.files ?? [], [detail?.files]); + const commits = useMemo(() => detail?.commits ?? [], [detail?.commits]); + const reviewThreads = useMemo(() => detail?.threads ?? EMPTY_REVIEW_THREADS, [detail?.threads]); + const reviewThreadByCommentId = useMemo( + () => new Map( + reviewThreads + .filter(thread => thread.providerCommentId) + .map(thread => [thread.providerCommentId as string, thread]), + ), + [reviewThreads], + ); + const reviewItemCount = reviewPagination.total + ?? (reviewThreads.length > 0 ? reviewThreads.length : (selectedPr?.comments ?? 0)); + const ciTotal = ciPagination.total ?? ciItems.length; + const ciPage = detailPageInfo(ciPagination, ciTotal); + const changePage = detailPageInfo(changePagination, changedFiles.length); + const commitPage = detailPageInfo(commitPagination, commits.length); + const reviewPage = detailPageInfo(reviewPagination, reviewThreads.length); + const pagedCiItems = ciItems; + const pagedChangedFiles = changedFiles; + const pagedCommits = commits; + const pagedReviewThreads = reviewThreads; + const remoteOptions = useMemo( + () => snapshot.remotes.map(remote => ({ + value: remote.id, + label: remoteLabel(remote), + description: `${remote.host} · ${authLabel(account && account.id === remote.id ? account : null)}`, + })), + [account, snapshot.remotes], + ); + + const loadSnapshot = useCallback(async (nextRemoteId?: string | null, options?: { force?: boolean; page?: number }) => { + const requestSeq = ++snapshotRequestSeq.current; + if (!workspacePath) { + setSnapshot(emptySnapshot()); + setSelectedRemoteId(null); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setError('No active workspace is available.'); + setLoading(false); + return; + } + + const requestedRemoteId = nextRemoteId !== undefined ? nextRemoteId : readRememberedRemote(workspacePath); + const requestedPage = Math.max(1, options?.page ?? 1); + const requestedCacheKey = snapshotCacheKey(workspacePath, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE); + const cached = snapshotCache.get(requestedCacheKey); + const force = options?.force === true; + + if (cached && !force) { + const remoteId = cached.snapshot.selectedRemoteId ?? cached.snapshot.remotes[0]?.id ?? null; + setSnapshot(cached.snapshot); + setSelectedRemoteId(remoteId); + setPageIndex(Math.max(0, (cached.snapshot.pagination.page || requestedPage) - 1)); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setError(null); + setSnapshotCacheState('cached'); + setLoading(false); + return; + } else { + setSnapshot(emptySnapshot()); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setSnapshotCacheState('none'); + } + + setLoading(true); + setError(null); + try { + const next = await reviewPlatformAPI.getWorkspaceSnapshot(workspacePath, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE); + if (snapshotRequestSeq.current !== requestSeq) return; + setSnapshot(next); + const remoteId = next.selectedRemoteId ?? next.remotes[0]?.id ?? null; + setSelectedRemoteId(remoteId); + setPageIndex(Math.max(0, (next.pagination.page || requestedPage) - 1)); + rememberRemote(workspacePath, remoteId); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + const entry = { snapshot: next, fetchedAt: Date.now() }; + snapshotCache.set(requestedCacheKey, entry); + if (remoteId) { + snapshotCache.set(snapshotCacheKey(workspacePath, remoteId, requestedPage, PR_PAGE_SIZE), entry); + } + setSnapshotCacheState('cached'); + } catch (err) { + if (snapshotRequestSeq.current !== requestSeq) return; + const message = err instanceof Error ? err.message : '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) { + setLoading(false); + } + } + }, [workspacePath]); + + const loadDetail = useCallback(async (repo: ReviewPlatformRepositoryRef | null, remoteId: string, pullRequestId: string, options?: { force?: boolean }) => { + const requestSeq = ++detailRequestSeq.current; + detailSectionRequestSeq.current += 1; + const repositoryPath = workspacePath || repo?.workspacePath || ''; + const cacheKey = detailCacheKey(repositoryPath, remoteId, pullRequestId); + const cached = detailCache.get(cacheKey); + const force = options?.force === true; + + setDetailError(null); + if (force) { + detailCache.delete(cacheKey); + clearDetailPageCacheForPullRequest(repositoryPath, remoteId, pullRequestId); + } + + if (cached && !force) { + setDetail(cached.detail); + setDetailLoading(false); + return; + } else { + setDetail(null); + } + + setDetailLoading(true); + try { + const nextDetail = await reviewPlatformAPI.getPullRequestDetailPage({ + repositoryPath, + remoteId, + pullRequestId, + section: 'overview', + page: 1, + perPage: 1, + }); + if (detailRequestSeq.current !== requestSeq) return; + setDetail(nextDetail); + detailCache.set(cacheKey, { detail: nextDetail, fetchedAt: Date.now() }); + } catch (err) { + if (detailRequestSeq.current !== requestSeq) return; + log.error('Failed to load pull request detail', { pullRequestId, error: err }); + setDetailError(err instanceof Error ? err.message : 'Failed to load pull request details.'); + if (!cached) { + setDetail(null); + } + } finally { + if (detailRequestSeq.current === requestSeq) { + setDetailLoading(false); + } + } + }, [workspacePath]); + + const applySectionPagination = useCallback((section: Exclude, pagination: ReviewPlatformPagination) => { + if (section === 'ci') { + setCiPagination(pagination); + } else if (section === 'files') { + setChangePagination(pagination); + } else if (section === 'commits') { + setCommitPagination(pagination); + } else { + setReviewPagination(pagination); + } + }, []); + + const loadDetailSection = useCallback(async ( + repo: ReviewPlatformRepositoryRef | null, + remoteId: string, + pullRequestId: string, + section: Exclude, + pageIndex: number, + perPage: number, + options?: { force?: boolean }, + ) => { + const repositoryPath = workspacePath || repo?.workspacePath || ''; + const page = Math.max(1, pageIndex + 1); + const cacheKey = detailPageCacheKey(repositoryPath, remoteId, pullRequestId, section, page, perPage); + const cached = detailPageCache.get(cacheKey); + const force = options?.force === true; + + if (cached && !force) { + setDetail(prev => mergeDetailPage(prev, cached.detail)); + applySectionPagination(section, cached.detail.pagination); + return; + } + + const requestSeq = ++detailSectionRequestSeq.current; + setDetailLoading(true); + setDetailError(null); + try { + const nextPage = await reviewPlatformAPI.getPullRequestDetailPage({ + repositoryPath, + remoteId, + pullRequestId, + section, + page, + perPage, + }); + if (detailSectionRequestSeq.current !== requestSeq) return; + detailPageCache.set(cacheKey, { detail: nextPage, fetchedAt: Date.now() }); + setDetail(prev => mergeDetailPage(prev, nextPage)); + applySectionPagination(section, nextPage.pagination); + } catch (err) { + if (detailSectionRequestSeq.current !== requestSeq) return; + log.error('Failed to load pull request detail section', { pullRequestId, section, page, perPage, error: err }); + setDetailError(err instanceof Error ? err.message : 'Failed to load pull request details.'); + } finally { + if (detailSectionRequestSeq.current === requestSeq) { + setDetailLoading(false); + } + } + }, [applySectionPagination, workspacePath]); + + useEffect(() => { + void loadSnapshot(detailOnly && initialRemoteId ? initialRemoteId : undefined); + }, [detailOnly, initialRemoteId, loadSnapshot]); + + useEffect(() => flowChatStore.subscribe(setFlowState), []); + + useEffect(() => { + if (!selectedRemoteId) { + setDetail(null); + setDetailError(null); + return; + } + if (!selectedPrId || (!repository && !workspacePath)) { + setDetail(null); + setDetailError(null); + return; + } + void loadDetail(repository, selectedRemoteId, selectedPrId); + }, [loadDetail, repository, selectedPrId, selectedRemoteId, workspacePath]); + + useEffect(() => { + if (!snapshot.remotes.length) return; + if (!selectedRemoteId && snapshot.selectedRemoteId) { + setSelectedRemoteId(snapshot.selectedRemoteId); + } + }, [selectedRemoteId, snapshot.remotes.length, snapshot.selectedRemoteId]); + + useEffect(() => { + if (!detailOnly) return; + const targetPullRequestId = initialPullRequestId ?? initialPullRequestTarget?.pullRequestId ?? null; + if (!targetPullRequestId) { + if (initialPullRequestUrl) { + setDetailError('This link is not a supported pull request URL.'); + } + return; + } + + const matchedRemote = initialRemoteId + ? snapshot.remotes.find(remote => remote.id === initialRemoteId) ?? null + : initialPullRequestTarget + ? snapshot.remotes.find(remote => remoteMatchesPullRequestLink(remote, initialPullRequestTarget)) ?? null + : null; + const nextRemoteId = initialRemoteId + ?? matchedRemote?.id + ?? (snapshot.remotes.length === 1 ? snapshot.remotes[0].id : null) + ?? snapshot.selectedRemoteId + ?? selectedRemoteId; + + if (nextRemoteId && selectedRemoteId !== nextRemoteId) { + setSelectedRemoteId(nextRemoteId); + rememberRemote(workspacePath, nextRemoteId); + } + + if (selectedPrId !== targetPullRequestId) { + setSelectedPrId(targetPullRequestId); + } + }, [ + detailOnly, + initialPullRequestId, + initialPullRequestTarget, + initialPullRequestUrl, + initialRemoteId, + selectedPrId, + selectedRemoteId, + snapshot.remotes, + snapshot.selectedRemoteId, + workspacePath, + ]); + + useEffect(() => { + setExpandedFileKeys(new Set()); + setExpandedCiItemIds(new Set()); + setCiLogById({}); + setCiLogErrorById({}); + setCiLogLoadingIds(new Set()); + setCiPageIndex(0); + setChangePageIndex(0); + setCommitPageIndex(0); + setReviewPageIndex(0); + setCiPagination(emptyPagination(1, CI_PAGE_SIZE)); + setChangePagination(emptyPagination(1, CHANGE_PAGE_SIZE)); + setCommitPagination(emptyPagination(1, COMMIT_PAGE_SIZE)); + setReviewPagination(emptyPagination(1, REVIEW_PAGE_SIZE)); + }, [selectedPrId]); + + useEffect(() => { + if (activeTab !== 'changes' || changedFiles.length === 0 || expandedFileKeys.size > 0) return; + setExpandedFileKeys(new Set(changedFiles.slice(0, 1).map(fileKey))); + }, [activeTab, changedFiles, expandedFileKeys.size]); + + useEffect(() => { + if (!hasDetail || !selectedRemoteId || !selectedPrId || (!repository && !workspacePath)) return; + if (activeTab === 'ci') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'ci', ciPageIndex, CI_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); + } else if (activeTab === 'reviews') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'reviews', reviewPageIndex, REVIEW_PAGE_SIZE); + } + }, [ + activeTab, + ciPageIndex, + changePageIndex, + commitPageIndex, + hasDetail, + loadDetailSection, + repository, + reviewPageIndex, + selectedPrId, + selectedRemoteId, + workspacePath, + ]); + + const visiblePullRequests = useMemo(() => { + const needle = query.trim().toLowerCase(); + return snapshot.pullRequests.filter(pr => { + if (stateFilter !== 'all' && pr.state !== stateFilter) return false; + if (!needle) return true; + return [ + pr.title, + pr.author, + pr.sourceBranch, + pr.targetBranch, + `#${pr.number}`, + ].some(value => value.toLowerCase().includes(needle)); + }); + }, [query, snapshot.pullRequests, stateFilter]); + + const parentSession = useMemo(() => { + const sessions = Array.from(flowState.sessions.values()); + const activeSession = flowState.activeSessionId + ? flowState.sessions.get(flowState.activeSessionId) + : undefined; + const sameWorkspace = (session?: Session) => + Boolean(session && (!workspacePath || normalizePath(session.workspacePath ?? '') === normalizePath(workspacePath))); + + if (activeSession?.sessionKind === 'normal' && sameWorkspace(activeSession)) { + return activeSession; + } + + if ( + activeSession && + (activeSession.sessionKind === 'review' || activeSession.sessionKind === 'deep_review') && + activeSession.parentSessionId + ) { + const parent = flowState.sessions.get(activeSession.parentSessionId); + if (parent?.sessionKind === 'normal' && sameWorkspace(parent)) { + return parent; + } + } + + return sessions + .filter(session => session.sessionKind === 'normal' && sameWorkspace(session)) + .sort((left, right) => (right.lastActiveAt || right.updatedAt || right.createdAt) - (left.lastActiveAt || left.updatedAt || left.createdAt))[0]; + }, [flowState.activeSessionId, flowState.sessions, workspacePath]); + + const linkedReviewSessions = useMemo(() => { + const sessions = Array.from(flowState.sessions.values()); + const markersByChildId = new Map(); + for (const session of sessions) { + for (const marker of extractReviewSessionMarkers(session)) { + markersByChildId.set(marker.childSessionId, marker); + } + } + + const selectedPaths = prFilePaths; + const sameWorkspace = (session: Session) => + !workspacePath || normalizePath(session.workspacePath ?? '') === normalizePath(workspacePath); + + return sessions + .filter(session => + (session.sessionKind === 'review' || session.sessionKind === 'deep_review') && + sameWorkspace(session), + ) + .map((session): LinkedReviewSession | null => { + const marker = markersByChildId.get(session.sessionId); + const requestedFiles = marker?.requestedFiles ?? []; + if (selectedPaths.length > 0 && requestedFiles.length > 0 && !pathsOverlap(selectedPaths, requestedFiles)) { + return null; + } + + const reviewResult = findLatestCodeReviewResult(session); + const summary = summarizeCodeReviewResult(reviewResult); + const kind = session.sessionKind === 'deep_review' ? 'deep_review' : 'review'; + return { + childSession: session, + parentSession: marker?.parentSessionId ? flowState.sessions.get(marker.parentSessionId) : undefined, + marker, + kind, + title: marker?.title || getSessionTitle(session, kind === 'deep_review' ? 'Deep review' : 'Code review'), + requestedFiles, + issueCount: summary.issueCount, + riskLevel: summary.riskLevel, + lifecycle: reviewSessionLifecycle(session), + updatedAt: session.lastActiveAt || session.updatedAt || session.createdAt, + }; + }) + .filter((session): session is LinkedReviewSession => Boolean(session)) + .sort((left, right) => right.updatedAt - left.updatedAt) + .slice(0, MAX_LINKED_REVIEW_SESSIONS); + }, [flowState.sessions, prFilePaths, workspacePath]); + + const pagination = snapshot.pagination; + const totalCount = pagination.total ?? null; + const currentPageIndex = Math.max(0, (pagination.page || pageIndex + 1) - 1); + const totalPages = totalCount !== null + ? Math.max(1, Math.ceil(totalCount / pagination.perPage)) + : currentPageIndex + (pagination.hasNext ? 2 : 1); + const pageStart = snapshot.pullRequests.length ? currentPageIndex * pagination.perPage + 1 : 0; + const pageEnd = totalCount !== null + ? Math.min(totalCount, currentPageIndex * pagination.perPage + snapshot.pullRequests.length) + : currentPageIndex * pagination.perPage + snapshot.pullRequests.length; + + const summary = useMemo(() => { + const prs = snapshot.pullRequests; + return { + open: prs.filter(pr => pr.state === 'open').length, + draft: prs.filter(pr => pr.state === 'draft').length, + merged: prs.filter(pr => pr.state === 'merged').length, + reviewRequired: prs.filter(pr => pr.reviewDecision === 'changes_requested' || pr.reviewDecision === 'pending').length, + }; + }, [snapshot.pullRequests]); + + const headerLabel = selectedRemote ? remoteLabel(selectedRemote) : repository ? repository.projectPath : 'No repository'; + const panelTitle = detailOnly ? 'Pull Request' : 'Pull Requests'; + + const handleRemoteChange = useCallback((value: string | number | (string | number)[]) => { + const remoteId = Array.isArray(value) ? String(value[0] ?? '') : String(value); + setSelectedRemoteId(remoteId || null); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setPageIndex(0); + rememberRemote(workspacePath, remoteId || null); + void loadSnapshot(remoteId || null, { page: 1 }); + }, [loadSnapshot, workspacePath]); + + const handlePageChange = useCallback((nextPageIndex: number) => { + const nextPage = Math.max(1, nextPageIndex + 1); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setPageIndex(nextPage - 1); + void loadSnapshot(selectedRemoteId, { page: nextPage }); + }, [loadSnapshot, selectedRemoteId]); + + const toggleFileExpanded = useCallback((key: string) => { + setExpandedFileKeys(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const renderDetailPagination = useCallback(( + label: string, + page: PageInfo, + itemCount: number, + onPageChange: (nextPageIndex: number) => void, + ) => { + if (itemCount <= 0 || (page.totalPages <= 1 && !page.hasNext && page.pageIndex === 0)) return null; + return ( +
+ onPageChange(page.pageIndex - 1)} + > + + + + {label}: {page.start}-{page.end} of {page.totalLabel} + + = page.totalPages - 1} + onClick={() => onPageChange(page.pageIndex + 1)} + > + + +
+ ); + }, []); + + const renderDetailLoading = useCallback((message: string, refreshing = false) => ( +
+ + {message} +
+ ), []); + + const handleOpenExternal = useCallback(async () => { + const webUrl = selectedPr?.webUrl || initialPullRequestUrl; + if (!webUrl) return; + try { + await systemAPI.openExternal(webUrl); + } catch (error) { + log.error('Failed to open pull request URL', { error, webUrl }); + } + }, [initialPullRequestUrl, selectedPr?.webUrl]); + + const handleOpenCiUrl = useCallback(async (webUrl?: string | null) => { + if (!webUrl) return; + try { + await systemAPI.openExternal(webUrl); + } catch (error) { + log.error('Failed to open CI URL', { error, webUrl }); + } + }, []); + + const loadCiLog = useCallback(async (item: ReviewPlatformCiItem): Promise => { + const cached = ciLogById[item.id]; + if (cached) return cached; + if (!canLoadCiLog(selectedRemote, item)) { + return { + ciItemId: item.id, + log: item.log ?? null, + truncated: item.logTruncated, + message: item.detail || null, + }; + } + if ((!repository && !workspacePath) || !selectedRemoteId || !selectedPrId) return null; + + const repositoryPath = workspacePath || repository?.workspacePath || ''; + setCiLogLoadingIds(prev => { + const next = new Set(prev); + next.add(item.id); + return next; + }); + setCiLogErrorById(prev => { + const next = { ...prev }; + delete next[item.id]; + return next; + }); + + try { + const nextLog = await reviewPlatformAPI.getPullRequestCiLog({ + repositoryPath, + remoteId: selectedRemoteId, + pullRequestId: selectedPrId, + ciItemId: item.id, + ciItemName: item.name, + }); + setCiLogById(prev => ({ ...prev, [item.id]: nextLog })); + return nextLog; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load CI error log.'; + setCiLogErrorById(prev => ({ ...prev, [item.id]: message })); + log.error('Failed to load CI log', { itemId: item.id, error: err }); + return null; + } finally { + setCiLogLoadingIds(prev => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + } + }, [ciLogById, repository, selectedPrId, selectedRemote, selectedRemoteId, workspacePath]); + + const toggleCiExpanded = useCallback((item: ReviewPlatformCiItem) => { + if (expandedCiItemIds.has(item.id)) { + setExpandedCiItemIds(prev => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + return; + } + + setExpandedCiItemIds(prev => { + const next = new Set(prev); + next.add(item.id); + return next; + }); + if (canLoadCiLog(selectedRemote, item) || item.log) { + void loadCiLog(item); + } + }, [expandedCiItemIds, loadCiLog, selectedRemote]); + + const handleOpenParentChat = useCallback(async () => { + if (!parentSession) { + notificationService.warning('Open or create a chat session before linking PR context.', { duration: 3500 }); + return; + } + await openMainSession(parentSession.sessionId); + }, [parentSession]); + + const addPullRequestContextToChat = useCallback(async (input: { + label: string; + section: PullRequestContext['section']; + content: string; + metadata?: Record; + }) => { + if (!parentSession) { + notificationService.warning('Open or create a chat session before sending PR context.', { duration: 3500 }); + return; + } + + await openMainSession(parentSession.sessionId); + const context: PullRequestContext = { + id: createContextId('pr'), + type: 'pull-request', + label: input.label, + section: input.section, + content: input.content, + metadata: input.metadata, + timestamp: Date.now(), + sourceUrl: selectedPr?.webUrl || initialPullRequestUrl, + remoteId: selectedRemote?.id, + repository: repository?.projectPath ?? selectedRemote?.projectPath, + pullRequestNumber: selectedPr?.number, + pullRequestTitle: selectedPr?.title, + }; + + useContextStore.getState().addContext(context); + window.dispatchEvent(new CustomEvent('insert-context-tag', { detail: { context } })); + }, [initialPullRequestUrl, parentSession, repository?.projectPath, selectedPr, selectedRemote?.id, selectedRemote?.projectPath]); + + const handleFillPrContext = useCallback(async () => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} overview`, + section: 'overview', + content: buildPrOverviewContext({ + pr: selectedPr, + detail, + remote: selectedRemote, + repository, + filePaths: prFilePaths, + reviewItemCount, + webUrl: selectedPr.webUrl, + }), + }); + }, [addPullRequestContextToChat, detail, prFilePaths, repository, reviewItemCount, selectedPr, selectedRemote]); + + const handleAddFileDiffContext = useCallback(async (file: ReviewPlatformFile) => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} ${file.path}`, + section: 'file-diff', + content: buildPrFileDiffContext(selectedPr, file), + }); + }, [addPullRequestContextToChat, selectedPr]); + + const handleAddCommitsContext = useCallback(async () => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} commits`, + section: 'commits', + content: buildPrCommitsContext(selectedPr, detail?.commits ?? []), + }); + }, [addPullRequestContextToChat, detail?.commits, selectedPr]); + + const handleAddReviewsContext = useCallback(async () => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} reviews`, + section: 'reviews', + content: buildPrReviewsContext(selectedPr, detail?.threads ?? []), + }); + }, [addPullRequestContextToChat, detail?.threads, selectedPr]); + + const handleAddCiPageContext = useCallback(async () => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} CI page`, + section: 'ci', + content: buildPrCiContext(selectedPr, detail?.ci ?? []), + }); + }, [addPullRequestContextToChat, detail?.ci, selectedPr]); + + const handleAddCiItemContext = useCallback(async (item: ReviewPlatformCiItem) => { + if (!selectedPr) return; + const ciLog = ciLogById[item.id] ?? await loadCiLog(item); + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} CI · ${item.name}`, + section: 'ci', + content: buildPrCiItemContext(selectedPr, item, ciLog), + metadata: { + ciItemId: item.id, + ciItemName: item.name, + ciItemStatus: item.status, + ciItemConclusion: item.conclusion, + ciItemStage: item.stage, + ciLogTruncated: ciLog?.truncated ?? false, + }, + }); + }, [addPullRequestContextToChat, ciLogById, loadCiLog, selectedPr]); + + const refreshAuthSnapshot = useCallback((remoteId: string | null) => { + snapshotCache.clear(); + detailCache.clear(); + detailPageCache.clear(); + void loadSnapshot(remoteId, { force: true, page: currentPageIndex + 1 }); + }, [currentPageIndex, loadSnapshot]); + + const handleOpenAuthModal = useCallback(() => { + setAuthToken(''); + setAuthError(null); + setAuthModalOpen(true); + }, []); + + const handleSaveAuthToken = useCallback(async () => { + if (!selectedRemote || selectedRemote.platform === 'unknown') return; + const token = authToken.trim(); + if (!token) { + setAuthError('Token is required.'); + return; + } + + setAuthSaving(true); + setAuthError(null); + try { + await reviewPlatformAPI.updateAuthToken({ + platform: selectedRemote.platform, + host: selectedRemote.host, + token, + }); + setAuthModalOpen(false); + setAuthToken(''); + refreshAuthSnapshot(selectedRemote.id); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save token.'; + setAuthError(message); + log.error('Failed to save review platform token', { error: err, host: selectedRemote.host }); + } finally { + setAuthSaving(false); + } + }, [authToken, refreshAuthSnapshot, selectedRemote]); + + const handleClearAuthToken = useCallback(async () => { + if (!selectedRemote || selectedRemote.platform === 'unknown') return; + setAuthSaving(true); + setAuthError(null); + try { + await reviewPlatformAPI.clearAuthToken({ + platform: selectedRemote.platform, + host: selectedRemote.host, + }); + refreshAuthSnapshot(selectedRemote.id); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to clear token.'; + setAuthError(message); + setAuthModalOpen(true); + log.error('Failed to clear review platform token', { error: err, host: selectedRemote.host }); + } finally { + setAuthSaving(false); + } + }, [refreshAuthSnapshot, selectedRemote]); + + const renderAuthGate = useCallback((mode: 'inline' | 'detail' = 'inline') => { + if (!authChallenge || !selectedRemote || selectedRemote.platform === 'unknown') return null; + return ( +
+
+ +
+
+ {authChallengeTitle(authChallenge)} + {authChallenge.message} + {authChallenge.host} · {authChallenge.projectPath} + Required scopes: {authChallengeScopes(authChallenge)} +
+
+ + +
+
+ ); + }, [authChallenge, authSaving, handleOpenAuthModal, loading, refreshAuthSnapshot, selectedRemote]); + + const handleRetryDetail = useCallback(() => { + if ((!repository && !workspacePath) || !selectedRemoteId || !selectedPrId) return; + if (activeTab === 'ci') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'ci', ciPageIndex, CI_PAGE_SIZE, { force: true }); + return; + } + if (activeTab === 'changes') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'files', changePageIndex, CHANGE_PAGE_SIZE, { force: true }); + return; + } + if (activeTab === 'commits') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'commits', commitPageIndex, COMMIT_PAGE_SIZE, { force: true }); + return; + } + if (activeTab === 'reviews') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'reviews', reviewPageIndex, REVIEW_PAGE_SIZE, { force: true }); + return; + } + void loadDetail(repository, selectedRemoteId, selectedPrId, { force: true }); + }, [ + activeTab, + changePageIndex, + commitPageIndex, + loadDetail, + loadDetailSection, + ciPageIndex, + repository, + reviewPageIndex, + selectedPrId, + selectedRemoteId, + workspacePath, + ]); + + const handleRefreshDetail = useCallback(() => { + if ((!repository && !workspacePath) || !selectedRemoteId || !selectedPrId) return; + if (activeTab === 'ci') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'ci', ciPageIndex, CI_PAGE_SIZE, { force: true }); + return; + } + void loadDetail(repository, selectedRemoteId, selectedPrId, { force: true }); + }, [activeTab, ciPageIndex, loadDetail, loadDetailSection, repository, selectedPrId, selectedRemoteId, workspacePath]); + + const remoteStatus = selectedRemote + ? `${providerLabel(selectedRemote)} · ${authLabel(account)}` + : 'No remote detected'; + const displayPr = detail ?? selectedPr; + const checksText = displayPr && displayPr.checks.total > 0 + ? `${displayPr.checks.passed}/${displayPr.checks.total}` + : 'N/A'; + const emptyStateMessage = snapshot.message + || account?.message + || selectedRemote?.message + || (snapshot.remotes.length ? 'No pull requests match the current filter.' : 'No supported remotes were detected.'); + const loadingLabel = loading + ? snapshotCacheState === 'refreshing' + ? 'Refreshing cached pull requests...' + : 'Loading pull requests...' + : snapshotCacheState === 'cached' + ? 'Cached pull requests' + : null; + const parentSessionLabel = parentSession ? getSessionTitle(parentSession, 'Current chat') : 'No chat session linked'; + + return ( +
+ {!detailOnly && ( +
+
+ +
+ {panelTitle} + {headerLabel} +
+
+ +
+
+ setQuery(event.target.value)} + placeholder="Search pull requests" + prefix={} + suffix={query ? setQuery('')}> : undefined} + /> +
+ {(['all', 'open', 'draft', 'merged', 'closed'] as ListStateFilter[]).map(state => ( + + ))} +
+
+ +
+ {loading && ( +
Loading pull requests...
+ )} + {error && ( +
+ + {error} + +
+ )} + {!loading && !error && !authChallenge && !visiblePullRequests.length && ( +
+ + {emptyStateMessage} +
+ )} + {!loading && !error && visiblePullRequests.map(pr => ( + (() => { + return ( + + ); + })() + ))} +
+ {!loading && !error && (totalPages > 1 || pagination.hasNext) && ( +
+ handlePageChange(currentPageIndex - 1)} + > + + + + {pageStart}-{pageEnd} of {totalCount ?? `${pageEnd}+`} + + = totalPages - 1} + onClick={() => handlePageChange(currentPageIndex + 1)} + > + + +
+ )} + + )} + +
+ {!selectedPr && detailOnly && (loading || detailLoading) && ( +
+ + Loading pull request details... +
+ )} + + {!selectedPr && detailOnly && !loading && !detailLoading && authChallenge && ( +
+ {renderAuthGate('detail')} +
+ )} + + {!selectedPr && detailOnly && !loading && !detailLoading && !authChallenge && (detailError || error) && ( +
+ + {detailError || error} +
+ + {selectedRemote && selectedRemote.platform !== 'unknown' && ( + + )} +
+
+ )} + + {!selectedPr && !detailOnly && !loading && ( +
+ + Select a pull request to inspect it. +
+ )} + + {selectedPr && ( + <> +
+
+
+ {getPrIcon(selectedPr)} +

{selectedPr.title}

+
+
+ #{selectedPr.number} + {selectedPr.author} + {formatAbsoluteTime(selectedPr.updatedAt) || formatRelativeTime(selectedPr.updatedAt)} + {selectedPr.sourceBranch} → {selectedPr.targetBranch} +
+
+
+ + + + {detailOnly && selectedRemote && selectedRemote.platform !== 'unknown' && ( + + )} + + +
+
+ +
+
+ Files + {displayPr?.changedFiles ?? selectedPr.changedFiles} +
+
+ Additions + +{displayPr?.additions ?? selectedPr.additions} +
+
+ Deletions + -{displayPr?.deletions ?? selectedPr.deletions} +
+
+ Checks + {checksText} +
+
+ +
+
+ Conversation link + {parentSessionLabel} + + {prFilePaths.length} changed files + {linkedReviewSessions.length > 0 ? ` · ${linkedReviewSessions.length} related review sessions` : ''} + +
+
+ + +
+
+ + setActiveTab(key as DetailTab)} + type="pill" + size="small" + className="review-platform__tabs" + > + +
+
+
+ Overview + +
+ {detailError ? ( +
+ + {detailError} + +
+ ) : detail?.body ? ( + + ) : ( + renderDetailLoading('Loading pull request summary...') + )} +
+
+ State: {stateLabel(displayPr?.state ?? selectedPr.state)} + + {decisionLabel(displayPr?.reviewDecision ?? selectedPr.reviewDecision)} + + + {selectedRemote ? providerLabel(selectedRemote) : 'Unknown provider'} + +
+
+ {checksText} checks + {reviewItemCount} review items + {displayPr?.author ?? selectedPr.author} + {formatAbsoluteTime(displayPr?.updatedAt ?? selectedPr.updatedAt)} +
+
+
+ + +
+
+ CI + + {ciTotal ? `${ciTotal} items · ${checksText}` : checksText} + + +
+ {detailError && ( +
+ + {detailError} + +
+ )} + {detailLoading && renderDetailLoading(pagedCiItems.length ? 'Refreshing CI...' : 'Loading CI...', pagedCiItems.length > 0)} + {pagedCiItems.map(item => { + const tone = ciItemTone(item); + const isCiExpanded = expandedCiItemIds.has(item.id); + const ciLog = ciLogById[item.id]; + const ciLogLoading = ciLogLoadingIds.has(item.id); + const ciLogError = ciLogErrorById[item.id]; + const logAvailable = canLoadCiLog(selectedRemote, item); + const expandable = canExpandCiItem(selectedRemote, item); + return ( +
+
+
+ {item.name} + + {[item.detail, item.stage].filter(Boolean).join(' · ') || (item.webUrl ? 'Provider details available' : 'No extra details provided')} + +
+
+ {ciLog && logAvailable && ( + + {ciLog.log ? (ciLog.truncated ? 'Errors truncated' : 'Errors loaded') : 'No errors'} + + )} + + {ciItemStatusText(item)} + + {expandable && ( + toggleCiExpanded(item)} + disabled={ciLogLoading} + aria-busy={ciLogLoading} + > + {isCiExpanded ? : } + + )} + void handleAddCiItemContext(item)} + disabled={!selectedPr} + > + + + {item.webUrl && ( + void handleOpenCiUrl(item.webUrl)} + > + + + )} +
+
+ {(item.startedAt || item.finishedAt) && ( +
+ {item.startedAt && Started: {formatAbsoluteTime(item.startedAt) || item.startedAt}} + {item.finishedAt && Finished: {formatAbsoluteTime(item.finishedAt) || item.finishedAt}} +
+ )} + {isCiExpanded && ( +
+
+ {item.stage && ( +
+ Stage + {item.stage} +
+ )} + {item.detail && ( +
+ Detail + {item.detail} +
+ )} + {item.webUrl && ( +
+ URL + {item.webUrl} +
+ )} +
+ {ciLogLoading && renderDetailLoading('Loading CI details...')} + {!ciLogLoading && ciLogError && logAvailable && ( +
+ + {ciLogError} + +
+ )} + {!ciLogLoading && !ciLogError && (ciLog?.log || item.log) && ( +
{ciLog?.log || item.log}
+ )} + {!ciLogLoading && !ciLogError && ciLog && !ciLog.log && !item.log && ciLog.message && ( +
+ {ciLog.message} +
+ )} +
+ )} +
+ ); + })} + {!detailLoading && detail && ciItems.length === 0 && ( +
No CI entries were returned by this provider.
+ )} + {renderDetailPagination('CI', ciPage, ciTotal, setCiPageIndex)} +
+
+ + +
+ {detailError && ( +
+ + {detailError} + +
+ )} + {detailLoading && renderDetailLoading(pagedChangedFiles.length ? 'Refreshing files...' : 'Loading files...', pagedChangedFiles.length > 0)} + {pagedChangedFiles.map(file => { + const key = fileKey(file); + const isExpanded = expandedFileKeys.has(key); + return ( +
+
+ + +
+ {isExpanded && ( + file.patch ? ( +
+                                {file.patch.split('\n').map((line, index) => (
+                                  
+                                    {line || ' '}
+                                  
+                                ))}
+                              
+ ) : ( +
No inline diff is available for this file.
+ ) + )} +
+ ); + })} + {!detailLoading && detail && detail.files.length === 0 && ( +
No changed files were returned by this provider.
+ )} + {renderDetailPagination('Files', changePage, changedFiles.length, setChangePageIndex)} +
+
+ + +
+
+ Commits + +
+ {detailError && ( +
+ + {detailError} + +
+ )} + {detailLoading && renderDetailLoading(pagedCommits.length ? 'Refreshing commits...' : 'Loading commits...', pagedCommits.length > 0)} + {pagedCommits.map(commit => ( +
+ + + {commit.title} + {commit.author} · {formatRelativeTime(commit.committedAt)} + + {commit.shortHash} +
+ ))} + {!detailLoading && detail && commits.length === 0 && ( +
No commits were returned by this provider.
+ )} + {renderDetailPagination('Commits', commitPage, commits.length, setCommitPageIndex)} +
+
+ + +
+
+ Reviews + {reviewItemCount} items + +
+ {detailError && ( +
+ + {detailError} + +
+ )} + {detailLoading && renderDetailLoading(reviewThreads.length ? 'Refreshing reviews...' : 'Loading reviews...', reviewThreads.length > 0)} + {pagedReviewThreads.map(thread => { + const parent = thread.replyToProviderCommentId + ? reviewThreadByCommentId.get(thread.replyToProviderCommentId) + : null; + return ( +
+
+
+ + {thread.kind === 'review' ? 'Review' : 'Comment'} + + + {thread.resolved ? 'Resolved' : 'Open'} + +
+ {formatRelativeTime(thread.updatedAt) || formatAbsoluteTime(thread.updatedAt)} +
+
+ {thread.author} +
+ {parent && ( +
+
+ Reply to + @{parent.author} +
+
+ +
+
+ )} +
+ +
+ {thread.filePath && ( + + {thread.filePath}{thread.line ? `:${thread.line}` : ''} + + )} +
+ ); + })} + {!detailLoading && detail && reviewThreads.length === 0 && ( +
No reviews or comments were returned by this provider.
+ )} + {renderDetailPagination('Reviews', reviewPage, reviewThreads.length, setReviewPageIndex)} +
+
+
+ + )} +
+
+ { + if (!authSaving) { + setAuthModalOpen(false); + setAuthError(null); + } + }} + title={`${selectedRemote ? providerLabel(selectedRemote) : 'Provider'} token`} + size="small" + contentInset + > +
{ + event.preventDefault(); + void handleSaveAuthToken(); + }} + > +
+ {selectedRemote?.host ?? 'No remote'} + {selectedRemote?.projectPath ?? ''} +
+ { + setAuthToken(event.target.value); + if (authError) setAuthError(null); + }} + /> +
+ + +
+
+
+
+ ); +}; + +export default ReviewPlatformPanel; diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index d8e1e23fc..8e508dbfa 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -637,6 +637,7 @@ export interface MarkdownProps { onOpenVisualization?: (visualization: any) => void; onFileViewRequest?: (filePath: string, fileName: string, lineRange?: LineRange) => void; onTabOpen?: (tabInfo: any) => void; + onHttpLinkClick?: (url: string, event: React.MouseEvent) => boolean | void; onReproductionProceed?: () => void; } @@ -649,6 +650,7 @@ export const Markdown = React.memo(({ onOpenVisualization, onFileViewRequest, onTabOpen, + onHttpLinkClick, onReproductionProceed }) => { const { isLight } = useTheme(); @@ -1138,6 +1140,9 @@ export const Markdown = React.memo(({ onClick={async (e) => { e.preventDefault(); e.stopPropagation(); + if (onHttpLinkClick?.(hrefValue, e)) { + return; + } try { await systemAPI.openExternal(hrefValue); } catch (error) { @@ -1231,6 +1236,7 @@ export const Markdown = React.memo(({ handleWebLinkContextMenu, handleOpenVisualization, handleTabOpen, + onHttpLinkClick, parseLineRange, syntaxTheme, isLight, diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index 723b9c9c8..941083aba 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -33,7 +33,7 @@ export const FlowTextBlock = React.memo(({ className = '', replayStreamingOnMount = true }) => { - const { onFileViewRequest, onTabOpen, onOpenVisualization } = useFlowChatContext(); + const { onFileViewRequest, onTabOpen, onHttpLinkClick, onOpenVisualization } = useFlowChatContext(); const { i18n } = useTranslation(); // Normalize content to a string. @@ -111,6 +111,7 @@ export const FlowTextBlock = React.memo(({ isStreaming={isStreaming} onFileViewRequest={onFileViewRequest} onTabOpen={onTabOpen} + onHttpLinkClick={onHttpLinkClick} onOpenVisualization={(visualization) => { onOpenVisualization?.(visualization?.type, visualization?.data); }} diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index 4e30c0d0e..90dbe12ed 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -42,6 +42,7 @@ function getContextDisplayName(context: ContextItem): string { case 'file': return context.fileName; case 'directory': return context.directoryName; case 'code-snippet': return `${context.fileName}:${context.startLine}-${context.endLine}`; + case 'pull-request': return context.label; case 'image': return context.imageName; case 'terminal-command': return context.command; case 'git-ref': return context.refValue; @@ -61,6 +62,7 @@ function getContextTagFormat(context: ContextItem): string { case 'file': return `#file:${context.fileName}`; case 'directory': return `#dir:${context.directoryName}`; case 'code-snippet': return `#code:${context.fileName}:${context.startLine}-${context.endLine}`; + case 'pull-request': return `#pr:${context.label.replace(/\s+/g, '_')}`; case 'image': return `#img:${context.imageName}`; case 'terminal-command': return `#cmd:${context.command}`; case 'git-ref': return `#git:${context.refValue}`; @@ -83,6 +85,14 @@ function getContextFullPath(context: ContextItem): string { return context.directoryPath + (context.recursive ? ' (recursive)' : ''); case 'code-snippet': return `${context.filePath} (lines ${context.startLine}-${context.endLine})`; + case 'pull-request': + return [ + context.repository, + context.remoteId ? `remote:${context.remoteId}` : null, + context.pullRequestNumber ? `PR #${context.pullRequestNumber}` : null, + context.section, + context.sourceUrl, + ].filter(Boolean).join(' · ') || context.label; case 'image': return context.imagePath; case 'terminal-command': diff --git a/src/web-ui/src/flow_chat/components/UserMessage.tsx b/src/web-ui/src/flow_chat/components/UserMessage.tsx index 42781fb38..fa67847cc 100644 --- a/src/web-ui/src/flow_chat/components/UserMessage.tsx +++ b/src/web-ui/src/flow_chat/components/UserMessage.tsx @@ -4,7 +4,7 @@ */ import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { File, Folder, Code, Image, Terminal, GitBranch, Link, FileText } from 'lucide-react'; +import { File, Folder, Code, Image, Terminal, GitBranch, Link, FileText, GitPullRequest } from 'lucide-react'; import { Tag } from '@/component-library'; import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { SnapshotRollbackButton } from './SnapshotRollbackButton'; @@ -38,7 +38,8 @@ const TAG_CONFIG = { cmd: { icon: Terminal, color: '#94a3b8', label: 'Command' }, chart: { icon: FileText, color: '#22d3ee', label: 'Chart' }, git: { icon: GitBranch, color: '#f87171', label: 'Git' }, - link: { icon: Link, color: '#60a5fa', label: 'Link' } + link: { icon: Link, color: '#60a5fa', label: 'Link' }, + pr: { icon: GitPullRequest, color: '#a78bfa', label: 'Pull Request' } }; /** @@ -59,7 +60,7 @@ function parseMessageContent(content: string): ContentPart[] { const parts: ContentPart[] = []; // Match #type:value until whitespace or line break. - const tagPattern = /#(file|dir|code|img|cmd|chart|git|link):([^\s\n]+)/g; + const tagPattern = /#(file|dir|code|img|cmd|chart|git|link|pr):([^\s\n]+)/g; let lastIndex = 0; let match; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx index c52a8af41..f714baa2b 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx @@ -4,6 +4,7 @@ */ import { createContext, useContext } from 'react'; +import type React from 'react'; import type { FlowChatConfig, Session } from '../../types/flow-chat'; import type { LineRange } from '@/component-library'; @@ -11,6 +12,7 @@ export interface FlowChatContextValue { // File and panel actions onFileViewRequest?: (filePath: string, fileName: string, lineRange?: LineRange) => void; onTabOpen?: (tabInfo: any, sessionId?: string, panelType?: string) => void; + onHttpLinkClick?: (url: string, event: React.MouseEvent) => boolean | void; onOpenVisualization?: (type: string, data: any) => void; onSwitchToChatPanel?: () => void; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index fc5728c7c..2b397b041 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -133,6 +133,7 @@ } &__search-btn, + &__review-platform-btn, &__search-close { flex: 0 0 auto; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 503c26959..fb49015f5 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -5,10 +5,12 @@ */ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { Bot, ChevronDown, ChevronUp, List, Search, X } from 'lucide-react'; +import { Bot, ChevronDown, ChevronUp, GitPullRequest, List, Search, X } from 'lucide-react'; import { Tooltip, IconButton, Input } from '@/component-library'; import { useTranslation } from 'react-i18next'; import { SessionFilesBadge } from './SessionFilesBadge'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { createReviewPlatformTab } from '@/shared/utils/tabUtils'; import './FlowChatHeader.scss'; export interface FlowChatHeaderTurnSummary { @@ -89,6 +91,7 @@ export const FlowChatHeader: React.FC = ({ onOpenBackgroundSubagent, }) => { const { t } = useTranslation('flow-chat'); + const { currentWorkspace } = useWorkspaceContext(); const [isTurnListOpen, setIsTurnListOpen] = useState(false); const [isSubagentListOpen, setIsSubagentListOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -248,6 +251,10 @@ export const FlowChatHeader: React.FC = ({ setIsSubagentListOpen(prev => !prev); }; + const handleOpenPullRequests = useCallback(() => { + createReviewPlatformTab(currentWorkspace?.rootPath); + }, [currentWorkspace?.rootPath]); + const handleTurnSelect = (turnId: string) => { if (!onJumpToTurn) return; onJumpToTurn(turnId); @@ -374,6 +381,17 @@ export const FlowChatHeader: React.FC = ({ )}
+ + + {isSearchOpen ? (
= ( workspacePath, onFileViewRequest, }); + const handleHttpLinkClick = useCallback((url: string, _event: React.MouseEvent) => { + const pullRequestTarget = parsePullRequestUrl(url); + if (!pullRequestTarget) { + return false; + } + + createReviewPlatformPullRequestDetailTab({ + workspacePath: activeSession?.workspacePath || workspacePath, + pullRequestId: pullRequestTarget.pullRequestId, + pullRequestUrl: pullRequestTarget.webUrl, + title: `PR #${pullRequestTarget.pullRequestId}`, + }); + return true; + }, [activeSession?.workspacePath, workspacePath]); const { searchQuery, onSearchChange, @@ -214,6 +230,7 @@ export const ModernFlowChatContainer: React.FC = ( const contextValue: FlowChatContextValue = useMemo(() => ({ onFileViewRequest: handleFileViewRequest, onTabOpen, + onHttpLinkClick: handleHttpLinkClick, onOpenVisualization, onSwitchToChatPanel, onToolConfirm: handleToolConfirm, @@ -241,6 +258,7 @@ export const ModernFlowChatContainer: React.FC = ( }), [ handleFileViewRequest, onTabOpen, + handleHttpLinkClick, onOpenVisualization, onSwitchToChatPanel, handleToolConfirm, diff --git a/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts b/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts index b4b34e9aa..8e2884611 100644 --- a/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts +++ b/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts @@ -23,6 +23,7 @@ import { resolveSlashCommandReviewTarget, } from './targetResolver'; import { + formatPullRequestLaunchPrompt, formatSessionFilesLaunchPrompt, formatSlashCommandLaunchPrompt, } from './launchPrompt'; @@ -182,6 +183,47 @@ export async function buildDeepReviewPreviewFromSessionFiles( }); } +export async function buildDeepReviewLaunchFromPullRequestFiles( + filePaths: string[], + extraContext?: string, + diffContext?: string, + workspacePath?: string, +): Promise { + const target = classifyReviewTargetFromFiles(filePaths, 'pull_request'); + const changeStats = buildUnknownChangeStats(target); + const team = await prepareDefaultReviewTeamForLaunch(workspacePath, { + reviewTargetFilePaths: filePaths, + target, + }); + const manifest = await buildReviewTeamManifestWithRuntimeSignals(team, { + workspacePath, + target, + changeStats, + }); + const prompt = formatPullRequestLaunchPrompt({ + filePaths, + extraContext, + diffContext, + reviewTeamPromptBlock: buildReviewTeamPromptBlock(team, manifest), + }); + + return { prompt, runManifest: manifest }; +} + +export async function buildDeepReviewPreviewFromPullRequestFiles( + filePaths: string[], + workspacePath?: string, +): Promise { + const team = await loadDefaultReviewTeam(workspacePath); + const target = classifyReviewTargetFromFiles(filePaths, 'pull_request'); + const changeStats = buildUnknownChangeStats(target); + return buildReviewTeamManifestWithRuntimeSignals(team, { + workspacePath, + target, + changeStats, + }); +} + export async function buildDeepReviewPromptFromSessionFiles( filePaths: string[], extraContext?: string, diff --git a/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.test.ts b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.test.ts index e61967e3a..9b643c768 100644 --- a/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.test.ts +++ b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { formatFileList, + formatPullRequestLaunchPrompt, formatSessionFilesLaunchPrompt, formatSlashCommandLaunchPrompt, } from './launchPrompt'; @@ -23,6 +24,20 @@ describe('Deep Review launch prompt formatting', () => { expect(prompt).toContain('Review team manifest.'); }); + it('builds a pull-request prompt that uses provider diff as source of truth', () => { + const prompt = formatPullRequestLaunchPrompt({ + filePaths: ['src/a.ts'], + extraContext: 'PR #42', + diffContext: 'File: src/a.ts\nPatch:\n+changed', + reviewTeamPromptBlock: 'Review team manifest.', + }); + + expect(prompt).toContain('Review scope: ONLY inspect the following files changed by this pull request.'); + expect(prompt).toContain('Pull request context:\nPR #42'); + expect(prompt).toContain('Pull request provider diff:\nFile: src/a.ts'); + expect(prompt).toContain('Treat the provider diff as the source of truth'); + }); + it('builds a slash-command prompt with original command and fallback focus', () => { const prompt = formatSlashCommandLaunchPrompt({ commandText: '/DeepReview', diff --git a/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts index cdd8e8ec1..258fd77e7 100644 --- a/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts +++ b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts @@ -4,6 +4,13 @@ interface SessionFilesLaunchPromptParams { reviewTeamPromptBlock: string; } +interface PullRequestLaunchPromptParams { + filePaths: string[]; + extraContext?: string; + diffContext?: string; + reviewTeamPromptBlock: string; +} + interface SlashCommandLaunchPromptParams { commandText: string; extraContext: string; @@ -33,6 +40,30 @@ export function formatSessionFilesLaunchPrompt({ ].join('\n\n'); } +export function formatPullRequestLaunchPrompt({ + filePaths, + extraContext, + diffContext, + reviewTeamPromptBlock, +}: PullRequestLaunchPromptParams): string { + const contextBlock = extraContext?.trim() + ? `Pull request context:\n${extraContext.trim()}` + : 'Pull request context:\nNone.'; + const diffBlock = diffContext?.trim() + ? `Pull request provider diff:\n${diffContext.trim()}` + : 'Pull request provider diff:\nNo provider diff was included. Confirm findings against the listed files and PR metadata.'; + + return [ + 'Run a deep code review using the parallel Code Review Team.', + 'Review scope: ONLY inspect the following files changed by this pull request.', + formatFileList(filePaths), + contextBlock, + diffBlock, + reviewTeamPromptBlock, + 'Treat the provider diff as the source of truth for what changed in the PR. Read repository files only to understand surrounding context or verify findings.', + ].join('\n\n'); +} + export function formatSlashCommandLaunchPrompt({ commandText, extraContext, diff --git a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts index a8c0d9671..e6ad8f377 100644 --- a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts +++ b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts @@ -14,6 +14,7 @@ import { notificationService } from '@/shared/notification-system'; import type { ContextItem, ImageContext } from '@/shared/types/context'; import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { createLogger } from '@/shared/utils/logger'; +import { formatContextForPrompt } from '@/shared/utils/contextPrompt'; const log = createLogger('FlowChat'); @@ -168,46 +169,7 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender const displayMessage = options?.displayMessage?.trim() || trimmedMessage; if (contexts.length > 0) { - const fullContextSection = contexts.map(ctx => { - switch (ctx.type) { - case 'file': - return `[File: ${ctx.relativePath || ctx.filePath}]`; - case 'directory': - return `[Directory: ${ctx.directoryPath}]`; - case 'code-snippet': - return `[Code Snippet: ${ctx.filePath}:${ctx.startLine}-${ctx.endLine}]`; - case 'image': - // Images are sent out-of-band via `imageContexts` so the backend can attach them - // for multimodal models or convert to text placeholders for text-only models. Avoid embedding - // "Image ID" references into the user prompt, which can cause redundant tool calls. - return ''; - case 'terminal-command': - return `[Command: ${ctx.command}]`; - case 'mermaid-node': - return `[Mermaid Node: ${ctx.nodeText}]`; - case 'mermaid-diagram': - return `[Mermaid Diagram${ctx.diagramTitle ? ': ' + ctx.diagramTitle : ''}]\n\`\`\`mermaid\n${ctx.diagramCode}\n\`\`\``; - case 'git-ref': - return `[Git Ref: ${ctx.refValue}]`; - case 'url': - return `[URL: ${ctx.url}]`; - case 'web-element': { - const attrStr = Object.entries(ctx.attributes) - .map(([k, v]) => `${k}="${v}"`) - .join(' '); - const lines = [ - `[Web Element: <${ctx.tagName}${attrStr ? ' ' + attrStr : ''}>]`, - `CSS Path: ${ctx.path}`, - ]; - if (ctx.sourceUrl) lines.push(`Source URL: ${ctx.sourceUrl}`); - if (ctx.textContent) lines.push(`Text Content: ${ctx.textContent}`); - if (ctx.outerHTML) lines.push(`Outer HTML:\n\`\`\`html\n${ctx.outerHTML}\n\`\`\``); - return lines.join('\n'); - } - default: - return ''; - } - }).filter(Boolean).join('\n'); + const fullContextSection = contexts.map(formatContextForPrompt).filter(Boolean).join('\n'); fullMessage = `${fullContextSection}\n\n${aiTrimmedMessage}`; } diff --git a/src/web-ui/src/infrastructure/api/index.ts b/src/web-ui/src/infrastructure/api/index.ts index 95329c9a1..721b874bc 100644 --- a/src/web-ui/src/infrastructure/api/index.ts +++ b/src/web-ui/src/infrastructure/api/index.ts @@ -31,10 +31,12 @@ import { sessionAPI } from './service-api/SessionAPI'; import { i18nAPI } from './service-api/I18nAPI'; import { btwAPI } from './service-api/BtwAPI'; import { editorAiAPI } from './service-api/EditorAiAPI'; +import { reviewPlatformAPI } from './service-api/ReviewPlatformAPI'; import { insightsApi } from './insightsApi'; // Export API modules -export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, startchatAgentAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, insightsApi }; +export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, startchatAgentAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, reviewPlatformAPI, insightsApi }; +export * from './service-api/ReviewPlatformAPI'; // Export types export type { GitRepoHistory }; @@ -62,6 +64,7 @@ export const bitfunAPI = { i18n: i18nAPI, btw: btwAPI, editorAi: editorAiAPI, + reviewPlatform: reviewPlatformAPI, insights: insightsApi, }; diff --git a/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts new file mode 100644 index 000000000..146fd2d50 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts @@ -0,0 +1,341 @@ +import { api } from './ApiClient'; +import { createTauriCommandError } from '../errors/TauriCommandError'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('ReviewPlatformAPI'); + +export type ReviewPlatformKind = 'github' | 'gitlab' | 'gitcode' | 'unknown'; +export type ReviewAuthState = 'not_connected' | 'not_required' | 'connected' | 'expired' | 'error' | 'unsupported'; +export type ReviewAuthSource = 'env' | 'stored' | 'none' | 'unsupported'; +export type ReviewAuthChallengeState = 'missing' | 'invalid' | 'insufficient_scope'; +export type ReviewItemState = 'open' | 'merged' | 'closed' | 'draft'; +export type ReviewDecision = 'approved' | 'changes_requested' | 'commented' | 'pending'; +export type ReviewFileStatus = 'added' | 'modified' | 'deleted' | 'renamed'; +export type ReviewPlatformDetailSection = 'overview' | 'ci' | 'files' | 'commits' | 'reviews'; + +export interface ReviewPlatformAccount { + id: string; + platform: ReviewPlatformKind; + label: string; + username?: string | null; + host: string; + authState: ReviewAuthState; + authSource: ReviewAuthSource; + scopes: string[]; + message?: string | null; +} + +export interface ReviewPlatformRepositoryRef { + providerId: string; + platform: ReviewPlatformKind; + host: string; + owner: string; + name: string; + projectPath: string; + defaultBranch: string; + workspacePath?: string | null; + webUrl: string; +} + +export interface ReviewPlatformAuthChallenge { + platform: ReviewPlatformKind; + host: string; + remoteId: string; + projectPath: string; + state: ReviewAuthChallengeState; + message: string; + requiredScopes: string[]; +} + +export interface ReviewPlatformRemote { + id: string; + name: string; + url: string; + platform: ReviewPlatformKind; + host: string; + owner: string; + repositoryName: string; + projectPath: string; + webUrl: string; + supported: boolean; + authState: ReviewAuthState; + authSource: ReviewAuthSource; + message?: string | null; +} + +export interface ReviewChecks { + total: number; + passed: number; + failed: number; + pending: number; +} + +export interface ReviewPlatformCiItem { + id: string; + name: string; + status: string; + conclusion?: string | null; + detail?: string | null; + stage?: string | null; + webUrl?: string | null; + log?: string | null; + logTruncated: boolean; + startedAt?: string | null; + finishedAt?: string | null; +} + +export interface ReviewPlatformPullRequest { + id: string; + number: number; + title: string; + state: ReviewItemState; + author: string; + sourceBranch: string; + targetBranch: string; + updatedAt: string; + webUrl: string; + additions: number; + deletions: number; + changedFiles: number; + comments: number; + reviewDecision: ReviewDecision; + checks: ReviewChecks; +} + +export interface ReviewPlatformFile { + path: string; + oldPath?: string | null; + status: ReviewFileStatus; + additions: number; + deletions: number; + patch?: string | null; +} + +export interface ReviewPlatformCommit { + hash: string; + shortHash: string; + title: string; + author: string; + committedAt: string; +} + +export interface ReviewPlatformThread { + id: string; + providerThreadId?: string | null; + providerCommentId?: string | null; + kind: 'review' | 'comment'; + replyToProviderCommentId?: string | null; + filePath?: string | null; + line?: number | null; + resolved: boolean; + author: string; + body: string; + updatedAt: string; +} + +export interface ReviewPlatformPullRequestDetail extends ReviewPlatformPullRequest { + body: string; + ci: ReviewPlatformCiItem[]; + files: ReviewPlatformFile[]; + commits: ReviewPlatformCommit[]; + threads: ReviewPlatformThread[]; +} + +export interface ReviewPlatformPullRequestDetailPage extends ReviewPlatformPullRequestDetail { + section: ReviewPlatformDetailSection; + pagination: ReviewPlatformPagination; +} + +export interface ReviewPlatformCiLog { + ciItemId: string; + log?: string | null; + truncated: boolean; + message?: string | null; +} + +export interface ReviewPlatformCapabilities { + canCreateReview: boolean; + canCreatePullRequest: boolean; + canReplyToThread: boolean; + canResolveThread: boolean; + canApprove: boolean; + canRevokeApproval: boolean; + canRequestChanges: boolean; + canMerge: boolean; + supportsDraftReview: boolean; +} + +export interface ReviewPlatformPagination { + page: number; + perPage: number; + total?: number | null; + hasNext: boolean; +} + +export interface ReviewPlatformWorkspaceSnapshot { + remotes: ReviewPlatformRemote[]; + selectedRemoteId?: string | null; + accounts: ReviewPlatformAccount[]; + repository: ReviewPlatformRepositoryRef | null; + pullRequests: ReviewPlatformPullRequest[]; + pagination: ReviewPlatformPagination; + capabilities: ReviewPlatformCapabilities; + message?: string | null; + authChallenge?: ReviewPlatformAuthChallenge | null; +} + +export interface ReviewPlatformWorkspaceSnapshotRequest { + repositoryPath: string; + remoteId?: string | null; + page?: number; + perPage?: number; +} + +export interface ReviewPlatformPullRequestDetailRequest { + repositoryPath: string; + remoteId: string; + pullRequestId: string; +} + +export interface ReviewPlatformPullRequestDetailPageRequest extends ReviewPlatformPullRequestDetailRequest { + section: ReviewPlatformDetailSection; + page?: number; + perPage?: number; +} + +export interface ReviewPlatformPullRequestCiLogRequest extends ReviewPlatformPullRequestDetailRequest { + ciItemId: string; + ciItemName: string; +} + +export interface ReviewPlatformUpdateAuthTokenRequest { + platform: ReviewPlatformKind; + host: string; + token: string; +} + +export interface ReviewPlatformClearAuthTokenRequest { + platform: ReviewPlatformKind; + host: string; +} + +export class ReviewPlatformAPI { + async getWorkspaceSnapshot( + repositoryPath: string, + remoteId?: string | null, + page?: number, + perPage?: number, + ): Promise { + try { + return await api.invoke('review_platform_get_workspace_snapshot', { + request: { repositoryPath, remoteId, page, perPage }, + }); + } catch (error) { + log.error('Failed to load review platform snapshot', { repositoryPath, remoteId, page, perPage, error }); + throw createTauriCommandError('review_platform_get_workspace_snapshot', error, { + repositoryPath, + remoteId, + page, + perPage, + }); + } + } + + async getPullRequestDetail( + repositoryPath: string, + remoteId: string, + pullRequestId: string, + ): Promise { + try { + return await api.invoke('review_platform_get_pull_request_detail', { + request: { repositoryPath, remoteId, pullRequestId }, + }); + } catch (error) { + log.error('Failed to load review platform pull request detail', { + repositoryPath, + remoteId, + pullRequestId, + error, + }); + throw createTauriCommandError('review_platform_get_pull_request_detail', error, { + repositoryPath, + remoteId, + pullRequestId, + }); + } + } + + async getPullRequestDetailPage( + request: ReviewPlatformPullRequestDetailPageRequest, + ): Promise { + try { + return await api.invoke('review_platform_get_pull_request_detail_page', { + request, + }); + } catch (error) { + log.error('Failed to load review platform pull request detail page', { + repositoryPath: request.repositoryPath, + remoteId: request.remoteId, + pullRequestId: request.pullRequestId, + section: request.section, + page: request.page, + perPage: request.perPage, + error, + }); + throw createTauriCommandError('review_platform_get_pull_request_detail_page', error, request); + } + } + + async getPullRequestCiLog( + request: ReviewPlatformPullRequestCiLogRequest, + ): Promise { + try { + return await api.invoke('review_platform_get_pull_request_ci_log', { + request, + }); + } catch (error) { + log.error('Failed to load review platform CI log', { + repositoryPath: request.repositoryPath, + remoteId: request.remoteId, + pullRequestId: request.pullRequestId, + ciItemId: request.ciItemId, + error, + }); + throw createTauriCommandError('review_platform_get_pull_request_ci_log', error, request); + } + } + + async updateAuthToken(request: ReviewPlatformUpdateAuthTokenRequest): Promise { + try { + await api.invoke('review_platform_update_auth_token', { request }); + } catch (error) { + log.error('Failed to update review platform auth token', { + platform: request.platform, + host: request.host, + error, + }); + throw createTauriCommandError('review_platform_update_auth_token', error, { + platform: request.platform, + host: request.host, + }); + } + } + + async clearAuthToken(request: ReviewPlatformClearAuthTokenRequest): Promise { + try { + await api.invoke('review_platform_clear_auth_token', { request }); + } catch (error) { + log.error('Failed to clear review platform auth token', { + platform: request.platform, + host: request.host, + error, + }); + throw createTauriCommandError('review_platform_clear_auth_token', error, { + platform: request.platform, + host: request.host, + }); + } + } +} + +export const reviewPlatformAPI = new ReviewPlatformAPI(); diff --git a/src/web-ui/src/shared/services/DragManager.ts b/src/web-ui/src/shared/services/DragManager.ts index 9c0ce5fa7..a113db0bc 100644 --- a/src/web-ui/src/shared/services/DragManager.ts +++ b/src/web-ui/src/shared/services/DragManager.ts @@ -240,6 +240,8 @@ export class DragManager { return context.directoryPath; case 'code-snippet': return context.selectedText; + case 'pull-request': + return context.content; case 'mermaid-node': return context.nodeText; case 'image': diff --git a/src/web-ui/src/shared/services/reviewTargetClassifier.ts b/src/web-ui/src/shared/services/reviewTargetClassifier.ts index cf7a08c2d..21f1fa426 100644 --- a/src/web-ui/src/shared/services/reviewTargetClassifier.ts +++ b/src/web-ui/src/shared/services/reviewTargetClassifier.ts @@ -1,5 +1,6 @@ export type ReviewTargetSource = | 'session_files' + | 'pull_request' | 'slash_command_explicit_files' | 'slash_command_git_ref' | 'workspace_diff' diff --git a/src/web-ui/src/shared/stores/contextStore.ts b/src/web-ui/src/shared/stores/contextStore.ts index 05925dcb8..a4ca94141 100644 --- a/src/web-ui/src/shared/stores/contextStore.ts +++ b/src/web-ui/src/shared/stores/contextStore.ts @@ -157,7 +157,7 @@ export const useContextStore = create()( }, partialize: (state: any) => ({ - contexts: state.contexts.filter((ctx: any) => ctx.type !== 'image') + contexts: state.contexts.filter((ctx: any) => ctx.type !== 'image' && ctx.type !== 'pull-request') }) } as any ), diff --git a/src/web-ui/src/shared/types/context.ts b/src/web-ui/src/shared/types/context.ts index e9efdf9d0..e33bba9c1 100644 --- a/src/web-ui/src/shared/types/context.ts +++ b/src/web-ui/src/shared/types/context.ts @@ -19,6 +19,7 @@ export type ContextItem = | FileContext | DirectoryContext | CodeSnippetContext + | PullRequestContext | MermaidNodeContext | MermaidDiagramContext | ImageContext @@ -57,6 +58,18 @@ export interface CodeSnippetContext extends BaseContext { afterContext?: string; } +export interface PullRequestContext extends BaseContext { + type: 'pull-request'; + label: string; + section: 'overview' | 'ci' | 'file-diff' | 'commits' | 'reviews' | 'summary'; + content: string; + sourceUrl?: string; + remoteId?: string; + repository?: string; + pullRequestNumber?: number; + pullRequestTitle?: string; +} + export interface MermaidNodeContext extends BaseContext { type: 'mermaid-node'; nodeId: string; @@ -167,6 +180,10 @@ export function isCodeSnippetContext(context: ContextItem): context is CodeSnipp return context.type === 'code-snippet'; } +export function isPullRequestContext(context: ContextItem): context is PullRequestContext { + return context.type === 'pull-request'; +} + export function isMermaidNodeContext(context: ContextItem): context is MermaidNodeContext { return context.type === 'mermaid-node'; } diff --git a/src/web-ui/src/shared/utils/contextPrompt.test.ts b/src/web-ui/src/shared/utils/contextPrompt.test.ts new file mode 100644 index 000000000..4d80781fa --- /dev/null +++ b/src/web-ui/src/shared/utils/contextPrompt.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import type { PullRequestContext } from '@/shared/types/context'; +import { formatContextForPrompt } from './contextPrompt'; + +describe('formatContextForPrompt', () => { + it('includes remote id for pull request contexts', () => { + const context: PullRequestContext = { + id: 'pr-1', + type: 'pull-request', + label: 'PR #42 overview', + section: 'overview', + content: 'Review this change.', + remoteId: 'origin-github', + repository: 'owner/repo', + pullRequestNumber: 42, + pullRequestTitle: 'Fix bug', + sourceUrl: 'https://example.com/owner/repo/pull/42', + timestamp: 123, + }; + + const rendered = formatContextForPrompt(context); + + expect(rendered).toContain('Remote ID: origin-github'); + expect(rendered).toContain('Repository: owner/repo'); + expect(rendered).toContain('Pull Request: #42 Fix bug'); + expect(rendered).toContain('URL: https://example.com/owner/repo/pull/42'); + }); + + it('formats pull request CI contexts', () => { + const context: PullRequestContext = { + id: 'pr-ci-1', + type: 'pull-request', + label: 'PR #42 CI', + section: 'ci', + content: 'Checks: 2/3 passed, 1 failed, 0 pending', + remoteId: 'origin-github', + repository: 'owner/repo', + pullRequestNumber: 42, + pullRequestTitle: 'Fix bug', + timestamp: 123, + }; + + const rendered = formatContextForPrompt(context); + + expect(rendered).toContain('[Pull Request Context: PR #42 CI]'); + expect(rendered).toContain('Section: ci'); + expect(rendered).toContain('Checks: 2/3 passed, 1 failed, 0 pending'); + }); +}); diff --git a/src/web-ui/src/shared/utils/contextPrompt.ts b/src/web-ui/src/shared/utils/contextPrompt.ts new file mode 100644 index 000000000..02611fba3 --- /dev/null +++ b/src/web-ui/src/shared/utils/contextPrompt.ts @@ -0,0 +1,52 @@ +import type { ContextItem } from '@/shared/types/context'; + +export function formatContextForPrompt(context: ContextItem): string { + switch (context.type) { + case 'file': + return `[File: ${context.relativePath || context.filePath}]`; + case 'directory': + return `[Directory: ${context.directoryPath}]`; + case 'code-snippet': + return `[Code Snippet: ${context.filePath}:${context.startLine}-${context.endLine}]`; + case 'pull-request': + return [ + `[Pull Request Context: ${context.label}]`, + context.repository ? `Repository: ${context.repository}` : '', + context.remoteId ? `Remote ID: ${context.remoteId}` : '', + context.pullRequestNumber ? `Pull Request: #${context.pullRequestNumber}${context.pullRequestTitle ? ` ${context.pullRequestTitle}` : ''}` : '', + context.sourceUrl ? `URL: ${context.sourceUrl}` : '', + `Section: ${context.section}`, + '', + context.content, + ].filter(line => line !== '').join('\n'); + case 'image': + return ''; + case 'terminal-command': + return `[Command: ${context.command}]`; + case 'mermaid-node': + return `[Mermaid Node: ${context.nodeText}]`; + case 'mermaid-diagram': + return `[Mermaid Diagram${context.diagramTitle ? ': ' + context.diagramTitle : ''}]\n\`\`\`mermaid\n${context.diagramCode}\n\`\`\``; + case 'git-ref': + return `[Git Ref: ${context.refValue}]`; + case 'url': + return `[URL: ${context.url}]`; + case 'web-element': { + const attrStr = Object.entries(context.attributes) + .map(([k, v]) => `${k}="${v}"`) + .join(' '); + const lines = [ + `[Web Element: <${context.tagName}${attrStr ? ' ' + attrStr : ''}>]`, + `CSS Path: ${context.path}`, + ]; + if (context.sourceUrl) lines.push(`Source URL: ${context.sourceUrl}`); + if (context.textContent) lines.push(`Text Content: ${context.textContent}`); + if (context.outerHTML) lines.push(`Outer HTML:\n\`\`\`html\n${context.outerHTML}\n\`\`\``); + return lines.join('\n'); + } + default: { + const exhaustive: never = context; + return String(exhaustive); + } + } +} diff --git a/src/web-ui/src/shared/utils/pullRequestLinks.ts b/src/web-ui/src/shared/utils/pullRequestLinks.ts new file mode 100644 index 000000000..27f69b928 --- /dev/null +++ b/src/web-ui/src/shared/utils/pullRequestLinks.ts @@ -0,0 +1,93 @@ +import type { ReviewPlatformRemote } from '@/infrastructure/api'; + +export interface PullRequestLinkTarget { + webUrl: string; + host: string; + projectPath: string; + pullRequestId: string; +} + +function cleanSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function normalizeProjectPath(value: string): string { + return value + .replace(/\\/g, '/') + .replace(/\.git$/i, '') + .replace(/^\/+|\/+$/g, '') + .toLowerCase(); +} + +export function parsePullRequestUrl(value: string): PullRequestLinkTarget | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + + const segments = url.pathname + .split('/') + .map(cleanSegment) + .filter(Boolean); + + const mergeRequestIndex = segments.findIndex(segment => segment === 'merge_requests'); + if (mergeRequestIndex >= 0 && segments[mergeRequestIndex + 1]) { + const projectEnd = segments[mergeRequestIndex - 1] === '-' + ? mergeRequestIndex - 1 + : mergeRequestIndex; + const projectPath = segments.slice(0, projectEnd).join('/'); + if (projectPath) { + return { + webUrl: url.toString(), + host: url.host.toLowerCase(), + projectPath, + pullRequestId: segments[mergeRequestIndex + 1], + }; + } + } + + const pullIndex = segments.findIndex(segment => segment === 'pull' || segment === 'pulls'); + if (pullIndex >= 0 && segments[pullIndex + 1]) { + const projectPath = segments.slice(0, pullIndex).join('/'); + if (projectPath) { + return { + webUrl: url.toString(), + host: url.host.toLowerCase(), + projectPath, + pullRequestId: segments[pullIndex + 1], + }; + } + } + + return null; +} + +export function remoteMatchesPullRequestLink(remote: ReviewPlatformRemote, target: PullRequestLinkTarget): boolean { + if (remote.host.toLowerCase() !== target.host) { + return false; + } + + const targetProject = normalizeProjectPath(target.projectPath); + const remoteProject = normalizeProjectPath(remote.projectPath); + if (targetProject === remoteProject) { + return true; + } + + try { + const remoteUrl = new URL(remote.webUrl || remote.url); + const remoteUrlProject = normalizeProjectPath(remoteUrl.pathname); + return targetProject === remoteUrlProject; + } catch { + return false; + } +} diff --git a/src/web-ui/src/shared/utils/tabUtils.ts b/src/web-ui/src/shared/utils/tabUtils.ts index a984d55b4..3322d8c8f 100644 --- a/src/web-ui/src/shared/utils/tabUtils.ts +++ b/src/web-ui/src/shared/utils/tabUtils.ts @@ -25,6 +25,14 @@ interface CreateTerminalTabOptions { sceneJustOpened?: boolean; } +export interface CreateReviewPlatformPullRequestDetailTabOptions { + workspacePath?: string; + remoteId?: string; + pullRequestId?: string; + pullRequestUrl?: string; + title?: string; +} + function isRightPanelCollapsed(): boolean { try { const layoutState = (window as any).__BITFUN_LAYOUT_STATE__; @@ -266,6 +274,74 @@ export function createConfigCenterTab( window.dispatchEvent(new CustomEvent('scene:open', { detail: { sceneId: 'settings' } })); } +export function createReviewPlatformTab(workspacePath?: string): void { + const detail = { + type: 'review-platform', + title: i18nService.getT()('common:tabs.pullRequests', { defaultValue: 'Pull Requests' }), + data: { workspacePath }, + metadata: { + workspacePath, + duplicateCheckKey: `review-platform:${workspacePath || 'current'}`, + }, + checkDuplicate: true, + duplicateCheckKey: `review-platform:${workspacePath || 'current'}`, + replaceExisting: true, + }; + + window.dispatchEvent(new CustomEvent(TAB_EVENTS.EXPAND_RIGHT_PANEL)); + + if (isRightPanelCollapsed()) { + window.setTimeout(() => { + window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail })); + }, 300); + return; + } + + window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail })); +} + +export function createReviewPlatformPullRequestDetailTab(options: CreateReviewPlatformPullRequestDetailTabOptions): void { + const pullRequestLabel = options.pullRequestId ? `#${options.pullRequestId}` : 'Pull Request'; + const title = options.title || pullRequestLabel; + const duplicateKey = [ + 'review-platform-pr-detail', + options.workspacePath || 'current', + options.remoteId || 'auto', + options.pullRequestId || options.pullRequestUrl || 'unknown', + ].join(':'); + const detail = { + type: 'review-platform-pr-detail', + title, + data: { + workspacePath: options.workspacePath, + remoteId: options.remoteId, + pullRequestId: options.pullRequestId, + pullRequestUrl: options.pullRequestUrl, + }, + metadata: { + workspacePath: options.workspacePath, + remoteId: options.remoteId, + pullRequestId: options.pullRequestId, + pullRequestUrl: options.pullRequestUrl, + duplicateCheckKey: duplicateKey, + }, + checkDuplicate: true, + duplicateCheckKey: duplicateKey, + replaceExisting: true, + }; + + window.dispatchEvent(new CustomEvent(TAB_EVENTS.EXPAND_RIGHT_PANEL)); + + if (isRightPanelCollapsed()) { + window.setTimeout(() => { + window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail })); + }, 300); + return; + } + + window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail })); +} + export function createTerminalTab( sessionId: string, sessionName: string,