新增: 模板系统+工作流节点(YAML加载器+GitNode/HTTPNode/NotifyNode)
- template_loader.rs: YAML→DagDef加载器+校验(空节点/未知类型/依赖环/边引用) - GitNode: 分支/checkout/commit/merge/push/status/log(git CLI封装) - HTTPNode: GET/POST/PUT/DELETE(reqwest,30s超时) - NotifyNode: 桌面通知(日志)+ Webhook(飞书/钉钉/自定义) - 8个模板加载器测试+37个节点测试全绿
This commit is contained in:
426
crates/df-nodes/src/git_node.rs
Normal file
426
crates/df-nodes/src/git_node.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
//! Git 节点 — 执行 git CLI 命令(branch/checkout/commit/merge/push/status/log)
|
||||
//!
|
||||
//! 通过 df_execute::shell::execute 调用本地 git CLI,working_dir 指定仓库路径。
|
||||
//! 不内嵌 git2/libgit2:CLI 路径更通用、调试透明,与 ScriptNode 一致。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Git 节点
|
||||
pub struct GitNode;
|
||||
|
||||
/// 支持的 Git 动作
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GitAction {
|
||||
Branch,
|
||||
Checkout,
|
||||
Commit,
|
||||
Merge,
|
||||
Push,
|
||||
Status,
|
||||
Log,
|
||||
}
|
||||
|
||||
impl GitAction {
|
||||
/// 从 config.action 字符串解析动作,非法值返回 Err。
|
||||
pub fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
match raw {
|
||||
"branch" => Ok(GitAction::Branch),
|
||||
"checkout" => Ok(GitAction::Checkout),
|
||||
"commit" => Ok(GitAction::Commit),
|
||||
"merge" => Ok(GitAction::Merge),
|
||||
"push" => Ok(GitAction::Push),
|
||||
"status" => Ok(GitAction::Status),
|
||||
"log" => Ok(GitAction::Log),
|
||||
other => anyhow::bail!(
|
||||
"GitNode 非法 action: {}(合法值: branch|checkout|commit|merge|push|status|log)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
GitAction::Branch => "branch",
|
||||
GitAction::Checkout => "checkout",
|
||||
GitAction::Commit => "commit",
|
||||
GitAction::Merge => "merge",
|
||||
GitAction::Push => "push",
|
||||
GitAction::Status => "status",
|
||||
GitAction::Log => "log",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 GitNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需构造完整 NodeContext / 起 shell)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitParams {
|
||||
pub action: GitAction,
|
||||
/// 目标分支(branch/checkout/merge)
|
||||
pub branch_name: Option<String>,
|
||||
/// commit 信息(commit)
|
||||
pub message: Option<String>,
|
||||
/// 仓库路径(None=当前目录)
|
||||
pub working_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 GitParams。action 必填;其余按需。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<GitParams> {
|
||||
let action = config
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode 缺少必填参数: action"))?;
|
||||
let action = GitAction::parse(action)?;
|
||||
|
||||
let branch_name = config
|
||||
.get("branch_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let message = config
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let working_dir = config
|
||||
.get("working_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(GitParams {
|
||||
action,
|
||||
branch_name,
|
||||
message,
|
||||
working_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据 action + 参数构建 git CLI 命令字符串。working_dir 由 ShellRequest 处理,此处不含 cd。
|
||||
fn build_command(params: &GitParams) -> anyhow::Result<String> {
|
||||
match params.action {
|
||||
GitAction::Branch => {
|
||||
// 无 branch_name → 列出所有本地分支;有 → 创建新分支
|
||||
match ¶ms.branch_name {
|
||||
Some(name) => Ok(format!("git branch {}", shell_quote(name))),
|
||||
None => Ok("git branch".to_string()),
|
||||
}
|
||||
}
|
||||
GitAction::Checkout => {
|
||||
let name = params
|
||||
.branch_name
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=checkout 缺少 branch_name"))?;
|
||||
Ok(format!("git checkout {}", shell_quote(name)))
|
||||
}
|
||||
GitAction::Commit => {
|
||||
let msg = params
|
||||
.message
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=commit 缺少 message"))?;
|
||||
Ok(format!("git commit -m {}", shell_quote(msg)))
|
||||
}
|
||||
GitAction::Merge => {
|
||||
let name = params
|
||||
.branch_name
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=merge 缺少 branch_name"))?;
|
||||
Ok(format!("git merge {}", shell_quote(name)))
|
||||
}
|
||||
GitAction::Push => Ok("git push".to_string()),
|
||||
GitAction::Status => Ok("git status".to_string()),
|
||||
GitAction::Log => Ok("git log --oneline -20".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||
/// 仅用于内部参数拼接(branch_name/message),命令名固定白名单不用户可控。
|
||||
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 GitNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("GitNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
let command = build_command(¶ms)?;
|
||||
|
||||
tracing::info!(
|
||||
action = params.action.as_str(),
|
||||
working_dir = ?params.working_dir,
|
||||
"GitNode 构建命令: {}",
|
||||
command
|
||||
);
|
||||
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command,
|
||||
working_dir: params.working_dir.clone(),
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(60),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
|
||||
let exit_code = result.exit_code.unwrap_or(-1);
|
||||
if exit_code != 0 {
|
||||
anyhow::bail!(
|
||||
"GitNode 执行失败 (exit_code={}): {}",
|
||||
exit_code,
|
||||
result.stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// 按 action 提取结构化字段(branch 列表/commit hash 等),无法解析时回退原始 stdout。
|
||||
let parsed = parse_action_output(¶ms.action, &result.stdout);
|
||||
|
||||
tracing::info!(
|
||||
action = params.action.as_str(),
|
||||
exit_code,
|
||||
duration_ms = result.duration_ms,
|
||||
"GitNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"action": params.action.as_str(),
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": result.duration_ms,
|
||||
"parsed": parsed,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["branch", "checkout", "commit", "merge", "push", "status", "log"]
|
||||
},
|
||||
"branch_name": { "type": "string", "description": "目标分支(branch/checkout/merge)" },
|
||||
"message": { "type": "string", "description": "commit 信息(commit)" },
|
||||
"working_dir": { "type": "string", "description": "git 仓库路径(可选)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"stdout": { "type": "string" },
|
||||
"stderr": { "type": "string" },
|
||||
"exit_code": { "type": "integer" },
|
||||
"duration_ms": { "type": "integer" },
|
||||
"parsed": { "type": "object" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"git"
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 action 解析 stdout 成结构化字段。
|
||||
/// - branch(无参列出):每行一个分支,`*` 标记当前分支
|
||||
/// - log:每行一条 `hash subject`
|
||||
/// - commit:首段 hash
|
||||
/// - 其余动作无强结构,返回空对象
|
||||
fn parse_action_output(action: &GitAction, stdout: &str) -> serde_json::Value {
|
||||
match action {
|
||||
GitAction::Branch => {
|
||||
let branches: Vec<&str> = stdout.lines().map(|l| l.trim_start_matches("* ").trim()).collect();
|
||||
let current = stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with('*'))
|
||||
.map(|l| l.trim_start_matches("* ").trim().to_string());
|
||||
serde_json::json!({ "branches": branches, "current": current })
|
||||
}
|
||||
GitAction::Log => {
|
||||
let entries: Vec<serde_json::Value> = stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let l = l.trim();
|
||||
let mut parts = l.splitn(2, ' ');
|
||||
let hash = parts.next()?.to_string();
|
||||
let subject = parts.next().unwrap_or("").to_string();
|
||||
Some(serde_json::json!({ "hash": hash, "subject": subject }))
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "entries": entries })
|
||||
}
|
||||
GitAction::Commit => {
|
||||
// `git commit` 默认输出含 `[branch hash]` 形式;取首个 7+ 位十六进制段
|
||||
let hash = stdout
|
||||
.split_whitespace()
|
||||
.find(|t| t.len() >= 7 && t.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
serde_json::json!({ "commit_hash": hash })
|
||||
}
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── GitAction::parse ──
|
||||
|
||||
#[test]
|
||||
fn parse_all_valid_actions() {
|
||||
for raw in ["branch", "checkout", "commit", "merge", "push", "status", "log"] {
|
||||
let a = GitAction::parse(raw).unwrap_or_else(|e| panic!("合法 action {} 应解析成功: {}", raw, e));
|
||||
assert_eq!(a.as_str(), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_action_errors() {
|
||||
let err = GitAction::parse("rebase").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 action"), "实际: {}", err);
|
||||
assert!(err.contains("rebase"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_action_errors() {
|
||||
let err = GitAction::parse("").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 action"), "实际: {}", err);
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_action_errors() {
|
||||
let err = parse_params(&json!({ "branch_name": "main" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("action"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_minimal_valid() {
|
||||
let p = parse_params(&json!({ "action": "status" })).unwrap();
|
||||
assert_eq!(p.action, GitAction::Status);
|
||||
assert_eq!(p.branch_name, None);
|
||||
assert_eq!(p.message, None);
|
||||
assert_eq!(p.working_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_full_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"action": "commit",
|
||||
"branch_name": "feature/x",
|
||||
"message": "fix: 修复",
|
||||
"working_dir": "/repo"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.action, GitAction::Commit);
|
||||
assert_eq!(p.branch_name.as_deref(), Some("feature/x"));
|
||||
assert_eq!(p.message.as_deref(), Some("fix: 修复"));
|
||||
assert_eq!(p.working_dir.as_deref(), Some("/repo"));
|
||||
}
|
||||
|
||||
// ── build_command ──
|
||||
|
||||
#[test]
|
||||
fn command_branch_list_when_no_name() {
|
||||
let p = parse_params(&json!({ "action": "branch" })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), "git branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_branch_create_when_name() {
|
||||
let p = parse_params(&json!({ "action": "branch", "branch_name": "feat" })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), "git branch feat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_checkout_requires_name() {
|
||||
let p = parse_params(&json!({ "action": "checkout" })).unwrap();
|
||||
let err = build_command(&p).unwrap_err().to_string();
|
||||
assert!(err.contains("branch_name"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_commit_requires_message() {
|
||||
let p = parse_params(&json!({ "action": "commit" })).unwrap();
|
||||
let err = build_command(&p).unwrap_err().to_string();
|
||||
assert!(err.contains("message"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_commit_quotes_message_with_space() {
|
||||
let p = parse_params(&json!({ "action": "commit", "message": "fix bug a" })).unwrap();
|
||||
let cmd = build_command(&p).unwrap();
|
||||
assert_eq!(cmd, "git commit -m \"fix bug a\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_merge_requires_name() {
|
||||
let p = parse_params(&json!({ "action": "merge" })).unwrap();
|
||||
assert!(build_command(&p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_push_status_log_fixed() {
|
||||
for (action, expected) in [
|
||||
("push", "git push"),
|
||||
("status", "git status"),
|
||||
("log", "git log --oneline -20"),
|
||||
] {
|
||||
let p = parse_params(&json!({ "action": action })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
// ── shell_quote ──
|
||||
|
||||
#[test]
|
||||
fn shell_quote_plain_passthrough() {
|
||||
assert_eq!(shell_quote("feat"), "feat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_escapes_embedded_double_quote() {
|
||||
let q = shell_quote("a\"b");
|
||||
assert_eq!(q, "\"a\\\"b\"");
|
||||
}
|
||||
|
||||
// ── parse_action_output ──
|
||||
|
||||
#[test]
|
||||
fn parse_branch_output() {
|
||||
let out = parse_action_output(
|
||||
&GitAction::Branch,
|
||||
"* main\n develop\n feature/x\n",
|
||||
);
|
||||
assert_eq!(out["current"], "main");
|
||||
assert_eq!(out["branches"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_log_output() {
|
||||
let out = parse_action_output(
|
||||
&GitAction::Log,
|
||||
"abc1234 fix bug\n deadbeef add feature\n",
|
||||
);
|
||||
let entries = out["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["hash"], "abc1234");
|
||||
assert_eq!(entries[1]["subject"], "add feature");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user