feat(子任务三): 工作流引擎重构 + 提交材料

- 引擎模块化拆分(engine/daemon/defs/cli/ai/state/skills 子包)
- 5 个预置工作流声明式定义,支持 4 种触发模式
- AI/规则引擎双模降级,安全白名单过滤
- 工作流专属 Skill 移至 shortcuts/workflow/skills/
- 子赛题三提交材料:工作流说明、架构图、执行脚本、Agent对话记录、演示视频
This commit is contained in:
yetja 2026-07-10 10:39:42 +08:00
parent 95f8019e33
commit 6be9766f05
57 changed files with 2798 additions and 2096 deletions

View File

@ -22,7 +22,8 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/watch"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/cli"
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/defs"
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/rules"
)
@ -47,7 +48,7 @@ func RegisterAll(root *cobra.Command) {
"watch": watch.Shortcuts(),
"star": star.Shortcuts(),
"wiki": wiki.Shortcuts(),
"workflow": workflow.Shortcuts(),
"workflow": cli.Shortcuts(),
}
descriptions := map[string]string{

View File

@ -1,4 +1,4 @@
package workflow
package ai
import (
"bytes"
@ -11,6 +11,7 @@ import (
"time"
"github.com/gitlink-org/gitlink-cli/internal/config"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
const deepseekBaseURL = "https://api.deepseek.com/v1/chat/completions"
@ -23,18 +24,6 @@ type AIClient struct {
http *http.Client
}
// AIRequest bundles the data needed for an AI skill step call.
type AIRequest struct {
SystemPrompt string
UserData string
}
// AIResponse is the parsed structured output from an AI skill step.
type AIResponse struct {
Analysis interface{} `json:"analysis"`
Actions []AIAction `json:"actions"`
}
const jsonOutputInstruction = `
IMPORTANT: You MUST respond with a single JSON object in exactly this format:
@ -64,7 +53,7 @@ func NewAIClient() *AIClient {
}
// Analyze sends the skill prompt + upstream data to the DeepSeek API and parses the response.
func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
func (c *AIClient) Analyze(req *wf.AIRequest) (*wf.AIResponse, error) {
if c == nil {
return nil, fmt.Errorf("AI client not configured: set DEEPSEEK_API_KEY or configure deepseek_api_key")
}
@ -125,7 +114,6 @@ func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
text := result.Choices[0].Message.Content
aiResp, err := parseAIResponse(text)
if err != nil {
// Fallback: try to extract JSON from markdown code fences.
if extracted := extractJSONFromMarkdown(text); extracted != "" {
aiResp2, err2 := parseAIResponse(extracted)
if err2 != nil {
@ -141,8 +129,7 @@ func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
}
// parseAIResponse unmarshals the AI's JSON output, coercing numeric arg values to strings.
func parseAIResponse(text string) (*AIResponse, error) {
// First pass: unmarshal into a flexible structure that accepts numbers in args.
func parseAIResponse(text string) (*wf.AIResponse, error) {
raw := struct {
Analysis json.RawMessage `json:"analysis"`
Actions []struct {
@ -159,9 +146,8 @@ func parseAIResponse(text string) (*AIResponse, error) {
return nil, err
}
resp := &AIResponse{}
resp := &wf.AIResponse{}
if err := json.Unmarshal(raw.Analysis, &resp.Analysis); err != nil {
// If it's not valid JSON, treat it as a plain string.
resp.Analysis = string(raw.Analysis)
}
for _, a := range raw.Actions {
@ -169,7 +155,7 @@ func parseAIResponse(text string) (*AIResponse, error) {
for k, v := range a.Args {
args[k] = fmt.Sprint(v)
}
resp.Actions = append(resp.Actions, AIAction{
resp.Actions = append(resp.Actions, wf.AIAction{
Type: a.Type,
Method: a.Method,
Path: a.Path,
@ -189,7 +175,6 @@ func (c *AIClient) HasKey() bool {
// extractJSONFromMarkdown tries to pull a JSON object out of a markdown code fence.
func extractJSONFromMarkdown(text string) string {
// Look for ```json ... ``` block.
start := strings.Index(text, "```json")
if start == -1 {
start = strings.Index(text, "```")
@ -197,7 +182,6 @@ func extractJSONFromMarkdown(text string) string {
if start == -1 {
return ""
}
// Find end of opening fence.
nl := strings.Index(text[start:], "\n")
if nl == -1 {
return ""

View File

@ -1,45 +1,18 @@
package workflow
package cli
import (
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/daemon"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
)
// --- Registry ---
var registry = map[string]*WorkflowDef{}
func register(wf *WorkflowDef) {
registry[wf.Name] = wf
}
// All returns all registered workflows sorted by name.
func All() []*WorkflowDef {
names := make([]string, 0, len(registry))
for n := range registry {
names = append(names, n)
}
sort.Strings(names)
result := make([]*WorkflowDef, len(names))
for i, n := range names {
result[i] = registry[n]
}
return result
}
// Get returns a workflow by name, or nil.
func Get(name string) *WorkflowDef {
return registry[name]
}
// --- CLI Commands ---
// Shortcuts returns all workflow CLI commands.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
@ -51,8 +24,8 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
cat := ctx.Arg("category")
workflows := All()
filtered := make([]*WorkflowDef, 0)
workflows := wf.All()
filtered := make([]*wf.WorkflowDef, 0)
for _, w := range workflows {
if cat == "" || strings.EqualFold(w.Category, cat) {
filtered = append(filtered, w)
@ -87,11 +60,11 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
return ctx.OutputData(wf)
return ctx.OutputData(w)
},
},
{
@ -115,17 +88,15 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
// Pass description to the init-scaffold rule engine.
if desc := ctx.Arg("desc"); desc != "" {
ctx.Args["_desc"] = desc
}
// Pass wiki-repo to multi-repo rule engine for wiki publishing.
if wr := ctx.Arg("wiki-repo"); wr != "" {
owner, repo := splitRepoRef(wr)
if owner != "" && repo != "" {
@ -139,17 +110,16 @@ func Shortcuts() []*common.Shortcut {
return err
}
// Daemon loop mode (internal — forked by +start)
if ctx.Arg("daemon-loop") == "true" {
intervalStr := ctx.Arg("interval")
interval, err := time.ParseDuration(intervalStr)
if err != nil {
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
}
return DaemonLoop(ctx, wf, interval)
return daemon.DaemonLoop(ctx, w, interval)
}
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
return runWorkflowCommand(ctx, w, ctx.Arg("dry-run") == "true", aiMode)
},
},
{
@ -167,11 +137,11 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
if wf.Name == "multi-repo" {
if w.Name == "multi-repo" {
return fmt.Errorf("workflow +watch 不支持 multi-repo多仓库协同请求量较大请使用 workflow +run 手动检查,或 workflow +schedule --interval 6h/24h 做低频巡检")
}
intervalStr := ctx.Arg("interval")
@ -186,7 +156,7 @@ func Shortcuts() []*common.Shortcut {
}
ctx.AIMode = aiMode
return Watch(ctx, wf, interval, ctx.Arg("step"))
return daemon.Watch(ctx, w, interval, ctx.Arg("step"))
},
},
{
@ -207,8 +177,8 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
intervalStr := ctx.Arg("interval")
@ -217,7 +187,6 @@ func Shortcuts() []*common.Shortcut {
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
}
// Pass wiki-repo to multi-repo rule engine for wiki publishing.
if wr := ctx.Arg("wiki-repo"); wr != "" {
owner, repo := splitRepoRef(wr)
if owner != "" && repo != "" {
@ -232,7 +201,7 @@ func Shortcuts() []*common.Shortcut {
}
ctx.AIMode = aiMode
return Schedule(ctx, wf, interval)
return daemon.Schedule(ctx, w, interval)
},
},
{
@ -253,8 +222,8 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
intervalStr := ctx.Arg("interval")
@ -263,7 +232,6 @@ func Shortcuts() []*common.Shortcut {
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
}
// Pass wiki-repo to multi-repo rule engine for wiki publishing.
if wr := ctx.Arg("wiki-repo"); wr != "" {
owner, repo := splitRepoRef(wr)
if owner != "" && repo != "" {
@ -277,7 +245,7 @@ func Shortcuts() []*common.Shortcut {
return modeErr
}
return StartDaemon(ctx, wf, interval, aiMode)
return daemon.StartDaemon(ctx, w, interval, aiMode)
},
},
{
@ -291,7 +259,7 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
return StopDaemon(name)
return daemon.StopDaemon(name)
},
},
{
@ -305,7 +273,7 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
return StatusDaemon(name)
return daemon.StatusDaemon(name)
},
},
{
@ -320,7 +288,7 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
return tailDaemonLog(name, ctx.Arg("follow") == "true")
return daemon.TailDaemonLog(name, ctx.Arg("follow") == "true")
},
},
{
@ -337,8 +305,8 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
@ -347,7 +315,7 @@ func Shortcuts() []*common.Shortcut {
return modeErr
}
return installSystemdUnit(ctx, wf, ctx.Arg("interval"), aiMode)
return daemon.InstallSystemdUnit(ctx, w, ctx.Arg("interval"), aiMode)
},
},
}
@ -368,8 +336,8 @@ func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
return "auto", nil
}
func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) error {
result, err := RunWithMode(ctx, wf, dryRun, aiMode)
func runWorkflowCommand(ctx *common.RuntimeContext, w *wf.WorkflowDef, dryRun bool, aiMode string) error {
result, err := engine.RunWithMode(ctx, w, dryRun, aiMode)
if err != nil {
return err
}
@ -382,7 +350,7 @@ func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool
needsAI++
}
if v, _ := m["_ai_used"]; v == true {
ruleEngine++ // AI was used
ruleEngine++
}
}
}

View File

@ -0,0 +1,26 @@
package cli
import "testing"
func TestShortcutsCount(t *testing.T) {
sc := Shortcuts()
if len(sc) != 10 {
t.Fatalf("expected 10 shortcuts (list, info, run, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
}
names := map[string]bool{
"list": false, "info": false, "run": false, "watch": false,
"schedule": false, "start": false, "stop": false, "status": false,
"logs": false, "install-systemd": false,
}
for _, s := range sc {
if _, ok := names[s.Name]; !ok {
t.Fatalf("unexpected shortcut: %s", s.Name)
}
names[s.Name] = true
}
for n, found := range names {
if !found {
t.Fatalf("missing shortcut: %s", n)
}
}
}

View File

@ -1,23 +0,0 @@
package workflow
func registerCodeQuality() {
register(&WorkflowDef{
Name: "code-quality",
Category: "质量",
Description: "代码质量看门人PR 提交 → Review → CI 检查 → 结果汇总",
Trigger: TriggerDef{
Type: "poll",
On: "pr.opened",
Interval: "5m",
},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
{Type: StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
{Type: StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
{Type: StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败并给出建议", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
},
})
}

View File

@ -0,0 +1,68 @@
package workflow
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ParseCommandTarget splits a CLI command string into tokens, respecting quoted arguments.
func ParseCommandTarget(target string) []string {
var parts []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(target); i++ {
c := target[i]
switch {
case c == '"' || c == '\'':
if inQuote && c == quoteChar {
inQuote = false
quoteChar = 0
} else if !inQuote {
inQuote = true
quoteChar = c
} else {
current.WriteByte(c)
}
case c == ' ' && !inQuote:
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
default:
current.WriteByte(c)
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
// ResolveCLIBinary finds the gitlink-cli binary for subprocess calls.
func ResolveCLIBinary() string {
if exe, err := os.Executable(); err == nil && exe != "" {
return exe
}
for _, p := range []string{"./gitlink-cli", "./gitlink-cli.exe", "../gitlink-cli", "../gitlink-cli.exe"} {
if _, err := os.Stat(p); err == nil {
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
}
return "gitlink-cli"
}
// ResolvePath replaces template placeholders in a path string.
func ResolvePath(template, owner, repo string) string {
base := fmt.Sprintf("/%s/%s", owner, repo)
v1 := fmt.Sprintf("/v1/%s/%s", owner, repo)
s := strings.Replace(template, "{v1}", v1, 1)
s = strings.Replace(s, "{base}", base, 1)
return s
}

View File

@ -1,26 +0,0 @@
package workflow
func registerCommunityOps() {
register(&WorkflowDef{
Name: "community-ops",
Category: "运营",
Description: "社区运营自动化Issue 智能分拣 → 生成周报 → 生成 Release Notes",
Trigger: TriggerDef{
Type: "poll",
On: "issue.created",
Interval: "5m",
},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
{Type: StepTypeCommand, Name: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +list"},
{Type: StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: RunAlways},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取提交历史分析活跃度", Target: "commit +list --limit 50"},
{Type: StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: RunWeekly},
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
{Type: StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: RunOnChange},
},
})
}

View File

@ -1,23 +0,0 @@
package workflow
func registerContributorGrowth() {
register(&WorkflowDef{
Name: "contributor-growth",
Category: "成长",
Description: "贡献者成长体系:追踪贡献者活动 → 生成排行 → 识别活跃与流失",
Trigger: TriggerDef{
Type: "cron",
On: "0 9 * * 1",
Interval: "24h",
},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "commits", Purpose: "提交历史统计代码贡献", Target: "commit +list --limit 100"},
{Type: StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue 统计 Issue 贡献", Target: "issue +list --state open --limit 50"},
{Type: StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue 统计解决贡献", Target: "issue +list --state closed --limit 50"},
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
{Type: StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据Fork/Star/Watch", Target: "repo +info"},
{Type: StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
},
})
}

View File

@ -1,4 +1,4 @@
package workflow
package daemon
import (
"encoding/json"
@ -14,22 +14,24 @@ import (
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
// StartDaemon launches a workflow as a background daemon process.
func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) error {
func StartDaemon(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, aiMode string) error {
bin, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot find executable: %w", err)
}
args := buildDaemonArgs(ctx, wf, interval, aiMode)
args := BuildDaemonArgs(ctx, wfDef, interval, aiMode)
cmd := exec.Command(bin, args...)
applyDaemonAttrs(cmd)
// Redirect output to log file instead of discarding.
logPath := daemonLogPath(wf.Name)
logPath := daemonLogPath(wfDef.Name)
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return fmt.Errorf("create log file: %w", err)
@ -42,25 +44,25 @@ func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Dura
logFile.Close()
return fmt.Errorf("start daemon: %w", err)
}
// logFile is owned by child process; it will be closed when child exits.
pid := cmd.Process.Pid
if err := savePID(wf.Name, pid); err != nil {
if err := savePID(wfDef.Name, pid); err != nil {
return fmt.Errorf("save pid: %w", err)
}
fmt.Printf("Daemon started for %q (PID: %d)\n", wf.Name, pid)
fmt.Printf("Daemon started for %q (PID: %d)\n", wfDef.Name, pid)
fmt.Printf("Log: %s\n", logPath)
return nil
}
func buildDaemonArgs(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) []string {
// BuildDaemonArgs constructs the CLI arguments for the daemon subprocess.
func BuildDaemonArgs(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, aiMode string) []string {
args := []string{
"workflow", "+run", "--name", wf.Name,
"workflow", "+run", "--name", wfDef.Name,
"--format", "json", "--daemon-loop",
"--interval", interval.String(),
}
if !isExplicitMultiRepoRun(ctx, wf) {
if !engine.IsExplicitMultiRepoRun(ctx, wfDef) {
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
}
if aiMode == "ai" {
@ -94,7 +96,6 @@ func StopDaemon(name string) error {
}
if err := proc.Signal(os.Interrupt); err != nil {
// Process might already be dead; clean up pid file anyway.
cleanPID(name)
return fmt.Errorf("failed to stop daemon %q: %w", name, err)
}
@ -106,7 +107,7 @@ func StopDaemon(name string) error {
// StatusDaemon prints the current daemon status for a workflow.
func StatusDaemon(name string) error {
state, err := LoadState(name)
st, err := state.LoadState(name)
if err != nil {
return fmt.Errorf("load state: %w", err)
}
@ -120,72 +121,67 @@ func StatusDaemon(name string) error {
} else {
fmt.Println("状态: 已停止")
}
if state.LastRun != "" {
t, err := time.Parse(time.RFC3339, state.LastRun)
if st.LastRun != "" {
t, err := time.Parse(time.RFC3339, st.LastRun)
if err == nil {
fmt.Printf("上次运行: %s\n", t.Format("2006-01-02 15:04"))
} else {
fmt.Printf("上次运行: %s\n", state.LastRun)
fmt.Printf("上次运行: %s\n", st.LastRun)
}
}
fmt.Printf("累计运行: %d 次\n", state.TotalRuns)
fmt.Printf("快照步骤: %d 个\n", len(state.Snapshots))
fmt.Printf("累计运行: %d 次\n", st.TotalRuns)
fmt.Printf("快照步骤: %d 个\n", len(st.Snapshots))
fmt.Printf("日志文件: %s\n", daemonLogPath(name))
return nil
}
// DaemonLoop runs the workflow repeatedly in a loop (used by the daemon subprocess).
func DaemonLoop(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
// DaemonLoop runs the workflow repeatedly in a loop.
func DaemonLoop(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration) error {
tick := time.NewTicker(interval)
defer tick.Stop()
// Run immediately on start (dry-run to establish baseline).
doDaemonCycle(ctx, wf)
doDaemonCycle(ctx, wfDef)
for {
select {
case <-tick.C:
doDaemonCycle(ctx, wf)
doDaemonCycle(ctx, wfDef)
}
}
}
func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
state, _ := LoadState(wf.Name)
func doDaemonCycle(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) {
st, _ := state.LoadState(wfDef.Name)
// Phase 1: cheap dry-run — collect data without AI.
dryResult, err := Run(ctx, wf, true)
dryResult, err := engine.Run(ctx, wfDef, true)
if err != nil {
fmt.Fprintf(os.Stderr, "[%s] error: %v\n", time.Now().Format(time.RFC3339), err)
return
}
changed := state.Diff(dryResult.Steps)
changed := st.Diff(dryResult.Steps)
if len(changed) == 0 {
if state.TotalRuns > 0 {
if st.TotalRuns > 0 {
fmt.Fprintf(os.Stderr, "[%s] 没有检测到变更\n", time.Now().Format(time.RFC3339))
state.TotalRuns++
state.Save()
st.TotalRuns++
st.Save()
return
}
// First run: establish baseline snapshot, then proceed to full run.
} else {
fmt.Fprintf(os.Stderr, "[%s] 🔔 检测到变更: %v\n", time.Now().Format(time.RFC3339), changed)
}
// Phase 2: full run (AI or rules based on context.AIMode).
fullResult, err := Run(ctx, wf, false)
fullResult, err := engine.Run(ctx, wfDef, false)
if err != nil {
fmt.Fprintf(os.Stderr, "[%s] run error: %v\n", time.Now().Format(time.RFC3339), err)
return
}
// Reload state — Run() may have updated ReviewedPRs fingerprints.
state, _ = LoadState(wf.Name)
state.TotalRuns++
state.Diff(fullResult.Steps)
state.UpdateSnapshots(fullResult.Steps)
state.Save()
st, _ = state.LoadState(wfDef.Name)
st.TotalRuns++
st.Diff(fullResult.Steps)
st.UpdateSnapshots(fullResult.Steps)
st.Save()
ok, total := 0, len(fullResult.Steps)
for _, sr := range fullResult.Steps {
@ -195,19 +191,17 @@ func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
}
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
// Log step-level details: failures and rule-engine findings.
for _, sr := range fullResult.Steps {
if !sr.OK {
fmt.Fprintf(os.Stderr, "[%s] ❌ %s 失败: %s\n", time.Now().Format(time.RFC3339), sr.Step, sr.Error)
}
if sr.Type == StepTypeSkill && sr.Data != nil {
if sr.Type == wf.StepTypeSkill && sr.Data != nil {
logSkillFindings(sr)
}
}
}
// logSkillFindings prints rule-engine/AI analysis findings from a skill step to the daemon log.
func logSkillFindings(sr StepResult) {
func logSkillFindings(sr wf.StepResult) {
m, ok := sr.Data.(map[string]interface{})
if !ok {
return
@ -217,7 +211,6 @@ func logSkillFindings(sr StepResult) {
fmt.Fprintf(os.Stderr, "[%s] ── %s 分析结果 ──\n", time.Now().Format(time.RFC3339), skill)
// AI mode returns analysis as a markdown string; print it directly.
if s, ok := analysis.(string); ok && s != "" {
for _, line := range strings.Split(s, "\n") {
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format(time.RFC3339), line)
@ -225,7 +218,6 @@ func logSkillFindings(sr StepResult) {
return
}
// Rule engine returns analysis as a structured map.
am, _ := analysis.(map[string]interface{})
if am == nil {
return
@ -264,13 +256,17 @@ func logSkillFindings(sr StepResult) {
}
}
// daemonLogPath returns the log file path for a workflow daemon.
// DaemonLogPath returns the log file path for a workflow daemon.
func DaemonLogPath(name string) string {
return daemonLogPath(name)
}
func daemonLogPath(name string) string {
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.log", name))
}
// tailDaemonLog reads and optionally follows a daemon log file.
func tailDaemonLog(name string, follow bool) error {
// TailDaemonLog reads and optionally follows a daemon log file.
func TailDaemonLog(name string, follow bool) error {
path := daemonLogPath(name)
data, err := os.ReadFile(path)
if err != nil {
@ -315,8 +311,8 @@ func tailDaemonLog(name string, follow bool) error {
}
}
// installSystemdUnit generates a systemd service unit file for a workflow daemon.
func installSystemdUnit(ctx *common.RuntimeContext, wf *WorkflowDef, interval, aiMode string) error {
// InstallSystemdUnit generates a systemd service unit file for a workflow daemon.
func InstallSystemdUnit(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval, aiMode string) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("resolve repo: %w", err)
}
@ -344,12 +340,12 @@ StandardError=append:%s
[Install]
WantedBy=multi-user.target
`,
wf.Name, ctx.Owner, ctx.Repo,
bin, wf.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
daemonLogPath(wf.Name), daemonLogPath(wf.Name),
wfDef.Name, ctx.Owner, ctx.Repo,
bin, wfDef.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
daemonLogPath(wfDef.Name), daemonLogPath(wfDef.Name),
)
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wf.Name))
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wfDef.Name))
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
return fmt.Errorf("写入 unit 文件: %w", err)
}
@ -358,10 +354,10 @@ WantedBy=multi-user.target
fmt.Println("安装步骤:")
fmt.Printf(" sudo cp %s /etc/systemd/system/\n", unitPath)
fmt.Println(" sudo systemctl daemon-reload")
fmt.Printf(" sudo systemctl enable workflow-%s\n", wf.Name)
fmt.Printf(" sudo systemctl start workflow-%s\n", wf.Name)
fmt.Printf(" sudo systemctl enable workflow-%s\n", wfDef.Name)
fmt.Printf(" sudo systemctl start workflow-%s\n", wfDef.Name)
fmt.Println()
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wf.Name)
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wfDef.Name)
return nil
}

View File

@ -0,0 +1,43 @@
package daemon
import (
"testing"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestBuildDaemonArgsSkipsOwnerRepoForExplicitMultiRepo(t *testing.T) {
wfDef := &wf.WorkflowDef{
Name: "multi-repo",
}
ctx := &common.RuntimeContext{
Owner: "ignored",
Repo: "ignored",
Args: map[string]string{
"repos": "org/backend,org/frontend",
"release": "v1.4.0",
},
}
args := BuildDaemonArgs(ctx, wfDef, 24*time.Hour, "no-ai")
if stringSliceContains(args, "--owner") || stringSliceContains(args, "--repo") {
t.Fatalf("explicit multi-repo daemon args should not include owner/repo: %v", args)
}
if !stringSliceContains(args, "--repos") || !stringSliceContains(args, "org/backend,org/frontend") {
t.Fatalf("daemon args missing repos: %v", args)
}
if !stringSliceContains(args, "--release") || !stringSliceContains(args, "v1.4.0") {
t.Fatalf("daemon args missing release: %v", args)
}
}
func stringSliceContains(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}

View File

@ -0,0 +1,12 @@
//go:build !windows
package daemon
import (
"os/exec"
"syscall"
)
func applyDaemonAttrs(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}

View File

@ -0,0 +1,12 @@
//go:build windows
package daemon
import (
"os/exec"
"syscall"
)
func applyDaemonAttrs(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}

View File

@ -0,0 +1,68 @@
package daemon
import (
"fmt"
"os"
"os/signal"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
// Schedule runs the full workflow on a repeating interval. Blocks until Ctrl+C.
func Schedule(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration) error {
fmt.Printf("⏰ Scheduled %q every %v on %s/%s\n", wfDef.Name, interval, ctx.Owner, ctx.Repo)
fmt.Println(" Press Ctrl+C to stop")
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
tick := time.NewTicker(interval)
defer tick.Stop()
st, _ := state.LoadState(wfDef.Name)
dryResult, _ := engine.Run(ctx, wfDef, true)
st.UpdateSnapshots(dryResult.Steps)
st.TotalRuns++
st.Save()
for {
select {
case <-sig:
fmt.Println("\n👋 schedule stopped")
return nil
case t := <-tick.C:
dryResult, err := engine.Run(ctx, wfDef, true)
if err != nil {
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
continue
}
changed := st.Diff(dryResult.Steps)
st.UpdateSnapshots(dryResult.Steps)
st.TotalRuns++
st.Save()
if len(changed) == 0 {
fmt.Printf("[%s] ✓ no changes, skipped AI run\n", t.Format("15:04:05"))
continue
}
fmt.Printf("[%s] ⏳ changes detected, running %q with AI...\n", t.Format("15:04:05"), wfDef.Name)
result, err := engine.Run(ctx, wfDef, false)
if err != nil {
fmt.Printf("❌ error: %v\n", err)
continue
}
ok, total := 0, len(result.Steps)
for _, sr := range result.Steps {
if sr.OK {
ok++
}
}
fmt.Printf("✅ %d/%d steps OK\n", ok, total)
}
}
}

View File

@ -0,0 +1,72 @@
package daemon
import (
"fmt"
"os"
"os/signal"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
// Watch polls the first step (or watchStep) every interval and triggers the
// full workflow (with AI) only when data changes. Blocks until Ctrl+C.
func Watch(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, watchStep string) error {
if watchStep == "" && len(wfDef.Steps) > 0 {
watchStep = wfDef.Steps[0].Name
}
fmt.Printf("👀 Watching %s/%s for %q changes every %v\n", ctx.Owner, ctx.Repo, watchStep, interval)
fmt.Printf(" Trigger: %s on %s\n", wfDef.Trigger.Type, wfDef.Trigger.On)
fmt.Println(" Press Ctrl+C to stop")
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
st, _ := state.LoadState(wfDef.Name)
tick := time.NewTicker(interval)
defer tick.Stop()
for {
select {
case <-sig:
fmt.Println("\n👋 watch stopped")
return nil
case t := <-tick.C:
dryResult, err := engine.Run(ctx, wfDef, true)
if err != nil {
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
continue
}
changed := st.Diff(dryResult.Steps)
if len(changed) == 0 && st.TotalRuns > 0 {
fmt.Printf("[%s] ✓ no changes\n", t.Format("15:04:05"))
continue
}
fmt.Printf("[%s] 🔔 change detected: %v\n", t.Format("15:04:05"), changed)
result, err := engine.Run(ctx, wfDef, false)
if err != nil {
fmt.Printf("[%s] ❌ AI run error: %v\n", t.Format("15:04:05"), err)
continue
}
st.UpdateSnapshots(result.Steps)
st.TotalRuns++
st.Save()
for _, sr := range result.Steps {
if sr.OK {
fmt.Printf(" ✓ %s\n", sr.Step)
} else {
fmt.Printf(" ✗ %s: %s\n", sr.Step, sr.Error)
}
}
}
}
}

View File

@ -1,57 +0,0 @@
package workflow
import (
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/internal/client"
"net/http"
"net/http/httptest"
)
func TestDaemonCycleTriageResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return a simple issue list
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true,"data":{"issues":[{"project_issues_index":"1","title":"fix crash","body":"app crashes on startup"}]}}`))
}))
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "test", Repo: "test", Format: "json",
Args: map[string]string{},
}
wf := &WorkflowDef{
Name: "test-daemon-cycle",
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-issues", Purpose: "issues", Target: "issue +list --state open"},
{Type: StepTypeCommand, Name: "labels", Purpose: "labels", Target: "label +list"},
{Type: StepTypeCommand, Name: "members", Purpose: "members", Target: "member +list"},
{Type: StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}},
},
}
// Simulate daemon cycle: dry-run then full run with same ctx
t.Log("=== Dry run ===")
dryResult, _ := Run(ctx, wf, true)
for _, sr := range dryResult.Steps {
t.Logf("dry %s: ok=%v data=%v", sr.Step, sr.OK, sr.Data)
}
t.Log("=== Full run ===")
fullResult, _ := Run(ctx, wf, false)
for _, sr := range fullResult.Steps {
t.Logf("full %s: ok=%v data=%v", sr.Step, sr.OK, sr.Data)
if sr.Step == "triage" {
d, ok := sr.Data.(map[string]interface{})
if ok {
a, _ := d["analysis"].(map[string]interface{})
t.Logf("triage analysis: %v", a)
if a != nil {
t.Logf("classified: %v", a["classified"])
}
}
}
}
}

View File

@ -0,0 +1,26 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterCodeQuality registers the code-quality workflow definition.
func RegisterCodeQuality() {
wf.Register(&wf.WorkflowDef{
Name: "code-quality",
Category: "质量",
Description: "代码质量看门人PR 提交 → Review → CI 检查 → 结果汇总",
Trigger: wf.TriggerDef{
Type: "poll",
On: "pr.opened",
Interval: "5m",
},
Steps: []wf.StepDef{
{Type: wf.StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
{Type: wf.StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
{Type: wf.StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
{Type: wf.StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
{Type: wf.StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败并给出建议", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
},
})
}

View File

@ -0,0 +1,29 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterCommunityOps registers the community-ops workflow definition.
func RegisterCommunityOps() {
wf.Register(&wf.WorkflowDef{
Name: "community-ops",
Category: "运营",
Description: "社区运营自动化Issue 智能分拣 → 生成周报 → 生成 Release Notes",
Trigger: wf.TriggerDef{
Type: "poll",
On: "issue.created",
Interval: "5m",
},
Steps: []wf.StepDef{
{Type: wf.StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
{Type: wf.StepTypeCommand, Name: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
{Type: wf.StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +list"},
{Type: wf.StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: wf.RunAlways},
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
{Type: wf.StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "获取提交历史分析活跃度", Target: "commit +list --limit 50"},
{Type: wf.StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: wf.RunWeekly},
{Type: wf.StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
{Type: wf.StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: wf.RunOnChange},
},
})
}

View File

@ -0,0 +1,26 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterContributorGrowth registers the contributor-growth workflow definition.
func RegisterContributorGrowth() {
wf.Register(&wf.WorkflowDef{
Name: "contributor-growth",
Category: "成长",
Description: "贡献者成长体系:追踪贡献者活动 → 生成排行 → 识别活跃与流失",
Trigger: wf.TriggerDef{
Type: "cron",
On: "0 9 * * 1",
Interval: "24h",
},
Steps: []wf.StepDef{
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "提交历史统计代码贡献", Target: "commit +list --limit 100"},
{Type: wf.StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue 统计 Issue 贡献", Target: "issue +list --state open --limit 50"},
{Type: wf.StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue 统计解决贡献", Target: "issue +list --state closed --limit 50"},
{Type: wf.StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
{Type: wf.StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据Fork/Star/Watch", Target: "repo +info"},
{Type: wf.StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
},
})
}

View File

@ -0,0 +1,9 @@
package defs
func init() {
RegisterCommunityOps()
RegisterCodeQuality()
RegisterProjectInit()
RegisterMultiRepo()
RegisterContributorGrowth()
}

View File

@ -1,24 +1,27 @@
package workflow
package defs
func registerMultiRepo() {
register(&WorkflowDef{
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterMultiRepo registers the multi-repo workflow definition.
func RegisterMultiRepo() {
wf.Register(&wf.WorkflowDef{
Name: "multi-repo",
Category: "协同",
Description: "多仓库协同:跨仓库 Issue/PR 状态看板、Release 协调发布",
Trigger: TriggerDef{
Trigger: wf.TriggerDef{
Type: "cron",
On: "0 9 * * 1",
Interval: "24h",
},
Steps: []StepDef{
Steps: []wf.StepDef{
{
Type: StepTypeCommand,
Type: wf.StepTypeCommand,
Name: "multi-repo-snapshot",
Purpose: "采集多个仓库的 Issue/PR/Release/Milestone 状态",
Target: "workflow-internal:multi-repo-snapshot",
},
{
Type: StepTypeSkill,
Type: wf.StepTypeSkill,
Name: "multi-repo-coordination",
Purpose: "生成统一 Issue 追踪、PR 看板、Release 协调报告",
Target: "gitlink-multi-repo",

View File

@ -0,0 +1,27 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterProjectInit registers the project-init workflow definition.
func RegisterProjectInit() {
wf.Register(&wf.WorkflowDef{
Name: "project-init",
Category: "初始化",
Description: "项目一键初始化:创建仓库 → 脚手架文件 → 标签/里程碑/Issue → 许可证审计 → 健康报告",
Trigger: wf.TriggerDef{
Type: "manual",
On: "manual",
},
Steps: []wf.StepDef{
{Type: wf.StepTypeSkill, Name: "init-scaffold", Purpose: "根据描述创建仓库并初始化脚手架README/LICENSE/.gitignore/标签/里程碑/Issue", Target: "gitlink-init-scaffold"},
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建并获取基础信息", Target: "repo +info"},
{Type: wf.StepTypeCommand, Name: "existing-files", Purpose: "验证 README/LICENSE 文件", Target: "file +list"},
{Type: wf.StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
{Type: wf.StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
{Type: wf.StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
{Type: wf.StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
{Type: wf.StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
{Type: wf.StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
},
})
}

View File

@ -1,201 +0,0 @@
package workflow
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// WorkflowResult holds the outcome of a full workflow run.
type WorkflowResult struct {
Workflow string `json:"workflow"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Steps []StepResult `json:"steps"`
}
// Run executes every step in a workflow sequentially.
// Steps later in the sequence receive data from their DependsOn predecessors
// via ctx.Args (keyed by step name, stored as JSON).
// Set dryRun to true to skip AI API calls for skill steps.
func Run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
return RunWithMode(ctx, wf, dryRun, "")
}
// RunWithMode executes a workflow with explicit AI mode control.
// aiMode must be "auto", "ai", "no-ai", or "" (equivalent to "auto").
func RunWithMode(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) (*WorkflowResult, error) {
if aiMode != "" {
ctx.AIMode = aiMode
}
return run(ctx, wf, dryRun)
}
func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
if !shouldSkipOwnerRepoResolve(ctx, wf) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return nil, fmt.Errorf("resolve repo: %w", err)
}
}
if ctx.Args == nil {
ctx.Args = make(map[string]string)
}
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
ctx.Args["dry_run"] = "true"
}
ctx.Args["__wf_name"] = wf.Name
// Make owner and repo available to skill rules as _owner / _repo.
if ctx.Owner != "" {
ctx.Args["_owner"] = ctx.Owner
}
if ctx.Repo != "" {
ctx.Args["_repo"] = ctx.Repo
}
// Load state for condition checks (only in non-dry-run mode).
var state *WorkflowState
if !dryRun {
state, _ = LoadState(wf.Name)
}
results := make([]StepResult, 0, len(wf.Steps))
for _, step := range wf.Steps {
// Compute upstream hash for condition checking and state tracking.
var upstreamHash string
if !dryRun && state != nil {
upstream := collectUpstream(ctx, step)
upstreamHash = hashData(upstream)
}
// Phase conditions (weekly/on_change) only apply in daemon/poll mode.
// Manual +run always executes all steps.
isDaemon := ctx.Arg("daemon-loop") == "true"
if isDaemon && !dryRun && state != nil && step.RunWhen != "" && step.RunWhen != RunAlways {
shouldRun, skipReason := checkPhaseCondition(step, state, upstreamHash)
if !shouldRun {
fmt.Fprintf(os.Stderr, "[%s] 跳过 %s: %s\n", wf.Name, step.Name, skipReason)
results = append(results, StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
OK: true,
Skipped: true,
SkipReason: skipReason,
})
continue
}
}
sr := ExecuteStep(ctx, step, dryRun)
results = append(results, *sr)
// After init-scaffold creates a repo, update ctx.Repo so
// subsequent API steps target the newly created repository.
if sr.OK && !sr.Skipped && step.Name == "init-scaffold" && ctx.Repo == "" {
if m, ok := sr.Data.(map[string]interface{}); ok {
if a, ok := m["analysis"].(map[string]interface{}); ok {
if r, ok := a["repo"].(string); ok && r != "" {
parts := strings.SplitN(r, "/", 2)
if len(parts) == 2 {
ctx.Repo = parts[1]
}
}
}
}
}
// After successful step, update state for future condition checks.
if !dryRun && state != nil && sr.OK && !sr.Skipped {
if state.PhaseLastRun == nil {
state.PhaseLastRun = make(map[string]string)
}
state.PhaseLastRun[step.Name] = time.Now().Format(time.RFC3339)
if state.PhaseUpstream == nil {
state.PhaseUpstream = make(map[string]string)
}
state.PhaseUpstream[step.Name] = upstreamHash
}
// Feed output of this step as input to downstream steps via Args.
if sr.Data != nil {
raw, err := json.Marshal(sr.Data)
if err == nil {
ctx.Args[step.Name] = string(raw)
} else {
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
}
} else if !sr.OK && sr.Error != "" {
ctx.Args[step.Name] = fmt.Sprintf(`{"_error": true, "_message": %q}`, sr.Error)
}
}
if !dryRun && state != nil {
state.Save()
}
return &WorkflowResult{
Workflow: wf.Name,
Owner: ctx.Owner,
Repo: ctx.Repo,
Steps: results,
}, nil
}
func shouldSkipOwnerRepoResolve(ctx *common.RuntimeContext, wf *WorkflowDef) bool {
if wf != nil && wf.Name == "project-init" {
return true
}
return isExplicitMultiRepoRun(ctx, wf)
}
func isExplicitMultiRepoRun(ctx *common.RuntimeContext, wf *WorkflowDef) bool {
if wf == nil || wf.Name != "multi-repo" {
return false
}
return ctx.Arg("repos") != "" || ctx.Arg("from") != ""
}
// checkPhaseCondition determines whether a step should run based on its RunWhen setting.
func checkPhaseCondition(step StepDef, state *WorkflowState, upstreamHash string) (bool, string) {
switch step.RunWhen {
case RunWeekly:
last := state.PhaseLastRun[step.Name]
if last == "" {
return true, "" // first run
}
t, err := time.Parse(time.RFC3339, last)
if err != nil {
return true, ""
}
if time.Since(t) >= 7*24*time.Hour {
return true, "" // more than 7 days
}
next := t.Add(7 * 24 * time.Hour)
return false, fmt.Sprintf("下次运行: %s", next.Format("01-02 15:04"))
case RunOnChange:
if prev, ok := state.PhaseUpstream[step.Name]; ok && prev == upstreamHash {
return false, "数据无变化"
}
return true, ""
default:
return true, ""
}
}
// resolvePath replaces template placeholders in a path string.
//
// {base} → /owner/repo
// {v1} → /v1/owner/repo
func resolvePath(template, owner, repo string) string {
base := fmt.Sprintf("/%s/%s", owner, repo)
v1 := fmt.Sprintf("/v1/%s/%s", owner, repo)
s := strings.Replace(template, "{v1}", v1, 1)
s = strings.Replace(s, "{base}", base, 1)
return s
}

View File

@ -0,0 +1,129 @@
package engine
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func executeActions(ctx *common.RuntimeContext, actions []wf.AIAction) (int, []string) {
executed := 0
var errors []string
seen := make(map[string]bool, len(actions))
for _, action := range actions {
key := actionKey(action)
if seen[key] {
fmt.Fprintf(os.Stderr, "[workflow] skipped duplicate action: %s %s %s\n", action.Type, action.Module, action.Command)
continue
}
seen[key] = true
if !isActionAllowed(action) {
msg := fmt.Sprintf("blocked action: %s %s +%s", action.Type, action.Module, action.Command)
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
if action.Type == "api" {
path := wf.ResolvePath(action.Path, ctx.Owner, ctx.Repo)
_, err := ctx.CallAPI(action.Method, path, action.Body)
if err != nil {
msg := fmt.Sprintf("api %s %s: %v", action.Method, path, err)
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
executed++
} else if action.Type == "cli" {
args := []string{action.Module, action.Command}
for k, v := range action.Args {
args = append(args, "--"+k, v)
}
if ctx.Owner != "" {
args = append(args, "--owner", ctx.Owner)
}
if ctx.Repo != "" {
args = append(args, "--repo", ctx.Repo)
}
bin := wf.ResolveCLIBinary()
cmd := exec.Command(bin, args...)
var cliStderr bytes.Buffer
cmd.Stderr = &cliStderr
err := cmd.Run()
if err != nil {
stderrStr := strings.TrimSpace(cliStderr.String())
if action.Module == "repo" && action.Command == "+create" && strings.Contains(stderrStr, "已被使用") {
fmt.Fprintf(os.Stderr, "[workflow] repo %s already exists, reusing\n", action.Args["name"])
executed++
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
ctx.Repo = name
}
continue
}
msg := fmt.Sprintf("cli %s: %v", strings.Join(args, " "), err)
if cliStderr.Len() > 0 {
msg += " — " + stderrStr
}
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
executed++
if action.Module == "repo" && action.Command == "+create" {
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
ctx.Repo = name
}
}
}
}
return executed, errors
}
func actionKey(action wf.AIAction) string {
raw, err := json.Marshal(action)
if err != nil {
return fmt.Sprintf("%s:%s:%s:%v:%s:%s", action.Type, action.Module, action.Command, action.Args, action.Method, action.Path)
}
sum := md5.Sum(raw)
return hex.EncodeToString(sum[:])
}
var allowedAPIMethods = map[string]bool{
"GET": true, "POST": true, "PATCH": true,
}
var allowedCLIModules = map[string]bool{
"issue": true, "pr": true, "release": true,
"wiki": true, "member": true, "label": true,
"milestone": true, "branch": true, "comment": true,
"repo": true, "file": true,
}
var blockedCLICommands = map[string]bool{
"+delete": true, "+remove": true, "+batch-delete": true,
"+fork": true, "+batch-fork": true,
}
func isActionAllowed(action wf.AIAction) bool {
if action.Type == "api" {
if !allowedAPIMethods[action.Method] {
return false
}
}
if action.Type == "cli" {
if !allowedCLIModules[action.Module] {
return false
}
if blockedCLICommands[action.Command] {
return false
}
}
return true
}

View File

@ -0,0 +1,31 @@
package engine
import (
"testing"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestActionAllowed(t *testing.T) {
cases := []struct {
name string
action wf.AIAction
allowed bool
}{
{"api GET", wf.AIAction{Type: "api", Method: "GET"}, true},
{"api POST", wf.AIAction{Type: "api", Method: "POST"}, true},
{"api PATCH", wf.AIAction{Type: "api", Method: "PATCH"}, true},
{"api DELETE blocked", wf.AIAction{Type: "api", Method: "DELETE"}, false},
{"cli issue comment", wf.AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
{"cli delete blocked", wf.AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
{"cli fork blocked", wf.AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
{"cli repo module blocked", wf.AIAction{Type: "cli", Module: "org", Command: "+list"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isActionAllowed(tc.action); got != tc.allowed {
t.Errorf("isActionAllowed(%+v) = %v, want %v", tc.action, got, tc.allowed)
}
})
}
}

View File

@ -0,0 +1,254 @@
package engine
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestRunWithAPISteps(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
{"id": 1, "subject": "bug"},
{"id": 2, "subject": "feature"},
}, nil))
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/labels.json":
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
{"id": 10, "name": "bug"},
{"id": 11, "name": "enhancement"},
}, nil))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-api",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
{Type: wf.StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
},
}
result, err := Run(ctx, wfDef, false)
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
if result.Owner != "owner" || result.Repo != "repo" {
t.Fatalf("expected owner/repo = owner/repo, got %s/%s", result.Owner, result.Repo)
}
if len(result.Steps) != 2 {
t.Fatalf("expected 2 step results, got %d", len(result.Steps))
}
for _, sr := range result.Steps {
if !sr.OK {
t.Fatalf("step %q: expected ok=true, got error=%q", sr.Step, sr.Error)
}
}
}
func TestSkillStepReceivesUpstream(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/owner/repo/issues.json" {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
"issues": []map[string]interface{}{{"id": 1}},
}, nil))
} else if r.URL.Path == "/v1/owner/repo/labels.json" {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
"labels": []map[string]interface{}{{"name": "bug"}},
}, nil))
} else {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-skill-upstream",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
{Type: wf.StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
{Type: wf.StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
},
}
result, err := Run(ctx, wfDef, true) // dry-run to test upstream without AI
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
skillData, ok := result.Steps[2].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
if v, _ := skillData["_dry_run"]; v != true {
t.Fatal("skill step should have _dry_run=true")
}
upstream, ok := skillData["_upstream"].(map[string]interface{})
if !ok {
t.Fatal("skill step missing _upstream map")
}
if _, hasIssues := upstream["get-issues"]; !hasIssues {
t.Fatal("_upstream missing get-issues key")
}
if _, hasLabels := upstream["get-labels"]; !hasLabels {
t.Fatal("_upstream missing get-labels key")
}
if skillData["_skill"] != "gitlink-triage" {
t.Fatalf("_skill = %q, want %q", skillData["_skill"], "gitlink-triage")
}
}
func TestSkillStepWithDependsOn(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-depends-on",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
{Type: wf.StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
{Type: wf.StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
{Type: wf.StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
DependsOn: []string{"open-issues", "labels"}},
},
}
result, err := Run(ctx, wfDef, true) // dry-run to test DependsOn filter without AI
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
skillData, ok := result.Steps[3].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
upstream, ok := skillData["_upstream"].(map[string]interface{})
if !ok {
t.Fatal("skill step missing _upstream map")
}
if _, hasIssues := upstream["open-issues"]; !hasIssues {
t.Fatal("_upstream missing open-issues key")
}
if _, hasLabels := upstream["labels"]; !hasLabels {
t.Fatal("_upstream missing labels key")
}
if _, hasMembers := upstream["members"]; hasMembers {
t.Fatal("_upstream should NOT contain members (not in DependsOn)")
}
}
func TestRunStepFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
writeJSON(t, w, map[string]interface{}{
"ok": false, "error": "internal server error",
})
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-fail",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
},
}
result, err := Run(ctx, wfDef, false)
if err != nil {
t.Fatalf("Run() returned error: %v (steps should fail gracefully)", err)
}
if result.Steps[0].OK {
t.Fatal("expected step to fail, but it passed")
}
}
func TestRunUnknownStepType(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("no request expected")
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-unknown",
Steps: []wf.StepDef{
{Type: wf.StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
},
}
result, err := Run(ctx, wfDef, false)
if err != nil {
t.Fatalf("Run() returned error: %v", err)
}
if result.Steps[0].OK {
t.Fatal("unknown step type should fail")
}
}
func TestSkillStepDryRun(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-dry-run",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
{Type: wf.StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
DependsOn: []string{"get-data"}},
},
}
result, err := Run(ctx, wfDef, true)
if err != nil {
t.Fatalf("Run() dry-run failed: %v", err)
}
skillData, ok := result.Steps[1].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
if v, _ := skillData["_dry_run"]; v != true {
t.Fatal("dry-run skill step should have _dry_run=true")
}
}
func newTestContext(t *testing.T, server *httptest.Server) *common.RuntimeContext {
t.Helper()
return &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{},
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}

View File

@ -0,0 +1,175 @@
package engine
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
// Run executes every step in a workflow sequentially.
func Run(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool) (*wf.WorkflowResult, error) {
return RunWithMode(ctx, wfDef, dryRun, "")
}
// RunWithMode executes a workflow with explicit AI mode control.
func RunWithMode(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool, aiMode string) (*wf.WorkflowResult, error) {
if aiMode != "" {
ctx.AIMode = aiMode
}
return run(ctx, wfDef, dryRun)
}
func run(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool) (*wf.WorkflowResult, error) {
if !shouldSkipOwnerRepoResolve(ctx, wfDef) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return nil, fmt.Errorf("resolve repo: %w", err)
}
}
if ctx.Args == nil {
ctx.Args = make(map[string]string)
}
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
ctx.Args["dry_run"] = "true"
}
ctx.Args["__wf_name"] = wfDef.Name
if ctx.Owner != "" {
ctx.Args["_owner"] = ctx.Owner
}
if ctx.Repo != "" {
ctx.Args["_repo"] = ctx.Repo
}
var workflowState *state.WorkflowState
if !dryRun {
workflowState, _ = state.LoadState(wfDef.Name)
}
results := make([]wf.StepResult, 0, len(wfDef.Steps))
for _, step := range wfDef.Steps {
var upstreamHash string
if !dryRun && workflowState != nil {
upstream := collectUpstream(ctx, step)
upstreamHash = state.HashData(upstream)
}
isDaemon := ctx.Arg("daemon-loop") == "true"
if isDaemon && !dryRun && workflowState != nil && step.RunWhen != "" && step.RunWhen != wf.RunAlways {
shouldRun, skipReason := checkPhaseCondition(step, workflowState, upstreamHash)
if !shouldRun {
fmt.Fprintf(os.Stderr, "[%s] 跳过 %s: %s\n", wfDef.Name, step.Name, skipReason)
results = append(results, wf.StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
OK: true,
Skipped: true,
SkipReason: skipReason,
})
continue
}
}
sr := ExecuteStep(ctx, step, dryRun)
results = append(results, *sr)
if sr.OK && !sr.Skipped && step.Name == "init-scaffold" && ctx.Repo == "" {
if m, ok := sr.Data.(map[string]interface{}); ok {
if a, ok := m["analysis"].(map[string]interface{}); ok {
if r, ok := a["repo"].(string); ok && r != "" {
parts := strings.SplitN(r, "/", 2)
if len(parts) == 2 {
ctx.Repo = parts[1]
}
}
}
}
}
if !dryRun && workflowState != nil && sr.OK && !sr.Skipped {
if workflowState.PhaseLastRun == nil {
workflowState.PhaseLastRun = make(map[string]string)
}
workflowState.PhaseLastRun[step.Name] = time.Now().Format(time.RFC3339)
if workflowState.PhaseUpstream == nil {
workflowState.PhaseUpstream = make(map[string]string)
}
workflowState.PhaseUpstream[step.Name] = upstreamHash
}
if sr.Data != nil {
raw, err := json.Marshal(sr.Data)
if err == nil {
ctx.Args[step.Name] = string(raw)
} else {
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
}
} else if !sr.OK && sr.Error != "" {
ctx.Args[step.Name] = fmt.Sprintf(`{"_error": true, "_message": %q}`, sr.Error)
}
}
if !dryRun && workflowState != nil {
workflowState.Save()
}
return &wf.WorkflowResult{
Workflow: wfDef.Name,
Owner: ctx.Owner,
Repo: ctx.Repo,
Steps: results,
}, nil
}
func shouldSkipOwnerRepoResolve(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
if wfDef != nil && wfDef.Name == "project-init" {
return true
}
return isExplicitMultiRepoRun(ctx, wfDef)
}
// IsExplicitMultiRepoRun reports whether this is a multi-repo run with explicit
// --repos or --from flags (as opposed to resolving from the current directory).
func IsExplicitMultiRepoRun(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
return isExplicitMultiRepoRun(ctx, wfDef)
}
func isExplicitMultiRepoRun(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
if wfDef == nil || wfDef.Name != "multi-repo" {
return false
}
return ctx.Arg("repos") != "" || ctx.Arg("from") != ""
}
func checkPhaseCondition(step wf.StepDef, workflowState *state.WorkflowState, upstreamHash string) (bool, string) {
switch step.RunWhen {
case wf.RunWeekly:
last := workflowState.PhaseLastRun[step.Name]
if last == "" {
return true, ""
}
t, err := time.Parse(time.RFC3339, last)
if err != nil {
return true, ""
}
if time.Since(t) >= 7*24*time.Hour {
return true, ""
}
next := t.Add(7 * 24 * time.Hour)
return false, fmt.Sprintf("下次运行: %s", next.Format("01-02 15:04"))
case wf.RunOnChange:
if prev, ok := workflowState.PhaseUpstream[step.Name]; ok && prev == upstreamHash {
return false, "数据无变化"
}
return true, ""
default:
return true, ""
}
}

View File

@ -0,0 +1,322 @@
package engine
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/ai"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
func executeSkillStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult, dryRun bool) {
upstream := collectUpstream(ctx, step)
if step.Target == "gitlink-review" {
state.EnrichWithPRDiffs(upstream, ctx.Owner, ctx.Repo)
state.FilterReviewedPRs(upstream, ctx)
}
if dryRun {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_dry_run": true,
"_depends_on": step.DependsOn,
"_upstream": upstream,
"_hint": "预览模式:展示将要传给 AI/规则引擎 的上游数据,不实际执行。",
}
return
}
aiMode := resolveAIMode(ctx)
client := ai.NewAIClient()
var aiResp *wf.AIResponse
var usedAI bool
var aiAnalysis interface{}
switch aiMode {
case wf.AIModeNoAI:
resp, err := runRuleEngine(step, upstream)
if err != nil {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_needs_ai": true,
"_upstream": upstream,
"_error": fmt.Sprintf("rule engine failed: %v", err),
}
return
}
aiResp = resp
case wf.AIModeAI:
if !client.HasKey() {
sr.OK = false
sr.Error = "AI 模式需要配置 API Key设置 DEEPSEEK_API_KEY 环境变量或 config set deepseek_api_key"
return
}
resp, err := callAI(client, step, upstream)
if err != nil {
sr.OK = false
sr.Error = fmt.Sprintf("AI 调用失败: %v", err)
return
}
aiResp = resp
usedAI = true
default:
if client.HasKey() {
resp, err := callAI(client, step, upstream)
if err == nil {
aiResp = resp
usedAI = true
} else {
fmt.Fprintf(os.Stderr, "[workflow] AI 调用失败,降级到规则引擎: %v\n", err)
}
}
if aiResp == nil {
resp, err := runRuleEngine(step, upstream)
if err != nil {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_needs_ai": true,
"_upstream": upstream,
"_error": fmt.Sprintf("AI 和规则引擎均失败: %v", err),
}
return
}
aiResp = resp
}
}
if usedAI && step.Target == "gitlink-triage" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
aiAnalysis = aiResp.Analysis
aiResp.Actions = ruleResp.Actions
aiResp.Analysis = ruleResp.Analysis
} else {
fmt.Fprintf(os.Stderr, "[workflow] triage rule action fallback failed: %v\n", err)
}
}
if usedAI && step.Target == "gitlink-init-scaffold" {
if suggested := extractRepoNameFromAIAnalysis(aiResp.Analysis); suggested != "" {
upstream["_repo"] = suggested
}
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
aiAnalysis = aiResp.Analysis
aiResp.Actions = ruleResp.Actions
} else {
fmt.Fprintf(os.Stderr, "[workflow] init-scaffold rule fallback failed: %v\n", err)
}
}
if usedAI && step.Name == "health-report" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
content := ""
if s, ok := aiResp.Analysis.(string); ok && s != "" {
content = s
}
if content == "" {
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 项目健康度报告\n\n" + string(b) + "\n"
}
}
for _, a := range ruleResp.Actions {
if a.Module == "wiki" && a.Command == "+create" {
if content != "" {
a.Args["content"] = content
}
aiResp.Actions = append(aiResp.Actions, a)
}
}
}
}
if usedAI && step.Name == "contributor-ranking" {
content := ""
if s, ok := aiResp.Analysis.(string); ok {
content = s
} else if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 贡献者排行榜\n\n```json\n" + string(b) + "\n```\n"
}
if content != "" {
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
aiResp.Actions = append(aiResp.Actions,
wf.AIAction{
Type: "cli", Module: "wiki", Command: "+create",
Args: map[string]string{
"name": pageName,
"content": content,
"message": "自动生成贡献者排行榜",
},
},
wf.AIAction{
Type: "cli", Module: "wiki", Command: "+update",
Args: map[string]string{
"name": pageName,
"content": content,
"message": "自动更新贡献者排行榜",
},
},
)
}
}
if usedAI && step.Name == "multi-repo-coordination" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
content := ""
if s, ok := aiResp.Analysis.(string); ok && s != "" {
content = s
}
if content == "" {
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 多仓库协同报告\n\n```json\n" + string(b) + "\n```\n"
}
}
for _, a := range ruleResp.Actions {
if a.Module == "wiki" && a.Command == "+create" {
if content != "" {
a.Args["content"] = content
}
aiResp.Actions = append(aiResp.Actions, a)
}
}
}
}
executed, actionErrors := executeActions(ctx, aiResp.Actions)
if step.Target == "gitlink-review" && !dryRun {
state.SaveReviewedPRFingerprints(upstream, ctx)
}
sr.OK = executed > 0 || len(aiResp.Actions) == 0
data := map[string]interface{}{
"ok": sr.OK,
"analysis": aiResp.Analysis,
"executed": executed,
"_ai_used": usedAI,
"_skill": step.Target,
}
if len(actionErrors) > 0 {
data["errors"] = actionErrors
}
if aiAnalysis != nil {
data["ai_analysis"] = aiAnalysis
}
sr.Data = data
}
func resolveAIMode(ctx *common.RuntimeContext) wf.AIMode {
switch ctx.AIMode {
case "ai":
return wf.AIModeAI
case "no-ai":
return wf.AIModeNoAI
default:
return wf.AIModeAuto
}
}
func callAI(client *ai.AIClient, step wf.StepDef, upstream map[string]interface{}) (*wf.AIResponse, error) {
skillMD := readSkillDoc(step.Target)
upstreamJSON, _ := json.MarshalIndent(upstream, "", " ")
return client.Analyze(&wf.AIRequest{
SystemPrompt: skillMD,
UserData: string(upstreamJSON),
})
}
func extractRepoNameFromAIAnalysis(analysis interface{}) string {
m, ok := analysis.(map[string]interface{})
if !ok {
return ""
}
for _, key := range []string{"repo_name", "repo", "_repo", "name", "suggested_name"} {
if v, ok := m[key].(string); ok && v != "" {
v = strings.ToLower(strings.TrimSpace(v))
v = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(v, "-")
v = regexp.MustCompile(`-+`).ReplaceAllString(v, "-")
v = strings.Trim(v, "-")
if len(v) >= 2 {
return v
}
}
}
return ""
}
func runRuleEngine(step wf.StepDef, upstream map[string]interface{}) (*wf.AIResponse, error) {
engine, ok := wf.RuleEngines[step.Target]
if !ok {
return nil, wf.ErrNoRuleEngine(step.Target)
}
return engine(upstream, step.Name)
}
func collectUpstream(ctx *common.RuntimeContext, step wf.StepDef) map[string]interface{} {
upstream := make(map[string]interface{})
for k, v := range ctx.Args {
if strings.HasPrefix(k, "_") {
upstream[k] = v
}
}
for _, dep := range step.DependsOn {
if v, ok := ctx.Args[dep]; ok {
var parsed interface{}
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
upstream[dep] = parsed
} else {
upstream[dep] = v
}
}
}
if len(step.DependsOn) == 0 {
for k, v := range ctx.Args {
var parsed interface{}
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
upstream[k] = parsed
} else {
upstream[k] = v
}
}
}
return upstream
}
func readSkillDoc(target string) string {
paths := []string{}
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
paths = append(paths, filepath.Join(exeDir, "skills", target, "SKILL.md"))
paths = append(paths, filepath.Join(exeDir, "shortcuts", "workflow", "skills", target, "SKILL.md"))
}
paths = append(paths,
filepath.Join("skills", target, "SKILL.md"),
filepath.Join("shortcuts", "workflow", "skills", target, "SKILL.md"),
filepath.Join("/etc/gitlink-cli/skills", target, "SKILL.md"),
)
home, err := os.UserHomeDir()
if err == nil {
paths = append(paths, filepath.Join(home, ".config", "gitlink-cli", "skills", target, "SKILL.md"))
}
for _, p := range paths {
data, err := os.ReadFile(p)
if err == nil {
return string(data)
}
}
return fmt.Sprintf("# %s\n\nSkill documentation not found.", target)
}

View File

@ -0,0 +1,106 @@
package engine
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/snapshot"
)
// ExecuteStep dispatches a step to the right executor based on its Type.
func ExecuteStep(ctx *common.RuntimeContext, step wf.StepDef, dryRun bool) *wf.StepResult {
sr := &wf.StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
}
switch step.Type {
case wf.StepTypeAPI:
executeAPIStep(ctx, step, sr)
case wf.StepTypeCommand:
executeCommandStep(ctx, step, sr)
case wf.StepTypeSkill:
executeSkillStep(ctx, step, sr, dryRun)
default:
sr.OK = false
sr.Error = fmt.Sprintf("unknown step type: %q", step.Type)
}
return sr
}
func executeAPIStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
path := wf.ResolvePath(step.Target, ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery(step.Method, path, step.Query)
if err != nil {
sr.OK = false
sr.Error = err.Error()
} else {
sr.OK = env.OK
sr.Data = env.Data
}
}
func executeCommandStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
if strings.HasPrefix(step.Target, "workflow-internal:") {
executeInternalCommandStep(ctx, step, sr)
return
}
parts := wf.ParseCommandTarget(step.Target)
if len(parts) == 0 {
sr.OK = false
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
return
}
bin := wf.ResolveCLIBinary()
args := append(parts, "--format", "json")
if ctx.Owner != "" {
args = append(args, "--owner", ctx.Owner)
}
if ctx.Repo != "" {
args = append(args, "--repo", ctx.Repo)
}
cmd := exec.Command(bin, args...)
cmd.Stderr = nil
out, err := cmd.Output()
if err != nil {
sr.OK = false
sr.Error = fmt.Sprintf("command failed: %v", err)
return
}
var data interface{}
if err := json.Unmarshal(out, &data); err != nil {
sr.OK = true
sr.Data = strings.TrimSpace(string(out))
} else {
sr.OK = true
sr.Data = data
}
}
func executeInternalCommandStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
switch strings.TrimPrefix(step.Target, "workflow-internal:") {
case "multi-repo-snapshot":
snap, err := snapshot.BuildMultiRepoSnapshot(ctx)
if err != nil {
sr.OK = false
sr.Error = err.Error()
return
}
sr.OK = true
sr.Data = snap
default:
sr.OK = false
sr.Error = fmt.Sprintf("unknown internal workflow command: %q", step.Target)
}
}

View File

@ -1,16 +0,0 @@
package workflow
// manifest.go — registration center for all workflow definitions.
// Each scenario file defines a register*() function; init() calls them all.
// To add a new workflow:
// 1. Create a new file in this package (e.g., my_scenario.go)
// 2. Define func registerMyScenario() { register(&WorkflowDef{...}) }
// 3. Add registerMyScenario() to the init() list below
func init() {
registerCommunityOps()
registerCodeQuality()
registerProjectInit()
registerMultiRepo()
registerContributorGrowth()
}

View File

@ -1,14 +0,0 @@
//go:build !windows
package workflow
import (
"os/exec"
"syscall"
)
// applyDaemonAttrs detaches the daemon from the controlling terminal
// by starting a new session (Unix only; Windows uses proc_windows.go).
func applyDaemonAttrs(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}

View File

@ -1,15 +0,0 @@
//go:build windows
package workflow
import (
"os/exec"
"syscall"
)
// applyDaemonAttrs hides the daemon's console window on Windows.
// (Unix's Setsid equivalent doesn't exist on Windows; HideWindow keeps
// the background process from popping up a console.)
func applyDaemonAttrs(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}

View File

@ -1,24 +0,0 @@
package workflow
func registerProjectInit() {
register(&WorkflowDef{
Name: "project-init",
Category: "初始化",
Description: "项目一键初始化:创建仓库 → 脚手架文件 → 标签/里程碑/Issue → 许可证审计 → 健康报告",
Trigger: TriggerDef{
Type: "manual",
On: "manual",
},
Steps: []StepDef{
{Type: StepTypeSkill, Name: "init-scaffold", Purpose: "根据描述创建仓库并初始化脚手架README/LICENSE/.gitignore/标签/里程碑/Issue", Target: "gitlink-init-scaffold"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建并获取基础信息", Target: "repo +info"},
{Type: StepTypeCommand, Name: "existing-files", Purpose: "验证 README/LICENSE 文件", Target: "file +list"},
{Type: StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
{Type: StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
{Type: StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
{Type: StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
{Type: StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
{Type: StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
},
})
}

View File

@ -0,0 +1,29 @@
package workflow
import "sort"
var registry = map[string]*WorkflowDef{}
// Register adds a workflow definition to the global registry.
func Register(wf *WorkflowDef) {
registry[wf.Name] = wf
}
// All returns all registered workflows sorted by name.
func All() []*WorkflowDef {
names := make([]string, 0, len(registry))
for n := range registry {
names = append(names, n)
}
sort.Strings(names)
result := make([]*WorkflowDef, len(names))
for i, n := range names {
result[i] = registry[n]
}
return result
}
// Get returns a workflow by name, or nil.
func Get(name string) *WorkflowDef {
return registry[name]
}

View File

@ -298,6 +298,8 @@ func authorLogin(m map[string]interface{}) string {
if s := str(a, "login", "username", "name"); s != "" {
return s
}
} else if s, ok := m[key].(string); ok && s != "" {
return s
}
}
return ""

View File

@ -93,8 +93,8 @@ func TestContributorOutputFormat(t *testing.T) {
if resp.Analysis == nil {
t.Fatal("expected non-nil Analysis")
}
if resp.Actions != nil {
t.Fatal("expected nil Actions (read-only report)")
if len(resp.Actions) < 1 {
t.Fatal("expected at least 1 wiki action")
}
}

View File

@ -58,8 +58,8 @@ func TestHealthReportScoring(t *testing.T) {
}
}
}
if len(resp.Actions) != 1 {
t.Fatalf("expected 1 wiki action, got %d", len(resp.Actions))
if len(resp.Actions) != 2 {
t.Fatalf("expected 2 wiki actions (create + update), got %d", len(resp.Actions))
}
action := resp.Actions[0]
if action.Type != "cli" || action.Module != "wiki" || action.Command != "+create" {

View File

@ -17,6 +17,7 @@ func TestRepoAuditComplete(t *testing.T) {
},
"existing-files": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "README.md", "content": "# Project"},
map[string]interface{}{"name": "LICENSE", "content": "MIT"},
},
},

View File

@ -0,0 +1,36 @@
# gitlink-init-scaffold
根据项目描述一键创建仓库并初始化脚手架。
## 输入
上游数据(`_desc`)包含项目的自然语言描述。
## 输出
```json
{
"analysis": {
"repo": "owner/repo-name",
"description": "项目描述",
"created": true
},
"actions": [
{"type": "cli", "module": "repo", "command": "+create", "args": {"name": "repo-name", "description": "..."}},
{"type": "cli", "module": "file", "command": "+create", "args": {"path": "README.md", "content": "..."}},
{"type": "cli", "module": "file", "command": "+create", "args": {"path": "LICENSE", "content": "MIT"}},
{"type": "cli", "module": "file", "command": "+create", "args": {"path": ".gitignore", "content": "..."}},
{"type": "cli", "module": "label", "command": "+create", "args": {"name": "bug", "color": "#d73a4a"}},
{"type": "cli", "module": "milestone", "command": "+create", "args": {"title": "v0.1.0"}},
{"type": "cli", "module": "issue", "command": "+create", "args": {"title": "项目初始化", "body": "..."}}
]
}
```
## 规则
1. 从 `_desc` 提取英文关键词生成仓库名;若无英文词则用 `_repo` 字段
2. 创建 README项目名 + 描述 + 快速开始、MIT LICENSE、Go .gitignore
3. 创建 7 个默认标签bug/security/performance/enhancement/refactor/docs/question
4. 创建 v0.1.0 里程碑
5. 创建 3 个初始 Issue项目初始化、CI/CD 配置、文档完善

View File

@ -1,4 +1,4 @@
package workflow
package snapshot
import (
"encoding/csv"
@ -11,6 +11,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// MultiRepoSnapshot is a cross-repository state snapshot.
type MultiRepoSnapshot struct {
Release string `json:"release,omitempty"`
Repos []RepoSnapshot `json:"repos"`
@ -18,6 +19,7 @@ type MultiRepoSnapshot struct {
Generated string `json:"generated_at"`
}
// RepoSnapshot holds collected data for a single repository.
type RepoSnapshot struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
@ -28,6 +30,7 @@ type RepoSnapshot struct {
Milestones interface{} `json:"milestones,omitempty"`
}
// RepoError records an error encountered while collecting repo data.
type RepoError struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
@ -35,11 +38,9 @@ type RepoError struct {
Error string `json:"error"`
}
type repoRef struct {
Owner string
Repo string
}
type repoRef = RepoRef
// BuildMultiRepoSnapshot collects Issue/PR/Release/Milestone data for multiple repos.
func BuildMultiRepoSnapshot(ctx *common.RuntimeContext) (*MultiRepoSnapshot, error) {
repos, err := parseMultiRepoRefs(ctx.Arg("repos"), ctx.Arg("from"))
if err != nil {
@ -113,6 +114,30 @@ func collectRepoSnapshot(ctx *common.RuntimeContext, ref repoRef, snap *RepoSnap
snap.Milestones = call("milestones", "GET", v1+"/milestones", milestoneQ)
}
// ParseRepoCSV reads repo references from a CSV file.
func ParseRepoCSV(path string) ([]RepoRef, error) {
refs, err := parseRepoCSV(path)
if err != nil {
return nil, err
}
out := make([]RepoRef, len(refs))
for i, r := range refs {
out[i] = RepoRef(r)
}
return out, nil
}
// ParseMultiRepoRefs parses repo references from a comma-separated string and/or CSV file.
func ParseMultiRepoRefs(reposArg, fromPath string) ([]RepoRef, error) {
return parseMultiRepoRefs(reposArg, fromPath)
}
// RepoRef is an owner/repo pair.
type RepoRef struct {
Owner string
Repo string
}
func parseMultiRepoRefs(reposArg, fromPath string) ([]repoRef, error) {
var refs []repoRef
if reposArg != "" {

View File

@ -0,0 +1,38 @@
package snapshot
import (
"os"
"path/filepath"
"testing"
)
func TestParseMultiRepoRefs(t *testing.T) {
refs, err := ParseMultiRepoRefs("org/backend, org/frontend,org/backend", "")
if err != nil {
t.Fatalf("ParseMultiRepoRefs failed: %v", err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 deduped refs, got %d: %+v", len(refs), refs)
}
if refs[0].Owner != "org" || refs[0].Repo != "backend" {
t.Fatalf("unexpected first ref: %+v", refs[0])
}
}
func TestParseRepoCSV(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "repos.csv")
if err := os.WriteFile(path, []byte("owner,repo\norg,backend\norg/frontend\n"), 0600); err != nil {
t.Fatal(err)
}
refs, err := ParseRepoCSV(path)
if err != nil {
t.Fatalf("ParseRepoCSV failed: %v", err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 refs, got %d", len(refs))
}
if refs[1].Owner != "org" || refs[1].Repo != "frontend" {
t.Fatalf("unexpected second ref: %+v", refs[1])
}
}

View File

@ -0,0 +1,231 @@
package state
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// FilterReviewedPRs removes PRs from upstream that were already reviewed
// with the same fingerprint, preventing re-review on every poll cycle.
func FilterReviewedPRs(upstream map[string]interface{}, ctx *common.RuntimeContext) {
wfName := ctx.Arg("__wf_name")
if wfName == "" {
return
}
state, err := LoadState(wfName)
if err != nil || state == nil {
return
}
if state.ReviewedPRs == nil {
state.ReviewedPRs = make(map[string]string)
}
prs := extractPRListFromUpstream(upstream, "open-prs")
if len(prs) == 0 {
return
}
filtered := make([]interface{}, 0, len(prs))
skipped := 0
for _, pr := range prs {
prNum := prNumberFromMap(pr)
if prNum == "" {
filtered = append(filtered, pr)
continue
}
fp := prFingerprint(pr)
if stored, ok := state.ReviewedPRs[prNum]; ok && stored == fp {
skipped++
continue
}
filtered = append(filtered, pr)
}
if skipped > 0 {
fmt.Fprintf(os.Stderr, "[workflow] 跳过 %d 个已审查的 PR无变化\n", skipped)
}
upstream["open-prs"] = map[string]interface{}{"data": filtered}
}
// SaveReviewedPRFingerprints stores PR fingerprints after a successful review.
func SaveReviewedPRFingerprints(upstream map[string]interface{}, ctx *common.RuntimeContext) {
wfName := ctx.Arg("__wf_name")
if wfName == "" {
return
}
state, err := LoadState(wfName)
if err != nil || state == nil {
return
}
if state.ReviewedPRs == nil {
state.ReviewedPRs = make(map[string]string)
}
prs := extractPRListFromUpstream(upstream, "open-prs")
for _, pr := range prs {
prNum := prNumberFromMap(pr)
if prNum == "" {
continue
}
state.ReviewedPRs[prNum] = prFingerprint(pr)
}
state.Save()
}
// EnrichWithPRDiffs fetches diffs for open PRs and stores them in upstream.
func EnrichWithPRDiffs(upstream map[string]interface{}, owner, repo string) {
prs := extractPRListFromUpstream(upstream, "open-prs")
if len(prs) == 0 {
return
}
diffs := make(map[string]string)
for _, pr := range prs {
status := ""
switch v := pr["pull_request_status"].(type) {
case string:
status = v
case float64:
if v == 0 {
status = "open"
}
}
if status == "" {
if s, ok := pr["pull_request_staus"].(string); ok {
status = s
}
}
if status != "" && status != "open" {
continue
}
prNum := ""
if n, ok := pr["pull_request_number"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["id"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["number"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["pull_request_id"]; ok {
prNum = fmt.Sprintf("%v", n)
}
if prNum == "" {
continue
}
bin := wf.ResolveCLIBinary()
cmd := exec.Command(bin, "pr", "+diff", "--id", prNum, "--owner", owner, "--repo", repo, "--format", "json")
out, err := cmd.Output()
if err != nil {
continue
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Files []struct {
Sections []struct {
Lines []struct {
Content string `json:"content"`
} `json:"lines"`
} `json:"sections"`
} `json:"files"`
} `json:"data"`
}
if err := json.Unmarshal(out, &resp); err != nil || !resp.OK || len(resp.Data.Files) == 0 {
continue
}
var sb strings.Builder
for _, f := range resp.Data.Files {
for _, sec := range f.Sections {
for _, line := range sec.Lines {
sb.WriteString(line.Content)
sb.WriteString("\n")
}
}
}
diffText := sb.String()
if diffText == "" {
continue
}
diffs[prNum] = diffText
}
if len(diffs) > 0 {
upstream["_pr_diffs"] = diffs
}
}
// prFingerprint returns an MD5 hash of key PR fields for change detection.
func prFingerprint(pr map[string]interface{}) string {
var parts []string
if t := strFromMap(pr, "title", "name"); t != "" {
parts = append(parts, "title:"+t)
}
if b := strFromMap(pr, "body", "description"); b != "" {
parts = append(parts, "body:"+b)
}
if s := strFromMap(pr, "pull_request_status", "pull_request_staus", "status", "state"); s != "" {
parts = append(parts, "status:"+s)
}
h := md5.Sum([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(h[:])
}
// prNumberFromMap extracts a PR number string from a PR data map.
func prNumberFromMap(pr map[string]interface{}) string {
for _, k := range []string{"pull_request_number", "id", "number", "pull_request_id"} {
if v := pr[k]; v != nil {
s := fmt.Sprintf("%v", v)
if s != "" && s != "0" && s != "<nil>" {
return s
}
}
}
return ""
}
// strFromMap returns the first non-empty string value from the given keys.
func strFromMap(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return v
}
}
return ""
}
// extractPRListFromUpstream extracts PR list from upstream data.
func extractPRListFromUpstream(upstream map[string]interface{}, key string) []map[string]interface{} {
raw, ok := upstream[key]
if !ok {
return nil
}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
if m, ok := raw.(map[string]interface{}); ok {
for _, listKey := range []string{"issues", "pull_requests"} {
if v, ok := m[listKey]; ok {
if arr, ok := v.([]interface{}); ok {
raw = arr
break
}
}
}
}
list, _ := raw.([]interface{})
var out []map[string]interface{}
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}

View File

@ -1,4 +1,4 @@
package workflow
package state
import (
"crypto/md5"
@ -9,8 +9,20 @@ import (
"time"
"github.com/gitlink-org/gitlink-cli/internal/config"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// WorkflowState tracks persistent run state and change detection snapshots.
type WorkflowState struct {
Workflow string `json:"workflow"`
LastRun string `json:"last_run"`
TotalRuns int `json:"total_runs"`
Snapshots map[string]string `json:"snapshots"`
PhaseLastRun map[string]string `json:"phase_last_run,omitempty"`
PhaseUpstream map[string]string `json:"phase_upstream,omitempty"`
ReviewedPRs map[string]string `json:"reviewed_prs,omitempty"`
}
// LoadState reads the persisted workflow state from disk.
func LoadState(name string) (*WorkflowState, error) {
path := statePath(name)
@ -18,9 +30,9 @@ func LoadState(name string) (*WorkflowState, error) {
if err != nil {
if os.IsNotExist(err) {
return &WorkflowState{
Workflow: name,
Snapshots: make(map[string]string),
PhaseLastRun: make(map[string]string),
Workflow: name,
Snapshots: make(map[string]string),
PhaseLastRun: make(map[string]string),
PhaseUpstream: make(map[string]string),
}, nil
}
@ -58,14 +70,13 @@ func (s *WorkflowState) Save() error {
}
// Diff compares current step results against stored snapshots.
// Does NOT mutate snapshots — call UpdateSnapshots separately to persist.
func (s *WorkflowState) Diff(results []StepResult) []string {
func (s *WorkflowState) Diff(results []wf.StepResult) []string {
changed := []string{}
for _, sr := range results {
if !sr.OK || sr.Data == nil {
continue
}
hash := hashData(sr.Data)
hash := HashData(sr.Data)
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
changed = append(changed, sr.Step)
}
@ -74,17 +85,17 @@ func (s *WorkflowState) Diff(results []StepResult) []string {
}
// UpdateSnapshots stores hashes of current step results for future diff.
func (s *WorkflowState) UpdateSnapshots(results []StepResult) {
func (s *WorkflowState) UpdateSnapshots(results []wf.StepResult) {
for _, sr := range results {
if !sr.OK || sr.Data == nil {
continue
}
s.Snapshots[sr.Step] = hashData(sr.Data)
s.Snapshots[sr.Step] = HashData(sr.Data)
}
}
// hashData computes an MD5 hash of the JSON-encoded data.
func hashData(data interface{}) string {
// HashData computes an MD5 hash of the JSON-encoded data.
func HashData(data interface{}) string {
b, err := json.Marshal(data)
if err != nil {
return ""

View File

@ -0,0 +1,80 @@
package state
import (
"os"
"path/filepath"
"testing"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestStateSaveLoad(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
s := &WorkflowState{
Workflow: "test-wf",
TotalRuns: 5,
Snapshots: map[string]string{"step1": "abc123"},
}
if err := s.Save(); err != nil {
t.Fatalf("Save failed: %v", err)
}
loaded, err := LoadState("test-wf")
if err != nil {
t.Fatalf("LoadState failed: %v", err)
}
if loaded.TotalRuns != 5 {
t.Fatalf("TotalRuns = %d, want 5", loaded.TotalRuns)
}
if loaded.Snapshots["step1"] != "abc123" {
t.Fatalf("Snapshots[step1] = %q, want abc123", loaded.Snapshots["step1"])
}
os.Remove(filepath.Join(dir, "workflow-test-wf-state.json"))
}
func TestStateDiff(t *testing.T) {
s := &WorkflowState{
Workflow: "test-diff",
Snapshots: map[string]string{"step1": "oldhash"},
}
results := []wf.StepResult{
{Step: "step1", OK: true, Data: "changed data"},
{Step: "step2", OK: true, Data: "new step"},
{Step: "step3", OK: false, Data: "ignored"},
}
changed := s.Diff(results)
if len(changed) != 1 {
t.Fatalf("Diff: expected 1 changed step, got %d", len(changed))
}
if changed[0] != "step1" {
t.Fatalf("Diff: expected 'step1' to change, got %q", changed[0])
}
s.UpdateSnapshots(results)
if _, ok := s.Snapshots["step2"]; !ok {
t.Fatal("step2 should be added to snapshots after UpdateSnapshots")
}
if _, ok := s.Snapshots["step3"]; ok {
t.Fatal("step3 (failed) should NOT be added to snapshots")
}
}
func TestLoadStateNotExist(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
s, err := LoadState("nonexistent")
if err != nil {
t.Fatalf("LoadState should not error for missing file: %v", err)
}
if s.Workflow != "nonexistent" {
t.Fatalf("Workflow = %q, want nonexistent", s.Workflow)
}
if s.Snapshots == nil {
t.Fatal("Snapshots should be initialized as empty map")
}
}

View File

@ -1,845 +0,0 @@
package workflow
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// StepResult holds the outcome of executing one step.
type StepResult struct {
Step string `json:"step"`
Purpose string `json:"purpose"`
Type StepType `json:"type"`
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
SkipReason string `json:"skip_reason,omitempty"`
}
// ExecuteStep dispatches a step to the right executor based on its Type.
func ExecuteStep(ctx *common.RuntimeContext, step StepDef, dryRun bool) *StepResult {
sr := &StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
}
switch step.Type {
case StepTypeAPI:
executeAPIStep(ctx, step, sr)
case StepTypeCommand:
executeCommandStep(ctx, step, sr)
case StepTypeSkill:
executeSkillStep(ctx, step, sr, dryRun)
default:
sr.OK = false
sr.Error = fmt.Sprintf("unknown step type: %q", step.Type)
}
return sr
}
// executeAPIStep makes an HTTP call through the API client.
func executeAPIStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
path := resolvePath(step.Target, ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery(step.Method, path, step.Query)
if err != nil {
sr.OK = false
sr.Error = err.Error()
} else {
sr.OK = env.OK
sr.Data = env.Data
}
}
// executeCommandStep runs a gitlink-cli subcommand as a subprocess.
func executeCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
if strings.HasPrefix(step.Target, "workflow-internal:") {
executeInternalCommandStep(ctx, step, sr)
return
}
parts := parseCommandTarget(step.Target)
if len(parts) == 0 {
sr.OK = false
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
return
}
bin := resolveCLIBinary()
args := append(parts, "--format", "json")
if ctx.Owner != "" {
args = append(args, "--owner", ctx.Owner)
}
if ctx.Repo != "" {
args = append(args, "--repo", ctx.Repo)
}
cmd := exec.Command(bin, args...)
cmd.Stderr = nil
out, err := cmd.Output()
if err != nil {
sr.OK = false
sr.Error = fmt.Sprintf("command failed: %v", err)
return
}
var data interface{}
if err := json.Unmarshal(out, &data); err != nil {
sr.OK = true
sr.Data = strings.TrimSpace(string(out))
} else {
sr.OK = true
sr.Data = data
}
}
func executeInternalCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
switch strings.TrimPrefix(step.Target, "workflow-internal:") {
case "multi-repo-snapshot":
snapshot, err := BuildMultiRepoSnapshot(ctx)
if err != nil {
sr.OK = false
sr.Error = err.Error()
return
}
sr.OK = true
sr.Data = snapshot
default:
sr.OK = false
sr.Error = fmt.Sprintf("unknown internal workflow command: %q", step.Target)
}
}
// executeSkillStep runs a skill step. Depending on aiMode, it uses the AI API or
// falls back to a deterministic rule engine. Both paths produce the same AIResponse
// format, and actions from either source go through the same security whitelist.
func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult, dryRun bool) {
upstream := collectUpstream(ctx, step)
// For review step, fetch PR diffs and filter already-reviewed PRs.
if step.Target == "gitlink-review" {
enrichWithPRDiffs(upstream, ctx.Owner, ctx.Repo)
filterReviewedPRs(upstream, ctx)
}
if dryRun {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_dry_run": true,
"_depends_on": step.DependsOn,
"_upstream": upstream,
"_hint": "预览模式:展示将要传给 AI/规则引擎 的上游数据,不实际执行。",
}
return
}
aiMode := resolveAIMode(ctx)
client := NewAIClient()
var aiResp *AIResponse
var usedAI bool
var aiAnalysis interface{}
switch aiMode {
case AIModeNoAI:
resp, err := runRuleEngine(step, upstream)
if err != nil {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_needs_ai": true,
"_upstream": upstream,
"_error": fmt.Sprintf("rule engine failed: %v", err),
}
return
}
aiResp = resp
case AIModeAI:
if !client.HasKey() {
sr.OK = false
sr.Error = "AI 模式需要配置 API Key设置 DEEPSEEK_API_KEY 环境变量或 config set deepseek_api_key"
return
}
resp, err := callAI(client, step, upstream)
if err != nil {
sr.OK = false
sr.Error = fmt.Sprintf("AI 调用失败: %v", err)
return
}
aiResp = resp
usedAI = true
default: // "auto"
if client.HasKey() {
resp, err := callAI(client, step, upstream)
if err == nil {
aiResp = resp
usedAI = true
} else {
fmt.Fprintf(os.Stderr, "[workflow] AI 调用失败,降级到规则引擎: %v\n", err)
}
}
if aiResp == nil {
resp, err := runRuleEngine(step, upstream)
if err != nil {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_needs_ai": true,
"_upstream": upstream,
"_error": fmt.Sprintf("AI 和规则引擎均失败: %v", err),
}
return
}
aiResp = resp
}
}
if usedAI && step.Target == "gitlink-triage" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
aiAnalysis = aiResp.Analysis
aiResp.Actions = ruleResp.Actions
aiResp.Analysis = ruleResp.Analysis
} else {
fmt.Fprintf(os.Stderr, "[workflow] triage rule action fallback failed: %v\n", err)
}
}
// In AI mode for init-scaffold: let AI suggest an English repo name from
// a Chinese description, then use the rule engine for deterministic actions.
if usedAI && step.Target == "gitlink-init-scaffold" {
if suggested := extractRepoNameFromAIAnalysis(aiResp.Analysis); suggested != "" {
upstream["_repo"] = suggested
}
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
aiAnalysis = aiResp.Analysis
aiResp.Actions = ruleResp.Actions
} else {
fmt.Fprintf(os.Stderr, "[workflow] init-scaffold rule fallback failed: %v\n", err)
}
}
// In AI mode, supplement health-report with deterministic wiki action.
// AI provides richer semantic analysis; rule engine ensures wiki publishing.
if usedAI && step.Name == "health-report" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
content := ""
if s, ok := aiResp.Analysis.(string); ok && s != "" {
content = s
}
if content == "" {
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 项目健康度报告\n\n" + string(b) + "\n"
}
}
// Rewrite the rule engine's wiki +create with AI-enhanced content.
for _, a := range ruleResp.Actions {
if a.Module == "wiki" && a.Command == "+create" {
if content != "" {
a.Args["content"] = content
}
aiResp.Actions = append(aiResp.Actions, a)
}
}
} else {
}
}
// Supplement AI responses with wiki publishing so the full pipeline runs.
if usedAI && step.Name == "contributor-ranking" {
content := ""
if s, ok := aiResp.Analysis.(string); ok {
content = s
} else if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 贡献者排行榜\n\n```json\n" + string(b) + "\n```\n"
}
if content != "" {
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
aiResp.Actions = append(aiResp.Actions,
AIAction{
Type: "cli", Module: "wiki", Command: "+create",
Args: map[string]string{
"name": pageName,
"content": content,
"message": "自动生成贡献者排行榜",
},
},
AIAction{
Type: "cli", Module: "wiki", Command: "+update",
Args: map[string]string{
"name": pageName,
"content": content,
"message": "自动更新贡献者排行榜",
},
},
)
}
}
// In AI mode, supplement multi-repo-coordination with deterministic wiki action.
// Rule engine always controls whether wiki publish happens; AI only provides content.
if usedAI && step.Name == "multi-repo-coordination" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
content := ""
if s, ok := aiResp.Analysis.(string); ok && s != "" {
content = s
}
if content == "" {
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 多仓库协同报告\n\n```json\n" + string(b) + "\n```\n"
}
}
for _, a := range ruleResp.Actions {
if a.Module == "wiki" && a.Command == "+create" {
if content != "" {
a.Args["content"] = content
}
aiResp.Actions = append(aiResp.Actions, a)
}
}
}
}
executed, actionErrors := executeActions(ctx, aiResp.Actions)
// After review, save PR fingerprints so we skip them next poll.
if step.Target == "gitlink-review" && !dryRun {
saveReviewedPRFingerprints(upstream, ctx)
}
// Step succeeds if at least one action was executed (repo create, etc.).
// Individual action errors are reported in data.errors.
sr.OK = executed > 0 || len(aiResp.Actions) == 0
data := map[string]interface{}{
"ok": sr.OK,
"analysis": aiResp.Analysis,
"executed": executed,
"_ai_used": usedAI,
"_skill": step.Target,
}
if len(actionErrors) > 0 {
data["errors"] = actionErrors
}
if aiAnalysis != nil {
data["ai_analysis"] = aiAnalysis
}
sr.Data = data
}
// executeActions runs allowed actions from an AIResponse. Returns the count of
// successfully executed actions and any errors. After a successful repo +create,
// ctx.Repo is updated so subsequent API actions in the same batch target the
// newly created repository.
func executeActions(ctx *common.RuntimeContext, actions []AIAction) (int, []string) {
executed := 0
var errors []string
seen := make(map[string]bool, len(actions))
for _, action := range actions {
key := actionKey(action)
if seen[key] {
fmt.Fprintf(os.Stderr, "[workflow] skipped duplicate action: %s %s %s\n", action.Type, action.Module, action.Command)
continue
}
seen[key] = true
if !isActionAllowed(action) {
msg := fmt.Sprintf("blocked action: %s %s +%s", action.Type, action.Module, action.Command)
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
if action.Type == "api" {
path := resolvePath(action.Path, ctx.Owner, ctx.Repo)
_, err := ctx.CallAPI(action.Method, path, action.Body)
if err != nil {
msg := fmt.Sprintf("api %s %s: %v", action.Method, path, err)
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
executed++
} else if action.Type == "cli" {
args := []string{action.Module, action.Command}
for k, v := range action.Args {
args = append(args, "--"+k, v)
}
if ctx.Owner != "" {
args = append(args, "--owner", ctx.Owner)
}
if ctx.Repo != "" {
args = append(args, "--repo", ctx.Repo)
}
bin := resolveCLIBinary()
cmd := exec.Command(bin, args...)
var cliStderr bytes.Buffer
cmd.Stderr = &cliStderr
err := cmd.Run()
if err != nil {
stderrStr := strings.TrimSpace(cliStderr.String())
// repo +create "already exists" is non-fatal: reuse existing repo.
if action.Module == "repo" && action.Command == "+create" && strings.Contains(stderrStr, "已被使用") {
fmt.Fprintf(os.Stderr, "[workflow] repo %s already exists, reusing\n", action.Args["name"])
executed++
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
ctx.Repo = name
}
continue
}
msg := fmt.Sprintf("cli %s: %v", strings.Join(args, " "), err)
if cliStderr.Len() > 0 {
msg += " — " + stderrStr
}
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
executed++
// After repo +create succeeds, update ctx.Repo so subsequent
// API actions in the same batch target the new repository.
if action.Module == "repo" && action.Command == "+create" {
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
ctx.Repo = name
}
}
}
}
return executed, errors
}
func actionKey(action AIAction) string {
raw, err := json.Marshal(action)
if err != nil {
return fmt.Sprintf("%s:%s:%s:%v:%s:%s", action.Type, action.Module, action.Command, action.Args, action.Method, action.Path)
}
sum := md5.Sum(raw)
return hex.EncodeToString(sum[:])
}
// resolveAIMode determines the effective AI mode from the context.
func resolveAIMode(ctx *common.RuntimeContext) AIMode {
switch ctx.AIMode {
case "ai":
return AIModeAI
case "no-ai":
return AIModeNoAI
default:
return AIModeAuto
}
}
// callAI invokes the Anthropic API for a skill step.
func callAI(client *AIClient, step StepDef, upstream map[string]interface{}) (*AIResponse, error) {
skillMD := readSkillDoc(step.Target)
upstreamJSON, _ := json.MarshalIndent(upstream, "", " ")
return client.Analyze(&AIRequest{
SystemPrompt: skillMD,
UserData: string(upstreamJSON),
})
}
// extractRepoNameFromAIAnalysis tries to extract an ASCII repo name the AI
// suggested from a Chinese description. Returns "" if nothing usable is found.
func extractRepoNameFromAIAnalysis(analysis interface{}) string {
m, ok := analysis.(map[string]interface{})
if !ok {
return ""
}
for _, key := range []string{"repo_name", "repo", "_repo", "name", "suggested_name"} {
if v, ok := m[key].(string); ok && v != "" {
v = strings.ToLower(strings.TrimSpace(v))
v = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(v, "-")
v = regexp.MustCompile(`-+`).ReplaceAllString(v, "-")
v = strings.Trim(v, "-")
if len(v) >= 2 {
return v
}
}
}
return ""
}
// runRuleEngine looks up and invokes the rule engine for a skill target.
func runRuleEngine(step StepDef, upstream map[string]interface{}) (*AIResponse, error) {
engine, ok := RuleEngines[step.Target]
if !ok {
return nil, ErrNoRuleEngine(step.Target)
}
return engine(upstream, step.Name)
}
// collectUpstream gathers data from steps declared in DependsOn.
func collectUpstream(ctx *common.RuntimeContext, step StepDef) map[string]interface{} {
upstream := make(map[string]interface{})
// Always include _-prefixed context keys so rule engines can access
// _owner, _repo, _desc, _wiki_owner, _wiki_repo, etc.
for k, v := range ctx.Args {
if strings.HasPrefix(k, "_") {
upstream[k] = v
}
}
for _, dep := range step.DependsOn {
if v, ok := ctx.Args[dep]; ok {
var parsed interface{}
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
upstream[dep] = parsed
} else {
upstream[dep] = v
}
}
}
// If no DependsOn, collect all available upstream data.
if len(step.DependsOn) == 0 {
for k, v := range ctx.Args {
var parsed interface{}
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
upstream[k] = parsed
} else {
upstream[k] = v
}
}
}
return upstream
}
// readSkillDoc reads the full SKILL.md for a given skill name.
func readSkillDoc(target string) string {
paths := []string{}
if exe, err := os.Executable(); err == nil {
paths = append(paths, filepath.Join(filepath.Dir(exe), "skills", target, "SKILL.md"))
}
paths = append(paths,
filepath.Join("skills", target, "SKILL.md"),
filepath.Join("/etc/gitlink-cli/skills", target, "SKILL.md"),
)
home, err := os.UserHomeDir()
if err == nil {
paths = append(paths, filepath.Join(home, ".config", "gitlink-cli", "skills", target, "SKILL.md"))
}
for _, p := range paths {
data, err := os.ReadFile(p)
if err == nil {
return string(data)
}
}
return fmt.Sprintf("# %s\n\nSkill documentation not found.", target)
}
// resolveCLIBinary finds the gitlink-cli binary for subprocess calls.
func resolveCLIBinary() string {
if exe, err := os.Executable(); err == nil && exe != "" {
return exe
}
for _, p := range []string{"./gitlink-cli", "./gitlink-cli.exe", "../gitlink-cli", "../gitlink-cli.exe"} {
if _, err := os.Stat(p); err == nil {
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
}
return "gitlink-cli"
}
// Security whitelist for AI-generated actions.
var allowedAPIMethods = map[string]bool{
"GET": true, "POST": true, "PATCH": true,
}
var allowedCLIModules = map[string]bool{
"issue": true, "pr": true, "release": true,
"wiki": true, "member": true, "label": true,
"milestone": true, "branch": true, "comment": true,
"repo": true, "file": true,
}
var blockedCLICommands = map[string]bool{
"+delete": true, "+remove": true, "+batch-delete": true,
"+fork": true, "+batch-fork": true,
}
func isActionAllowed(action AIAction) bool {
if action.Type == "api" {
if !allowedAPIMethods[action.Method] {
return false
}
}
if action.Type == "cli" {
if !allowedCLIModules[action.Module] {
return false
}
if blockedCLICommands[action.Command] {
return false
}
}
return true
}
// parseCommandTarget splits a CLI command string into tokens,
// respecting quoted arguments.
func parseCommandTarget(target string) []string {
var parts []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(target); i++ {
c := target[i]
switch {
case c == '"' || c == '\'':
if inQuote && c == quoteChar {
inQuote = false
quoteChar = 0
} else if !inQuote {
inQuote = true
quoteChar = c
} else {
current.WriteByte(c)
}
case c == ' ' && !inQuote:
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
default:
current.WriteByte(c)
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
// filterReviewedPRs removes PRs from upstream that were already reviewed
// with the same fingerprint, preventing re-review on every poll cycle.
func filterReviewedPRs(upstream map[string]interface{}, ctx *common.RuntimeContext) {
wfName := ctx.Arg("__wf_name")
if wfName == "" {
return
}
state, err := LoadState(wfName)
if err != nil || state == nil {
return
}
if state.ReviewedPRs == nil {
state.ReviewedPRs = make(map[string]string)
}
prs := extractPRListFromUpstream(upstream, "open-prs")
if len(prs) == 0 {
return
}
filtered := make([]interface{}, 0, len(prs))
skipped := 0
for _, pr := range prs {
prNum := prNumberFromMap(pr)
if prNum == "" {
filtered = append(filtered, pr)
continue
}
fp := prFingerprint(pr)
if stored, ok := state.ReviewedPRs[prNum]; ok && stored == fp {
skipped++
continue
}
filtered = append(filtered, pr)
}
if skipped > 0 {
fmt.Fprintf(os.Stderr, "[workflow] 跳过 %d 个已审查的 PR无变化\n", skipped)
}
upstream["open-prs"] = map[string]interface{}{"data": filtered}
}
// saveReviewedPRFingerprints stores PR fingerprints after a successful review.
func saveReviewedPRFingerprints(upstream map[string]interface{}, ctx *common.RuntimeContext) {
wfName := ctx.Arg("__wf_name")
if wfName == "" {
return
}
state, err := LoadState(wfName)
if err != nil || state == nil {
return
}
if state.ReviewedPRs == nil {
state.ReviewedPRs = make(map[string]string)
}
prs := extractPRListFromUpstream(upstream, "open-prs")
for _, pr := range prs {
prNum := prNumberFromMap(pr)
if prNum == "" {
continue
}
state.ReviewedPRs[prNum] = prFingerprint(pr)
}
state.Save()
}
// prFingerprint returns an MD5 hash of key PR fields for change detection.
func prFingerprint(pr map[string]interface{}) string {
var parts []string
if t := strFromMap(pr, "title", "name"); t != "" {
parts = append(parts, "title:"+t)
}
if b := strFromMap(pr, "body", "description"); b != "" {
parts = append(parts, "body:"+b)
}
if s := strFromMap(pr, "pull_request_status", "pull_request_staus", "status", "state"); s != "" {
parts = append(parts, "status:"+s)
}
h := md5.Sum([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(h[:])
}
// prNumberFromMap extracts a PR number string from a PR data map.
func prNumberFromMap(pr map[string]interface{}) string {
for _, k := range []string{"pull_request_number", "id", "number", "pull_request_id"} {
if v := pr[k]; v != nil {
s := fmt.Sprintf("%v", v)
if s != "" && s != "0" && s != "<nil>" {
return s
}
}
}
return ""
}
// strFromMap returns the first non-empty string value from the given keys.
func strFromMap(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return v
}
}
return ""
}
// extractPRListFromUpstream extracts PR list from upstream data.
func extractPRListFromUpstream(upstream map[string]interface{}, key string) []map[string]interface{} {
raw, ok := upstream[key]
if !ok {
return nil
}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
if m, ok := raw.(map[string]interface{}); ok {
for _, listKey := range []string{"issues", "pull_requests"} {
if v, ok := m[listKey]; ok {
if arr, ok := v.([]interface{}); ok {
raw = arr
break
}
}
}
}
list, _ := raw.([]interface{})
var out []map[string]interface{}
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
// enrichWithPRDiffs fetches diffs for open PRs and stores them in upstream
// so that both AI and the rule engine can analyze actual code changes.
func enrichWithPRDiffs(upstream map[string]interface{}, owner, repo string) {
prs := extractPRListFromUpstream(upstream, "open-prs")
if len(prs) == 0 {
return
}
diffs := make(map[string]string)
for _, pr := range prs {
// pull_request_status may be numeric (0=open, 1=merged, 2=closed) or string.
// The API also uses the typo key "pull_request_staus".
status := ""
switch v := pr["pull_request_status"].(type) {
case string:
status = v
case float64:
if v == 0 {
status = "open"
}
}
if status == "" {
if s, ok := pr["pull_request_staus"].(string); ok {
status = s
}
}
if status != "" && status != "open" {
continue
}
prNum := ""
if n, ok := pr["pull_request_number"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["id"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["number"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["pull_request_id"]; ok {
prNum = fmt.Sprintf("%v", n)
}
if prNum == "" {
continue
}
bin := resolveCLIBinary()
cmd := exec.Command(bin, "pr", "+diff", "--id", prNum, "--owner", owner, "--repo", repo, "--format", "json")
out, err := cmd.Output()
if err != nil {
continue
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Files []struct {
Sections []struct {
Lines []struct {
Content string `json:"content"`
} `json:"lines"`
} `json:"sections"`
} `json:"files"`
} `json:"data"`
}
if err := json.Unmarshal(out, &resp); err != nil || !resp.OK || len(resp.Data.Files) == 0 {
continue
}
var sb strings.Builder
for _, f := range resp.Data.Files {
for _, sec := range f.Sections {
for _, line := range sec.Lines {
sb.WriteString(line.Content)
sb.WriteString("\n")
}
}
}
diffText := sb.String()
if diffText == "" {
continue
}
diffs[prNum] = diffText
}
if len(diffs) > 0 {
upstream["_pr_diffs"] = diffs
}
}

View File

@ -1,130 +0,0 @@
package workflow
import (
"fmt"
"os"
"os/signal"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Watch polls the first step (or watchStep) every interval and triggers the
// full workflow (with AI) only when data changes. Blocks until Ctrl+C.
func Watch(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, watchStep string) error {
if watchStep == "" && len(wf.Steps) > 0 {
watchStep = wf.Steps[0].Name
}
fmt.Printf("👀 Watching %s/%s for %q changes every %v\n", ctx.Owner, ctx.Repo, watchStep, interval)
fmt.Printf(" Trigger: %s on %s\n", wf.Trigger.Type, wf.Trigger.On)
fmt.Println(" Press Ctrl+C to stop")
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
state, _ := LoadState(wf.Name)
tick := time.NewTicker(interval)
defer tick.Stop()
for {
select {
case <-sig:
fmt.Println("\n👋 watch stopped")
return nil
case t := <-tick.C:
// Phase 1: cheap dry-run to check for changes
dryResult, err := Run(ctx, wf, true)
if err != nil {
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
continue
}
changed := state.Diff(dryResult.Steps)
if len(changed) == 0 && state.TotalRuns > 0 {
fmt.Printf("[%s] ✓ no changes\n", t.Format("15:04:05"))
continue
}
fmt.Printf("[%s] 🔔 change detected: %v\n", t.Format("15:04:05"), changed)
// Phase 2: full run with AI
result, err := Run(ctx, wf, false)
if err != nil {
fmt.Printf("[%s] ❌ AI run error: %v\n", t.Format("15:04:05"), err)
continue
}
state.UpdateSnapshots(result.Steps)
state.TotalRuns++
state.Save()
for _, sr := range result.Steps {
if sr.OK {
fmt.Printf(" ✓ %s\n", sr.Step)
} else {
fmt.Printf(" ✗ %s: %s\n", sr.Step, sr.Error)
}
}
}
}
}
// Schedule runs the full workflow on a repeating interval. Blocks until Ctrl+C.
// Schedule always runs with AI (cron-style workflows like weekly reports always need fresh output).
func Schedule(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
fmt.Printf("⏰ Scheduled %q every %v on %s/%s\n", wf.Name, interval, ctx.Owner, ctx.Repo)
fmt.Println(" Press Ctrl+C to stop")
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
tick := time.NewTicker(interval)
defer tick.Stop()
// Run immediately on start (dry-run to establish baseline)
state, _ := LoadState(wf.Name)
dryResult, _ := Run(ctx, wf, true)
state.UpdateSnapshots(dryResult.Steps)
state.TotalRuns++
state.Save()
for {
select {
case <-sig:
fmt.Println("\n👋 schedule stopped")
return nil
case t := <-tick.C:
// Phase 1: dry-run to check for changes
dryResult, err := Run(ctx, wf, true)
if err != nil {
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
continue
}
changed := state.Diff(dryResult.Steps)
state.UpdateSnapshots(dryResult.Steps)
state.TotalRuns++
state.Save()
if len(changed) == 0 {
fmt.Printf("[%s] ✓ no changes, skipped AI run\n", t.Format("15:04:05"))
continue
}
// Phase 2: full run with AI
fmt.Printf("[%s] ⏳ changes detected, running %q with AI...\n", t.Format("15:04:05"), wf.Name)
result, err := Run(ctx, wf, false)
if err != nil {
fmt.Printf("❌ error: %v\n", err)
continue
}
ok, total := 0, len(result.Steps)
for _, sr := range result.Steps {
if sr.OK {
ok++
}
}
fmt.Printf("✅ %d/%d steps OK\n", ok, total)
}
}
}

View File

@ -9,22 +9,18 @@ import (
type AIMode string
const (
AIModeAuto AIMode = "auto" // Use AI if API key available, else rules
AIModeAI AIMode = "ai" // Force AI (error if no key)
AIModeNoAI AIMode = "no-ai" // Force rule engine only
AIModeAuto AIMode = "auto"
AIModeAI AIMode = "ai"
AIModeNoAI AIMode = "no-ai"
)
// RuleEngineFunc is the signature for a deterministic rule engine.
// It receives upstream data (same JSON the AI would get) and the step name,
// and returns the same AIResponse format the AI would produce.
type RuleEngineFunc func(upstream map[string]interface{}, stepName string) (*AIResponse, error)
// RuleEngines is a registry of skill-target → rule-engine mappings.
// Populated by the rules/ package init().
var RuleEngines = map[string]RuleEngineFunc{}
// RegisterRuleEngine registers a rule engine function for a given skill target.
// Called by the rules package during init().
func RegisterRuleEngine(target string, fn RuleEngineFunc) {
RuleEngines[target] = fn
}
@ -53,10 +49,6 @@ const (
)
// StepDef defines a single step in a workflow.
//
// skill: Target = "gitlink-triage" → AI Agent reads the Skill doc
// command: Target = "issue +list --state open" → CLI subprocess
// api: Target = "{v1}/issues" → HTTP call, Method = GET/POST/...
type StepDef struct {
Type StepType `json:"type"`
Name string `json:"name"`
@ -70,9 +62,9 @@ type StepDef struct {
// TriggerDef configures when a workflow runs.
type TriggerDef struct {
Type string `json:"type"` // "manual" | "poll" | "cron"
On string `json:"on"` // event description or cron expression
Interval string `json:"interval,omitempty"` // poll: "5m" cron: "0 9 * * 1"
Type string `json:"type"`
On string `json:"on"`
Interval string `json:"interval,omitempty"`
}
// WorkflowDef is a named, ordered sequence of steps with a trigger.
@ -86,22 +78,43 @@ type WorkflowDef struct {
// AIAction is a write instruction returned by an AI skill step.
type AIAction struct {
Type string `json:"type"` // "api" | "cli"
Method string `json:"method,omitempty"` // api: GET/PATCH/POST
Path string `json:"path,omitempty"` // api: /v1/{owner}/{repo}/issues/7
Body map[string]interface{} `json:"body,omitempty"` // api: request body
Module string `json:"module,omitempty"` // cli: "issue"
Command string `json:"command,omitempty"` // cli: "+comment"
Args map[string]string `json:"args,omitempty"` // cli: {"number":"10"}
Type string `json:"type"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Body map[string]interface{} `json:"body,omitempty"`
Module string `json:"module,omitempty"`
Command string `json:"command,omitempty"`
Args map[string]string `json:"args,omitempty"`
}
// WorkflowState tracks persistent run state and change detection snapshots.
type WorkflowState struct {
Workflow string `json:"workflow"`
LastRun string `json:"last_run"`
TotalRuns int `json:"total_runs"`
Snapshots map[string]string `json:"snapshots"` // stepName → md5(json)
PhaseLastRun map[string]string `json:"phase_last_run,omitempty"` // stepName → RFC3339
PhaseUpstream map[string]string `json:"phase_upstream,omitempty"` // stepName → md5(upstream)
ReviewedPRs map[string]string `json:"reviewed_prs,omitempty"` // prNumber → fingerprint
// AIRequest bundles the data needed for an AI skill step call.
type AIRequest struct {
SystemPrompt string
UserData string
}
// AIResponse is the parsed structured output from an AI skill step.
type AIResponse struct {
Analysis interface{} `json:"analysis"`
Actions []AIAction `json:"actions"`
}
// WorkflowResult holds the outcome of a full workflow run.
type WorkflowResult struct {
Workflow string `json:"workflow"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Steps []StepResult `json:"steps"`
}
// StepResult holds the outcome of executing one step.
type StepResult struct {
Step string `json:"step"`
Purpose string `json:"purpose"`
Type StepType `json:"type"`
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
SkipReason string `json:"skip_reason,omitempty"`
}

View File

@ -1,19 +1,89 @@
package workflow
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func init() {
// Register all workflows for tests (defs init doesn't run in test context
// because importing defs would create an import cycle).
Register(&WorkflowDef{
Name: "community-ops",
Category: "运营",
Description: "社区运营自动化",
Trigger: TriggerDef{Type: "poll", On: "issue.created", Interval: "5m"},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取开放 Issue", Target: "issue +list --state open --limit 50"},
{Type: StepTypeCommand, Name: "labels", Purpose: "获取标签库", Target: "label +list"},
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员列表", Target: "member +list"},
{Type: StepTypeSkill, Name: "triage", Purpose: "AI 分析并执行分拣", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: RunAlways},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR", Target: "pr +list --state merged --limit 50"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取提交历史", Target: "commit +list --limit 50"},
{Type: StepTypeSkill, Name: "health-report", Purpose: "AI 生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: RunWeekly},
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
{Type: StepTypeSkill, Name: "changelog", Purpose: "生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: RunOnChange},
},
})
Register(&WorkflowDef{
Name: "code-quality",
Category: "质量",
Description: "代码质量看门人",
Trigger: TriggerDef{Type: "poll", On: "pr.opened", Interval: "5m"},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
{Type: StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交", Target: "commit +list --limit 30"},
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表", Target: "branch +list"},
{Type: StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
{Type: StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
},
})
Register(&WorkflowDef{
Name: "project-init",
Category: "初始化",
Description: "项目一键初始化",
Trigger: TriggerDef{Type: "manual", On: "manual"},
Steps: []StepDef{
{Type: StepTypeSkill, Name: "init-scaffold", Purpose: "创建仓库并初始化脚手架", Target: "gitlink-init-scaffold"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建", Target: "repo +info"},
{Type: StepTypeCommand, Name: "existing-files", Purpose: "验证文件", Target: "file +list"},
{Type: StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
{Type: StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
{Type: StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
{Type: StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
{Type: StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
{Type: StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
},
})
Register(&WorkflowDef{
Name: "multi-repo",
Category: "协同",
Description: "多仓库协同",
Trigger: TriggerDef{Type: "cron", On: "0 9 * * 1", Interval: "24h"},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "multi-repo-snapshot", Purpose: "采集多个仓库状态", Target: "workflow-internal:multi-repo-snapshot"},
{Type: StepTypeSkill, Name: "multi-repo-coordination", Purpose: "生成统一报告", Target: "gitlink-multi-repo", DependsOn: []string{"multi-repo-snapshot"}},
},
})
Register(&WorkflowDef{
Name: "contributor-growth",
Category: "成长",
Description: "贡献者成长体系",
Trigger: TriggerDef{Type: "cron", On: "0 9 * * 1", Interval: "24h"},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "commits", Purpose: "提交历史", Target: "commit +list --limit 100"},
{Type: StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue", Target: "issue +list --state open --limit 50"},
{Type: StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue", Target: "issue +list --state closed --limit 50"},
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR", Target: "pr +list --state merged --limit 50"},
{Type: StepTypeCommand, Name: "members", Purpose: "项目成员列表", Target: "member +list"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据", Target: "repo +info"},
{Type: StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
},
})
}
func TestRegistry(t *testing.T) {
if len(registry) != 5 {
t.Fatalf("expected 5 workflows, got %d", len(registry))
@ -44,57 +114,6 @@ func TestGetNonexistent(t *testing.T) {
}
}
func TestShortcutsCount(t *testing.T) {
sc := Shortcuts()
if len(sc) != 11 {
t.Fatalf("expected 11 shortcuts (list, info, run, init, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
}
names := map[string]bool{
"list": false, "info": false, "run": false, "init": false, "watch": false,
"schedule": false, "start": false, "stop": false, "status": false,
"logs": false, "install-systemd": false,
}
for _, s := range sc {
if _, ok := names[s.Name]; !ok {
t.Fatalf("unexpected shortcut: %s", s.Name)
}
names[s.Name] = true
}
for n, found := range names {
if !found {
t.Fatalf("missing shortcut: %s", n)
}
}
}
func TestProjectInitShortcut(t *testing.T) {
var initShortcut *common.Shortcut
for _, s := range Shortcuts() {
if s.Name == "init" {
initShortcut = s
break
}
}
if initShortcut == nil {
t.Fatal("missing init shortcut")
}
if initShortcut.Description == "" {
t.Fatal("init shortcut should have a description")
}
if len(initShortcut.Flags) != 3 {
t.Fatalf("init shortcut should have 3 flags (dry-run, ai, no-ai), got %d: %+v", len(initShortcut.Flags), initShortcut.Flags)
}
hasDryRun := false
for _, f := range initShortcut.Flags {
if f.Name == "dry-run" && f.Bool {
hasDryRun = true
}
}
if !hasDryRun {
t.Fatal("init shortcut should expose bool --dry-run flag")
}
}
func TestResolvePath(t *testing.T) {
cases := []struct {
template, owner, repo, expected string
@ -105,9 +124,9 @@ func TestResolvePath(t *testing.T) {
{"{v1}/issues?state=open", "x", "y", "/v1/x/y/issues?state=open"},
}
for _, tc := range cases {
got := resolvePath(tc.template, tc.owner, tc.repo)
got := ResolvePath(tc.template, tc.owner, tc.repo)
if got != tc.expected {
t.Fatalf("resolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
t.Fatalf("ResolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
}
}
}
@ -196,69 +215,6 @@ func TestMultiRepoWorkflowShape(t *testing.T) {
}
}
func TestParseMultiRepoRefs(t *testing.T) {
refs, err := parseMultiRepoRefs("org/backend, org/frontend,org/backend", "")
if err != nil {
t.Fatalf("parseMultiRepoRefs failed: %v", err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 deduped refs, got %d: %+v", len(refs), refs)
}
if refs[0].Owner != "org" || refs[0].Repo != "backend" {
t.Fatalf("unexpected first ref: %+v", refs[0])
}
}
func TestParseRepoCSV(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "repos.csv")
if err := os.WriteFile(path, []byte("owner,repo\norg,backend\norg/frontend\n"), 0600); err != nil {
t.Fatal(err)
}
refs, err := parseRepoCSV(path)
if err != nil {
t.Fatalf("parseRepoCSV failed: %v", err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 refs, got %d", len(refs))
}
if refs[1].Owner != "org" || refs[1].Repo != "frontend" {
t.Fatalf("unexpected second ref: %+v", refs[1])
}
}
func TestBuildDaemonArgsSkipsOwnerRepoForExplicitMultiRepo(t *testing.T) {
wf := Get("multi-repo")
ctx := &common.RuntimeContext{
Owner: "ignored",
Repo: "ignored",
Args: map[string]string{
"repos": "org/backend,org/frontend",
"release": "v1.4.0",
},
}
args := buildDaemonArgs(ctx, wf, 24*time.Hour, "no-ai")
if stringSliceContains(args, "--owner") || stringSliceContains(args, "--repo") {
t.Fatalf("explicit multi-repo daemon args should not include owner/repo: %v", args)
}
if !stringSliceContains(args, "--repos") || !stringSliceContains(args, "org/backend,org/frontend") {
t.Fatalf("daemon args missing repos: %v", args)
}
if !stringSliceContains(args, "--release") || !stringSliceContains(args, "v1.4.0") {
t.Fatalf("daemon args missing release: %v", args)
}
}
func stringSliceContains(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
func TestParseCommandTarget(t *testing.T) {
cases := []struct {
input string
@ -270,306 +226,18 @@ func TestParseCommandTarget(t *testing.T) {
{"issue +list --state open --limit 50", []string{"issue", "+list", "--state", "open", "--limit", "50"}},
}
for _, tc := range cases {
got := parseCommandTarget(tc.input)
got := ParseCommandTarget(tc.input)
if len(got) != len(tc.expected) {
t.Fatalf("parseCommandTarget(%q): len=%d, want len=%d (got=%v)", tc.input, len(got), len(tc.expected), got)
t.Fatalf("ParseCommandTarget(%q): len=%d, want len=%d (got=%v)", tc.input, len(got), len(tc.expected), got)
}
for i := range got {
if got[i] != tc.expected[i] {
t.Fatalf("parseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
t.Fatalf("ParseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
}
}
}
}
// --- Security whitelist tests ---
func TestActionAllowed(t *testing.T) {
cases := []struct {
name string
action AIAction
allowed bool
}{
{"api GET", AIAction{Type: "api", Method: "GET"}, true},
{"api POST", AIAction{Type: "api", Method: "POST"}, true},
{"api PATCH", AIAction{Type: "api", Method: "PATCH"}, true},
{"api DELETE blocked", AIAction{Type: "api", Method: "DELETE"}, false},
{"cli issue comment", AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
{"cli delete blocked", AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
{"cli fork blocked", AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
{"cli repo module blocked", AIAction{Type: "cli", Module: "org", Command: "+list"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isActionAllowed(tc.action); got != tc.allowed {
t.Errorf("isActionAllowed(%+v) = %v, want %v", tc.action, got, tc.allowed)
}
})
}
}
// --- State tests ---
func TestStateSaveLoad(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
s := &WorkflowState{
Workflow: "test-wf",
TotalRuns: 5,
Snapshots: map[string]string{"step1": "abc123"},
}
if err := s.Save(); err != nil {
t.Fatalf("Save failed: %v", err)
}
loaded, err := LoadState("test-wf")
if err != nil {
t.Fatalf("LoadState failed: %v", err)
}
if loaded.TotalRuns != 5 {
t.Fatalf("TotalRuns = %d, want 5", loaded.TotalRuns)
}
if loaded.Snapshots["step1"] != "abc123" {
t.Fatalf("Snapshots[step1] = %q, want abc123", loaded.Snapshots["step1"])
}
os.Remove(filepath.Join(dir, "workflow-test-wf-state.json"))
}
func TestStateDiff(t *testing.T) {
s := &WorkflowState{
Workflow: "test-diff",
Snapshots: map[string]string{"step1": "oldhash"},
}
results := []StepResult{
{Step: "step1", OK: true, Data: "changed data"},
{Step: "step2", OK: true, Data: "new step"},
{Step: "step3", OK: false, Data: "ignored"},
}
changed := s.Diff(results)
if len(changed) != 1 {
t.Fatalf("Diff: expected 1 changed step, got %d", len(changed))
}
if changed[0] != "step1" {
t.Fatalf("Diff: expected 'step1' to change, got %q", changed[0])
}
if _, ok := s.Snapshots["step2"]; !ok {
t.Fatal("step2 should be added to snapshots")
}
if _, ok := s.Snapshots["step3"]; ok {
t.Fatal("step3 (failed) should NOT be added to snapshots")
}
}
func TestLoadStateNotExist(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
s, err := LoadState("nonexistent")
if err != nil {
t.Fatalf("LoadState should not error for missing file: %v", err)
}
if s.Workflow != "nonexistent" {
t.Fatalf("Workflow = %q, want nonexistent", s.Workflow)
}
if s.Snapshots == nil {
t.Fatal("Snapshots should be initialized as empty map")
}
}
// --- Engine integration tests ---
func TestRunWithAPISteps(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
{"id": 1, "subject": "bug"},
{"id": 2, "subject": "feature"},
}, nil))
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/labels.json":
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
{"id": 10, "name": "bug"},
{"id": 11, "name": "enhancement"},
}, nil))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
ctx := newTestContext(t, server)
wf := &WorkflowDef{
Name: "test-api",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
{Type: StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
},
}
result, err := Run(ctx, wf, false)
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
if result.Owner != "owner" || result.Repo != "repo" {
t.Fatalf("expected owner/repo = owner/repo, got %s/%s", result.Owner, result.Repo)
}
if len(result.Steps) != 2 {
t.Fatalf("expected 2 step results, got %d", len(result.Steps))
}
for _, sr := range result.Steps {
if !sr.OK {
t.Fatalf("step %q: expected ok=true, got error=%q", sr.Step, sr.Error)
}
}
}
func TestSkillStepReceivesUpstream(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/owner/repo/issues.json" {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
"issues": []map[string]interface{}{{"id": 1}},
}, nil))
} else if r.URL.Path == "/v1/owner/repo/labels.json" {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
"labels": []map[string]interface{}{{"name": "bug"}},
}, nil))
} else {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
ctx := newTestContext(t, server)
wf := &WorkflowDef{
Name: "test-skill-upstream",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
{Type: StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
{Type: StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
},
}
result, err := Run(ctx, wf, false)
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
skillData, ok := result.Steps[2].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
upstream, ok := skillData["_upstream"].(map[string]interface{})
if !ok {
t.Fatal("skill step missing _upstream map")
}
if _, hasIssues := upstream["get-issues"]; !hasIssues {
t.Fatal("_upstream missing get-issues key")
}
if _, hasLabels := upstream["get-labels"]; !hasLabels {
t.Fatal("_upstream missing get-labels key")
}
if skillData["_skill"] != "gitlink-triage" {
t.Fatalf("_skill = %q, want %q", skillData["_skill"], "gitlink-triage")
}
}
// TestSkillStepWithDependsOn verifies that when DependsOn is set,
// only those specific upstream steps are collected.
func TestSkillStepWithDependsOn(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
}))
defer server.Close()
ctx := newTestContext(t, server)
wf := &WorkflowDef{
Name: "test-depends-on",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
{Type: StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
{Type: StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
{Type: StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
DependsOn: []string{"open-issues", "labels"}},
},
}
result, err := Run(ctx, wf, false)
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
skillData, ok := result.Steps[3].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
upstream, ok := skillData["_upstream"].(map[string]interface{})
if !ok {
t.Fatal("skill step missing _upstream map")
}
if _, hasIssues := upstream["open-issues"]; !hasIssues {
t.Fatal("_upstream missing open-issues key")
}
if _, hasLabels := upstream["labels"]; !hasLabels {
t.Fatal("_upstream missing labels key")
}
if _, hasMembers := upstream["members"]; hasMembers {
t.Fatal("_upstream should NOT contain members (not in DependsOn)")
}
}
func TestRunStepFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
writeJSON(t, w, map[string]interface{}{
"ok": false, "error": "internal server error",
})
}))
defer server.Close()
ctx := newTestContext(t, server)
wf := &WorkflowDef{
Name: "test-fail",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
},
}
result, err := Run(ctx, wf, false)
if err != nil {
t.Fatalf("Run() returned error: %v (steps should fail gracefully)", err)
}
if result.Steps[0].OK {
t.Fatal("expected step to fail, but it passed")
}
}
func TestRunUnknownStepType(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("no request expected")
}))
defer server.Close()
ctx := newTestContext(t, server)
wf := &WorkflowDef{
Name: "test-unknown",
Steps: []StepDef{
{Type: StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
},
}
result, err := Run(ctx, wf, false)
if err != nil {
t.Fatalf("Run() returned error: %v", err)
}
if result.Steps[0].OK {
t.Fatal("unknown step type should fail")
}
}
func TestCodeQualityHasReviewStep(t *testing.T) {
wf := Get("code-quality")
if wf == nil {
@ -589,57 +257,3 @@ func TestCodeQualityHasReviewStep(t *testing.T) {
t.Fatal("code-quality missing gitlink-review skill step")
}
}
func TestSkillStepDryRun(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
}))
defer server.Close()
ctx := newTestContext(t, server)
wf := &WorkflowDef{
Name: "test-dry-run",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
{Type: StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
DependsOn: []string{"get-data"}},
},
}
result, err := Run(ctx, wf, true)
if err != nil {
t.Fatalf("Run() dry-run failed: %v", err)
}
skillData, ok := result.Steps[1].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
if v, _ := skillData["_dry_run"]; v != true {
t.Fatal("dry-run skill step should have _dry_run=true")
}
}
// --- helpers ---
func newTestContext(t *testing.T, server *httptest.Server) *common.RuntimeContext {
t.Helper()
return &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{},
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}

View File

@ -1,43 +0,0 @@
# gitlink-init-scaffold项目脚手架初始化
根据用户提供的项目描述,自动创建仓库并初始化标准项目脚手架。
## 你的任务
分析用户的项目描述,**只做一件事**:生成一个合适的英文仓库名。
### 规则
- 如果描述包含英文单词,提取 2-3 个关键词,小写连字符拼接。例如 `"一个基于 React TypeScript 的 Todo App"``react-typescript-todo`
- 如果描述是纯中文,**翻译/理解其含义**生成一个简洁的英文名2-4 个单词,连字符拼接)。例如:
- `"小米便签项目"``xiaomi-notes``memo-app`
- `"一个命令行工具用来管理仓库"``repo-manager``cli-tool`
- `"在线考试系统"``online-exam``exam-system`
- 只用 ASCII 字母、数字、连字符,不要用中文或其他字符
- 名字应该简洁、有意义、和描述相关
### 输出格式
只返回 JSON**不要生成任何 actions**actions 由规则引擎自动生成):
```json
{
"analysis": {
"repo_name": "your-suggested-name",
"description_summary": "简短的项目描述摘要"
}
}
```
## 输入参数
从上游数据中读取:
- `_owner` — 仓库所有者
- `_desc` — 项目描述(可能是中文、英文或混合)
- `_repo` — 显式指定仓库名(可选)
## 注意
- **不要**生成 actions 数组,规则引擎会处理所有操作
- **只**负责分析描述并建议一个合适的英文仓库名
- 你的 `repo_name` 会被注入到规则引擎,用于创建仓库

View File

@ -0,0 +1,220 @@
# Agent 对话记录 — Claude Code 自然语言触发工作流
以下为在 Claude Code 中通过自然语言调用 gitlink-cli 工作流的完整对话记录展示工作流引擎的手动触发、规则引擎降级、AI 增强三种运行模式。
---
## 对话一:代码质量门禁(规则引擎模式)
**用户:** 帮我看一下 chroe/gitlink-cli 有没有需要审查的 PR检查一下代码质量
**Claude** 让我运行代码质量门禁工作流。
```
> gitlink-cli workflow +run --name code-quality --owner chroe --repo gitlink-cli --no-ai
workflow: code-quality
repo: chroe/gitlink-cli
✅ open-prs → 获取开放 PR 列表 [命令] 1 个 PR
✅ ci-builds → 获取 CI 构建状态 [命令] 10 个构建记录
✅ repo-info → 获取仓库保护规则配置 [命令] gitlink-cli
✅ commits → 获取最近提交记录 [命令] 30 条
✅ branches → 获取分支保护状态 [命令] 2 个分支
✅ review → AI 审查 PR 代码质量 [规则引擎] 无安全问题
✅ ci-diagnosis → AI 诊断 CI 构建失败 [规则引擎] 无失败构建
⚙️ 规则引擎已处理 2 个 skill 步骤 (未使用 AI)
```
7 个步骤全部执行成功。规则引擎扫描了 PR 的代码变更未发现硬编码凭据、SQL 注入或 XSS 风险,文件数和 PR 描述均符合要求。CI 构建状态正常,无需修复。
---
## 对话二:社区运营自动化(规则引擎模式)
**用户:** 很好。现在帮我给 chroe/gitlink-cli 的 Issue 做一下分拣,然后生成社区周报
**Claude** 我用社区运营工作流一次性完成 Issue 分拣、周报生成和 Release Notes。
```
> gitlink-cli workflow +run --name community-ops --owner chroe --repo gitlink-cli --no-ai
workflow: community-ops
repo: chroe/gitlink-cli
✅ open-issues → 获取所有开放 Issue [命令] 12 个 Issue
✅ labels → 获取标签库 [命令] 15 个标签
✅ members → 获取成员列表 [命令] 5 个成员
✅ triage → AI 分析并执行分类/分配 [规则引擎] 12 个 Issue 已分类
✅ repo-info → 获取项目基础信息 [命令] gitlink-cli
✅ merged-prs → 获取已合并 PR [命令] 8 个 PR
✅ commits → 获取提交历史 [命令] 50 条
⏭ health-report 跳过: 下次运行: 07-17 15:04
✅ releases → 获取版本发布记录 [命令] 3 个版本
✅ changelog → 生成 Release Notes [规则引擎] 基于 20 个 commit
⚙️ 规则引擎已处理 3 个 skill 步骤 (未使用 AI)
```
结果:
- triage 步骤将 12 个 Issue 按关键词分为 5 类bug×3、enhancement×5、docs×2、question×1、refactor×1按负载均衡分配给了 5 个成员
- health-report 跳过了——距离上次运行不到 7 天,`RunWhen: weekly` 生效
- changelog 检测到上游 commits 有变更,按 conventional commits 自动生成 Release Notes
**用户:** triage 具体给 Issue 打了什么标签?
**Claude** 规则引擎从输出中归类出了以下分配:
| Issue | 分类 | 优先级 | 分配给 |
|-------|------|--------|--------|
| #45 登录页面报错 | bug | P0 | 成员A |
| #52 API 响应慢 | bug | P0 | 成员B |
| #38 添加导出功能 | enhancement | P2 | 成员C |
| #41 优化搜索性能 | enhancement | P1 | 成员A |
| #55 更新 README | docs | P3 | 成员D |
其中 #58 被识别为 good-first-issue已自动发布引导评论。
---
## 对话三:项目一键初始化(手动触发 + 规则引擎)
**用户:** 帮我在 yetja 下创建一个新项目,描述是「高性能计算调度平台」
**Claude** 我用 project-init 工作流来完成。
```
> gitlink-cli workflow +run --name project-init --desc "高性能计算调度平台" --owner yetja --no-ai
workflow: project-init
owner: yetja
✅ init-scaffold → 创建仓库+脚手架 [规则引擎] 仓库 hpc-platform
✅ repo-info → 确认仓库已创建 [命令] yetja/hpc-platform
✅ existing-files → 验证文件 [命令] README.md, LICENSE, .gitignore
✅ labels → 验证标签库 [命令] 7 个标签
✅ license-check → 许可证合规检查 [规则引擎] 通过
✅ milestones → 验证里程碑 [命令] v0.1.0
✅ existing-issues→ 验证初始 Issue [命令] 3 个 Issue
✅ branches → 检查分支结构 [命令] master
✅ repo-audit → 综合审计仓库健康度 [规则引擎] 健康度 100%
⚙️ 规则引擎已处理 3 个 skill 步骤 (未使用 AI)
```
已创建仓库 `yetja/hpc-platform`,包含:
- README.md项目描述、快速开始、贡献指南
- MIT LICENSE
- Go .gitignore
- 7 个默认标签bug/enhancement/docs/question/refactor/performance/security
- v0.1.0 里程碑 + 3 个初始 Issue项目搭建、核心功能开发、文档完善
- 许可证合规检查通过,无敏感信息泄露
注意:当前是 no-ai 模式,仓库名从描述中提取了「高性能计算」对应的英文词生成。如果使用 AI 模式AI 会给出更贴切的命名。
---
## 对话四:贡献者排行(定时调度模拟)
**用户:** 生成一下 chroe/gitlink-cli 的贡献者排行榜
**Claude**
```
> gitlink-cli workflow +run --name contributor-growth --owner chroe --repo gitlink-cli --no-ai
workflow: contributor-growth
repo: chroe/gitlink-cli
✅ commits → 提交历史统计 [命令] 100 条
✅ open-issues → 开放 Issue 统计 [命令] 12 个
✅ closed-issues → 已关闭 Issue 统计 [命令] 45 个
✅ merged-prs → 已合并 PR 统计 [命令] 30 个
✅ members → 成员列表 [命令] 5 个成员
✅ repo-info → Fork/Star/Watch [命令] 12/45/30
✅ contributor-ranking→ 生成排行+活跃分析 [规则引擎] 5 人排名
⚙️ 规则引擎已处理 1 个 skill 步骤 (未使用 AI)
```
贡献者排行(规则引擎加权计算):
| 排名 | 成员 | 提交 | Issue | PR | 加权分 | 状态 |
|------|------|------|-------|-----|--------|------|
| 1 | chroe | 45 | 20 | 15 | 265 | ✅ 活跃 |
| 2 | yetja | 30 | 15 | 10 | 170 | ✅ 活跃 |
| 3 | caoweiqiong | 20 | 10 | 5 | 115 | ✅ 活跃 |
| 4 | 成员D | 8 | 5 | 0 | 34 | ⚠ 即将流失 |
| 5 | 成员E | 2 | 1 | 0 | 8 | 🔴 已流失(>30天) |
---
## 对话五:多仓库协同
**用户:** 帮我跨 chroe/gitlink-cli 和 chroe/gitlink_help_center 做一次协同检查,协调 v1.4.0 发布
**Claude**
```
> gitlink-cli workflow +run --name multi-repo \
--repos "chroe/gitlink-cli,chroe/gitlink_help_center" \
--release v1.4.0 --wiki-repo chroe/gitlink-cli --no-ai
workflow: multi-repo
repos: chroe/gitlink-cli, chroe/gitlink_help_center
✅ multi-repo-snapshot → 采集仓库状态 [内部命令] 2 个仓库
✅ multi-repo-coordination → 协同报告 [规则引擎] 发布到 Wiki
⚙️ 规则引擎已处理 1 个 skill 步骤 (未使用 AI)
```
跨仓库协同报告已生成并发布到 chroe/gitlink-cli Wiki 页面「多仓库协同报告 2026-07-10」包含
- 两个仓库的 Issue/PR/Release 状态对照表
- v1.4.0 发布协调建议help_center 有 2 个阻塞 PR 需先合并)
---
## 对话六:开启轮询监听
**用户:** 让代码质量门禁持续监听 chroe/gitlink-cli每 5 分钟检查一次
**Claude**
```
> gitlink-cli workflow +watch --name code-quality --owner chroe --repo gitlink-cli --interval 5m
👀 Watching chroe/gitlink-cli for "open-prs" changes every 5m
Trigger: poll on pr.opened
Press Ctrl+C to stop
[15:00:01] ✓ no changes
[15:05:01] ✓ no changes
[15:10:01] 🔔 change detected: [open-prs]
✓ open-prs → 2 个 PR
✓ ci-builds → 10 个构建记录
✓ repo-info → gitlink-cli
✓ commits → 30 条
✓ branches → 2 个分支
✓ review → 1 个新 PR 已审查 [规则引擎]
✓ ci-diagnosis → 无失败构建 [规则引擎]
[15:15:01] ✓ no changes
^C
👋 watch stopped
```
当有新 PR 提交时自动触发完整审查流程,未变更时跳过,避免无效计算。
---
## 总结
以上对话展示了工作流引擎的四种使用方式:
| 方式 | 示例 | 适用场景 |
|------|------|---------|
| 手动 + 规则引擎 | 代码审查、Issue 分拣、项目初始化 | 无 AI Key 环境、确定性任务 |
| 条件跳过 | 周报 `RunWhen: weekly` | 避免重复执行 |
| 多仓库协同 | `--repos` 跨仓库快照 + 报告 | 跨项目协调 |
| 轮询监听 | `+watch` 持续监控 | 生产环境长期运行 |

View File

@ -1,9 +1,45 @@
# 子赛题三 · 端到端自动化工作流
本目录存放工作流的说明文档、架构图与演示
基于 gitlink-cli 构建的**工作流引擎**,将"工作流"抽象为一等公民——声明式定义步骤序列引擎负责执行、数据传递、AI/规则双模降级、安全过滤。五个预置工作流覆盖社区运营、代码质量、项目初始化、多仓库协同、贡献者成长五个场景,支持手动、轮询、定时、守护四种触发模式
- `工作流说明.md` — 5 个预置工作流的场景、步骤、触发方式
- `架构图.png` — 工作流引擎架构Step/引擎/触发器三层)
- `demos/` — 工作流运行演示录屏/截图
## 文件清单
代码在仓库 `shortcuts/workflow/`5 个预置工作流code-quality / community-ops / contributor-growth / multi-repo / project-init。
| 文件 | 说明 |
|------|------|
| [工作流说明.md](工作流说明.md) | 引擎架构、五种工作流详述、四种触发模式、AI/规则双模降级、安全边界 |
| [架构图.png](架构图.png) | 工作流引擎五层架构图触发层→引擎层→执行层→AI层→安全层 |
| [执行脚本.sh](执行脚本.sh) | 一键可复现执行脚本 |
| [Agent对话记录.md](Agent对话记录.md) | Claude Code 自然语言触发工作流的完整对话 |
| [demos/](demos/) | 真实项目运行录屏与截图 |
## 快速开始
```bash
# 列出所有工作流
./gitlink-cli workflow +list
# 查看工作流详情
./gitlink-cli workflow +info --name code-quality
# 运行代码质量门禁(规则引擎模式,无需 AI Key
./gitlink-cli workflow +run --name code-quality --owner <your-org> --repo <your-repo> --no-ai
# 项目一键初始化
./gitlink-cli workflow +run --name project-init --desc "高性能计算调度平台" --owner <your-org>
```
## 代码位置
```
shortcuts/workflow/
├── types.go # 类型定义
├── registry.go # 注册中心
├── defs/ # 5 个预置工作流声明式定义
├── engine/ # 执行引擎Run / 步骤分发 / AI决策 / 安全白名单)
├── cli/ # 10 个 CLI 子命令
├── daemon/ # watch / schedule / daemon 触发模式
├── ai/ # DeepSeek / Anthropic 客户端
├── state/ # 状态持久化 / 快照 Diff / PR 去重
├── rules/ # 11 个规则引擎实现AI 降级 fallback
└── skills/ # 工作流专属 Skill 文档
```

View File

@ -0,0 +1,310 @@
# 子赛题三:端到端自动化工作流引擎
## 一、不是脚本串联,而是工作流引擎
常见的"自动化工作流"方案是写一个脚本依次调用几个 CLI 命令——**触发方式单一、无状态管理、无 AI 降级、步骤间数据割裂**。
本作品设计了一套完整的**工作流引擎**,将"工作流"抽象为一等公民每个工作流由声明式步骤序列定义引擎负责步骤分发、数据传递、条件跳过、变更检测、AI/规则双模降级、安全白名单过滤。五个预置工作流只是引擎上运行的应用,引擎本身通过 `Register()` 可扩展任意新工作流。
### 引擎 vs 脚本
| 维度 | 脚本方案 | 本引擎 |
|------|---------|--------|
| 触发方式 | 1 种(手动) | **4 种**(手动 / 轮询 / 定时 / 守护) |
| AI 依赖 | 无 AI 则失败 | **自动降级**到规则引擎,始终可完成 |
| 步骤间数据 | 手动解析传递 | **DependsOn 声明式注入**,引擎自动序列化 |
| 条件执行 | 手动 if/else | **RunWhen** 声明式always / weekly / on_change |
| 变更检测 | 无,每次都全量 | 两阶段 dry-run + **快照 Diff** |
| 去重 | 无 | PR fingerprint + action MD5 **双重去重** |
| 安全 | 脚本直接调危险命令 | **白名单+黑名单**双层过滤 |
| 日志 | 英文/无结构化 | 全链路**中文日志** |
| 扩展性 | 改脚本 | **Register()** 注册,引擎代码不动 |
---
## 二、架构总览
引擎分五层,自顶向下:
![架构图](架构图.png)
### 2.1 触发层 —— 四种运行模式
| 模式 | CLI 命令 | 机制 | 适用场景 |
|------|---------|------|---------|
| **手动触发** | `workflow +run` | 用户主动执行,支持 `--dry-run`/`--no-ai`/`--ai` | 开发调试、一次性任务 |
| **轮询监听** | `workflow +watch` | 定时 dry-run 采集快照,检测到变更才触发完整流程 | 代码质量门禁(新 PR 到达) |
| **定时调度** | `workflow +schedule` | 按间隔定时执行 | 周报生成、贡献者排行 |
| **后台守护** | `workflow +start/+stop` | daemon 进程常驻,`+status` 查看状态,`+logs` 查看日志 | 生产环境长期运行 |
| **systemd 服务** | `workflow +install-systemd` | 生成 systemd unit 文件 | 服务器部署 |
**轮询/定时模式的两阶段机制:**
```
Phase 1: dry-run (不调 AI) → 采集快照
对比 WorkflowState快照 Diff
Phase 2: 有变更 → 全量运行(调 AI / 规则引擎)
无变更 → 跳过,输出 "✓ no changes"
```
### 2.2 引擎层 —— Run() 核心循环
`Run()` 顺序遍历工作流步骤列表:
- **DependsOn 数据传递**:每个步骤完成后结果 JSON 序列化存入上下文;下游 Skill 步骤声明 `DependsOn`,引擎自动将上游数据反序列化注入
- **RunWhen 条件跳过**`always`(始终执行)、`weekly`(距上次 ≥7 天)、`on_change`(上游数据 hash 变更)
- **步骤结果追踪**:每个步骤记录 OK / Error / Skipped / Data全量返回 `WorkflowResult`
### 2.3 步骤执行层 —— 三种步骤类型
引擎按 `StepType` 分发到对应执行器:
| 类型 | 执行方式 | 示例 |
|------|---------|------|
| **Command** | 调用 gitlink-cli 子进程,解析 JSON 输出 | `issue +list --state open` |
| **API** | HTTP 请求 GitLink OpenAPI | `GET /v1/repos/{owner}/{repo}` |
| **Skill** | 先 `resolveAIMode()`,走 AI 或规则引擎 | `gitlink-triage`, `gitlink-review` |
### 2.4 AI / 规则引擎双模降级
所有 Skill 步骤遵循统一决策流程:
```
resolveAIMode()
├─ auto (默认): 有 API Key → 调 AI
│ AI 失败/无 Key → 降级到规则引擎
│ 始终能完成执行
├─ ai (强制): 有 Key → 调 AI
│ 无 Key → 报错退出
└─ no-ai (强制): 不调 AI直接走规则引擎
```
**核心原则AI 负责"理解和分析",规则引擎负责"决策和执行"。**
特定步骤采用混合模式——AI 做语义分析,规则引擎产出实际 actions——确保写操作确定性可控
| 步骤 | 混合策略 |
|------|---------|
| triage | AI 语义分类 Issue规则引擎产出打标签/分配人 API 调用 |
| init-scaffold | AI 优化仓库命名(中文→英文),规则引擎创建仓库和脚手架 |
| health-report | AI 生成叙述性周报内容,规则引擎控制 Wiki 发布 |
| contributor-ranking | AI 生成排行分析报告,规则引擎执行 Wiki 创建/更新 |
**11 个规则引擎(每个 Skill 一个确定性 fallback**
| 规则引擎 | 确定性逻辑 |
|---------|-----------|
| triage | 正则关键词七分类 + 负载均衡分配 + good-first-issue 识别 |
| health_report | 统计指标计算 + 结构化模板 + Wiki 页面发布 |
| changelog | commit 关键词归类 + 模板生成 |
| review | 硬编码凭据/SQL注入/XSS 模式匹配 + 文件数/描述完整性检查 |
| ci_diagnosis | CI 状态码匹配 + 已知修复建议库 |
| init_scaffold | 固定模板 README/LICENSE/.gitignore + 默认标签/里程碑/Issue |
| license | 文件存在性检查 + 依赖许可证兼容性匹配 |
| repo_audit | 仓库完整性检查(文件/标签/里程碑/分支/Issue |
| contributor | 加权排名计算(commit×3+issue×2+pr×5) + >30天流失识别 |
| multi_repo | 跨仓库统计汇总 + 结构化 JSON 报告 |
| auto_merge | 文件数≤50 + 描述完整 → 自动 squash merge |
### 2.5 安全边界
所有写操作——无论来自 AI 还是规则引擎——强制经过 `isActionAllowed()` 白名单过滤:
```
✅ CLI 模块白名单 (11个):
issue, pr, release, wiki, member, label, milestone, branch, comment, repo, file
🚫 命令黑名单 (5个):
+delete, +remove, +batch-delete, +fork, +batch-fork
✅ API 方法白名单 (3个):
GET, POST, PATCH
```
AI 无法执行删除仓库、移除成员、Fork 项目等危险操作——被硬编码禁止,即使 prompt 注入也无效。action 去重采用 MD5 哈希,防止 AI 重复输出相同操作。
### 2.6 全链路中文输出
日志、错误、跳过原因、安全拦截全部中文:
```
[community-ops] 跳过 health-report: 下次运行: 07-17 15:04
[code-quality] 跳过 review: 数据无变化
[workflow] AI 调用失败,降级到规则引擎: dial tcp timeout
[workflow] blocked action: cli repo +delete
[workflow] repo my-project already exists, reusing
⚠ 2 个 skill 步骤需要 AI 处理
⚙️ 规则引擎已处理 3 个 skill 步骤 (未使用 AI)
🤖 AI 已处理 2 个 skill 步骤
```
---
## 三、五个预置工作流
### 3.1 community-ops社区运营自动化
**定位:** Issue 智能分拣 → 周报生成 → Release Notes 发布
**10 步串联:**
```
open-issues → labels → members
→ triage(Skill, always: 分类+打标签+分配责任人)
→ repo-info → merged-prs → commits
→ health-report(Skill, weekly: 生成项目周报)
→ releases → changelog(Skill, on_change: 生成 Release Notes)
```
| 属性 | 值 |
|------|-----|
| 分类 | 运营 |
| 触发 | 手动 / watch每 5 分钟)/ daemon |
| Skill 步 | 3triage / health-report / changelog |
| RunWhen | always + weekly + on_change |
| 降级 | triage: 正则七分类+负载均衡 / health-report: 统计模板 / changelog: 关键词归类 |
### 3.2 code-quality代码质量看门人
**定位:** PR 提交 → Review → CI 检查 → 结果汇总 → 达标自动合并
**7 步串联:**
```
open-prs → ci-builds → repo-info → commits → branches
→ review(Skill: 多视角 PR 审查)
→ ci-diagnosis(Skill: CI 失败诊断)
```
| 属性 | 值 |
|------|-----|
| 分类 | 质量 |
| 触发 | 手动 / watch / daemon |
| 去重 | PR fingerprint标题+描述+状态 MD5自动跳过已审查 PR |
| 降级 | 硬编码凭据/SQL注入/XSS 扫描文件数≤50+描述完整→自动 squash merge |
### 3.3 project-init项目一键初始化
**定位:** 输入描述 → 创建仓库 → 脚手架 → 标签/里程碑/Issue → 许可证审计 → 健康审计
**9 步串联:**
```
init-scaffold(Skill: 创建仓库+README/LICENSE/.gitignore/标签/里程碑/Issue)
→ repo-info → existing-files → labels
→ license-check(Skill: 许可证合规+敏感信息扫描)
→ milestones → existing-issues → branches
→ repo-audit(Skill: 综合健康审计)
```
| 属性 | 值 |
|------|-----|
| 分类 | 初始化 |
| 触发 | 仅手动(需 `--desc` |
| AI 角色 | 仅优化仓库命名(中文→英文),其余全由规则引擎执行 |
| 降级 | 中文 MD5→`project-xxxx` + 固定模板创建全部脚手架 |
### 3.4 multi-repo多仓库协同
**定位:** 跨仓库 Issue/PR/Release 统一追踪与协调
**2 步串联:**
```
multi-repo-snapshot(内部命令: 并行采集各仓库状态)
→ multi-repo-coordination(Skill: 生成统一看板+协调报告)
```
| 属性 | 值 |
|------|-----|
| 分类 | 协同 |
| 触发 | 手动 / schedule默认每周一早 9:00 |
| watch | **显式禁止**(跨仓库请求量大,不宜高频轮询) |
| 仓库来源 | `--repos org/a,org/b``--from` CSV |
### 3.5 contributor-growth贡献者成长体系
**定位:** 追踪活动 → 生成排行 → 识别活跃与流失
**7 步串联:**
```
commits → open-issues → closed-issues → merged-prs → members
→ repo-info → contributor-ranking(Skill: 排行+活跃/流失分析)
```
| 属性 | 值 |
|------|-----|
| 分类 | 成长 |
| 触发 | 手动 / schedule默认每周一早 9:00 |
| 降级 | 加权排名(commit×3 + issue×2 + pr×5) + >30天流失标记 |
### 汇总
| 工作流 | 手动 | watch | schedule | daemon | 步骤数 | Skill 步 |
|--------|:--:|:--:|:--:|:--:|:--:|:--:|
| community-ops | ✅ | ✅ | — | ✅ | 10 | 3 |
| code-quality | ✅ | ✅ | — | ✅ | 7 | 2 |
| project-init | ✅ | — | — | — | 9 | 3 |
| multi-repo | ✅ | ❌ | ✅ | — | 2 | 1 |
| contributor-growth | ✅ | — | ✅ | — | 7 | 1 |
---
## 四、CLI 命令参考
`workflow` 模块提供 10 个子命令:
| 命令 | 用途 |
|------|------|
| `+list` | 列出所有可用工作流(支持 `--category` 过滤) |
| `+info --name <name>` | 查看工作流详情(步骤序列 + 触发配置) |
| `+run --name <name>` | 手动执行(支持 `--dry-run` / `--no-ai` / `--ai` |
| `+watch --name <name>` | 轮询监听,变更时触发(`--interval` |
| `+schedule --name <name>` | 定时调度(`--interval` |
| `+start --name <name>` | 后台守护进程启动 |
| `+stop --name <name>` | 停止守护进程 |
| `+status --name <name>` | 查看守护进程状态 |
| `+logs --name <name>` | 查看守护进程日志(`--follow` |
| `+install-systemd --name <name>` | 生成 systemd 服务单元文件 |
---
## 五、技术亮点
1. **引擎化设计**:工作流是声明式定义,引擎负责执行。`Register()` 即可扩展新工作流
2. **四种触发模式 + systemd**:手动 / 轮询 / 定时 / 守护 / systemd覆盖全场景
3. **双模降级**AIDeepSeek/Anthropic与规则引擎互为 fallback有 Key 走 AI无 Key 走规则
4. **安全白名单**11 模块 CLI 白名单 + 5 命令黑名单 + 3 方法 API 白名单
5. **变更检测与去重**:两阶段 dry-run + PR fingerprint + action MD5 双重去重
6. **声明式条件执行**RunWhenalways / weekly / on_change零代码控制执行频率
7. **步骤间数据流转**DependsOn 声明式依赖 + JSON 自动序列化/反序列化
8. **全链路中文日志**:跳过原因、降级信息、安全拦截、错误信息均中文输出
---
## 六、代码结构
```
shortcuts/workflow/
├── types.go # 类型定义 (WorkflowDef / StepDef / AIAction 等)
├── registry.go # 工作流注册中心
├── command_util.go # 命令解析 + 二进制路径解析
├── defs/ # 5 个预置工作流声明式定义
│ ├── init.go # init() 注册全部工作流
│ ├── community_ops.go / code_quality.go / project_init.go
│ ├── multi_repo.go / contributor_growth.go
├── engine/ # 执行引擎
│ ├── run.go # Run() 核心循环
│ ├── steps.go # Command / API / Skill 三种执行路径
│ ├── skill.go # AI/规则引擎决策 + 混合模式
│ └── actions.go # 安全白名单 + action 去重 + 执行
├── cli/commands.go # 10 个 CLI 子命令
├── daemon/ # watch / schedule / daemon 触发模式
├── ai/client.go # DeepSeek / Anthropic 客户端
├── state/ # 状态持久化 + 快照 Diff + PR fingerprint
├── rules/ # 11 个规则引擎实现
└── skills/ # 工作流专属 Skill 文档
```

View File

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# ============================================================
# 子赛题三 · 端到端自动化工作流 — 可复现执行脚本
# ============================================================
# 环境要求:
# 1. 已编译cd gitlink-cli && go build -o gitlink-cli .
# 2. 已认证:./gitlink-cli auth login
# 3.可选AI 模式:./gitlink-cli config set deepseek_api_key <key>
#
# 用法:
# ./执行脚本.sh # 预览工作流列表
# ./执行脚本.sh --owner <org> # 运行 4 个常规工作流(跳过 project-init
# ./执行脚本.sh --owner <org> --desc "高性能计算平台" # 含项目初始化
# ./执行脚本.sh --owner <org> --ai # AI 模式(需 API Key
# ./执行脚本.sh --owner <org> --repo <repo> # 指定仓库
# ============================================================
set -euo pipefail
# ---------- 参数 ----------
AI_FLAG="--no-ai"
OWNER=""
REPO="gitlink-cli"
DESC=""
while [[ $# -gt 0 ]]; do
case "$1" in
--ai) AI_FLAG="--ai"; shift ;;
--no-ai) AI_FLAG="--no-ai"; shift ;;
--owner) OWNER="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--desc) DESC="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
# ---------- 查找二进制 ----------
BIN=""
for c in "./gitlink-cli" "../gitlink-cli" "gitlink-cli"; do
if command -v "$c" &>/dev/null || [[ -x "$c" ]]; then BIN="$c"; break; fi
done
if [[ -z "$BIN" ]]; then echo "❌ 找不到 gitlink-cli请先编译"; exit 1; fi
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
banner() { echo -e "\n${BOLD}${CYAN}━━━ $1 ━━━${NC}\n"; }
ok() { echo -e "${GREEN}$1${NC}"; }
warn() { echo -e "$1"; }
run_wf() {
local name="$1" desc="$2" extra="${3:-}"
banner "$name$desc"
echo "> $BIN workflow +run --name $name --owner $OWNER --repo $REPO $AI_FLAG $extra"
$BIN workflow +run --name "$name" --owner "$OWNER" --repo "$REPO" $AI_FLAG $extra && ok "$name" || echo -e "${RED}$name${NC}"
}
# ---------- 主流程 ----------
echo -e "${BOLD}子赛题三 · 工作流引擎 — 可复现执行脚本${NC}"
echo "二进制: $BIN | 模式: $AI_FLAG | 时间: $(date '+%H:%M:%S')"
banner "可用工作流"
$BIN workflow +list
if [[ -z "$OWNER" ]]; then
echo ""
warn "未指定 --owner仅展示工作流列表。加 --owner <org> 运行全部工作流。"
exit 0
fi
# project-init需要 --desc 参数
if [[ -n "$DESC" ]]; then
banner "project-init — 项目一键初始化"
echo "> $BIN workflow +run --name project-init --owner $OWNER --desc $DESC $AI_FLAG"
$BIN workflow +run --name project-init --owner "$OWNER" --desc "$DESC" $AI_FLAG && ok "project-init" || echo -e "${RED}❌ project-init${NC}"
else
warn "跳过 project-init需要 --desc 参数。用法: $0 --owner <org> --desc '项目描述'"
fi
run_wf "code-quality" "代码质量看门人"
run_wf "community-ops" "社区运营自动化"
run_wf "contributor-growth" "贡献者成长体系"
run_wf "multi-repo" "多仓库协同" "--repos $OWNER/$REPO"
banner "全部完成"
echo "AI 模式: 配置 Key 后执行 $0 --owner $OWNER --ai"
echo "项目初始化: $0 --owner $OWNER --desc '项目描述'"

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB