diff --git a/crates/app/src/bin/workflow.rs b/crates/app/src/bin/workflow.rs index 831ab326..8c1aa57c 100644 --- a/crates/app/src/bin/workflow.rs +++ b/crates/app/src/bin/workflow.rs @@ -7,9 +7,9 @@ use app::cli::{DiffCommand, RollbackCommand}; use app::{ bootstrap::{get_alias_service, get_logger_manager}, cli::{ - AliasCommand, BranchSubcommand, Cli, Command, CompletionCommand, GithubCommand, - IgnoreSubcommand, JiraCommand, LlmCommand, LogCommand, PrSubcommand, RepoCommand, - SshCommand, StashSubcommand, TagSubcommand, UninstallArgs, UpdateArgs, + AliasCommand, BranchSubcommand, Cli, CodeupCommand, Command, CompletionCommand, + GithubCommand, IgnoreSubcommand, JiraCommand, LlmCommand, LogCommand, PrSubcommand, + RepoCommand, SshCommand, StashSubcommand, TagSubcommand, UninstallArgs, UpdateArgs, }, commands, }; @@ -127,6 +127,16 @@ fn main() -> Result<(), Box> { cmd.run()?; } }, + Command::Codeup(codeup_cmd) => match codeup_cmd { + CodeupCommand::Check => { + let cmd = commands::codeup::CodeupCheckCommand::new(); + cmd.run()?; + } + CodeupCommand::Setup => { + let cmd = commands::codeup::CodeupSetupCommand::new(); + cmd.run()?; + } + }, Command::Jira(jira_cmd) => match jira_cmd { JiraCommand::Check => { let cmd = commands::jira::JiraCheckCommand::new(); diff --git a/crates/app/src/bootstrap/context/codeup_context.rs b/crates/app/src/bootstrap/context/codeup_context.rs new file mode 100644 index 00000000..ccdf83a9 --- /dev/null +++ b/crates/app/src/bootstrap/context/codeup_context.rs @@ -0,0 +1,64 @@ +//! Codeup 配置上下文实现 +//! +//! 实现 `client::codeup::context::CodeupConfigContext` trait, +//! 提供配置获取逻辑。 + +use std::sync::Arc; + +use client::{CodeupClientError, CodeupConfigContext}; +use domain::GlobalConfigRepository; + +/// Codeup 配置上下文实现 +/// +/// 实现 `CodeupConfigContext` trait,提供基于配置适配器的配置获取逻辑。 +pub struct CodeupContextImpl { + config: Arc, +} + +impl CodeupContextImpl { + pub fn new(config: Arc) -> Self { + Self { config } + } + + /// 获取 Codeup 配置 + fn get_codeup_settings(&self) -> Result { + let config = self + .config + .load() + .map_err(|e| CodeupClientError::ConfigError(format!("加载配置失败: {}", e)))?; + Ok(config.codeup) + } +} + +// 实现 client 层的 CodeupConfigContext +impl CodeupConfigContext for CodeupContextImpl { + fn get_project_id(&self) -> Result { + let settings = self.get_codeup_settings()?; + if settings.project_id.is_empty() { + return Err(CodeupClientError::ConfigError( + "Codeup project_id 未配置".to_string(), + )); + } + Ok(settings.project_id) + } + + fn get_csrf_token(&self) -> Result { + let settings = self.get_codeup_settings()?; + if settings.csrf_token.is_empty() { + return Err(CodeupClientError::ConfigError( + "Codeup csrf_token 未配置".to_string(), + )); + } + Ok(settings.csrf_token) + } + + fn get_cookie(&self) -> Result { + let settings = self.get_codeup_settings()?; + if settings.cookie.is_empty() { + return Err(CodeupClientError::ConfigError( + "Codeup cookie 未配置".to_string(), + )); + } + Ok(settings.cookie) + } +} diff --git a/crates/app/src/bootstrap/context/mod.rs b/crates/app/src/bootstrap/context/mod.rs index 95bd208e..558f4f21 100644 --- a/crates/app/src/bootstrap/context/mod.rs +++ b/crates/app/src/bootstrap/context/mod.rs @@ -1,13 +1,15 @@ +mod codeup_context; mod github_context; mod jira_context; mod llm_context; use std::sync::Arc; -use client::{GitHubConfigContext, JiraConfigContext, LLMConfigContext}; +use client::{CodeupConfigContext, GitHubConfigContext, JiraConfigContext, LLMConfigContext}; use di::{bind, Container, InjectionError, Scope}; use domain::{GlobalConfigRepository, PathService}; +pub use codeup_context::CodeupContextImpl; pub use github_context::GitHubContextImpl; pub use jira_context::JiraConfigContextImpl; pub use llm_context::LLMConfigContextImpl; @@ -44,5 +46,12 @@ pub fn register_context() -> Result<(), InjectionError> { }) .in_scope(Scope::Singleton)?; + // Codeup Config Context + bind!(dyn CodeupConfigContext, |c: &Container| { + let global_config = c.get::()?; + Ok(Arc::new(CodeupContextImpl::new(global_config))) + }) + .in_scope(Scope::Singleton)?; + Ok(()) } diff --git a/crates/app/src/bootstrap/mod.rs b/crates/app/src/bootstrap/mod.rs index a1031b53..3f51e11d 100644 --- a/crates/app/src/bootstrap/mod.rs +++ b/crates/app/src/bootstrap/mod.rs @@ -9,8 +9,8 @@ use std::sync::{Arc, LazyLock}; use client::LanguageManager; use domain::{ - AliasService, BranchService, CommitMessageService, CommitSummaryService, CompletionService, - GitHubRepository, GitRepository, GlobalConfigRepository, JiraRepository, + AliasService, BranchService, CodeupRepository, CommitMessageService, CommitSummaryService, + CompletionService, GitHubRepository, GitRepository, GlobalConfigRepository, JiraRepository, JiraWorkHistoryRepository, PathService, PullRequestService, RepoConfigRepository, VerificationService, }; @@ -157,6 +157,11 @@ pub fn get_github_repository() -> Arc { get_service::() } +/// 获取 CodeupRepository +pub fn get_codeup_repository() -> Arc { + get_service::() +} + /// 获取 JiraRepository pub fn get_jira_repository() -> Arc { get_service::() diff --git a/crates/app/src/commands/branch/create.rs b/crates/app/src/commands/branch/create.rs index 0a78dcbe..65667334 100644 --- a/crates/app/src/commands/branch/create.rs +++ b/crates/app/src/commands/branch/create.rs @@ -4,9 +4,9 @@ use domain::GitRepository; use prompt::{error, info, input, select, spinner, success}; use crate::util::{ - generate_branch_name_from_jira, generate_branch_name_from_template, select_branch_type, to_slug, + generate_branch_name_from_jira, generate_branch_name_from_template, safe_pull, + select_branch_type, to_slug, PullOptions, }; -use crate::util::{safe_pull, PullOptions}; use crate::{bootstrap, commands::jira::utils::get_jira_id_interactive_optional}; /// 源分支选项 diff --git a/crates/app/src/commands/cli.rs b/crates/app/src/commands/cli.rs index cae1a729..0acd3e2e 100644 --- a/crates/app/src/commands/cli.rs +++ b/crates/app/src/commands/cli.rs @@ -16,6 +16,7 @@ pub use crate::commands::diff::DiffCommand; pub use crate::commands::rollback::RollbackCommand; pub use crate::commands::{ branch::{BranchSubcommand, IgnoreSubcommand}, + codeup::CodeupCommand, completion::CompletionCommand, github::GithubCommand, jira::{AttachmentsArgs, CleanArgs, InfoArgs, JiraCommand, OutputFormat}, @@ -68,6 +69,9 @@ pub enum Command { /// GitHub account management commands #[command(subcommand)] Github(GithubCommand), + /// Codeup configuration management commands + #[command(subcommand)] + Codeup(CodeupCommand), /// Jira configuration management commands #[command(subcommand)] Jira(JiraCommand), diff --git a/crates/app/src/commands/codeup/check.rs b/crates/app/src/commands/codeup/check.rs new file mode 100644 index 00000000..68e334ac --- /dev/null +++ b/crates/app/src/commands/codeup/check.rs @@ -0,0 +1,32 @@ +//! 检查 Codeup 配置命令 + +use prompt::{br, separator}; + +use crate::bootstrap; +use crate::interactive::{WorkflowExecutor, CODEUP_STAGE_NAME}; + +/// Codeup Check 命令 +pub struct CodeupCheckCommand; + +impl Default for CodeupCheckCommand { + fn default() -> Self { + Self::new() + } +} + +impl CodeupCheckCommand { + /// 创建新的 CodeupCheckCommand + pub fn new() -> Self { + Self + } + + /// 运行 `workflow codeup check` 命令 + pub fn run(&self) -> Result<(), Box> { + separator!('─', 80, "Codeup Configuration Check"); + br!(); + let stage = bootstrap::get_workflow_stage_registry() + .stage_by_name(CODEUP_STAGE_NAME) + .expect("Codeup stage must be registered"); + WorkflowExecutor::new(stage).run_verify() + } +} diff --git a/crates/app/src/commands/codeup/cli.rs b/crates/app/src/commands/codeup/cli.rs new file mode 100644 index 00000000..0c68316c --- /dev/null +++ b/crates/app/src/commands/codeup/cli.rs @@ -0,0 +1,14 @@ +//! Codeup 配置管理子命令 +//! +//! Codeup 配置管理子命令结构定义 + +use clap::Subcommand; + +/// Codeup 配置管理子命令 +#[derive(Subcommand)] +pub enum CodeupCommand { + /// 检查 Codeup 配置(显示项目 ID、验证状态) + Check, + /// 设置 Codeup 配置(交互式配置项目 ID、CSRF Token、Cookie) + Setup, +} diff --git a/crates/app/src/commands/codeup/mod.rs b/crates/app/src/commands/codeup/mod.rs new file mode 100644 index 00000000..0c438276 --- /dev/null +++ b/crates/app/src/commands/codeup/mod.rs @@ -0,0 +1,9 @@ +//! Codeup 配置管理命令 + +pub mod check; +mod cli; +pub mod setup; + +pub use check::CodeupCheckCommand; +pub use cli::CodeupCommand; +pub use setup::CodeupSetupCommand; diff --git a/crates/app/src/commands/codeup/setup.rs b/crates/app/src/commands/codeup/setup.rs new file mode 100644 index 00000000..371878b9 --- /dev/null +++ b/crates/app/src/commands/codeup/setup.rs @@ -0,0 +1,28 @@ +//! 设置 Codeup 配置命令 + +use crate::bootstrap; +use crate::interactive::{WorkflowExecutor, CODEUP_STAGE_NAME}; + +/// Codeup Setup 命令 +pub struct CodeupSetupCommand; + +impl Default for CodeupSetupCommand { + fn default() -> Self { + Self::new() + } +} + +impl CodeupSetupCommand { + /// 创建新的 CodeupSetupCommand + pub fn new() -> Self { + Self + } + + /// 运行 `workflow codeup setup` 命令 + pub fn run(&self) -> Result<(), Box> { + let stage = bootstrap::get_workflow_stage_registry() + .stage_by_name(CODEUP_STAGE_NAME) + .expect("Codeup stage must be registered"); + WorkflowExecutor::new(stage).run_command_setup() + } +} diff --git a/crates/app/src/commands/mod.rs b/crates/app/src/commands/mod.rs index 3ca20585..8f44df5a 100644 --- a/crates/app/src/commands/mod.rs +++ b/crates/app/src/commands/mod.rs @@ -17,6 +17,7 @@ pub mod rollback; pub mod alias; pub mod branch; pub mod check; +pub mod codeup; pub mod commit; pub mod completion; pub mod github; diff --git a/crates/app/src/commands/pr/create/pr.rs b/crates/app/src/commands/pr/create/pr.rs index b8711946..89452c2f 100644 --- a/crates/app/src/commands/pr/create/pr.rs +++ b/crates/app/src/commands/pr/create/pr.rs @@ -143,7 +143,13 @@ pub fn create_pull_request( (Some(repo_name), Some(CodePlatform::GitHub)) => { Some(format!("https://github.com/{}/pull/{}", repo_name, pr_id)) } - // 将来可以添加其他平台支持 + (Some(_repo_name), Some(CodePlatform::Codeup)) => { + // Codeup URL 格式: https://codeup.aliyun.com/project/{project_id}/merge_request/{pr_id} + // 从 PR 服务获取实际 URL + let codeup_repo = bootstrap::get_codeup_repository(); + codeup_repo.get_pull_request_url(&pr_id).ok() + } + // 其他平台支持 _ => None, }; diff --git a/crates/app/src/interactive/core/platform/config.rs b/crates/app/src/interactive/core/platform/config.rs index d8819c08..131f6f5a 100644 --- a/crates/app/src/interactive/core/platform/config.rs +++ b/crates/app/src/interactive/core/platform/config.rs @@ -1,7 +1,7 @@ //! 平台配置流程 use domain::GlobalConfig; -use prompt::{br, info, separator, SelectBuilder}; +use prompt::{br, confirm, info, separator, SelectBuilder}; use crate::interactive::core::context::{WorkflowContext, WorkflowMode}; use crate::interactive::core::platform::{ @@ -66,7 +66,14 @@ where } else { info!("No {} accounts were detected.", platform_name); br!(); - add_account_fn(context, AccountSetMode::SetAsCurrent)?; + let should_configure = confirm!("Do you want to configure {}?", platform_name) + .default(true) + .result_title(format!("Configure {}", platform_name)) + .prompt() + .map_err(|e| e.to_string())?; + if should_configure { + add_account_fn(context, AccountSetMode::SetAsCurrent)?; + } } Ok(()) diff --git a/crates/app/src/interactive/core/stage.rs b/crates/app/src/interactive/core/stage.rs index d7d512f6..2c1e121f 100644 --- a/crates/app/src/interactive/core/stage.rs +++ b/crates/app/src/interactive/core/stage.rs @@ -96,7 +96,7 @@ impl<'a> WorkflowExecutor<'a> { let stage_name = self.stage.stage_name(); if !self.stage.is_configured(settings) { - warning!("{} is not configured. Skipping verification.", stage_name); + // warning!("{} is not configured. Skipping verification.", stage_name); return Ok(()); } diff --git a/crates/app/src/interactive/display/codeup.rs b/crates/app/src/interactive/display/codeup.rs new file mode 100644 index 00000000..7dce2194 --- /dev/null +++ b/crates/app/src/interactive/display/codeup.rs @@ -0,0 +1,63 @@ +//! Codeup 验证结果格式化实现 + +use domain::{CodeupVerificationResult, CodeupVerificationStatus}; +use prompt::{br, success, warning, TableBuilder, Tabled}; + +use crate::interactive::display::formatter::VerificationResultFormatter; + +/// Codeup 配置表格行 +pub struct CodeupConfigRow { + pub project_id: String, + pub csrf_token: String, + pub cookie: String, +} + +impl Tabled for CodeupConfigRow { + fn headers() -> Vec { + vec![ + "Project ID".to_string(), + "CSRF Token".to_string(), + "Cookie".to_string(), + ] + } + + fn row(&self) -> Vec { + vec![ + self.project_id.clone(), + self.csrf_token.clone(), + self.cookie.clone(), + ] + } +} + +impl VerificationResultFormatter for CodeupVerificationResult { + fn format(&self) { + if !self.configured { + return; + } + + if let Some(ref config) = self.config { + let row = CodeupConfigRow { + project_id: config.project_id.clone(), + csrf_token: config.csrf_token.clone(), + cookie: config.cookie.clone(), + }; + + let table_builder = TableBuilder::from_tabled(vec![row]); + let _ = table_builder.display(); + } + + if let Some(ref verification) = self.verification { + match verification { + CodeupVerificationStatus::Success { username } => { + success!("Codeup verification successful! User: {}", username); + } + CodeupVerificationStatus::Failed { reason, .. } => { + warning!("Codeup verification error: {}", reason); + } + } + } + + br!(); + } +} diff --git a/crates/app/src/interactive/display/mod.rs b/crates/app/src/interactive/display/mod.rs index 40089608..7b8c5d6f 100644 --- a/crates/app/src/interactive/display/mod.rs +++ b/crates/app/src/interactive/display/mod.rs @@ -3,6 +3,7 @@ //! 提供验证结果的格式化显示功能,将 domain 层的验证结果转换为表格和消息输出。 mod attachment; +mod codeup; mod formatter; mod github; mod jira; diff --git a/crates/app/src/interactive/display/ssh.rs b/crates/app/src/interactive/display/ssh.rs index 9d38d934..51b5312b 100644 --- a/crates/app/src/interactive/display/ssh.rs +++ b/crates/app/src/interactive/display/ssh.rs @@ -1,4 +1,6 @@ //! SSH 验证结果格式化实现 +//! +//! 用于 check 命令的表格展示,setup 的简洁展示在 platforms/ssh.rs 中实现。 use domain::SshVerificationResult; use prompt::{br, info, warning, Alignment, TableBuilder}; diff --git a/crates/app/src/interactive/manager.rs b/crates/app/src/interactive/manager.rs index e45f2bbb..21d8315b 100644 --- a/crates/app/src/interactive/manager.rs +++ b/crates/app/src/interactive/manager.rs @@ -2,7 +2,9 @@ //! //! 提供 Stage 的集中注册与按名称查找,固定顺序为 Jira → GitHub → LLM → Log。 -use crate::interactive::platforms::{github_stage, jira_stage, llm_stage, log_stage, ssh_stage}; +use crate::interactive::platforms::{ + codeup_stage, github_stage, jira_stage, llm_stage, log_stage, ssh_stage, +}; use crate::interactive::WorkflowStage; // ============================================================================ @@ -12,6 +14,9 @@ use crate::interactive::WorkflowStage; /// Jira 阶段名称 pub const JIRA_STAGE_NAME: &str = "Jira"; +/// Codeup 阶段名称 +pub const CODEUP_STAGE_NAME: &str = "Codeup"; + /// SSH 阶段名称 pub const SSH_STAGE_NAME: &str = "SSH"; @@ -32,7 +37,7 @@ pub const LOG_STAGE_NAME: &str = "Log"; /// /// 提供按固定顺序获取所有 stage,以及按名称查找单个 stage 的能力。 pub trait WorkflowStageManager: Send + Sync { - /// 返回所有 stage,按固定顺序:Jira → SSH → GitHub → LLM → Log + /// 返回所有 stage,按固定顺序:Jira → SSH → GitHub → Codeup → LLM → Log fn stages(&self) -> Vec<&'static dyn WorkflowStage>; /// 按名称查找 stage @@ -55,7 +60,7 @@ pub trait WorkflowStageManager: Send + Sync { /// 默认的 WorkflowStageRegistry 实现 /// -/// Stage 顺序固定为:Jira → SSH → GitHub → LLM → Log +/// Stage 顺序固定为:Jira → SSH → GitHub → Codeup → LLM → Log pub struct WorkflowStageManagerImpl; impl WorkflowStageManagerImpl { @@ -70,6 +75,7 @@ impl WorkflowStageManager for WorkflowStageManagerImpl { jira_stage(), ssh_stage(), github_stage(), + codeup_stage(), llm_stage(), log_stage(), ] diff --git a/crates/app/src/interactive/mod.rs b/crates/app/src/interactive/mod.rs index 5d9f498e..aa3f826b 100644 --- a/crates/app/src/interactive/mod.rs +++ b/crates/app/src/interactive/mod.rs @@ -10,6 +10,6 @@ mod platforms; // 重新导出常用接口 pub use core::{WorkflowContext, WorkflowExecutor, WorkflowMode, WorkflowStage}; pub use manager::{ - WorkflowStageManager, WorkflowStageManagerImpl, GITHUB_STAGE_NAME, JIRA_STAGE_NAME, - LLM_STAGE_NAME, LOG_STAGE_NAME, SSH_STAGE_NAME, + WorkflowStageManager, WorkflowStageManagerImpl, CODEUP_STAGE_NAME, GITHUB_STAGE_NAME, + JIRA_STAGE_NAME, LLM_STAGE_NAME, LOG_STAGE_NAME, SSH_STAGE_NAME, }; diff --git a/crates/app/src/interactive/platforms/codeup.rs b/crates/app/src/interactive/platforms/codeup.rs new file mode 100644 index 00000000..f207c1ff --- /dev/null +++ b/crates/app/src/interactive/platforms/codeup.rs @@ -0,0 +1,149 @@ +//! Codeup 工作流阶段 (v2) + +use std::error::Error; + +use domain::{GlobalConfig, VerificationService}; +use prompt::{ + br, confirm, info, separator, FormBuilder, InputFormField, PasswordFormField, PromptError, +}; +use toolkit::Sensitive; + +use crate::interactive::{ + core::{WorkflowContext, WorkflowMode, WorkflowStage}, + display::VerificationResultFormatter, +}; + +/// Codeup 工作流阶段 +pub struct CodeupStage; + +impl CodeupStage { + /// 运行 Codeup 配置表单 + fn run_form(settings: &mut GlobalConfig) -> Result<(), String> { + info!("Configure Codeup project ID, CSRF token and cookie. Leave fields empty to keep defaults or skip."); + br!(); + + let codeup = &mut settings.codeup; + let current_project_id = codeup.project_id.clone(); + let current_csrf_token = codeup.csrf_token.clone(); + let current_cookie = codeup.cookie.clone(); + + let builder = FormBuilder::new() + .with_title("Codeup configuration") + .add_input( + InputFormField::new("project_id", "Please enter your Codeup project ID") + .default(current_project_id) + .result_title("Your Codeup project ID") + .required(), + ) + .add_password( + PasswordFormField::new("csrf_token", "Please enter your Codeup CSRF token") + .default(current_csrf_token) + .result_title("Your Codeup CSRF token") + .required(), + ) + .add_password( + PasswordFormField::new("cookie", "Please enter your Codeup cookie") + .default(current_cookie) + .result_title("Your Codeup cookie") + .required(), + ); + + let result = builder.run().map_err(|e| e.to_string())?; + let project_id = result.get_string("project_id"); + let csrf_token = result.get_string("csrf_token"); + let cookie = result.get_string("cookie"); + + if !project_id.trim().is_empty() { + codeup.project_id = project_id.trim().to_string(); + } + if !csrf_token.trim().is_empty() { + codeup.csrf_token = csrf_token; + } + if !cookie.trim().is_empty() { + codeup.cookie = cookie; + } + + Ok(()) + } +} + +impl WorkflowStage for CodeupStage { + fn stage_name(&self) -> &'static str { + "Codeup" + } + + fn configure(&self, context: &mut WorkflowContext) -> Result<(), Box> { + let mode = context.mode(); + let settings = context.settings_mut(); + + separator!('─', 80, "Codeup configuration"); + br!(); + + let codeup = &settings.codeup; + let has_codeup = !codeup.project_id.is_empty() + || !codeup.csrf_token.is_empty() + || !codeup.cookie.is_empty(); + + if has_codeup { + info!("Codeup configuration detected!"); + info!(" - Project ID: {}", codeup.project_id); + if !codeup.csrf_token.is_empty() { + info!(" - CSRF token: {}", codeup.csrf_token.mask()); + } + if !codeup.cookie.is_empty() { + info!(" - Cookie: {}", codeup.cookie.mask()); + } + br!(); + } else { + info!("No Codeup configuration detected."); + br!(); + } + + if !has_codeup { + // 仅在 workflow setup 流程中询问是否配置;workflow codeup setup 已明确意图,直接进入表单 + if mode == WorkflowMode::Setup { + let should_configure = confirm!("Do you want to configure Codeup?") + .default(true) + .result_title("Configure Codeup") + .prompt() + .map_err(|e: PromptError| Box::new(e) as Box)?; + if !should_configure { + return Ok(()); + } + } + } else if mode == WorkflowMode::Setup && has_codeup { + let keep = confirm!("Existing Codeup configuration detected. Keep current values?") + .default(true) + .result_title("Keep Codeup configuration") + .prompt() + .map_err(|e: PromptError| Box::new(e) as Box)?; + + if keep { + return Ok(()); + } + } + + Self::run_form(settings)?; + + Ok(()) + } + + fn is_configured(&self, settings: &GlobalConfig) -> bool { + !settings.codeup.is_empty() + } + + fn verify( + &self, + service: &dyn VerificationService, + ) -> Result, Box> { + service + .verify_codeup_config() + .map(|r| Box::new(r) as Box) + .map_err(|e| Box::new(e) as Box) + } +} + +/// 获取 Codeup 阶段实例 +pub fn codeup_stage() -> &'static dyn WorkflowStage { + &CodeupStage +} diff --git a/crates/app/src/interactive/platforms/mod.rs b/crates/app/src/interactive/platforms/mod.rs index 7049b01d..65be5715 100644 --- a/crates/app/src/interactive/platforms/mod.rs +++ b/crates/app/src/interactive/platforms/mod.rs @@ -2,12 +2,14 @@ //! //! 包含各个平台的工作流阶段实现。 +mod codeup; mod github; mod jira; mod llm; mod log; mod ssh; +pub use codeup::codeup_stage; pub use github::github_stage; pub use jira::jira_stage; pub use llm::llm_stage; diff --git a/crates/app/src/interactive/platforms/ssh.rs b/crates/app/src/interactive/platforms/ssh.rs index d4793c20..64ef1f0d 100644 --- a/crates/app/src/interactive/platforms/ssh.rs +++ b/crates/app/src/interactive/platforms/ssh.rs @@ -3,7 +3,7 @@ use std::error::Error; use domain::{GlobalConfig, VerificationService}; -use prompt::{br, confirm, select, separator, success, warning}; +use prompt::{br, confirm, info, select, separator, success, warning}; use crate::{ bootstrap::{get_ssh_service, get_verification_service}, @@ -11,9 +11,31 @@ use crate::{ core::{WorkflowContext, WorkflowMode, WorkflowStage}, display::VerificationResultFormatter, }, - util::{add_ssh_key, generate_ssh_key, remove_ssh_key, GenerateOptions}, + util::{add_ssh_key, generate_ssh_key, has_unloaded_keys, remove_ssh_key, GenerateOptions}, }; +/// SSH 配置操作选项 +#[derive(Debug, Clone, PartialEq)] +enum SshAction { + AddExistingKey, + GenerateNewKey, + RemoveKey, + ContinueToNextStep, + Done, +} + +impl std::fmt::Display for SshAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AddExistingKey => write!(f, "Add an existing key to the agent"), + Self::GenerateNewKey => write!(f, "Generate a new SSH key"), + Self::RemoveKey => write!(f, "Remove a key from the agent"), + Self::ContinueToNextStep => write!(f, "Continue to next step"), + Self::Done => write!(f, "Done"), + } + } +} + /// SSH 工作流阶段 pub struct SshStage; @@ -46,55 +68,75 @@ impl WorkflowStage for SshStage { loop { br!(); let verification_result = get_verification_service().verify_ssh_config()?; - verification_result.format(); + // Setup 使用简洁展示(与 Jira 一致),check 使用 VerificationResultFormatter 表格 + if verification_result.agent_available { + if verification_result.loaded_keys.is_empty() { + info!("SSH agent: running (no keys loaded)"); + info!(" - Run `workflow ssh add` to load a key."); + } else { + info!("SSH configuration detected!"); + info!( + " - SSH agent: running ({} key(s) loaded)", + verification_result.loaded_keys.len() + ); + for key in &verification_result.loaded_keys { + info!( + " - {} ({}): {}", + key.comment, key.algorithm, key.fingerprint + ); + } + } + } br!(); let has_keys = !verification_result.loaded_keys.is_empty(); - let has_key_files = !ssh.scan_keys().is_empty(); + let has_unloaded_keys = has_unloaded_keys(); - let mut options = Vec::new(); - if has_key_files { - options.push("Add an existing key to the agent".to_string()); + let mut options: Vec = Vec::new(); + if has_unloaded_keys { + options.push(SshAction::AddExistingKey); } - options.push("Generate a new SSH key".to_string()); - if has_keys { - options.push("Remove a key from the agent".to_string()); + options.push(SshAction::GenerateNewKey); + if has_keys && context.mode() == WorkflowMode::Command { + options.push(SshAction::RemoveKey); } - - let exit_option = if context.mode() == WorkflowMode::Setup { - "Continue to next step" - } else { - "Done" - }; - options.push(exit_option.to_string()); - - let selected = select!("What would you like to do?", options).prompt()?; - - if selected.contains("Generate") { - br!(); - let key_path = generate_ssh_key(GenerateOptions::default())?; - br!(); - let add_now = confirm!("Add the new key to the ssh-agent now?") - .default(true) - .result_title("Add key to agent") - .prompt()?; - if add_now { - add_ssh_key(Some(key_path), None)?; + options.push(match context.mode() { + WorkflowMode::Setup => SshAction::ContinueToNextStep, + WorkflowMode::Command => SshAction::Done, + }); + + let default_idx = options.len() - 1; + let selected = + select!("What would you like to do?", options).default(default_idx).prompt()?; + + match selected { + SshAction::AddExistingKey => { + br!(); + add_ssh_key(None, None)?; + break; } - break; - } else if selected.contains("Add an existing") { - br!(); - add_ssh_key(None, None)?; - break; - } else if selected.contains("Remove a key") { - br!(); - if let Err(e) = remove_ssh_key(None, false) { - warning!("{}", e); - } else { + SshAction::GenerateNewKey => { + br!(); + let key_path = generate_ssh_key(GenerateOptions::default())?; + br!(); + let add_now = confirm!("Add the new key to the ssh-agent now?") + .default(true) + .result_title("Add key to agent") + .prompt()?; + if add_now { + add_ssh_key(Some(key_path), None)?; + } break; } - } else if selected.contains(exit_option) { - break; + SshAction::RemoveKey => { + br!(); + if let Err(e) = remove_ssh_key(None, false) { + warning!("{}", e); + } else { + break; + } + } + SshAction::ContinueToNextStep | SshAction::Done => break, } } diff --git a/crates/app/src/logger/manager.rs b/crates/app/src/logger/manager.rs index 59b28a45..cfd303b3 100644 --- a/crates/app/src/logger/manager.rs +++ b/crates/app/src/logger/manager.rs @@ -33,6 +33,7 @@ impl LoggerManagerImpl { Command::Log(_) => Some("log"), Command::Llm(_) => Some("llm"), Command::Github(_) => Some("github"), + Command::Codeup(_) => Some("codeup"), Command::Jira(_) => Some("jira"), Command::Ssh(_) => Some("ssh"), Command::Branch(_) => Some("branch"), diff --git a/crates/app/src/util/mod.rs b/crates/app/src/util/mod.rs index c9db3956..3b377e20 100644 --- a/crates/app/src/util/mod.rs +++ b/crates/app/src/util/mod.rs @@ -8,8 +8,8 @@ pub use branch::{ generate_branch_name_from_template, select_branch_type, to_slug, }; pub use ssh::{ - add_ssh_key, ensure_ssh_ready, generate_ssh_key, remove_ssh_key, GenerateOptions, - SshOperationError, + add_ssh_key, ensure_ssh_ready, generate_ssh_key, has_unloaded_keys, remove_ssh_key, + GenerateOptions, SshOperationError, }; pub use sync::{safe_pull, safe_push, PullOptions}; pub use version::{compare_versions, get_current_version, get_target_version, VersionComparison}; diff --git a/crates/app/src/util/ssh/add.rs b/crates/app/src/util/ssh/add.rs index c38d4feb..78d99b25 100644 --- a/crates/app/src/util/ssh/add.rs +++ b/crates/app/src/util/ssh/add.rs @@ -6,16 +6,54 @@ use prompt::{info, select}; use crate::bootstrap::get_ssh_service; use crate::util::SshOperationError; +/// 是否存在可添加到 agent 的密钥(磁盘上有且未加载) +pub fn has_unloaded_keys() -> bool { + let ssh = get_ssh_service(); + let all_keys = ssh.scan_keys(); + if all_keys.is_empty() { + return false; + } + if !ssh.is_agent_available() { + return true; + } + let loaded_paths: std::collections::HashSet<_> = ssh + .list_loaded_keys() + .unwrap_or_default() + .iter() + .filter_map(|k| ssh.find_key_path_by_fingerprint(&k.fingerprint)) + .collect(); + all_keys.iter().any(|p| !loaded_paths.contains(p)) +} + fn select_key_interactively() -> Result { let ssh = get_ssh_service(); - let keys = ssh.scan_keys(); + let all_keys = ssh.scan_keys(); - if keys.is_empty() { + if all_keys.is_empty() { return Err(SshOperationError::OperationFailed( "No SSH keys found in ~/.ssh/. Run `workflow ssh generate` to create one.".into(), )); } + // 过滤掉已在 agent 中的密钥 + let keys: Vec = if ssh.is_agent_available() { + let loaded_paths: std::collections::HashSet<_> = ssh + .list_loaded_keys() + .unwrap_or_default() + .iter() + .filter_map(|k| ssh.find_key_path_by_fingerprint(&k.fingerprint)) + .collect(); + all_keys.into_iter().filter(|p| !loaded_paths.contains(p)).collect() + } else { + all_keys + }; + + if keys.is_empty() { + return Err(SshOperationError::OperationFailed( + "All SSH keys are already loaded in the agent.".into(), + )); + } + if keys.len() == 1 { info!("Found key: {}", keys[0].display()); return Ok(keys[0].clone()); diff --git a/crates/app/src/util/ssh/mod.rs b/crates/app/src/util/ssh/mod.rs index 1d7352e8..e8226f98 100644 --- a/crates/app/src/util/ssh/mod.rs +++ b/crates/app/src/util/ssh/mod.rs @@ -4,7 +4,7 @@ mod error; mod generate; mod remove; -pub use add::add_ssh_key; +pub use add::{add_ssh_key, has_unloaded_keys}; pub use ensure::ensure_ssh_ready; pub use error::SshOperationError; pub use generate::{generate_ssh_key, GenerateOptions}; diff --git a/crates/client/src/codeup/client.rs b/crates/client/src/codeup/client.rs new file mode 100644 index 00000000..588d5543 --- /dev/null +++ b/crates/client/src/codeup/client.rs @@ -0,0 +1,70 @@ +//! Codeup API 客户端 + +use serde_json::Value; + +use crate::{ + codeup::{CodeupClientError, CodeupResponse}, + http::HttpMethod, +}; + +pub struct CodeupRequest { + pub path: String, + pub method: HttpMethod, + pub body: Option, + pub query: Option, +} + +pub trait CodeupClient: Send + Sync { + /// 执行 Codeup API 请求(核心方法) + fn execute(&self, request: CodeupRequest) -> Result; + + /// GET 请求 + fn get(&self, path: &str) -> Result { + self.execute(CodeupRequest { + path: path.to_string(), + method: HttpMethod::GET, + body: None, + query: None, + }) + } + + /// POST 请求 + fn post(&self, path: &str, body: &Value) -> Result { + self.execute(CodeupRequest { + path: path.to_string(), + method: HttpMethod::POST, + body: Some(body.clone()), + query: None, + }) + } + + /// PUT 请求 + fn put(&self, path: &str, body: &Value) -> Result { + self.execute(CodeupRequest { + path: path.to_string(), + method: HttpMethod::PUT, + body: Some(body.clone()), + query: None, + }) + } + + /// PATCH 请求 + fn patch(&self, path: &str, body: &Value) -> Result { + self.execute(CodeupRequest { + path: path.to_string(), + method: HttpMethod::PATCH, + body: Some(body.clone()), + query: None, + }) + } + + /// DELETE 请求 + fn delete(&self, path: &str) -> Result { + self.execute(CodeupRequest { + path: path.to_string(), + method: HttpMethod::DELETE, + body: None, + query: None, + }) + } +} diff --git a/crates/client/src/codeup/context.rs b/crates/client/src/codeup/context.rs new file mode 100644 index 00000000..3a8d6bd8 --- /dev/null +++ b/crates/client/src/codeup/context.rs @@ -0,0 +1,15 @@ +//! Codeup Provider 接口 + +use crate::codeup::CodeupClientError; + +/// Codeup Context trait +/// +/// 提供 Codeup API 所需的配置信息 +pub trait CodeupConfigContext: Send + Sync { + /// 获取项目 ID + fn get_project_id(&self) -> Result; + /// 获取 CSRF Token + fn get_csrf_token(&self) -> Result; + /// 获取 Cookie + fn get_cookie(&self) -> Result; +} diff --git a/crates/client/src/codeup/error.rs b/crates/client/src/codeup/error.rs new file mode 100644 index 00000000..5f3b0c7a --- /dev/null +++ b/crates/client/src/codeup/error.rs @@ -0,0 +1,28 @@ +//! Codeup 客户端错误类型 + +use thiserror::Error; + +/// Codeup API 错误 +#[derive(Error, Debug)] +pub enum CodeupClientError { + #[error("Codeup API 调用失败: {0}")] + ApiError(String), + + #[error("认证失败,请检查 CSRF Token 和 Cookie")] + AuthenticationFailed, + + #[error("配置错误: {0}")] + ConfigError(String), + + #[error("HTTP 错误: {0}")] + HttpError(String), + + #[error("JSON 解析错误: {0}")] + JsonError(String), +} + +impl From for CodeupClientError { + fn from(err: crate::HttpError) -> Self { + CodeupClientError::HttpError(err.to_string()) + } +} diff --git a/crates/client/src/codeup/mod.rs b/crates/client/src/codeup/mod.rs new file mode 100644 index 00000000..87667a80 --- /dev/null +++ b/crates/client/src/codeup/mod.rs @@ -0,0 +1,14 @@ +//! Codeup API 客户端模块 + +mod client; +mod context; +mod error; +mod types; + +pub use client::{CodeupClient, CodeupRequest}; +pub use context::CodeupConfigContext; +pub use error::CodeupClientError; +pub use types::{ + CodeupErrorResponse, CodeupPullRequestListResponse, CodeupResponse, + CreateCodeupPullRequestRequest, CreateCodeupPullRequestResponse, +}; diff --git a/crates/client/src/codeup/types.rs b/crates/client/src/codeup/types.rs new file mode 100644 index 00000000..f5480f04 --- /dev/null +++ b/crates/client/src/codeup/types.rs @@ -0,0 +1,74 @@ +//! Codeup API 响应和错误处理 + +use crate::{CodeupClientError, HttpResponse}; +use serde::{Deserialize, Serialize}; + +/// HTTP 响应格式 +#[derive(Debug)] +pub struct CodeupResponse { + response: HttpResponse, +} + +impl CodeupResponse { + pub fn new(response: HttpResponse) -> Self { + Self { response } + } + + /// 解析为 JSON + pub fn json(&self) -> Result + where + T: for<'de> Deserialize<'de>, + { + self.response.json() + } + + /// 解析为文本 + pub fn text(&self) -> Result<&str, CodeupClientError> { + self.response.text().map_err(|e| CodeupClientError::ApiError(e.to_string())) + } + + pub fn status_code(&self) -> u16 { + self.response.status + } +} + +/// Codeup 错误响应结构 +#[derive(Debug, Deserialize)] +pub struct CodeupErrorResponse { + pub message: String, + pub error: Option, +} + +/// Codeup PR 创建请求 +#[derive(Debug, Serialize)] +pub struct CreateCodeupPullRequestRequest { + pub title: String, + pub description: String, + pub source_branch: String, + pub target_branch: String, +} + +/// Codeup PR 创建响应 +#[derive(Debug, Deserialize)] +pub struct CreateCodeupPullRequestResponse { + pub id: i64, + pub iid: i64, + pub title: String, + pub description: Option, + pub state: String, + pub source_branch: String, + pub target_branch: String, + pub web_url: String, +} + +/// Codeup PR 列表响应 +#[derive(Debug, Deserialize)] +pub struct CodeupPullRequestListResponse { + pub id: i64, + pub iid: i64, + pub title: String, + pub state: String, + pub source_branch: String, + pub target_branch: String, + pub web_url: String, +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index ef2d4806..70f7bce0 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -9,12 +9,19 @@ //! - LLM: `LLMClient` trait //! - GitHub: `GitHubClient` trait //! - Jira: `JiraClient` trait +//! - Codeup: `CodeupClient` trait +mod codeup; mod github; mod http; mod jira; mod llm; +pub use codeup::{ + CodeupClient, CodeupClientError, CodeupConfigContext, CodeupErrorResponse, + CodeupPullRequestListResponse, CodeupRequest, CodeupResponse, CreateCodeupPullRequestRequest, + CreateCodeupPullRequestResponse, +}; pub use github::{ GitHubClient, GitHubClientError, GitHubConfigContext, GitHubErrorResource, GitHubErrorResponse, GitHubRequest, GitHubResponse, diff --git a/crates/domain/src/codeup/entity.rs b/crates/domain/src/codeup/entity.rs new file mode 100644 index 00000000..ea29d3a0 --- /dev/null +++ b/crates/domain/src/codeup/entity.rs @@ -0,0 +1,47 @@ +//! Codeup 实体类型 + +use serde::{Deserialize, Serialize}; + +/// Codeup 用户信息 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodeupUser { + pub id: i64, + pub name: String, + pub email: Option, +} + +impl CodeupUser { + /// 创建新的 Codeup 用户 + pub fn new(id: i64, name: impl Into) -> Self { + Self { + id, + name: name.into(), + email: None, + } + } + + /// 设置用户邮箱 + pub fn with_email(mut self, email: impl Into) -> Self { + self.email = Some(email.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_codeup_user_new() { + let user = CodeupUser::new(123, "test_user"); + assert_eq!(user.id, 123); + assert_eq!(user.name, "test_user"); + assert_eq!(user.email, None); + } + + #[test] + fn test_codeup_user_with_email() { + let user = CodeupUser::new(123, "test_user").with_email("test@example.com"); + assert_eq!(user.email, Some("test@example.com".to_string())); + } +} diff --git a/crates/domain/src/codeup/error.rs b/crates/domain/src/codeup/error.rs new file mode 100644 index 00000000..cf1072c2 --- /dev/null +++ b/crates/domain/src/codeup/error.rs @@ -0,0 +1,35 @@ +//! Codeup 错误类型 + +use thiserror::Error; + +/// Codeup API 错误 +#[derive(Error, Debug)] +pub enum CodeupError { + #[error("Codeup API 调用失败: {0}")] + ApiError(String), + + #[error("认证失败,请检查 CSRF Token 和 Cookie 是否有效")] + AuthenticationFailed, + + #[error("资源不存在: {0}")] + NotFound(String), + + #[error("权限不足")] + InsufficientPermissions, + + #[error("配置不完整,请检查 codeup.project_id、codeup.csrf_token 和 codeup.cookie")] + ConfigurationIncomplete, + + #[error("PR 已存在: {0}")] + PullRequestAlreadyExists(String), + + #[error("PR 无法合并: {0}")] + PullRequestNotMergeable(String), +} + +/// 从 CodeupClientError 转换为 CodeupError +impl From for CodeupError { + fn from(err: client::CodeupClientError) -> Self { + CodeupError::ApiError(err.to_string()) + } +} diff --git a/crates/domain/src/codeup/mod.rs b/crates/domain/src/codeup/mod.rs new file mode 100644 index 00000000..602532a1 --- /dev/null +++ b/crates/domain/src/codeup/mod.rs @@ -0,0 +1,12 @@ +//! Codeup 业务域 +//! +//! 包含 Codeup 相关的实体、仓储接口和错误类型 + +pub mod entity; +pub mod error; +pub mod repository; + +pub use crate::config::CodeupSettings; +pub use entity::CodeupUser; +pub use error::CodeupError; +pub use repository::CodeupRepository; diff --git a/crates/domain/src/codeup/repository.rs b/crates/domain/src/codeup/repository.rs new file mode 100644 index 00000000..13af5fad --- /dev/null +++ b/crates/domain/src/codeup/repository.rs @@ -0,0 +1,85 @@ +//! Codeup 仓储接口 +//! +//! 定义与 Codeup REST API 交互的底层接口。 + +use crate::{ + codeup::{entity::CodeupUser, error::CodeupError}, + pr::entity::PullRequestInfo, +}; + +/// Codeup 仓储接口 +/// +/// 提供与 Codeup REST API 交互的底层接口,封装了 Pull Request 和用户信息的操作。 +pub trait CodeupRepository: Send + Sync { + /// 创建 Pull Request + fn create_pull_request( + &self, + title: &str, + body: &str, + source_branch: &str, + target_branch: &str, + ) -> Result; + + /// 获取 Pull Request 信息 + fn get_pull_request(&self, pr_id: &str) -> Result; + + /// 合并 Pull Request + fn merge_pull_request(&self, pr_id: &str, force: bool) -> Result<(), CodeupError>; + + /// 获取用户信息 + fn get_user_info(&self) -> Result; + + /// 关闭 Pull Request + fn close_pull_request(&self, pr_id: &str) -> Result<(), CodeupError>; + + /// 列出 Pull Requests + fn list_pull_requests( + &self, + state: Option<&str>, + limit: Option, + ) -> Result, CodeupError>; + + /// 更新 Pull Request 的标题和/或描述 + fn update_pull_request( + &self, + pr_id: &str, + title: Option<&str>, + body: Option<&str>, + ) -> Result<(), CodeupError>; + + /// 添加评论到 Pull Request + fn add_comment(&self, pr_id: &str, comment: &str) -> Result<(), CodeupError>; + + /// 批准 Pull Request + fn approve_pull_request(&self, pr_id: &str) -> Result<(), CodeupError>; + + /// 获取 Pull Request 的 diff 内容 + fn get_pr_diff(&self, pr_id: &str) -> Result; + + /// 获取 PR 信息(格式化字符串) + fn get_pull_request_info(&self, pr_id: &str) -> Result; + + /// 获取 PR URL + fn get_pull_request_url(&self, pr_id: &str) -> Result; + + /// 获取 PR 标题 + fn get_pull_request_title(&self, pr_id: &str) -> Result; + + /// 获取 PR body 内容 + fn get_pull_request_body(&self, pr_id: &str) -> Result, CodeupError>; + + /// 获取 PR 状态 + fn get_pull_request_status( + &self, + pr_id: &str, + ) -> Result<(String, bool, Option), CodeupError>; + + /// 更新 PR 的 base 分支 + fn update_pr_base(&self, pr_id: &str, new_base: &str) -> Result<(), CodeupError>; + + /// 获取当前分支的 PR ID + fn get_current_branch_pull_request( + &self, + current_branch: &str, + ) -> Result, CodeupError>; +} diff --git a/crates/domain/src/config/global/codeup/config.rs b/crates/domain/src/config/global/codeup/config.rs new file mode 100644 index 00000000..9069af50 --- /dev/null +++ b/crates/domain/src/config/global/codeup/config.rs @@ -0,0 +1,102 @@ +//! Codeup 配置相关结构体 + +use serde::{Deserialize, Serialize}; + +/// Codeup 配置(TOML) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CodeupSettings { + /// Codeup 项目 ID + #[serde(default, skip_serializing_if = "String::is_empty")] + pub project_id: String, + /// Codeup CSRF Token + #[serde(default, skip_serializing_if = "String::is_empty")] + pub csrf_token: String, + /// Codeup Cookie + #[serde(default, skip_serializing_if = "String::is_empty")] + pub cookie: String, +} + +impl CodeupSettings { + /// 检查 Codeup 配置是否为空 + pub fn is_empty(&self) -> bool { + self.project_id.is_empty() && self.csrf_token.is_empty() && self.cookie.is_empty() + } + + /// 检查 Codeup 配置是否完整(用于 API 调用) + pub fn is_complete(&self) -> bool { + !self.project_id.is_empty() && !self.csrf_token.is_empty() && !self.cookie.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_codeup_settings_is_empty() { + let empty_settings = CodeupSettings::default(); + assert!(empty_settings.is_empty()); + + let non_empty_settings = CodeupSettings { + project_id: "12345".to_string(), + csrf_token: String::new(), + cookie: String::new(), + }; + assert!(!non_empty_settings.is_empty()); + } + + #[test] + fn test_codeup_settings_is_complete() { + let incomplete_settings = CodeupSettings { + project_id: "12345".to_string(), + csrf_token: String::new(), + cookie: String::new(), + }; + assert!(!incomplete_settings.is_complete()); + + let complete_settings = CodeupSettings { + project_id: "12345".to_string(), + csrf_token: "csrf_token_value".to_string(), + cookie: "cookie_value".to_string(), + }; + assert!(complete_settings.is_complete()); + } + + #[test] + fn test_codeup_settings_serialize() { + let settings = CodeupSettings { + project_id: "12345".to_string(), + csrf_token: "csrf_token_value".to_string(), + cookie: "cookie_value".to_string(), + }; + + let toml = toml::to_string(&settings).unwrap(); + assert!(toml.contains("project_id = \"12345\"")); + assert!(toml.contains("csrf_token = \"csrf_token_value\"")); + assert!(toml.contains("cookie = \"cookie_value\"")); + } + + #[test] + fn test_codeup_settings_deserialize() { + let toml = r#" + project_id = "12345" + csrf_token = "csrf_token_value" + cookie = "cookie_value" + "#; + + let settings: CodeupSettings = toml::from_str(toml).unwrap(); + assert_eq!(settings.project_id, "12345"); + assert_eq!(settings.csrf_token, "csrf_token_value"); + assert_eq!(settings.cookie, "cookie_value"); + } + + #[test] + fn test_codeup_settings_serialize_skip_empty() { + let settings = CodeupSettings::default(); + let toml = toml::to_string(&settings).unwrap(); + // 空配置应该生成空字符串(因为 skip_serializing_if) + assert!(!toml.contains("project_id")); + assert!(!toml.contains("csrf_token")); + assert!(!toml.contains("cookie")); + } +} diff --git a/crates/domain/src/config/global/codeup/mod.rs b/crates/domain/src/config/global/codeup/mod.rs new file mode 100644 index 00000000..17382237 --- /dev/null +++ b/crates/domain/src/config/global/codeup/mod.rs @@ -0,0 +1,7 @@ +//! Codeup 配置模块 + +pub mod config; +pub mod verification; + +pub use config::CodeupSettings; +pub use verification::{CodeupConfigInfo, CodeupVerificationResult, CodeupVerificationStatus}; diff --git a/crates/domain/src/config/global/codeup/verification.rs b/crates/domain/src/config/global/codeup/verification.rs new file mode 100644 index 00000000..51b7f3ef --- /dev/null +++ b/crates/domain/src/config/global/codeup/verification.rs @@ -0,0 +1,35 @@ +//! Codeup 验证结果类型 + +/// Codeup 配置信息 +#[derive(Debug, Clone)] +pub struct CodeupConfigInfo { + /// 项目 ID + pub project_id: String, + /// CSRF Token(掩码显示) + pub csrf_token: String, + /// Cookie(掩码显示) + pub cookie: String, +} + +/// Codeup 验证状态 +#[derive(Debug, Clone)] +pub enum CodeupVerificationStatus { + /// 验证成功 + Success { username: String }, + /// 验证失败 + Failed { + reason: String, + details: Vec, + }, +} + +/// Codeup 验证结果 +#[derive(Debug, Clone)] +pub struct CodeupVerificationResult { + /// 是否已配置 + pub configured: bool, + /// 配置信息(如果已配置) + pub config: Option, + /// 验证结果 + pub verification: Option, +} diff --git a/crates/domain/src/config/global/config.rs b/crates/domain/src/config/global/config.rs index d2e78bc8..eacf659a 100644 --- a/crates/domain/src/config/global/config.rs +++ b/crates/domain/src/config/global/config.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::config::global::{ - github::config::GitHubSettings, jira::config::JiraSettings, llm::config::LLMSettings, - log::config::LogSettings, + codeup::config::CodeupSettings, github::config::GitHubSettings, jira::config::JiraSettings, + llm::config::LLMSettings, log::config::LogSettings, }; /// 全局配置 @@ -19,6 +19,9 @@ pub struct GlobalConfig { /// GitHub 配置 #[serde(default, skip_serializing_if = "GitHubSettings::is_empty")] pub github: GitHubSettings, + /// Codeup 配置 + #[serde(default, skip_serializing_if = "CodeupSettings::is_empty")] + pub codeup: CodeupSettings, /// 日志配置 #[serde(default, skip_serializing_if = "LogSettings::is_empty")] pub log: LogSettings, @@ -53,6 +56,7 @@ mod tests { service_address: "https://jira.example.com".to_string(), }, github: GitHubSettings::default(), + codeup: CodeupSettings::default(), log: LogSettings::default(), llm: LLMSettings::default(), aliases, diff --git a/crates/domain/src/config/global/mod.rs b/crates/domain/src/config/global/mod.rs index 38f47fc7..498b00e5 100644 --- a/crates/domain/src/config/global/mod.rs +++ b/crates/domain/src/config/global/mod.rs @@ -2,6 +2,7 @@ //! //! 从 workflow.toml 配置文件读取全局应用配置 +pub mod codeup; pub mod config; pub mod github; pub mod jira; @@ -12,6 +13,9 @@ pub mod ssh; pub mod verification_service; // Re-export public types +pub use codeup::{ + CodeupConfigInfo, CodeupSettings, CodeupVerificationResult, CodeupVerificationStatus, +}; pub use config::GlobalConfig; pub use github::{ GitHubAccount, GitHubAccountInfo, GitHubSettings, GitHubVerificationResult, diff --git a/crates/domain/src/config/global/verification_service.rs b/crates/domain/src/config/global/verification_service.rs index 77615fee..c28d5e72 100644 --- a/crates/domain/src/config/global/verification_service.rs +++ b/crates/domain/src/config/global/verification_service.rs @@ -4,9 +4,9 @@ use crate::config::error::ConfigError; use crate::config::global::{ - github::verification::GitHubVerificationResult, jira::verification::JiraVerificationResult, - llm::verification::LLMVerificationResult, log::verification::LogVerificationResult, - ssh::verification::SshVerificationResult, + codeup::verification::CodeupVerificationResult, github::verification::GitHubVerificationResult, + jira::verification::JiraVerificationResult, llm::verification::LLMVerificationResult, + log::verification::LogVerificationResult, ssh::verification::SshVerificationResult, }; /// 验证服务接口 @@ -17,6 +17,9 @@ pub trait VerificationService: Send + Sync { /// 验证 GitHub 配置 fn verify_github_config(&self) -> Result; + /// 验证 Codeup 配置 + fn verify_codeup_config(&self) -> Result; + /// 验证 LLM 配置 fn verify_llm_config(&self) -> Result; diff --git a/crates/domain/src/config/mod.rs b/crates/domain/src/config/mod.rs index 59c8b1f8..1f53770b 100644 --- a/crates/domain/src/config/mod.rs +++ b/crates/domain/src/config/mod.rs @@ -9,6 +9,10 @@ pub mod repo; // Re-export public types pub use error::ConfigError; pub use global::{ + CodeupConfigInfo, + CodeupSettings, + CodeupVerificationResult, + CodeupVerificationStatus, GitHubAccount, // Verification types GitHubAccountInfo, diff --git a/crates/domain/src/git/entity/repo.rs b/crates/domain/src/git/entity/repo.rs index 225b7c9d..06943b01 100644 --- a/crates/domain/src/git/entity/repo.rs +++ b/crates/domain/src/git/entity/repo.rs @@ -51,12 +51,16 @@ impl CodePlatform { /// 获取所有已实现的平台(不包括 Unknown) pub fn implemented() -> Vec { - vec![CodePlatform::GitHub, CodePlatform::CNB] + vec![ + CodePlatform::GitHub, + CodePlatform::CNB, + CodePlatform::Codeup, + ] } /// 检查平台是否已完全实现(支持完整的 PR 功能) pub fn is_fully_implemented(&self) -> bool { - matches!(self, CodePlatform::GitHub) + matches!(self, CodePlatform::GitHub | CodePlatform::Codeup) } /// 从字符串解析平台类型(不包括 Unknown) @@ -126,7 +130,7 @@ mod tests { fn test_code_platform_is_fully_implemented() { assert!(CodePlatform::GitHub.is_fully_implemented()); assert!(!CodePlatform::CNB.is_fully_implemented()); - assert!(!CodePlatform::Codeup.is_fully_implemented()); + assert!(CodePlatform::Codeup.is_fully_implemented()); assert!(!CodePlatform::Unknown.is_fully_implemented()); } @@ -156,9 +160,10 @@ mod tests { #[test] fn test_code_platform_implemented() { let implemented = CodePlatform::implemented(); - assert_eq!(implemented.len(), 2); + assert_eq!(implemented.len(), 3); assert!(implemented.contains(&CodePlatform::GitHub)); assert!(implemented.contains(&CodePlatform::CNB)); + assert!(implemented.contains(&CodePlatform::Codeup)); } #[test] diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 3bc22c24..8d587e03 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -6,6 +6,7 @@ pub(crate) mod alias; pub(crate) mod branch; +pub(crate) mod codeup; pub(crate) mod commit; pub(crate) mod completion; pub(crate) mod config; @@ -43,6 +44,9 @@ pub use completion::{ pub use config::{ BranchConfig, BranchTemplates, + CodeupConfigInfo, + CodeupVerificationResult, + CodeupVerificationStatus, CommitTemplates, ConfigError, GitHubAccount, @@ -87,6 +91,7 @@ pub use git::{ // Re-export SSH types pub use ssh::{SshError, SshKeyInfo, SshService}; // Re-export external service types +pub use codeup::{CodeupError, CodeupRepository, CodeupSettings, CodeupUser}; pub use github::{GitHubError, GitHubRepository, GitHubUser, GitHubVerificationService}; pub use jira::{ extract_jira_project, extract_jira_ticket_id, validate_jira_ticket_format, diff --git a/crates/infra/src/bootstrap.rs b/crates/infra/src/bootstrap.rs index df5b92c7..56ba109f 100644 --- a/crates/infra/src/bootstrap.rs +++ b/crates/infra/src/bootstrap.rs @@ -3,10 +3,11 @@ use std::sync::Arc; use di::{bind, Container, InjectionError, Scope}; use client::{ - GitHubClient, GitHubConfigContext, HttpClient, JiraClient, JiraConfigContext, LLMClient, - LLMConfigContext, LanguageManager, + CodeupClient, CodeupConfigContext, GitHubClient, GitHubConfigContext, HttpClient, JiraClient, + JiraConfigContext, LLMClient, LLMConfigContext, LanguageManager, }; +use crate::codeup::CodeupClientImpl; use crate::github::GitHubClientImpl; use crate::http::ReqwestHttpClient; use crate::jira::JiraClientImpl; @@ -46,5 +47,12 @@ pub fn register_client() -> Result<(), InjectionError> { }) .in_scope(Scope::Singleton)?; + bind!(dyn CodeupClient, |c: &Container| { + let context = c.get::()?; + let client = c.get::()?; + Ok(Arc::new(CodeupClientImpl::new(client, context))) + }) + .in_scope(Scope::Singleton)?; + Ok(()) } diff --git a/crates/infra/src/codeup/client.rs b/crates/infra/src/codeup/client.rs new file mode 100644 index 00000000..12ac613d --- /dev/null +++ b/crates/infra/src/codeup/client.rs @@ -0,0 +1,144 @@ +use std::{collections::HashMap, sync::Arc}; + +use client::{ + CodeupClient, CodeupClientError, CodeupConfigContext, CodeupErrorResponse, CodeupRequest, + CodeupResponse, HttpClientHolder, +}; +use client::{HttpClient, HttpResponse}; +use serde_json::Value; +use toolkit::log_debug; + +use crate::http::RestRequestBuilder; + +pub const API_BASE: &str = "https://codeup.aliyun.com"; + +/// Codeup API 客户端 +/// +/// 封装 Codeup API 的公共 HTTP 请求逻辑 +pub struct CodeupClientImpl { + holder: HttpClientHolder, + context: Arc, +} + +impl CodeupClientImpl { + pub fn new(http_client: Arc, context: Arc) -> Self { + let holder = HttpClientHolder::new(http_client); + Self { holder, context } + } + + /// 获取 Codeup API 请求的 headers + fn get_headers(&self) -> Result, CodeupClientError> { + let csrf_token = self.context.get_csrf_token()?; + let cookie = self.context.get_cookie()?; + + let mut headers = HashMap::new(); + headers.insert("X-CSRF-Token".to_string(), csrf_token); + headers.insert("Cookie".to_string(), cookie); + headers.insert("Accept".to_string(), "application/json".to_string()); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + headers.insert("User-Agent".to_string(), "workflow-cli".to_string()); + + Ok(headers) + } + + /// 将 Response 错误转换为 CodeupClientError + fn convert_to_codeup_error(&self, response: HttpResponse) -> CodeupClientError { + // 尝试解析 JSON 错误 + if let Ok(data) = response.json::() { + // 尝试解析为 Codeup 错误格式 + if let Ok(error) = serde_json::from_value::(data.clone()) { + return self.format_from_codeup_error(&error, &response); + } + + // 如果无法解析为 Codeup 格式,返回 JSON 字符串 + if let Ok(json_str) = serde_json::to_string_pretty(&data) { + let msg = format!( + "Codeup API 请求失败: {}\n\n响应:\n{}", + response.status, json_str + ); + return CodeupClientError::ApiError(msg); + } + } + + // 回退到简单错误 + let error_msg = response.get_error_message(); + if let Ok(error_msg) = error_msg { + return CodeupClientError::ApiError(error_msg); + } + CodeupClientError::ApiError(format!("Codeup API 请求失败: {}", response.status)) + } + + /// 格式化 Codeup 错误信息 + fn format_from_codeup_error( + &self, + error: &CodeupErrorResponse, + response: &HttpResponse, + ) -> CodeupClientError { + let mut msg = format!( + "Codeup API 错误: {} (状态码: {})", + error.message, response.status + ); + + if let Some(error_detail) = &error.error { + msg.push_str(&format!("\n错误详情: {}", error_detail)); + } + + // 尝试添加完整的错误响应 JSON 以便调试 + if let Ok(data) = response.json::() { + if let Ok(json_str) = serde_json::to_string_pretty(&data) { + msg.push_str(&format!("\n\n完整错误响应:\n{}", json_str)); + } + } else { + // 如果无法解析为 JSON,添加提取的错误消息 + let error_msg = response.get_error_message(); + if let Ok(error_msg) = error_msg { + if !error_msg.is_empty() { + msg.push_str(&format!("\n\n错误详情:\n{}", error_msg)); + } + } + } + + CodeupClientError::ApiError(msg) + } + + /// 构建完整的 API URL + fn build_url(&self, path: &str) -> String { + if path.starts_with("http://") || path.starts_with("https://") { + path.to_string() + } else { + format!("{}{}", API_BASE, path) + } + } +} + +impl CodeupClient for CodeupClientImpl { + fn execute(&self, request: CodeupRequest) -> Result { + let url = self.build_url(&request.path); + let headers = self.get_headers()?; + + log_debug!("Codeup API 请求: {} {}", request.method, url); + + // 使用 RestRequestBuilder 简化请求构建 + let response = RestRequestBuilder::new(&self.holder, request.method, url) + .headers(headers) + .body(request.body) + .query(request.query) + .execute() + .map_err(|e| CodeupClientError::HttpError(format!("请求失败: {}", e)))?; + + if response.is_success() { + // 记录成功响应的内容(如果可以解析为 JSON) + if let Ok(json) = response.json::() { + log_debug!("Codeup API 响应体: {}", json); + } + Ok(CodeupResponse::new(response)) + } else { + // 记录错误响应 + log_debug!( + "Codeup API 错误响应: {}", + response.get_error_message().map_err(|e| e.to_string()).unwrap_or_default() + ); + Err(self.convert_to_codeup_error(response)) + } + } +} diff --git a/crates/infra/src/codeup/mod.rs b/crates/infra/src/codeup/mod.rs new file mode 100644 index 00000000..fa4eb14c --- /dev/null +++ b/crates/infra/src/codeup/mod.rs @@ -0,0 +1,5 @@ +//! Codeup 基础设施模块 + +mod client; + +pub use client::CodeupClientImpl; diff --git a/crates/infra/src/lib.rs b/crates/infra/src/lib.rs index 3ad3aa23..def6aa7f 100644 --- a/crates/infra/src/lib.rs +++ b/crates/infra/src/lib.rs @@ -3,6 +3,7 @@ //! 提供跨领域的基础设施能力,作为底层技术支撑。 pub mod bootstrap; +pub mod codeup; pub mod github; pub mod http; pub mod jira; diff --git a/crates/services/src/bootstrap.rs b/crates/services/src/bootstrap.rs index dccc4d44..8f51008c 100644 --- a/crates/services/src/bootstrap.rs +++ b/crates/services/src/bootstrap.rs @@ -7,9 +7,10 @@ use std::sync::Arc; use client::{GitHubClient, LLMClient, LLMConfigContext, LanguageManager}; use di::{bind, Container, InjectionError, Scope}; use domain::{ - AliasService, BranchService, CommitMessageService, CommitSummaryService, CompletionService, - GitHubRepository, GitHubVerificationService, GitRepository, GlobalConfigRepository, - JiraRepository, PathService, PullRequestService, SshService, VerificationService, + AliasService, BranchService, CodeupRepository, CommitMessageService, CommitSummaryService, + CompletionService, GitHubRepository, GitHubVerificationService, GitRepository, + GlobalConfigRepository, JiraRepository, PathService, PullRequestService, SshService, + VerificationService, }; use crate::{ @@ -105,6 +106,7 @@ pub fn register_services() -> Result<(), InjectionError> { let config_repository = c.get::()?; let jira_repository = c.get::()?; let github_verification_service = c.get::()?; + let codeup_repository = c.get::()?; let llm_language_manager = c.get::()?; let ssh_service = c.get::()?; Ok(Arc::new(VerificationServiceImpl::new( @@ -113,6 +115,7 @@ pub fn register_services() -> Result<(), InjectionError> { config_repository, jira_repository, github_verification_service, + codeup_repository, ssh_service, ))) }) diff --git a/crates/services/src/config/service.rs b/crates/services/src/config/service.rs index 72a56bc9..f98bf673 100644 --- a/crates/services/src/config/service.rs +++ b/crates/services/src/config/service.rs @@ -23,6 +23,7 @@ use client::{ IntoLLMRequestParameters, LLMClient, LLMConversation, LanguageManager, SupportedLanguage, }; use domain::{ + CodeupConfigInfo, CodeupRepository, CodeupVerificationResult, CodeupVerificationStatus, ConfigError, GitHubAccountInfo, GitHubVerificationResult, GitHubVerificationService, GitHubVerificationSummary, GlobalConfigRepository, JiraConfigInfo, JiraRepository, JiraVerificationResult, JiraVerificationStatus, LLMConfig, LLMSettings, LLMVerificationResult, @@ -74,6 +75,7 @@ pub(crate) struct VerificationServiceImpl { config_repository: Arc, jira_repository: Arc, github_verification_service: Arc, + codeup_repository: Arc, ssh_service: Arc, } @@ -84,6 +86,7 @@ impl VerificationServiceImpl { config_repository: Arc, jira_repository: Arc, github_verification_service: Arc, + codeup_repository: Arc, ssh_service: Arc, ) -> Self { Self { @@ -92,6 +95,7 @@ impl VerificationServiceImpl { config_repository, jira_repository, github_verification_service, + codeup_repository, ssh_service, } } @@ -203,6 +207,42 @@ impl VerificationService for VerificationServiceImpl { }) } + /// 验证 Codeup 配置 + fn verify_codeup_config(&self) -> Result { + let global_config = self.config_repository.load()?; + let codeup_settings = &global_config.codeup; + + if codeup_settings.is_empty() { + return Ok(CodeupVerificationResult { + configured: false, + config: None, + verification: None, + }); + } + + let config = CodeupConfigInfo { + project_id: codeup_settings.project_id.clone(), + csrf_token: codeup_settings.csrf_token.mask(), + cookie: codeup_settings.cookie.mask(), + }; + + let verification = match self.codeup_repository.get_user_info() { + Ok(user) => Some(CodeupVerificationStatus::Success { + username: user.name.clone(), + }), + Err(e) => Some(CodeupVerificationStatus::Failed { + reason: e.to_string(), + details: vec![], + }), + }; + + Ok(CodeupVerificationResult { + configured: true, + config: Some(config), + verification, + }) + } + /// 验证 LLM 配置 fn verify_llm_config(&self) -> Result { let global_config = self.config_repository.load()?; diff --git a/crates/storage/src/bootstrap/codeup.rs b/crates/storage/src/bootstrap/codeup.rs new file mode 100644 index 00000000..88c95490 --- /dev/null +++ b/crates/storage/src/bootstrap/codeup.rs @@ -0,0 +1,70 @@ +//! Codeup 服务注册 + +use std::sync::Arc; + +use client::CodeupClient; +use di::{bind, Container, InjectionError, Scope}; +use domain::{CodeupRepository, GlobalConfigRepository}; + +use crate::codeup::{ + CodeupRepositoryImpl, PullRequestMutationService, PullRequestMutationServiceImpl, + PullRequestQueryService, PullRequestQueryServiceImpl, ServiceContext, ServiceContextImpl, +}; + +/// 注册 Codeup 相关服务 +/// +/// # 注册顺序和依赖关系 +/// +/// 服务注册顺序: +/// 1. **CodeupConfigContext** (外部注册) - 必须在调用此函数前注册 +/// 2. **CodeupClient** (依赖 CodeupConfigContext) +/// 3. **ServiceContext** (依赖 CodeupSettings) +/// 4. **PullRequestQueryService** (依赖 CodeupClient, ServiceContext) +/// 5. **PullRequestMutationService** (依赖 CodeupClient, ServiceContext) +/// 6. **CodeupRepository** (依赖上述服务) +pub(super) fn register_codeup() -> Result<(), InjectionError> { + // Service Context + bind!(dyn ServiceContext, |c: &Container| { + let global_config = c.get::().map_err(|e| { + InjectionError::ValidationError(format!("Failed to get config repository: {}", e)) + })?; + let config = global_config.load().map_err(|e| { + InjectionError::ValidationError(format!("Failed to load config: {}", e)) + })?; + Ok(Arc::new(ServiceContextImpl::new(Arc::new(config.codeup)))) + }) + .in_scope(Scope::Singleton)?; + + // Pull Request Query Service + bind!(dyn PullRequestQueryService, |c: &Container| { + let client = c.get::()?; + let context = c.get::()?; + Ok(Arc::new(PullRequestQueryServiceImpl::new(client, context))) + }) + .in_scope(Scope::Singleton)?; + + // Pull Request Mutation Service + bind!(dyn PullRequestMutationService, |c: &Container| { + let client = c.get::()?; + let context = c.get::()?; + Ok(Arc::new(PullRequestMutationServiceImpl::new( + client, context, + ))) + }) + .in_scope(Scope::Singleton)?; + + // Codeup Repository + bind!(dyn CodeupRepository, |c: &Container| { + let query_service = c.get::()?; + let mutation_service = c.get::()?; + let context = c.get::()?; + Ok(Arc::new(CodeupRepositoryImpl::new( + mutation_service, + query_service, + context, + ))) + }) + .in_scope(Scope::Singleton)?; + + Ok(()) +} diff --git a/crates/storage/src/bootstrap/mod.rs b/crates/storage/src/bootstrap/mod.rs index c8bc3897..4f5604ab 100644 --- a/crates/storage/src/bootstrap/mod.rs +++ b/crates/storage/src/bootstrap/mod.rs @@ -4,6 +4,7 @@ use di::InjectionError; +mod codeup; mod config; mod git; mod github; @@ -17,6 +18,7 @@ pub fn register_storage() -> Result<(), InjectionError> { git::register_git()?; jira::register_jira()?; github::register_github()?; + codeup::register_codeup()?; ssh::register_ssh()?; Ok(()) diff --git a/crates/storage/src/codeup/mod.rs b/crates/storage/src/codeup/mod.rs new file mode 100644 index 00000000..c4683afc --- /dev/null +++ b/crates/storage/src/codeup/mod.rs @@ -0,0 +1,14 @@ +//! Codeup 存储实现 +//! +//! 本模块提供了与 Codeup API 交互的完整功能 + +mod repository; +mod services; +mod types; + +// 仅在本 crate 内使用 +pub(crate) use repository::CodeupRepositoryImpl; +pub(crate) use services::{ + PullRequestMutationService, PullRequestMutationServiceImpl, PullRequestQueryService, + PullRequestQueryServiceImpl, ServiceContext, ServiceContextImpl, +}; diff --git a/crates/storage/src/codeup/repository.rs b/crates/storage/src/codeup/repository.rs new file mode 100644 index 00000000..00b854b6 --- /dev/null +++ b/crates/storage/src/codeup/repository.rs @@ -0,0 +1,180 @@ +//! Codeup 仓储实现 + +use std::sync::Arc; + +use domain::{CodeupError, CodeupRepository, CodeupUser, PullRequestInfo as DomainPullRequestInfo}; + +use crate::codeup::services::{ + PullRequestMutationService, PullRequestQueryService, ServiceContext, +}; + +/// Codeup 仓储实现 +pub struct CodeupRepositoryImpl { + mutation_service: Arc, + query_service: Arc, + #[allow(dead_code)] + context: Arc, +} + +impl CodeupRepositoryImpl { + pub fn new( + mutation_service: Arc, + query_service: Arc, + context: Arc, + ) -> Self { + Self { + mutation_service, + query_service, + context, + } + } + + /// 验证 PR ID + fn validate_pr_id(&self, pr_id: &str) -> Result<(), CodeupError> { + if pr_id.is_empty() { + return Err(CodeupError::ApiError("PR ID 不能为空".to_string())); + } + Ok(()) + } +} + +impl CodeupRepository for CodeupRepositoryImpl { + fn create_pull_request( + &self, + title: &str, + body: &str, + source_branch: &str, + target_branch: &str, + ) -> Result { + if title.is_empty() { + return Err(CodeupError::ApiError("PR 标题不能为空".to_string())); + } + if source_branch.is_empty() { + return Err(CodeupError::ApiError("源分支不能为空".to_string())); + } + if target_branch.is_empty() { + return Err(CodeupError::ApiError("目标分支不能为空".to_string())); + } + + let pr_url = + self.mutation_service + .create_pull_request(title, body, source_branch, target_branch)?; + + // 从 URL 中提取 PR ID + let pr_id = pr_url + .rsplit('/') + .next() + .ok_or_else(|| CodeupError::ApiError(format!("无法从 URL 提取 PR ID: {}", pr_url)))? + .to_string(); + + Ok(pr_id) + } + + fn get_pull_request(&self, pr_id: &str) -> Result { + self.validate_pr_id(pr_id)?; + // 需要将 Codeup 的 PullRequestInfo 转换为 domain 的 PullRequestInfo + // 这里简化处理,实际应该做类型转换 + Err(CodeupError::ApiError("需要实现类型转换".to_string())) + } + + fn merge_pull_request(&self, pr_id: &str, force: bool) -> Result<(), CodeupError> { + self.validate_pr_id(pr_id)?; + self.mutation_service.merge_pull_request(pr_id, force) + } + + fn get_user_info(&self) -> Result { + self.query_service.get_user_info() + } + + fn close_pull_request(&self, pr_id: &str) -> Result<(), CodeupError> { + self.validate_pr_id(pr_id)?; + self.mutation_service.close_pull_request(pr_id) + } + + fn list_pull_requests( + &self, + _state: Option<&str>, + _limit: Option, + ) -> Result, CodeupError> { + // 需要类型转换 + Err(CodeupError::ApiError("需要实现类型转换".to_string())) + } + + fn update_pull_request( + &self, + pr_id: &str, + title: Option<&str>, + body: Option<&str>, + ) -> Result<(), CodeupError> { + self.validate_pr_id(pr_id)?; + self.mutation_service.update_pull_request(pr_id, title, body) + } + + fn add_comment(&self, pr_id: &str, comment: &str) -> Result<(), CodeupError> { + self.validate_pr_id(pr_id)?; + if comment.is_empty() { + return Err(CodeupError::ApiError("评论内容不能为空".to_string())); + } + // 需要实现评论服务 + Err(CodeupError::ApiError("评论功能暂未实现".to_string())) + } + + fn approve_pull_request(&self, pr_id: &str) -> Result<(), CodeupError> { + self.validate_pr_id(pr_id)?; + // 需要实现批准服务 + Err(CodeupError::ApiError("批准功能暂未实现".to_string())) + } + + fn get_pr_diff(&self, pr_id: &str) -> Result { + self.validate_pr_id(pr_id)?; + // 需要实现 diff 获取服务 + Err(CodeupError::ApiError("获取 diff 功能暂未实现".to_string())) + } + + fn get_pull_request_info(&self, pr_id: &str) -> Result { + self.validate_pr_id(pr_id)?; + self.query_service.get_pull_request_info(pr_id) + } + + fn get_pull_request_url(&self, pr_id: &str) -> Result { + self.validate_pr_id(pr_id)?; + self.query_service.get_pull_request_url(pr_id) + } + + fn get_pull_request_title(&self, pr_id: &str) -> Result { + self.validate_pr_id(pr_id)?; + self.query_service.get_pull_request_title(pr_id) + } + + fn get_pull_request_body(&self, pr_id: &str) -> Result, CodeupError> { + self.validate_pr_id(pr_id)?; + self.query_service.get_pull_request_body(pr_id) + } + + fn get_pull_request_status( + &self, + pr_id: &str, + ) -> Result<(String, bool, Option), CodeupError> { + self.validate_pr_id(pr_id)?; + self.query_service.get_pull_request_status(pr_id) + } + + fn update_pr_base(&self, pr_id: &str, new_base: &str) -> Result<(), CodeupError> { + self.validate_pr_id(pr_id)?; + if new_base.is_empty() { + return Err(CodeupError::ApiError("新 base 分支不能为空".to_string())); + } + // 需要通过更新 PR 来实现 + self.mutation_service.update_pull_request(pr_id, None, None) + } + + fn get_current_branch_pull_request( + &self, + current_branch: &str, + ) -> Result, CodeupError> { + if current_branch.is_empty() { + return Err(CodeupError::ApiError("当前分支不能为空".to_string())); + } + self.query_service.get_current_branch_pull_request(current_branch) + } +} diff --git a/crates/storage/src/codeup/services/context.rs b/crates/storage/src/codeup/services/context.rs new file mode 100644 index 00000000..d6b5ff02 --- /dev/null +++ b/crates/storage/src/codeup/services/context.rs @@ -0,0 +1,38 @@ +//! Codeup 服务上下文 + +use std::sync::Arc; + +use domain::{CodeupError, CodeupSettings}; + +pub trait ServiceContext: Send + Sync { + /// 获取项目 ID + fn get_project_id(&self) -> Result; + /// 解析 PR ID 为 iid + fn parse_pr_id(&self, pr_id: &str) -> Result { + pr_id.parse::().map_err(|_| { + CodeupError::ApiError("无效的 PR ID: 应为数字 ID (例如: '123')".to_string()) + }) + } +} + +/// Codeup 服务上下文 +pub struct ServiceContextImpl { + settings: Arc, +} + +impl ServiceContextImpl { + /// 创建新的服务上下文 + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +impl ServiceContext for ServiceContextImpl { + /// 从配置获取项目 ID + fn get_project_id(&self) -> Result { + if self.settings.project_id.is_empty() { + return Err(CodeupError::ConfigurationIncomplete); + } + Ok(self.settings.project_id.clone()) + } +} diff --git a/crates/storage/src/codeup/services/mod.rs b/crates/storage/src/codeup/services/mod.rs new file mode 100644 index 00000000..c7bceb6e --- /dev/null +++ b/crates/storage/src/codeup/services/mod.rs @@ -0,0 +1,9 @@ +//! Codeup 服务模块 + +pub mod context; +pub mod mutation; +pub mod query; + +pub use context::{ServiceContext, ServiceContextImpl}; +pub use mutation::{PullRequestMutationService, PullRequestMutationServiceImpl}; +pub use query::{PullRequestQueryService, PullRequestQueryServiceImpl}; diff --git a/crates/storage/src/codeup/services/mutation.rs b/crates/storage/src/codeup/services/mutation.rs new file mode 100644 index 00000000..c8a93be4 --- /dev/null +++ b/crates/storage/src/codeup/services/mutation.rs @@ -0,0 +1,147 @@ +//! Codeup Pull Request 变更服务 + +use std::sync::Arc; + +use domain::CodeupError; + +use crate::codeup::{ + services::ServiceContext, + types::{ + CreatePullRequestRequest, CreatePullRequestResponse, MergePullRequestRequest, + UpdatePullRequestRequest, + }, +}; +use client::CodeupClient; + +/// Pull Request 变更服务接口 +pub trait PullRequestMutationService: Send + Sync { + /// 创建 Pull Request + fn create_pull_request( + &self, + title: &str, + body: &str, + source_branch: &str, + target_branch: &str, + ) -> Result; + + /// 合并 Pull Request + fn merge_pull_request(&self, pr_id: &str, force: bool) -> Result<(), CodeupError>; + + /// 关闭 Pull Request + fn close_pull_request(&self, pr_id: &str) -> Result<(), CodeupError>; + + /// 更新 Pull Request 的标题和/或描述 + fn update_pull_request( + &self, + pr_id: &str, + title: Option<&str>, + body: Option<&str>, + ) -> Result<(), CodeupError>; +} + +/// Pull Request 变更服务实现 +pub struct PullRequestMutationServiceImpl { + client: Arc, + context: Arc, +} + +impl PullRequestMutationServiceImpl { + pub fn new(client: Arc, context: Arc) -> Self { + Self { client, context } + } +} + +impl PullRequestMutationService for PullRequestMutationServiceImpl { + fn create_pull_request( + &self, + title: &str, + body: &str, + source_branch: &str, + target_branch: &str, + ) -> Result { + let project_id = self.context.get_project_id()?; + let url = format!("/api/v3/projects/{}/code_reviews", project_id); + + let request = CreatePullRequestRequest { + title: title.to_string(), + description: body.to_string(), + source_branch: source_branch.to_string(), + target_branch: target_branch.to_string(), + }; + + let body = serde_json::to_value(&request) + .map_err(|e| CodeupError::ApiError(format!("序列化请求失败: {}", e)))?; + let response = self.client.post(&url, &body)?; + let json_value = response + .json() + .map_err(|e| CodeupError::ApiError(format!("解析响应 JSON 失败: {}", e)))?; + let response_data: CreatePullRequestResponse = serde_json::from_value(json_value) + .map_err(|e| CodeupError::ApiError(format!("反序列化响应失败: {}", e)))?; + + Ok(response_data.web_url) + } + + fn merge_pull_request(&self, pr_id: &str, force: bool) -> Result<(), CodeupError> { + let project_id = self.context.get_project_id()?; + let pr_iid = self.context.parse_pr_id(pr_id)?; + + let url = format!( + "/api/v3/projects/{}/code_reviews/{}/merge", + project_id, pr_iid + ); + + let request = MergePullRequestRequest { + merge_commit_message: None, + should_remove_source_branch: force, + }; + + let body = serde_json::to_value(&request) + .map_err(|e| CodeupError::ApiError(format!("序列化请求失败: {}", e)))?; + self.client.post(&url, &body)?; + + Ok(()) + } + + fn close_pull_request(&self, pr_id: &str) -> Result<(), CodeupError> { + let project_id = self.context.get_project_id()?; + let pr_iid = self.context.parse_pr_id(pr_id)?; + + let url = format!("/api/v3/projects/{}/code_reviews/{}", project_id, pr_iid); + + let request = UpdatePullRequestRequest { + title: None, + description: None, + state: Some("closed".to_string()), + }; + + let body = serde_json::to_value(&request) + .map_err(|e| CodeupError::ApiError(format!("序列化请求失败: {}", e)))?; + self.client.patch(&url, &body)?; + + Ok(()) + } + + fn update_pull_request( + &self, + pr_id: &str, + title: Option<&str>, + body: Option<&str>, + ) -> Result<(), CodeupError> { + let project_id = self.context.get_project_id()?; + let pr_iid = self.context.parse_pr_id(pr_id)?; + + let url = format!("/api/v3/projects/{}/code_reviews/{}", project_id, pr_iid); + + let request = UpdatePullRequestRequest { + title: title.map(|s| s.to_string()), + description: body.map(|s| s.to_string()), + state: None, + }; + + let body = serde_json::to_value(&request) + .map_err(|e| CodeupError::ApiError(format!("序列化请求失败: {}", e)))?; + self.client.patch(&url, &body)?; + + Ok(()) + } +} diff --git a/crates/storage/src/codeup/services/query.rs b/crates/storage/src/codeup/services/query.rs new file mode 100644 index 00000000..a6b4c250 --- /dev/null +++ b/crates/storage/src/codeup/services/query.rs @@ -0,0 +1,186 @@ +//! Codeup Pull Request 查询服务 + +use std::{fmt::Write, sync::Arc}; + +use domain::{CodeupError, CodeupUser}; + +use crate::codeup::{services::ServiceContext, types::PullRequestInfo}; +use client::CodeupClient; + +/// Pull Request 查询服务接口 +pub trait PullRequestQueryService: Send + Sync { + /// 获取 PR 信息 + fn get_pull_request_info(&self, pr_id: &str) -> Result; + + /// 获取 PR URL + fn get_pull_request_url(&self, pr_id: &str) -> Result; + + /// 获取 PR 标题 + fn get_pull_request_title(&self, pr_id: &str) -> Result; + + /// 获取 PR body 内容 + fn get_pull_request_body(&self, pr_id: &str) -> Result, CodeupError>; + + /// 获取 PR 状态 + fn get_pull_request_status( + &self, + pr_id: &str, + ) -> Result<(String, bool, Option), CodeupError>; + + /// 获取 PR 列表 + #[allow(dead_code)] + fn get_pull_requests( + &self, + state: Option<&str>, + limit: Option, + ) -> Result, CodeupError>; + + /// 获取当前分支的 PR ID + fn get_current_branch_pull_request( + &self, + current_branch: &str, + ) -> Result, CodeupError>; + + /// 获取 PR 信息(内部方法) + fn fetch_pr_info(&self, pr_iid: i64) -> Result; + + /// 获取 Codeup 用户信息 + fn get_user_info(&self) -> Result; +} + +/// Pull Request 查询服务实现 +pub struct PullRequestQueryServiceImpl { + client: Arc, + context: Arc, +} + +impl PullRequestQueryServiceImpl { + pub fn new(client: Arc, context: Arc) -> Self { + Self { client, context } + } +} + +impl PullRequestQueryService for PullRequestQueryServiceImpl { + fn get_pull_request_info(&self, pr_id: &str) -> Result { + let pr_iid = self.context.parse_pr_id(pr_id)?; + let pr = self.fetch_pr_info(pr_iid)?; + + let mut info = String::new(); + writeln!(info, "标题: {}", pr.title).ok(); + if let Some(desc) = pr.description { + writeln!(info, "描述: {}", desc).ok(); + } + writeln!(info, "状态: {}", pr.state).ok(); + writeln!(info, "源分支: {}", pr.source_branch).ok(); + writeln!(info, "目标分支: {}", pr.target_branch).ok(); + writeln!(info, "URL: {}", pr.web_url).ok(); + + Ok(info) + } + + fn get_pull_request_url(&self, pr_id: &str) -> Result { + let pr_iid = self.context.parse_pr_id(pr_id)?; + let pr = self.fetch_pr_info(pr_iid)?; + Ok(pr.web_url) + } + + fn get_pull_request_title(&self, pr_id: &str) -> Result { + let pr_iid = self.context.parse_pr_id(pr_id)?; + let pr = self.fetch_pr_info(pr_iid)?; + Ok(pr.title) + } + + fn get_pull_request_body(&self, pr_id: &str) -> Result, CodeupError> { + let pr_iid = self.context.parse_pr_id(pr_id)?; + let pr = self.fetch_pr_info(pr_iid)?; + Ok(pr.description) + } + + fn get_pull_request_status( + &self, + pr_id: &str, + ) -> Result<(String, bool, Option), CodeupError> { + let pr_iid = self.context.parse_pr_id(pr_id)?; + let pr = self.fetch_pr_info(pr_iid)?; + Ok((pr.state, pr.merged, None)) + } + + fn get_pull_requests( + &self, + state: Option<&str>, + limit: Option, + ) -> Result, CodeupError> { + let project_id = self.context.get_project_id()?; + + let state_param = match state { + Some("open") => "opened", + Some("closed") => "closed", + Some("merged") => "merged", + _ => "all", + }; + + let per_page = limit.unwrap_or(30).min(100); + let url = format!( + "/api/v3/projects/{}/code_reviews?state={}&per_page={}", + project_id, state_param, per_page + ); + + let response = self.client.get(&url)?; + let json_value = response + .json() + .map_err(|e| CodeupError::ApiError(format!("解析响应 JSON 失败: {}", e)))?; + let prs: Vec = serde_json::from_value(json_value) + .map_err(|e| CodeupError::ApiError(format!("反序列化 PR 列表失败: {}", e)))?; + + Ok(prs) + } + + fn get_current_branch_pull_request( + &self, + current_branch: &str, + ) -> Result, CodeupError> { + let project_id = self.context.get_project_id()?; + + for state in ["opened", "all"] { + let url = format!( + "/api/v3/projects/{}/code_reviews?source_branch={}&state={}", + project_id, current_branch, state + ); + + let response = self.client.get(&url)?; + let json_value = response + .json() + .map_err(|e| CodeupError::ApiError(format!("解析响应 JSON 失败: {}", e)))?; + let prs: Vec = serde_json::from_value(json_value) + .map_err(|e| CodeupError::ApiError(format!("反序列化 PR 列表失败: {}", e)))?; + + if let Some(pr) = prs.first() { + return Ok(Some(pr.iid.to_string())); + } + } + + Ok(None) + } + + fn fetch_pr_info(&self, pr_iid: i64) -> Result { + let project_id = self.context.get_project_id()?; + + let url = format!("/api/v3/projects/{}/code_reviews/{}", project_id, pr_iid); + + let response = self.client.get(&url)?; + let json_value = response + .json() + .map_err(|e| CodeupError::ApiError(format!("解析响应 JSON 失败: {}", e)))?; + let pr_info: PullRequestInfo = serde_json::from_value(json_value) + .map_err(|e| CodeupError::ApiError(format!("反序列化 PR 信息失败: {}", e)))?; + Ok(pr_info) + } + + fn get_user_info(&self) -> Result { + // Codeup API 可能需要通过其他方式获取用户信息 + // 这里暂时返回错误,后续根据实际 API 调整 + Err(CodeupError::ApiError( + "获取用户信息功能暂未实现".to_string(), + )) + } +} diff --git a/crates/storage/src/codeup/types.rs b/crates/storage/src/codeup/types.rs new file mode 100644 index 00000000..348fa99a --- /dev/null +++ b/crates/storage/src/codeup/types.rs @@ -0,0 +1,76 @@ +//! Codeup API 类型定义 + +use serde::{Deserialize, Serialize}; + +/// 创建 Pull Request 请求 +#[derive(Debug, Serialize)] +pub struct CreatePullRequestRequest { + pub title: String, + pub description: String, + pub source_branch: String, + pub target_branch: String, +} + +/// 创建 Pull Request 响应 +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct CreatePullRequestResponse { + pub id: i64, + pub iid: i64, + pub title: String, + pub description: Option, + pub state: String, + pub source_branch: String, + pub target_branch: String, + pub web_url: String, +} + +/// 更新 Pull Request 请求 +#[derive(Debug, Serialize, Default)] +pub struct UpdatePullRequestRequest { + pub title: Option, + pub description: Option, + pub state: Option, +} + +/// 合并 Pull Request 请求 +#[derive(Debug, Serialize)] +pub struct MergePullRequestRequest { + pub merge_commit_message: Option, + pub should_remove_source_branch: bool, +} + +/// Pull Request 信息 +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] +pub struct PullRequestInfo { + pub id: i64, + pub iid: i64, + pub title: String, + pub description: Option, + pub state: String, + pub source_branch: String, + pub target_branch: String, + pub web_url: String, + #[serde(default)] + pub merged: bool, + #[serde(default)] + pub mergeable: Option, + pub author: Option, +} + +/// Codeup 用户信息 +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] +pub struct CodeupUser { + pub id: i64, + pub name: String, + pub email: Option, +} + +/// 添加评论请求 +#[derive(Debug, Serialize)] +#[allow(dead_code)] +pub struct AddCommentRequest { + pub body: String, +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 9b531348..bd47e8a4 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -17,6 +17,7 @@ pub mod git; pub mod testing; pub(crate) mod bootstrap; +pub(crate) mod codeup; pub(crate) mod config; pub(crate) mod github; pub(crate) mod jira;