gitlink-cli/shortcuts/workflow/workflow.go

430 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 "", ""
}