diff --git a/shortcuts/workflow/aiclient.go b/shortcuts/workflow/aiclient.go new file mode 100644 index 0000000..a29b6e2 --- /dev/null +++ b/shortcuts/workflow/aiclient.go @@ -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 != "" +} diff --git a/shortcuts/workflow/code_quality.go b/shortcuts/workflow/code_quality.go new file mode 100644 index 0000000..fa2c238 --- /dev/null +++ b/shortcuts/workflow/code_quality.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/community_ops.go b/shortcuts/workflow/community_ops.go new file mode 100644 index 0000000..d8a2911 --- /dev/null +++ b/shortcuts/workflow/community_ops.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/contributor_growth.go b/shortcuts/workflow/contributor_growth.go new file mode 100644 index 0000000..2e0adb3 --- /dev/null +++ b/shortcuts/workflow/contributor_growth.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/daemon.go b/shortcuts/workflow/daemon.go new file mode 100644 index 0000000..1ec4391 --- /dev/null +++ b/shortcuts/workflow/daemon.go @@ -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)) +} diff --git a/shortcuts/workflow/engine.go b/shortcuts/workflow/engine.go new file mode 100644 index 0000000..70688de --- /dev/null +++ b/shortcuts/workflow/engine.go @@ -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 +} diff --git a/shortcuts/workflow/manifest.go b/shortcuts/workflow/manifest.go new file mode 100644 index 0000000..d99f289 --- /dev/null +++ b/shortcuts/workflow/manifest.go @@ -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() +} diff --git a/shortcuts/workflow/multi_repo.go b/shortcuts/workflow/multi_repo.go new file mode 100644 index 0000000..e0e2c28 --- /dev/null +++ b/shortcuts/workflow/multi_repo.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/project_init.go b/shortcuts/workflow/project_init.go new file mode 100644 index 0000000..ec24413 --- /dev/null +++ b/shortcuts/workflow/project_init.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/rules/changelog.go b/shortcuts/workflow/rules/changelog.go new file mode 100644 index 0000000..90426db --- /dev/null +++ b/shortcuts/workflow/rules/changelog.go @@ -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() +} diff --git a/shortcuts/workflow/rules/changelog_test.go b/shortcuts/workflow/rules/changelog_test.go new file mode 100644 index 0000000..fc7b17e --- /dev/null +++ b/shortcuts/workflow/rules/changelog_test.go @@ -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 +} diff --git a/shortcuts/workflow/rules/ci_diagnosis.go b/shortcuts/workflow/rules/ci_diagnosis.go new file mode 100644 index 0000000..0c86ae2 --- /dev/null +++ b/shortcuts/workflow/rules/ci_diagnosis.go @@ -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 +} diff --git a/shortcuts/workflow/rules/ci_diagnosis_test.go b/shortcuts/workflow/rules/ci_diagnosis_test.go new file mode 100644 index 0000000..4680621 --- /dev/null +++ b/shortcuts/workflow/rules/ci_diagnosis_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/rules/contributor.go b/shortcuts/workflow/rules/contributor.go new file mode 100644 index 0000000..160ceb5 --- /dev/null +++ b/shortcuts/workflow/rules/contributor.go @@ -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 "" +} \ No newline at end of file diff --git a/shortcuts/workflow/rules/contributor_test.go b/shortcuts/workflow/rules/contributor_test.go new file mode 100644 index 0000000..b6c0855 --- /dev/null +++ b/shortcuts/workflow/rules/contributor_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/rules/dispatch.go b/shortcuts/workflow/rules/dispatch.go new file mode 100644 index 0000000..6d096ab --- /dev/null +++ b/shortcuts/workflow/rules/dispatch.go @@ -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) +} diff --git a/shortcuts/workflow/rules/health_report.go b/shortcuts/workflow/rules/health_report.go new file mode 100644 index 0000000..0e93c13 --- /dev/null +++ b/shortcuts/workflow/rules/health_report.go @@ -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) +} diff --git a/shortcuts/workflow/rules/health_report_test.go b/shortcuts/workflow/rules/health_report_test.go new file mode 100644 index 0000000..30effc8 --- /dev/null +++ b/shortcuts/workflow/rules/health_report_test.go @@ -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 +} diff --git a/shortcuts/workflow/rules/license.go b/shortcuts/workflow/rules/license.go new file mode 100644 index 0000000..6ff31a8 --- /dev/null +++ b/shortcuts/workflow/rules/license.go @@ -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))", "", "your_token_here", "xxx", "replace_me", " 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 +} diff --git a/shortcuts/workflow/rules/repo_audit_test.go b/shortcuts/workflow/rules/repo_audit_test.go new file mode 100644 index 0000000..50c4666 --- /dev/null +++ b/shortcuts/workflow/rules/repo_audit_test.go @@ -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) +} diff --git a/shortcuts/workflow/rules/review.go b/shortcuts/workflow/rules/review.go new file mode 100644 index 0000000..5c269cb --- /dev/null +++ b/shortcuts/workflow/rules/review.go @@ -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) + } +} diff --git a/shortcuts/workflow/rules/review_test.go b/shortcuts/workflow/rules/review_test.go new file mode 100644 index 0000000..4ed6a80 --- /dev/null +++ b/shortcuts/workflow/rules/review_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/rules/triage.go b/shortcuts/workflow/rules/triage.go new file mode 100644 index 0000000..54dc54a --- /dev/null +++ b/shortcuts/workflow/rules/triage.go @@ -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 != "" { + return s + } + } + return "" +} diff --git a/shortcuts/workflow/rules/triage_test.go b/shortcuts/workflow/rules/triage_test.go new file mode 100644 index 0000000..b4d95ce --- /dev/null +++ b/shortcuts/workflow/rules/triage_test.go @@ -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 +} diff --git a/shortcuts/workflow/state.go b/shortcuts/workflow/state.go new file mode 100644 index 0000000..4866f13 --- /dev/null +++ b/shortcuts/workflow/state.go @@ -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)) +} diff --git a/shortcuts/workflow/steps.go b/shortcuts/workflow/steps.go new file mode 100644 index 0000000..c808de5 --- /dev/null +++ b/shortcuts/workflow/steps.go @@ -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 +} diff --git a/shortcuts/workflow/trigger.go b/shortcuts/workflow/trigger.go new file mode 100644 index 0000000..6dc4a2b --- /dev/null +++ b/shortcuts/workflow/trigger.go @@ -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) + } + } +} diff --git a/shortcuts/workflow/types.go b/shortcuts/workflow/types.go new file mode 100644 index 0000000..082c5f2 --- /dev/null +++ b/shortcuts/workflow/types.go @@ -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) +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go new file mode 100644 index 0000000..2b04069 --- /dev/null +++ b/shortcuts/workflow/workflow.go @@ -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 \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)) +} diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go new file mode 100644 index 0000000..af93a91 --- /dev/null +++ b/shortcuts/workflow/workflow_test.go @@ -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) + } +} diff --git a/skills/gitlink-workflow/REFERENCE.md b/skills/gitlink-workflow/REFERENCE.md new file mode 100644 index 0000000..6e8257e --- /dev/null +++ b/skills/gitlink-workflow/REFERENCE.md @@ -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 : ──────→ branch +create 的产物 +├── --base ─────────────→ 目标分支(通常 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 --repo + +# 2. 克隆自己的 Fork +git clone https://gitlink.org.cn//.git +cd + +# 3. 添加上游 remote +git remote add upstream https://gitlink.org.cn//.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 --repo \ + --head :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 ` 或通过 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 页面手动开启。 diff --git a/skills/gitlink-workflow/examples/deploy.md b/skills/gitlink-workflow/examples/deploy.md new file mode 100644 index 0000000..8f365ca --- /dev/null +++ b/skills/gitlink-workflow/examples/deploy.md @@ -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 -f` 查看实时日志 +- Timer 的 `OnCalendar` 语法参考 systemd.time(7) 手册 diff --git a/skills/gitlink-workflow/examples/pr-workflow.md b/skills/gitlink-workflow/examples/pr-workflow.md new file mode 100644 index 0000000..76a6645 --- /dev/null +++ b/skills/gitlink-workflow/examples/pr-workflow.md @@ -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 --format json + +# 查看变更文件列表 +gitlink-cli pr +files --owner chroe --repo gitlink-cli --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 --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):` 前缀,与项目现有风格一致