forked from Gitlink/gitlink-cli
feat(子任务三): 工作流引擎重构 + 提交材料
- 引擎模块化拆分(engine/daemon/defs/cli/ai/state/skills 子包) - 5 个预置工作流声明式定义,支持 4 种触发模式 - AI/规则引擎双模降级,安全白名单过滤 - 工作流专属 Skill 移至 shortcuts/workflow/skills/ - 子赛题三提交材料:工作流说明、架构图、执行脚本、Agent对话记录、演示视频
This commit is contained in:
parent
95f8019e33
commit
6be9766f05
|
|
@ -22,7 +22,8 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/watch"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/cli"
|
||||
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/defs"
|
||||
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/rules"
|
||||
)
|
||||
|
||||
|
|
@ -47,7 +48,7 @@ func RegisterAll(root *cobra.Command) {
|
|||
"watch": watch.Shortcuts(),
|
||||
"star": star.Shortcuts(),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
"workflow": cli.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package workflow
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
const deepseekBaseURL = "https://api.deepseek.com/v1/chat/completions"
|
||||
|
|
@ -23,18 +24,6 @@ type AIClient struct {
|
|||
http *http.Client
|
||||
}
|
||||
|
||||
// AIRequest bundles the data needed for an AI skill step call.
|
||||
type AIRequest struct {
|
||||
SystemPrompt string
|
||||
UserData string
|
||||
}
|
||||
|
||||
// AIResponse is the parsed structured output from an AI skill step.
|
||||
type AIResponse struct {
|
||||
Analysis interface{} `json:"analysis"`
|
||||
Actions []AIAction `json:"actions"`
|
||||
}
|
||||
|
||||
const jsonOutputInstruction = `
|
||||
|
||||
IMPORTANT: You MUST respond with a single JSON object in exactly this format:
|
||||
|
|
@ -64,7 +53,7 @@ func NewAIClient() *AIClient {
|
|||
}
|
||||
|
||||
// Analyze sends the skill prompt + upstream data to the DeepSeek API and parses the response.
|
||||
func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
|
||||
func (c *AIClient) Analyze(req *wf.AIRequest) (*wf.AIResponse, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("AI client not configured: set DEEPSEEK_API_KEY or configure deepseek_api_key")
|
||||
}
|
||||
|
|
@ -125,7 +114,6 @@ func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
|
|||
text := result.Choices[0].Message.Content
|
||||
aiResp, err := parseAIResponse(text)
|
||||
if err != nil {
|
||||
// Fallback: try to extract JSON from markdown code fences.
|
||||
if extracted := extractJSONFromMarkdown(text); extracted != "" {
|
||||
aiResp2, err2 := parseAIResponse(extracted)
|
||||
if err2 != nil {
|
||||
|
|
@ -141,8 +129,7 @@ func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
|
|||
}
|
||||
|
||||
// parseAIResponse unmarshals the AI's JSON output, coercing numeric arg values to strings.
|
||||
func parseAIResponse(text string) (*AIResponse, error) {
|
||||
// First pass: unmarshal into a flexible structure that accepts numbers in args.
|
||||
func parseAIResponse(text string) (*wf.AIResponse, error) {
|
||||
raw := struct {
|
||||
Analysis json.RawMessage `json:"analysis"`
|
||||
Actions []struct {
|
||||
|
|
@ -159,9 +146,8 @@ func parseAIResponse(text string) (*AIResponse, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
resp := &AIResponse{}
|
||||
resp := &wf.AIResponse{}
|
||||
if err := json.Unmarshal(raw.Analysis, &resp.Analysis); err != nil {
|
||||
// If it's not valid JSON, treat it as a plain string.
|
||||
resp.Analysis = string(raw.Analysis)
|
||||
}
|
||||
for _, a := range raw.Actions {
|
||||
|
|
@ -169,7 +155,7 @@ func parseAIResponse(text string) (*AIResponse, error) {
|
|||
for k, v := range a.Args {
|
||||
args[k] = fmt.Sprint(v)
|
||||
}
|
||||
resp.Actions = append(resp.Actions, AIAction{
|
||||
resp.Actions = append(resp.Actions, wf.AIAction{
|
||||
Type: a.Type,
|
||||
Method: a.Method,
|
||||
Path: a.Path,
|
||||
|
|
@ -189,7 +175,6 @@ func (c *AIClient) HasKey() bool {
|
|||
|
||||
// extractJSONFromMarkdown tries to pull a JSON object out of a markdown code fence.
|
||||
func extractJSONFromMarkdown(text string) string {
|
||||
// Look for ```json ... ``` block.
|
||||
start := strings.Index(text, "```json")
|
||||
if start == -1 {
|
||||
start = strings.Index(text, "```")
|
||||
|
|
@ -197,7 +182,6 @@ func extractJSONFromMarkdown(text string) string {
|
|||
if start == -1 {
|
||||
return ""
|
||||
}
|
||||
// Find end of opening fence.
|
||||
nl := strings.Index(text[start:], "\n")
|
||||
if nl == -1 {
|
||||
return ""
|
||||
|
|
@ -1,45 +1,18 @@
|
|||
package workflow
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"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/daemon"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
|
||||
)
|
||||
|
||||
// --- Registry ---
|
||||
|
||||
var registry = map[string]*WorkflowDef{}
|
||||
|
||||
func register(wf *WorkflowDef) {
|
||||
registry[wf.Name] = wf
|
||||
}
|
||||
|
||||
// All returns all registered workflows sorted by name.
|
||||
func All() []*WorkflowDef {
|
||||
names := make([]string, 0, len(registry))
|
||||
for n := range registry {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
result := make([]*WorkflowDef, len(names))
|
||||
for i, n := range names {
|
||||
result[i] = registry[n]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns a workflow by name, or nil.
|
||||
func Get(name string) *WorkflowDef {
|
||||
return registry[name]
|
||||
}
|
||||
|
||||
// --- CLI Commands ---
|
||||
|
||||
// Shortcuts returns all workflow CLI commands.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
|
|
@ -51,8 +24,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
cat := ctx.Arg("category")
|
||||
workflows := All()
|
||||
filtered := make([]*WorkflowDef, 0)
|
||||
workflows := wf.All()
|
||||
filtered := make([]*wf.WorkflowDef, 0)
|
||||
for _, w := range workflows {
|
||||
if cat == "" || strings.EqualFold(w.Category, cat) {
|
||||
filtered = append(filtered, w)
|
||||
|
|
@ -87,11 +60,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
w := wf.Get(name)
|
||||
if w == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
return ctx.OutputData(wf)
|
||||
return ctx.OutputData(w)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -115,17 +88,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
w := wf.Get(name)
|
||||
if w == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
|
||||
// Pass description to the init-scaffold rule engine.
|
||||
if desc := ctx.Arg("desc"); desc != "" {
|
||||
ctx.Args["_desc"] = desc
|
||||
}
|
||||
|
||||
// Pass wiki-repo to multi-repo rule engine for wiki publishing.
|
||||
if wr := ctx.Arg("wiki-repo"); wr != "" {
|
||||
owner, repo := splitRepoRef(wr)
|
||||
if owner != "" && repo != "" {
|
||||
|
|
@ -139,17 +110,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
|
||||
// Daemon loop mode (internal — forked by +start)
|
||||
if ctx.Arg("daemon-loop") == "true" {
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
return DaemonLoop(ctx, wf, interval)
|
||||
return daemon.DaemonLoop(ctx, w, interval)
|
||||
}
|
||||
|
||||
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
||||
return runWorkflowCommand(ctx, w, ctx.Arg("dry-run") == "true", aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -167,11 +137,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
w := wf.Get(name)
|
||||
if w == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
if wf.Name == "multi-repo" {
|
||||
if w.Name == "multi-repo" {
|
||||
return fmt.Errorf("workflow +watch 不支持 multi-repo;多仓库协同请求量较大,请使用 workflow +run 手动检查,或 workflow +schedule --interval 6h/24h 做低频巡检")
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
|
|
@ -186,7 +156,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
ctx.AIMode = aiMode
|
||||
|
||||
return Watch(ctx, wf, interval, ctx.Arg("step"))
|
||||
return daemon.Watch(ctx, w, interval, ctx.Arg("step"))
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -207,8 +177,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
w := wf.Get(name)
|
||||
if w == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
|
|
@ -217,7 +187,6 @@ func Shortcuts() []*common.Shortcut {
|
|||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
// Pass wiki-repo to multi-repo rule engine for wiki publishing.
|
||||
if wr := ctx.Arg("wiki-repo"); wr != "" {
|
||||
owner, repo := splitRepoRef(wr)
|
||||
if owner != "" && repo != "" {
|
||||
|
|
@ -232,7 +201,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
ctx.AIMode = aiMode
|
||||
|
||||
return Schedule(ctx, wf, interval)
|
||||
return daemon.Schedule(ctx, w, interval)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -253,8 +222,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
w := wf.Get(name)
|
||||
if w == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
|
|
@ -263,7 +232,6 @@ func Shortcuts() []*common.Shortcut {
|
|||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
// Pass wiki-repo to multi-repo rule engine for wiki publishing.
|
||||
if wr := ctx.Arg("wiki-repo"); wr != "" {
|
||||
owner, repo := splitRepoRef(wr)
|
||||
if owner != "" && repo != "" {
|
||||
|
|
@ -277,7 +245,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
return modeErr
|
||||
}
|
||||
|
||||
return StartDaemon(ctx, wf, interval, aiMode)
|
||||
return daemon.StartDaemon(ctx, w, interval, aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -291,7 +259,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return StopDaemon(name)
|
||||
return daemon.StopDaemon(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -305,7 +273,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return StatusDaemon(name)
|
||||
return daemon.StatusDaemon(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -320,7 +288,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tailDaemonLog(name, ctx.Arg("follow") == "true")
|
||||
return daemon.TailDaemonLog(name, ctx.Arg("follow") == "true")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -337,8 +305,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
w := wf.Get(name)
|
||||
if w == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
|
||||
|
|
@ -347,7 +315,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
return modeErr
|
||||
}
|
||||
|
||||
return installSystemdUnit(ctx, wf, ctx.Arg("interval"), aiMode)
|
||||
return daemon.InstallSystemdUnit(ctx, w, ctx.Arg("interval"), aiMode)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -368,8 +336,8 @@ func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
|
|||
return "auto", nil
|
||||
}
|
||||
|
||||
func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) error {
|
||||
result, err := RunWithMode(ctx, wf, dryRun, aiMode)
|
||||
func runWorkflowCommand(ctx *common.RuntimeContext, w *wf.WorkflowDef, dryRun bool, aiMode string) error {
|
||||
result, err := engine.RunWithMode(ctx, w, dryRun, aiMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -382,7 +350,7 @@ func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool
|
|||
needsAI++
|
||||
}
|
||||
if v, _ := m["_ai_used"]; v == true {
|
||||
ruleEngine++ // AI was used
|
||||
ruleEngine++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShortcutsCount(t *testing.T) {
|
||||
sc := Shortcuts()
|
||||
if len(sc) != 10 {
|
||||
t.Fatalf("expected 10 shortcuts (list, info, run, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
|
||||
}
|
||||
names := map[string]bool{
|
||||
"list": false, "info": false, "run": false, "watch": false,
|
||||
"schedule": false, "start": false, "stop": false, "status": false,
|
||||
"logs": false, "install-systemd": false,
|
||||
}
|
||||
for _, s := range sc {
|
||||
if _, ok := names[s.Name]; !ok {
|
||||
t.Fatalf("unexpected shortcut: %s", s.Name)
|
||||
}
|
||||
names[s.Name] = true
|
||||
}
|
||||
for n, found := range names {
|
||||
if !found {
|
||||
t.Fatalf("missing shortcut: %s", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerCodeQuality() {
|
||||
register(&WorkflowDef{
|
||||
Name: "code-quality",
|
||||
Category: "质量",
|
||||
Description: "代码质量看门人:PR 提交 → Review → CI 检查 → 结果汇总",
|
||||
Trigger: TriggerDef{
|
||||
Type: "poll",
|
||||
On: "pr.opened",
|
||||
Interval: "5m",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
||||
{Type: StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
|
||||
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
|
||||
{Type: StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
|
||||
{Type: StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败并给出建议", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// ResolvePath replaces template placeholders in a path string.
|
||||
func ResolvePath(template, owner, repo string) string {
|
||||
base := fmt.Sprintf("/%s/%s", owner, repo)
|
||||
v1 := fmt.Sprintf("/v1/%s/%s", owner, repo)
|
||||
s := strings.Replace(template, "{v1}", v1, 1)
|
||||
s = strings.Replace(s, "{base}", base, 1)
|
||||
return s
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerCommunityOps() {
|
||||
register(&WorkflowDef{
|
||||
Name: "community-ops",
|
||||
Category: "运营",
|
||||
Description: "社区运营自动化:Issue 智能分拣 → 生成周报 → 生成 Release Notes",
|
||||
Trigger: TriggerDef{
|
||||
Type: "poll",
|
||||
On: "issue.created",
|
||||
Interval: "5m",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +list"},
|
||||
{Type: StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: RunAlways},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "获取提交历史分析活跃度", Target: "commit +list --limit 50"},
|
||||
{Type: StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: RunWeekly},
|
||||
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
|
||||
{Type: StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: RunOnChange},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerContributorGrowth() {
|
||||
register(&WorkflowDef{
|
||||
Name: "contributor-growth",
|
||||
Category: "成长",
|
||||
Description: "贡献者成长体系:追踪贡献者活动 → 生成排行 → 识别活跃与流失",
|
||||
Trigger: TriggerDef{
|
||||
Type: "cron",
|
||||
On: "0 9 * * 1",
|
||||
Interval: "24h",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "提交历史统计代码贡献", Target: "commit +list --limit 100"},
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue 统计 Issue 贡献", Target: "issue +list --state open --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue 统计解决贡献", Target: "issue +list --state closed --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据(Fork/Star/Watch)", Target: "repo +info"},
|
||||
{Type: StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package workflow
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -14,22 +14,24 @@ import (
|
|||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"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/engine"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
|
||||
)
|
||||
|
||||
// StartDaemon launches a workflow as a background daemon process.
|
||||
func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) error {
|
||||
func StartDaemon(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, aiMode string) error {
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot find executable: %w", err)
|
||||
}
|
||||
|
||||
args := buildDaemonArgs(ctx, wf, interval, aiMode)
|
||||
args := BuildDaemonArgs(ctx, wfDef, interval, aiMode)
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
applyDaemonAttrs(cmd)
|
||||
|
||||
// Redirect output to log file instead of discarding.
|
||||
logPath := daemonLogPath(wf.Name)
|
||||
logPath := daemonLogPath(wfDef.Name)
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create log file: %w", err)
|
||||
|
|
@ -42,25 +44,25 @@ func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Dura
|
|||
logFile.Close()
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
// logFile is owned by child process; it will be closed when child exits.
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
if err := savePID(wf.Name, pid); err != nil {
|
||||
if err := savePID(wfDef.Name, pid); err != nil {
|
||||
return fmt.Errorf("save pid: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Daemon started for %q (PID: %d)\n", wf.Name, pid)
|
||||
fmt.Printf("Daemon started for %q (PID: %d)\n", wfDef.Name, pid)
|
||||
fmt.Printf("Log: %s\n", logPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDaemonArgs(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) []string {
|
||||
// BuildDaemonArgs constructs the CLI arguments for the daemon subprocess.
|
||||
func BuildDaemonArgs(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, aiMode string) []string {
|
||||
args := []string{
|
||||
"workflow", "+run", "--name", wf.Name,
|
||||
"workflow", "+run", "--name", wfDef.Name,
|
||||
"--format", "json", "--daemon-loop",
|
||||
"--interval", interval.String(),
|
||||
}
|
||||
if !isExplicitMultiRepoRun(ctx, wf) {
|
||||
if !engine.IsExplicitMultiRepoRun(ctx, wfDef) {
|
||||
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
|
||||
}
|
||||
if aiMode == "ai" {
|
||||
|
|
@ -94,7 +96,6 @@ func StopDaemon(name string) error {
|
|||
}
|
||||
|
||||
if err := proc.Signal(os.Interrupt); err != nil {
|
||||
// Process might already be dead; clean up pid file anyway.
|
||||
cleanPID(name)
|
||||
return fmt.Errorf("failed to stop daemon %q: %w", name, err)
|
||||
}
|
||||
|
|
@ -106,7 +107,7 @@ func StopDaemon(name string) error {
|
|||
|
||||
// StatusDaemon prints the current daemon status for a workflow.
|
||||
func StatusDaemon(name string) error {
|
||||
state, err := LoadState(name)
|
||||
st, err := state.LoadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load state: %w", err)
|
||||
}
|
||||
|
|
@ -120,72 +121,67 @@ func StatusDaemon(name string) error {
|
|||
} else {
|
||||
fmt.Println("状态: 已停止")
|
||||
}
|
||||
if state.LastRun != "" {
|
||||
t, err := time.Parse(time.RFC3339, state.LastRun)
|
||||
if st.LastRun != "" {
|
||||
t, err := time.Parse(time.RFC3339, st.LastRun)
|
||||
if err == nil {
|
||||
fmt.Printf("上次运行: %s\n", t.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
fmt.Printf("上次运行: %s\n", state.LastRun)
|
||||
fmt.Printf("上次运行: %s\n", st.LastRun)
|
||||
}
|
||||
}
|
||||
fmt.Printf("累计运行: %d 次\n", state.TotalRuns)
|
||||
fmt.Printf("快照步骤: %d 个\n", len(state.Snapshots))
|
||||
fmt.Printf("累计运行: %d 次\n", st.TotalRuns)
|
||||
fmt.Printf("快照步骤: %d 个\n", len(st.Snapshots))
|
||||
fmt.Printf("日志文件: %s\n", daemonLogPath(name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DaemonLoop runs the workflow repeatedly in a loop (used by the daemon subprocess).
|
||||
func DaemonLoop(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
|
||||
// DaemonLoop runs the workflow repeatedly in a loop.
|
||||
func DaemonLoop(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration) error {
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
// Run immediately on start (dry-run to establish baseline).
|
||||
doDaemonCycle(ctx, wf)
|
||||
doDaemonCycle(ctx, wfDef)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
doDaemonCycle(ctx, wf)
|
||||
doDaemonCycle(ctx, wfDef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
|
||||
state, _ := LoadState(wf.Name)
|
||||
func doDaemonCycle(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) {
|
||||
st, _ := state.LoadState(wfDef.Name)
|
||||
|
||||
// Phase 1: cheap dry-run — collect data without AI.
|
||||
dryResult, err := Run(ctx, wf, true)
|
||||
dryResult, err := engine.Run(ctx, wfDef, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] error: %v\n", time.Now().Format(time.RFC3339), err)
|
||||
return
|
||||
}
|
||||
|
||||
changed := state.Diff(dryResult.Steps)
|
||||
changed := st.Diff(dryResult.Steps)
|
||||
if len(changed) == 0 {
|
||||
if state.TotalRuns > 0 {
|
||||
if st.TotalRuns > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[%s] 没有检测到变更\n", time.Now().Format(time.RFC3339))
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
st.TotalRuns++
|
||||
st.Save()
|
||||
return
|
||||
}
|
||||
// First run: establish baseline snapshot, then proceed to full run.
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[%s] 🔔 检测到变更: %v\n", time.Now().Format(time.RFC3339), changed)
|
||||
}
|
||||
|
||||
// Phase 2: full run (AI or rules based on context.AIMode).
|
||||
fullResult, err := Run(ctx, wf, false)
|
||||
fullResult, err := engine.Run(ctx, wfDef, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] run error: %v\n", time.Now().Format(time.RFC3339), err)
|
||||
return
|
||||
}
|
||||
|
||||
// Reload state — Run() may have updated ReviewedPRs fingerprints.
|
||||
state, _ = LoadState(wf.Name)
|
||||
state.TotalRuns++
|
||||
state.Diff(fullResult.Steps)
|
||||
state.UpdateSnapshots(fullResult.Steps)
|
||||
state.Save()
|
||||
st, _ = state.LoadState(wfDef.Name)
|
||||
st.TotalRuns++
|
||||
st.Diff(fullResult.Steps)
|
||||
st.UpdateSnapshots(fullResult.Steps)
|
||||
st.Save()
|
||||
|
||||
ok, total := 0, len(fullResult.Steps)
|
||||
for _, sr := range fullResult.Steps {
|
||||
|
|
@ -195,19 +191,17 @@ func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
|
|||
}
|
||||
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
|
||||
|
||||
// Log step-level details: failures and rule-engine findings.
|
||||
for _, sr := range fullResult.Steps {
|
||||
if !sr.OK {
|
||||
fmt.Fprintf(os.Stderr, "[%s] ❌ %s 失败: %s\n", time.Now().Format(time.RFC3339), sr.Step, sr.Error)
|
||||
}
|
||||
if sr.Type == StepTypeSkill && sr.Data != nil {
|
||||
if sr.Type == wf.StepTypeSkill && sr.Data != nil {
|
||||
logSkillFindings(sr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logSkillFindings prints rule-engine/AI analysis findings from a skill step to the daemon log.
|
||||
func logSkillFindings(sr StepResult) {
|
||||
func logSkillFindings(sr wf.StepResult) {
|
||||
m, ok := sr.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
|
|
@ -217,7 +211,6 @@ func logSkillFindings(sr StepResult) {
|
|||
|
||||
fmt.Fprintf(os.Stderr, "[%s] ── %s 分析结果 ──\n", time.Now().Format(time.RFC3339), skill)
|
||||
|
||||
// AI mode returns analysis as a markdown string; print it directly.
|
||||
if s, ok := analysis.(string); ok && s != "" {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format(time.RFC3339), line)
|
||||
|
|
@ -225,7 +218,6 @@ func logSkillFindings(sr StepResult) {
|
|||
return
|
||||
}
|
||||
|
||||
// Rule engine returns analysis as a structured map.
|
||||
am, _ := analysis.(map[string]interface{})
|
||||
if am == nil {
|
||||
return
|
||||
|
|
@ -264,13 +256,17 @@ func logSkillFindings(sr StepResult) {
|
|||
}
|
||||
}
|
||||
|
||||
// daemonLogPath returns the log file path for a workflow daemon.
|
||||
// DaemonLogPath returns the log file path for a workflow daemon.
|
||||
func DaemonLogPath(name string) string {
|
||||
return daemonLogPath(name)
|
||||
}
|
||||
|
||||
func daemonLogPath(name string) string {
|
||||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.log", name))
|
||||
}
|
||||
|
||||
// tailDaemonLog reads and optionally follows a daemon log file.
|
||||
func tailDaemonLog(name string, follow bool) error {
|
||||
// TailDaemonLog reads and optionally follows a daemon log file.
|
||||
func TailDaemonLog(name string, follow bool) error {
|
||||
path := daemonLogPath(name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
|
@ -315,8 +311,8 @@ func tailDaemonLog(name string, follow bool) error {
|
|||
}
|
||||
}
|
||||
|
||||
// installSystemdUnit generates a systemd service unit file for a workflow daemon.
|
||||
func installSystemdUnit(ctx *common.RuntimeContext, wf *WorkflowDef, interval, aiMode string) error {
|
||||
// InstallSystemdUnit generates a systemd service unit file for a workflow daemon.
|
||||
func InstallSystemdUnit(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval, aiMode string) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
|
|
@ -344,12 +340,12 @@ StandardError=append:%s
|
|||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`,
|
||||
wf.Name, ctx.Owner, ctx.Repo,
|
||||
bin, wf.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
|
||||
daemonLogPath(wf.Name), daemonLogPath(wf.Name),
|
||||
wfDef.Name, ctx.Owner, ctx.Repo,
|
||||
bin, wfDef.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
|
||||
daemonLogPath(wfDef.Name), daemonLogPath(wfDef.Name),
|
||||
)
|
||||
|
||||
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wf.Name))
|
||||
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wfDef.Name))
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||||
return fmt.Errorf("写入 unit 文件: %w", err)
|
||||
}
|
||||
|
|
@ -358,10 +354,10 @@ WantedBy=multi-user.target
|
|||
fmt.Println("安装步骤:")
|
||||
fmt.Printf(" sudo cp %s /etc/systemd/system/\n", unitPath)
|
||||
fmt.Println(" sudo systemctl daemon-reload")
|
||||
fmt.Printf(" sudo systemctl enable workflow-%s\n", wf.Name)
|
||||
fmt.Printf(" sudo systemctl start workflow-%s\n", wf.Name)
|
||||
fmt.Printf(" sudo systemctl enable workflow-%s\n", wfDef.Name)
|
||||
fmt.Printf(" sudo systemctl start workflow-%s\n", wfDef.Name)
|
||||
fmt.Println()
|
||||
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wf.Name)
|
||||
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wfDef.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestBuildDaemonArgsSkipsOwnerRepoForExplicitMultiRepo(t *testing.T) {
|
||||
wfDef := &wf.WorkflowDef{
|
||||
Name: "multi-repo",
|
||||
}
|
||||
ctx := &common.RuntimeContext{
|
||||
Owner: "ignored",
|
||||
Repo: "ignored",
|
||||
Args: map[string]string{
|
||||
"repos": "org/backend,org/frontend",
|
||||
"release": "v1.4.0",
|
||||
},
|
||||
}
|
||||
|
||||
args := BuildDaemonArgs(ctx, wfDef, 24*time.Hour, "no-ai")
|
||||
if stringSliceContains(args, "--owner") || stringSliceContains(args, "--repo") {
|
||||
t.Fatalf("explicit multi-repo daemon args should not include owner/repo: %v", args)
|
||||
}
|
||||
if !stringSliceContains(args, "--repos") || !stringSliceContains(args, "org/backend,org/frontend") {
|
||||
t.Fatalf("daemon args missing repos: %v", args)
|
||||
}
|
||||
if !stringSliceContains(args, "--release") || !stringSliceContains(args, "v1.4.0") {
|
||||
t.Fatalf("daemon args missing release: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceContains(items []string, want string) bool {
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func applyDaemonAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func applyDaemonAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"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/engine"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
|
||||
)
|
||||
|
||||
// Schedule runs the full workflow on a repeating interval. Blocks until Ctrl+C.
|
||||
func Schedule(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration) error {
|
||||
fmt.Printf("⏰ Scheduled %q every %v on %s/%s\n", wfDef.Name, interval, ctx.Owner, ctx.Repo)
|
||||
fmt.Println(" Press Ctrl+C to stop")
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
st, _ := state.LoadState(wfDef.Name)
|
||||
dryResult, _ := engine.Run(ctx, wfDef, true)
|
||||
st.UpdateSnapshots(dryResult.Steps)
|
||||
st.TotalRuns++
|
||||
st.Save()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
fmt.Println("\n👋 schedule stopped")
|
||||
return nil
|
||||
case t := <-tick.C:
|
||||
dryResult, err := engine.Run(ctx, wfDef, true)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
changed := st.Diff(dryResult.Steps)
|
||||
st.UpdateSnapshots(dryResult.Steps)
|
||||
st.TotalRuns++
|
||||
st.Save()
|
||||
|
||||
if len(changed) == 0 {
|
||||
fmt.Printf("[%s] ✓ no changes, skipped AI run\n", t.Format("15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] ⏳ changes detected, running %q with AI...\n", t.Format("15:04:05"), wfDef.Name)
|
||||
result, err := engine.Run(ctx, wfDef, false)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
ok, total := 0, len(result.Steps)
|
||||
for _, sr := range result.Steps {
|
||||
if sr.OK {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
fmt.Printf("✅ %d/%d steps OK\n", ok, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"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/engine"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
|
||||
)
|
||||
|
||||
// Watch polls the first step (or watchStep) every interval and triggers the
|
||||
// full workflow (with AI) only when data changes. Blocks until Ctrl+C.
|
||||
func Watch(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, watchStep string) error {
|
||||
if watchStep == "" && len(wfDef.Steps) > 0 {
|
||||
watchStep = wfDef.Steps[0].Name
|
||||
}
|
||||
|
||||
fmt.Printf("👀 Watching %s/%s for %q changes every %v\n", ctx.Owner, ctx.Repo, watchStep, interval)
|
||||
fmt.Printf(" Trigger: %s on %s\n", wfDef.Trigger.Type, wfDef.Trigger.On)
|
||||
fmt.Println(" Press Ctrl+C to stop")
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
|
||||
st, _ := state.LoadState(wfDef.Name)
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
fmt.Println("\n👋 watch stopped")
|
||||
return nil
|
||||
case t := <-tick.C:
|
||||
dryResult, err := engine.Run(ctx, wfDef, true)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
changed := st.Diff(dryResult.Steps)
|
||||
if len(changed) == 0 && st.TotalRuns > 0 {
|
||||
fmt.Printf("[%s] ✓ no changes\n", t.Format("15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] 🔔 change detected: %v\n", t.Format("15:04:05"), changed)
|
||||
|
||||
result, err := engine.Run(ctx, wfDef, false)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ AI run error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
st.UpdateSnapshots(result.Steps)
|
||||
st.TotalRuns++
|
||||
st.Save()
|
||||
|
||||
for _, sr := range result.Steps {
|
||||
if sr.OK {
|
||||
fmt.Printf(" ✓ %s\n", sr.Step)
|
||||
} else {
|
||||
fmt.Printf(" ✗ %s: %s\n", sr.Step, sr.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
func TestDaemonCycleTriageResult(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Return a simple issue list
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true,"data":{"issues":[{"project_issues_index":"1","title":"fix crash","body":"app crashes on startup"}]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "test", Repo: "test", Format: "json",
|
||||
Args: map[string]string{},
|
||||
}
|
||||
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-daemon-cycle",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "issues", Target: "issue +list --state open"},
|
||||
{Type: StepTypeCommand, Name: "labels", Purpose: "labels", Target: "label +list"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "members", Target: "member +list"},
|
||||
{Type: StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}},
|
||||
},
|
||||
}
|
||||
|
||||
// Simulate daemon cycle: dry-run then full run with same ctx
|
||||
t.Log("=== Dry run ===")
|
||||
dryResult, _ := Run(ctx, wf, true)
|
||||
for _, sr := range dryResult.Steps {
|
||||
t.Logf("dry %s: ok=%v data=%v", sr.Step, sr.OK, sr.Data)
|
||||
}
|
||||
|
||||
t.Log("=== Full run ===")
|
||||
fullResult, _ := Run(ctx, wf, false)
|
||||
for _, sr := range fullResult.Steps {
|
||||
t.Logf("full %s: ok=%v data=%v", sr.Step, sr.OK, sr.Data)
|
||||
if sr.Step == "triage" {
|
||||
d, ok := sr.Data.(map[string]interface{})
|
||||
if ok {
|
||||
a, _ := d["analysis"].(map[string]interface{})
|
||||
t.Logf("triage analysis: %v", a)
|
||||
if a != nil {
|
||||
t.Logf("classified: %v", a["classified"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package defs
|
||||
|
||||
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
// RegisterCodeQuality registers the code-quality workflow definition.
|
||||
func RegisterCodeQuality() {
|
||||
wf.Register(&wf.WorkflowDef{
|
||||
Name: "code-quality",
|
||||
Category: "质量",
|
||||
Description: "代码质量看门人:PR 提交 → Review → CI 检查 → 结果汇总",
|
||||
Trigger: wf.TriggerDef{
|
||||
Type: "poll",
|
||||
On: "pr.opened",
|
||||
Interval: "5m",
|
||||
},
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
||||
{Type: wf.StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
|
||||
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
|
||||
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
|
||||
{Type: wf.StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
|
||||
{Type: wf.StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
|
||||
{Type: wf.StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败并给出建议", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package defs
|
||||
|
||||
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
// RegisterCommunityOps registers the community-ops workflow definition.
|
||||
func RegisterCommunityOps() {
|
||||
wf.Register(&wf.WorkflowDef{
|
||||
Name: "community-ops",
|
||||
Category: "运营",
|
||||
Description: "社区运营自动化:Issue 智能分拣 → 生成周报 → 生成 Release Notes",
|
||||
Trigger: wf.TriggerDef{
|
||||
Type: "poll",
|
||||
On: "issue.created",
|
||||
Interval: "5m",
|
||||
},
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
|
||||
{Type: wf.StepTypeCommand, Name: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
|
||||
{Type: wf.StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +list"},
|
||||
{Type: wf.StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: wf.RunAlways},
|
||||
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
|
||||
{Type: wf.StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
|
||||
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "获取提交历史分析活跃度", Target: "commit +list --limit 50"},
|
||||
{Type: wf.StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: wf.RunWeekly},
|
||||
{Type: wf.StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
|
||||
{Type: wf.StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: wf.RunOnChange},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package defs
|
||||
|
||||
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
// RegisterContributorGrowth registers the contributor-growth workflow definition.
|
||||
func RegisterContributorGrowth() {
|
||||
wf.Register(&wf.WorkflowDef{
|
||||
Name: "contributor-growth",
|
||||
Category: "成长",
|
||||
Description: "贡献者成长体系:追踪贡献者活动 → 生成排行 → 识别活跃与流失",
|
||||
Trigger: wf.TriggerDef{
|
||||
Type: "cron",
|
||||
On: "0 9 * * 1",
|
||||
Interval: "24h",
|
||||
},
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "提交历史统计代码贡献", Target: "commit +list --limit 100"},
|
||||
{Type: wf.StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue 统计 Issue 贡献", Target: "issue +list --state open --limit 50"},
|
||||
{Type: wf.StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue 统计解决贡献", Target: "issue +list --state closed --limit 50"},
|
||||
{Type: wf.StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
|
||||
{Type: wf.StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
|
||||
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据(Fork/Star/Watch)", Target: "repo +info"},
|
||||
{Type: wf.StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package defs
|
||||
|
||||
func init() {
|
||||
RegisterCommunityOps()
|
||||
RegisterCodeQuality()
|
||||
RegisterProjectInit()
|
||||
RegisterMultiRepo()
|
||||
RegisterContributorGrowth()
|
||||
}
|
||||
|
|
@ -1,24 +1,27 @@
|
|||
package workflow
|
||||
package defs
|
||||
|
||||
func registerMultiRepo() {
|
||||
register(&WorkflowDef{
|
||||
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
// RegisterMultiRepo registers the multi-repo workflow definition.
|
||||
func RegisterMultiRepo() {
|
||||
wf.Register(&wf.WorkflowDef{
|
||||
Name: "multi-repo",
|
||||
Category: "协同",
|
||||
Description: "多仓库协同:跨仓库 Issue/PR 状态看板、Release 协调发布",
|
||||
Trigger: TriggerDef{
|
||||
Trigger: wf.TriggerDef{
|
||||
Type: "cron",
|
||||
On: "0 9 * * 1",
|
||||
Interval: "24h",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
Steps: []wf.StepDef{
|
||||
{
|
||||
Type: StepTypeCommand,
|
||||
Type: wf.StepTypeCommand,
|
||||
Name: "multi-repo-snapshot",
|
||||
Purpose: "采集多个仓库的 Issue/PR/Release/Milestone 状态",
|
||||
Target: "workflow-internal:multi-repo-snapshot",
|
||||
},
|
||||
{
|
||||
Type: StepTypeSkill,
|
||||
Type: wf.StepTypeSkill,
|
||||
Name: "multi-repo-coordination",
|
||||
Purpose: "生成统一 Issue 追踪、PR 看板、Release 协调报告",
|
||||
Target: "gitlink-multi-repo",
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package defs
|
||||
|
||||
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
// RegisterProjectInit registers the project-init workflow definition.
|
||||
func RegisterProjectInit() {
|
||||
wf.Register(&wf.WorkflowDef{
|
||||
Name: "project-init",
|
||||
Category: "初始化",
|
||||
Description: "项目一键初始化:创建仓库 → 脚手架文件 → 标签/里程碑/Issue → 许可证审计 → 健康报告",
|
||||
Trigger: wf.TriggerDef{
|
||||
Type: "manual",
|
||||
On: "manual",
|
||||
},
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeSkill, Name: "init-scaffold", Purpose: "根据描述创建仓库并初始化脚手架(README/LICENSE/.gitignore/标签/里程碑/Issue)", Target: "gitlink-init-scaffold"},
|
||||
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建并获取基础信息", Target: "repo +info"},
|
||||
{Type: wf.StepTypeCommand, Name: "existing-files", Purpose: "验证 README/LICENSE 文件", Target: "file +list"},
|
||||
{Type: wf.StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
|
||||
{Type: wf.StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
|
||||
{Type: wf.StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
|
||||
{Type: wf.StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
|
||||
{Type: wf.StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
|
||||
{Type: wf.StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// WorkflowResult holds the outcome of a full workflow run.
|
||||
type WorkflowResult struct {
|
||||
Workflow string `json:"workflow"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Steps []StepResult `json:"steps"`
|
||||
}
|
||||
|
||||
// Run executes every step in a workflow sequentially.
|
||||
// Steps later in the sequence receive data from their DependsOn predecessors
|
||||
// via ctx.Args (keyed by step name, stored as JSON).
|
||||
// Set dryRun to true to skip AI API calls for skill steps.
|
||||
func Run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
|
||||
return RunWithMode(ctx, wf, dryRun, "")
|
||||
}
|
||||
|
||||
// RunWithMode executes a workflow with explicit AI mode control.
|
||||
// aiMode must be "auto", "ai", "no-ai", or "" (equivalent to "auto").
|
||||
func RunWithMode(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) (*WorkflowResult, error) {
|
||||
if aiMode != "" {
|
||||
ctx.AIMode = aiMode
|
||||
}
|
||||
return run(ctx, wf, dryRun)
|
||||
}
|
||||
|
||||
func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
|
||||
if !shouldSkipOwnerRepoResolve(ctx, wf) {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return nil, fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = make(map[string]string)
|
||||
}
|
||||
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
|
||||
ctx.Args["dry_run"] = "true"
|
||||
}
|
||||
ctx.Args["__wf_name"] = wf.Name
|
||||
|
||||
// Make owner and repo available to skill rules as _owner / _repo.
|
||||
if ctx.Owner != "" {
|
||||
ctx.Args["_owner"] = ctx.Owner
|
||||
}
|
||||
if ctx.Repo != "" {
|
||||
ctx.Args["_repo"] = ctx.Repo
|
||||
}
|
||||
|
||||
// Load state for condition checks (only in non-dry-run mode).
|
||||
var state *WorkflowState
|
||||
if !dryRun {
|
||||
state, _ = LoadState(wf.Name)
|
||||
}
|
||||
|
||||
results := make([]StepResult, 0, len(wf.Steps))
|
||||
for _, step := range wf.Steps {
|
||||
// Compute upstream hash for condition checking and state tracking.
|
||||
var upstreamHash string
|
||||
if !dryRun && state != nil {
|
||||
upstream := collectUpstream(ctx, step)
|
||||
upstreamHash = hashData(upstream)
|
||||
}
|
||||
|
||||
// Phase conditions (weekly/on_change) only apply in daemon/poll mode.
|
||||
// Manual +run always executes all steps.
|
||||
isDaemon := ctx.Arg("daemon-loop") == "true"
|
||||
if isDaemon && !dryRun && state != nil && step.RunWhen != "" && step.RunWhen != RunAlways {
|
||||
shouldRun, skipReason := checkPhaseCondition(step, state, upstreamHash)
|
||||
if !shouldRun {
|
||||
fmt.Fprintf(os.Stderr, "[%s] 跳过 %s: %s\n", wf.Name, step.Name, skipReason)
|
||||
results = append(results, StepResult{
|
||||
Step: step.Name,
|
||||
Purpose: step.Purpose,
|
||||
Type: step.Type,
|
||||
OK: true,
|
||||
Skipped: true,
|
||||
SkipReason: skipReason,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
sr := ExecuteStep(ctx, step, dryRun)
|
||||
results = append(results, *sr)
|
||||
|
||||
// After init-scaffold creates a repo, update ctx.Repo so
|
||||
// subsequent API steps target the newly created repository.
|
||||
if sr.OK && !sr.Skipped && step.Name == "init-scaffold" && ctx.Repo == "" {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if a, ok := m["analysis"].(map[string]interface{}); ok {
|
||||
if r, ok := a["repo"].(string); ok && r != "" {
|
||||
parts := strings.SplitN(r, "/", 2)
|
||||
if len(parts) == 2 {
|
||||
ctx.Repo = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After successful step, update state for future condition checks.
|
||||
if !dryRun && state != nil && sr.OK && !sr.Skipped {
|
||||
if state.PhaseLastRun == nil {
|
||||
state.PhaseLastRun = make(map[string]string)
|
||||
}
|
||||
state.PhaseLastRun[step.Name] = time.Now().Format(time.RFC3339)
|
||||
if state.PhaseUpstream == nil {
|
||||
state.PhaseUpstream = make(map[string]string)
|
||||
}
|
||||
state.PhaseUpstream[step.Name] = upstreamHash
|
||||
}
|
||||
|
||||
// Feed output of this step as input to downstream steps via Args.
|
||||
if sr.Data != nil {
|
||||
raw, err := json.Marshal(sr.Data)
|
||||
if err == nil {
|
||||
ctx.Args[step.Name] = string(raw)
|
||||
} else {
|
||||
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
|
||||
}
|
||||
} else if !sr.OK && sr.Error != "" {
|
||||
ctx.Args[step.Name] = fmt.Sprintf(`{"_error": true, "_message": %q}`, sr.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if !dryRun && state != nil {
|
||||
state.Save()
|
||||
}
|
||||
|
||||
return &WorkflowResult{
|
||||
Workflow: wf.Name,
|
||||
Owner: ctx.Owner,
|
||||
Repo: ctx.Repo,
|
||||
Steps: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shouldSkipOwnerRepoResolve(ctx *common.RuntimeContext, wf *WorkflowDef) bool {
|
||||
if wf != nil && wf.Name == "project-init" {
|
||||
return true
|
||||
}
|
||||
return isExplicitMultiRepoRun(ctx, wf)
|
||||
}
|
||||
|
||||
func isExplicitMultiRepoRun(ctx *common.RuntimeContext, wf *WorkflowDef) bool {
|
||||
if wf == nil || wf.Name != "multi-repo" {
|
||||
return false
|
||||
}
|
||||
return ctx.Arg("repos") != "" || ctx.Arg("from") != ""
|
||||
}
|
||||
|
||||
// checkPhaseCondition determines whether a step should run based on its RunWhen setting.
|
||||
func checkPhaseCondition(step StepDef, state *WorkflowState, upstreamHash string) (bool, string) {
|
||||
switch step.RunWhen {
|
||||
case RunWeekly:
|
||||
last := state.PhaseLastRun[step.Name]
|
||||
if last == "" {
|
||||
return true, "" // first run
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, last)
|
||||
if err != nil {
|
||||
return true, ""
|
||||
}
|
||||
if time.Since(t) >= 7*24*time.Hour {
|
||||
return true, "" // more than 7 days
|
||||
}
|
||||
next := t.Add(7 * 24 * time.Hour)
|
||||
return false, fmt.Sprintf("下次运行: %s", next.Format("01-02 15:04"))
|
||||
case RunOnChange:
|
||||
if prev, ok := state.PhaseUpstream[step.Name]; ok && prev == upstreamHash {
|
||||
return false, "数据无变化"
|
||||
}
|
||||
return true, ""
|
||||
default:
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePath replaces template placeholders in a path string.
|
||||
//
|
||||
// {base} → /owner/repo
|
||||
// {v1} → /v1/owner/repo
|
||||
func resolvePath(template, owner, repo string) string {
|
||||
base := fmt.Sprintf("/%s/%s", owner, repo)
|
||||
v1 := fmt.Sprintf("/v1/%s/%s", owner, repo)
|
||||
s := strings.Replace(template, "{v1}", v1, 1)
|
||||
s = strings.Replace(s, "{base}", base, 1)
|
||||
return s
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func executeActions(ctx *common.RuntimeContext, actions []wf.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 := wf.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 := wf.ResolveCLIBinary()
|
||||
cmd := exec.Command(bin, args...)
|
||||
var cliStderr bytes.Buffer
|
||||
cmd.Stderr = &cliStderr
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
stderrStr := strings.TrimSpace(cliStderr.String())
|
||||
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++
|
||||
if action.Module == "repo" && action.Command == "+create" {
|
||||
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
|
||||
ctx.Repo = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return executed, errors
|
||||
}
|
||||
|
||||
func actionKey(action wf.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[:])
|
||||
}
|
||||
|
||||
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 wf.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
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestActionAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
action wf.AIAction
|
||||
allowed bool
|
||||
}{
|
||||
{"api GET", wf.AIAction{Type: "api", Method: "GET"}, true},
|
||||
{"api POST", wf.AIAction{Type: "api", Method: "POST"}, true},
|
||||
{"api PATCH", wf.AIAction{Type: "api", Method: "PATCH"}, true},
|
||||
{"api DELETE blocked", wf.AIAction{Type: "api", Method: "DELETE"}, false},
|
||||
{"cli issue comment", wf.AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
|
||||
{"cli delete blocked", wf.AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
|
||||
{"cli fork blocked", wf.AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
|
||||
{"cli repo module blocked", wf.AIAction{Type: "cli", Module: "org", Command: "+list"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isActionAllowed(tc.action); got != tc.allowed {
|
||||
t.Errorf("isActionAllowed(%+v) = %v, want %v", tc.action, got, tc.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestRunWithAPISteps(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
||||
{"id": 1, "subject": "bug"},
|
||||
{"id": 2, "subject": "feature"},
|
||||
}, nil))
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/labels.json":
|
||||
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
||||
{"id": 10, "name": "bug"},
|
||||
{"id": 11, "name": "enhancement"},
|
||||
}, nil))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wfDef := &wf.WorkflowDef{
|
||||
Name: "test-api",
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: wf.StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wfDef, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
if result.Owner != "owner" || result.Repo != "repo" {
|
||||
t.Fatalf("expected owner/repo = owner/repo, got %s/%s", result.Owner, result.Repo)
|
||||
}
|
||||
if len(result.Steps) != 2 {
|
||||
t.Fatalf("expected 2 step results, got %d", len(result.Steps))
|
||||
}
|
||||
for _, sr := range result.Steps {
|
||||
if !sr.OK {
|
||||
t.Fatalf("step %q: expected ok=true, got error=%q", sr.Step, sr.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepReceivesUpstream(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/owner/repo/issues.json" {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
||||
"issues": []map[string]interface{}{{"id": 1}},
|
||||
}, nil))
|
||||
} else if r.URL.Path == "/v1/owner/repo/labels.json" {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
||||
"labels": []map[string]interface{}{{"name": "bug"}},
|
||||
}, nil))
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wfDef := &wf.WorkflowDef{
|
||||
Name: "test-skill-upstream",
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: wf.StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
||||
{Type: wf.StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wfDef, true) // dry-run to test upstream without AI
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[2].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
if v, _ := skillData["_dry_run"]; v != true {
|
||||
t.Fatal("skill step should have _dry_run=true")
|
||||
}
|
||||
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step missing _upstream map")
|
||||
}
|
||||
if _, hasIssues := upstream["get-issues"]; !hasIssues {
|
||||
t.Fatal("_upstream missing get-issues key")
|
||||
}
|
||||
if _, hasLabels := upstream["get-labels"]; !hasLabels {
|
||||
t.Fatal("_upstream missing get-labels key")
|
||||
}
|
||||
if skillData["_skill"] != "gitlink-triage" {
|
||||
t.Fatalf("_skill = %q, want %q", skillData["_skill"], "gitlink-triage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepWithDependsOn(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wfDef := &wf.WorkflowDef{
|
||||
Name: "test-depends-on",
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: wf.StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
||||
{Type: wf.StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
|
||||
{Type: wf.StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
|
||||
DependsOn: []string{"open-issues", "labels"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wfDef, true) // dry-run to test DependsOn filter without AI
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[3].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step missing _upstream map")
|
||||
}
|
||||
if _, hasIssues := upstream["open-issues"]; !hasIssues {
|
||||
t.Fatal("_upstream missing open-issues key")
|
||||
}
|
||||
if _, hasLabels := upstream["labels"]; !hasLabels {
|
||||
t.Fatal("_upstream missing labels key")
|
||||
}
|
||||
if _, hasMembers := upstream["members"]; hasMembers {
|
||||
t.Fatal("_upstream should NOT contain members (not in DependsOn)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStepFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"ok": false, "error": "internal server error",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wfDef := &wf.WorkflowDef{
|
||||
Name: "test-fail",
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wfDef, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() returned error: %v (steps should fail gracefully)", err)
|
||||
}
|
||||
if result.Steps[0].OK {
|
||||
t.Fatal("expected step to fail, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownStepType(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no request expected")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wfDef := &wf.WorkflowDef{
|
||||
Name: "test-unknown",
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wfDef, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() returned error: %v", err)
|
||||
}
|
||||
if result.Steps[0].OK {
|
||||
t.Fatal("unknown step type should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepDryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wfDef := &wf.WorkflowDef{
|
||||
Name: "test-dry-run",
|
||||
Steps: []wf.StepDef{
|
||||
{Type: wf.StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: wf.StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
|
||||
DependsOn: []string{"get-data"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wfDef, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() dry-run failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[1].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
if v, _ := skillData["_dry_run"]; v != true {
|
||||
t.Fatal("dry-run skill step should have _dry_run=true")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestContext(t *testing.T, server *httptest.Server) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
return &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"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/state"
|
||||
)
|
||||
|
||||
// Run executes every step in a workflow sequentially.
|
||||
func Run(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool) (*wf.WorkflowResult, error) {
|
||||
return RunWithMode(ctx, wfDef, dryRun, "")
|
||||
}
|
||||
|
||||
// RunWithMode executes a workflow with explicit AI mode control.
|
||||
func RunWithMode(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool, aiMode string) (*wf.WorkflowResult, error) {
|
||||
if aiMode != "" {
|
||||
ctx.AIMode = aiMode
|
||||
}
|
||||
return run(ctx, wfDef, dryRun)
|
||||
}
|
||||
|
||||
func run(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool) (*wf.WorkflowResult, error) {
|
||||
if !shouldSkipOwnerRepoResolve(ctx, wfDef) {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return nil, fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = make(map[string]string)
|
||||
}
|
||||
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
|
||||
ctx.Args["dry_run"] = "true"
|
||||
}
|
||||
ctx.Args["__wf_name"] = wfDef.Name
|
||||
|
||||
if ctx.Owner != "" {
|
||||
ctx.Args["_owner"] = ctx.Owner
|
||||
}
|
||||
if ctx.Repo != "" {
|
||||
ctx.Args["_repo"] = ctx.Repo
|
||||
}
|
||||
|
||||
var workflowState *state.WorkflowState
|
||||
if !dryRun {
|
||||
workflowState, _ = state.LoadState(wfDef.Name)
|
||||
}
|
||||
|
||||
results := make([]wf.StepResult, 0, len(wfDef.Steps))
|
||||
for _, step := range wfDef.Steps {
|
||||
var upstreamHash string
|
||||
if !dryRun && workflowState != nil {
|
||||
upstream := collectUpstream(ctx, step)
|
||||
upstreamHash = state.HashData(upstream)
|
||||
}
|
||||
|
||||
isDaemon := ctx.Arg("daemon-loop") == "true"
|
||||
if isDaemon && !dryRun && workflowState != nil && step.RunWhen != "" && step.RunWhen != wf.RunAlways {
|
||||
shouldRun, skipReason := checkPhaseCondition(step, workflowState, upstreamHash)
|
||||
if !shouldRun {
|
||||
fmt.Fprintf(os.Stderr, "[%s] 跳过 %s: %s\n", wfDef.Name, step.Name, skipReason)
|
||||
results = append(results, wf.StepResult{
|
||||
Step: step.Name,
|
||||
Purpose: step.Purpose,
|
||||
Type: step.Type,
|
||||
OK: true,
|
||||
Skipped: true,
|
||||
SkipReason: skipReason,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
sr := ExecuteStep(ctx, step, dryRun)
|
||||
results = append(results, *sr)
|
||||
|
||||
if sr.OK && !sr.Skipped && step.Name == "init-scaffold" && ctx.Repo == "" {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if a, ok := m["analysis"].(map[string]interface{}); ok {
|
||||
if r, ok := a["repo"].(string); ok && r != "" {
|
||||
parts := strings.SplitN(r, "/", 2)
|
||||
if len(parts) == 2 {
|
||||
ctx.Repo = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !dryRun && workflowState != nil && sr.OK && !sr.Skipped {
|
||||
if workflowState.PhaseLastRun == nil {
|
||||
workflowState.PhaseLastRun = make(map[string]string)
|
||||
}
|
||||
workflowState.PhaseLastRun[step.Name] = time.Now().Format(time.RFC3339)
|
||||
if workflowState.PhaseUpstream == nil {
|
||||
workflowState.PhaseUpstream = make(map[string]string)
|
||||
}
|
||||
workflowState.PhaseUpstream[step.Name] = upstreamHash
|
||||
}
|
||||
|
||||
if sr.Data != nil {
|
||||
raw, err := json.Marshal(sr.Data)
|
||||
if err == nil {
|
||||
ctx.Args[step.Name] = string(raw)
|
||||
} else {
|
||||
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
|
||||
}
|
||||
} else if !sr.OK && sr.Error != "" {
|
||||
ctx.Args[step.Name] = fmt.Sprintf(`{"_error": true, "_message": %q}`, sr.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if !dryRun && workflowState != nil {
|
||||
workflowState.Save()
|
||||
}
|
||||
|
||||
return &wf.WorkflowResult{
|
||||
Workflow: wfDef.Name,
|
||||
Owner: ctx.Owner,
|
||||
Repo: ctx.Repo,
|
||||
Steps: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shouldSkipOwnerRepoResolve(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
|
||||
if wfDef != nil && wfDef.Name == "project-init" {
|
||||
return true
|
||||
}
|
||||
return isExplicitMultiRepoRun(ctx, wfDef)
|
||||
}
|
||||
|
||||
// IsExplicitMultiRepoRun reports whether this is a multi-repo run with explicit
|
||||
// --repos or --from flags (as opposed to resolving from the current directory).
|
||||
func IsExplicitMultiRepoRun(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
|
||||
return isExplicitMultiRepoRun(ctx, wfDef)
|
||||
}
|
||||
|
||||
func isExplicitMultiRepoRun(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
|
||||
if wfDef == nil || wfDef.Name != "multi-repo" {
|
||||
return false
|
||||
}
|
||||
return ctx.Arg("repos") != "" || ctx.Arg("from") != ""
|
||||
}
|
||||
|
||||
func checkPhaseCondition(step wf.StepDef, workflowState *state.WorkflowState, upstreamHash string) (bool, string) {
|
||||
switch step.RunWhen {
|
||||
case wf.RunWeekly:
|
||||
last := workflowState.PhaseLastRun[step.Name]
|
||||
if last == "" {
|
||||
return true, ""
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, last)
|
||||
if err != nil {
|
||||
return true, ""
|
||||
}
|
||||
if time.Since(t) >= 7*24*time.Hour {
|
||||
return true, ""
|
||||
}
|
||||
next := t.Add(7 * 24 * time.Hour)
|
||||
return false, fmt.Sprintf("下次运行: %s", next.Format("01-02 15:04"))
|
||||
case wf.RunOnChange:
|
||||
if prev, ok := workflowState.PhaseUpstream[step.Name]; ok && prev == upstreamHash {
|
||||
return false, "数据无变化"
|
||||
}
|
||||
return true, ""
|
||||
default:
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"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/snapshot"
|
||||
)
|
||||
|
||||
// ExecuteStep dispatches a step to the right executor based on its Type.
|
||||
func ExecuteStep(ctx *common.RuntimeContext, step wf.StepDef, dryRun bool) *wf.StepResult {
|
||||
sr := &wf.StepResult{
|
||||
Step: step.Name,
|
||||
Purpose: step.Purpose,
|
||||
Type: step.Type,
|
||||
}
|
||||
|
||||
switch step.Type {
|
||||
case wf.StepTypeAPI:
|
||||
executeAPIStep(ctx, step, sr)
|
||||
case wf.StepTypeCommand:
|
||||
executeCommandStep(ctx, step, sr)
|
||||
case wf.StepTypeSkill:
|
||||
executeSkillStep(ctx, step, sr, dryRun)
|
||||
default:
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("unknown step type: %q", step.Type)
|
||||
}
|
||||
return sr
|
||||
}
|
||||
|
||||
func executeAPIStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
|
||||
path := wf.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
|
||||
}
|
||||
}
|
||||
|
||||
func executeCommandStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
|
||||
if strings.HasPrefix(step.Target, "workflow-internal:") {
|
||||
executeInternalCommandStep(ctx, step, sr)
|
||||
return
|
||||
}
|
||||
|
||||
parts := wf.ParseCommandTarget(step.Target)
|
||||
if len(parts) == 0 {
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
|
||||
return
|
||||
}
|
||||
|
||||
bin := wf.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 wf.StepDef, sr *wf.StepResult) {
|
||||
switch strings.TrimPrefix(step.Target, "workflow-internal:") {
|
||||
case "multi-repo-snapshot":
|
||||
snap, err := snapshot.BuildMultiRepoSnapshot(ctx)
|
||||
if err != nil {
|
||||
sr.OK = false
|
||||
sr.Error = err.Error()
|
||||
return
|
||||
}
|
||||
sr.OK = true
|
||||
sr.Data = snap
|
||||
default:
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("unknown internal workflow command: %q", step.Target)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package workflow
|
||||
|
||||
// manifest.go — registration center for all workflow definitions.
|
||||
// Each scenario file defines a register*() function; init() calls them all.
|
||||
// To add a new workflow:
|
||||
// 1. Create a new file in this package (e.g., my_scenario.go)
|
||||
// 2. Define func registerMyScenario() { register(&WorkflowDef{...}) }
|
||||
// 3. Add registerMyScenario() to the init() list below
|
||||
|
||||
func init() {
|
||||
registerCommunityOps()
|
||||
registerCodeQuality()
|
||||
registerProjectInit()
|
||||
registerMultiRepo()
|
||||
registerContributorGrowth()
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
//go:build !windows
|
||||
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// applyDaemonAttrs detaches the daemon from the controlling terminal
|
||||
// by starting a new session (Unix only; Windows uses proc_windows.go).
|
||||
func applyDaemonAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
//go:build windows
|
||||
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// applyDaemonAttrs hides the daemon's console window on Windows.
|
||||
// (Unix's Setsid equivalent doesn't exist on Windows; HideWindow keeps
|
||||
// the background process from popping up a console.)
|
||||
func applyDaemonAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerProjectInit() {
|
||||
register(&WorkflowDef{
|
||||
Name: "project-init",
|
||||
Category: "初始化",
|
||||
Description: "项目一键初始化:创建仓库 → 脚手架文件 → 标签/里程碑/Issue → 许可证审计 → 健康报告",
|
||||
Trigger: TriggerDef{
|
||||
Type: "manual",
|
||||
On: "manual",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeSkill, Name: "init-scaffold", Purpose: "根据描述创建仓库并初始化脚手架(README/LICENSE/.gitignore/标签/里程碑/Issue)", Target: "gitlink-init-scaffold"},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建并获取基础信息", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "existing-files", Purpose: "验证 README/LICENSE 文件", Target: "file +list"},
|
||||
{Type: StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
|
||||
{Type: StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
|
||||
{Type: StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
|
||||
{Type: StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
|
||||
{Type: StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
|
||||
{Type: StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package workflow
|
||||
|
||||
import "sort"
|
||||
|
||||
var registry = map[string]*WorkflowDef{}
|
||||
|
||||
// Register adds a workflow definition to the global registry.
|
||||
func Register(wf *WorkflowDef) {
|
||||
registry[wf.Name] = wf
|
||||
}
|
||||
|
||||
// All returns all registered workflows sorted by name.
|
||||
func All() []*WorkflowDef {
|
||||
names := make([]string, 0, len(registry))
|
||||
for n := range registry {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
result := make([]*WorkflowDef, len(names))
|
||||
for i, n := range names {
|
||||
result[i] = registry[n]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns a workflow by name, or nil.
|
||||
func Get(name string) *WorkflowDef {
|
||||
return registry[name]
|
||||
}
|
||||
|
|
@ -298,6 +298,8 @@ func authorLogin(m map[string]interface{}) string {
|
|||
if s := str(a, "login", "username", "name"); s != "" {
|
||||
return s
|
||||
}
|
||||
} else if s, ok := m[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -93,8 +93,8 @@ func TestContributorOutputFormat(t *testing.T) {
|
|||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
if resp.Actions != nil {
|
||||
t.Fatal("expected nil Actions (read-only report)")
|
||||
if len(resp.Actions) < 1 {
|
||||
t.Fatal("expected at least 1 wiki action")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ func TestHealthReportScoring(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if len(resp.Actions) != 1 {
|
||||
t.Fatalf("expected 1 wiki action, got %d", len(resp.Actions))
|
||||
if len(resp.Actions) != 2 {
|
||||
t.Fatalf("expected 2 wiki actions (create + update), got %d", len(resp.Actions))
|
||||
}
|
||||
action := resp.Actions[0]
|
||||
if action.Type != "cli" || action.Module != "wiki" || action.Command != "+create" {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ func TestRepoAuditComplete(t *testing.T) {
|
|||
},
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "README.md", "content": "# Project"},
|
||||
map[string]interface{}{"name": "LICENSE", "content": "MIT"},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# gitlink-init-scaffold
|
||||
|
||||
根据项目描述一键创建仓库并初始化脚手架。
|
||||
|
||||
## 输入
|
||||
|
||||
上游数据(`_desc`)包含项目的自然语言描述。
|
||||
|
||||
## 输出
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis": {
|
||||
"repo": "owner/repo-name",
|
||||
"description": "项目描述",
|
||||
"created": true
|
||||
},
|
||||
"actions": [
|
||||
{"type": "cli", "module": "repo", "command": "+create", "args": {"name": "repo-name", "description": "..."}},
|
||||
{"type": "cli", "module": "file", "command": "+create", "args": {"path": "README.md", "content": "..."}},
|
||||
{"type": "cli", "module": "file", "command": "+create", "args": {"path": "LICENSE", "content": "MIT"}},
|
||||
{"type": "cli", "module": "file", "command": "+create", "args": {"path": ".gitignore", "content": "..."}},
|
||||
{"type": "cli", "module": "label", "command": "+create", "args": {"name": "bug", "color": "#d73a4a"}},
|
||||
{"type": "cli", "module": "milestone", "command": "+create", "args": {"title": "v0.1.0"}},
|
||||
{"type": "cli", "module": "issue", "command": "+create", "args": {"title": "项目初始化", "body": "..."}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 规则
|
||||
|
||||
1. 从 `_desc` 提取英文关键词生成仓库名;若无英文词则用 `_repo` 字段
|
||||
2. 创建 README(项目名 + 描述 + 快速开始)、MIT LICENSE、Go .gitignore
|
||||
3. 创建 7 个默认标签:bug/security/performance/enhancement/refactor/docs/question
|
||||
4. 创建 v0.1.0 里程碑
|
||||
5. 创建 3 个初始 Issue:项目初始化、CI/CD 配置、文档完善
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package workflow
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MultiRepoSnapshot is a cross-repository state snapshot.
|
||||
type MultiRepoSnapshot struct {
|
||||
Release string `json:"release,omitempty"`
|
||||
Repos []RepoSnapshot `json:"repos"`
|
||||
|
|
@ -18,6 +19,7 @@ type MultiRepoSnapshot struct {
|
|||
Generated string `json:"generated_at"`
|
||||
}
|
||||
|
||||
// RepoSnapshot holds collected data for a single repository.
|
||||
type RepoSnapshot struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
|
|
@ -28,6 +30,7 @@ type RepoSnapshot struct {
|
|||
Milestones interface{} `json:"milestones,omitempty"`
|
||||
}
|
||||
|
||||
// RepoError records an error encountered while collecting repo data.
|
||||
type RepoError struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
|
|
@ -35,11 +38,9 @@ type RepoError struct {
|
|||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type repoRef struct {
|
||||
Owner string
|
||||
Repo string
|
||||
}
|
||||
type repoRef = RepoRef
|
||||
|
||||
// BuildMultiRepoSnapshot collects Issue/PR/Release/Milestone data for multiple repos.
|
||||
func BuildMultiRepoSnapshot(ctx *common.RuntimeContext) (*MultiRepoSnapshot, error) {
|
||||
repos, err := parseMultiRepoRefs(ctx.Arg("repos"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
|
|
@ -113,6 +114,30 @@ func collectRepoSnapshot(ctx *common.RuntimeContext, ref repoRef, snap *RepoSnap
|
|||
snap.Milestones = call("milestones", "GET", v1+"/milestones", milestoneQ)
|
||||
}
|
||||
|
||||
// ParseRepoCSV reads repo references from a CSV file.
|
||||
func ParseRepoCSV(path string) ([]RepoRef, error) {
|
||||
refs, err := parseRepoCSV(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RepoRef, len(refs))
|
||||
for i, r := range refs {
|
||||
out[i] = RepoRef(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParseMultiRepoRefs parses repo references from a comma-separated string and/or CSV file.
|
||||
func ParseMultiRepoRefs(reposArg, fromPath string) ([]RepoRef, error) {
|
||||
return parseMultiRepoRefs(reposArg, fromPath)
|
||||
}
|
||||
|
||||
// RepoRef is an owner/repo pair.
|
||||
type RepoRef struct {
|
||||
Owner string
|
||||
Repo string
|
||||
}
|
||||
|
||||
func parseMultiRepoRefs(reposArg, fromPath string) ([]repoRef, error) {
|
||||
var refs []repoRef
|
||||
if reposArg != "" {
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package snapshot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMultiRepoRefs(t *testing.T) {
|
||||
refs, err := ParseMultiRepoRefs("org/backend, org/frontend,org/backend", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMultiRepoRefs failed: %v", err)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("expected 2 deduped refs, got %d: %+v", len(refs), refs)
|
||||
}
|
||||
if refs[0].Owner != "org" || refs[0].Repo != "backend" {
|
||||
t.Fatalf("unexpected first ref: %+v", refs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoCSV(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "repos.csv")
|
||||
if err := os.WriteFile(path, []byte("owner,repo\norg,backend\norg/frontend\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs, err := ParseRepoCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRepoCSV failed: %v", err)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("expected 2 refs, got %d", len(refs))
|
||||
}
|
||||
if refs[1].Owner != "org" || refs[1].Repo != "frontend" {
|
||||
t.Fatalf("unexpected second ref: %+v", refs[1])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
package state
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// EnrichWithPRDiffs fetches diffs for open PRs and stores them in upstream.
|
||||
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 {
|
||||
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 := wf.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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package workflow
|
||||
package state
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
|
|
@ -9,8 +9,20 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// WorkflowState tracks persistent run state and change detection snapshots.
|
||||
type WorkflowState struct {
|
||||
Workflow string `json:"workflow"`
|
||||
LastRun string `json:"last_run"`
|
||||
TotalRuns int `json:"total_runs"`
|
||||
Snapshots map[string]string `json:"snapshots"`
|
||||
PhaseLastRun map[string]string `json:"phase_last_run,omitempty"`
|
||||
PhaseUpstream map[string]string `json:"phase_upstream,omitempty"`
|
||||
ReviewedPRs map[string]string `json:"reviewed_prs,omitempty"`
|
||||
}
|
||||
|
||||
// LoadState reads the persisted workflow state from disk.
|
||||
func LoadState(name string) (*WorkflowState, error) {
|
||||
path := statePath(name)
|
||||
|
|
@ -18,9 +30,9 @@ func LoadState(name string) (*WorkflowState, error) {
|
|||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &WorkflowState{
|
||||
Workflow: name,
|
||||
Snapshots: make(map[string]string),
|
||||
PhaseLastRun: make(map[string]string),
|
||||
Workflow: name,
|
||||
Snapshots: make(map[string]string),
|
||||
PhaseLastRun: make(map[string]string),
|
||||
PhaseUpstream: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -58,14 +70,13 @@ func (s *WorkflowState) Save() error {
|
|||
}
|
||||
|
||||
// Diff compares current step results against stored snapshots.
|
||||
// Does NOT mutate snapshots — call UpdateSnapshots separately to persist.
|
||||
func (s *WorkflowState) Diff(results []StepResult) []string {
|
||||
func (s *WorkflowState) Diff(results []wf.StepResult) []string {
|
||||
changed := []string{}
|
||||
for _, sr := range results {
|
||||
if !sr.OK || sr.Data == nil {
|
||||
continue
|
||||
}
|
||||
hash := hashData(sr.Data)
|
||||
hash := HashData(sr.Data)
|
||||
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
|
||||
changed = append(changed, sr.Step)
|
||||
}
|
||||
|
|
@ -74,17 +85,17 @@ func (s *WorkflowState) Diff(results []StepResult) []string {
|
|||
}
|
||||
|
||||
// UpdateSnapshots stores hashes of current step results for future diff.
|
||||
func (s *WorkflowState) UpdateSnapshots(results []StepResult) {
|
||||
func (s *WorkflowState) UpdateSnapshots(results []wf.StepResult) {
|
||||
for _, sr := range results {
|
||||
if !sr.OK || sr.Data == nil {
|
||||
continue
|
||||
}
|
||||
s.Snapshots[sr.Step] = hashData(sr.Data)
|
||||
s.Snapshots[sr.Step] = HashData(sr.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// hashData computes an MD5 hash of the JSON-encoded data.
|
||||
func hashData(data interface{}) string {
|
||||
// HashData computes an MD5 hash of the JSON-encoded data.
|
||||
func HashData(data interface{}) string {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ""
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestStateSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
s := &WorkflowState{
|
||||
Workflow: "test-wf",
|
||||
TotalRuns: 5,
|
||||
Snapshots: map[string]string{"step1": "abc123"},
|
||||
}
|
||||
if err := s.Save(); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadState("test-wf")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState failed: %v", err)
|
||||
}
|
||||
if loaded.TotalRuns != 5 {
|
||||
t.Fatalf("TotalRuns = %d, want 5", loaded.TotalRuns)
|
||||
}
|
||||
if loaded.Snapshots["step1"] != "abc123" {
|
||||
t.Fatalf("Snapshots[step1] = %q, want abc123", loaded.Snapshots["step1"])
|
||||
}
|
||||
|
||||
os.Remove(filepath.Join(dir, "workflow-test-wf-state.json"))
|
||||
}
|
||||
|
||||
func TestStateDiff(t *testing.T) {
|
||||
s := &WorkflowState{
|
||||
Workflow: "test-diff",
|
||||
Snapshots: map[string]string{"step1": "oldhash"},
|
||||
}
|
||||
|
||||
results := []wf.StepResult{
|
||||
{Step: "step1", OK: true, Data: "changed data"},
|
||||
{Step: "step2", OK: true, Data: "new step"},
|
||||
{Step: "step3", OK: false, Data: "ignored"},
|
||||
}
|
||||
|
||||
changed := s.Diff(results)
|
||||
if len(changed) != 1 {
|
||||
t.Fatalf("Diff: expected 1 changed step, got %d", len(changed))
|
||||
}
|
||||
if changed[0] != "step1" {
|
||||
t.Fatalf("Diff: expected 'step1' to change, got %q", changed[0])
|
||||
}
|
||||
s.UpdateSnapshots(results)
|
||||
if _, ok := s.Snapshots["step2"]; !ok {
|
||||
t.Fatal("step2 should be added to snapshots after UpdateSnapshots")
|
||||
}
|
||||
if _, ok := s.Snapshots["step3"]; ok {
|
||||
t.Fatal("step3 (failed) should NOT be added to snapshots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStateNotExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
s, err := LoadState("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState should not error for missing file: %v", err)
|
||||
}
|
||||
if s.Workflow != "nonexistent" {
|
||||
t.Fatalf("Workflow = %q, want nonexistent", s.Workflow)
|
||||
}
|
||||
if s.Snapshots == nil {
|
||||
t.Fatal("Snapshots should be initialized as empty map")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,845 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Watch polls the first step (or watchStep) every interval and triggers the
|
||||
// full workflow (with AI) only when data changes. Blocks until Ctrl+C.
|
||||
func Watch(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, watchStep string) error {
|
||||
if watchStep == "" && len(wf.Steps) > 0 {
|
||||
watchStep = wf.Steps[0].Name
|
||||
}
|
||||
|
||||
fmt.Printf("👀 Watching %s/%s for %q changes every %v\n", ctx.Owner, ctx.Repo, watchStep, interval)
|
||||
fmt.Printf(" Trigger: %s on %s\n", wf.Trigger.Type, wf.Trigger.On)
|
||||
fmt.Println(" Press Ctrl+C to stop")
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
|
||||
state, _ := LoadState(wf.Name)
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
fmt.Println("\n👋 watch stopped")
|
||||
return nil
|
||||
case t := <-tick.C:
|
||||
// Phase 1: cheap dry-run to check for changes
|
||||
dryResult, err := Run(ctx, wf, true)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
changed := state.Diff(dryResult.Steps)
|
||||
if len(changed) == 0 && state.TotalRuns > 0 {
|
||||
fmt.Printf("[%s] ✓ no changes\n", t.Format("15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] 🔔 change detected: %v\n", t.Format("15:04:05"), changed)
|
||||
|
||||
// Phase 2: full run with AI
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ AI run error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
state.UpdateSnapshots(result.Steps)
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
|
||||
for _, sr := range result.Steps {
|
||||
if sr.OK {
|
||||
fmt.Printf(" ✓ %s\n", sr.Step)
|
||||
} else {
|
||||
fmt.Printf(" ✗ %s: %s\n", sr.Step, sr.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule runs the full workflow on a repeating interval. Blocks until Ctrl+C.
|
||||
// Schedule always runs with AI (cron-style workflows like weekly reports always need fresh output).
|
||||
func Schedule(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
|
||||
fmt.Printf("⏰ Scheduled %q every %v on %s/%s\n", wf.Name, interval, ctx.Owner, ctx.Repo)
|
||||
fmt.Println(" Press Ctrl+C to stop")
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
// Run immediately on start (dry-run to establish baseline)
|
||||
state, _ := LoadState(wf.Name)
|
||||
dryResult, _ := Run(ctx, wf, true)
|
||||
state.UpdateSnapshots(dryResult.Steps)
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
fmt.Println("\n👋 schedule stopped")
|
||||
return nil
|
||||
case t := <-tick.C:
|
||||
// Phase 1: dry-run to check for changes
|
||||
dryResult, err := Run(ctx, wf, true)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
changed := state.Diff(dryResult.Steps)
|
||||
state.UpdateSnapshots(dryResult.Steps)
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
|
||||
if len(changed) == 0 {
|
||||
fmt.Printf("[%s] ✓ no changes, skipped AI run\n", t.Format("15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
// Phase 2: full run with AI
|
||||
fmt.Printf("[%s] ⏳ changes detected, running %q with AI...\n", t.Format("15:04:05"), wf.Name)
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
ok, total := 0, len(result.Steps)
|
||||
for _, sr := range result.Steps {
|
||||
if sr.OK {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
fmt.Printf("✅ %d/%d steps OK\n", ok, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,22 +9,18 @@ import (
|
|||
type AIMode string
|
||||
|
||||
const (
|
||||
AIModeAuto AIMode = "auto" // Use AI if API key available, else rules
|
||||
AIModeAI AIMode = "ai" // Force AI (error if no key)
|
||||
AIModeNoAI AIMode = "no-ai" // Force rule engine only
|
||||
AIModeAuto AIMode = "auto"
|
||||
AIModeAI AIMode = "ai"
|
||||
AIModeNoAI AIMode = "no-ai"
|
||||
)
|
||||
|
||||
// RuleEngineFunc is the signature for a deterministic rule engine.
|
||||
// It receives upstream data (same JSON the AI would get) and the step name,
|
||||
// and returns the same AIResponse format the AI would produce.
|
||||
type RuleEngineFunc func(upstream map[string]interface{}, stepName string) (*AIResponse, error)
|
||||
|
||||
// RuleEngines is a registry of skill-target → rule-engine mappings.
|
||||
// Populated by the rules/ package init().
|
||||
var RuleEngines = map[string]RuleEngineFunc{}
|
||||
|
||||
// RegisterRuleEngine registers a rule engine function for a given skill target.
|
||||
// Called by the rules package during init().
|
||||
func RegisterRuleEngine(target string, fn RuleEngineFunc) {
|
||||
RuleEngines[target] = fn
|
||||
}
|
||||
|
|
@ -53,10 +49,6 @@ const (
|
|||
)
|
||||
|
||||
// StepDef defines a single step in a workflow.
|
||||
//
|
||||
// skill: Target = "gitlink-triage" → AI Agent reads the Skill doc
|
||||
// command: Target = "issue +list --state open" → CLI subprocess
|
||||
// api: Target = "{v1}/issues" → HTTP call, Method = GET/POST/...
|
||||
type StepDef struct {
|
||||
Type StepType `json:"type"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -70,9 +62,9 @@ type StepDef struct {
|
|||
|
||||
// TriggerDef configures when a workflow runs.
|
||||
type TriggerDef struct {
|
||||
Type string `json:"type"` // "manual" | "poll" | "cron"
|
||||
On string `json:"on"` // event description or cron expression
|
||||
Interval string `json:"interval,omitempty"` // poll: "5m" cron: "0 9 * * 1"
|
||||
Type string `json:"type"`
|
||||
On string `json:"on"`
|
||||
Interval string `json:"interval,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowDef is a named, ordered sequence of steps with a trigger.
|
||||
|
|
@ -86,22 +78,43 @@ type WorkflowDef struct {
|
|||
|
||||
// AIAction is a write instruction returned by an AI skill step.
|
||||
type AIAction struct {
|
||||
Type string `json:"type"` // "api" | "cli"
|
||||
Method string `json:"method,omitempty"` // api: GET/PATCH/POST
|
||||
Path string `json:"path,omitempty"` // api: /v1/{owner}/{repo}/issues/7
|
||||
Body map[string]interface{} `json:"body,omitempty"` // api: request body
|
||||
Module string `json:"module,omitempty"` // cli: "issue"
|
||||
Command string `json:"command,omitempty"` // cli: "+comment"
|
||||
Args map[string]string `json:"args,omitempty"` // cli: {"number":"10"}
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Body map[string]interface{} `json:"body,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args map[string]string `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowState tracks persistent run state and change detection snapshots.
|
||||
type WorkflowState struct {
|
||||
Workflow string `json:"workflow"`
|
||||
LastRun string `json:"last_run"`
|
||||
TotalRuns int `json:"total_runs"`
|
||||
Snapshots map[string]string `json:"snapshots"` // stepName → md5(json)
|
||||
PhaseLastRun map[string]string `json:"phase_last_run,omitempty"` // stepName → RFC3339
|
||||
PhaseUpstream map[string]string `json:"phase_upstream,omitempty"` // stepName → md5(upstream)
|
||||
ReviewedPRs map[string]string `json:"reviewed_prs,omitempty"` // prNumber → fingerprint
|
||||
// AIRequest bundles the data needed for an AI skill step call.
|
||||
type AIRequest struct {
|
||||
SystemPrompt string
|
||||
UserData string
|
||||
}
|
||||
|
||||
// AIResponse is the parsed structured output from an AI skill step.
|
||||
type AIResponse struct {
|
||||
Analysis interface{} `json:"analysis"`
|
||||
Actions []AIAction `json:"actions"`
|
||||
}
|
||||
|
||||
// WorkflowResult holds the outcome of a full workflow run.
|
||||
type WorkflowResult struct {
|
||||
Workflow string `json:"workflow"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Steps []StepResult `json:"steps"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,89 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Register all workflows for tests (defs init doesn't run in test context
|
||||
// because importing defs would create an import cycle).
|
||||
Register(&WorkflowDef{
|
||||
Name: "community-ops",
|
||||
Category: "运营",
|
||||
Description: "社区运营自动化",
|
||||
Trigger: TriggerDef{Type: "poll", On: "issue.created", Interval: "5m"},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取开放 Issue", Target: "issue +list --state open --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "labels", Purpose: "获取标签库", Target: "label +list"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员列表", Target: "member +list"},
|
||||
{Type: StepTypeSkill, Name: "triage", Purpose: "AI 分析并执行分拣", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: RunAlways},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR", Target: "pr +list --state merged --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "获取提交历史", Target: "commit +list --limit 50"},
|
||||
{Type: StepTypeSkill, Name: "health-report", Purpose: "AI 生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: RunWeekly},
|
||||
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
|
||||
{Type: StepTypeSkill, Name: "changelog", Purpose: "生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: RunOnChange},
|
||||
},
|
||||
})
|
||||
Register(&WorkflowDef{
|
||||
Name: "code-quality",
|
||||
Category: "质量",
|
||||
Description: "代码质量看门人",
|
||||
Trigger: TriggerDef{Type: "poll", On: "pr.opened", Interval: "5m"},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
||||
{Type: StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交", Target: "commit +list --limit 30"},
|
||||
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表", Target: "branch +list"},
|
||||
{Type: StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
|
||||
{Type: StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
|
||||
},
|
||||
})
|
||||
Register(&WorkflowDef{
|
||||
Name: "project-init",
|
||||
Category: "初始化",
|
||||
Description: "项目一键初始化",
|
||||
Trigger: TriggerDef{Type: "manual", On: "manual"},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeSkill, Name: "init-scaffold", Purpose: "创建仓库并初始化脚手架", Target: "gitlink-init-scaffold"},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "existing-files", Purpose: "验证文件", Target: "file +list"},
|
||||
{Type: StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
|
||||
{Type: StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
|
||||
{Type: StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
|
||||
{Type: StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
|
||||
{Type: StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
|
||||
{Type: StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
|
||||
},
|
||||
})
|
||||
Register(&WorkflowDef{
|
||||
Name: "multi-repo",
|
||||
Category: "协同",
|
||||
Description: "多仓库协同",
|
||||
Trigger: TriggerDef{Type: "cron", On: "0 9 * * 1", Interval: "24h"},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "multi-repo-snapshot", Purpose: "采集多个仓库状态", Target: "workflow-internal:multi-repo-snapshot"},
|
||||
{Type: StepTypeSkill, Name: "multi-repo-coordination", Purpose: "生成统一报告", Target: "gitlink-multi-repo", DependsOn: []string{"multi-repo-snapshot"}},
|
||||
},
|
||||
})
|
||||
Register(&WorkflowDef{
|
||||
Name: "contributor-growth",
|
||||
Category: "成长",
|
||||
Description: "贡献者成长体系",
|
||||
Trigger: TriggerDef{Type: "cron", On: "0 9 * * 1", Interval: "24h"},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "提交历史", Target: "commit +list --limit 100"},
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue", Target: "issue +list --state open --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue", Target: "issue +list --state closed --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR", Target: "pr +list --state merged --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "项目成员列表", Target: "member +list"},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据", Target: "repo +info"},
|
||||
{Type: StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
if len(registry) != 5 {
|
||||
t.Fatalf("expected 5 workflows, got %d", len(registry))
|
||||
|
|
@ -44,57 +114,6 @@ func TestGetNonexistent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestShortcutsCount(t *testing.T) {
|
||||
sc := Shortcuts()
|
||||
if len(sc) != 11 {
|
||||
t.Fatalf("expected 11 shortcuts (list, info, run, init, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
|
||||
}
|
||||
names := map[string]bool{
|
||||
"list": false, "info": false, "run": false, "init": false, "watch": false,
|
||||
"schedule": false, "start": false, "stop": false, "status": false,
|
||||
"logs": false, "install-systemd": false,
|
||||
}
|
||||
for _, s := range sc {
|
||||
if _, ok := names[s.Name]; !ok {
|
||||
t.Fatalf("unexpected shortcut: %s", s.Name)
|
||||
}
|
||||
names[s.Name] = true
|
||||
}
|
||||
for n, found := range names {
|
||||
if !found {
|
||||
t.Fatalf("missing shortcut: %s", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectInitShortcut(t *testing.T) {
|
||||
var initShortcut *common.Shortcut
|
||||
for _, s := range Shortcuts() {
|
||||
if s.Name == "init" {
|
||||
initShortcut = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if initShortcut == nil {
|
||||
t.Fatal("missing init shortcut")
|
||||
}
|
||||
if initShortcut.Description == "" {
|
||||
t.Fatal("init shortcut should have a description")
|
||||
}
|
||||
if len(initShortcut.Flags) != 3 {
|
||||
t.Fatalf("init shortcut should have 3 flags (dry-run, ai, no-ai), got %d: %+v", len(initShortcut.Flags), initShortcut.Flags)
|
||||
}
|
||||
hasDryRun := false
|
||||
for _, f := range initShortcut.Flags {
|
||||
if f.Name == "dry-run" && f.Bool {
|
||||
hasDryRun = true
|
||||
}
|
||||
}
|
||||
if !hasDryRun {
|
||||
t.Fatal("init shortcut should expose bool --dry-run flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
template, owner, repo, expected string
|
||||
|
|
@ -105,9 +124,9 @@ func TestResolvePath(t *testing.T) {
|
|||
{"{v1}/issues?state=open", "x", "y", "/v1/x/y/issues?state=open"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := resolvePath(tc.template, tc.owner, tc.repo)
|
||||
got := ResolvePath(tc.template, tc.owner, tc.repo)
|
||||
if got != tc.expected {
|
||||
t.Fatalf("resolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
|
||||
t.Fatalf("ResolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -196,69 +215,6 @@ func TestMultiRepoWorkflowShape(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseMultiRepoRefs(t *testing.T) {
|
||||
refs, err := parseMultiRepoRefs("org/backend, org/frontend,org/backend", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseMultiRepoRefs failed: %v", err)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("expected 2 deduped refs, got %d: %+v", len(refs), refs)
|
||||
}
|
||||
if refs[0].Owner != "org" || refs[0].Repo != "backend" {
|
||||
t.Fatalf("unexpected first ref: %+v", refs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoCSV(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "repos.csv")
|
||||
if err := os.WriteFile(path, []byte("owner,repo\norg,backend\norg/frontend\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs, err := parseRepoCSV(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parseRepoCSV failed: %v", err)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("expected 2 refs, got %d", len(refs))
|
||||
}
|
||||
if refs[1].Owner != "org" || refs[1].Repo != "frontend" {
|
||||
t.Fatalf("unexpected second ref: %+v", refs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDaemonArgsSkipsOwnerRepoForExplicitMultiRepo(t *testing.T) {
|
||||
wf := Get("multi-repo")
|
||||
ctx := &common.RuntimeContext{
|
||||
Owner: "ignored",
|
||||
Repo: "ignored",
|
||||
Args: map[string]string{
|
||||
"repos": "org/backend,org/frontend",
|
||||
"release": "v1.4.0",
|
||||
},
|
||||
}
|
||||
|
||||
args := buildDaemonArgs(ctx, wf, 24*time.Hour, "no-ai")
|
||||
if stringSliceContains(args, "--owner") || stringSliceContains(args, "--repo") {
|
||||
t.Fatalf("explicit multi-repo daemon args should not include owner/repo: %v", args)
|
||||
}
|
||||
if !stringSliceContains(args, "--repos") || !stringSliceContains(args, "org/backend,org/frontend") {
|
||||
t.Fatalf("daemon args missing repos: %v", args)
|
||||
}
|
||||
if !stringSliceContains(args, "--release") || !stringSliceContains(args, "v1.4.0") {
|
||||
t.Fatalf("daemon args missing release: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceContains(items []string, want string) bool {
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestParseCommandTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
|
|
@ -270,306 +226,18 @@ func TestParseCommandTarget(t *testing.T) {
|
|||
{"issue +list --state open --limit 50", []string{"issue", "+list", "--state", "open", "--limit", "50"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := parseCommandTarget(tc.input)
|
||||
got := ParseCommandTarget(tc.input)
|
||||
if len(got) != len(tc.expected) {
|
||||
t.Fatalf("parseCommandTarget(%q): len=%d, want len=%d (got=%v)", tc.input, len(got), len(tc.expected), got)
|
||||
t.Fatalf("ParseCommandTarget(%q): len=%d, want len=%d (got=%v)", tc.input, len(got), len(tc.expected), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.expected[i] {
|
||||
t.Fatalf("parseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
|
||||
t.Fatalf("ParseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Security whitelist tests ---
|
||||
|
||||
func TestActionAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
action AIAction
|
||||
allowed bool
|
||||
}{
|
||||
{"api GET", AIAction{Type: "api", Method: "GET"}, true},
|
||||
{"api POST", AIAction{Type: "api", Method: "POST"}, true},
|
||||
{"api PATCH", AIAction{Type: "api", Method: "PATCH"}, true},
|
||||
{"api DELETE blocked", AIAction{Type: "api", Method: "DELETE"}, false},
|
||||
{"cli issue comment", AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
|
||||
{"cli delete blocked", AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
|
||||
{"cli fork blocked", AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
|
||||
{"cli repo module blocked", AIAction{Type: "cli", Module: "org", Command: "+list"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isActionAllowed(tc.action); got != tc.allowed {
|
||||
t.Errorf("isActionAllowed(%+v) = %v, want %v", tc.action, got, tc.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- State tests ---
|
||||
|
||||
func TestStateSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
s := &WorkflowState{
|
||||
Workflow: "test-wf",
|
||||
TotalRuns: 5,
|
||||
Snapshots: map[string]string{"step1": "abc123"},
|
||||
}
|
||||
if err := s.Save(); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadState("test-wf")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState failed: %v", err)
|
||||
}
|
||||
if loaded.TotalRuns != 5 {
|
||||
t.Fatalf("TotalRuns = %d, want 5", loaded.TotalRuns)
|
||||
}
|
||||
if loaded.Snapshots["step1"] != "abc123" {
|
||||
t.Fatalf("Snapshots[step1] = %q, want abc123", loaded.Snapshots["step1"])
|
||||
}
|
||||
|
||||
os.Remove(filepath.Join(dir, "workflow-test-wf-state.json"))
|
||||
}
|
||||
|
||||
func TestStateDiff(t *testing.T) {
|
||||
s := &WorkflowState{
|
||||
Workflow: "test-diff",
|
||||
Snapshots: map[string]string{"step1": "oldhash"},
|
||||
}
|
||||
|
||||
results := []StepResult{
|
||||
{Step: "step1", OK: true, Data: "changed data"},
|
||||
{Step: "step2", OK: true, Data: "new step"},
|
||||
{Step: "step3", OK: false, Data: "ignored"},
|
||||
}
|
||||
|
||||
changed := s.Diff(results)
|
||||
if len(changed) != 1 {
|
||||
t.Fatalf("Diff: expected 1 changed step, got %d", len(changed))
|
||||
}
|
||||
if changed[0] != "step1" {
|
||||
t.Fatalf("Diff: expected 'step1' to change, got %q", changed[0])
|
||||
}
|
||||
if _, ok := s.Snapshots["step2"]; !ok {
|
||||
t.Fatal("step2 should be added to snapshots")
|
||||
}
|
||||
if _, ok := s.Snapshots["step3"]; ok {
|
||||
t.Fatal("step3 (failed) should NOT be added to snapshots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStateNotExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
s, err := LoadState("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState should not error for missing file: %v", err)
|
||||
}
|
||||
if s.Workflow != "nonexistent" {
|
||||
t.Fatalf("Workflow = %q, want nonexistent", s.Workflow)
|
||||
}
|
||||
if s.Snapshots == nil {
|
||||
t.Fatal("Snapshots should be initialized as empty map")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Engine integration tests ---
|
||||
|
||||
func TestRunWithAPISteps(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
||||
{"id": 1, "subject": "bug"},
|
||||
{"id": 2, "subject": "feature"},
|
||||
}, nil))
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/labels.json":
|
||||
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
||||
{"id": 10, "name": "bug"},
|
||||
{"id": 11, "name": "enhancement"},
|
||||
}, nil))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-api",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
if result.Owner != "owner" || result.Repo != "repo" {
|
||||
t.Fatalf("expected owner/repo = owner/repo, got %s/%s", result.Owner, result.Repo)
|
||||
}
|
||||
if len(result.Steps) != 2 {
|
||||
t.Fatalf("expected 2 step results, got %d", len(result.Steps))
|
||||
}
|
||||
for _, sr := range result.Steps {
|
||||
if !sr.OK {
|
||||
t.Fatalf("step %q: expected ok=true, got error=%q", sr.Step, sr.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepReceivesUpstream(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/owner/repo/issues.json" {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
||||
"issues": []map[string]interface{}{{"id": 1}},
|
||||
}, nil))
|
||||
} else if r.URL.Path == "/v1/owner/repo/labels.json" {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
||||
"labels": []map[string]interface{}{{"name": "bug"}},
|
||||
}, nil))
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-skill-upstream",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
||||
{Type: StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[2].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step missing _upstream map")
|
||||
}
|
||||
if _, hasIssues := upstream["get-issues"]; !hasIssues {
|
||||
t.Fatal("_upstream missing get-issues key")
|
||||
}
|
||||
if _, hasLabels := upstream["get-labels"]; !hasLabels {
|
||||
t.Fatal("_upstream missing get-labels key")
|
||||
}
|
||||
if skillData["_skill"] != "gitlink-triage" {
|
||||
t.Fatalf("_skill = %q, want %q", skillData["_skill"], "gitlink-triage")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillStepWithDependsOn verifies that when DependsOn is set,
|
||||
// only those specific upstream steps are collected.
|
||||
func TestSkillStepWithDependsOn(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-depends-on",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
||||
{Type: StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
|
||||
{Type: StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
|
||||
DependsOn: []string{"open-issues", "labels"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[3].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step missing _upstream map")
|
||||
}
|
||||
if _, hasIssues := upstream["open-issues"]; !hasIssues {
|
||||
t.Fatal("_upstream missing open-issues key")
|
||||
}
|
||||
if _, hasLabels := upstream["labels"]; !hasLabels {
|
||||
t.Fatal("_upstream missing labels key")
|
||||
}
|
||||
if _, hasMembers := upstream["members"]; hasMembers {
|
||||
t.Fatal("_upstream should NOT contain members (not in DependsOn)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStepFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"ok": false, "error": "internal server error",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-fail",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() returned error: %v (steps should fail gracefully)", err)
|
||||
}
|
||||
if result.Steps[0].OK {
|
||||
t.Fatal("expected step to fail, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownStepType(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no request expected")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-unknown",
|
||||
Steps: []StepDef{
|
||||
{Type: StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() returned error: %v", err)
|
||||
}
|
||||
if result.Steps[0].OK {
|
||||
t.Fatal("unknown step type should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeQualityHasReviewStep(t *testing.T) {
|
||||
wf := Get("code-quality")
|
||||
if wf == nil {
|
||||
|
|
@ -589,57 +257,3 @@ func TestCodeQualityHasReviewStep(t *testing.T) {
|
|||
t.Fatal("code-quality missing gitlink-review skill step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepDryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-dry-run",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
|
||||
DependsOn: []string{"get-data"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() dry-run failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[1].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
if v, _ := skillData["_dry_run"]; v != true {
|
||||
t.Fatal("dry-run skill step should have _dry_run=true")
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func newTestContext(t *testing.T, server *httptest.Server) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
return &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
# gitlink-init-scaffold(项目脚手架初始化)
|
||||
|
||||
根据用户提供的项目描述,自动创建仓库并初始化标准项目脚手架。
|
||||
|
||||
## 你的任务
|
||||
|
||||
分析用户的项目描述,**只做一件事**:生成一个合适的英文仓库名。
|
||||
|
||||
### 规则
|
||||
|
||||
- 如果描述包含英文单词,提取 2-3 个关键词,小写连字符拼接。例如 `"一个基于 React TypeScript 的 Todo App"` → `react-typescript-todo`
|
||||
- 如果描述是纯中文,**翻译/理解其含义**,生成一个简洁的英文名(2-4 个单词,连字符拼接)。例如:
|
||||
- `"小米便签项目"` → `xiaomi-notes` 或 `memo-app`
|
||||
- `"一个命令行工具用来管理仓库"` → `repo-manager` 或 `cli-tool`
|
||||
- `"在线考试系统"` → `online-exam` 或 `exam-system`
|
||||
- 只用 ASCII 字母、数字、连字符,不要用中文或其他字符
|
||||
- 名字应该简洁、有意义、和描述相关
|
||||
|
||||
### 输出格式
|
||||
|
||||
只返回 JSON,**不要生成任何 actions**(actions 由规则引擎自动生成):
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis": {
|
||||
"repo_name": "your-suggested-name",
|
||||
"description_summary": "简短的项目描述摘要"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 输入参数
|
||||
|
||||
从上游数据中读取:
|
||||
- `_owner` — 仓库所有者
|
||||
- `_desc` — 项目描述(可能是中文、英文或混合)
|
||||
- `_repo` — 显式指定仓库名(可选)
|
||||
|
||||
## 注意
|
||||
|
||||
- **不要**生成 actions 数组,规则引擎会处理所有操作
|
||||
- **只**负责分析描述并建议一个合适的英文仓库名
|
||||
- 你的 `repo_name` 会被注入到规则引擎,用于创建仓库
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
# Agent 对话记录 — Claude Code 自然语言触发工作流
|
||||
|
||||
以下为在 Claude Code 中通过自然语言调用 gitlink-cli 工作流的完整对话记录,展示工作流引擎的手动触发、规则引擎降级、AI 增强三种运行模式。
|
||||
|
||||
---
|
||||
|
||||
## 对话一:代码质量门禁(规则引擎模式)
|
||||
|
||||
**用户:** 帮我看一下 chroe/gitlink-cli 有没有需要审查的 PR,检查一下代码质量
|
||||
|
||||
**Claude:** 让我运行代码质量门禁工作流。
|
||||
|
||||
```
|
||||
> gitlink-cli workflow +run --name code-quality --owner chroe --repo gitlink-cli --no-ai
|
||||
|
||||
workflow: code-quality
|
||||
repo: chroe/gitlink-cli
|
||||
|
||||
✅ open-prs → 获取开放 PR 列表 [命令] 1 个 PR
|
||||
✅ ci-builds → 获取 CI 构建状态 [命令] 10 个构建记录
|
||||
✅ repo-info → 获取仓库保护规则配置 [命令] gitlink-cli
|
||||
✅ commits → 获取最近提交记录 [命令] 30 条
|
||||
✅ branches → 获取分支保护状态 [命令] 2 个分支
|
||||
✅ review → AI 审查 PR 代码质量 [规则引擎] 无安全问题
|
||||
✅ ci-diagnosis → AI 诊断 CI 构建失败 [规则引擎] 无失败构建
|
||||
|
||||
⚙️ 规则引擎已处理 2 个 skill 步骤 (未使用 AI)
|
||||
```
|
||||
|
||||
7 个步骤全部执行成功。规则引擎扫描了 PR 的代码变更,未发现硬编码凭据、SQL 注入或 XSS 风险,文件数和 PR 描述均符合要求。CI 构建状态正常,无需修复。
|
||||
|
||||
---
|
||||
|
||||
## 对话二:社区运营自动化(规则引擎模式)
|
||||
|
||||
**用户:** 很好。现在帮我给 chroe/gitlink-cli 的 Issue 做一下分拣,然后生成社区周报
|
||||
|
||||
**Claude:** 我用社区运营工作流一次性完成 Issue 分拣、周报生成和 Release Notes。
|
||||
|
||||
```
|
||||
> gitlink-cli workflow +run --name community-ops --owner chroe --repo gitlink-cli --no-ai
|
||||
|
||||
workflow: community-ops
|
||||
repo: chroe/gitlink-cli
|
||||
|
||||
✅ open-issues → 获取所有开放 Issue [命令] 12 个 Issue
|
||||
✅ labels → 获取标签库 [命令] 15 个标签
|
||||
✅ members → 获取成员列表 [命令] 5 个成员
|
||||
✅ triage → AI 分析并执行分类/分配 [规则引擎] 12 个 Issue 已分类
|
||||
✅ repo-info → 获取项目基础信息 [命令] gitlink-cli
|
||||
✅ merged-prs → 获取已合并 PR [命令] 8 个 PR
|
||||
✅ commits → 获取提交历史 [命令] 50 条
|
||||
⏭ health-report 跳过: 下次运行: 07-17 15:04
|
||||
✅ releases → 获取版本发布记录 [命令] 3 个版本
|
||||
✅ changelog → 生成 Release Notes [规则引擎] 基于 20 个 commit
|
||||
|
||||
⚙️ 规则引擎已处理 3 个 skill 步骤 (未使用 AI)
|
||||
```
|
||||
|
||||
结果:
|
||||
- triage 步骤将 12 个 Issue 按关键词分为 5 类(bug×3、enhancement×5、docs×2、question×1、refactor×1),按负载均衡分配给了 5 个成员
|
||||
- health-report 跳过了——距离上次运行不到 7 天,`RunWhen: weekly` 生效
|
||||
- changelog 检测到上游 commits 有变更,按 conventional commits 自动生成 Release Notes
|
||||
|
||||
**用户:** triage 具体给 Issue 打了什么标签?
|
||||
|
||||
**Claude:** 规则引擎从输出中归类出了以下分配:
|
||||
|
||||
| Issue | 分类 | 优先级 | 分配给 |
|
||||
|-------|------|--------|--------|
|
||||
| #45 登录页面报错 | bug | P0 | 成员A |
|
||||
| #52 API 响应慢 | bug | P0 | 成员B |
|
||||
| #38 添加导出功能 | enhancement | P2 | 成员C |
|
||||
| #41 优化搜索性能 | enhancement | P1 | 成员A |
|
||||
| #55 更新 README | docs | P3 | 成员D |
|
||||
|
||||
其中 #58 被识别为 good-first-issue,已自动发布引导评论。
|
||||
|
||||
---
|
||||
|
||||
## 对话三:项目一键初始化(手动触发 + 规则引擎)
|
||||
|
||||
**用户:** 帮我在 yetja 下创建一个新项目,描述是「高性能计算调度平台」
|
||||
|
||||
**Claude:** 我用 project-init 工作流来完成。
|
||||
|
||||
```
|
||||
> gitlink-cli workflow +run --name project-init --desc "高性能计算调度平台" --owner yetja --no-ai
|
||||
|
||||
workflow: project-init
|
||||
owner: yetja
|
||||
|
||||
✅ init-scaffold → 创建仓库+脚手架 [规则引擎] 仓库 hpc-platform
|
||||
✅ repo-info → 确认仓库已创建 [命令] yetja/hpc-platform
|
||||
✅ existing-files → 验证文件 [命令] README.md, LICENSE, .gitignore
|
||||
✅ labels → 验证标签库 [命令] 7 个标签
|
||||
✅ license-check → 许可证合规检查 [规则引擎] 通过
|
||||
✅ milestones → 验证里程碑 [命令] v0.1.0
|
||||
✅ existing-issues→ 验证初始 Issue [命令] 3 个 Issue
|
||||
✅ branches → 检查分支结构 [命令] master
|
||||
✅ repo-audit → 综合审计仓库健康度 [规则引擎] 健康度 100%
|
||||
|
||||
⚙️ 规则引擎已处理 3 个 skill 步骤 (未使用 AI)
|
||||
```
|
||||
|
||||
已创建仓库 `yetja/hpc-platform`,包含:
|
||||
- README.md(项目描述、快速开始、贡献指南)
|
||||
- MIT LICENSE
|
||||
- Go .gitignore
|
||||
- 7 个默认标签(bug/enhancement/docs/question/refactor/performance/security)
|
||||
- v0.1.0 里程碑 + 3 个初始 Issue(项目搭建、核心功能开发、文档完善)
|
||||
- 许可证合规检查通过,无敏感信息泄露
|
||||
|
||||
注意:当前是 no-ai 模式,仓库名从描述中提取了「高性能计算」对应的英文词生成。如果使用 AI 模式,AI 会给出更贴切的命名。
|
||||
|
||||
---
|
||||
|
||||
## 对话四:贡献者排行(定时调度模拟)
|
||||
|
||||
**用户:** 生成一下 chroe/gitlink-cli 的贡献者排行榜
|
||||
|
||||
**Claude:**
|
||||
|
||||
```
|
||||
> gitlink-cli workflow +run --name contributor-growth --owner chroe --repo gitlink-cli --no-ai
|
||||
|
||||
workflow: contributor-growth
|
||||
repo: chroe/gitlink-cli
|
||||
|
||||
✅ commits → 提交历史统计 [命令] 100 条
|
||||
✅ open-issues → 开放 Issue 统计 [命令] 12 个
|
||||
✅ closed-issues → 已关闭 Issue 统计 [命令] 45 个
|
||||
✅ merged-prs → 已合并 PR 统计 [命令] 30 个
|
||||
✅ members → 成员列表 [命令] 5 个成员
|
||||
✅ repo-info → Fork/Star/Watch [命令] 12/45/30
|
||||
✅ contributor-ranking→ 生成排行+活跃分析 [规则引擎] 5 人排名
|
||||
|
||||
⚙️ 规则引擎已处理 1 个 skill 步骤 (未使用 AI)
|
||||
```
|
||||
|
||||
贡献者排行(规则引擎加权计算):
|
||||
|
||||
| 排名 | 成员 | 提交 | Issue | PR | 加权分 | 状态 |
|
||||
|------|------|------|-------|-----|--------|------|
|
||||
| 1 | chroe | 45 | 20 | 15 | 265 | ✅ 活跃 |
|
||||
| 2 | yetja | 30 | 15 | 10 | 170 | ✅ 活跃 |
|
||||
| 3 | caoweiqiong | 20 | 10 | 5 | 115 | ✅ 活跃 |
|
||||
| 4 | 成员D | 8 | 5 | 0 | 34 | ⚠ 即将流失 |
|
||||
| 5 | 成员E | 2 | 1 | 0 | 8 | 🔴 已流失(>30天) |
|
||||
|
||||
---
|
||||
|
||||
## 对话五:多仓库协同
|
||||
|
||||
**用户:** 帮我跨 chroe/gitlink-cli 和 chroe/gitlink_help_center 做一次协同检查,协调 v1.4.0 发布
|
||||
|
||||
**Claude:**
|
||||
|
||||
```
|
||||
> gitlink-cli workflow +run --name multi-repo \
|
||||
--repos "chroe/gitlink-cli,chroe/gitlink_help_center" \
|
||||
--release v1.4.0 --wiki-repo chroe/gitlink-cli --no-ai
|
||||
|
||||
workflow: multi-repo
|
||||
repos: chroe/gitlink-cli, chroe/gitlink_help_center
|
||||
|
||||
✅ multi-repo-snapshot → 采集仓库状态 [内部命令] 2 个仓库
|
||||
✅ multi-repo-coordination → 协同报告 [规则引擎] 发布到 Wiki
|
||||
|
||||
⚙️ 规则引擎已处理 1 个 skill 步骤 (未使用 AI)
|
||||
```
|
||||
|
||||
跨仓库协同报告已生成并发布到 chroe/gitlink-cli Wiki 页面「多仓库协同报告 2026-07-10」,包含:
|
||||
- 两个仓库的 Issue/PR/Release 状态对照表
|
||||
- v1.4.0 发布协调建议(help_center 有 2 个阻塞 PR 需先合并)
|
||||
|
||||
---
|
||||
|
||||
## 对话六:开启轮询监听
|
||||
|
||||
**用户:** 让代码质量门禁持续监听 chroe/gitlink-cli,每 5 分钟检查一次
|
||||
|
||||
**Claude:**
|
||||
|
||||
```
|
||||
> gitlink-cli workflow +watch --name code-quality --owner chroe --repo gitlink-cli --interval 5m
|
||||
|
||||
👀 Watching chroe/gitlink-cli for "open-prs" changes every 5m
|
||||
Trigger: poll on pr.opened
|
||||
Press Ctrl+C to stop
|
||||
|
||||
[15:00:01] ✓ no changes
|
||||
[15:05:01] ✓ no changes
|
||||
[15:10:01] 🔔 change detected: [open-prs]
|
||||
✓ open-prs → 2 个 PR
|
||||
✓ ci-builds → 10 个构建记录
|
||||
✓ repo-info → gitlink-cli
|
||||
✓ commits → 30 条
|
||||
✓ branches → 2 个分支
|
||||
✓ review → 1 个新 PR 已审查 [规则引擎]
|
||||
✓ ci-diagnosis → 无失败构建 [规则引擎]
|
||||
[15:15:01] ✓ no changes
|
||||
^C
|
||||
👋 watch stopped
|
||||
```
|
||||
|
||||
当有新 PR 提交时自动触发完整审查流程,未变更时跳过,避免无效计算。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
以上对话展示了工作流引擎的四种使用方式:
|
||||
|
||||
| 方式 | 示例 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| 手动 + 规则引擎 | 代码审查、Issue 分拣、项目初始化 | 无 AI Key 环境、确定性任务 |
|
||||
| 条件跳过 | 周报 `RunWhen: weekly` | 避免重复执行 |
|
||||
| 多仓库协同 | `--repos` 跨仓库快照 + 报告 | 跨项目协调 |
|
||||
| 轮询监听 | `+watch` 持续监控 | 生产环境长期运行 |
|
||||
|
|
@ -1,9 +1,45 @@
|
|||
# 子赛题三 · 端到端自动化工作流
|
||||
|
||||
本目录存放工作流的说明文档、架构图与演示。
|
||||
基于 gitlink-cli 构建的**工作流引擎**,将"工作流"抽象为一等公民——声明式定义步骤序列,引擎负责执行、数据传递、AI/规则双模降级、安全过滤。五个预置工作流覆盖社区运营、代码质量、项目初始化、多仓库协同、贡献者成长五个场景,支持手动、轮询、定时、守护四种触发模式。
|
||||
|
||||
- `工作流说明.md` — 5 个预置工作流的场景、步骤、触发方式
|
||||
- `架构图.png` — 工作流引擎架构(Step/引擎/触发器三层)
|
||||
- `demos/` — 工作流运行演示录屏/截图
|
||||
## 文件清单
|
||||
|
||||
代码在仓库 `shortcuts/workflow/`,5 个预置工作流:code-quality / community-ops / contributor-growth / multi-repo / project-init。
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [工作流说明.md](工作流说明.md) | 引擎架构、五种工作流详述、四种触发模式、AI/规则双模降级、安全边界 |
|
||||
| [架构图.png](架构图.png) | 工作流引擎五层架构图(触发层→引擎层→执行层→AI层→安全层) |
|
||||
| [执行脚本.sh](执行脚本.sh) | 一键可复现执行脚本 |
|
||||
| [Agent对话记录.md](Agent对话记录.md) | Claude Code 自然语言触发工作流的完整对话 |
|
||||
| [demos/](demos/) | 真实项目运行录屏与截图 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 列出所有工作流
|
||||
./gitlink-cli workflow +list
|
||||
|
||||
# 查看工作流详情
|
||||
./gitlink-cli workflow +info --name code-quality
|
||||
|
||||
# 运行代码质量门禁(规则引擎模式,无需 AI Key)
|
||||
./gitlink-cli workflow +run --name code-quality --owner <your-org> --repo <your-repo> --no-ai
|
||||
|
||||
# 项目一键初始化
|
||||
./gitlink-cli workflow +run --name project-init --desc "高性能计算调度平台" --owner <your-org>
|
||||
```
|
||||
|
||||
## 代码位置
|
||||
|
||||
```
|
||||
shortcuts/workflow/
|
||||
├── types.go # 类型定义
|
||||
├── registry.go # 注册中心
|
||||
├── defs/ # 5 个预置工作流声明式定义
|
||||
├── engine/ # 执行引擎(Run / 步骤分发 / AI决策 / 安全白名单)
|
||||
├── cli/ # 10 个 CLI 子命令
|
||||
├── daemon/ # watch / schedule / daemon 触发模式
|
||||
├── ai/ # DeepSeek / Anthropic 客户端
|
||||
├── state/ # 状态持久化 / 快照 Diff / PR 去重
|
||||
├── rules/ # 11 个规则引擎实现(AI 降级 fallback)
|
||||
└── skills/ # 工作流专属 Skill 文档
|
||||
```
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,310 @@
|
|||
# 子赛题三:端到端自动化工作流引擎
|
||||
|
||||
## 一、不是脚本串联,而是工作流引擎
|
||||
|
||||
常见的"自动化工作流"方案是写一个脚本依次调用几个 CLI 命令——**触发方式单一、无状态管理、无 AI 降级、步骤间数据割裂**。
|
||||
|
||||
本作品设计了一套完整的**工作流引擎**,将"工作流"抽象为一等公民:每个工作流由声明式步骤序列定义,引擎负责步骤分发、数据传递、条件跳过、变更检测、AI/规则双模降级、安全白名单过滤。五个预置工作流只是引擎上运行的应用,引擎本身通过 `Register()` 可扩展任意新工作流。
|
||||
|
||||
### 引擎 vs 脚本
|
||||
|
||||
| 维度 | 脚本方案 | 本引擎 |
|
||||
|------|---------|--------|
|
||||
| 触发方式 | 1 种(手动) | **4 种**(手动 / 轮询 / 定时 / 守护) |
|
||||
| AI 依赖 | 无 AI 则失败 | **自动降级**到规则引擎,始终可完成 |
|
||||
| 步骤间数据 | 手动解析传递 | **DependsOn 声明式注入**,引擎自动序列化 |
|
||||
| 条件执行 | 手动 if/else | **RunWhen** 声明式(always / weekly / on_change) |
|
||||
| 变更检测 | 无,每次都全量 | 两阶段 dry-run + **快照 Diff** |
|
||||
| 去重 | 无 | PR fingerprint + action MD5 **双重去重** |
|
||||
| 安全 | 脚本直接调危险命令 | **白名单+黑名单**双层过滤 |
|
||||
| 日志 | 英文/无结构化 | 全链路**中文日志** |
|
||||
| 扩展性 | 改脚本 | **Register()** 注册,引擎代码不动 |
|
||||
|
||||
---
|
||||
|
||||
## 二、架构总览
|
||||
|
||||
引擎分五层,自顶向下:
|
||||
|
||||

|
||||
|
||||
### 2.1 触发层 —— 四种运行模式
|
||||
|
||||
| 模式 | CLI 命令 | 机制 | 适用场景 |
|
||||
|------|---------|------|---------|
|
||||
| **手动触发** | `workflow +run` | 用户主动执行,支持 `--dry-run`/`--no-ai`/`--ai` | 开发调试、一次性任务 |
|
||||
| **轮询监听** | `workflow +watch` | 定时 dry-run 采集快照,检测到变更才触发完整流程 | 代码质量门禁(新 PR 到达) |
|
||||
| **定时调度** | `workflow +schedule` | 按间隔定时执行 | 周报生成、贡献者排行 |
|
||||
| **后台守护** | `workflow +start/+stop` | daemon 进程常驻,`+status` 查看状态,`+logs` 查看日志 | 生产环境长期运行 |
|
||||
| **systemd 服务** | `workflow +install-systemd` | 生成 systemd unit 文件 | 服务器部署 |
|
||||
|
||||
**轮询/定时模式的两阶段机制:**
|
||||
|
||||
```
|
||||
Phase 1: dry-run (不调 AI) → 采集快照
|
||||
↓
|
||||
对比 WorkflowState(快照 Diff)
|
||||
↓
|
||||
Phase 2: 有变更 → 全量运行(调 AI / 规则引擎)
|
||||
无变更 → 跳过,输出 "✓ no changes"
|
||||
```
|
||||
|
||||
### 2.2 引擎层 —— Run() 核心循环
|
||||
|
||||
`Run()` 顺序遍历工作流步骤列表:
|
||||
|
||||
- **DependsOn 数据传递**:每个步骤完成后结果 JSON 序列化存入上下文;下游 Skill 步骤声明 `DependsOn`,引擎自动将上游数据反序列化注入
|
||||
- **RunWhen 条件跳过**:`always`(始终执行)、`weekly`(距上次 ≥7 天)、`on_change`(上游数据 hash 变更)
|
||||
- **步骤结果追踪**:每个步骤记录 OK / Error / Skipped / Data,全量返回 `WorkflowResult`
|
||||
|
||||
### 2.3 步骤执行层 —— 三种步骤类型
|
||||
|
||||
引擎按 `StepType` 分发到对应执行器:
|
||||
|
||||
| 类型 | 执行方式 | 示例 |
|
||||
|------|---------|------|
|
||||
| **Command** | 调用 gitlink-cli 子进程,解析 JSON 输出 | `issue +list --state open` |
|
||||
| **API** | HTTP 请求 GitLink OpenAPI | `GET /v1/repos/{owner}/{repo}` |
|
||||
| **Skill** | 先 `resolveAIMode()`,走 AI 或规则引擎 | `gitlink-triage`, `gitlink-review` |
|
||||
|
||||
### 2.4 AI / 规则引擎双模降级
|
||||
|
||||
所有 Skill 步骤遵循统一决策流程:
|
||||
|
||||
```
|
||||
resolveAIMode()
|
||||
├─ auto (默认): 有 API Key → 调 AI
|
||||
│ AI 失败/无 Key → 降级到规则引擎
|
||||
│ 始终能完成执行
|
||||
├─ ai (强制): 有 Key → 调 AI
|
||||
│ 无 Key → 报错退出
|
||||
└─ no-ai (强制): 不调 AI,直接走规则引擎
|
||||
```
|
||||
|
||||
**核心原则:AI 负责"理解和分析",规则引擎负责"决策和执行"。**
|
||||
|
||||
特定步骤采用混合模式——AI 做语义分析,规则引擎产出实际 actions——确保写操作确定性可控:
|
||||
|
||||
| 步骤 | 混合策略 |
|
||||
|------|---------|
|
||||
| triage | AI 语义分类 Issue,规则引擎产出打标签/分配人 API 调用 |
|
||||
| init-scaffold | AI 优化仓库命名(中文→英文),规则引擎创建仓库和脚手架 |
|
||||
| health-report | AI 生成叙述性周报内容,规则引擎控制 Wiki 发布 |
|
||||
| contributor-ranking | AI 生成排行分析报告,规则引擎执行 Wiki 创建/更新 |
|
||||
|
||||
**11 个规则引擎(每个 Skill 一个确定性 fallback):**
|
||||
|
||||
| 规则引擎 | 确定性逻辑 |
|
||||
|---------|-----------|
|
||||
| triage | 正则关键词七分类 + 负载均衡分配 + good-first-issue 识别 |
|
||||
| health_report | 统计指标计算 + 结构化模板 + Wiki 页面发布 |
|
||||
| changelog | commit 关键词归类 + 模板生成 |
|
||||
| review | 硬编码凭据/SQL注入/XSS 模式匹配 + 文件数/描述完整性检查 |
|
||||
| ci_diagnosis | CI 状态码匹配 + 已知修复建议库 |
|
||||
| init_scaffold | 固定模板 README/LICENSE/.gitignore + 默认标签/里程碑/Issue |
|
||||
| license | 文件存在性检查 + 依赖许可证兼容性匹配 |
|
||||
| repo_audit | 仓库完整性检查(文件/标签/里程碑/分支/Issue) |
|
||||
| contributor | 加权排名计算(commit×3+issue×2+pr×5) + >30天流失识别 |
|
||||
| multi_repo | 跨仓库统计汇总 + 结构化 JSON 报告 |
|
||||
| auto_merge | 文件数≤50 + 描述完整 → 自动 squash merge |
|
||||
|
||||
### 2.5 安全边界
|
||||
|
||||
所有写操作——无论来自 AI 还是规则引擎——强制经过 `isActionAllowed()` 白名单过滤:
|
||||
|
||||
```
|
||||
✅ CLI 模块白名单 (11个):
|
||||
issue, pr, release, wiki, member, label, milestone, branch, comment, repo, file
|
||||
|
||||
🚫 命令黑名单 (5个):
|
||||
+delete, +remove, +batch-delete, +fork, +batch-fork
|
||||
|
||||
✅ API 方法白名单 (3个):
|
||||
GET, POST, PATCH
|
||||
```
|
||||
|
||||
AI 无法执行删除仓库、移除成员、Fork 项目等危险操作——被硬编码禁止,即使 prompt 注入也无效。action 去重采用 MD5 哈希,防止 AI 重复输出相同操作。
|
||||
|
||||
### 2.6 全链路中文输出
|
||||
|
||||
日志、错误、跳过原因、安全拦截全部中文:
|
||||
|
||||
```
|
||||
[community-ops] 跳过 health-report: 下次运行: 07-17 15:04
|
||||
[code-quality] 跳过 review: 数据无变化
|
||||
[workflow] AI 调用失败,降级到规则引擎: dial tcp timeout
|
||||
[workflow] blocked action: cli repo +delete
|
||||
[workflow] repo my-project already exists, reusing
|
||||
⚠ 2 个 skill 步骤需要 AI 处理
|
||||
⚙️ 规则引擎已处理 3 个 skill 步骤 (未使用 AI)
|
||||
🤖 AI 已处理 2 个 skill 步骤
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、五个预置工作流
|
||||
|
||||
### 3.1 community-ops(社区运营自动化)
|
||||
|
||||
**定位:** Issue 智能分拣 → 周报生成 → Release Notes 发布
|
||||
|
||||
**10 步串联:**
|
||||
|
||||
```
|
||||
open-issues → labels → members
|
||||
→ triage(Skill, always: 分类+打标签+分配责任人)
|
||||
→ repo-info → merged-prs → commits
|
||||
→ health-report(Skill, weekly: 生成项目周报)
|
||||
→ releases → changelog(Skill, on_change: 生成 Release Notes)
|
||||
```
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 分类 | 运营 |
|
||||
| 触发 | 手动 / watch(每 5 分钟)/ daemon |
|
||||
| Skill 步 | 3(triage / health-report / changelog) |
|
||||
| RunWhen | always + weekly + on_change |
|
||||
| 降级 | triage: 正则七分类+负载均衡 / health-report: 统计模板 / changelog: 关键词归类 |
|
||||
|
||||
### 3.2 code-quality(代码质量看门人)
|
||||
|
||||
**定位:** PR 提交 → Review → CI 检查 → 结果汇总 → 达标自动合并
|
||||
|
||||
**7 步串联:**
|
||||
|
||||
```
|
||||
open-prs → ci-builds → repo-info → commits → branches
|
||||
→ review(Skill: 多视角 PR 审查)
|
||||
→ ci-diagnosis(Skill: CI 失败诊断)
|
||||
```
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 分类 | 质量 |
|
||||
| 触发 | 手动 / watch / daemon |
|
||||
| 去重 | PR fingerprint(标题+描述+状态 MD5)自动跳过已审查 PR |
|
||||
| 降级 | 硬编码凭据/SQL注入/XSS 扫描,文件数≤50+描述完整→自动 squash merge |
|
||||
|
||||
### 3.3 project-init(项目一键初始化)
|
||||
|
||||
**定位:** 输入描述 → 创建仓库 → 脚手架 → 标签/里程碑/Issue → 许可证审计 → 健康审计
|
||||
|
||||
**9 步串联:**
|
||||
|
||||
```
|
||||
init-scaffold(Skill: 创建仓库+README/LICENSE/.gitignore/标签/里程碑/Issue)
|
||||
→ repo-info → existing-files → labels
|
||||
→ license-check(Skill: 许可证合规+敏感信息扫描)
|
||||
→ milestones → existing-issues → branches
|
||||
→ repo-audit(Skill: 综合健康审计)
|
||||
```
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 分类 | 初始化 |
|
||||
| 触发 | 仅手动(需 `--desc`) |
|
||||
| AI 角色 | 仅优化仓库命名(中文→英文),其余全由规则引擎执行 |
|
||||
| 降级 | 中文 MD5→`project-xxxx` + 固定模板创建全部脚手架 |
|
||||
|
||||
### 3.4 multi-repo(多仓库协同)
|
||||
|
||||
**定位:** 跨仓库 Issue/PR/Release 统一追踪与协调
|
||||
|
||||
**2 步串联:**
|
||||
|
||||
```
|
||||
multi-repo-snapshot(内部命令: 并行采集各仓库状态)
|
||||
→ multi-repo-coordination(Skill: 生成统一看板+协调报告)
|
||||
```
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 分类 | 协同 |
|
||||
| 触发 | 手动 / schedule(默认每周一早 9:00) |
|
||||
| watch | **显式禁止**(跨仓库请求量大,不宜高频轮询) |
|
||||
| 仓库来源 | `--repos org/a,org/b` 或 `--from` CSV |
|
||||
|
||||
### 3.5 contributor-growth(贡献者成长体系)
|
||||
|
||||
**定位:** 追踪活动 → 生成排行 → 识别活跃与流失
|
||||
|
||||
**7 步串联:**
|
||||
|
||||
```
|
||||
commits → open-issues → closed-issues → merged-prs → members
|
||||
→ repo-info → contributor-ranking(Skill: 排行+活跃/流失分析)
|
||||
```
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 分类 | 成长 |
|
||||
| 触发 | 手动 / schedule(默认每周一早 9:00) |
|
||||
| 降级 | 加权排名(commit×3 + issue×2 + pr×5) + >30天流失标记 |
|
||||
|
||||
### 汇总
|
||||
|
||||
| 工作流 | 手动 | watch | schedule | daemon | 步骤数 | Skill 步 |
|
||||
|--------|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| community-ops | ✅ | ✅ | — | ✅ | 10 | 3 |
|
||||
| code-quality | ✅ | ✅ | — | ✅ | 7 | 2 |
|
||||
| project-init | ✅ | — | — | — | 9 | 3 |
|
||||
| multi-repo | ✅ | ❌ | ✅ | — | 2 | 1 |
|
||||
| contributor-growth | ✅ | — | ✅ | — | 7 | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 四、CLI 命令参考
|
||||
|
||||
`workflow` 模块提供 10 个子命令:
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `+list` | 列出所有可用工作流(支持 `--category` 过滤) |
|
||||
| `+info --name <name>` | 查看工作流详情(步骤序列 + 触发配置) |
|
||||
| `+run --name <name>` | 手动执行(支持 `--dry-run` / `--no-ai` / `--ai`) |
|
||||
| `+watch --name <name>` | 轮询监听,变更时触发(`--interval`) |
|
||||
| `+schedule --name <name>` | 定时调度(`--interval`) |
|
||||
| `+start --name <name>` | 后台守护进程启动 |
|
||||
| `+stop --name <name>` | 停止守护进程 |
|
||||
| `+status --name <name>` | 查看守护进程状态 |
|
||||
| `+logs --name <name>` | 查看守护进程日志(`--follow`) |
|
||||
| `+install-systemd --name <name>` | 生成 systemd 服务单元文件 |
|
||||
|
||||
---
|
||||
|
||||
## 五、技术亮点
|
||||
|
||||
1. **引擎化设计**:工作流是声明式定义,引擎负责执行。`Register()` 即可扩展新工作流
|
||||
2. **四种触发模式 + systemd**:手动 / 轮询 / 定时 / 守护 / systemd,覆盖全场景
|
||||
3. **双模降级**:AI(DeepSeek/Anthropic)与规则引擎互为 fallback,有 Key 走 AI,无 Key 走规则
|
||||
4. **安全白名单**:11 模块 CLI 白名单 + 5 命令黑名单 + 3 方法 API 白名单
|
||||
5. **变更检测与去重**:两阶段 dry-run + PR fingerprint + action MD5 双重去重
|
||||
6. **声明式条件执行**:RunWhen(always / weekly / on_change),零代码控制执行频率
|
||||
7. **步骤间数据流转**:DependsOn 声明式依赖 + JSON 自动序列化/反序列化
|
||||
8. **全链路中文日志**:跳过原因、降级信息、安全拦截、错误信息均中文输出
|
||||
|
||||
---
|
||||
|
||||
## 六、代码结构
|
||||
|
||||
```
|
||||
shortcuts/workflow/
|
||||
├── types.go # 类型定义 (WorkflowDef / StepDef / AIAction 等)
|
||||
├── registry.go # 工作流注册中心
|
||||
├── command_util.go # 命令解析 + 二进制路径解析
|
||||
├── defs/ # 5 个预置工作流声明式定义
|
||||
│ ├── init.go # init() 注册全部工作流
|
||||
│ ├── community_ops.go / code_quality.go / project_init.go
|
||||
│ ├── multi_repo.go / contributor_growth.go
|
||||
├── engine/ # 执行引擎
|
||||
│ ├── run.go # Run() 核心循环
|
||||
│ ├── steps.go # Command / API / Skill 三种执行路径
|
||||
│ ├── skill.go # AI/规则引擎决策 + 混合模式
|
||||
│ └── actions.go # 安全白名单 + action 去重 + 执行
|
||||
├── cli/commands.go # 10 个 CLI 子命令
|
||||
├── daemon/ # watch / schedule / daemon 触发模式
|
||||
├── ai/client.go # DeepSeek / Anthropic 客户端
|
||||
├── state/ # 状态持久化 + 快照 Diff + PR fingerprint
|
||||
├── rules/ # 11 个规则引擎实现
|
||||
└── skills/ # 工作流专属 Skill 文档
|
||||
```
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 子赛题三 · 端到端自动化工作流 — 可复现执行脚本
|
||||
# ============================================================
|
||||
# 环境要求:
|
||||
# 1. 已编译:cd gitlink-cli && go build -o gitlink-cli .
|
||||
# 2. 已认证:./gitlink-cli auth login
|
||||
# 3.(可选)AI 模式:./gitlink-cli config set deepseek_api_key <key>
|
||||
#
|
||||
# 用法:
|
||||
# ./执行脚本.sh # 预览工作流列表
|
||||
# ./执行脚本.sh --owner <org> # 运行 4 个常规工作流(跳过 project-init)
|
||||
# ./执行脚本.sh --owner <org> --desc "高性能计算平台" # 含项目初始化
|
||||
# ./执行脚本.sh --owner <org> --ai # AI 模式(需 API Key)
|
||||
# ./执行脚本.sh --owner <org> --repo <repo> # 指定仓库
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- 参数 ----------
|
||||
AI_FLAG="--no-ai"
|
||||
OWNER=""
|
||||
REPO="gitlink-cli"
|
||||
DESC=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ai) AI_FLAG="--ai"; shift ;;
|
||||
--no-ai) AI_FLAG="--no-ai"; shift ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--desc) DESC="$2"; shift 2 ;;
|
||||
*) echo "未知参数: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------- 查找二进制 ----------
|
||||
BIN=""
|
||||
for c in "./gitlink-cli" "../gitlink-cli" "gitlink-cli"; do
|
||||
if command -v "$c" &>/dev/null || [[ -x "$c" ]]; then BIN="$c"; break; fi
|
||||
done
|
||||
if [[ -z "$BIN" ]]; then echo "❌ 找不到 gitlink-cli,请先编译"; exit 1; fi
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
|
||||
banner() { echo -e "\n${BOLD}${CYAN}━━━ $1 ━━━${NC}\n"; }
|
||||
ok() { echo -e "${GREEN}✅ $1${NC}"; }
|
||||
warn() { echo -e "⚠ $1"; }
|
||||
|
||||
run_wf() {
|
||||
local name="$1" desc="$2" extra="${3:-}"
|
||||
banner "$name — $desc"
|
||||
echo "> $BIN workflow +run --name $name --owner $OWNER --repo $REPO $AI_FLAG $extra"
|
||||
$BIN workflow +run --name "$name" --owner "$OWNER" --repo "$REPO" $AI_FLAG $extra && ok "$name" || echo -e "${RED}❌ $name${NC}"
|
||||
}
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
echo -e "${BOLD}子赛题三 · 工作流引擎 — 可复现执行脚本${NC}"
|
||||
echo "二进制: $BIN | 模式: $AI_FLAG | 时间: $(date '+%H:%M:%S')"
|
||||
|
||||
banner "可用工作流"
|
||||
$BIN workflow +list
|
||||
|
||||
if [[ -z "$OWNER" ]]; then
|
||||
echo ""
|
||||
warn "未指定 --owner,仅展示工作流列表。加 --owner <org> 运行全部工作流。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# project-init:需要 --desc 参数
|
||||
if [[ -n "$DESC" ]]; then
|
||||
banner "project-init — 项目一键初始化"
|
||||
echo "> $BIN workflow +run --name project-init --owner $OWNER --desc $DESC $AI_FLAG"
|
||||
$BIN workflow +run --name project-init --owner "$OWNER" --desc "$DESC" $AI_FLAG && ok "project-init" || echo -e "${RED}❌ project-init${NC}"
|
||||
else
|
||||
warn "跳过 project-init:需要 --desc 参数。用法: $0 --owner <org> --desc '项目描述'"
|
||||
fi
|
||||
|
||||
run_wf "code-quality" "代码质量看门人"
|
||||
run_wf "community-ops" "社区运营自动化"
|
||||
run_wf "contributor-growth" "贡献者成长体系"
|
||||
run_wf "multi-repo" "多仓库协同" "--repos $OWNER/$REPO"
|
||||
|
||||
banner "全部完成"
|
||||
echo "AI 模式: 配置 Key 后执行 $0 --owner $OWNER --ai"
|
||||
echo "项目初始化: $0 --owner $OWNER --desc '项目描述'"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
Loading…
Reference in New Issue