Merge pull request 'feat(cicd): 增加 Agent 安全工作流投影' (#17) from feat/cicd-workflow-t10 into master
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
Reviewed-on: #17
This commit is contained in:
commit
57db32aaf0
@ -15,5 +15,6 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
|
||||
4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。
|
||||
5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。
|
||||
6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
|
||||
7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。
|
||||
|
||||
如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。
|
||||
|
||||
@ -173,6 +173,8 @@ modules:
|
||||
area: infra
|
||||
status: done
|
||||
progress: 100
|
||||
updated: 2026-09-22
|
||||
note: "Gitea workflow 已具备 Agent 安全投影:先只读预览,再以摘要锁定和显式确认写入;不接收凭证值,不触发 CI 或部署。"
|
||||
position: [0, 650]
|
||||
|
||||
- id: auto-update
|
||||
|
||||
@ -57,3 +57,10 @@ CI/CD 配置管理和 GitHub Actions Workflow 文件生成。支持两种部署
|
||||
查看 GitHub Actions 运行状态,支持手动触发。
|
||||
|
||||
> ⚠️ 已随 github-decommission(2026-07-02)移除,仅作历史记录;Gitea Actions 接入后按新事实源重建。
|
||||
|
||||
### ✅ T10. CI/CD 能力的 Agent 安全投影
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/cicd.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/models.rs, src-tauri/src/lib.rs, src/lib/commands.ts, docs/ai-context/project-context.yaml, .agents/skills/lian-jing/SKILL.md, src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, src-tauri/resources/onboarding/.lian-jing/capability-packs.json, .lian-jing/capability-packs.json, plugins/lian-jing-codex/.codex-plugin/plugin.json, plugins/lian-jing-codex/skills/lian-jing/SKILL.md
|
||||
- acceptance: "agent_verify: [cargo test] plan_cicd_workflow(project_id, workflow_type) 返回目标文件、工具链来源、required_secrets、change 与 content_digest,且磁盘零写入;apply_cicd_workflow 在 confirmed=false、摘要不一致、未知项目、配置缺失、路径越界或工具链冲突时零写入并返回证据,只有 confirmed=true 且摘要匹配时写入预览对应的 `.gitea/workflows/` 文件;[MCP schema] 支持 pr_ci/deepseek_review/web_ssh/tauri_oss,不暴露密钥值、任意命令或远程执行参数;cargo test --lib、pnpm typecheck、git diff --check 通过,pnpm-lock.yaml 无改动。"
|
||||
- decision_note: "保留 distribute_cicd 作为 pr_ci/deepseek_review 兼容入口;部署类 workflow 只开放 plan/apply,两阶段确认仅写项目文件,不触发 Gitea Actions、SSH、部署、回滚或 migration。"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"packs": [
|
||||
{
|
||||
"pack_id": "base-onboarding",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"supported_clients": ["codex", "claude"],
|
||||
"project_artifacts": [
|
||||
"AGENTS.md",
|
||||
|
||||
@ -13,8 +13,9 @@ contracts:
|
||||
- "src-tauri/src/mcp_tools.rs"
|
||||
- "src-tauri/src/commands/delivery_status.rs"
|
||||
- "src-tauri/src/commands/toolchain.rs"
|
||||
- "src-tauri/src/commands/cicd.rs"
|
||||
- "src-tauri/src/commands/gitea.rs"
|
||||
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功"
|
||||
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
||||
|
||||
- label: 被管理项目身份上下文
|
||||
paths:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lian-jing-codex",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Guide Codex through Lian Jing project context and existing MCP workflows.",
|
||||
"author": {
|
||||
"name": "Lian Jing"
|
||||
|
||||
@ -12,7 +12,8 @@ description: Inspect an already onboarded Lian Jing project, use its existing MC
|
||||
3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding`、`get_toolchain_status` 和 `get_delivery_status` 获取可观察信号;所有调用显式传入 `project_id`。
|
||||
4. Node/pnpm 项目运行包管理命令前先读取工具链状态;`ready_managed` 时使用 `mise exec -- pnpm ...`。只有用户明确确认下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配 Codex bundled runtime 修改项目版本或 lockfile。
|
||||
5. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
|
||||
6. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。
|
||||
7. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。
|
||||
6. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。
|
||||
7. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。
|
||||
8. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。
|
||||
|
||||
本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。
|
||||
|
||||
@ -15,5 +15,6 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
|
||||
4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。
|
||||
5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。
|
||||
6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
|
||||
7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。
|
||||
|
||||
如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"packs": [
|
||||
{
|
||||
"pack_id": "base-onboarding",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"supported_clients": ["codex", "claude"],
|
||||
"project_artifacts": [
|
||||
"AGENTS.md",
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
// 历史部署教程与踩坑指南见 docs/tauri-cicd-guide.md(原 GitHub Actions 方案)
|
||||
use crate::commands::toolchain::parse_toolchain_contract;
|
||||
use crate::db;
|
||||
use crate::models::{CicdConfig, WorkflowResult};
|
||||
use rusqlite::params;
|
||||
use std::path::Path;
|
||||
use crate::models::{CicdConfig, WorkflowApplyResult, WorkflowPlan, WorkflowResult};
|
||||
use rusqlite::{params, Connection};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@ -118,9 +119,7 @@ impl WorkflowToolchain {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_cicd_config(project_id: String) -> Result<Option<CicdConfig>, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
fn get_cicd_config_for(conn: &Connection, project_id: &str) -> Result<Option<CicdConfig>, String> {
|
||||
let result = conn.query_row(
|
||||
"SELECT id, project_id, config_type, trigger_branch, build_command, node_version,
|
||||
server_host, server_user, deploy_path, start_command, oss_endpoint, created_at
|
||||
@ -129,12 +128,291 @@ pub fn get_cicd_config(project_id: String) -> Result<Option<CicdConfig>, String>
|
||||
CicdConfig::from_row,
|
||||
);
|
||||
match result {
|
||||
Ok(cfg) => Ok(Some(cfg)),
|
||||
Ok(config) => Ok(Some(config)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_config(project_id: &str, workflow_type: &str) -> CicdConfig {
|
||||
CicdConfig {
|
||||
id: String::new(),
|
||||
project_id: project_id.to_string(),
|
||||
config_type: workflow_type.to_string(),
|
||||
trigger_branch: "main".to_string(),
|
||||
build_command: None,
|
||||
node_version: None,
|
||||
server_host: None,
|
||||
server_user: None,
|
||||
deploy_path: None,
|
||||
start_command: None,
|
||||
oss_endpoint: None,
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_for_workflow(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
workflow_type: &str,
|
||||
) -> Result<CicdConfig, String> {
|
||||
match workflow_type {
|
||||
"pr_ci" | "deepseek_review" => Ok(get_cicd_config_for(conn, project_id)?
|
||||
.filter(|config| config.config_type == workflow_type)
|
||||
.unwrap_or_else(|| minimal_config(project_id, workflow_type))),
|
||||
"web_ssh" | "tauri_oss" => {
|
||||
let config = get_cicd_config_for(conn, project_id)?.ok_or_else(|| {
|
||||
format!(
|
||||
"项目 {project_id} 尚未保存 {workflow_type} CI/CD 配置;请先在炼境 UI 配置,Agent 不接收服务器地址、命令或密钥值"
|
||||
)
|
||||
})?;
|
||||
if config.config_type != workflow_type {
|
||||
return Err(format!(
|
||||
"项目 {project_id} 当前保存的 CI/CD 类型为 {},不能按 {workflow_type} 生成;请先由用户在炼境 UI 确认配置类型",
|
||||
config.config_type
|
||||
));
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
other => Err(format!(
|
||||
"不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review / web_ssh / tauri_oss)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_registered_project_root(conn: &Connection, project_id: &str) -> Result<PathBuf, String> {
|
||||
if project_id.trim().is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
|
||||
let (win_path, wsl_path, platform): (Option<String>, Option<String>, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
|
||||
params![project_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.map_err(|_| format!("项目 {project_id} 不存在"))?;
|
||||
|
||||
let configured_path = if platform.as_deref() == Some("wsl") {
|
||||
let wsl = wsl_path.ok_or("WSL 项目未配置 wsl_path")?;
|
||||
let distro: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'wsl_distro'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| "Ubuntu".to_string());
|
||||
let inner = wsl.trim_start_matches('/').replace('/', "\\");
|
||||
format!("\\\\wsl.localhost\\{distro}\\{inner}")
|
||||
} else {
|
||||
win_path.ok_or("项目未配置本地路径")?
|
||||
};
|
||||
|
||||
let root = PathBuf::from(&configured_path);
|
||||
if !root.is_absolute() {
|
||||
return Err(format!(
|
||||
"项目路径不是绝对路径,拒绝生成 workflow:{configured_path}"
|
||||
));
|
||||
}
|
||||
let canonical = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("项目路径不可访问 {configured_path}:{error}"))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err(format!("项目路径不是目录:{}", canonical.display()));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn workflow_target(root: &Path, filename: &str) -> Result<PathBuf, String> {
|
||||
let components = Path::new(filename).components().collect::<Vec<_>>();
|
||||
if components.len() != 1 || !matches!(components[0], Component::Normal(_)) {
|
||||
return Err(format!("非法 workflow 文件名,拒绝路径越界:{filename}"));
|
||||
}
|
||||
|
||||
let gitea_dir = root.join(".gitea");
|
||||
let workflows_dir = gitea_dir.join("workflows");
|
||||
let existing_parent = if workflows_dir.exists() {
|
||||
workflows_dir.canonicalize()
|
||||
} else if gitea_dir.exists() {
|
||||
gitea_dir.canonicalize()
|
||||
} else {
|
||||
root.canonicalize()
|
||||
}
|
||||
.map_err(|error| format!("无法校验 workflow 目录边界:{error}"))?;
|
||||
|
||||
if !existing_parent.starts_with(root) {
|
||||
return Err(format!(
|
||||
"workflow 目录越出项目根目录,拒绝写入:{}",
|
||||
existing_parent.display()
|
||||
));
|
||||
}
|
||||
|
||||
let target = workflows_dir.join(filename);
|
||||
if target.exists() {
|
||||
let canonical_target = target
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("无法校验 workflow 文件边界:{error}"))?;
|
||||
if !canonical_target.starts_with(root) {
|
||||
return Err(format!(
|
||||
"workflow 文件越出项目根目录,拒绝写入:{}",
|
||||
canonical_target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn content_digest(content: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn build_workflow_for_type(
|
||||
root: &Path,
|
||||
config: &CicdConfig,
|
||||
workflow_type: &str,
|
||||
) -> Result<(String, String, Vec<String>, Option<WorkflowToolchain>), String> {
|
||||
match workflow_type {
|
||||
"web_ssh" => {
|
||||
let toolchain = WorkflowToolchain::from_project(root, config)?;
|
||||
let (content, filename, secrets) = build_web_ssh_workflow(config, &toolchain);
|
||||
Ok((content, filename, secrets, Some(toolchain)))
|
||||
}
|
||||
"tauri_oss" => {
|
||||
let toolchain = WorkflowToolchain::from_project(root, config)?;
|
||||
let (content, filename, secrets) = build_tauri_oss_workflow(config, &toolchain);
|
||||
Ok((content, filename, secrets, Some(toolchain)))
|
||||
}
|
||||
"pr_ci" => {
|
||||
let toolchain = WorkflowToolchain::from_project(root, config)?;
|
||||
let (content, filename, secrets) = build_pr_ci_workflow(config, &toolchain);
|
||||
Ok((content, filename, secrets, Some(toolchain)))
|
||||
}
|
||||
"deepseek_review" => {
|
||||
let (content, filename, secrets) = build_deepseek_review_workflow(config);
|
||||
Ok((content, filename, secrets, None))
|
||||
}
|
||||
other => Err(format!(
|
||||
"不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review / web_ssh / tauri_oss)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plan_cicd_workflow_for(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
workflow_type: &str,
|
||||
) -> Result<WorkflowPlan, String> {
|
||||
let root = resolve_registered_project_root(conn, project_id)?;
|
||||
let config = config_for_workflow(conn, project_id, workflow_type)?;
|
||||
let (content, filename, required_secrets, toolchain) =
|
||||
build_workflow_for_type(&root, &config, workflow_type)?;
|
||||
let target = workflow_target(&root, &filename)?;
|
||||
let change = match std::fs::read(&target) {
|
||||
Ok(existing) if existing == content.as_bytes() => "noop",
|
||||
Ok(_) => "update",
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => "create",
|
||||
Err(error) => return Err(format!("读取现有 workflow 失败:{error}")),
|
||||
}
|
||||
.to_string();
|
||||
|
||||
Ok(WorkflowPlan {
|
||||
project_id: project_id.to_string(),
|
||||
workflow_type: workflow_type.to_string(),
|
||||
content_digest: content_digest(&content),
|
||||
content,
|
||||
path: target.to_string_lossy().to_string(),
|
||||
change,
|
||||
required_secrets,
|
||||
node_source: toolchain.as_ref().map(|value| value.node_source.clone()),
|
||||
pnpm_source: toolchain.as_ref().map(|value| value.pnpm_source.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_cicd_workflow_for(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
workflow_type: &str,
|
||||
expected_digest: &str,
|
||||
confirmed: bool,
|
||||
) -> Result<WorkflowApplyResult, String> {
|
||||
if !confirmed {
|
||||
return Err("写入 CI workflow 需要当前用户明确确认,并传 confirmed=true".to_string());
|
||||
}
|
||||
if expected_digest.trim().is_empty() {
|
||||
return Err("expected_digest 不能为空;请先调用 plan_cicd_workflow".to_string());
|
||||
}
|
||||
|
||||
let mut plan = plan_cicd_workflow_for(conn, project_id, workflow_type)?;
|
||||
if plan.content_digest != expected_digest {
|
||||
return Err(format!(
|
||||
"workflow 预览已变化,拒绝写入:expected_digest={expected_digest},actual_digest={}",
|
||||
plan.content_digest
|
||||
));
|
||||
}
|
||||
|
||||
let root = resolve_registered_project_root(conn, project_id)?;
|
||||
let filename = Path::new(&plan.path)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.ok_or("workflow 目标文件名无效")?;
|
||||
let target = workflow_target(&root, filename)?;
|
||||
let workflows_dir = target.parent().ok_or("workflow 目标目录无效")?;
|
||||
std::fs::create_dir_all(workflows_dir)
|
||||
.map_err(|error| format!("创建 workflow 目录失败:{error}"))?;
|
||||
let canonical_dir = workflows_dir
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("无法校验 workflow 目录:{error}"))?;
|
||||
if !canonical_dir.starts_with(&root) {
|
||||
return Err(format!(
|
||||
"workflow 目录越出项目根目录,拒绝写入:{}",
|
||||
canonical_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
let written = plan.change != "noop";
|
||||
if written {
|
||||
std::fs::write(&target, plan.content.as_bytes())
|
||||
.map_err(|error| format!("写入 workflow 失败:{error}"))?;
|
||||
}
|
||||
plan.path = target.to_string_lossy().to_string();
|
||||
|
||||
Ok(WorkflowApplyResult { plan, written })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn plan_cicd_workflow(
|
||||
project_id: String,
|
||||
workflow_type: String,
|
||||
) -> Result<WorkflowPlan, String> {
|
||||
let conn = db::pool().get().map_err(|error| error.to_string())?;
|
||||
plan_cicd_workflow_for(&conn, &project_id, &workflow_type)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_cicd_workflow(
|
||||
project_id: String,
|
||||
workflow_type: String,
|
||||
expected_digest: String,
|
||||
confirmed: bool,
|
||||
) -> Result<WorkflowApplyResult, String> {
|
||||
let conn = db::pool().get().map_err(|error| error.to_string())?;
|
||||
apply_cicd_workflow_for(
|
||||
&conn,
|
||||
&project_id,
|
||||
&workflow_type,
|
||||
&expected_digest,
|
||||
confirmed,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_cicd_config(project_id: String) -> Result<Option<CicdConfig>, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
get_cicd_config_for(&conn, &project_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_cicd_config(input: CicdConfig) -> Result<CicdConfig, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
@ -196,21 +474,14 @@ pub fn generate_workflow(
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let project_root = Path::new(&project_path);
|
||||
let (content, filename, secrets) = match config.config_type.as_str() {
|
||||
"tauri_oss" => {
|
||||
let toolchain = WorkflowToolchain::from_project(project_root, &config)?;
|
||||
build_tauri_oss_workflow(&config, &toolchain)
|
||||
}
|
||||
"pr_ci" => {
|
||||
let toolchain = WorkflowToolchain::from_project(project_root, &config)?;
|
||||
build_pr_ci_workflow(&config, &toolchain)
|
||||
}
|
||||
"deepseek_review" => build_deepseek_review_workflow(&config),
|
||||
_ => {
|
||||
let toolchain = WorkflowToolchain::from_project(project_root, &config)?;
|
||||
build_web_ssh_workflow(&config, &toolchain)
|
||||
}
|
||||
let workflow_type = match config.config_type.as_str() {
|
||||
"tauri_oss" => "tauri_oss",
|
||||
"pr_ci" => "pr_ci",
|
||||
"deepseek_review" => "deepseek_review",
|
||||
_ => "web_ssh",
|
||||
};
|
||||
let (content, filename, secrets, _) =
|
||||
build_workflow_for_type(project_root, &config, workflow_type)?;
|
||||
|
||||
let workflows_dir = project_root.join(".gitea").join("workflows");
|
||||
std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?;
|
||||
@ -230,70 +501,26 @@ pub fn distribute_workflow_for_project(
|
||||
project_id: &str,
|
||||
workflow_type: &str,
|
||||
) -> Result<String, String> {
|
||||
if !matches!(workflow_type, "pr_ci" | "deepseek_review") {
|
||||
return Err(format!(
|
||||
"distribute_cicd 兼容入口仅支持 pr_ci / deepseek_review;{workflow_type} 请先调用 plan_cicd_workflow,再由用户确认摘要后调用 apply_cicd_workflow"
|
||||
));
|
||||
}
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
let (win_path, wsl_path, platform): (Option<String>, Option<String>, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
|
||||
params![project_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||||
|
||||
let project_path = if platform.as_deref() == Some("wsl") {
|
||||
let wsl = wsl_path.ok_or("WSL 项目未配置 wsl_path")?;
|
||||
let distro: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'wsl_distro'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| "Ubuntu".to_string());
|
||||
let inner = wsl.trim_start_matches('/').replace('/', "\\");
|
||||
format!("\\\\wsl.localhost\\{}\\{}", distro, inner)
|
||||
} else {
|
||||
win_path.ok_or("项目未配置本地路径")?
|
||||
};
|
||||
|
||||
// 最小 config,pr_ci 用 trigger_branch;deepseek_review 不使用 config 字段
|
||||
let minimal_cfg = crate::models::CicdConfig {
|
||||
id: String::new(),
|
||||
project_id: project_id.to_string(),
|
||||
config_type: workflow_type.to_string(),
|
||||
trigger_branch: "main".to_string(),
|
||||
build_command: None,
|
||||
node_version: None,
|
||||
server_host: None,
|
||||
server_user: None,
|
||||
deploy_path: None,
|
||||
start_command: None,
|
||||
oss_endpoint: None,
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
let (content, filename, secrets) = match workflow_type {
|
||||
"pr_ci" => {
|
||||
let toolchain =
|
||||
WorkflowToolchain::from_project(Path::new(&project_path), &minimal_cfg)?;
|
||||
build_pr_ci_workflow(&minimal_cfg, &toolchain)
|
||||
}
|
||||
"deepseek_review" => build_deepseek_review_workflow(&minimal_cfg),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review)"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let workflows_dir = Path::new(&project_path).join(".gitea").join("workflows");
|
||||
std::fs::create_dir_all(&workflows_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||
let file_path = workflows_dir.join(&filename);
|
||||
std::fs::write(&file_path, &content).map_err(|e| format!("写入文件失败: {e}"))?;
|
||||
|
||||
let secrets_hint = if secrets.is_empty() {
|
||||
let plan = plan_cicd_workflow_for(&conn, project_id, workflow_type)?;
|
||||
let digest = plan.content_digest.clone();
|
||||
let result = apply_cicd_workflow_for(&conn, project_id, workflow_type, &digest, true)?;
|
||||
let filename = Path::new(&result.plan.path)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("workflow.yml");
|
||||
let secrets_hint = if result.plan.required_secrets.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(",需要配置 secrets: {}", secrets.join(" / "))
|
||||
format!(
|
||||
",需要配置 secrets: {}",
|
||||
result.plan.required_secrets.join(" / ")
|
||||
)
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
@ -628,6 +855,44 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn setup_cicd_schema(conn: &Connection) {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE project_workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
win_path TEXT,
|
||||
wsl_path TEXT,
|
||||
platform TEXT
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
CREATE TABLE cicd_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL UNIQUE,
|
||||
config_type TEXT NOT NULL,
|
||||
trigger_branch TEXT NOT NULL,
|
||||
build_command TEXT,
|
||||
node_version TEXT,
|
||||
server_host TEXT,
|
||||
server_user TEXT,
|
||||
deploy_path TEXT,
|
||||
start_command TEXT,
|
||||
oss_endpoint TEXT,
|
||||
created_at TEXT
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn register_project(conn: &Connection, project_id: &str, root: &Path) {
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id, win_path, platform) VALUES (?1, ?2, 'windows')",
|
||||
params![project_id, root.to_string_lossy()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn cfg(config_type: &str) -> CicdConfig {
|
||||
CicdConfig {
|
||||
id: "t".into(),
|
||||
@ -801,4 +1066,108 @@ mod tests {
|
||||
assert!(error.contains(".node-version=22.14.0"));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_is_read_only_and_apply_requires_confirmation_and_digest() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
setup_cicd_schema(&conn);
|
||||
let root = temp_project("plan-apply");
|
||||
std::fs::write(root.join(".nvmrc"), "24.19.0\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"packageManager":"pnpm@9.15.0"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let unknown = plan_cicd_workflow_for(&conn, "missing", "pr_ci").unwrap_err();
|
||||
assert!(unknown.contains("项目 missing 不存在"));
|
||||
assert!(!root.join(".gitea").exists());
|
||||
|
||||
register_project(&conn, "p1", &root);
|
||||
|
||||
let plan = plan_cicd_workflow_for(&conn, "p1", "pr_ci").unwrap();
|
||||
let target = PathBuf::from(&plan.path);
|
||||
assert_eq!(plan.change, "create");
|
||||
assert_eq!(plan.content_digest.len(), 64);
|
||||
assert_eq!(plan.node_source.as_deref(), Some(".nvmrc"));
|
||||
assert_eq!(
|
||||
plan.pnpm_source.as_deref(),
|
||||
Some("package.json#packageManager")
|
||||
);
|
||||
assert!(!target.exists(), "预览不得写入 workflow");
|
||||
|
||||
let error =
|
||||
apply_cicd_workflow_for(&conn, "p1", "pr_ci", &plan.content_digest, false).unwrap_err();
|
||||
assert!(error.contains("明确确认"));
|
||||
assert!(!target.exists());
|
||||
|
||||
let error = apply_cicd_workflow_for(&conn, "p1", "pr_ci", "stale", true).unwrap_err();
|
||||
assert!(error.contains("预览已变化"));
|
||||
assert!(!target.exists());
|
||||
|
||||
let applied =
|
||||
apply_cicd_workflow_for(&conn, "p1", "pr_ci", &plan.content_digest, true).unwrap();
|
||||
assert!(applied.written);
|
||||
assert_eq!(applied.plan.change, "create");
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), plan.content);
|
||||
|
||||
let second_plan = plan_cicd_workflow_for(&conn, "p1", "pr_ci").unwrap();
|
||||
assert_eq!(second_plan.change, "noop");
|
||||
let second_apply =
|
||||
apply_cicd_workflow_for(&conn, "p1", "pr_ci", &second_plan.content_digest, true)
|
||||
.unwrap();
|
||||
assert!(!second_apply.written);
|
||||
assert_eq!(second_apply.plan.change, "noop");
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deploy_plan_requires_saved_matching_config() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
setup_cicd_schema(&conn);
|
||||
let root = temp_project("deploy-config");
|
||||
register_project(&conn, "p1", &root);
|
||||
|
||||
let missing = plan_cicd_workflow_for(&conn, "p1", "web_ssh").unwrap_err();
|
||||
assert!(missing.contains("尚未保存 web_ssh"));
|
||||
assert!(!root.join(".gitea").exists());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO cicd_configs (
|
||||
id, project_id, config_type, trigger_branch, build_command, node_version
|
||||
) VALUES ('cfg-1', 'p1', 'tauri_oss', 'main', 'pnpm build', '20')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mismatch = plan_cicd_workflow_for(&conn, "p1", "web_ssh").unwrap_err();
|
||||
assert!(mismatch.contains("当前保存的 CI/CD 类型为 tauri_oss"));
|
||||
assert!(!root.join(".gitea").exists());
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_target_rejects_path_escape() {
|
||||
let root = temp_project("path-escape").canonicalize().unwrap();
|
||||
let error = workflow_target(&root, "../outside.yml").unwrap_err();
|
||||
assert!(error.contains("路径越界"));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_conflict_fails_without_writing() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
setup_cicd_schema(&conn);
|
||||
let root = temp_project("plan-conflict");
|
||||
std::fs::write(root.join(".nvmrc"), "24.19.0\n").unwrap();
|
||||
std::fs::write(root.join(".node-version"), "22.14.0\n").unwrap();
|
||||
register_project(&conn, "p1", &root);
|
||||
|
||||
let error = plan_cicd_workflow_for(&conn, "p1", "pr_ci").unwrap_err();
|
||||
assert!(error.contains("拒绝生成 CI workflow"));
|
||||
assert!(!root.join(".gitea").exists());
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str =
|
||||
const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl");
|
||||
const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1;
|
||||
const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1;
|
||||
const BASE_ONBOARDING_PACK_VERSION: &str = "1.3.0";
|
||||
const BASE_ONBOARDING_PACK_VERSION: &str = "1.4.0";
|
||||
const TOOLCHAIN_PACK_VERSION: &str = "1.0.0";
|
||||
|
||||
/// 单个模板的分发结果。
|
||||
@ -843,7 +843,7 @@ pub fn verify_onboarding(
|
||||
if yml_count > 0 {
|
||||
format!(".gitea/workflows/ 有 {yml_count} 个 workflow")
|
||||
} else {
|
||||
"无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into()
|
||||
"无 .gitea/workflows/*.yml——先调 plan_cicd_workflow 预览,用户确认摘要后再调 apply_cicd_workflow 写入".into()
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
@ -293,6 +293,8 @@ pub fn run() {
|
||||
get_cicd_config,
|
||||
save_cicd_config,
|
||||
generate_workflow,
|
||||
plan_cicd_workflow,
|
||||
apply_cicd_workflow,
|
||||
// blueprint
|
||||
get_blueprint,
|
||||
generate_blueprint_prompt,
|
||||
|
||||
@ -257,9 +257,35 @@ pub fn tools_list_result() -> Value {
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "plan_cicd_workflow",
|
||||
"description": "只读预览项目 CI workflow:返回目标文件、create/update/noop、完整内容摘要 digest、所需 secret 名称与工具链来源;不写磁盘、不配置 secret、不触发 CI 或部署。部署类模板只读取炼境 UI 已保存的配置。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
|
||||
"workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review", "web_ssh", "tauri_oss"], "description": "要预览的 workflow 类型" }
|
||||
},
|
||||
"required": ["project_id", "workflow_type"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apply_cicd_workflow",
|
||||
"description": "在当前用户明确确认 plan_cicd_workflow 的摘要后,仅写入该项目对应的 .gitea/workflows 文件。必须提交同一预览的 digest;配置或内容变化会拒绝写入。不会配置 secret、触发 CI、SSH、部署、回滚或 migration。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
|
||||
"workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review", "web_ssh", "tauri_oss"], "description": "与预览相同的 workflow 类型" },
|
||||
"expected_digest": { "type": "string", "description": "用户确认时看到的 plan_cicd_workflow.content_digest" },
|
||||
"confirmed": { "type": "boolean", "description": "当前用户是否已明确确认该预览摘要与写入目标" }
|
||||
},
|
||||
"required": ["project_id", "workflow_type", "expected_digest", "confirmed"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "distribute_cicd",
|
||||
"description": "向项目写入 CI 模板文件(.gitea/workflows/)。当项目缺少 CI 配置时调用,自动解析项目路径写入。支持 pr_ci(PR 质量检查)和 deepseek_review(AI 代码审查)两种类型。",
|
||||
"description": "旧客户端兼容入口:直接写入 pr_ci 或 deepseek_review 模板。新 Agent 应使用 plan_cicd_workflow → 用户确认摘要 → apply_cicd_workflow;部署类模板不允许通过本入口写入。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -638,6 +664,62 @@ pub async fn tools_call(params: Option<&Value>) -> Value {
|
||||
let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
crate::commands::cicd::distribute_workflow_for_project(pid, wt)
|
||||
}
|
||||
"plan_cicd_workflow" => {
|
||||
let pid = args.get("project_id").and_then(|value| value.as_str()).unwrap_or("");
|
||||
let workflow_type = args
|
||||
.get("workflow_type")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("");
|
||||
if pid.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
if workflow_type.is_empty() {
|
||||
return Err("workflow_type 不能为空".to_string());
|
||||
}
|
||||
let conn = db::pool().get().map_err(|error| error.to_string())?;
|
||||
let plan = crate::commands::cicd::plan_cicd_workflow_for(
|
||||
&conn,
|
||||
pid,
|
||||
workflow_type,
|
||||
)?;
|
||||
serde_json::to_string_pretty(&plan).map_err(|error| error.to_string())
|
||||
}
|
||||
"apply_cicd_workflow" => {
|
||||
let pid = args.get("project_id").and_then(|value| value.as_str()).unwrap_or("");
|
||||
let workflow_type = args
|
||||
.get("workflow_type")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("");
|
||||
let expected_digest = args
|
||||
.get("expected_digest")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("");
|
||||
let confirmed = args
|
||||
.get("confirmed")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
if pid.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
if workflow_type.is_empty() {
|
||||
return Err("workflow_type 不能为空".to_string());
|
||||
}
|
||||
if !confirmed {
|
||||
return Err("写入 CI workflow 需要当前用户明确确认(confirmed=true)".to_string());
|
||||
}
|
||||
if expected_digest.is_empty() {
|
||||
return Err("expected_digest 不能为空;请先调用 plan_cicd_workflow".to_string());
|
||||
}
|
||||
let conn = db::pool().get().map_err(|error| error.to_string())?;
|
||||
let result = crate::commands::cicd::apply_cicd_workflow_for(
|
||||
&conn,
|
||||
pid,
|
||||
workflow_type,
|
||||
expected_digest,
|
||||
confirmed,
|
||||
)?;
|
||||
serde_json::to_string_pretty(&result).map_err(|error| error.to_string())
|
||||
}
|
||||
"list_all_projects" => list_all_projects(),
|
||||
"scan_unregistered_projects" => scan_unregistered_projects(),
|
||||
"unregister_project" => {
|
||||
@ -1658,6 +1740,14 @@ mod tests {
|
||||
"缺少 unregister_project"
|
||||
);
|
||||
assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo");
|
||||
assert!(
|
||||
names.contains(&"plan_cicd_workflow"),
|
||||
"缺少 plan_cicd_workflow"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"apply_cicd_workflow"),
|
||||
"缺少 apply_cicd_workflow"
|
||||
);
|
||||
// 每个工具都有 description 和 inputSchema
|
||||
for tool in tools {
|
||||
let name = tool["name"].as_str().unwrap_or("?");
|
||||
@ -1738,6 +1828,70 @@ mod tests {
|
||||
.contains("project_id 不能为空"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cicd_workflow_schemas_require_safe_confirmation_contract() {
|
||||
let result = tools_list_result();
|
||||
let tools = result["tools"].as_array().unwrap();
|
||||
let plan = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "plan_cicd_workflow")
|
||||
.expect("缺少 plan_cicd_workflow");
|
||||
let apply = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "apply_cicd_workflow")
|
||||
.expect("缺少 apply_cicd_workflow");
|
||||
let required = apply["inputSchema"]["required"].as_array().unwrap();
|
||||
for field in [
|
||||
"project_id",
|
||||
"workflow_type",
|
||||
"expected_digest",
|
||||
"confirmed",
|
||||
] {
|
||||
assert!(required.iter().any(|value| value == field));
|
||||
}
|
||||
for (name, tool) in [("plan", plan), ("apply", apply)] {
|
||||
let properties = tool["inputSchema"]["properties"].as_object().unwrap();
|
||||
for forbidden in ["secret", "command", "path", "url", "host"] {
|
||||
assert!(
|
||||
!properties.contains_key(forbidden),
|
||||
"{name} schema 不应接收 {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_cicd_workflow_rejects_missing_confirmation_before_db_io() {
|
||||
let result = tools_call(Some(&json!({
|
||||
"name": "apply_cicd_workflow",
|
||||
"arguments": {
|
||||
"project_id": "project-1",
|
||||
"workflow_type": "pr_ci",
|
||||
"expected_digest": "0123456789abcdef"
|
||||
}
|
||||
})))
|
||||
.await;
|
||||
assert_eq!(result["isError"], true);
|
||||
assert!(result["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("明确确认"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_cicd_workflow_requires_project_id_before_db_io() {
|
||||
let result = tools_call(Some(&json!({
|
||||
"name": "plan_cicd_workflow",
|
||||
"arguments": { "workflow_type": "pr_ci" }
|
||||
})))
|
||||
.await;
|
||||
assert_eq!(result["isError"], true);
|
||||
assert!(result["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("project_id 不能为空"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_toolchain_schema_requires_confirmation() {
|
||||
let result = tools_list_result();
|
||||
|
||||
@ -209,6 +209,25 @@ pub struct WorkflowResult {
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct WorkflowPlan {
|
||||
pub project_id: String,
|
||||
pub workflow_type: String,
|
||||
pub content: String,
|
||||
pub path: String,
|
||||
pub content_digest: String,
|
||||
pub change: String,
|
||||
pub required_secrets: Vec<String>,
|
||||
pub node_source: Option<String>,
|
||||
pub pnpm_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct WorkflowApplyResult {
|
||||
pub plan: WorkflowPlan,
|
||||
pub written: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct GitRepo {
|
||||
pub id: String,
|
||||
|
||||
@ -763,6 +763,25 @@ export interface WorkflowResult {
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
export type CicdWorkflowType = "pr_ci" | "deepseek_review" | "web_ssh" | "tauri_oss";
|
||||
|
||||
export interface WorkflowPlan {
|
||||
project_id: string;
|
||||
workflow_type: CicdWorkflowType;
|
||||
content: string;
|
||||
path: string;
|
||||
content_digest: string;
|
||||
change: "create" | "update" | "noop";
|
||||
required_secrets: string[];
|
||||
node_source?: string;
|
||||
pnpm_source?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowApplyResult {
|
||||
plan: WorkflowPlan;
|
||||
written: boolean;
|
||||
}
|
||||
|
||||
export const getCicdConfig = (projectId: string) =>
|
||||
invoke<CicdConfig | null>("get_cicd_config", { projectId });
|
||||
|
||||
@ -773,6 +792,24 @@ export const saveCicdConfig = (input: CicdConfig) =>
|
||||
export const generateWorkflow = (projectId: string, projectPath: string) =>
|
||||
invoke<WorkflowResult>("generate_workflow", { projectId, projectPath });
|
||||
|
||||
/** 只读预览:不写文件、不触发 CI 或部署。 */
|
||||
export const planCicdWorkflow = (projectId: string, workflowType: CicdWorkflowType) =>
|
||||
invoke<WorkflowPlan>("plan_cicd_workflow", { projectId, workflowType });
|
||||
|
||||
/** 写入预览对应的 workflow;调用方必须传入用户确认过的摘要。 */
|
||||
export const applyCicdWorkflow = (
|
||||
projectId: string,
|
||||
workflowType: CicdWorkflowType,
|
||||
expectedDigest: string,
|
||||
confirmed: boolean,
|
||||
) =>
|
||||
invoke<WorkflowApplyResult>("apply_cicd_workflow", {
|
||||
projectId,
|
||||
workflowType,
|
||||
expectedDigest,
|
||||
confirmed,
|
||||
});
|
||||
|
||||
/** 根据节点数据构造 share link(前端完成,避免 Rust 侧 URL 编码依赖) */
|
||||
export function buildShareLink(node: V2raynNode): string {
|
||||
const remarks = encodeURIComponent(node.remarks);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user