forked from Gitlink/gitlink-cli
846 lines
23 KiB
Go
846 lines
23 KiB
Go
package workflow
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/md5"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"`
|
||
Skipped bool `json:"skipped,omitempty"`
|
||
SkipReason string `json:"skip_reason,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) {
|
||
if strings.HasPrefix(step.Target, "workflow-internal:") {
|
||
executeInternalCommandStep(ctx, step, sr)
|
||
return
|
||
}
|
||
|
||
parts := parseCommandTarget(step.Target)
|
||
if len(parts) == 0 {
|
||
sr.OK = false
|
||
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
|
||
return
|
||
}
|
||
|
||
bin := resolveCLIBinary()
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
func executeInternalCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
|
||
switch strings.TrimPrefix(step.Target, "workflow-internal:") {
|
||
case "multi-repo-snapshot":
|
||
snapshot, err := BuildMultiRepoSnapshot(ctx)
|
||
if err != nil {
|
||
sr.OK = false
|
||
sr.Error = err.Error()
|
||
return
|
||
}
|
||
sr.OK = true
|
||
sr.Data = snapshot
|
||
default:
|
||
sr.OK = false
|
||
sr.Error = fmt.Sprintf("unknown internal workflow command: %q", step.Target)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
|
||
// For review step, fetch PR diffs and filter already-reviewed PRs.
|
||
if step.Target == "gitlink-review" {
|
||
enrichWithPRDiffs(upstream, ctx.Owner, ctx.Repo)
|
||
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 := NewAIClient()
|
||
var aiResp *AIResponse
|
||
var usedAI bool
|
||
var aiAnalysis interface{}
|
||
|
||
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(设置 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: // "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
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
// In AI mode for init-scaffold: let AI suggest an English repo name from
|
||
// a Chinese description, then use the rule engine for deterministic actions.
|
||
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)
|
||
}
|
||
}
|
||
// In AI mode, supplement health-report with deterministic wiki action.
|
||
// AI provides richer semantic analysis; rule engine ensures wiki publishing.
|
||
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"
|
||
}
|
||
}
|
||
// Rewrite the rule engine's wiki +create with AI-enhanced content.
|
||
for _, a := range ruleResp.Actions {
|
||
if a.Module == "wiki" && a.Command == "+create" {
|
||
if content != "" {
|
||
a.Args["content"] = content
|
||
}
|
||
aiResp.Actions = append(aiResp.Actions, a)
|
||
}
|
||
}
|
||
} else {
|
||
}
|
||
}
|
||
// Supplement AI responses with wiki publishing so the full pipeline runs.
|
||
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,
|
||
AIAction{
|
||
Type: "cli", Module: "wiki", Command: "+create",
|
||
Args: map[string]string{
|
||
"name": pageName,
|
||
"content": content,
|
||
"message": "自动生成贡献者排行榜",
|
||
},
|
||
},
|
||
AIAction{
|
||
Type: "cli", Module: "wiki", Command: "+update",
|
||
Args: map[string]string{
|
||
"name": pageName,
|
||
"content": content,
|
||
"message": "自动更新贡献者排行榜",
|
||
},
|
||
},
|
||
)
|
||
}
|
||
}
|
||
// In AI mode, supplement multi-repo-coordination with deterministic wiki action.
|
||
// Rule engine always controls whether wiki publish happens; AI only provides content.
|
||
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)
|
||
|
||
// After review, save PR fingerprints so we skip them next poll.
|
||
if step.Target == "gitlink-review" && !dryRun {
|
||
saveReviewedPRFingerprints(upstream, ctx)
|
||
}
|
||
|
||
// Step succeeds if at least one action was executed (repo create, etc.).
|
||
// Individual action errors are reported in data.errors.
|
||
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
|
||
}
|
||
|
||
// executeActions runs allowed actions from an AIResponse. Returns the count of
|
||
// successfully executed actions and any errors. After a successful repo +create,
|
||
// ctx.Repo is updated so subsequent API actions in the same batch target the
|
||
// newly created repository.
|
||
func executeActions(ctx *common.RuntimeContext, actions []AIAction) (int, []string) {
|
||
executed := 0
|
||
var errors []string
|
||
seen := make(map[string]bool, len(actions))
|
||
for _, action := range actions {
|
||
key := actionKey(action)
|
||
if seen[key] {
|
||
fmt.Fprintf(os.Stderr, "[workflow] skipped duplicate action: %s %s %s\n", action.Type, action.Module, action.Command)
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
if !isActionAllowed(action) {
|
||
msg := fmt.Sprintf("blocked action: %s %s +%s", action.Type, action.Module, action.Command)
|
||
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
|
||
errors = append(errors, msg)
|
||
continue
|
||
}
|
||
if action.Type == "api" {
|
||
path := resolvePath(action.Path, ctx.Owner, ctx.Repo)
|
||
_, err := ctx.CallAPI(action.Method, path, action.Body)
|
||
if err != nil {
|
||
msg := fmt.Sprintf("api %s %s: %v", action.Method, path, err)
|
||
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
|
||
errors = append(errors, msg)
|
||
continue
|
||
}
|
||
executed++
|
||
} else if action.Type == "cli" {
|
||
args := []string{action.Module, action.Command}
|
||
for k, v := range action.Args {
|
||
args = append(args, "--"+k, v)
|
||
}
|
||
if ctx.Owner != "" {
|
||
args = append(args, "--owner", ctx.Owner)
|
||
}
|
||
if ctx.Repo != "" {
|
||
args = append(args, "--repo", ctx.Repo)
|
||
}
|
||
bin := resolveCLIBinary()
|
||
cmd := exec.Command(bin, args...)
|
||
var cliStderr bytes.Buffer
|
||
cmd.Stderr = &cliStderr
|
||
err := cmd.Run()
|
||
if err != nil {
|
||
stderrStr := strings.TrimSpace(cliStderr.String())
|
||
// repo +create "already exists" is non-fatal: reuse existing repo.
|
||
if action.Module == "repo" && action.Command == "+create" && strings.Contains(stderrStr, "已被使用") {
|
||
fmt.Fprintf(os.Stderr, "[workflow] repo %s already exists, reusing\n", action.Args["name"])
|
||
executed++
|
||
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
|
||
ctx.Repo = name
|
||
}
|
||
continue
|
||
}
|
||
msg := fmt.Sprintf("cli %s: %v", strings.Join(args, " "), err)
|
||
if cliStderr.Len() > 0 {
|
||
msg += " — " + stderrStr
|
||
}
|
||
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
|
||
errors = append(errors, msg)
|
||
continue
|
||
}
|
||
executed++
|
||
// After repo +create succeeds, update ctx.Repo so subsequent
|
||
// API actions in the same batch target the new repository.
|
||
if action.Module == "repo" && action.Command == "+create" {
|
||
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
|
||
ctx.Repo = name
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return executed, errors
|
||
}
|
||
|
||
func actionKey(action AIAction) string {
|
||
raw, err := json.Marshal(action)
|
||
if err != nil {
|
||
return fmt.Sprintf("%s:%s:%s:%v:%s:%s", action.Type, action.Module, action.Command, action.Args, action.Method, action.Path)
|
||
}
|
||
sum := md5.Sum(raw)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// 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),
|
||
})
|
||
}
|
||
|
||
// extractRepoNameFromAIAnalysis tries to extract an ASCII repo name the AI
|
||
// suggested from a Chinese description. Returns "" if nothing usable is found.
|
||
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 ""
|
||
}
|
||
|
||
// 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{})
|
||
|
||
// Always include _-prefixed context keys so rule engines can access
|
||
// _owner, _repo, _desc, _wiki_owner, _wiki_repo, etc.
|
||
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 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)
|
||
}
|
||
|
||
// resolveCLIBinary finds the gitlink-cli binary for subprocess calls.
|
||
func resolveCLIBinary() string {
|
||
if exe, err := os.Executable(); err == nil && exe != "" {
|
||
return exe
|
||
}
|
||
for _, p := range []string{"./gitlink-cli", "./gitlink-cli.exe", "../gitlink-cli", "../gitlink-cli.exe"} {
|
||
if _, err := os.Stat(p); err == nil {
|
||
if abs, err := filepath.Abs(p); err == nil {
|
||
return abs
|
||
}
|
||
return p
|
||
}
|
||
}
|
||
return "gitlink-cli"
|
||
}
|
||
|
||
// 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,
|
||
"repo": true, "file": 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
|
||
}
|
||
|
||
// filterReviewedPRs removes PRs from upstream that were already reviewed
|
||
// with the same fingerprint, preventing re-review on every poll cycle.
|
||
func filterReviewedPRs(upstream map[string]interface{}, ctx *common.RuntimeContext) {
|
||
wfName := ctx.Arg("__wf_name")
|
||
if wfName == "" {
|
||
return
|
||
}
|
||
state, err := LoadState(wfName)
|
||
if err != nil || state == nil {
|
||
return
|
||
}
|
||
if state.ReviewedPRs == nil {
|
||
state.ReviewedPRs = make(map[string]string)
|
||
}
|
||
|
||
prs := extractPRListFromUpstream(upstream, "open-prs")
|
||
if len(prs) == 0 {
|
||
return
|
||
}
|
||
|
||
filtered := make([]interface{}, 0, len(prs))
|
||
skipped := 0
|
||
for _, pr := range prs {
|
||
prNum := prNumberFromMap(pr)
|
||
if prNum == "" {
|
||
filtered = append(filtered, pr)
|
||
continue
|
||
}
|
||
fp := prFingerprint(pr)
|
||
if stored, ok := state.ReviewedPRs[prNum]; ok && stored == fp {
|
||
skipped++
|
||
continue
|
||
}
|
||
filtered = append(filtered, pr)
|
||
}
|
||
|
||
if skipped > 0 {
|
||
fmt.Fprintf(os.Stderr, "[workflow] 跳过 %d 个已审查的 PR(无变化)\n", skipped)
|
||
}
|
||
|
||
upstream["open-prs"] = map[string]interface{}{"data": filtered}
|
||
}
|
||
|
||
// saveReviewedPRFingerprints stores PR fingerprints after a successful review.
|
||
func saveReviewedPRFingerprints(upstream map[string]interface{}, ctx *common.RuntimeContext) {
|
||
wfName := ctx.Arg("__wf_name")
|
||
if wfName == "" {
|
||
return
|
||
}
|
||
state, err := LoadState(wfName)
|
||
if err != nil || state == nil {
|
||
return
|
||
}
|
||
if state.ReviewedPRs == nil {
|
||
state.ReviewedPRs = make(map[string]string)
|
||
}
|
||
|
||
prs := extractPRListFromUpstream(upstream, "open-prs")
|
||
for _, pr := range prs {
|
||
prNum := prNumberFromMap(pr)
|
||
if prNum == "" {
|
||
continue
|
||
}
|
||
state.ReviewedPRs[prNum] = prFingerprint(pr)
|
||
}
|
||
state.Save()
|
||
}
|
||
|
||
// prFingerprint returns an MD5 hash of key PR fields for change detection.
|
||
func prFingerprint(pr map[string]interface{}) string {
|
||
var parts []string
|
||
if t := strFromMap(pr, "title", "name"); t != "" {
|
||
parts = append(parts, "title:"+t)
|
||
}
|
||
if b := strFromMap(pr, "body", "description"); b != "" {
|
||
parts = append(parts, "body:"+b)
|
||
}
|
||
if s := strFromMap(pr, "pull_request_status", "pull_request_staus", "status", "state"); s != "" {
|
||
parts = append(parts, "status:"+s)
|
||
}
|
||
h := md5.Sum([]byte(strings.Join(parts, "|")))
|
||
return hex.EncodeToString(h[:])
|
||
}
|
||
|
||
// prNumberFromMap extracts a PR number string from a PR data map.
|
||
func prNumberFromMap(pr map[string]interface{}) string {
|
||
for _, k := range []string{"pull_request_number", "id", "number", "pull_request_id"} {
|
||
if v := pr[k]; v != nil {
|
||
s := fmt.Sprintf("%v", v)
|
||
if s != "" && s != "0" && s != "<nil>" {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// strFromMap returns the first non-empty string value from the given keys.
|
||
func strFromMap(m map[string]interface{}, keys ...string) string {
|
||
for _, k := range keys {
|
||
if v, ok := m[k].(string); ok && v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// extractPRListFromUpstream extracts PR list from upstream data.
|
||
func extractPRListFromUpstream(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||
raw, ok := upstream[key]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
if m, ok := raw.(map[string]interface{}); ok {
|
||
if data, ok := m["data"]; ok {
|
||
raw = data
|
||
}
|
||
}
|
||
if m, ok := raw.(map[string]interface{}); ok {
|
||
for _, listKey := range []string{"issues", "pull_requests"} {
|
||
if v, ok := m[listKey]; ok {
|
||
if arr, ok := v.([]interface{}); ok {
|
||
raw = arr
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
list, _ := raw.([]interface{})
|
||
var out []map[string]interface{}
|
||
for _, item := range list {
|
||
if m, ok := item.(map[string]interface{}); ok {
|
||
out = append(out, m)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// enrichWithPRDiffs fetches diffs for open PRs and stores them in upstream
|
||
// so that both AI and the rule engine can analyze actual code changes.
|
||
func enrichWithPRDiffs(upstream map[string]interface{}, owner, repo string) {
|
||
prs := extractPRListFromUpstream(upstream, "open-prs")
|
||
if len(prs) == 0 {
|
||
return
|
||
}
|
||
diffs := make(map[string]string)
|
||
for _, pr := range prs {
|
||
// pull_request_status may be numeric (0=open, 1=merged, 2=closed) or string.
|
||
// The API also uses the typo key "pull_request_staus".
|
||
status := ""
|
||
switch v := pr["pull_request_status"].(type) {
|
||
case string:
|
||
status = v
|
||
case float64:
|
||
if v == 0 {
|
||
status = "open"
|
||
}
|
||
}
|
||
if status == "" {
|
||
if s, ok := pr["pull_request_staus"].(string); ok {
|
||
status = s
|
||
}
|
||
}
|
||
if status != "" && status != "open" {
|
||
continue
|
||
}
|
||
prNum := ""
|
||
if n, ok := pr["pull_request_number"]; ok {
|
||
prNum = fmt.Sprintf("%v", n)
|
||
} else if n, ok := pr["id"]; ok {
|
||
prNum = fmt.Sprintf("%v", n)
|
||
} else if n, ok := pr["number"]; ok {
|
||
prNum = fmt.Sprintf("%v", n)
|
||
} else if n, ok := pr["pull_request_id"]; ok {
|
||
prNum = fmt.Sprintf("%v", n)
|
||
}
|
||
if prNum == "" {
|
||
continue
|
||
}
|
||
bin := resolveCLIBinary()
|
||
cmd := exec.Command(bin, "pr", "+diff", "--id", prNum, "--owner", owner, "--repo", repo, "--format", "json")
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
var resp struct {
|
||
OK bool `json:"ok"`
|
||
Data struct {
|
||
Files []struct {
|
||
Sections []struct {
|
||
Lines []struct {
|
||
Content string `json:"content"`
|
||
} `json:"lines"`
|
||
} `json:"sections"`
|
||
} `json:"files"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(out, &resp); err != nil || !resp.OK || len(resp.Data.Files) == 0 {
|
||
continue
|
||
}
|
||
var sb strings.Builder
|
||
for _, f := range resp.Data.Files {
|
||
for _, sec := range f.Sections {
|
||
for _, line := range sec.Lines {
|
||
sb.WriteString(line.Content)
|
||
sb.WriteString("\n")
|
||
}
|
||
}
|
||
}
|
||
diffText := sb.String()
|
||
if diffText == "" {
|
||
continue
|
||
}
|
||
diffs[prNum] = diffText
|
||
}
|
||
if len(diffs) > 0 {
|
||
upstream["_pr_diffs"] = diffs
|
||
}
|
||
}
|