diff --git a/shortcuts/register.go b/shortcuts/register.go index d1ac713..526074d 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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{ diff --git a/shortcuts/workflow/aiclient.go b/shortcuts/workflow/ai/client.go similarity index 86% rename from shortcuts/workflow/aiclient.go rename to shortcuts/workflow/ai/client.go index 6ec6a53..fa967b0 100644 --- a/shortcuts/workflow/aiclient.go +++ b/shortcuts/workflow/ai/client.go @@ -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 "" diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/cli/commands.go similarity index 86% rename from shortcuts/workflow/workflow.go rename to shortcuts/workflow/cli/commands.go index c91f99d..367cba6 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/cli/commands.go @@ -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++ } } } diff --git a/shortcuts/workflow/cli/commands_test.go b/shortcuts/workflow/cli/commands_test.go new file mode 100644 index 0000000..ccee4a2 --- /dev/null +++ b/shortcuts/workflow/cli/commands_test.go @@ -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) + } + } +} diff --git a/shortcuts/workflow/code_quality.go b/shortcuts/workflow/code_quality.go deleted file mode 100644 index 0314bcf..0000000 --- a/shortcuts/workflow/code_quality.go +++ /dev/null @@ -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"}}, - }, - }) -} diff --git a/shortcuts/workflow/command_util.go b/shortcuts/workflow/command_util.go new file mode 100644 index 0000000..7896eff --- /dev/null +++ b/shortcuts/workflow/command_util.go @@ -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 +} diff --git a/shortcuts/workflow/community_ops.go b/shortcuts/workflow/community_ops.go deleted file mode 100644 index c931093..0000000 --- a/shortcuts/workflow/community_ops.go +++ /dev/null @@ -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}, - }, - }) -} diff --git a/shortcuts/workflow/contributor_growth.go b/shortcuts/workflow/contributor_growth.go deleted file mode 100644 index 9868cd8..0000000 --- a/shortcuts/workflow/contributor_growth.go +++ /dev/null @@ -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"}}, - }, - }) -} diff --git a/shortcuts/workflow/daemon.go b/shortcuts/workflow/daemon/daemon.go similarity index 72% rename from shortcuts/workflow/daemon.go rename to shortcuts/workflow/daemon/daemon.go index 8bb6d78..407567a 100644 --- a/shortcuts/workflow/daemon.go +++ b/shortcuts/workflow/daemon/daemon.go @@ -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 } diff --git a/shortcuts/workflow/daemon/daemon_test.go b/shortcuts/workflow/daemon/daemon_test.go new file mode 100644 index 0000000..b5a1653 --- /dev/null +++ b/shortcuts/workflow/daemon/daemon_test.go @@ -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 +} diff --git a/shortcuts/workflow/daemon/proc_unix.go b/shortcuts/workflow/daemon/proc_unix.go new file mode 100644 index 0000000..6558bf4 --- /dev/null +++ b/shortcuts/workflow/daemon/proc_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package daemon + +import ( + "os/exec" + "syscall" +) + +func applyDaemonAttrs(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/shortcuts/workflow/daemon/proc_windows.go b/shortcuts/workflow/daemon/proc_windows.go new file mode 100644 index 0000000..6c19b37 --- /dev/null +++ b/shortcuts/workflow/daemon/proc_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package daemon + +import ( + "os/exec" + "syscall" +) + +func applyDaemonAttrs(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/shortcuts/workflow/daemon/schedule.go b/shortcuts/workflow/daemon/schedule.go new file mode 100644 index 0000000..a741954 --- /dev/null +++ b/shortcuts/workflow/daemon/schedule.go @@ -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) + } + } +} diff --git a/shortcuts/workflow/daemon/watch.go b/shortcuts/workflow/daemon/watch.go new file mode 100644 index 0000000..febe563 --- /dev/null +++ b/shortcuts/workflow/daemon/watch.go @@ -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) + } + } + } + } +} diff --git a/shortcuts/workflow/daemon_debug_test.go b/shortcuts/workflow/daemon_debug_test.go deleted file mode 100644 index 614e4b7..0000000 --- a/shortcuts/workflow/daemon_debug_test.go +++ /dev/null @@ -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"]) - } - } - } - } -} diff --git a/shortcuts/workflow/defs/code_quality.go b/shortcuts/workflow/defs/code_quality.go new file mode 100644 index 0000000..1a8eb39 --- /dev/null +++ b/shortcuts/workflow/defs/code_quality.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/defs/community_ops.go b/shortcuts/workflow/defs/community_ops.go new file mode 100644 index 0000000..b3267b1 --- /dev/null +++ b/shortcuts/workflow/defs/community_ops.go @@ -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}, + }, + }) +} diff --git a/shortcuts/workflow/defs/contributor_growth.go b/shortcuts/workflow/defs/contributor_growth.go new file mode 100644 index 0000000..43e10f4 --- /dev/null +++ b/shortcuts/workflow/defs/contributor_growth.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/defs/init.go b/shortcuts/workflow/defs/init.go new file mode 100644 index 0000000..e7ad598 --- /dev/null +++ b/shortcuts/workflow/defs/init.go @@ -0,0 +1,9 @@ +package defs + +func init() { + RegisterCommunityOps() + RegisterCodeQuality() + RegisterProjectInit() + RegisterMultiRepo() + RegisterContributorGrowth() +} diff --git a/shortcuts/workflow/multi_repo.go b/shortcuts/workflow/defs/multi_repo.go similarity index 65% rename from shortcuts/workflow/multi_repo.go rename to shortcuts/workflow/defs/multi_repo.go index cb63cec..b407f86 100644 --- a/shortcuts/workflow/multi_repo.go +++ b/shortcuts/workflow/defs/multi_repo.go @@ -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", diff --git a/shortcuts/workflow/defs/project_init.go b/shortcuts/workflow/defs/project_init.go new file mode 100644 index 0000000..aaa4968 --- /dev/null +++ b/shortcuts/workflow/defs/project_init.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/engine.go b/shortcuts/workflow/engine.go deleted file mode 100644 index 7546270..0000000 --- a/shortcuts/workflow/engine.go +++ /dev/null @@ -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 -} diff --git a/shortcuts/workflow/engine/actions.go b/shortcuts/workflow/engine/actions.go new file mode 100644 index 0000000..de8fa92 --- /dev/null +++ b/shortcuts/workflow/engine/actions.go @@ -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 +} diff --git a/shortcuts/workflow/engine/actions_test.go b/shortcuts/workflow/engine/actions_test.go new file mode 100644 index 0000000..8dfbed0 --- /dev/null +++ b/shortcuts/workflow/engine/actions_test.go @@ -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) + } + }) + } +} diff --git a/shortcuts/workflow/engine/engine_test.go b/shortcuts/workflow/engine/engine_test.go new file mode 100644 index 0000000..18efff5 --- /dev/null +++ b/shortcuts/workflow/engine/engine_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/engine/run.go b/shortcuts/workflow/engine/run.go new file mode 100644 index 0000000..248e1ae --- /dev/null +++ b/shortcuts/workflow/engine/run.go @@ -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, "" + } +} diff --git a/shortcuts/workflow/engine/skill.go b/shortcuts/workflow/engine/skill.go new file mode 100644 index 0000000..7096a5b --- /dev/null +++ b/shortcuts/workflow/engine/skill.go @@ -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) +} diff --git a/shortcuts/workflow/engine/steps.go b/shortcuts/workflow/engine/steps.go new file mode 100644 index 0000000..095bd2f --- /dev/null +++ b/shortcuts/workflow/engine/steps.go @@ -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) + } +} diff --git a/shortcuts/workflow/manifest.go b/shortcuts/workflow/manifest.go deleted file mode 100644 index d99f289..0000000 --- a/shortcuts/workflow/manifest.go +++ /dev/null @@ -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() -} diff --git a/shortcuts/workflow/proc_unix.go b/shortcuts/workflow/proc_unix.go deleted file mode 100644 index e49445c..0000000 --- a/shortcuts/workflow/proc_unix.go +++ /dev/null @@ -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} -} diff --git a/shortcuts/workflow/proc_windows.go b/shortcuts/workflow/proc_windows.go deleted file mode 100644 index 758156d..0000000 --- a/shortcuts/workflow/proc_windows.go +++ /dev/null @@ -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} -} diff --git a/shortcuts/workflow/project_init.go b/shortcuts/workflow/project_init.go deleted file mode 100644 index 3a75aa9..0000000 --- a/shortcuts/workflow/project_init.go +++ /dev/null @@ -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"}}, - }, - }) -} diff --git a/shortcuts/workflow/registry.go b/shortcuts/workflow/registry.go new file mode 100644 index 0000000..c137bb3 --- /dev/null +++ b/shortcuts/workflow/registry.go @@ -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] +} diff --git a/shortcuts/workflow/rules/contributor.go b/shortcuts/workflow/rules/contributor.go index 6d9d5b5..34aa193 100644 --- a/shortcuts/workflow/rules/contributor.go +++ b/shortcuts/workflow/rules/contributor.go @@ -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 "" diff --git a/shortcuts/workflow/rules/contributor_test.go b/shortcuts/workflow/rules/contributor_test.go index b6c0855..fa9a645 100644 --- a/shortcuts/workflow/rules/contributor_test.go +++ b/shortcuts/workflow/rules/contributor_test.go @@ -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") } } diff --git a/shortcuts/workflow/rules/health_report_test.go b/shortcuts/workflow/rules/health_report_test.go index 8d2282c..cde334a 100644 --- a/shortcuts/workflow/rules/health_report_test.go +++ b/shortcuts/workflow/rules/health_report_test.go @@ -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" { diff --git a/shortcuts/workflow/rules/repo_audit_test.go b/shortcuts/workflow/rules/repo_audit_test.go index 50c4666..8bd028a 100644 --- a/shortcuts/workflow/rules/repo_audit_test.go +++ b/shortcuts/workflow/rules/repo_audit_test.go @@ -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"}, }, }, diff --git a/skills/gitlink-auto-merge/SKILL.md b/shortcuts/workflow/skills/gitlink-auto-merge/SKILL.md similarity index 100% rename from skills/gitlink-auto-merge/SKILL.md rename to shortcuts/workflow/skills/gitlink-auto-merge/SKILL.md diff --git a/skills/gitlink-contributor-ranking/SKILL.md b/shortcuts/workflow/skills/gitlink-contributor-ranking/SKILL.md similarity index 100% rename from skills/gitlink-contributor-ranking/SKILL.md rename to shortcuts/workflow/skills/gitlink-contributor-ranking/SKILL.md diff --git a/shortcuts/workflow/skills/gitlink-init-scaffold/SKILL.md b/shortcuts/workflow/skills/gitlink-init-scaffold/SKILL.md new file mode 100644 index 0000000..7eb0a71 --- /dev/null +++ b/shortcuts/workflow/skills/gitlink-init-scaffold/SKILL.md @@ -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 配置、文档完善 diff --git a/skills/gitlink-multi-repo/SKILL.md b/shortcuts/workflow/skills/gitlink-multi-repo/SKILL.md similarity index 100% rename from skills/gitlink-multi-repo/SKILL.md rename to shortcuts/workflow/skills/gitlink-multi-repo/SKILL.md diff --git a/shortcuts/workflow/multi_repo_snapshot.go b/shortcuts/workflow/snapshot/snapshot.go similarity index 85% rename from shortcuts/workflow/multi_repo_snapshot.go rename to shortcuts/workflow/snapshot/snapshot.go index 0a20793..dd07928 100644 --- a/shortcuts/workflow/multi_repo_snapshot.go +++ b/shortcuts/workflow/snapshot/snapshot.go @@ -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 != "" { diff --git a/shortcuts/workflow/snapshot/snapshot_test.go b/shortcuts/workflow/snapshot/snapshot_test.go new file mode 100644 index 0000000..c0c6342 --- /dev/null +++ b/shortcuts/workflow/snapshot/snapshot_test.go @@ -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]) + } +} diff --git a/shortcuts/workflow/state/review.go b/shortcuts/workflow/state/review.go new file mode 100644 index 0000000..1bf7eb4 --- /dev/null +++ b/shortcuts/workflow/state/review.go @@ -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 != "" { + 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 +} diff --git a/shortcuts/workflow/state.go b/shortcuts/workflow/state/state.go similarity index 65% rename from shortcuts/workflow/state.go rename to shortcuts/workflow/state/state.go index f65c821..fb8db2f 100644 --- a/shortcuts/workflow/state.go +++ b/shortcuts/workflow/state/state.go @@ -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 "" diff --git a/shortcuts/workflow/state/state_test.go b/shortcuts/workflow/state/state_test.go new file mode 100644 index 0000000..bd72dc5 --- /dev/null +++ b/shortcuts/workflow/state/state_test.go @@ -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") + } +} diff --git a/shortcuts/workflow/steps.go b/shortcuts/workflow/steps.go deleted file mode 100644 index 022b9d4..0000000 --- a/shortcuts/workflow/steps.go +++ /dev/null @@ -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 != "" { - 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 - } -} diff --git a/shortcuts/workflow/trigger.go b/shortcuts/workflow/trigger.go deleted file mode 100644 index 5e21510..0000000 --- a/shortcuts/workflow/trigger.go +++ /dev/null @@ -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) - } - } -} diff --git a/shortcuts/workflow/types.go b/shortcuts/workflow/types.go index 9529e99..b3ba79e 100644 --- a/shortcuts/workflow/types.go +++ b/shortcuts/workflow/types.go @@ -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"` } diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index d383989..c122413 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -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) - } -} diff --git a/skills/gitlink-init-scaffold/SKILL.md b/skills/gitlink-init-scaffold/SKILL.md deleted file mode 100644 index f1c5a98..0000000 --- a/skills/gitlink-init-scaffold/SKILL.md +++ /dev/null @@ -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` 会被注入到规则引擎,用于创建仓库 diff --git a/提交材料/子赛题三-工作流/Agent对话记录.md b/提交材料/子赛题三-工作流/Agent对话记录.md new file mode 100644 index 0000000..c843b28 --- /dev/null +++ b/提交材料/子赛题三-工作流/Agent对话记录.md @@ -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` 持续监控 | 生产环境长期运行 | diff --git a/提交材料/子赛题三-工作流/README.md b/提交材料/子赛题三-工作流/README.md index e318468..9c6c394 100644 --- a/提交材料/子赛题三-工作流/README.md +++ b/提交材料/子赛题三-工作流/README.md @@ -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 --repo --no-ai + +# 项目一键初始化 +./gitlink-cli workflow +run --name project-init --desc "高性能计算调度平台" --owner +``` + +## 代码位置 + +``` +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 文档 +``` diff --git a/提交材料/子赛题三-工作流/demos/社区运营自动化.mp4 b/提交材料/子赛题三-工作流/demos/社区运营自动化.mp4 new file mode 100644 index 0000000..3e56905 Binary files /dev/null and b/提交材料/子赛题三-工作流/demos/社区运营自动化.mp4 differ diff --git a/提交材料/子赛题三-工作流/工作流说明.md b/提交材料/子赛题三-工作流/工作流说明.md new file mode 100644 index 0000000..abc459e --- /dev/null +++ b/提交材料/子赛题三-工作流/工作流说明.md @@ -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 步 | 3(triage / 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 ` | 查看工作流详情(步骤序列 + 触发配置) | +| `+run --name ` | 手动执行(支持 `--dry-run` / `--no-ai` / `--ai`) | +| `+watch --name ` | 轮询监听,变更时触发(`--interval`) | +| `+schedule --name ` | 定时调度(`--interval`) | +| `+start --name ` | 后台守护进程启动 | +| `+stop --name ` | 停止守护进程 | +| `+status --name ` | 查看守护进程状态 | +| `+logs --name ` | 查看守护进程日志(`--follow`) | +| `+install-systemd --name ` | 生成 systemd 服务单元文件 | + +--- + +## 五、技术亮点 + +1. **引擎化设计**:工作流是声明式定义,引擎负责执行。`Register()` 即可扩展新工作流 +2. **四种触发模式 + systemd**:手动 / 轮询 / 定时 / 守护 / systemd,覆盖全场景 +3. **双模降级**:AI(DeepSeek/Anthropic)与规则引擎互为 fallback,有 Key 走 AI,无 Key 走规则 +4. **安全白名单**:11 模块 CLI 白名单 + 5 命令黑名单 + 3 方法 API 白名单 +5. **变更检测与去重**:两阶段 dry-run + PR fingerprint + action MD5 双重去重 +6. **声明式条件执行**:RunWhen(always / 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 文档 +``` diff --git a/提交材料/子赛题三-工作流/执行脚本.sh b/提交材料/子赛题三-工作流/执行脚本.sh new file mode 100644 index 0000000..aa546c8 --- /dev/null +++ b/提交材料/子赛题三-工作流/执行脚本.sh @@ -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 +# +# 用法: +# ./执行脚本.sh # 预览工作流列表 +# ./执行脚本.sh --owner # 运行 4 个常规工作流(跳过 project-init) +# ./执行脚本.sh --owner --desc "高性能计算平台" # 含项目初始化 +# ./执行脚本.sh --owner --ai # AI 模式(需 API Key) +# ./执行脚本.sh --owner --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 运行全部工作流。" + 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 --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 '项目描述'" diff --git a/提交材料/子赛题三-工作流/架构图.png b/提交材料/子赛题三-工作流/架构图.png new file mode 100644 index 0000000..f9702c6 Binary files /dev/null and b/提交材料/子赛题三-工作流/架构图.png differ