gitlink-cli/shortcuts/workflow/steps.go

382 lines
9.5 KiB
Go
Raw Permalink 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 (
"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
}