forked from Gitlink/gitlink-cli
430 lines
13 KiB
Go
430 lines
13 KiB
Go
package workflow
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
|
||
// --- 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{
|
||
{
|
||
Name: "list",
|
||
Description: "List available workflows",
|
||
Flags: []common.Flag{
|
||
{Name: "category", Short: "c", Usage: "Filter by category", Default: ""},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
cat := ctx.Arg("category")
|
||
workflows := All()
|
||
filtered := make([]*WorkflowDef, 0)
|
||
for _, w := range workflows {
|
||
if cat == "" || strings.EqualFold(w.Category, cat) {
|
||
filtered = append(filtered, w)
|
||
}
|
||
}
|
||
type listItem struct {
|
||
Name string `json:"name"`
|
||
Category string `json:"category"`
|
||
Description string `json:"description"`
|
||
StepCount int `json:"step_count"`
|
||
}
|
||
items := make([]listItem, len(filtered))
|
||
for i, w := range filtered {
|
||
items[i] = listItem{
|
||
Name: w.Name,
|
||
Category: w.Category,
|
||
Description: w.Description,
|
||
StepCount: len(w.Steps),
|
||
}
|
||
}
|
||
return ctx.OutputData(items)
|
||
},
|
||
},
|
||
{
|
||
Name: "info",
|
||
Description: "Show workflow detail (steps and trigger)",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
wf := Get(name)
|
||
if wf == nil {
|
||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||
}
|
||
return ctx.OutputData(wf)
|
||
},
|
||
},
|
||
{
|
||
Name: "run",
|
||
Description: "Execute a workflow manually",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
|
||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||
{Name: "desc", Short: "d", Usage: "Project description (for project-init workflow)", Default: ""},
|
||
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow, e.g. org/backend,org/frontend", Default: ""},
|
||
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
|
||
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
|
||
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
|
||
{Name: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
|
||
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
wf := Get(name)
|
||
if wf == 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 != "" {
|
||
ctx.Args["_wiki_owner"] = owner
|
||
ctx.Args["_wiki_repo"] = repo
|
||
}
|
||
}
|
||
|
||
aiMode, err := resolveAIModeFromArgs(ctx)
|
||
if err != nil {
|
||
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 runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
||
},
|
||
},
|
||
{
|
||
Name: "watch",
|
||
Description: "Poll for changes and trigger workflow on delta",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 30s, 5m, 1h)", Default: "5m"},
|
||
{Name: "step", Short: "s", Usage: "Step name to watch for changes (default: first step)", Default: ""},
|
||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
wf := Get(name)
|
||
if wf == nil {
|
||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||
}
|
||
if wf.Name == "multi-repo" {
|
||
return fmt.Errorf("workflow +watch 不支持 multi-repo;多仓库协同请求量较大,请使用 workflow +run 手动检查,或 workflow +schedule --interval 6h/24h 做低频巡检")
|
||
}
|
||
intervalStr := ctx.Arg("interval")
|
||
interval, err := time.ParseDuration(intervalStr)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||
}
|
||
|
||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||
if modeErr != nil {
|
||
return modeErr
|
||
}
|
||
ctx.AIMode = aiMode
|
||
|
||
return Watch(ctx, wf, interval, ctx.Arg("step"))
|
||
},
|
||
},
|
||
{
|
||
Name: "schedule",
|
||
Description: "Run a workflow on a repeating schedule",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
{Name: "interval", Short: "i", Usage: "Run interval (e.g. 1h, 24h)", Default: "24h"},
|
||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow", Default: ""},
|
||
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
|
||
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
|
||
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
wf := Get(name)
|
||
if wf == nil {
|
||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||
}
|
||
intervalStr := ctx.Arg("interval")
|
||
interval, err := time.ParseDuration(intervalStr)
|
||
if err != nil {
|
||
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 != "" {
|
||
ctx.Args["_wiki_owner"] = owner
|
||
ctx.Args["_wiki_repo"] = repo
|
||
}
|
||
}
|
||
|
||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||
if modeErr != nil {
|
||
return modeErr
|
||
}
|
||
ctx.AIMode = aiMode
|
||
|
||
return Schedule(ctx, wf, interval)
|
||
},
|
||
},
|
||
{
|
||
Name: "start",
|
||
Description: "Start workflow as background daemon",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 5m, 1h)", Default: "5m"},
|
||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow", Default: ""},
|
||
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
|
||
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
|
||
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
wf := Get(name)
|
||
if wf == nil {
|
||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||
}
|
||
intervalStr := ctx.Arg("interval")
|
||
interval, err := time.ParseDuration(intervalStr)
|
||
if err != nil {
|
||
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 != "" {
|
||
ctx.Args["_wiki_owner"] = owner
|
||
ctx.Args["_wiki_repo"] = repo
|
||
}
|
||
}
|
||
|
||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||
if modeErr != nil {
|
||
return modeErr
|
||
}
|
||
|
||
return StartDaemon(ctx, wf, interval, aiMode)
|
||
},
|
||
},
|
||
{
|
||
Name: "stop",
|
||
Description: "Stop workflow daemon",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return StopDaemon(name)
|
||
},
|
||
},
|
||
{
|
||
Name: "status",
|
||
Description: "Show daemon status",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return StatusDaemon(name)
|
||
},
|
||
},
|
||
{
|
||
Name: "logs",
|
||
Description: "View daemon log output",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
{Name: "follow", Short: "f", Usage: "Follow log output (like tail -f)", Bool: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return tailDaemonLog(name, ctx.Arg("follow") == "true")
|
||
},
|
||
},
|
||
{
|
||
Name: "install-systemd",
|
||
Description: "Generate systemd service unit for a workflow daemon",
|
||
Flags: []common.Flag{
|
||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||
{Name: "interval", Short: "i", Usage: "Poll interval", Default: "5m"},
|
||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
name, err := ctx.RequireArg("name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
wf := Get(name)
|
||
if wf == nil {
|
||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||
}
|
||
|
||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||
if modeErr != nil {
|
||
return modeErr
|
||
}
|
||
|
||
return installSystemdUnit(ctx, wf, ctx.Arg("interval"), aiMode)
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
|
||
ai := ctx.Arg("ai") == "true"
|
||
noAI := ctx.Arg("no-ai") == "true"
|
||
if ai && noAI {
|
||
return "", fmt.Errorf("--ai 和 --no-ai 互斥,只能指定其中一个")
|
||
}
|
||
if ai {
|
||
return "ai", nil
|
||
}
|
||
if noAI {
|
||
return "no-ai", nil
|
||
}
|
||
return "auto", nil
|
||
}
|
||
|
||
func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) error {
|
||
result, err := RunWithMode(ctx, wf, dryRun, aiMode)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
needsAI := 0
|
||
ruleEngine := 0
|
||
for _, sr := range result.Steps {
|
||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||
if v, _ := m["_needs_ai"]; v == true {
|
||
needsAI++
|
||
}
|
||
if v, _ := m["_ai_used"]; v == true {
|
||
ruleEngine++ // AI was used
|
||
}
|
||
}
|
||
}
|
||
|
||
if needsAI > 0 {
|
||
fmt.Fprintf(os.Stderr, "\n⚠ %d 个 skill 步骤需要 AI 处理:\n", needsAI)
|
||
for _, sr := range result.Steps {
|
||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||
if v, _ := m["_needs_ai"]; v == true {
|
||
fmt.Fprintf(os.Stderr, " - %s (%s)\n", sr.Step, m["_skill"])
|
||
}
|
||
}
|
||
}
|
||
fmt.Fprintf(os.Stderr, "\n你可以:\n")
|
||
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set deepseek_api_key <key>\n")
|
||
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
|
||
} else if ruleEngine > 0 {
|
||
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
|
||
} else {
|
||
noAI := 0
|
||
for _, sr := range result.Steps {
|
||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||
if v, _ := m["_ai_used"]; v == false {
|
||
if _, hasSkill := m["_skill"]; hasSkill {
|
||
noAI++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if noAI > 0 {
|
||
fmt.Fprintf(os.Stderr, "⚙️ 规则引擎已处理 %d 个 skill 步骤 (未使用 AI)\n", noAI)
|
||
}
|
||
}
|
||
|
||
return ctx.Output(output.SuccessEnvelope(result, nil))
|
||
}
|
||
|
||
func splitRepoRef(raw string) (owner, repo string) {
|
||
parts := strings.SplitN(strings.TrimSpace(raw), "/", 2)
|
||
if len(parts) == 2 {
|
||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||
}
|
||
return "", ""
|
||
}
|