gitlink-cli/shortcuts/workflow/engine/skill.go

323 lines
8.4 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 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)
}