forked from chroe/gitlink-cli
382 lines
9.5 KiB
Go
382 lines
9.5 KiB
Go
package workflow
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"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"`
|
||
}
|
||
|
||
// 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) {
|
||
parts := parseCommandTarget(step.Target)
|
||
if len(parts) == 0 {
|
||
sr.OK = false
|
||
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
|
||
return
|
||
}
|
||
|
||
bin, err := os.Executable()
|
||
if err != nil {
|
||
bin = "gitlink-cli"
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
|
||
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
|
||
|
||
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(设置 ANTHROPIC_API_KEY 环境变量或 config set anthropic_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
|
||
}
|
||
}
|
||
|
||
executed := executeActions(ctx, aiResp.Actions)
|
||
|
||
sr.OK = true
|
||
sr.Data = map[string]interface{}{
|
||
"ok": true,
|
||
"analysis": aiResp.Analysis,
|
||
"executed": executed,
|
||
"_ai_used": usedAI,
|
||
"_skill": step.Target,
|
||
}
|
||
}
|
||
|
||
// executeActions runs allowed actions from an AIResponse. Returns count of
|
||
// successfully executed actions. Actions from both AI and rule engines pass
|
||
// through the same security whitelist.
|
||
func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
|
||
executed := 0
|
||
for _, action := range actions {
|
||
if !isActionAllowed(action) {
|
||
fmt.Fprintf(os.Stderr, "[workflow] blocked action: %s %s\n", action.Type, action.Command)
|
||
continue
|
||
}
|
||
if action.Type == "api" {
|
||
path := resolvePath(action.Path, ctx.Owner, ctx.Repo)
|
||
_, err := ctx.CallAPI(action.Method, path, action.Body)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "[workflow] api action failed: %v\n", err)
|
||
continue
|
||
}
|
||
executed++
|
||
} else if action.Type == "cli" {
|
||
args := []string{action.Module, action.Command}
|
||
for k, v := range action.Args {
|
||
args = append(args, "--"+k, v)
|
||
}
|
||
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
|
||
bin, _ := os.Executable()
|
||
if bin == "" {
|
||
bin = "gitlink-cli"
|
||
}
|
||
err := exec.Command(bin, args...).Run()
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "[workflow] cli action failed: %v\n", err)
|
||
continue
|
||
}
|
||
executed++
|
||
}
|
||
}
|
||
return executed
|
||
}
|
||
|
||
// 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),
|
||
})
|
||
}
|
||
|
||
// 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{})
|
||
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)
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
|
||
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
|
||
}
|