新增: DockerNode+CI状态读取+模板CRUD+CIStatus面板
- DockerNode: 环境检测(docker --version)+授权+容器内执行(12个测试) - ci_status.rs: Gitea commit status API 读取(7个测试,失败返回空不阻塞) - CIStatus.vue: CI检查面板(通过/失败/pending 汇总+可点击跳转) - 模板CRUD IPC: list/save/delete templates(内置只读+自定义KV持久化) - TemplateInfo 结构体(IPC传输用) - 零编译警告,vue-tsc+vite build 通过
This commit is contained in:
419
crates/df-nodes/src/docker_node.rs
Normal file
419
crates/df-nodes/src/docker_node.rs
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
//! Docker 节点 — 在 Docker 容器内执行命令
|
||||||
|
//!
|
||||||
|
//! 通过 `docker run --rm` 一次性容器执行命令。复用 df_execute::shell::execute 调用本地
|
||||||
|
//! docker CLI,与 ScriptNode/GitNode 路径一致(不内嵌 docker SDK,CLI 更通用透明)。
|
||||||
|
//!
|
||||||
|
//! 执行流程:
|
||||||
|
//! 1. 先 `docker --version` 探测 Docker 可用性(未装/未运行直接报错,避免容器启动失败
|
||||||
|
//! 时退出码语义混淆)。
|
||||||
|
//! 2. 构建 `docker run --rm {volumes} {env} -w {working_dir} {image} {command}`。
|
||||||
|
//! 3. 经 df_execute::shell::execute 执行,回传 stdout/stderr/exit_code。
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||||
|
|
||||||
|
/// Docker 节点
|
||||||
|
pub struct DockerNode;
|
||||||
|
|
||||||
|
/// 卷挂载配置项(volumes 数组元素)
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct VolumeMount {
|
||||||
|
pub host: String,
|
||||||
|
pub container: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 NodeContext.config 解析出的 DockerNode 参数。
|
||||||
|
/// 抽离此结构便于单元测试 config 解析逻辑(无需起 shell / 真实容器)。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DockerParams {
|
||||||
|
/// Docker 镜像名(必填,如 "rust:latest")
|
||||||
|
pub image: String,
|
||||||
|
/// 容器内执行命令(必填)
|
||||||
|
pub command: String,
|
||||||
|
/// 容器内工作目录(默认 "/workspace")
|
||||||
|
pub working_dir: String,
|
||||||
|
/// 超时秒数(默认 300)
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
/// 卷挂载列表(可选)
|
||||||
|
pub volumes: Vec<VolumeMount>,
|
||||||
|
/// 环境变量(可选)
|
||||||
|
pub env: std::collections::HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 config JSON 解析 DockerParams。image / command 必填,其余按默认/可选。
|
||||||
|
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<DockerParams> {
|
||||||
|
let image = config
|
||||||
|
.get("image")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: image"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let command = config
|
||||||
|
.get("command")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: command"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let working_dir = config
|
||||||
|
.get("working_dir")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "/workspace".to_string());
|
||||||
|
|
||||||
|
let timeout_secs = config
|
||||||
|
.get("timeout_secs")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(300);
|
||||||
|
|
||||||
|
// volumes: JSON 数组,每项 {"host": "...", "container": "..."}
|
||||||
|
// 容错:类型不符/缺字段项跳过(不整体失败,单条坏配置不阻塞整个工作流)。
|
||||||
|
let volumes = config
|
||||||
|
.get("volumes")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let host = item.get("host")?.as_str()?.to_string();
|
||||||
|
let container = item.get("container")?.as_str()?.to_string();
|
||||||
|
Some(VolumeMount { host, container })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// env: JSON 对象 { KEY: VALUE },值统一转字符串。
|
||||||
|
let env = config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.map(|obj| {
|
||||||
|
obj.iter()
|
||||||
|
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(DockerParams {
|
||||||
|
image,
|
||||||
|
command,
|
||||||
|
working_dir,
|
||||||
|
timeout_secs,
|
||||||
|
volumes,
|
||||||
|
env,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测 Docker 是否可用:`docker --version` 退出码 0 视为可用。
|
||||||
|
async fn check_docker_available() -> anyhow::Result<()> {
|
||||||
|
let request = df_execute::shell::ShellRequest {
|
||||||
|
command: "docker --version".to_string(),
|
||||||
|
working_dir: None,
|
||||||
|
env: std::collections::HashMap::new(),
|
||||||
|
timeout_secs: Some(15),
|
||||||
|
shell_type: Default::default(),
|
||||||
|
};
|
||||||
|
let result = df_execute::shell::execute(request).await;
|
||||||
|
match result {
|
||||||
|
Ok(r) if r.exit_code.unwrap_or(-1) == 0 => Ok(()),
|
||||||
|
_ => anyhow::bail!("Docker 未安装或未运行"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建 docker run 命令字符串。
|
||||||
|
/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。
|
||||||
|
fn build_command(params: &DockerParams) -> String {
|
||||||
|
let mut parts: Vec<String> = vec!["docker run --rm".to_string()];
|
||||||
|
|
||||||
|
for v in ¶ms.volumes {
|
||||||
|
parts.push(format!(
|
||||||
|
"-v {}:{}",
|
||||||
|
shell_quote(&v.host),
|
||||||
|
shell_quote(&v.container)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (k, val) in ¶ms.env {
|
||||||
|
parts.push(format!("-e {}={}", k, shell_quote(val)));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(format!("-w {}", shell_quote(¶ms.working_dir)));
|
||||||
|
parts.push(shell_quote(¶ms.image));
|
||||||
|
// command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹,
|
||||||
|
// 与脚本节点一致由 shell 解释器解析。
|
||||||
|
parts.push(params.command.clone());
|
||||||
|
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
if s
|
||||||
|
.chars()
|
||||||
|
.any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`')
|
||||||
|
{
|
||||||
|
format!("\"{}\"", s.replace('"', "\\\""))
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Node for DockerNode {
|
||||||
|
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||||
|
tracing::info!("DockerNode 执行: node_id={}", ctx.node_id);
|
||||||
|
|
||||||
|
// 1. Docker 可用性探测(未装/未运行直接 fail-fast)。
|
||||||
|
if let Err(e) = check_docker_available().await {
|
||||||
|
anyhow::bail!(e.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = parse_params(&ctx.config)?;
|
||||||
|
let command = build_command(¶ms);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
image = %params.image,
|
||||||
|
working_dir = %params.working_dir,
|
||||||
|
timeout_secs = params.timeout_secs,
|
||||||
|
"DockerNode 构建命令: {}",
|
||||||
|
command
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = df_execute::shell::ShellRequest {
|
||||||
|
command,
|
||||||
|
// 宿主机工作目录对 docker run 无意义,置 None。
|
||||||
|
working_dir: None,
|
||||||
|
env: std::collections::HashMap::new(),
|
||||||
|
timeout_secs: Some(params.timeout_secs),
|
||||||
|
shell_type: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = df_execute::shell::execute(request).await?;
|
||||||
|
let exit_code = result.exit_code.unwrap_or(-1);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
exit_code,
|
||||||
|
duration_ms = result.duration_ms,
|
||||||
|
"DockerNode 完成"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(NodeOutput::from_value(serde_json::json!({
|
||||||
|
"image": params.image,
|
||||||
|
"stdout": result.stdout,
|
||||||
|
"stderr": result.stderr,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"duration_ms": result.duration_ms,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> NodeSchema {
|
||||||
|
NodeSchema {
|
||||||
|
params: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"image": { "type": "string", "description": "Docker 镜像名(如 rust:latest)" },
|
||||||
|
"command": { "type": "string", "description": "容器内执行命令" },
|
||||||
|
"working_dir": { "type": "string", "description": "容器工作目录(默认 /workspace)" },
|
||||||
|
"timeout_secs": { "type": "integer", "description": "超时秒数(默认 300)" },
|
||||||
|
"volumes": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "卷挂载 [{host, container}]",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"host": { "type": "string" },
|
||||||
|
"container": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"env": { "type": "object", "description": "环境变量键值对" }
|
||||||
|
},
|
||||||
|
"required": ["image", "command"]
|
||||||
|
}),
|
||||||
|
output: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"image": { "type": "string" },
|
||||||
|
"stdout": { "type": "string" },
|
||||||
|
"stderr": { "type": "string" },
|
||||||
|
"exit_code": { "type": "integer" },
|
||||||
|
"duration_ms": { "type": "integer" }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_type(&self) -> &str {
|
||||||
|
"docker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// ── parse_params: 必填缺失 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_missing_image_errors() {
|
||||||
|
let err = parse_params(&json!({ "command": "ls" }))
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("image"), "实际: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_missing_command_errors() {
|
||||||
|
let err = parse_params(&json!({ "image": "rust:latest" }))
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("command"), "实际: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse_params: 默认值 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_minimal_uses_defaults() {
|
||||||
|
let p = parse_params(&json!({ "image": "alpine", "command": "echo hi" })).unwrap();
|
||||||
|
assert_eq!(p.image, "alpine");
|
||||||
|
assert_eq!(p.command, "echo hi");
|
||||||
|
assert_eq!(p.working_dir, "/workspace");
|
||||||
|
assert_eq!(p.timeout_secs, 300);
|
||||||
|
assert!(p.volumes.is_empty());
|
||||||
|
assert!(p.env.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse_params: 完整字段 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_full_fields() {
|
||||||
|
let p = parse_params(&json!({
|
||||||
|
"image": "rust:latest",
|
||||||
|
"command": "cargo test",
|
||||||
|
"working_dir": "/app",
|
||||||
|
"timeout_secs": 120
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p.image, "rust:latest");
|
||||||
|
assert_eq!(p.command, "cargo test");
|
||||||
|
assert_eq!(p.working_dir, "/app");
|
||||||
|
assert_eq!(p.timeout_secs, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse_params: volumes 解析 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_volumes_parsed() {
|
||||||
|
let p = parse_params(&json!({
|
||||||
|
"image": "node",
|
||||||
|
"command": "npm ci",
|
||||||
|
"volumes": [
|
||||||
|
{ "host": "/host/a", "container": "/c/a" },
|
||||||
|
{ "host": "/host/b", "container": "/c/b" }
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p.volumes.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
p.volumes[0],
|
||||||
|
VolumeMount {
|
||||||
|
host: "/host/a".into(),
|
||||||
|
container: "/c/a".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(p.volumes[1].container, "/c/b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_volumes_skips_malformed_items() {
|
||||||
|
// 缺 container / 非 object 项应被跳过,不整体失败。
|
||||||
|
let p = parse_params(&json!({
|
||||||
|
"image": "node",
|
||||||
|
"command": "ls",
|
||||||
|
"volumes": [
|
||||||
|
{ "host": "/ok", "container": "/ok" },
|
||||||
|
{ "host": "/no-container" },
|
||||||
|
"not-an-object",
|
||||||
|
{ "container": "/no-host" }
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p.volumes.len(), 1);
|
||||||
|
assert_eq!(p.volumes[0].host, "/ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse_params: env 解析 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_env_parsed() {
|
||||||
|
let p = parse_params(&json!({
|
||||||
|
"image": "python",
|
||||||
|
"command": "pytest",
|
||||||
|
"env": {
|
||||||
|
"FOO": "bar",
|
||||||
|
"DEBUG": "1"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p.env.len(), 2);
|
||||||
|
assert_eq!(p.env.get("FOO").map(|s| s.as_str()), Some("bar"));
|
||||||
|
assert_eq!(p.env.get("DEBUG").map(|s| s.as_str()), Some("1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_env_empty_when_non_object() {
|
||||||
|
// env 非 object(误传字符串)时回退空 map,不报错。
|
||||||
|
let p = parse_params(&json!({
|
||||||
|
"image": "python",
|
||||||
|
"command": "ls",
|
||||||
|
"env": "should-be-object"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(p.env.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── build_command ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_minimal_shape() {
|
||||||
|
let p = parse_params(&json!({
|
||||||
|
"image": "alpine",
|
||||||
|
"command": "echo hello"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let cmd = build_command(&p);
|
||||||
|
assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd);
|
||||||
|
assert!(cmd.contains("-w /workspace"), "实际: {}", cmd);
|
||||||
|
assert!(cmd.contains(" alpine "), "实际: {}", cmd);
|
||||||
|
assert!(cmd.ends_with("echo hello"), "实际: {}", cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_includes_volumes_and_env() {
|
||||||
|
let p = parse_params(&json!({
|
||||||
|
"image": "rust:latest",
|
||||||
|
"command": "cargo build",
|
||||||
|
"working_dir": "/app",
|
||||||
|
"volumes": [
|
||||||
|
{ "host": "/host/src", "container": "/app" }
|
||||||
|
],
|
||||||
|
"env": { "CARGO_HOME": "/cargo" }
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let cmd = build_command(&p);
|
||||||
|
assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd);
|
||||||
|
assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd);
|
||||||
|
assert!(cmd.contains("-w /app"), "实际: {}", cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shell_quote ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_quote_plain_passthrough() {
|
||||||
|
assert_eq!(shell_quote("abc"), "abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_quote_wraps_spaces() {
|
||||||
|
assert_eq!(shell_quote("/a b/c"), "\"/a b/c\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、HTTP、通知
|
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
|
||||||
|
|
||||||
pub mod ai_node;
|
pub mod ai_node;
|
||||||
pub mod ai_self_review_node;
|
pub mod ai_self_review_node;
|
||||||
mod ai_node_helpers;
|
mod ai_node_helpers;
|
||||||
|
pub mod docker_node;
|
||||||
pub mod git_node;
|
pub mod git_node;
|
||||||
pub mod http_node;
|
pub mod http_node;
|
||||||
pub mod human_node;
|
pub mod human_node;
|
||||||
|
|||||||
219
src-tauri/src/commands/ci_status.rs
Normal file
219
src-tauri/src/commands/ci_status.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//! CI 检查状态查询命令 — 读取 Gitea commit 检查状态(对标 GitHub commit status API)
|
||||||
|
//!
|
||||||
|
//! 调用 Gitea `{api_base}/api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` 拉取该
|
||||||
|
//! commit 的 CI 检查项列表,转成 CheckStatus 返回前端(供 commit 详情面板展示 ✓/✗/⏳)。
|
||||||
|
//!
|
||||||
|
//! 设计取舍:
|
||||||
|
//! - **容错优先**:网络错误 / URL 解析失败 / Gitea 异常 → 返回空 vec,**不阻断工作流**
|
||||||
|
//! (CI 状态属于辅助信息,失败不应阻塞 Git 浏览等主功能)。
|
||||||
|
//! - URL 解析:从 repo_url(如 `https://gitea.1216.top/lxy/DevFlow`)拆出
|
||||||
|
//! `{scheme}://{host}` 作 api_base,路径段尾两段作 owner/repo。
|
||||||
|
//! - 字段映射:Gitea status.state 取值 `pending/success/failure/error/...`,
|
||||||
|
//! error/warning 归一为 failure 展示;`context` 作检查名,`target_url` 作详情链接。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// 前端展示用的 CI 检查状态(归一化后)
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CheckStatus {
|
||||||
|
/// 检查名(如 "ci/cargo-test"、"continuous-integration/drone/push")
|
||||||
|
pub name: String,
|
||||||
|
/// 归一状态:"pending" | "success" | "failure"
|
||||||
|
pub status: String,
|
||||||
|
/// 详情链接(Gitea target_url,可能为空)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gitea API 原始返回项(仅取关心的字段,其余忽略)
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaStatus {
|
||||||
|
/// 检查上下文名(Gitea 字段名 context)
|
||||||
|
context: Option<String>,
|
||||||
|
/// 原始状态:pending / success / failure / error / warning / ...
|
||||||
|
state: Option<String>,
|
||||||
|
/// 详情链接
|
||||||
|
target_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 repo_url 解析 (api_base, owner, repo)。
|
||||||
|
///
|
||||||
|
/// 例:`https://gitea.1216.top/lxy/DevFlow` →
|
||||||
|
/// (`https://gitea.1216.top`, `lxy`, `DevFlow`)
|
||||||
|
///
|
||||||
|
/// `.git` 后缀容忍;路径段不足两段返回 None。
|
||||||
|
fn parse_repo_url(repo_url: &str) -> Option<(String, String, String)> {
|
||||||
|
let url = reqwest::Url::parse(repo_url.trim()).ok()?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let host = url.host_str()?;
|
||||||
|
let port = url.port_or_known_default();
|
||||||
|
let authority = match port {
|
||||||
|
Some(p) => format!("{}:{}", host, p),
|
||||||
|
None => host.to_string(),
|
||||||
|
};
|
||||||
|
let api_base = format!("{}://{}", url.scheme(), authority);
|
||||||
|
|
||||||
|
// 路径段:去空 / 去 .git 后缀 → 取末两段 owner / repo
|
||||||
|
// (url::Url::path_segments() 返 Option<Split>,Split 不实现 Default,需手动兜底)
|
||||||
|
let path_iter = url.path_segments().unwrap_or("".split('/'));
|
||||||
|
let mut segments: Vec<String> = path_iter
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.trim_end_matches(".git").to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
if segments.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let repo = segments.pop()?;
|
||||||
|
let owner = segments.pop()?;
|
||||||
|
Some((api_base, owner, repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 Gitea 原始 state 归一为前端三态:"pending" | "success" | "failure"
|
||||||
|
fn normalize_state(raw: &str) -> &'static str {
|
||||||
|
match raw.to_lowercase().as_str() {
|
||||||
|
"success" => "success",
|
||||||
|
"pending" | "" => "pending",
|
||||||
|
// failure / error / warning / blocked / 其他均视为失败展示
|
||||||
|
_ => "failure",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询指定 commit 的 CI 检查状态列表。
|
||||||
|
///
|
||||||
|
/// 任一环节失败(网络/解析/Gitea 异常)均返回空 vec,日志告警但不返回错误,
|
||||||
|
/// 避免阻断前端 Git 浏览等主流程。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_commit_status(
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
repo_url: String,
|
||||||
|
commit_sha: String,
|
||||||
|
) -> Result<Vec<CheckStatus>, String> {
|
||||||
|
let (api_base, owner, repo) = match parse_repo_url(&repo_url) {
|
||||||
|
Some(parsed) => parsed,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
repo_url = %repo_url,
|
||||||
|
"[ci_status] repo_url 解析失败,返回空列表"
|
||||||
|
);
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/repos/{}/{}/commits/{}/statuses",
|
||||||
|
api_base, owner, repo, commit_sha
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::info!(url = %url, "[ci_status] 查询 Gitea commit statuses");
|
||||||
|
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "[ci_status] 构建 reqwest client 失败");
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = match client.get(&url).send().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "[ci_status] 请求 Gitea 失败(网络不可达或未鉴权)");
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
tracing::warn!(
|
||||||
|
status = %resp.status(),
|
||||||
|
"[ci_status] Gitea 响应非 2xx,返回空列表"
|
||||||
|
);
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: Vec<GiteaStatus> = match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "[ci_status] 解析 Gitea 响应 JSON 失败");
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let checks = raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| CheckStatus {
|
||||||
|
name: s.context.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
status: normalize_state(s.state.as_deref().unwrap_or("")).to_string(),
|
||||||
|
url: s.target_url.filter(|u| !u.is_empty()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── parse_repo_url ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_standard_gitea_url() {
|
||||||
|
let (base, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow").unwrap();
|
||||||
|
assert_eq!(base, "https://gitea.1216.top");
|
||||||
|
assert_eq!(owner, "lxy");
|
||||||
|
assert_eq!(repo, "DevFlow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_strips_git_suffix() {
|
||||||
|
let (_, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow.git").unwrap();
|
||||||
|
assert_eq!(owner, "lxy");
|
||||||
|
assert_eq!(repo, "DevFlow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_with_port() {
|
||||||
|
let (base, _, _) = parse_repo_url("http://gitea.local:3000/team/proj").unwrap();
|
||||||
|
assert_eq!(base, "http://gitea.local:3000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_non_http() {
|
||||||
|
assert!(parse_repo_url("ftp://gitea/x/y").is_none());
|
||||||
|
assert!(parse_repo_url("file:///x/y").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_short_path() {
|
||||||
|
assert!(parse_repo_url("https://gitea.1216.top/").is_none());
|
||||||
|
assert!(parse_repo_url("https://gitea.1216.top/onlyone").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── normalize_state ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_success_and_pending() {
|
||||||
|
assert_eq!(normalize_state("success"), "success");
|
||||||
|
assert_eq!(normalize_state("pending"), "pending");
|
||||||
|
assert_eq!(normalize_state(""), "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_failure_variants() {
|
||||||
|
assert_eq!(normalize_state("failure"), "failure");
|
||||||
|
assert_eq!(normalize_state("error"), "failure");
|
||||||
|
assert_eq!(normalize_state("warning"), "failure");
|
||||||
|
assert_eq!(normalize_state("blocked"), "failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
||||||
|
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
|
pub mod ci_status;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod idea;
|
pub mod idea;
|
||||||
pub mod knowledge;
|
pub mod knowledge;
|
||||||
|
|||||||
@@ -141,6 +141,95 @@ pub async fn get_plan_execution() -> Result<bool, String> {
|
|||||||
Ok(df_ai::plan_executor::plan_execution_enabled())
|
Ok(df_ai::plan_executor::plan_execution_enabled())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 列出可用模板(内置 + 自定义)。
|
||||||
|
///
|
||||||
|
/// 内置模板从 assets/templates/ 加载;自定义模板从 app_settings KV(df-custom-templates)读取。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_templates(state: State<'_, AppState>) -> Result<Vec<TemplateInfo>, String> {
|
||||||
|
let mut templates = Vec::new();
|
||||||
|
|
||||||
|
// 内置模板
|
||||||
|
let builtin = [
|
||||||
|
("code-review", "代码审查", "AI 驱动的代码审查流程"),
|
||||||
|
("bug-fix", "Bug 修复", "AI 驱动的 bug 修复流程"),
|
||||||
|
("feature-dev", "功能开发", "AI 驱动的功能开发全流程"),
|
||||||
|
];
|
||||||
|
for (id, name, desc) in &builtin {
|
||||||
|
templates.push(TemplateInfo {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
description: desc.to_string(),
|
||||||
|
builtin: true,
|
||||||
|
yaml: String::new(), // 内置模板不返回全文(前端需要时单独加载)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义模板
|
||||||
|
if let Ok(Some(custom_json)) = state.settings.get("df-custom-templates").await {
|
||||||
|
if let Ok(custom) = serde_json::from_str::<Vec<TemplateInfo>>(&custom_json) {
|
||||||
|
templates.extend(custom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存自定义模板(新建/覆盖)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_template(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
template: TemplateInfo,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut customs: Vec<TemplateInfo> = state
|
||||||
|
.settings
|
||||||
|
.get("df-custom-templates")
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// 按 id 去重(覆盖同名)
|
||||||
|
customs.retain(|t| t.id != template.id);
|
||||||
|
customs.push(template);
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&customs).map_err(|e| e.to_string())?;
|
||||||
|
state.settings.set("df-custom-templates", &json).await.map_err(err_str)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除自定义模板。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_template(state: State<'_, AppState>, template_id: String) -> Result<(), String> {
|
||||||
|
let mut customs: Vec<TemplateInfo> = state
|
||||||
|
.settings
|
||||||
|
.get("df-custom-templates")
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let before = customs.len();
|
||||||
|
customs.retain(|t| t.id != template_id);
|
||||||
|
|
||||||
|
if customs.len() < before {
|
||||||
|
let json = serde_json::to_string(&customs).map_err(|e| e.to_string())?;
|
||||||
|
state.settings.set("df-custom-templates", &json).await.map_err(err_str)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板信息(IPC 传输用)
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct TemplateInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
/// true = 内置模板(只读);false = 自定义模板
|
||||||
|
pub builtin: bool,
|
||||||
|
/// 自定义模板的 YAML 内容(内置模板为空)
|
||||||
|
pub yaml: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// 解决合并冲突(用户/reviewer 选择解决方案后调用)。
|
/// 解决合并冲突(用户/reviewer 选择解决方案后调用)。
|
||||||
///
|
///
|
||||||
/// 更新 ai_conflicts 表的 resolution + resolved_by + resolved_at,
|
/// 更新 ai_conflicts 表的 resolution + resolved_by + resolved_at,
|
||||||
|
|||||||
@@ -438,6 +438,14 @@ pub fn run() {
|
|||||||
commands::settings::get_plan_execution,
|
commands::settings::get_plan_execution,
|
||||||
// 冲突解决
|
// 冲突解决
|
||||||
commands::settings::resolve_conflict,
|
commands::settings::resolve_conflict,
|
||||||
|
// 模板管理
|
||||||
|
commands::settings::list_templates,
|
||||||
|
commands::settings::save_template,
|
||||||
|
commands::settings::delete_template,
|
||||||
|
// CI 状态
|
||||||
|
commands::ci_status::get_commit_status,
|
||||||
|
// CI 检查状态(Gitea commit statuses,失败返回空列表不阻断工作流)
|
||||||
|
commands::ci_status::get_commit_status,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
120
src/components/ai/CIStatus.vue
Normal file
120
src/components/ai/CIStatus.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ci-status" v-if="statuses.length > 0">
|
||||||
|
<div class="ci-header">
|
||||||
|
<span class="ci-title">CI 检查</span>
|
||||||
|
<span class="ci-summary" :class="summaryClass">{{ summaryText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ci-list">
|
||||||
|
<a
|
||||||
|
v-for="check in statuses"
|
||||||
|
:key="check.name"
|
||||||
|
:href="check.url || undefined"
|
||||||
|
class="ci-item"
|
||||||
|
:class="'ci-item--' + check.status"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<span class="ci-item-icon">{{ statusIcon(check.status) }}</span>
|
||||||
|
<span class="ci-item-name">{{ check.name }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onMounted } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
interface CheckStatus {
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
repoUrl: string
|
||||||
|
commitSha: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statuses = ref<CheckStatus[]>([])
|
||||||
|
|
||||||
|
const summaryClass = computed(() => {
|
||||||
|
const hasFailure = statuses.value.some(s => s.status === 'failure')
|
||||||
|
const hasPending = statuses.value.some(s => s.status === 'pending')
|
||||||
|
if (hasFailure) return 'ci-summary--fail'
|
||||||
|
if (hasPending) return 'ci-summary--pending'
|
||||||
|
return 'ci-summary--pass'
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryText = computed(() => {
|
||||||
|
const pass = statuses.value.filter(s => s.status === 'success').length
|
||||||
|
const fail = statuses.value.filter(s => s.status === 'failure').length
|
||||||
|
const pending = statuses.value.filter(s => s.status === 'pending').length
|
||||||
|
if (fail > 0) return `${fail} 项失败`
|
||||||
|
if (pending > 0) return `${pending} 项进行中`
|
||||||
|
return `${pass} 项通过`
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusIcon(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return '✓'
|
||||||
|
case 'failure': return '✗'
|
||||||
|
case 'pending': return '⏳'
|
||||||
|
default: return '○'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
statuses.value = await invoke<CheckStatus[]>('get_commit_status', {
|
||||||
|
repoUrl: props.repoUrl,
|
||||||
|
commitSha: props.commitSha,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
statuses.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ci-status {
|
||||||
|
border: 1px solid var(--df-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
background: var(--df-bg-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ci-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ci-title { color: var(--df-text); }
|
||||||
|
.ci-summary--pass { color: var(--df-success); }
|
||||||
|
.ci-summary--fail { color: var(--df-danger); }
|
||||||
|
.ci-summary--pending { color: var(--df-warning); }
|
||||||
|
.ci-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ci-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--df-bg);
|
||||||
|
border: 1px solid var(--df-border);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
|
.ci-item--success { border-color: var(--df-success); }
|
||||||
|
.ci-item--failure { border-color: var(--df-danger); }
|
||||||
|
.ci-item--pending { border-color: var(--df-warning); }
|
||||||
|
.ci-item-icon { font-size: 10px; font-weight: 700; }
|
||||||
|
.ci-item:hover { opacity: 0.8; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user