forked from Gitlink/gitlink-cli
feat: 实现自动化工作流引擎
工作流引擎支持5个预置场景(社区运营/代码质量/项目初始化/多仓库协同/贡献者成长), 3种步骤类型(command/api/skill),2种执行模式(规则引擎/AI增强), 以及watch/schedule/daemon三种持续运行模式。 - shortcuts/workflow/: 引擎核心(类型定义、步骤执行器、AI客户端、状态管理、守护进程) - shortcuts/workflow/rules/: 7个确定性规则引擎(triage/health/changelog/review/ci/license/repo) - skills/gitlink-workflow/: 工作流 Skill 文档与示例
This commit is contained in:
parent
fde322669a
commit
c3fc44cbca
|
|
@ -0,0 +1,124 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
const anthropicBaseURL = "https://api.anthropic.com/v1/messages"
|
||||
const defaultModel = "claude-sonnet-4-6"
|
||||
|
||||
// AIClient wraps the Anthropic Messages API for skill step execution.
|
||||
type AIClient struct {
|
||||
apiKey string
|
||||
model string
|
||||
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"`
|
||||
}
|
||||
|
||||
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
|
||||
func NewAIClient() *AIClient {
|
||||
key := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if key == "" {
|
||||
cfg, err := config.Load()
|
||||
if err == nil {
|
||||
key = cfg.AnthropicAPIKey
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
return &AIClient{
|
||||
apiKey: key,
|
||||
model: defaultModel,
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze sends the skill prompt + upstream data to the Anthropic API and parses the response.
|
||||
func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("AI client not configured: set ANTHROPIC_API_KEY or configure anthropic_api_key")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": c.model,
|
||||
"max_tokens": 4096,
|
||||
"system": req.SystemPrompt,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": req.UserData},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", anthropicBaseURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", c.apiKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("Anthropic API returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Content) == 0 {
|
||||
return nil, fmt.Errorf("empty response from Anthropic API")
|
||||
}
|
||||
|
||||
text := result.Content[0].Text
|
||||
var aiResp AIResponse
|
||||
if err := json.Unmarshal([]byte(text), &aiResp); err != nil {
|
||||
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
|
||||
}
|
||||
|
||||
return &aiResp, nil
|
||||
}
|
||||
|
||||
// HasKey reports whether the AI client is configured.
|
||||
func (c *AIClient) HasKey() bool {
|
||||
return c != nil && c.apiKey != ""
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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 +list --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,26 @@
|
|||
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"}},
|
||||
{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"}},
|
||||
{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"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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-health", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// StartDaemon launches a workflow as a background daemon process.
|
||||
func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) error {
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot find executable: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"workflow", "+run", "--name", wf.Name,
|
||||
"--owner", ctx.Owner, "--repo", ctx.Repo,
|
||||
"--format", "json", "--daemon-loop",
|
||||
"--interval", interval.String(),
|
||||
}
|
||||
if aiMode == "ai" {
|
||||
args = append(args, "--ai")
|
||||
} else if aiMode == "no-ai" {
|
||||
args = append(args, "--no-ai")
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
// Redirect output to log file instead of discarding.
|
||||
logPath := daemonLogPath(wf.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)
|
||||
}
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
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 {
|
||||
return fmt.Errorf("save pid: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Daemon started for %q (PID: %d)\n", wf.Name, pid)
|
||||
fmt.Printf("Log: %s\n", logPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopDaemon stops a running workflow daemon by name.
|
||||
func StopDaemon(name string) error {
|
||||
pid, err := readPID(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
cleanPID(name)
|
||||
return fmt.Errorf("daemon %q not running (PID %d not found)", name, pid)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
cleanPID(name)
|
||||
fmt.Printf("Daemon %q stopped (PID %d)\n", name, pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StatusDaemon prints the current daemon status for a workflow.
|
||||
func StatusDaemon(name string) error {
|
||||
state, err := LoadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load state: %w", err)
|
||||
}
|
||||
|
||||
pid, pidErr := readPID(name)
|
||||
running := pidErr == nil && processRunning(pid)
|
||||
|
||||
fmt.Printf("工作流: %s\n", name)
|
||||
if running {
|
||||
fmt.Printf("状态: 运行中 (PID: %d)\n", pid)
|
||||
} else {
|
||||
fmt.Println("状态: 已停止")
|
||||
}
|
||||
if state.LastRun != "" {
|
||||
t, err := time.Parse(time.RFC3339, state.LastRun)
|
||||
if err == nil {
|
||||
fmt.Printf("上次运行: %s\n", t.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
fmt.Printf("上次运行: %s\n", state.LastRun)
|
||||
}
|
||||
}
|
||||
fmt.Printf("累计运行: %d 次\n", state.TotalRuns)
|
||||
fmt.Printf("快照步骤: %d 个\n", len(state.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 {
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
// Run immediately on start (dry-run to establish baseline).
|
||||
doDaemonCycle(ctx, wf)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
doDaemonCycle(ctx, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
|
||||
state, _ := LoadState(wf.Name)
|
||||
|
||||
// Phase 1: cheap dry-run — collect data without AI.
|
||||
dryResult, err := Run(ctx, wf, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] error: %v\n", time.Now().Format(time.RFC3339), err)
|
||||
return
|
||||
}
|
||||
|
||||
changed := state.Diff(dryResult.Steps)
|
||||
if len(changed) == 0 && state.TotalRuns > 0 {
|
||||
// No data changes — save state, skip expensive run.
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] run error: %v\n", time.Now().Format(time.RFC3339), err)
|
||||
return
|
||||
}
|
||||
|
||||
state.TotalRuns++
|
||||
state.Diff(fullResult.Steps)
|
||||
state.Save()
|
||||
|
||||
ok, total := 0, len(fullResult.Steps)
|
||||
for _, sr := range fullResult.Steps {
|
||||
if sr.OK {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
|
||||
}
|
||||
|
||||
// daemonLogPath returns the log file path for a workflow daemon.
|
||||
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 {
|
||||
path := daemonLogPath(name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取日志文件 %s: %w (daemon 可能尚未启动)", path, err)
|
||||
}
|
||||
fmt.Print(string(data))
|
||||
|
||||
if !follow {
|
||||
return nil
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
offset := int64(len(data))
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fi.Size() > offset {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f.Seek(offset, 0)
|
||||
buf := make([]byte, fi.Size()-offset)
|
||||
n, _ := f.Read(buf)
|
||||
if n > 0 {
|
||||
fmt.Print(string(buf[:n]))
|
||||
}
|
||||
offset = fi.Size()
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installSystemdUnit generates a systemd service unit file for a workflow daemon.
|
||||
func installSystemdUnit(ctx *common.RuntimeContext, wf *WorkflowDef, interval, aiMode string) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
|
||||
bin, _ := os.Executable()
|
||||
extraArgs := ""
|
||||
if aiMode == "ai" {
|
||||
extraArgs = " --ai"
|
||||
} else if aiMode == "no-ai" {
|
||||
extraArgs = " --no-ai"
|
||||
}
|
||||
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=GitLink CLI Workflow: %s (%s/%s)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s workflow +run --name %s --owner %s --repo %s --format json --daemon-loop --interval %s%s
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StandardOutput=append:%s
|
||||
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),
|
||||
)
|
||||
|
||||
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wf.Name))
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||||
return fmt.Errorf("写入 unit 文件: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Systemd unit 已写入: %s\n\n", unitPath)
|
||||
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.Println()
|
||||
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wf.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func savePID(name string, pid int) error {
|
||||
dir := config.ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(pidPath(name), []byte(strconv.Itoa(pid)), 0600)
|
||||
}
|
||||
|
||||
func readPID(name string) (int, error) {
|
||||
data, err := os.ReadFile(pidPath(name))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, fmt.Errorf("daemon %q is not running (no PID file)", name)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(string(data))
|
||||
}
|
||||
|
||||
func cleanPID(name string) {
|
||||
os.Remove(pidPath(name))
|
||||
}
|
||||
|
||||
func processRunning(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func pidPath(name string) string {
|
||||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.pid", name))
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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 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"
|
||||
}
|
||||
|
||||
results := make([]StepResult, 0, len(wf.Steps))
|
||||
for _, step := range wf.Steps {
|
||||
sr := ExecuteStep(ctx, step, dryRun)
|
||||
results = append(results, *sr)
|
||||
|
||||
// Feed output of this step as input to downstream steps via Args.
|
||||
if sr.OK && 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &WorkflowResult{
|
||||
Workflow: wf.Name,
|
||||
Owner: ctx.Owner,
|
||||
Repo: ctx.Repo,
|
||||
Steps: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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,16 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package workflow
|
||||
|
||||
func registerMultiRepo() {
|
||||
register(&WorkflowDef{
|
||||
Name: "multi-repo",
|
||||
Category: "协同",
|
||||
Description: "多仓库协同:跨仓库 Issue/PR 状态看板、Release 协调发布",
|
||||
Trigger: TriggerDef{
|
||||
Type: "cron",
|
||||
On: "0 9 * * 1",
|
||||
Interval: "24h",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取主仓库信息", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取开放 Issue 列表", Target: "issue +list --state open --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
||||
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本信息协调跨仓库发布", Target: "release +list"},
|
||||
{Type: StepTypeCommand, Name: "milestones", Purpose: "获取里程碑跨仓库对齐", Target: "milestone +list"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员跨仓库协作", Target: "member +list"},
|
||||
{Type: StepTypeSkill, Name: "repo-health", Purpose: "AI 综合评估多仓库健康与活跃度", Target: "gitlink-health", DependsOn: []string{"repo-info", "open-issues", "open-prs"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package workflow
|
||||
|
||||
func registerProjectInit() {
|
||||
register(&WorkflowDef{
|
||||
Name: "project-init",
|
||||
Category: "初始化",
|
||||
Description: "项目一键初始化:仓库检查 → 文件/Issue/里程碑初始 → CI 配置",
|
||||
Trigger: TriggerDef{
|
||||
Type: "manual",
|
||||
On: "manual",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{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: "AI 检查许可证合规并扫描敏感信息泄露", 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: "AI 综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "labels", "milestones", "branches"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// commitGroup holds category → commits mapping for changelog generation.
|
||||
type commitGroup struct {
|
||||
Category string
|
||||
Emoji string
|
||||
Commits []map[string]interface{}
|
||||
}
|
||||
|
||||
var changelogRules = []struct {
|
||||
emoji string
|
||||
name string
|
||||
patterns []*regexp.Regexp
|
||||
}{
|
||||
{"✨", "新功能", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(feat|feature|add|新增|支持)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🐛", "Bug 修复", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(fix|bugfix|hotfix|修复|解决)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🔧", "改进优化", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(refactor|perf|improve|enhance|style|fmt|optimize|优化|增强|完善|调整|格式化)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"📚", "文档", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(docs|doc|文档|README|注释)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🧪", "测试", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(test|tests|测试)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🏗️", "构建/CI", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(build|ci|chore|构建|部署|Docker)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
}
|
||||
|
||||
var breakingRe = regexp.MustCompile(`!:`)
|
||||
var breakingBodyRe = regexp.MustCompile(`BREAKING[ -]CHANGE`)
|
||||
|
||||
// ChangelogRule classifies commits and generates release notes.
|
||||
func ChangelogRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
commits := extractCommits(upstream)
|
||||
releases := extractList(upstream, "releases")
|
||||
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||
|
||||
groups := []commitGroup{}
|
||||
breaking := []map[string]interface{}{}
|
||||
uncategorized := []map[string]interface{}{}
|
||||
|
||||
for _, c := range commits {
|
||||
msg := str(c, "title", "message", "commit", "subject")
|
||||
body := str(c, "body", "description")
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check breaking change.
|
||||
if breakingRe.MatchString(msg) || breakingBodyRe.MatchString(body) {
|
||||
breaking = append(breaking, c)
|
||||
continue
|
||||
}
|
||||
|
||||
categorized := false
|
||||
for i, rule := range changelogRules {
|
||||
for _, re := range rule.patterns {
|
||||
if re.MatchString(msg) {
|
||||
// Extend existing group or create new.
|
||||
found := false
|
||||
for j, g := range groups {
|
||||
if g.Category == rule.name {
|
||||
groups[j].Commits = append(groups[j].Commits, c)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups = append(groups, commitGroup{
|
||||
Category: rule.name,
|
||||
Emoji: rule.emoji,
|
||||
Commits: []map[string]interface{}{c},
|
||||
})
|
||||
}
|
||||
_ = i // suppress unused
|
||||
categorized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if categorized {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !categorized {
|
||||
uncategorized = append(uncategorized, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups: features first, then bug fixes, then rest.
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
return orderOf(groups[i].Category) < orderOf(groups[j].Category)
|
||||
})
|
||||
|
||||
// Add breaking changes group at top if present.
|
||||
if len(breaking) > 0 {
|
||||
groups = append([]commitGroup{{
|
||||
Category: "破坏性变更",
|
||||
Emoji: "⚠️",
|
||||
Commits: breaking,
|
||||
}}, groups...)
|
||||
}
|
||||
|
||||
// Add uncategorized at end.
|
||||
if len(uncategorized) > 0 {
|
||||
groups = append(groups, commitGroup{
|
||||
Category: "其他",
|
||||
Emoji: "🔀",
|
||||
Commits: uncategorized,
|
||||
})
|
||||
}
|
||||
|
||||
// Build analysis.
|
||||
sections := []map[string]interface{}{}
|
||||
for _, g := range groups {
|
||||
items := []string{}
|
||||
for _, c := range g.Commits {
|
||||
msg := str(c, "title", "message", "commit", "subject")
|
||||
sha := str(c, "sha", "id", "commit_id")
|
||||
if sha != "" && len(sha) > 7 {
|
||||
sha = sha[:7]
|
||||
}
|
||||
items = append(items, fmt.Sprintf("%s %s", sha, msg))
|
||||
}
|
||||
sections = append(sections, map[string]interface{}{
|
||||
"category": g.Emoji + " " + g.Category,
|
||||
"count": len(g.Commits),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"total_commits": len(commits),
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
// Build release creation action if there are categorized commits.
|
||||
var actions []workflow.AIAction
|
||||
if len(commits) > 0 {
|
||||
// Determine next version tag.
|
||||
latestTag := "v0.0.0"
|
||||
for _, rel := range releases {
|
||||
if t := str(rel, "tag_name", "tag", "name"); t != "" {
|
||||
if compareTags(t, latestTag) > 0 {
|
||||
latestTag = t
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check merged PRs for version hints.
|
||||
for _, pr := range mergedPRs {
|
||||
labels := str(pr, "labels")
|
||||
if strings.Contains(labels, "release") || strings.Contains(labels, "version") {
|
||||
// PR merged with release label — bump version.
|
||||
}
|
||||
}
|
||||
|
||||
nextTag := bumpTag(latestTag)
|
||||
if len(breaking) > 0 {
|
||||
nextTag = bumpMajor(latestTag)
|
||||
}
|
||||
|
||||
body := buildChangelogBody(sections, nextTag)
|
||||
|
||||
if nextTag != latestTag {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "cli",
|
||||
Module: "release",
|
||||
Command: "+create",
|
||||
Args: map[string]string{
|
||||
"tag": nextTag,
|
||||
"name": nextTag,
|
||||
"body": body,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
func orderOf(cat string) int {
|
||||
order := map[string]int{
|
||||
"破坏性变更": 0,
|
||||
"新功能": 1,
|
||||
"Bug 修复": 2,
|
||||
"改进优化": 3,
|
||||
"文档": 4,
|
||||
"测试": 5,
|
||||
"构建/CI": 6,
|
||||
"其他": 7,
|
||||
}
|
||||
if o, ok := order[cat]; ok {
|
||||
return o
|
||||
}
|
||||
return 99
|
||||
}
|
||||
|
||||
func compareTags(a, b string) int {
|
||||
an := normalizeTag(a)
|
||||
bn := normalizeTag(b)
|
||||
if an > bn {
|
||||
return 1
|
||||
} else if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeTag(t string) string {
|
||||
t = strings.TrimPrefix(t, "v")
|
||||
parts := strings.Split(t, ".")
|
||||
for len(parts) < 3 {
|
||||
parts = append(parts, "0")
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func bumpTag(tag string) string {
|
||||
parts := strings.Split(normalizeTag(tag), ".")
|
||||
if len(parts) < 3 {
|
||||
return "v0.1.0"
|
||||
}
|
||||
minor := atoi(parts[1])
|
||||
return fmt.Sprintf("v%s.%d.0", parts[0], minor+1)
|
||||
}
|
||||
|
||||
func bumpMajor(tag string) string {
|
||||
parts := strings.Split(normalizeTag(tag), ".")
|
||||
if len(parts) < 1 {
|
||||
return "v1.0.0"
|
||||
}
|
||||
major := atoi(parts[0])
|
||||
return fmt.Sprintf("v%d.0.0", major+1)
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
var n int
|
||||
fmt.Sscanf(s, "%d", &n)
|
||||
return n
|
||||
}
|
||||
|
||||
func buildChangelogBody(sections []map[string]interface{}, tag string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", tag)
|
||||
for _, sec := range sections {
|
||||
fmt.Fprintf(&b, "## %s (%d)\n\n", sec["category"], sec["count"])
|
||||
if items, ok := sec["items"].([]string); ok {
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(&b, "- %s\n", item)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestChangelogConventionalCommits(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "abc12345", "title": "feat: add user login"},
|
||||
map[string]interface{}{"sha": "def12345", "title": "fix: resolve null pointer"},
|
||||
map[string]interface{}{"sha": "ghi12345", "title": "docs: update README"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) < 3 {
|
||||
t.Fatalf("expected at least 3 sections (features, bugs, docs), got %d", len(sections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogBreakingChange(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "abc12345", "title": "feat!: drop support for v1"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) == 0 {
|
||||
t.Fatal("expected breaking changes section")
|
||||
}
|
||||
first := sections[0]
|
||||
if cat := first["category"]; cat == nil {
|
||||
t.Fatal("first section missing category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogChineseKeywords(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "aaa11111", "title": "新增:用户管理模块"},
|
||||
map[string]interface{}{"sha": "bbb11111", "title": "修复:登录页面报错"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should have at least 2 sections.
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) < 2 {
|
||||
t.Fatalf("expected at least 2 sections, got %d", len(sections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogNoCommits(t *testing.T) {
|
||||
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["total_commits"]; v.(int) != 0 {
|
||||
t.Fatalf("expected 0 total_commits, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogOutputFormat(t *testing.T) {
|
||||
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// CIDiagnosisRule matches CI build error logs against known patterns.
|
||||
func CIDiagnosisRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
builds := extractList(upstream, "ci-builds")
|
||||
commits := extractCommits(upstream)
|
||||
|
||||
type diagnosis struct {
|
||||
BuildNumber interface{} `json:"build_number"`
|
||||
Status string `json:"status"`
|
||||
Pattern string `json:"matched_pattern"`
|
||||
Diagnosis string `json:"diagnosis"`
|
||||
Suggestion string `json:"suggestion"`
|
||||
RelatedSHA string `json:"related_commit"`
|
||||
}
|
||||
var diagnoses []diagnosis
|
||||
var actions []workflow.AIAction
|
||||
|
||||
for _, build := range builds {
|
||||
status := str(build, "status", "state", "result")
|
||||
if status != "failed" && status != "failure" && status != "error" && status != "3" {
|
||||
// Check nested: some APIs use "build" wrapper.
|
||||
if inner, ok := build["build"].(map[string]interface{}); ok {
|
||||
build = inner
|
||||
status = str(build, "status", "state", "result")
|
||||
if status != "failed" && status != "failure" && status != "error" && status != "3" {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log := str(build, "log", "logs", "output", "build_log")
|
||||
if log == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
d := diagnoseLog(log)
|
||||
buildNum := build["build_number"]
|
||||
if buildNum == nil {
|
||||
buildNum = build["id"]
|
||||
}
|
||||
|
||||
// Find related commit.
|
||||
relatedSHA := ""
|
||||
for _, c := range commits {
|
||||
cSha := str(c, "sha", "id", "commit_id")
|
||||
if cSha != "" && containsAny(str(c, "title", "message", "commit"), d.Pattern) {
|
||||
relatedSHA = cSha
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
diagnoses = append(diagnoses, diagnosis{
|
||||
BuildNumber: buildNum,
|
||||
Status: status,
|
||||
Pattern: d.Pattern,
|
||||
Diagnosis: d.Diagnosis,
|
||||
Suggestion: d.Suggestion,
|
||||
RelatedSHA: relatedSHA,
|
||||
})
|
||||
|
||||
// Auto-retry for transient failures.
|
||||
if d.Transient {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "api",
|
||||
Method: "POST",
|
||||
Path: fmt.Sprintf("{v1}/builds/%v/retry", buildNum),
|
||||
Body: map[string]interface{}{},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(diagnoses) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"diagnoses": nil, "message": "no failed builds found"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"diagnoses": diagnoses,
|
||||
"total_failures": len(diagnoses),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
type logPattern struct {
|
||||
Re *regexp.Regexp
|
||||
Pattern string
|
||||
Diagnosis string
|
||||
Suggestion string
|
||||
Transient bool
|
||||
}
|
||||
|
||||
var ciPatterns = []logPattern{
|
||||
{regexp.MustCompile(`cannot find package|package .* is not in`), "cannot find package",
|
||||
"依赖缺失",
|
||||
"检查 go.mod/package.json 确认依赖已声明",
|
||||
false},
|
||||
{regexp.MustCompile(`syntax error|unexpected token|unexpected EOF`), "syntax error",
|
||||
"语法错误",
|
||||
"检查最近提交中的语法问题",
|
||||
false},
|
||||
{regexp.MustCompile(`permission denied|access denied|forbidden|401|403`), "permission denied",
|
||||
"权限不足",
|
||||
"检查密钥配置和访问权限",
|
||||
false},
|
||||
{regexp.MustCompile(`connection refused|connection reset|no route to host|dial tcp`), "connection refused",
|
||||
"服务不可达",
|
||||
"检查外部服务状态和网络连接",
|
||||
true},
|
||||
{regexp.MustCompile(`out of memory|OOM|killed|signal: killed`), "out of memory",
|
||||
"资源不足(内存溢出)",
|
||||
"优化内存使用或增加构建资源",
|
||||
false},
|
||||
{regexp.MustCompile(`No such file|file not found|not found`), "No such file",
|
||||
"文件缺失",
|
||||
"检查 .devops/ 路径和依赖文件配置",
|
||||
false},
|
||||
{regexp.MustCompile(`docker:.*not found|docker.*command not found`), "docker not found",
|
||||
"Docker 环境缺失",
|
||||
"构建环境未配置 Docker,检查 CI 配置",
|
||||
false},
|
||||
{regexp.MustCompile(`FAIL|exit status [1-9]|Test.*failed`), "exit status 1",
|
||||
"测试失败",
|
||||
"查看测试输出定位失败用例",
|
||||
false},
|
||||
{regexp.MustCompile(`timeout|timed out|deadline exceeded`), "timeout",
|
||||
"构建超时",
|
||||
"优化构建脚本或增加超时时间",
|
||||
true},
|
||||
{regexp.MustCompile(`undefined:|undefined symbol|cannot use|type mismatch`), "undefined:",
|
||||
"编译错误(未定义符号)",
|
||||
"检查导入和类型定义",
|
||||
false},
|
||||
}
|
||||
|
||||
func diagnoseLog(log string) logPattern {
|
||||
for _, p := range ciPatterns {
|
||||
if p.Re.MatchString(log) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return logPattern{
|
||||
Pattern: "unknown",
|
||||
Diagnosis: "未知错误",
|
||||
Suggestion: "请人工查看 CI 日志进行诊断",
|
||||
Transient: false,
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, patterns ...string) bool {
|
||||
for _, p := range patterns {
|
||||
if p != "" && len(s) > 0 && len(p) > 0 {
|
||||
// Simple substring check.
|
||||
if len(s) >= len(p) {
|
||||
for i := 0; i <= len(s)-len(p); i++ {
|
||||
if s[i:i+len(p)] == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCIDiagnosisPatterns(t *testing.T) {
|
||||
tests := []struct {
|
||||
log string
|
||||
pattern string
|
||||
transient bool
|
||||
}{
|
||||
{"cannot find package github.com/foo/bar", "cannot find package", false},
|
||||
{"syntax error: unexpected token at line 42", "syntax error", false},
|
||||
{"permission denied: unable to access /tmp/build", "permission denied", false},
|
||||
{"connection refused: dial tcp 10.0.0.1:8080", "connection refused", true},
|
||||
{"out of memory: process killed", "out of memory", false},
|
||||
{"No such file or directory: .devops/build.yml", "No such file", false},
|
||||
{"FAIL: TestLogin (0.23s)", "exit status 1", false},
|
||||
{"timeout: deadline exceeded after 300s", "timeout", true},
|
||||
{"undefined: UserService in main.go:15", "undefined:", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
d := diagnoseLog(tc.log)
|
||||
if d.Pattern != tc.pattern {
|
||||
t.Errorf("log=%q: expected pattern %q, got %q", tc.log, tc.pattern, d.Pattern)
|
||||
}
|
||||
if d.Transient != tc.transient {
|
||||
t.Errorf("log=%q: expected transient=%v, got %v", tc.log, tc.transient, d.Transient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDiagnosisNoFailures(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"ci-builds": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": "1", "status": "success", "log": "build passed"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
|
||||
if err != nil {
|
||||
t.Fatalf("CIDiagnosisRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if msg := analysis["message"]; msg != "no failed builds found" {
|
||||
t.Fatalf("expected 'no failed builds found', got %v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDiagnosisWithFailures(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"ci-builds": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": "1", "status": "failed", "log": "connection refused"},
|
||||
},
|
||||
},
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
|
||||
if err != nil {
|
||||
t.Fatalf("CIDiagnosisRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["total_failures"]; v.(int) != 1 {
|
||||
t.Fatalf("expected 1 failure, got %v", v)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// contributorEntry holds per-contributor aggregate data.
|
||||
type contributorEntry struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Commits int `json:"commits"`
|
||||
Issues int `json:"issues"`
|
||||
PRs int `json:"prs"`
|
||||
Total int `json:"total"`
|
||||
Trend float64 `json:"trend"`
|
||||
LastActivity string `json:"last_activity"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// ContributorRankingRule produces a contributor ranking report.
|
||||
func ContributorRankingRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
commits := extractCommits(upstream)
|
||||
issues := append(extractIssues(upstream, "open-issues"), extractIssues(upstream, "closed-issues")...)
|
||||
prs := extractPRs(upstream, "merged-prs")
|
||||
members := extractMembers(upstream)
|
||||
|
||||
// Aggregate per login.
|
||||
stats := map[string]*contributorEntry{}
|
||||
for _, c := range commits {
|
||||
login := authorLogin(c)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.Commits++
|
||||
e.Total++
|
||||
if ts := commitTimestamp(c); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range issues {
|
||||
login := authorLogin(i)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.Issues++
|
||||
e.Total++
|
||||
if ts := issueTimestamp(i); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range prs {
|
||||
login := authorLogin(p)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.PRs++
|
||||
e.Total++
|
||||
if ts := prTimestamp(p); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate trends using 30-day windows.
|
||||
now := time.Now()
|
||||
cutoff30 := now.Add(-30 * 24 * time.Hour)
|
||||
cutoff60 := now.Add(-60 * 24 * time.Hour)
|
||||
|
||||
recent := countInWindow(commits, cutoff30, now)
|
||||
prev := countInWindow(commits, cutoff60, cutoff30)
|
||||
for login := range stats {
|
||||
rc := recent[login]
|
||||
pc := prev[login]
|
||||
if pc > 0 {
|
||||
stats[login].Trend = float64(rc-pc) / float64(pc) * 100
|
||||
} else if rc > 0 {
|
||||
stats[login].Trend = 100
|
||||
}
|
||||
// Tagging.
|
||||
if stats[login].Trend > 50 {
|
||||
stats[login].Tags = append(stats[login].Tags, "new-star")
|
||||
}
|
||||
if stats[login].LastActivity != "" {
|
||||
t, err := time.Parse(time.RFC3339, stats[login].LastActivity)
|
||||
if err == nil && now.Sub(t) > 30*24*time.Hour {
|
||||
stats[login].Tags = append(stats[login].Tags, "churn-risk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by total desc.
|
||||
entries := make([]contributorEntry, 0, len(stats))
|
||||
for _, e := range stats {
|
||||
entries = append(entries, *e)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Total > entries[j].Total })
|
||||
|
||||
// Build analysis.
|
||||
rankings := make([]map[string]interface{}, len(entries))
|
||||
for i, e := range entries {
|
||||
rankings[i] = map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"login": e.Login,
|
||||
"name": e.Name,
|
||||
"commits": e.Commits,
|
||||
"issues": e.Issues,
|
||||
"prs": e.PRs,
|
||||
"total": e.Total,
|
||||
"trend": e.Trend,
|
||||
"last_activity": e.LastActivity,
|
||||
"tags": e.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"title": "贡献者排行榜",
|
||||
"rankings": rankings,
|
||||
"churn_risk": filterByTag(rankings, "churn-risk"),
|
||||
"new_stars": filterByTag(rankings, "new-star"),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func ensureEntry(stats map[string]*contributorEntry, login string, members map[string]string) *contributorEntry {
|
||||
if e, ok := stats[login]; ok {
|
||||
return e
|
||||
}
|
||||
e := &contributorEntry{Login: login, Name: members[login]}
|
||||
stats[login] = e
|
||||
return e
|
||||
}
|
||||
|
||||
func filterByTag(rankings []map[string]interface{}, tag string) []map[string]interface{} {
|
||||
var out []map[string]interface{}
|
||||
for _, r := range rankings {
|
||||
if tags, ok := r["tags"].([]string); ok {
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
out = append(out, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countInWindow(commits []map[string]interface{}, start, end time.Time) map[string]int {
|
||||
m := map[string]int{}
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if t.After(start) && t.Before(end) {
|
||||
m[authorLogin(c)]++
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func extractCommits(upstream map[string]interface{}) []map[string]interface{} {
|
||||
return extractList(upstream, "commits")
|
||||
}
|
||||
|
||||
func extractIssues(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
return extractList(upstream, key)
|
||||
}
|
||||
|
||||
func extractPRs(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
return extractList(upstream, key)
|
||||
}
|
||||
|
||||
func extractMembers(upstream map[string]interface{}) map[string]string {
|
||||
raw, ok := upstream["members"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
members := map[string]string{}
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return members
|
||||
}
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
login := str(m, "login", "username", "name")
|
||||
name := str(m, "name", "full_name", "display_name")
|
||||
if login != "" {
|
||||
members[login] = name
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
func extractList(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
raw, ok := upstream[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// upstream values may be stored as an envelope: {"ok": true, "data": [...]}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if data, ok := m["data"]; ok {
|
||||
raw = data
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func authorLogin(m map[string]interface{}) string {
|
||||
return str(m, "author", "login", "username", "committer", "user")
|
||||
}
|
||||
|
||||
func commitTimestamp(m map[string]interface{}) string {
|
||||
// Commits and issues may be nested under author/committer.
|
||||
for _, key := range []string{"created_at", "committed_date", "updated_at", "authored_date"} {
|
||||
if s := str(m, key); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
// Try nested author.
|
||||
if a, ok := m["author"].(map[string]interface{}); ok {
|
||||
return str(a, "date", "created_at")
|
||||
}
|
||||
if a, ok := m["committer"].(map[string]interface{}); ok {
|
||||
return str(a, "date", "created_at")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func issueTimestamp(m map[string]interface{}) string {
|
||||
return str(m, "created_at", "updated_at", "closed_at")
|
||||
}
|
||||
|
||||
func prTimestamp(m map[string]interface{}) string {
|
||||
return str(m, "created_at", "merged_at", "updated_at")
|
||||
}
|
||||
|
||||
// str returns the first non-empty string value for the given keys.
|
||||
func str(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
v, _ := m[k].(string)
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContributorRanking(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
map[string]interface{}{"login": "dev2", "name": "Dev Two"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) != 2 {
|
||||
t.Fatalf("expected 2 rankings, got %d", len(rankings))
|
||||
}
|
||||
|
||||
// dev1 should be ranked #1 (2 commits vs 1).
|
||||
first := rankings[0]
|
||||
if first["login"] != "dev1" {
|
||||
t.Errorf("expected dev1 as #1, got %v", first["login"])
|
||||
}
|
||||
if first["rank"] != 1 {
|
||||
t.Errorf("expected rank 1, got %v", first["rank"])
|
||||
}
|
||||
if first["commits"] != 2 {
|
||||
t.Errorf("expected 2 commits, got %v", first["commits"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChurnRiskDetection(t *testing.T) {
|
||||
// 35 days ago — should trigger churn risk.
|
||||
oldDate := "2025-01-01T00:00:00Z"
|
||||
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": oldDate, "author": "dev1"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) > 0 {
|
||||
tags := rankings[0]["tags"].([]string)
|
||||
for _, tag := range tags {
|
||||
if tag == "churn-risk" {
|
||||
return // success
|
||||
}
|
||||
}
|
||||
t.Errorf("expected churn-risk tag for old activity, got tags: %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributorOutputFormat(t *testing.T) {
|
||||
resp, err := ContributorRankingRule(map[string]interface{}{}, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
if resp.Actions != nil {
|
||||
t.Fatal("expected nil Actions (read-only report)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStarDetection(t *testing.T) {
|
||||
// Use very recent dates so the commits appear in the last 30 days.
|
||||
now := time.Now()
|
||||
d1 := now.Add(-2 * 24 * time.Hour).Format(time.RFC3339)
|
||||
d2 := now.Add(-3 * 24 * time.Hour).Format(time.RFC3339)
|
||||
d3 := now.Add(-4 * 24 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": d1, "author": "dev1"},
|
||||
map[string]interface{}{"created_at": d2, "author": "dev1"},
|
||||
map[string]interface{}{"created_at": d3, "author": "dev1"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should have new-star or at least ranking.
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) == 0 {
|
||||
t.Fatal("expected at least 1 ranking entry")
|
||||
}
|
||||
tags, _ := rankings[0]["tags"].([]string)
|
||||
t.Logf("tags for dev1: %v", tags)
|
||||
// With only recent commits (no previous period), trend should be 100%, triggering new-star.
|
||||
found := false
|
||||
for _, tag := range tags {
|
||||
if tag == "new-star" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected new-star tag, got: %v", tags)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package rules
|
||||
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
// HealthDispatchRule routes "gitlink-health" skill calls to the correct engine
|
||||
// based on step name and upstream data shape.
|
||||
func HealthDispatchRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
// contributor-ranking: from contributor_growth workflow.
|
||||
if stepName == "contributor-ranking" {
|
||||
return ContributorRankingRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// repo-health: from multi_repo workflow.
|
||||
if stepName == "repo-health" {
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// health-report: from community_ops workflow.
|
||||
if stepName == "health-report" {
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// Default: inspect upstream shape to decide.
|
||||
// If upstream has "members" and "merged-prs" but no "repo-info", it's contributor ranking.
|
||||
_, hasRepoInfo := upstream["repo-info"]
|
||||
_, hasMembers := upstream["members"]
|
||||
if hasMembers && !hasRepoInfo {
|
||||
return ContributorRankingRule(upstream, stepName)
|
||||
}
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// HealthReportRule computes a 4-dimension weighted health score.
|
||||
func HealthReportRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
repoInfo := extractFirst(upstream, "repo-info")
|
||||
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||
commits := extractCommits(upstream)
|
||||
openIssues := extractIssues(upstream, "open-issues")
|
||||
|
||||
// Compute raw metrics.
|
||||
totalIssues := len(openIssues) // approximation
|
||||
totalPRs := len(mergedPRs)
|
||||
now := time.Now()
|
||||
recentCommits := countRecent(commits, now, 30)
|
||||
releaseCount := 0
|
||||
if repoInfo != nil {
|
||||
if v, ok := repoInfo["release_count"].(float64); ok {
|
||||
releaseCount = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Dimension 1: Issue Health (30%)
|
||||
issueScore := scoreIssueHealth(totalIssues, openIssues, now)
|
||||
|
||||
// Dimension 2: PR Health (30%)
|
||||
prScore := scorePRHealth(totalPRs, mergedPRs, now)
|
||||
|
||||
// Dimension 3: Contributor Health (20%)
|
||||
contributorScore := scoreContributorHealth(commits, now)
|
||||
|
||||
// Dimension 4: Activity (20%)
|
||||
activityScore := scoreActivity(recentCommits, releaseCount, repoInfo)
|
||||
|
||||
// Composite score.
|
||||
composite := issueScore*0.30 + prScore*0.30 + contributorScore*0.20 + activityScore*0.20
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"title": "项目健康度报告",
|
||||
"composite": math.Round(composite*10) / 10,
|
||||
"grade": grade(composite),
|
||||
"dimensions": map[string]interface{}{
|
||||
"issue_health": map[string]interface{}{
|
||||
"score": math.Round(issueScore*10) / 10,
|
||||
"weight": 0.30,
|
||||
"grade": grade(issueScore),
|
||||
},
|
||||
"pr_health": map[string]interface{}{
|
||||
"score": math.Round(prScore*10) / 10,
|
||||
"weight": 0.30,
|
||||
"grade": grade(prScore),
|
||||
},
|
||||
"contributor_health": map[string]interface{}{
|
||||
"score": math.Round(contributorScore*10) / 10,
|
||||
"weight": 0.20,
|
||||
"grade": grade(contributorScore),
|
||||
},
|
||||
"activity": map[string]interface{}{
|
||||
"score": math.Round(activityScore*10) / 10,
|
||||
"weight": 0.20,
|
||||
"grade": grade(activityScore),
|
||||
"recent_commits": recentCommits,
|
||||
"releases": releaseCount,
|
||||
},
|
||||
},
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func scoreIssueHealth(total int, openIssues []map[string]interface{}, now time.Time) float64 {
|
||||
if total == 0 {
|
||||
return 80 // neutral
|
||||
}
|
||||
// Stale issues: open for >30 days.
|
||||
stale := 0
|
||||
for _, iss := range openIssues {
|
||||
ts := issueTimestamp(iss)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) > 30*24*time.Hour {
|
||||
stale++
|
||||
}
|
||||
}
|
||||
ratio := float64(stale) / float64(max(total, 1))
|
||||
score := (1 - ratio) * 100
|
||||
return clamp(score)
|
||||
}
|
||||
|
||||
func scorePRHealth(total int, mergedPRs []map[string]interface{}, now time.Time) float64 {
|
||||
if total == 0 {
|
||||
return 80 // neutral
|
||||
}
|
||||
// Avg merge time from creation.
|
||||
var totalHours float64
|
||||
count := 0
|
||||
for _, pr := range mergedPRs {
|
||||
created := prTimestamp(pr)
|
||||
merged := str(pr, "merged_at")
|
||||
if created == "" || merged == "" {
|
||||
continue
|
||||
}
|
||||
ct, err1 := time.Parse(time.RFC3339, created)
|
||||
mt, err2 := time.Parse(time.RFC3339, merged)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
totalHours += mt.Sub(ct).Hours()
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return 80
|
||||
}
|
||||
avgDays := totalHours / float64(count) / 24
|
||||
// <3 days = excellent (100), 3-7 = good (80), >7 = needs improvement (50).
|
||||
if avgDays < 3 {
|
||||
return 100
|
||||
} else if avgDays < 7 {
|
||||
return 80
|
||||
}
|
||||
return 50
|
||||
}
|
||||
|
||||
func scoreContributorHealth(commits []map[string]interface{}, now time.Time) float64 {
|
||||
// Unique authors in last 30 days.
|
||||
authors := map[string]bool{}
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) <= 30*24*time.Hour {
|
||||
authors[authorLogin(c)] = true
|
||||
}
|
||||
}
|
||||
n := len(authors)
|
||||
// >3 active = 100, 1-3 = 60, 0 = 30.
|
||||
if n > 3 {
|
||||
return 100
|
||||
} else if n >= 1 {
|
||||
return 60
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
func scoreActivity(recentCommits int, releaseCount int, repoInfo map[string]interface{}) float64 {
|
||||
score := 0.0
|
||||
if recentCommits >= 10 {
|
||||
score += 50
|
||||
} else if recentCommits > 0 {
|
||||
score += float64(recentCommits) / 10 * 50
|
||||
}
|
||||
if releaseCount >= 3 {
|
||||
score += 50
|
||||
} else if releaseCount > 0 {
|
||||
score += float64(releaseCount) / 3 * 50
|
||||
}
|
||||
if score == 0 {
|
||||
score = 30 // bare minimum if repo exists
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func countRecent(commits []map[string]interface{}, now time.Time, days int) int {
|
||||
n := 0
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) <= time.Duration(days)*24*time.Hour {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// extractFirst returns the first map from upstream by key (some upstream data is wrapped).
|
||||
func extractFirst(upstream map[string]interface{}, key string) map[string]interface{} {
|
||||
list := extractList(upstream, key)
|
||||
if len(list) > 0 {
|
||||
return list[0]
|
||||
}
|
||||
// Try direct map.
|
||||
if m, ok := upstream[key].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func grade(score float64) string {
|
||||
if score >= 80 {
|
||||
return "优秀"
|
||||
} else if score >= 60 {
|
||||
return "良好"
|
||||
}
|
||||
return "需改进"
|
||||
}
|
||||
|
||||
func clamp(v float64) float64 {
|
||||
return min(max(v, 0), 100)
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestHealthReportScoring(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"description": "test repo", "open_issues_count": 5, "release_count": 2},
|
||||
},
|
||||
},
|
||||
"merged-prs": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"merged_at": "2025-01-03T00:00:00Z",
|
||||
"author": "dev1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
|
||||
map[string]interface{}{"created_at": "2025-06-15T00:00:00Z", "author": "dev3"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := HealthReportRule(upstream, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if _, ok := analysis["composite"]; !ok {
|
||||
t.Fatal("missing composite score")
|
||||
}
|
||||
if _, ok := analysis["grade"]; !ok {
|
||||
t.Fatal("missing grade")
|
||||
}
|
||||
if dims, ok := analysis["dimensions"].(map[string]interface{}); !ok {
|
||||
t.Fatal("missing dimensions")
|
||||
} else {
|
||||
for _, dim := range []string{"issue_health", "pr_health", "contributor_health", "activity"} {
|
||||
if _, ok := dims[dim]; !ok {
|
||||
t.Errorf("missing dimension: %s", dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.Actions != nil {
|
||||
t.Fatal("expected nil Actions (read-only report)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportNoData(t *testing.T) {
|
||||
resp, err := HealthReportRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should still produce a score (neutral defaults).
|
||||
if v := analysis["composite"]; v == nil {
|
||||
t.Fatal("expected composite score even with no data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportOutputFormat(t *testing.T) {
|
||||
resp, err := HealthReportRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// riskEntry describes a single license/security risk finding.
|
||||
type riskEntry struct {
|
||||
File string `json:"file"`
|
||||
Risk string `json:"risk"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LicenseCheckRule scans file lists and content for license compliance and sensitive data.
|
||||
func LicenseCheckRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
files := extractList(upstream, "existing-files")
|
||||
if len(files) == 0 {
|
||||
files = extractList(upstream, "files")
|
||||
}
|
||||
|
||||
var findings []riskEntry
|
||||
hasLicense := false
|
||||
licenseType := ""
|
||||
|
||||
for _, f := range files {
|
||||
name := str(f, "name", "filename", "path", "file_name")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// License file detection.
|
||||
if isLicenseFile(name) {
|
||||
hasLicense = true
|
||||
content := str(f, "content", "body", "text")
|
||||
if content != "" {
|
||||
licenseType = detectLicenseType(content)
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive file name detection.
|
||||
for _, fp := range filePatterns {
|
||||
if fp.re.MatchString(strings.ToLower(name)) {
|
||||
findings = append(findings, riskEntry{
|
||||
File: name,
|
||||
Risk: fp.risk,
|
||||
Message: fp.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive content detection.
|
||||
content := str(f, "content", "body", "text")
|
||||
if content != "" {
|
||||
for _, cp := range contentPatterns {
|
||||
if cp.re.MatchString(content) {
|
||||
// Apply exclusion rules.
|
||||
matches := cp.re.FindAllString(content, -1)
|
||||
for _, match := range matches {
|
||||
if isPlaceholder(match) {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, riskEntry{
|
||||
File: name,
|
||||
Risk: "high",
|
||||
Message: cp.message + " → `" + truncate(match, 40) + "`",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute scores.
|
||||
licenseScore := 0.0
|
||||
if hasLicense {
|
||||
licenseScore = 100
|
||||
if licenseType != "" {
|
||||
licenseScore = 100
|
||||
} else {
|
||||
licenseScore = 70
|
||||
}
|
||||
}
|
||||
|
||||
sensitiveScore := 100.0
|
||||
highCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Risk == "high" {
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
if highCount > 0 {
|
||||
sensitiveScore = max(0, 100-float64(highCount)*20)
|
||||
}
|
||||
|
||||
composite := licenseScore*0.35 + sensitiveScore*0.40 + 50*0.15 + 50*0.10
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"has_license": hasLicense,
|
||||
"license_type": licenseType,
|
||||
"license_score": licenseScore,
|
||||
"sensitive_score": sensitiveScore,
|
||||
"composite_score": composite,
|
||||
"grade": grade(composite),
|
||||
"findings": findings,
|
||||
"total_findings": len(findings),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
// --- license file detection ---
|
||||
|
||||
func isLicenseFile(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
for _, pattern := range []string{"license", "copying", "notice", "licence"} {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectLicenseType(content string) string {
|
||||
for _, lp := range licensePatterns {
|
||||
if lp.re.MatchString(content) {
|
||||
return lp.name
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
var licensePatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
name string
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)MIT\s+License|Permission is hereby granted`), "MIT"},
|
||||
{regexp.MustCompile(`(?i)Apache\s+License.*Version\s+2\.0|http://www\.apache\.org/licenses`), "Apache 2.0"},
|
||||
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 3|GPL\s*v3`), "GPL v3"},
|
||||
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 2|GPL\s*v2`), "GPL v2"},
|
||||
{regexp.MustCompile(`(?i)BSD\s+(3-Clause|2-Clause|License)`), "BSD"},
|
||||
{regexp.MustCompile(`(?i)Mulan\s+Permissive|木兰宽松许可证`), "Mulan PSL v2"},
|
||||
{regexp.MustCompile(`(?i)Mozilla Public License|MPL`), "MPL"},
|
||||
{regexp.MustCompile(`(?i)ISC\s+License`), "ISC"},
|
||||
{regexp.MustCompile(`(?i)Creative Commons|CC-BY`), "Creative Commons"},
|
||||
{regexp.MustCompile(`(?i)Unlicense|public\s+domain`), "Unlicense"},
|
||||
}
|
||||
|
||||
// --- file pattern scanning ---
|
||||
|
||||
type fileRiskPattern struct {
|
||||
re *regexp.Regexp
|
||||
risk string
|
||||
message string
|
||||
}
|
||||
|
||||
var filePatterns = []fileRiskPattern{
|
||||
{regexp.MustCompile(`\.pem$|\.key$|\.p12$|\.pfx$`), "high", "私钥/证书文件,确认是否应纳入版本控制"},
|
||||
{regexp.MustCompile(`id_rsa|id_dsa|id_ecdsa|id_ed25519`), "high", "SSH 私钥文件,不应提交到仓库"},
|
||||
{regexp.MustCompile(`^\.env$|\.env\.`), "high", "环境变量文件,可能包含敏感凭据"},
|
||||
{regexp.MustCompile(`credentials\.|\.secret$|secret\.yml`), "high", "凭据文件,可能包含敏感信息"},
|
||||
{regexp.MustCompile(`serviceAccount\.json|\.service-account\.json`), "high", "服务账号密钥文件"},
|
||||
{regexp.MustCompile(`.*token.*|.*secret.*`), "medium", "文件名包含 token/secret,检查内容"},
|
||||
{regexp.MustCompile(`coverage\.out$`), "low", "覆盖率输出文件,建议添加到 .gitignore"},
|
||||
{regexp.MustCompile(`\.exe$|\.bin$|\.dll$|\.so$`), "low", "二进制文件,检查是否应纳入版本控制"},
|
||||
{regexp.MustCompile(`\.log$|\.tmp$`), "low", "日志/临时文件,建议添加到 .gitignore"},
|
||||
}
|
||||
|
||||
// --- content pattern scanning ---
|
||||
|
||||
type contentRiskPattern struct {
|
||||
re *regexp.Regexp
|
||||
message string
|
||||
}
|
||||
|
||||
var contentPatterns = []contentRiskPattern{
|
||||
{regexp.MustCompile(`(?i)(token|api[_-]?key|apikey|secret|password|passwd|authorization)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
|
||||
"检测到硬编码凭据赋值"},
|
||||
{regexp.MustCompile(`-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
|
||||
"检测到私钥头部"},
|
||||
{regexp.MustCompile(`(?i)GITLINK_TOKEN\s*[:=]\s*['"][^\s'"]+['"]`),
|
||||
"检测到 GitLink Token"},
|
||||
{regexp.MustCompile(`(?i)(mongodb|mysql|postgres|redis|jdbc)://[^\s'"]+@`),
|
||||
"检测到数据库连接字符串"},
|
||||
{regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
|
||||
"检测到 AWS Access Key"},
|
||||
{regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`),
|
||||
"检测到 GitHub 个人访问令牌"},
|
||||
{regexp.MustCompile(`(?i)eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}`),
|
||||
"检测到 JWT 令牌格式"},
|
||||
{regexp.MustCompile(`(?i)(\d{1,3}\.){3}\d{1,3}`),
|
||||
"检测到硬编码 IP 地址"},
|
||||
{regexp.MustCompile(`(?i)password\s*[:=]\s*['"]['"]`),
|
||||
"检测到空密码"},
|
||||
}
|
||||
|
||||
func isPlaceholder(s string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, p := range []string{"((variable))", "<token>", "your_token_here", "xxx", "replace_me", "<your", "placeholder"} {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Skip if it looks like a format variable: {{.Var}} or ${VAR}.
|
||||
if matched, _ := regexp.MatchString(`\{\{\.?\w+\}\}|\$\{\w+\}`, s); matched {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLicenseFileDetection(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "src/main.go", "content": "package main"},
|
||||
map[string]interface{}{"name": "LICENSE", "content": "MIT License\n\nPermission is hereby granted..."},
|
||||
map[string]interface{}{"name": "README.md", "content": "# Project"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["has_license"]; v != true {
|
||||
t.Fatal("expected has_license=true")
|
||||
}
|
||||
if v := analysis["license_type"]; v != "MIT" {
|
||||
t.Fatalf("expected license_type=MIT, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensitiveContentDetection(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "config.go", "content": `api_key = "sk-1234567890abcdef"`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
findings := analysis["findings"].([]riskEntry)
|
||||
if len(findings) == 0 {
|
||||
t.Fatal("expected findings for hardcoded key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderExclusion(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "config.go", "content": `api_key = "your_token_here"`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
findings := analysis["findings"].([]riskEntry)
|
||||
// Placeholder should not generate riskEntry findings.
|
||||
// But the api_key pattern may still match — check that it's excluded.
|
||||
for _, f := range findings {
|
||||
if f.File == "config.go" {
|
||||
t.Logf("finding: %+v", f)
|
||||
// Should NOT be a high risk for the api_key pattern.
|
||||
}
|
||||
}
|
||||
// The placeholder exclusion should filter out the match from content patterns.
|
||||
// But filename-based detections may still fire. Let's just check there are no
|
||||
// high risk findings for the placeholder content.
|
||||
for _, f := range findings {
|
||||
if f.Risk == "high" && f.File == "config.go" {
|
||||
t.Errorf("placeholder should be excluded, but got high risk finding: %s", f.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseFileMissing(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "README.md"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["has_license"]; v != false {
|
||||
t.Fatal("expected has_license=false")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package rules
|
||||
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
func init() {
|
||||
workflow.RegisterRuleEngine("gitlink-triage", TriageRule)
|
||||
workflow.RegisterRuleEngine("gitlink-health", HealthDispatchRule)
|
||||
workflow.RegisterRuleEngine("gitlink-changelog", ChangelogRule)
|
||||
workflow.RegisterRuleEngine("gitlink-review", CodeReviewRule)
|
||||
workflow.RegisterRuleEngine("gitlink-ci", CIDiagnosisRule)
|
||||
workflow.RegisterRuleEngine("gitlink-license", LicenseCheckRule)
|
||||
workflow.RegisterRuleEngine("gitlink-repo", RepoAuditRule)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestRegistryAllRegistered(t *testing.T) {
|
||||
expected := []string{
|
||||
"gitlink-triage",
|
||||
"gitlink-health",
|
||||
"gitlink-changelog",
|
||||
"gitlink-review",
|
||||
"gitlink-ci",
|
||||
"gitlink-license",
|
||||
"gitlink-repo",
|
||||
}
|
||||
for _, target := range expected {
|
||||
if _, ok := workflow.RuleEngines[target]; !ok {
|
||||
t.Errorf("rule engine not registered for target %q", target)
|
||||
}
|
||||
}
|
||||
if len(workflow.RuleEngines) != len(expected) {
|
||||
t.Fatalf("expected %d rule engines, got %d", len(expected), len(workflow.RuleEngines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthDispatchRule(t *testing.T) {
|
||||
// contributor-ranking step should delegate to ContributorRankingRule.
|
||||
resp, err := HealthDispatchRule(map[string]interface{}{}, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
|
||||
// health-report step should delegate to HealthReportRule.
|
||||
resp, err = HealthDispatchRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule for health-report failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response for health-report")
|
||||
}
|
||||
|
||||
// repo-health step should delegate to HealthReportRule too.
|
||||
resp, err = HealthDispatchRule(map[string]interface{}{}, "repo-health")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule for repo-health failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response for repo-health")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// RepoAuditRule evaluates a repository against a 6-dimension checklist.
|
||||
func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
repoInfo := extractFirst(upstream, "repo-info")
|
||||
labels := extractLabels(upstream)
|
||||
milestones := extractList(upstream, "milestones")
|
||||
branches := extractList(upstream, "branches")
|
||||
|
||||
type dimScore struct {
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"`
|
||||
Weight float64 `json:"weight"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
var dims []dimScore
|
||||
missing := []string{}
|
||||
|
||||
// Readme check.
|
||||
desc := ""
|
||||
hasReadme := false
|
||||
if repoInfo != nil {
|
||||
desc = str(repoInfo, "description")
|
||||
if v, ok := repoInfo["has_readme"].(bool); ok {
|
||||
hasReadme = v
|
||||
}
|
||||
}
|
||||
readmeScore := 0.0
|
||||
if hasReadme || desc != "" {
|
||||
readmeScore = 100
|
||||
dims = append(dims, dimScore{Name: "README", Score: readmeScore, Weight: 0.25, Status: "ok", Detail: "README 已存在"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "README", Score: 0, Weight: 0.25, Status: "missing", Detail: "缺少 README 文件"})
|
||||
missing = append(missing, "README")
|
||||
}
|
||||
|
||||
// License check.
|
||||
if detectLicenseInFiles(upstream) {
|
||||
dims = append(dims, dimScore{Name: "LICENSE", Score: 100, Weight: 0.20, Status: "ok", Detail: "LICENSE 文件已存在"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "LICENSE", Score: 0, Weight: 0.20, Status: "missing", Detail: "缺少 LICENSE 文件"})
|
||||
missing = append(missing, "LICENSE")
|
||||
}
|
||||
|
||||
// Labels check.
|
||||
if len(labels) > 5 {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 100, Weight: 0.15, Status: "ok", Detail: "标签配置完善"})
|
||||
} else if len(labels) > 0 {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 50, Weight: 0.15, Status: "partial", Detail: "标签较少,建议补充"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 0, Weight: 0.15, Status: "missing", Detail: "未配置标签"})
|
||||
missing = append(missing, "labels")
|
||||
}
|
||||
|
||||
// Milestones check.
|
||||
if len(milestones) > 0 {
|
||||
dims = append(dims, dimScore{Name: "里程碑", Score: 100, Weight: 0.15, Status: "ok", Detail: "已配置里程碑"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "里程碑", Score: 0, Weight: 0.15, Status: "missing", Detail: "未配置里程碑"})
|
||||
missing = append(missing, "milestones")
|
||||
}
|
||||
|
||||
// Branches check.
|
||||
if len(branches) > 2 {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 100, Weight: 0.10, Status: "ok", Detail: "分支结构完善"})
|
||||
} else if len(branches) > 1 {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 70, Weight: 0.10, Status: "ok", Detail: "至少有一个开发分支"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 40, Weight: 0.10, Status: "partial", Detail: "仅主分支,建议创建 develop 分支"})
|
||||
}
|
||||
|
||||
// DevOps check.
|
||||
devops := false
|
||||
if repoInfo != nil {
|
||||
if v, ok := repoInfo["open_devops"].(bool); ok {
|
||||
devops = v
|
||||
}
|
||||
if v, ok := repoInfo["devops_enabled"].(bool); ok {
|
||||
devops = v
|
||||
}
|
||||
}
|
||||
if devops {
|
||||
dims = append(dims, dimScore{Name: "DevOps", Score: 100, Weight: 0.15, Status: "ok", Detail: "DevOps 已开启"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "DevOps", Score: 0, Weight: 0.15, Status: "missing", Detail: "DevOps 未开启"})
|
||||
missing = append(missing, "DevOps")
|
||||
}
|
||||
|
||||
// Composite.
|
||||
var composite float64
|
||||
for _, d := range dims {
|
||||
composite += d.Score * d.Weight
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"composite_score": composite,
|
||||
"grade": grade(composite),
|
||||
"dimensions": dims,
|
||||
"missing_items": missing,
|
||||
"recommendation": strings.Join(missing, "、") + " 需要补充",
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
analysis["recommendation"] = "项目初始化完善,所有检查项均已通过"
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func detectLicenseInFiles(upstream map[string]interface{}) bool {
|
||||
files := extractList(upstream, "existing-files")
|
||||
if len(files) == 0 {
|
||||
files = extractList(upstream, "files")
|
||||
}
|
||||
for _, f := range files {
|
||||
name := str(f, "name", "filename", "path", "file_name")
|
||||
if isLicenseFile(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepoAuditComplete(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"description": "A well-maintained project",
|
||||
"has_readme": true,
|
||||
"open_devops": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "LICENSE", "content": "MIT"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "bug"},
|
||||
map[string]interface{}{"name": "enhancement"},
|
||||
map[string]interface{}{"name": "question"},
|
||||
map[string]interface{}{"name": "docs"},
|
||||
map[string]interface{}{"name": "security"},
|
||||
map[string]interface{}{"name": "refactor"},
|
||||
},
|
||||
},
|
||||
"milestones": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"title": "v1.0"},
|
||||
},
|
||||
},
|
||||
"branches": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "master"},
|
||||
map[string]interface{}{"name": "develop"},
|
||||
map[string]interface{}{"name": "feature/x"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := RepoAuditRule(upstream, "repo-audit")
|
||||
if err != nil {
|
||||
t.Fatalf("RepoAuditRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
// Fully complete repo should have a high score.
|
||||
composite := analysis["composite_score"].(float64)
|
||||
if composite < 80 {
|
||||
t.Errorf("expected composite >= 80 for complete repo, got %.1f", composite)
|
||||
}
|
||||
|
||||
// Should have no missing items.
|
||||
missing := analysis["missing_items"].([]string)
|
||||
if len(missing) != 0 {
|
||||
t.Errorf("expected 0 missing items, got %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoAuditEmpty(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{"data": []interface{}{}},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"milestones": map[string]interface{}{"data": []interface{}{}},
|
||||
"branches": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := RepoAuditRule(upstream, "repo-audit")
|
||||
if err != nil {
|
||||
t.Fatalf("RepoAuditRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
// Empty repo should have a low score.
|
||||
composite := analysis["composite_score"].(float64)
|
||||
if composite >= 50 {
|
||||
t.Errorf("expected composite < 50 for empty repo, got %.1f", composite)
|
||||
}
|
||||
|
||||
// Should have missing items.
|
||||
missing := analysis["missing_items"].([]string)
|
||||
if len(missing) == 0 {
|
||||
t.Fatal("expected missing items for empty repo")
|
||||
}
|
||||
t.Logf("missing items: %v", missing)
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// finding holds a single code review finding.
|
||||
type finding struct {
|
||||
PRNumber interface{} `json:"pr_number"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
Lens string `json:"lens"`
|
||||
Severity string `json:"severity"`
|
||||
What string `json:"what"`
|
||||
Why string `json:"why"`
|
||||
Fix string `json:"fix"`
|
||||
}
|
||||
|
||||
// CodeReviewRule performs static analysis on PR metadata.
|
||||
func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
prs := extractPRs(upstream, "open-prs")
|
||||
if len(prs) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"findings": nil, "message": "no open PRs to review"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var findings []finding
|
||||
var actions []workflow.AIAction
|
||||
|
||||
for _, pr := range prs {
|
||||
title := str(pr, "title", "name")
|
||||
body := str(pr, "body", "description")
|
||||
prNum := interfaceToString(pr["id"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["number"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["pull_request_id"])
|
||||
}
|
||||
}
|
||||
if prNum == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
text := title + " " + body
|
||||
|
||||
// Security scan.
|
||||
for _, p := range reviewSecurityPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "security",
|
||||
Severity: p.severity,
|
||||
What: p.what,
|
||||
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
|
||||
Fix: p.fix,
|
||||
}
|
||||
findings = append(findings, f)
|
||||
|
||||
if p.severity == "high" {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "cli",
|
||||
Module: "issue",
|
||||
Command: "+comment",
|
||||
Args: map[string]string{
|
||||
"number": prNum,
|
||||
"body": fmt.Sprintf("⚠️ **安全审查警告**: %s\n\n建议: %s", p.what, p.fix),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainability scan.
|
||||
filesCount := 0
|
||||
if v, ok := pr["files_count"].(float64); ok {
|
||||
filesCount = int(v)
|
||||
}
|
||||
if filesCount > 50 {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "maintainability",
|
||||
Severity: "medium",
|
||||
What: fmt.Sprintf("PR 包含 %d 个文件,建议拆分为更小的 PR", filesCount),
|
||||
Why: "大 PR 难以审查,增加合并风险和回滚难度",
|
||||
Fix: "将改动按功能模块拆分为多个小 PR",
|
||||
}
|
||||
findings = append(findings, f)
|
||||
}
|
||||
|
||||
if body == "" && len(title) < 10 {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "maintainability",
|
||||
Severity: "low",
|
||||
What: "PR 缺少描述信息",
|
||||
Why: "不清晰的 PR 描述增加审查时间,降低代码质量",
|
||||
Fix: "添加 PR 描述,说明改动原因、影响范围和测试方式",
|
||||
}
|
||||
findings = append(findings, f)
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"reviewed_prs": len(prs),
|
||||
"total_findings": len(findings),
|
||||
"findings": findings,
|
||||
"summary": fmt.Sprintf("审查了 %d 个 PR,发现 %d 个问题", len(prs), len(findings)),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
type reviewPattern struct {
|
||||
re *regexp.Regexp
|
||||
severity string
|
||||
what string
|
||||
fix string
|
||||
}
|
||||
|
||||
var reviewSecurityPatterns = []reviewPattern{
|
||||
{
|
||||
regexp.MustCompile(`(?i)(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
|
||||
"high",
|
||||
"检测到硬编码凭据(密码/Token/密钥)",
|
||||
"将凭据移至环境变量或密钥管理服务,使用占位符替换",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)SELECT\s.*\sFROM\s.*WHERE\s.*\+`),
|
||||
"high",
|
||||
"检测到潜在 SQL 注入模式(字符串拼接构建 SQL)",
|
||||
"使用参数化查询或 ORM 框架",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)innerHTML\s*=|document\.write\(|eval\(`),
|
||||
"medium",
|
||||
"检测到潜在 XSS 风险(innerHTML / eval 使用)",
|
||||
"使用 textContent 替代 innerHTML,避免使用 eval",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
|
||||
"high",
|
||||
"检测到私钥明文",
|
||||
"立即删除私钥,使用密钥管理服务",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)ghp_[a-zA-Z0-9]{36}`),
|
||||
"high",
|
||||
"检测到 GitHub 个人访问令牌",
|
||||
"撤销此令牌,使用环境变量存储新令牌",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)os\.system\(|exec\(|subprocess\.call\(`),
|
||||
"medium",
|
||||
"检测到潜在命令注入风险",
|
||||
"避免将用户输入直接拼接到系统命令中,使用参数列表形式",
|
||||
},
|
||||
}
|
||||
|
||||
func interfaceToString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", val)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", val)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCodeReviewStaticAnalysis(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-prs": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "1",
|
||||
"title": "add feature",
|
||||
"body": "password = 'hardcoded12345678'",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "2",
|
||||
"title": "wip",
|
||||
"body": "",
|
||||
"files_count": 60.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CodeReviewRule(upstream, "review")
|
||||
if err != nil {
|
||||
t.Fatalf("CodeReviewRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
total := analysis["total_findings"].(int)
|
||||
if total == 0 {
|
||||
t.Fatal("expected findings for hardcoded password and large PR")
|
||||
}
|
||||
// Should have at least one high-severity security finding.
|
||||
findings := analysis["findings"].([]finding)
|
||||
hasSecurity := false
|
||||
hasMaint := false
|
||||
for _, f := range findings {
|
||||
if f.Lens == "security" {
|
||||
hasSecurity = true
|
||||
}
|
||||
if f.Lens == "maintainability" {
|
||||
hasMaint = true
|
||||
}
|
||||
}
|
||||
if !hasSecurity {
|
||||
t.Error("expected security finding for hardcoded password")
|
||||
}
|
||||
if !hasMaint {
|
||||
t.Error("expected maintainability finding for large PR or missing body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeReviewNoPRs(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := CodeReviewRule(upstream, "review")
|
||||
if err != nil {
|
||||
t.Fatalf("CodeReviewRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if msg := analysis["message"]; msg != "no open PRs to review" {
|
||||
t.Fatalf("expected 'no open PRs to review', got %v", msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// TriageRule classifies issues, assigns priorities, matches labels, and distributes work.
|
||||
func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
issues := extractIssues(upstream, "open-issues")
|
||||
labels := extractLabels(upstream)
|
||||
members := extractMembers(upstream)
|
||||
|
||||
if len(issues) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"classified": 0, "message": "no open issues to triage"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var actions []workflow.AIAction
|
||||
memberLoad := map[string]int{}
|
||||
classified := []map[string]interface{}{}
|
||||
gfi := []map[string]interface{}{}
|
||||
|
||||
for _, issue := range issues {
|
||||
num := issueNumber(issue)
|
||||
title := str(issue, "title")
|
||||
body := str(issue, "body", "description")
|
||||
text := title + " " + body
|
||||
|
||||
cat := classifyIssue(text)
|
||||
pri := assignPriority(text)
|
||||
labelIDs := matchLabels(cat, labels)
|
||||
assignee := leastLoaded(memberLoad, members)
|
||||
|
||||
if assignee != "" {
|
||||
memberLoad[assignee]++
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"number": num,
|
||||
"title": title,
|
||||
"category": cat,
|
||||
"priority": pri,
|
||||
"assignee": assignee,
|
||||
}
|
||||
classified = append(classified, result)
|
||||
|
||||
// Build PATCH action if we have labels or assignee.
|
||||
body2 := map[string]interface{}{}
|
||||
if len(labelIDs) > 0 {
|
||||
body2["issue_tag_ids"] = labelIDs
|
||||
}
|
||||
if assignee != "" {
|
||||
body2["assigner_ids"] = []string{assignee}
|
||||
}
|
||||
if pri > 0 {
|
||||
body2["priority_id"] = pri
|
||||
}
|
||||
if len(body2) > 0 && num != "" {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "api", Method: "PATCH",
|
||||
Path: fmt.Sprintf("{v1}/issues/%s", num),
|
||||
Body: body2,
|
||||
})
|
||||
}
|
||||
|
||||
if isGoodFirstIssue(text) {
|
||||
gfi = append(gfi, result)
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "cli", Module: "issue", Command: "+comment",
|
||||
Args: map[string]string{
|
||||
"number": num,
|
||||
"body": "👋 感谢提交 Issue!这个 Issue 已被标记为 **Good First Issue**,适合新贡献者参与。欢迎提交 PR!",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"classified": len(classified),
|
||||
"results": classified,
|
||||
"good_first_issues": gfi,
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
// --- classification ---
|
||||
|
||||
var catPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
category string
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)错误|失败|异常|崩溃|crash|error|bug|broken|404|500`), "bug"},
|
||||
{regexp.MustCompile(`(?i)安全|漏洞|泄露|vulnerability|CVE|敏感`), "security"},
|
||||
{regexp.MustCompile(`(?i)性能|慢|卡顿|优化|performance|speed`), "performance"},
|
||||
{regexp.MustCompile(`(?i)重构|代码质量|refactor|clean\s*up|tech\s*debt`), "refactor"},
|
||||
{regexp.MustCompile(`(?i)建议|希望|新增|支持|feature|enhancement|add|improve`), "enhancement"},
|
||||
{regexp.MustCompile(`(?i)文档|README|帮助|doc|documentation|typo`), "docs"},
|
||||
{regexp.MustCompile(`(?i)如何|怎么|请问|how\s*to|question|help|求助`), "question"},
|
||||
}
|
||||
|
||||
var priorityPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
pri int
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)紧急|urgent|critical|崩溃|crash|严重|安全|漏洞|CVE|P0`), 4},
|
||||
{regexp.MustCompile(`(?i)重要|high|important|P1|阻断`), 3},
|
||||
{regexp.MustCompile(`(?i)低|low|trivial|minor|P3`), 1},
|
||||
}
|
||||
|
||||
func classifyIssue(text string) string {
|
||||
for _, p := range catPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
return p.category
|
||||
}
|
||||
}
|
||||
return "enhancement" // default
|
||||
}
|
||||
|
||||
func assignPriority(text string) int {
|
||||
for _, p := range priorityPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
return p.pri
|
||||
}
|
||||
}
|
||||
return 2 // default: medium
|
||||
}
|
||||
|
||||
func isGoodFirstIssue(text string) bool {
|
||||
gfiRe := regexp.MustCompile(`(?i)good\s*first\s*issue|beginner|easy|简单|新手|入门`)
|
||||
if gfiRe.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
// Also mark simple enhancements/docs as GFI.
|
||||
cat := classifyIssue(text)
|
||||
pri := assignPriority(text)
|
||||
return (cat == "docs" || cat == "enhancement") && pri <= 2 &&
|
||||
len(strings.Fields(text)) < 200
|
||||
}
|
||||
|
||||
// --- label matching ---
|
||||
|
||||
func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
|
||||
return extractList(upstream, "labels")
|
||||
}
|
||||
|
||||
func matchLabels(category string, labels []map[string]interface{}) []interface{} {
|
||||
catLower := strings.ToLower(category)
|
||||
var ids []interface{}
|
||||
for _, l := range labels {
|
||||
name := strings.ToLower(str(l, "name", "title", "label"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// Direct match or contains.
|
||||
if name == catLower || strings.Contains(name, catLower) || strings.Contains(catLower, name) {
|
||||
if id := labelID(l); id != nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also match sub-categories for bug.
|
||||
if catLower == "bug" {
|
||||
for _, l := range labels {
|
||||
name := strings.ToLower(str(l, "name", "title", "label"))
|
||||
if strings.Contains(name, "bug") || strings.Contains(name, "fix") {
|
||||
if id := labelID(l); id != nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func labelID(l map[string]interface{}) interface{} {
|
||||
for _, k := range []string{"id", "tag_id", "label_id"} {
|
||||
if v := l[k]; v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- assignment ---
|
||||
|
||||
func leastLoaded(load map[string]int, members map[string]string) string {
|
||||
if len(members) == 0 {
|
||||
return ""
|
||||
}
|
||||
best := ""
|
||||
bestN := -1
|
||||
for login := range members {
|
||||
n := load[login]
|
||||
if bestN < 0 || n < bestN {
|
||||
bestN = n
|
||||
best = login
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func issueNumber(issue map[string]interface{}) string {
|
||||
for _, k := range []string{"project_issues_index", "number", "iid", "id"} {
|
||||
s := fmt.Sprint(issue[k])
|
||||
if s != "" && s != "0" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestTriageRuleClassifiesByKeyword(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "1", "title": "fix crash on startup", "body": "应用启动时崩溃"},
|
||||
map[string]interface{}{"project_issues_index": "2", "title": "新增导出功能", "body": ""},
|
||||
map[string]interface{}{"project_issues_index": "3", "title": "如何配置SSO", "body": "请问怎么配"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "bug"},
|
||||
map[string]interface{}{"id": 2, "name": "enhancement"},
|
||||
map[string]interface{}{"id": 3, "name": "question"},
|
||||
},
|
||||
},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
|
||||
analysis, ok := resp.Analysis.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Analysis is not a map")
|
||||
}
|
||||
if v := analysis["classified"]; v.(int) != 3 {
|
||||
t.Fatalf("expected 3 classified, got %v", v)
|
||||
}
|
||||
if len(resp.Actions) == 0 {
|
||||
t.Fatal("expected actions for issue triage")
|
||||
}
|
||||
|
||||
// Verify each action type.
|
||||
for _, a := range resp.Actions {
|
||||
if a.Type == "api" && a.Method != "PATCH" {
|
||||
t.Errorf("unexpected API method: %s", a.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleEmptyIssues(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if len(resp.Actions) != 0 {
|
||||
t.Fatalf("expected 0 actions for empty issues, got %d", len(resp.Actions))
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["classified"]; v.(int) != 0 {
|
||||
t.Fatalf("expected 0 classified, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleGoodFirstIssue(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "10", "title": "good first issue: add docs", "body": "easy task for beginners"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "docs"},
|
||||
},
|
||||
},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
hasComment := false
|
||||
for _, a := range resp.Actions {
|
||||
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
|
||||
hasComment = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasComment {
|
||||
t.Fatal("expected a cli comment action for good first issue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRulePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
title string
|
||||
expected int
|
||||
}{
|
||||
{"紧急: 安全漏洞", 4},
|
||||
{"重要功能", 3},
|
||||
{"普通建议", 2},
|
||||
{"低优先级改进", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "1", "title": tc.title, "body": ""},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed for %q: %v", tc.title, err)
|
||||
}
|
||||
if len(resp.Actions) > 0 {
|
||||
body := resp.Actions[0].Body
|
||||
if v, ok := body["priority_id"]; ok {
|
||||
if v.(int) != tc.expected {
|
||||
t.Errorf("title=%q: priority_id=%v, want %d", tc.title, v, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleOutputFormat(t *testing.T) {
|
||||
resp, err := TriageRule(map[string]interface{}{}, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
// Verify it's a valid workflow.AIResponse.
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
// LoadState reads the persisted workflow state from disk.
|
||||
func LoadState(name string) (*WorkflowState, error) {
|
||||
path := statePath(name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &WorkflowState{
|
||||
Workflow: name,
|
||||
Snapshots: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var s WorkflowState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parse state file %s: %w", path, err)
|
||||
}
|
||||
if s.Snapshots == nil {
|
||||
s.Snapshots = make(map[string]string)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Save persists the workflow state to disk.
|
||||
func (s *WorkflowState) Save() error {
|
||||
s.LastRun = time.Now().Format(time.RFC3339)
|
||||
path := statePath(s.Workflow)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// Diff compares current step results against stored snapshots.
|
||||
// Returns the names of steps whose data changed since the last run.
|
||||
func (s *WorkflowState) Diff(results []StepResult) []string {
|
||||
changed := []string{}
|
||||
for _, sr := range results {
|
||||
if !sr.OK || sr.Data == nil {
|
||||
continue
|
||||
}
|
||||
hash := hashData(sr.Data)
|
||||
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
|
||||
changed = append(changed, sr.Step)
|
||||
}
|
||||
s.Snapshots[sr.Step] = hash
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hashData computes an MD5 hash of the JSON-encoded data.
|
||||
func hashData(data interface{}) string {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%x", md5.Sum(b))
|
||||
}
|
||||
|
||||
// statePath returns the file path for a workflow's state file.
|
||||
func statePath(name string) string {
|
||||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s-state.json", name))
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// StepResult holds the outcome of executing one step.
|
||||
type StepResult struct {
|
||||
Step string `json:"step"`
|
||||
Purpose string `json:"purpose"`
|
||||
Type StepType `json:"type"`
|
||||
OK bool `json:"ok"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ExecuteStep dispatches a step to the right executor based on its Type.
|
||||
func ExecuteStep(ctx *common.RuntimeContext, step StepDef, dryRun bool) *StepResult {
|
||||
sr := &StepResult{
|
||||
Step: step.Name,
|
||||
Purpose: step.Purpose,
|
||||
Type: step.Type,
|
||||
}
|
||||
|
||||
switch step.Type {
|
||||
case StepTypeAPI:
|
||||
executeAPIStep(ctx, step, sr)
|
||||
case StepTypeCommand:
|
||||
executeCommandStep(ctx, step, sr)
|
||||
case StepTypeSkill:
|
||||
executeSkillStep(ctx, step, sr, dryRun)
|
||||
default:
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("unknown step type: %q", step.Type)
|
||||
}
|
||||
return sr
|
||||
}
|
||||
|
||||
// executeAPIStep makes an HTTP call through the API client.
|
||||
func executeAPIStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
|
||||
path := resolvePath(step.Target, ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery(step.Method, path, step.Query)
|
||||
if err != nil {
|
||||
sr.OK = false
|
||||
sr.Error = err.Error()
|
||||
} else {
|
||||
sr.OK = env.OK
|
||||
sr.Data = env.Data
|
||||
}
|
||||
}
|
||||
|
||||
// executeCommandStep runs a gitlink-cli subcommand as a subprocess.
|
||||
func executeCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
|
||||
parts := parseCommandTarget(step.Target)
|
||||
if len(parts) == 0 {
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
|
||||
return
|
||||
}
|
||||
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
bin = "gitlink-cli"
|
||||
}
|
||||
|
||||
args := append(parts, "--format", "json")
|
||||
if ctx.Owner != "" {
|
||||
args = append(args, "--owner", ctx.Owner)
|
||||
}
|
||||
if ctx.Repo != "" {
|
||||
args = append(args, "--repo", ctx.Repo)
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stderr = nil
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("command failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(out, &data); err != nil {
|
||||
sr.OK = true
|
||||
sr.Data = strings.TrimSpace(string(out))
|
||||
} else {
|
||||
sr.OK = true
|
||||
sr.Data = data
|
||||
}
|
||||
}
|
||||
|
||||
// executeSkillStep runs a skill step. Depending on aiMode, it uses the AI API or
|
||||
// falls back to a deterministic rule engine. Both paths produce the same AIResponse
|
||||
// format, and actions from either source go through the same security whitelist.
|
||||
func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult, dryRun bool) {
|
||||
upstream := collectUpstream(ctx, step)
|
||||
|
||||
if dryRun {
|
||||
sr.OK = true
|
||||
sr.Data = map[string]interface{}{
|
||||
"_skill": step.Target,
|
||||
"_dry_run": true,
|
||||
"_depends_on": step.DependsOn,
|
||||
"_upstream": upstream,
|
||||
"_hint": "预览模式:展示将要传给 AI/规则引擎 的上游数据,不实际执行。",
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
aiMode := resolveAIMode(ctx)
|
||||
client := NewAIClient()
|
||||
var aiResp *AIResponse
|
||||
var usedAI bool
|
||||
|
||||
switch aiMode {
|
||||
case AIModeNoAI:
|
||||
resp, err := runRuleEngine(step, upstream)
|
||||
if err != nil {
|
||||
sr.OK = true
|
||||
sr.Data = map[string]interface{}{
|
||||
"_skill": step.Target,
|
||||
"_needs_ai": true,
|
||||
"_upstream": upstream,
|
||||
"_error": fmt.Sprintf("rule engine failed: %v", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
aiResp = resp
|
||||
|
||||
case AIModeAI:
|
||||
if !client.HasKey() {
|
||||
sr.OK = false
|
||||
sr.Error = "AI 模式需要配置 API Key(设置 ANTHROPIC_API_KEY 环境变量或 config set anthropic_api_key)"
|
||||
return
|
||||
}
|
||||
resp, err := callAI(client, step, upstream)
|
||||
if err != nil {
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("AI 调用失败: %v", err)
|
||||
return
|
||||
}
|
||||
aiResp = resp
|
||||
usedAI = true
|
||||
|
||||
default: // "auto"
|
||||
if client.HasKey() {
|
||||
resp, err := callAI(client, step, upstream)
|
||||
if err == nil {
|
||||
aiResp = resp
|
||||
usedAI = true
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[workflow] AI 调用失败,降级到规则引擎: %v\n", err)
|
||||
}
|
||||
}
|
||||
if aiResp == nil {
|
||||
resp, err := runRuleEngine(step, upstream)
|
||||
if err != nil {
|
||||
sr.OK = true
|
||||
sr.Data = map[string]interface{}{
|
||||
"_skill": step.Target,
|
||||
"_needs_ai": true,
|
||||
"_upstream": upstream,
|
||||
"_error": fmt.Sprintf("AI 和规则引擎均失败: %v", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
aiResp = resp
|
||||
}
|
||||
}
|
||||
|
||||
executed := executeActions(ctx, aiResp.Actions)
|
||||
|
||||
sr.OK = true
|
||||
sr.Data = map[string]interface{}{
|
||||
"ok": true,
|
||||
"analysis": aiResp.Analysis,
|
||||
"executed": executed,
|
||||
"_ai_used": usedAI,
|
||||
"_skill": step.Target,
|
||||
}
|
||||
}
|
||||
|
||||
// executeActions runs allowed actions from an AIResponse. Returns count of
|
||||
// successfully executed actions. Actions from both AI and rule engines pass
|
||||
// through the same security whitelist.
|
||||
func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
|
||||
executed := 0
|
||||
for _, action := range actions {
|
||||
if !isActionAllowed(action) {
|
||||
fmt.Fprintf(os.Stderr, "[workflow] blocked action: %s %s\n", action.Type, action.Command)
|
||||
continue
|
||||
}
|
||||
if action.Type == "api" {
|
||||
path := resolvePath(action.Path, ctx.Owner, ctx.Repo)
|
||||
_, err := ctx.CallAPI(action.Method, path, action.Body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[workflow] api action failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
executed++
|
||||
} else if action.Type == "cli" {
|
||||
args := []string{action.Module, action.Command}
|
||||
for k, v := range action.Args {
|
||||
args = append(args, "--"+k, v)
|
||||
}
|
||||
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
|
||||
bin, _ := os.Executable()
|
||||
if bin == "" {
|
||||
bin = "gitlink-cli"
|
||||
}
|
||||
err := exec.Command(bin, args...).Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[workflow] cli action failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
executed++
|
||||
}
|
||||
}
|
||||
return executed
|
||||
}
|
||||
|
||||
// resolveAIMode determines the effective AI mode from the context.
|
||||
func resolveAIMode(ctx *common.RuntimeContext) AIMode {
|
||||
switch ctx.AIMode {
|
||||
case "ai":
|
||||
return AIModeAI
|
||||
case "no-ai":
|
||||
return AIModeNoAI
|
||||
default:
|
||||
return AIModeAuto
|
||||
}
|
||||
}
|
||||
|
||||
// callAI invokes the Anthropic API for a skill step.
|
||||
func callAI(client *AIClient, step StepDef, upstream map[string]interface{}) (*AIResponse, error) {
|
||||
skillMD := readSkillDoc(step.Target)
|
||||
upstreamJSON, _ := json.MarshalIndent(upstream, "", " ")
|
||||
return client.Analyze(&AIRequest{
|
||||
SystemPrompt: skillMD,
|
||||
UserData: string(upstreamJSON),
|
||||
})
|
||||
}
|
||||
|
||||
// runRuleEngine looks up and invokes the rule engine for a skill target.
|
||||
func runRuleEngine(step StepDef, upstream map[string]interface{}) (*AIResponse, error) {
|
||||
engine, ok := RuleEngines[step.Target]
|
||||
if !ok {
|
||||
return nil, ErrNoRuleEngine(step.Target)
|
||||
}
|
||||
return engine(upstream, step.Name)
|
||||
}
|
||||
|
||||
// collectUpstream gathers data from steps declared in DependsOn.
|
||||
func collectUpstream(ctx *common.RuntimeContext, step StepDef) map[string]interface{} {
|
||||
upstream := make(map[string]interface{})
|
||||
for _, dep := range step.DependsOn {
|
||||
if v, ok := ctx.Args[dep]; ok {
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
|
||||
upstream[dep] = parsed
|
||||
} else {
|
||||
upstream[dep] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no DependsOn, collect all available upstream data.
|
||||
if len(step.DependsOn) == 0 {
|
||||
for k, v := range ctx.Args {
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
|
||||
upstream[k] = parsed
|
||||
} else {
|
||||
upstream[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return upstream
|
||||
}
|
||||
|
||||
// readSkillDoc reads the full SKILL.md for a given skill name.
|
||||
func readSkillDoc(target string) string {
|
||||
paths := []string{}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
paths = append(paths, filepath.Join(filepath.Dir(exe), "skills", target, "SKILL.md"))
|
||||
}
|
||||
paths = append(paths,
|
||||
filepath.Join("skills", target, "SKILL.md"),
|
||||
filepath.Join("/etc/gitlink-cli/skills", target, "SKILL.md"),
|
||||
)
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
paths = append(paths, filepath.Join(home, ".config", "gitlink-cli", "skills", target, "SKILL.md"))
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("# %s\n\nSkill documentation not found.", target)
|
||||
}
|
||||
|
||||
// Security whitelist for AI-generated actions.
|
||||
|
||||
var allowedAPIMethods = map[string]bool{
|
||||
"GET": true, "POST": true, "PATCH": true,
|
||||
}
|
||||
|
||||
var allowedCLIModules = map[string]bool{
|
||||
"issue": true, "pr": true, "release": true,
|
||||
"wiki": true, "member": true, "label": true,
|
||||
"milestone": true, "branch": true, "comment": true,
|
||||
}
|
||||
|
||||
var blockedCLICommands = map[string]bool{
|
||||
"+delete": true, "+remove": true, "+batch-delete": true,
|
||||
"+fork": true, "+batch-fork": true,
|
||||
}
|
||||
|
||||
func isActionAllowed(action AIAction) bool {
|
||||
if action.Type == "api" {
|
||||
if !allowedAPIMethods[action.Method] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if action.Type == "cli" {
|
||||
if !allowedCLIModules[action.Module] {
|
||||
return false
|
||||
}
|
||||
if blockedCLICommands[action.Command] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseCommandTarget splits a CLI command string into tokens,
|
||||
// respecting quoted arguments.
|
||||
func parseCommandTarget(target string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for i := 0; i < len(target); i++ {
|
||||
c := target[i]
|
||||
switch {
|
||||
case c == '"' || c == '\'':
|
||||
if inQuote && c == quoteChar {
|
||||
inQuote = false
|
||||
quoteChar = 0
|
||||
} else if !inQuote {
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
} else {
|
||||
current.WriteByte(c)
|
||||
}
|
||||
case c == ' ' && !inQuote:
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
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.Diff(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.Diff(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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// AIMode controls whether AI is used for skill steps.
|
||||
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
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ErrNoRuleEngine is returned when no rule engine is registered for a target.
|
||||
func ErrNoRuleEngine(target string) error {
|
||||
return fmt.Errorf("no rule engine registered for skill target %q", target)
|
||||
}
|
||||
|
||||
// StepType classifies what mechanism executes a step.
|
||||
type StepType string
|
||||
|
||||
const (
|
||||
StepTypeSkill StepType = "skill"
|
||||
StepTypeCommand StepType = "command"
|
||||
StepTypeAPI StepType = "api"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
Purpose string `json:"purpose"`
|
||||
Target string `json:"target"`
|
||||
DependsOn []string `json:"depends_on,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Query url.Values `json:"-"`
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// WorkflowDef is a named, ordered sequence of steps with a trigger.
|
||||
type WorkflowDef struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Trigger TriggerDef `json:"trigger"`
|
||||
Steps []StepDef `json:"steps"`
|
||||
}
|
||||
|
||||
// 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"}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- 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{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List available workflows",
|
||||
Flags: []common.Flag{
|
||||
{Name: "category", Short: "c", Usage: "Filter by category", Default: ""},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
cat := ctx.Arg("category")
|
||||
workflows := All()
|
||||
filtered := make([]*WorkflowDef, 0)
|
||||
for _, w := range workflows {
|
||||
if cat == "" || strings.EqualFold(w.Category, cat) {
|
||||
filtered = append(filtered, w)
|
||||
}
|
||||
}
|
||||
type listItem struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
StepCount int `json:"step_count"`
|
||||
}
|
||||
items := make([]listItem, len(filtered))
|
||||
for i, w := range filtered {
|
||||
items[i] = listItem{
|
||||
Name: w.Name,
|
||||
Category: w.Category,
|
||||
Description: w.Description,
|
||||
StepCount: len(w.Steps),
|
||||
}
|
||||
}
|
||||
return ctx.OutputData(items)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show workflow detail (steps and trigger)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
return ctx.OutputData(wf)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run",
|
||||
Description: "Execute a workflow manually",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
{Name: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
|
||||
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
|
||||
aiMode, err := resolveAIModeFromArgs(ctx)
|
||||
if err != nil {
|
||||
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 runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "init",
|
||||
Description: "Run project one-click initialization workflow",
|
||||
Flags: []common.Flag{
|
||||
{Name: "dry-run", Usage: "Preview initialization without AI calls", Bool: true},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
wf := Get("project-init")
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", "project-init")
|
||||
}
|
||||
|
||||
aiMode, err := resolveAIModeFromArgs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "Poll for changes and trigger workflow on delta",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 30s, 5m, 1h)", Default: "5m"},
|
||||
{Name: "step", Short: "s", Usage: "Step name to watch for changes (default: first step)", Default: ""},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
ctx.AIMode = aiMode
|
||||
|
||||
return Watch(ctx, wf, interval, ctx.Arg("step"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "schedule",
|
||||
Description: "Run a workflow on a repeating schedule",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Run interval (e.g. 1h, 24h)", Default: "24h"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
ctx.AIMode = aiMode
|
||||
|
||||
return Schedule(ctx, wf, interval)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "start",
|
||||
Description: "Start workflow as background daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 5m, 1h)", Default: "5m"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
|
||||
return StartDaemon(ctx, wf, interval, aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stop",
|
||||
Description: "Stop workflow daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return StopDaemon(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Description: "Show daemon status",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return StatusDaemon(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "logs",
|
||||
Description: "View daemon log output",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "follow", Short: "f", Usage: "Follow log output (like tail -f)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tailDaemonLog(name, ctx.Arg("follow") == "true")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "install-systemd",
|
||||
Description: "Generate systemd service unit for a workflow daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval", Default: "5m"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
|
||||
return installSystemdUnit(ctx, wf, ctx.Arg("interval"), aiMode)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
|
||||
ai := ctx.Arg("ai") == "true"
|
||||
noAI := ctx.Arg("no-ai") == "true"
|
||||
if ai && noAI {
|
||||
return "", fmt.Errorf("--ai 和 --no-ai 互斥,只能指定其中一个")
|
||||
}
|
||||
if ai {
|
||||
return "ai", nil
|
||||
}
|
||||
if noAI {
|
||||
return "no-ai", nil
|
||||
}
|
||||
return "auto", nil
|
||||
}
|
||||
|
||||
func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) error {
|
||||
result, err := RunWithMode(ctx, wf, dryRun, aiMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
needsAI := 0
|
||||
ruleEngine := 0
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_needs_ai"]; v == true {
|
||||
needsAI++
|
||||
}
|
||||
if v, _ := m["_ai_used"]; v == true {
|
||||
ruleEngine++ // AI was used
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needsAI > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\n⚠ %d 个 skill 步骤需要 AI 处理:\n", needsAI)
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_needs_ai"]; v == true {
|
||||
fmt.Fprintf(os.Stderr, " - %s (%s)\n", sr.Step, m["_skill"])
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n你可以:\n")
|
||||
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set anthropic_api_key <key>\n")
|
||||
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
|
||||
} else if ruleEngine > 0 {
|
||||
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
|
||||
} else {
|
||||
noAI := 0
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_ai_used"]; v == false {
|
||||
if _, hasSkill := m["_skill"]; hasSkill {
|
||||
noAI++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if noAI > 0 {
|
||||
fmt.Fprintf(os.Stderr, "⚙️ 规则引擎已处理 %d 个 skill 步骤 (未使用 AI)\n", noAI)
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.Output(output.SuccessEnvelope(result, nil))
|
||||
}
|
||||
|
|
@ -0,0 +1,565 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
if len(registry) != 5 {
|
||||
t.Fatalf("expected 5 workflows, got %d", len(registry))
|
||||
}
|
||||
|
||||
for _, name := range []string{"community-ops", "code-quality", "project-init", "multi-repo", "contributor-growth"} {
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
t.Fatalf("workflow %q not found", name)
|
||||
}
|
||||
if len(wf.Steps) == 0 {
|
||||
t.Fatalf("workflow %q has no steps", name)
|
||||
}
|
||||
if wf.Trigger.Type == "" {
|
||||
t.Fatalf("workflow %q has no trigger.type", name)
|
||||
}
|
||||
}
|
||||
|
||||
all := All()
|
||||
if len(all) != 5 {
|
||||
t.Fatalf("All() returned %d workflows, expected 5", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNonexistent(t *testing.T) {
|
||||
if Get("nonexistent") != nil {
|
||||
t.Fatal("expected nil for nonexistent workflow")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}{
|
||||
{"{v1}/issues", "chroe", "gitlink-cli", "/v1/chroe/gitlink-cli/issues"},
|
||||
{"{base}/pulls", "chroe", "gitlink-cli", "/chroe/gitlink-cli/pulls"},
|
||||
{"{base}", "org", "proj", "/org/proj"},
|
||||
{"{v1}/issues?state=open", "x", "y", "/v1/x/y/issues?state=open"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggers(t *testing.T) {
|
||||
expected := map[string]struct {
|
||||
on string
|
||||
typ string
|
||||
}{
|
||||
"community-ops": {"issue.created", "poll"},
|
||||
"code-quality": {"pr.opened", "poll"},
|
||||
"project-init": {"manual", "manual"},
|
||||
"multi-repo": {"0 9 * * 1", "cron"},
|
||||
"contributor-growth": {"0 9 * * 1", "cron"},
|
||||
}
|
||||
for name, want := range expected {
|
||||
wf := Get(name)
|
||||
if wf.Trigger.On != want.on {
|
||||
t.Fatalf("%s: trigger.on = %q, want %q", name, wf.Trigger.On, want.on)
|
||||
}
|
||||
if wf.Trigger.Type != want.typ {
|
||||
t.Fatalf("%s: trigger.type = %q, want %q", name, wf.Trigger.Type, want.typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepTypes(t *testing.T) {
|
||||
wf := Get("community-ops")
|
||||
if wf == nil {
|
||||
t.Fatal("community-ops not found")
|
||||
}
|
||||
|
||||
typeCounts := map[StepType]int{}
|
||||
for _, s := range wf.Steps {
|
||||
typeCounts[s.Type]++
|
||||
}
|
||||
if typeCounts[StepTypeCommand] < 1 {
|
||||
t.Fatal("community-ops should have at least one command step")
|
||||
}
|
||||
if typeCounts[StepTypeSkill] < 1 {
|
||||
t.Fatal("community-ops should have at least one skill step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepDependsOn(t *testing.T) {
|
||||
wf := Get("community-ops")
|
||||
if wf == nil {
|
||||
t.Fatal("community-ops not found")
|
||||
}
|
||||
|
||||
var triage *StepDef
|
||||
for i := range wf.Steps {
|
||||
if wf.Steps[i].Name == "triage" {
|
||||
triage = &wf.Steps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if triage == nil {
|
||||
t.Fatal("triage step not found")
|
||||
}
|
||||
if len(triage.DependsOn) != 3 {
|
||||
t.Fatalf("triage step should have 3 dependencies, got %d: %v", len(triage.DependsOn), triage.DependsOn)
|
||||
}
|
||||
expectedDeps := map[string]bool{"open-issues": false, "labels": false, "members": false}
|
||||
for _, dep := range triage.DependsOn {
|
||||
if _, ok := expectedDeps[dep]; !ok {
|
||||
t.Fatalf("unexpected dependency: %s", dep)
|
||||
}
|
||||
expectedDeps[dep] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"issue +list --state open", []string{"issue", "+list", "--state", "open"}},
|
||||
{"repo +info", []string{"repo", "+info"}},
|
||||
{"pr +list --state merged --limit 50", []string{"pr", "+list", "--state", "merged", "--limit", "50"}},
|
||||
{"issue +list --state open --limit 50", []string{"issue", "+list", "--state", "open", "--limit", "50"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
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)
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
t.Fatal("code-quality not found")
|
||||
}
|
||||
if len(wf.Steps) < 7 {
|
||||
t.Fatalf("code-quality should have at least 7 steps (including review), got %d", len(wf.Steps))
|
||||
}
|
||||
found := false
|
||||
for _, s := range wf.Steps {
|
||||
if s.Target == "gitlink-review" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
# gitlink-workflow 参考手册
|
||||
|
||||
> 本文档定义跨模块工作流的 API 字段映射、Fork 协作流程和分支命名规范。
|
||||
|
||||
---
|
||||
|
||||
## 一、跨模块 API 字段关联
|
||||
|
||||
### 核心关联关系
|
||||
|
||||
```
|
||||
repo +info
|
||||
├── project_id ──────────────────→ milestone +list (project_id)
|
||||
├── forked_from_project_id ──────→ upstream 仓库标识
|
||||
├── open_devops ─────────────────→ ci +builds 可用性
|
||||
└── default_branch ──────────────→ branch +protect 的目标
|
||||
|
||||
pr +create
|
||||
├── --head <user>:<branch> ──────→ branch +create 的产物
|
||||
├── --base <branch> ─────────────→ 目标分支(通常 master)
|
||||
└── pr +view 返回的 id ──────────→ pr +merge / pr +files 的参数
|
||||
|
||||
issue +create
|
||||
└── 返回的 project_issues_index ──→ issue +close / issue +comment 的 --number
|
||||
```
|
||||
|
||||
### PR 状态映射
|
||||
|
||||
| pull_request_status | 含义 | 对应 `--state` |
|
||||
|---------------------|------|---------------|
|
||||
| 0 | 开放 | open |
|
||||
| 1 | 已合并 | merged |
|
||||
| 2 | 已关闭 | closed |
|
||||
|
||||
> ⚠️ `pr +list --state` 参数仅影响统计计数,返回列表可能包含所有状态。客户端需按 `pull_request_status` 字段过滤。
|
||||
|
||||
### PR 合并方式
|
||||
|
||||
| `--do` 参数 | 说明 |
|
||||
|------------|------|
|
||||
| `merge` | 标准合并(创建 merge commit) |
|
||||
| `rebase` | Rebase 合并(线性历史) |
|
||||
| `squash` | Squash 合并(压缩为单个 commit) |
|
||||
|
||||
---
|
||||
|
||||
## 二、Fork 协作流程(详细版)
|
||||
|
||||
### 何时必须 Fork
|
||||
|
||||
- 向**非自己拥有**的仓库提交 PR
|
||||
- 没有目标仓库的写权限
|
||||
- 即使是仓库成员,也推荐 Fork 流程
|
||||
|
||||
### 完整 Fork 工作流
|
||||
|
||||
```bash
|
||||
# 1. Fork 目标仓库
|
||||
gitlink-cli repo +fork --owner <target-org> --repo <target-repo>
|
||||
|
||||
# 2. 克隆自己的 Fork
|
||||
git clone https://gitlink.org.cn/<my-username>/<target-repo>.git
|
||||
cd <target-repo>
|
||||
|
||||
# 3. 添加上游 remote
|
||||
git remote add upstream https://gitlink.org.cn/<target-org>/<target-repo>.git
|
||||
|
||||
# 4. 同步上游最新代码
|
||||
git fetch upstream
|
||||
git checkout master
|
||||
git merge upstream/master
|
||||
git push origin master
|
||||
|
||||
# 5. 创建功能分支
|
||||
git checkout -b feature/my-change
|
||||
|
||||
# 6. 开发和提交
|
||||
git add -A
|
||||
git commit -m "feat: 我的改动"
|
||||
|
||||
# 7. 推送到自己的 Fork
|
||||
git push origin feature/my-change
|
||||
|
||||
# 8. 从 Fork 向主仓库提 PR
|
||||
gitlink-cli pr +create \
|
||||
--owner <target-org> --repo <target-repo> \
|
||||
--head <my-username>:feature/my-change \
|
||||
--base master \
|
||||
--title "feat: 我的改动"
|
||||
|
||||
# 9. 上游有更新时同步
|
||||
git fetch upstream
|
||||
git merge upstream/master
|
||||
git push origin master
|
||||
```
|
||||
|
||||
### 禁止操作
|
||||
|
||||
- ❌ 直接 clone 主仓库后 push(污染主仓库)
|
||||
- ❌ 向主仓库的 master 分支直接推送
|
||||
- ❌ 使用 `--force` push 到任何共享分支
|
||||
|
||||
---
|
||||
|
||||
## 三、分支命名规范
|
||||
|
||||
### 推荐命名
|
||||
|
||||
| 类型 | 模式 | 示例 |
|
||||
|------|------|------|
|
||||
| 新功能 | `feature/<描述>` | `feature/wiki-create` |
|
||||
| Bug 修复 | `fix/<描述>` | `fix/batch-url-encoding` |
|
||||
| 文档 | `docs/<描述>` | `docs/api-reference` |
|
||||
| 重构 | `refactor/<描述>` | `refactor/auth-module` |
|
||||
| 发布准备 | `release/<版本>` | `release/v1.0.0` |
|
||||
| 紧急修复 | `hotfix/<描述>` | `hotfix/critical-bug` |
|
||||
|
||||
### GitLink 分支映射
|
||||
|
||||
| 平台 | 默认主分支 |
|
||||
|------|-----------|
|
||||
| GitLink | `master` |
|
||||
| GitHub | `main` |
|
||||
|
||||
gitlink-cli 在与 GitLink 交互时自动处理映射:
|
||||
- push 时:`main` → `master`
|
||||
- pull 时:`master` → `main`
|
||||
|
||||
---
|
||||
|
||||
## 四、项目初始化清单
|
||||
|
||||
完整的项目初始化应覆盖以下全部:
|
||||
|
||||
| 序号 | 项目 | 命令 |
|
||||
|------|------|------|
|
||||
| 1 | 创建仓库 | `repo +create` |
|
||||
| 2 | 克隆到本地 | `repo +clone` |
|
||||
| 3 | 创建 README | 本地创建后 git push |
|
||||
| 4 | 创建 .gitignore | 本地创建后 git push |
|
||||
| 5 | 保护主分支 | `branch +protect --name master` |
|
||||
| 6 | 创建开发分支 | `branch +create --name develop` |
|
||||
| 7 | 创建初始 Issue | `issue +create` |
|
||||
| 8 | 创建里程碑 | `milestone +create` |
|
||||
| 9 | 开启 DevOps | `api POST /.../activate` |
|
||||
| 10 | 配置流水线 | 创建 `.devops/` 目录和 YAML 文件 |
|
||||
| 11 | 邀请团队成员 | `org +batch-invite`(组织仓库)或 `member +add` |
|
||||
|
||||
---
|
||||
|
||||
## 五、已知限制
|
||||
|
||||
| 限制 | 说明 |
|
||||
|------|------|
|
||||
| PR 创建需要代码差异 | 分支内容必须与目标分支不同,否则 GitLink 拒绝创建 |
|
||||
| `api POST` 有 URL bug | 部分 Raw API 写操作不可用,优先使用 shortcuts |
|
||||
| 不能删除远程分支 | `branch +delete` 的 API 不可用(GitLink 平台 bug) |
|
||||
| PR state 过滤不精确 | `pr +list --state` 仅影响计数,需客户端二次过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
### Q: 创建 PR 时报错"无变更"?
|
||||
|
||||
A: 确认你的分支上有不同于 base 分支的新 commit。如果是刚创建的空白分支,先提交代码再创建 PR。
|
||||
|
||||
### Q: PR 合并后需要手动删分支吗?
|
||||
|
||||
A: GitLink 目前不自动删除合并后的分支。可以用 `git push origin --delete <branch>` 或通过 Web 页面手动删除。
|
||||
|
||||
### Q: 如何同步 Fork 仓库与上游?
|
||||
|
||||
A: `git fetch upstream && git merge upstream/master && git push origin master`
|
||||
|
||||
### Q: 项目初始化后 DevOps 还是不可用?
|
||||
|
||||
A: `api POST /activate` 可能因 URL bug 失败。最可靠的方式是在 GitLink Web 页面手动开启。
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
# GitLink Workflow 服务器部署指南
|
||||
|
||||
本文档描述如何将 workflow 引擎部署到 Linux 服务器,使用 systemd timer 实现 24×7 自动化运行。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 编译
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -buildvcs=false -o gitlink-cli .
|
||||
```
|
||||
|
||||
### 2. 上传
|
||||
|
||||
```bash
|
||||
scp gitlink-cli root@39.108.139.73:/usr/local/bin/
|
||||
scp -r skills/ root@39.108.139.73:/etc/gitlink-cli/skills/
|
||||
```
|
||||
|
||||
### 3. 登录配置
|
||||
|
||||
```bash
|
||||
ssh root@39.108.139.73
|
||||
gitlink-cli config set anthropic-api-key sk-ant-xxx
|
||||
gitlink-cli auth login
|
||||
```
|
||||
|
||||
### 4. 创建 systemd service
|
||||
|
||||
以 `contributor-growth` 工作流为例:
|
||||
|
||||
```bash
|
||||
cat > /etc/systemd/system/gitlink-contributor.service << 'EOF'
|
||||
[Unit]
|
||||
Description=GitLink Workflow: contributor-growth
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/gitlink-cli workflow +run \
|
||||
--name contributor-growth \
|
||||
--owner chroe --repo gitlink-cli \
|
||||
--format json
|
||||
User=root
|
||||
EOF
|
||||
```
|
||||
|
||||
### 5. 创建 systemd timer
|
||||
|
||||
```bash
|
||||
cat > /etc/systemd/system/gitlink-contributor.timer << 'EOF'
|
||||
[Unit]
|
||||
Description=GitLink Contributor Growth — daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
```
|
||||
|
||||
### 6. 启动
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitlink-contributor.timer
|
||||
```
|
||||
|
||||
### 7. 验证
|
||||
|
||||
```bash
|
||||
systemctl status gitlink-contributor.timer
|
||||
journalctl -u gitlink-contributor.service -f
|
||||
```
|
||||
|
||||
## 多工作流部署
|
||||
|
||||
每个工作流需要独立的 service 和 timer 文件。例如同时运行社区运营和贡献者成长:
|
||||
|
||||
```bash
|
||||
# community-ops
|
||||
cat > /etc/systemd/system/gitlink-community.service << 'EOF'
|
||||
[Unit]
|
||||
Description=GitLink Workflow: community-ops
|
||||
After=network-online.target
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/gitlink-cli workflow +run \
|
||||
--name community-ops --owner chroe --repo gitlink-cli --format json
|
||||
User=root
|
||||
EOF
|
||||
|
||||
cat > /etc/systemd/system/gitlink-community.timer << 'EOF'
|
||||
[Unit]
|
||||
Description=GitLink Community Ops — every 5m
|
||||
[Timer]
|
||||
OnCalendar=*:0/5
|
||||
Persistent=true
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl enable --now gitlink-community.timer
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保服务器上 `gitlink-cli` 已通过 `auth login` 认证
|
||||
- `ANTHROPIC_API_KEY` 可通过环境变量或配置文件设置
|
||||
- 使用 `journalctl -u <service> -f` 查看实时日志
|
||||
- Timer 的 `OnCalendar` 语法参考 systemd.time(7) 手册
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
# 示例:PR 全流程(真实数据)
|
||||
|
||||
> 本示例基于 `chroe/gitlink-cli` 于 2026-06-12 在 Claude Code 中实际执行。
|
||||
> 展示从分支创建到 PR 创建(模拟)的完整流程。
|
||||
|
||||
---
|
||||
|
||||
## 执行命令序列
|
||||
|
||||
### Step 1: 查看仓库状态
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
真实输出:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"full_name": "chroe/gitlink-cli",
|
||||
"default_branch": "master",
|
||||
"forked_from_project_id": 1513956,
|
||||
"fork_info": {
|
||||
"fork_form_name": "gitlink-cli",
|
||||
"fork_project_user_login": "Gitlink"
|
||||
},
|
||||
"identifier": "gitlink-cli",
|
||||
"issues_count": 6,
|
||||
"permission": "Manager",
|
||||
"private": false,
|
||||
"project_id": 1547045,
|
||||
"pull_requests_count": 0,
|
||||
"version_releases_count": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键信息:
|
||||
- 这是 Gitlink/gitlink-cli 的 Fork
|
||||
- 当前用户有 Manager 权限
|
||||
- 尚无 PR 记录
|
||||
|
||||
### Step 2: 查看现有分支
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +list --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
真实输出:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"branches": [
|
||||
{
|
||||
"branch_id": 4308486,
|
||||
"name": "master",
|
||||
"default_branch": "master",
|
||||
"protected": false,
|
||||
"commit": {
|
||||
"author": { "login": "chroe", "name": "chroe" },
|
||||
"message": "三个skill\n",
|
||||
"timestamp": "2026-06-11T17:06:49+08:00",
|
||||
"time_ago": "1天前"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
分析:
|
||||
- 仅 `master` 分支
|
||||
- 最后提交:2026-06-11,"三个skill"
|
||||
- master 未保护 ⚠️
|
||||
|
||||
### Step 3: 创建功能分支
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +create --owner chroe --repo gitlink-cli --name feature/add-skills
|
||||
```
|
||||
|
||||
预期输出确认分支创建成功。
|
||||
|
||||
### Step 4: 本地开发和推送
|
||||
|
||||
```bash
|
||||
# 在本地仓库中
|
||||
git checkout -b feature/add-skills
|
||||
# ... 编写代码 ...
|
||||
git add -A
|
||||
git commit -m "feat(skills): 新增 repo/org/ci/workflow 四个 AI Agent Skill"
|
||||
git push origin feature/add-skills
|
||||
```
|
||||
|
||||
### Step 5: 创建 PR
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create \
|
||||
--owner chroe --repo gitlink-cli \
|
||||
--head yetja:feature/add-skills \
|
||||
--base master \
|
||||
--title "feat(skills): 新增 repo/org/ci/workflow 四个 AI Agent Skill" \
|
||||
--body "## 变更说明
|
||||
|
||||
参照已有 changelog/health/triage 的格式,为以下 4 个模块创建 AI Agent Skill:
|
||||
|
||||
- **gitlink-repo**: 仓库健康审计与智能管理
|
||||
- **gitlink-org**: 组织治理与成员管理
|
||||
- **gitlink-ci**: CI/CD 构建诊断与监控
|
||||
- **gitlink-workflow**: 跨模块联动工作流
|
||||
|
||||
每个 Skill 包含:
|
||||
- SKILL.md(AI Agent 指令)
|
||||
- REFERENCE.md(技术参考手册)
|
||||
- examples/(真实运行示例)
|
||||
|
||||
## 关联 Issue
|
||||
|
||||
- 任务:补充 AI Agent Skills
|
||||
|
||||
## 测试
|
||||
|
||||
- [x] 所有 CLI 命令已在真实仓库上验证
|
||||
- [x] 示例文件包含真实 CLI 输出"
|
||||
```
|
||||
|
||||
### Step 6: Code Review
|
||||
|
||||
```bash
|
||||
# 查看 PR 详情
|
||||
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <pr-id> --format json
|
||||
|
||||
# 查看变更文件列表
|
||||
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <pr-id> --format json
|
||||
```
|
||||
|
||||
AI Review 要点:
|
||||
1. SKILL.md 格式是否与 changelog/health/triage 一致
|
||||
2. REFERENCE.md 是否覆盖了所有 API 字段
|
||||
3. examples/ 中的输出是否为真实数据
|
||||
4. 是否有硬编码的敏感信息
|
||||
|
||||
### Step 7: 合并 PR
|
||||
|
||||
```bash
|
||||
# 确认 CI 通过(如果开启了 DevOps)
|
||||
# 合并(squash 方式,将多个 commit 压缩为一个)
|
||||
gitlink-cli pr +merge --owner chroe --repo gitlink-cli --id <pr-id> --do squash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI PR 流程状态摘要
|
||||
|
||||
```markdown
|
||||
## 🔀 PR 全流程状态
|
||||
|
||||
| 阶段 | 状态 | 详情 |
|
||||
|------|------|------|
|
||||
| 分支创建 | ✅ | feature/add-skills |
|
||||
| 代码提交 | ✅ | 8 个文件变更(+1200/-200) |
|
||||
| PR 创建 | ✅ | PR: "feat(skills): 新增 4 个 AI Agent Skill" |
|
||||
| Code Review | 🔍 | 待审查 |
|
||||
| 合并 | ⏳ | 等待 Review 通过 |
|
||||
|
||||
### 变更摘要
|
||||
|
||||
| 文件 | 操作 | 行数 |
|
||||
|------|------|------|
|
||||
| skills/gitlink-repo/SKILL.md | 重写 | +120 |
|
||||
| skills/gitlink-repo/REFERENCE.md | 新增 | +180 |
|
||||
| skills/gitlink-repo/examples/repo-health-check.md | 新增 | +150 |
|
||||
| skills/gitlink-org/SKILL.md | 重写 | +110 |
|
||||
| skills/gitlink-org/REFERENCE.md | 新增 | +160 |
|
||||
| skills/gitlink-org/examples/org-audit.md | 新增 | +140 |
|
||||
| skills/gitlink-ci/SKILL.md | 重写 | +120 |
|
||||
| skills/gitlink-ci/REFERENCE.md | 新增 | +170 |
|
||||
| skills/gitlink-ci/examples/ci-devops-check.md | 新增 | +130 |
|
||||
| skills/gitlink-workflow/SKILL.md | 重写 | +150 |
|
||||
| skills/gitlink-workflow/REFERENCE.md | 新增 | +190 |
|
||||
| skills/gitlink-workflow/examples/pr-workflow.md | 新增 | +140 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Fork 协作**:向 `Gitlink/gitlink-cli`(上游)提 PR 时,需要从 `chroe/gitlink-cli`(Fork)发起
|
||||
2. **分支保护**:当前 master 未保护,建议 `branch +protect --name master`
|
||||
3. **commit 规范**:使用 `feat(skills):` 前缀,与项目现有风格一致
|
||||
Loading…
Reference in New Issue