forked from Gitlink/gitlink-cli
323 lines
8.4 KiB
Go
323 lines
8.4 KiB
Go
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)
|
||
}
|