Compare commits

...

10 Commits

Author SHA1 Message Date
yetja c6ebe659bc fix(workflow): AI JSON 解析支持数字类型参数,补充 health-report wiki 发布逻辑
- parseAIResponse 将 args 中的数字值统一转为字符串,避免类型不匹配
- health-report 步骤 AI 模式下补充规则引擎的 wiki 发布动作
- 简化 init 工作流参数,去掉冗余选项
- 新增 mapKeys 辅助函数
2026-07-07 16:52:39 +08:00
yetja 43eb36cb33 feat(workflow): 新增 multi-repo 多仓库协同工作流,优化代码审查自动合并
- 新增 multi-repo 工作流:通过 --repos/--from 采集多仓库快照,生成统一 Issue/PR/Release 协调报告
- 新增 workflow-internal: 内部命令协议,multi-repo-snapshot 直接走 API 采集
- 代码审查规则改进:无问题 PR 自动 squash 合并,每 PR 均发布审查评论
- 合并 workflow init 命令到 +run,统一入口减少重复
- daemon 支持 multi-repo 参数透传,跳过 owner/repo 解析
- action 执行新增去重,避免重复操作
2026-07-07 16:05:56 +08:00
yetja 8eaeced2e8 docs: add scriptable output note 2026-07-07 08:41:02 +08:00
yetja 04a990da31 feat(workflow): Issue 分拣规则增强、展示页交互优化
- triage 规则支持更多 Issue 分类维度
- 展示页样式与交互细节调整
- gitlink-triage Skill 文档同步更新
2026-07-07 08:25:08 +08:00
yetja ca616101c7 merge: test-review-dedup into workflow 2026-07-06 22:03:26 +08:00
yetja aa5e1ff6b9 test(triage): guard comments for closed issues 2026-07-06 22:01:23 +08:00
yetja c60c30676f feat(workflow): 工作流调度增强、展示页交互优化、新增 auto-merge/init-scaffold 规则
- 社区运营工作流增加 RunWhen 条件(RunAlways/RunWeekly)
- daemon/engine 调度逻辑优化
- 健康报告、贡献者分析、issue 分拣规则增强
- 新增 auto_merge、init_scaffold 工作流规则
- 展示页交互增强
2026-07-06 21:54:28 +08:00
yetja 25e1915579 feat(workflow): PR 审查去重、DeepSeek AI 迁移、展示页交互增强
- review 步骤增加 PR 指纹去重,避免重复审查已处理的 PR
- AI 客户端从 Anthropic 迁移到 DeepSeek API
- 贡献者排行 AI 模式补充 Wiki 发布动作
- daemon 轮询与调度逻辑优化
- 健康报告、贡献者分析等规则增强
- 展示页增加模式选择器、工作流参数面板、功能标签
2026-07-04 20:52:03 +08:00
yetja 4e4438eed6 test: add hardcoded GitHub token for security scan
token = 'ghp_1234567890abcdefghijklmnopqrstuv' should trigger the security scanner.
2026-07-04 14:31:33 +08:00
yetja cb2e401a83 refactor(showcase): 重构工作流展示为按工作流分块的操作面板
每个工作流独立成卡片,包含:执行(AI模式可选)/启动守护/停止/状态/日志/详情
顶部保留全局命令:列出所有工作流、生成systemd服务
2026-07-02 19:30:39 +08:00
42 changed files with 3946 additions and 284 deletions

View File

@ -20,6 +20,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — Interactive login or `GITLINK_TOKEN` env var, from install to first API call in just 3 steps
- **Secure & Controllable** — OS-native keychain credential storage, `GITLINK_TOKEN` env var for CI/CD & non-interactive environments, auto git remote context resolution
- **Scriptable Output** — Designed for repeatable terminal workflows and automation pipelines
- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → Raw API (full coverage) → Config (configuration management)
## Features

View File

@ -14,11 +14,11 @@ const (
)
type Config struct {
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
AnthropicAPIKey string `yaml:"anthropic_api_key,omitempty"`
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
DeepSeekAPIKey string `yaml:"deepseek_api_key,omitempty"`
}
func DefaultConfig() *Config {
@ -92,6 +92,8 @@ func Get(key string) (string, error) {
return cfg.Editor, nil
case "pager":
return cfg.Pager, nil
case "deepseek_api_key":
return cfg.DeepSeekAPIKey, nil
default:
return "", nil
}
@ -111,6 +113,8 @@ func Set(key, value string) error {
cfg.Editor = value
case "pager":
cfg.Pager = value
case "deepseek_api_key":
cfg.DeepSeekAPIKey = value
}
return Save(cfg)
}

View File

@ -7,15 +7,16 @@ import (
"io"
"net/http"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/internal/config"
)
const anthropicBaseURL = "https://api.anthropic.com/v1/messages"
const defaultModel = "claude-sonnet-4-6"
const deepseekBaseURL = "https://api.deepseek.com/v1/chat/completions"
const defaultModel = "deepseek-chat"
// AIClient wraps the Anthropic Messages API for skill step execution.
// AIClient wraps the DeepSeek API for skill step execution.
type AIClient struct {
apiKey string
model string
@ -34,13 +35,22 @@ type AIResponse struct {
Actions []AIAction `json:"actions"`
}
const jsonOutputInstruction = `
IMPORTANT: You MUST respond with a single JSON object in exactly this format:
{"analysis": "<your full markdown report as a single string>", "actions": [...list of actions...]}
The "analysis" field is a STRING containing your entire analysis report in markdown.
The "actions" field is an array of action objects: {"type":"cli", "module":"pr", "command":"+comment", "args":{"id":"4", "body":"..."}} or {"type":"api", "method":"POST", "path":"/v1/...", "body":{...}}.
Do NOT include any text outside the JSON object. Do NOT use markdown code fences around the JSON.`
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
func NewAIClient() *AIClient {
key := os.Getenv("ANTHROPIC_API_KEY")
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
cfg, err := config.Load()
if err == nil {
key = cfg.AnthropicAPIKey
key = cfg.DeepSeekAPIKey
}
}
if key == "" {
@ -49,23 +59,25 @@ func NewAIClient() *AIClient {
return &AIClient{
apiKey: key,
model: defaultModel,
http: &http.Client{Timeout: 60 * time.Second},
http: &http.Client{Timeout: 120 * time.Second},
}
}
// Analyze sends the skill prompt + upstream data to the Anthropic API and parses the response.
// Analyze sends the skill prompt + upstream data to the DeepSeek 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")
return nil, fmt.Errorf("AI client not configured: set DEEPSEEK_API_KEY or configure deepseek_api_key")
}
body := map[string]interface{}{
"model": c.model,
"max_tokens": 4096,
"system": req.SystemPrompt,
"model": c.model,
"max_tokens": 4096,
"temperature": 0.1,
"messages": []map[string]string{
{"role": "system", "content": req.SystemPrompt + jsonOutputInstruction},
{"role": "user", "content": req.UserData},
},
"response_format": map[string]string{"type": "json_object"},
}
payload, err := json.Marshal(body)
@ -73,13 +85,12 @@ func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequest("POST", anthropicBaseURL, bytes.NewReader(payload))
httpReq, err := http.NewRequest("POST", deepseekBaseURL, 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")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.http.Do(httpReq)
if err != nil {
@ -93,32 +104,108 @@ func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Anthropic API returned %d: %s", resp.StatusCode, string(respBody))
return nil, fmt.Errorf("DeepSeek API returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
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")
if len(result.Choices) == 0 {
return nil, fmt.Errorf("empty response from DeepSeek 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)
text := result.Choices[0].Message.Content
aiResp, err := parseAIResponse(text)
if err != nil {
// Fallback: try to extract JSON from markdown code fences.
if extracted := extractJSONFromMarkdown(text); extracted != "" {
aiResp2, err2 := parseAIResponse(extracted)
if err2 != nil {
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
}
aiResp = aiResp2
} else {
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
}
}
return &aiResp, nil
return aiResp, nil
}
// parseAIResponse unmarshals the AI's JSON output, coercing numeric arg values to strings.
func parseAIResponse(text string) (*AIResponse, error) {
// First pass: unmarshal into a flexible structure that accepts numbers in args.
raw := struct {
Analysis json.RawMessage `json:"analysis"`
Actions []struct {
Type string `json:"type"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Body map[string]interface{} `json:"body,omitempty"`
Module string `json:"module,omitempty"`
Command string `json:"command,omitempty"`
Args map[string]interface{} `json:"args,omitempty"`
} `json:"actions"`
}{}
if err := json.Unmarshal([]byte(text), &raw); err != nil {
return nil, err
}
resp := &AIResponse{}
if err := json.Unmarshal(raw.Analysis, &resp.Analysis); err != nil {
// If it's not valid JSON, treat it as a plain string.
resp.Analysis = string(raw.Analysis)
}
for _, a := range raw.Actions {
args := make(map[string]string, len(a.Args))
for k, v := range a.Args {
args[k] = fmt.Sprint(v)
}
resp.Actions = append(resp.Actions, AIAction{
Type: a.Type,
Method: a.Method,
Path: a.Path,
Body: a.Body,
Module: a.Module,
Command: a.Command,
Args: args,
})
}
return resp, nil
}
// HasKey reports whether the AI client is configured.
func (c *AIClient) HasKey() bool {
return c != nil && c.apiKey != ""
}
// extractJSONFromMarkdown tries to pull a JSON object out of a markdown code fence.
func extractJSONFromMarkdown(text string) string {
// Look for ```json ... ``` block.
start := strings.Index(text, "```json")
if start == -1 {
start = strings.Index(text, "```")
}
if start == -1 {
return ""
}
// Find end of opening fence.
nl := strings.Index(text[start:], "\n")
if nl == -1 {
return ""
}
content := text[start+nl+1:]
end := strings.Index(content, "```")
if end == -1 {
return ""
}
return strings.TrimSpace(content[:end])
}

View File

@ -12,7 +12,7 @@ func registerCodeQuality() {
},
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: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},

View File

@ -14,13 +14,13 @@ func registerCommunityOps() {
{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: StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: RunAlways},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取提交历史分析活跃度", Target: "commit +list --limit 50"},
{Type: StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}},
{Type: StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: RunWeekly},
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
{Type: StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}},
{Type: StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: RunOnChange},
},
})
}

View File

@ -17,7 +17,7 @@ func registerContributorGrowth() {
{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"}},
{Type: StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
},
})
}

View File

@ -1,12 +1,14 @@
package workflow
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@ -21,17 +23,7 @@ func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Dura
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")
}
args := buildDaemonArgs(ctx, wf, interval, aiMode)
cmd := exec.Command(bin, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
@ -62,6 +54,32 @@ func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Dura
return nil
}
func buildDaemonArgs(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) []string {
args := []string{
"workflow", "+run", "--name", wf.Name,
"--format", "json", "--daemon-loop",
"--interval", interval.String(),
}
if !isExplicitMultiRepoRun(ctx, wf) {
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
}
if aiMode == "ai" {
args = append(args, "--ai")
} else if aiMode == "no-ai" {
args = append(args, "--no-ai")
}
if repos := ctx.Arg("repos"); repos != "" {
args = append(args, "--repos", repos)
}
if from := ctx.Arg("from"); from != "" {
args = append(args, "--from", from)
}
if release := ctx.Arg("release"); release != "" {
args = append(args, "--release", release)
}
return args
}
// StopDaemon stops a running workflow daemon by name.
func StopDaemon(name string) error {
pid, err := readPID(name)
@ -143,15 +161,18 @@ func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
}
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
if len(changed) == 0 {
if state.TotalRuns > 0 {
fmt.Fprintf(os.Stderr, "[%s] 没有检测到变更\n", time.Now().Format(time.RFC3339))
state.TotalRuns++
state.Save()
return
}
// First run: establish baseline snapshot, then proceed to full run.
} else {
fmt.Fprintf(os.Stderr, "[%s] 🔔 检测到变更: %v\n", time.Now().Format(time.RFC3339), changed)
}
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 {
@ -159,8 +180,11 @@ func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
return
}
// Reload state — Run() may have updated ReviewedPRs fingerprints.
state, _ = LoadState(wf.Name)
state.TotalRuns++
state.Diff(fullResult.Steps)
state.UpdateSnapshots(fullResult.Steps)
state.Save()
ok, total := 0, len(fullResult.Steps)
@ -170,6 +194,74 @@ func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
}
}
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
// Log step-level details: failures and rule-engine findings.
for _, sr := range fullResult.Steps {
if !sr.OK {
fmt.Fprintf(os.Stderr, "[%s] ❌ %s 失败: %s\n", time.Now().Format(time.RFC3339), sr.Step, sr.Error)
}
if sr.Type == StepTypeSkill && sr.Data != nil {
logSkillFindings(sr)
}
}
}
// logSkillFindings prints rule-engine/AI analysis findings from a skill step to the daemon log.
func logSkillFindings(sr StepResult) {
m, ok := sr.Data.(map[string]interface{})
if !ok {
return
}
skill, _ := m["_skill"].(string)
analysis := m["analysis"]
fmt.Fprintf(os.Stderr, "[%s] ── %s 分析结果 ──\n", time.Now().Format(time.RFC3339), skill)
// AI mode returns analysis as a markdown string; print it directly.
if s, ok := analysis.(string); ok && s != "" {
for _, line := range strings.Split(s, "\n") {
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format(time.RFC3339), line)
}
return
}
// Rule engine returns analysis as a structured map.
am, _ := analysis.(map[string]interface{})
if am == nil {
return
}
if summary, ok := am["summary"].(string); ok && summary != "" {
fmt.Fprintf(os.Stderr, "[%s] 📋 %s\n", time.Now().Format(time.RFC3339), summary)
}
if findings, ok := am["findings"]; ok && findings != nil {
raw, _ := json.Marshal(findings)
var arr []interface{}
if json.Unmarshal(raw, &arr) == nil {
for _, f := range arr {
if fm, ok := f.(map[string]interface{}); ok {
sev := fm["severity"]
what := fm["what"]
fmt.Fprintf(os.Stderr, "[%s] [%v] %v\n", time.Now().Format(time.RFC3339), sev, what)
if prNum, ok := fm["pr_number"]; ok && prNum != nil {
fmt.Fprintf(os.Stderr, "[%s] PR: #%v\n", time.Now().Format(time.RFC3339), prNum)
}
}
}
}
}
if msg, ok := am["message"].(string); ok && msg != "" {
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format(time.RFC3339), msg)
}
if reviewData, ok := am["reviewed_prs"]; ok {
fmt.Fprintf(os.Stderr, "[%s] 已审查 PR 数: %v\n", time.Now().Format(time.RFC3339), reviewData)
}
if totalFindings, ok := am["total_findings"]; ok {
fmt.Fprintf(os.Stderr, "[%s] 发现问题数: %v\n", time.Now().Format(time.RFC3339), totalFindings)
}
}
// daemonLogPath returns the log file path for a workflow daemon.

View File

@ -0,0 +1,57 @@
package workflow
import (
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/internal/client"
"net/http"
"net/http/httptest"
)
func TestDaemonCycleTriageResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return a simple issue list
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true,"data":{"issues":[{"project_issues_index":"1","title":"fix crash","body":"app crashes on startup"}]}}`))
}))
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "test", Repo: "test", Format: "json",
Args: map[string]string{},
}
wf := &WorkflowDef{
Name: "test-daemon-cycle",
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-issues", Purpose: "issues", Target: "issue +list --state open"},
{Type: StepTypeCommand, Name: "labels", Purpose: "labels", Target: "label +list"},
{Type: StepTypeCommand, Name: "members", Purpose: "members", Target: "member +list"},
{Type: StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}},
},
}
// Simulate daemon cycle: dry-run then full run with same ctx
t.Log("=== Dry run ===")
dryResult, _ := Run(ctx, wf, true)
for _, sr := range dryResult.Steps {
t.Logf("dry %s: ok=%v data=%v", sr.Step, sr.OK, sr.Data)
}
t.Log("=== Full run ===")
fullResult, _ := Run(ctx, wf, false)
for _, sr := range fullResult.Steps {
t.Logf("full %s: ok=%v data=%v", sr.Step, sr.OK, sr.Data)
if sr.Step == "triage" {
d, ok := sr.Data.(map[string]interface{})
if ok {
a, _ := d["analysis"].(map[string]interface{})
t.Logf("triage analysis: %v", a)
if a != nil {
t.Logf("classified: %v", a["classified"])
}
}
}
}
}

View File

@ -3,7 +3,9 @@ package workflow
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -34,8 +36,10 @@ func RunWithMode(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMod
}
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 !shouldSkipOwnerRepoResolve(ctx, wf) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return nil, fmt.Errorf("resolve repo: %w", err)
}
}
if ctx.Args == nil {
@ -44,23 +48,80 @@ func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowRes
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
ctx.Args["dry_run"] = "true"
}
ctx.Args["__wf_name"] = wf.Name
// Make owner and repo available to skill rules as _owner / _repo.
if ctx.Owner != "" {
ctx.Args["_owner"] = ctx.Owner
}
if ctx.Repo != "" {
ctx.Args["_repo"] = ctx.Repo
}
// Load state for condition checks (only in non-dry-run mode).
var state *WorkflowState
if !dryRun {
state, _ = LoadState(wf.Name)
}
results := make([]StepResult, 0, len(wf.Steps))
for _, step := range wf.Steps {
// Compute upstream hash for condition checking and state tracking.
var upstreamHash string
if !dryRun && state != nil {
upstream := collectUpstream(ctx, step)
upstreamHash = hashData(upstream)
}
// Check phase condition for non-dry-run, non-default steps.
if !dryRun && state != nil && step.RunWhen != "" && step.RunWhen != RunAlways {
shouldRun, skipReason := checkPhaseCondition(step, state, upstreamHash)
if !shouldRun {
fmt.Fprintf(os.Stderr, "[%s] 跳过 %s: %s\n", wf.Name, step.Name, skipReason)
results = append(results, StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
OK: true,
Skipped: true,
SkipReason: skipReason,
})
continue
}
}
sr := ExecuteStep(ctx, step, dryRun)
results = append(results, *sr)
// After successful step, update state for future condition checks.
if !dryRun && state != nil && sr.OK && !sr.Skipped {
if state.PhaseLastRun == nil {
state.PhaseLastRun = make(map[string]string)
}
state.PhaseLastRun[step.Name] = time.Now().Format(time.RFC3339)
if state.PhaseUpstream == nil {
state.PhaseUpstream = make(map[string]string)
}
state.PhaseUpstream[step.Name] = upstreamHash
}
// Feed output of this step as input to downstream steps via Args.
if sr.OK && sr.Data != nil {
if sr.Data != nil {
raw, err := json.Marshal(sr.Data)
if err == nil {
ctx.Args[step.Name] = string(raw)
} else {
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
}
} else if !sr.OK && sr.Error != "" {
ctx.Args[step.Name] = fmt.Sprintf(`{"_error": true, "_message": %q}`, sr.Error)
}
}
if !dryRun && state != nil {
state.Save()
}
return &WorkflowResult{
Workflow: wf.Name,
Owner: ctx.Owner,
@ -69,6 +130,44 @@ func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowRes
}, nil
}
func shouldSkipOwnerRepoResolve(ctx *common.RuntimeContext, wf *WorkflowDef) bool {
return isExplicitMultiRepoRun(ctx, wf)
}
func isExplicitMultiRepoRun(ctx *common.RuntimeContext, wf *WorkflowDef) bool {
if wf == nil || wf.Name != "multi-repo" {
return false
}
return ctx.Arg("repos") != "" || ctx.Arg("from") != ""
}
// checkPhaseCondition determines whether a step should run based on its RunWhen setting.
func checkPhaseCondition(step StepDef, state *WorkflowState, upstreamHash string) (bool, string) {
switch step.RunWhen {
case RunWeekly:
last := state.PhaseLastRun[step.Name]
if last == "" {
return true, "" // first run
}
t, err := time.Parse(time.RFC3339, last)
if err != nil {
return true, ""
}
if time.Since(t) >= 7*24*time.Hour {
return true, "" // more than 7 days
}
next := t.Add(7 * 24 * time.Hour)
return false, fmt.Sprintf("下次运行: %s", next.Format("01-02 15:04"))
case RunOnChange:
if prev, ok := state.PhaseUpstream[step.Name]; ok && prev == upstreamHash {
return false, "数据无变化"
}
return true, ""
default:
return true, ""
}
}
// resolvePath replaces template placeholders in a path string.
//
// {base} → /owner/repo

View File

@ -11,13 +11,19 @@ func registerMultiRepo() {
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"}},
{
Type: StepTypeCommand,
Name: "multi-repo-snapshot",
Purpose: "采集多个仓库的 Issue/PR/Release/Milestone 状态",
Target: "workflow-internal:multi-repo-snapshot",
},
{
Type: StepTypeSkill,
Name: "multi-repo-coordination",
Purpose: "生成统一 Issue 追踪、PR 看板、Release 协调报告",
Target: "gitlink-multi-repo",
DependsOn: []string{"multi-repo-snapshot"},
},
},
})
}

View File

@ -0,0 +1,196 @@
package workflow
import (
"encoding/csv"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type MultiRepoSnapshot struct {
Release string `json:"release,omitempty"`
Repos []RepoSnapshot `json:"repos"`
Errors []RepoError `json:"errors,omitempty"`
Generated string `json:"generated_at"`
}
type RepoSnapshot struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Info interface{} `json:"info,omitempty"`
OpenIssues interface{} `json:"open_issues,omitempty"`
OpenPRs interface{} `json:"open_prs,omitempty"`
Releases interface{} `json:"releases,omitempty"`
Milestones interface{} `json:"milestones,omitempty"`
}
type RepoError struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Step string `json:"step"`
Error string `json:"error"`
}
type repoRef struct {
Owner string
Repo string
}
func BuildMultiRepoSnapshot(ctx *common.RuntimeContext) (*MultiRepoSnapshot, error) {
repos, err := parseMultiRepoRefs(ctx.Arg("repos"), ctx.Arg("from"))
if err != nil {
return nil, err
}
if len(repos) == 0 {
return nil, fmt.Errorf("multi-repo workflow requires --repos owner/repo[,owner/repo...] or --from repos.csv")
}
snapshot := &MultiRepoSnapshot{
Release: ctx.Arg("release"),
Repos: make([]RepoSnapshot, 0, len(repos)),
Generated: time.Now().Format(time.RFC3339),
}
for _, ref := range repos {
repoSnap := RepoSnapshot{Owner: ref.Owner, Repo: ref.Repo}
collectRepoSnapshot(ctx, ref, &repoSnap, &snapshot.Errors)
snapshot.Repos = append(snapshot.Repos, repoSnap)
}
return snapshot, nil
}
func collectRepoSnapshot(ctx *common.RuntimeContext, ref repoRef, snap *RepoSnapshot, errs *[]RepoError) {
call := func(step, method, path string, q url.Values) interface{} {
env, err := ctx.CallAPIWithQuery(method, path, q)
if err != nil {
*errs = append(*errs, RepoError{Owner: ref.Owner, Repo: ref.Repo, Step: step, Error: err.Error()})
return nil
}
if env == nil || !env.OK {
msg := "request failed"
if env != nil && env.Error != nil {
msg = env.Error.Message
}
*errs = append(*errs, RepoError{Owner: ref.Owner, Repo: ref.Repo, Step: step, Error: msg})
return nil
}
return env.Data
}
base := fmt.Sprintf("/%s/%s", ref.Owner, ref.Repo)
v1 := fmt.Sprintf("/v1/%s/%s", ref.Owner, ref.Repo)
snap.Info = call("repo-info", "GET", base, nil)
issueQ := url.Values{}
issueQ.Set("page", "1")
issueQ.Set("limit", "100")
issueQ.Set("state", "open")
snap.OpenIssues = call("open-issues", "GET", v1+"/issues", issueQ)
prQ := url.Values{}
prQ.Set("page", "1")
prQ.Set("limit", "100")
prQ.Set("state", "open")
snap.OpenPRs = call("open-prs", "GET", base+"/pulls", prQ)
releaseQ := url.Values{}
releaseQ.Set("page", "1")
releaseQ.Set("limit", "100")
snap.Releases = call("releases", "GET", base+"/releases", releaseQ)
milestoneQ := url.Values{}
milestoneQ.Set("page", "1")
milestoneQ.Set("limit", "100")
milestoneQ.Set("category", "opening")
milestoneQ.Set("sort_by", "created_on")
milestoneQ.Set("sort_direction", "desc")
snap.Milestones = call("milestones", "GET", v1+"/milestones", milestoneQ)
}
func parseMultiRepoRefs(reposArg, fromPath string) ([]repoRef, error) {
var refs []repoRef
if reposArg != "" {
for _, raw := range strings.Split(reposArg, ",") {
ref, err := parseRepoRef(raw)
if err != nil {
return nil, err
}
refs = append(refs, ref)
}
}
if fromPath != "" {
fileRefs, err := parseRepoCSV(fromPath)
if err != nil {
return nil, err
}
refs = append(refs, fileRefs...)
}
return dedupeRepoRefs(refs), nil
}
func parseRepoRef(raw string) (repoRef, error) {
raw = strings.TrimSpace(raw)
parts := strings.Split(raw, "/")
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return repoRef{}, fmt.Errorf("invalid repo %q: expected owner/repo", raw)
}
return repoRef{Owner: strings.TrimSpace(parts[0]), Repo: strings.TrimSpace(parts[1])}, nil
}
func parseRepoCSV(path string) ([]repoRef, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read repo file %s: %w", path, err)
}
defer f.Close()
reader := csv.NewReader(f)
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse repo file %s: %w", path, err)
}
var refs []repoRef
for i, rec := range records {
if len(rec) == 0 {
continue
}
if i == 0 && len(rec) >= 2 && strings.EqualFold(strings.TrimSpace(rec[0]), "owner") && strings.EqualFold(strings.TrimSpace(rec[1]), "repo") {
continue
}
if len(rec) == 1 {
ref, err := parseRepoRef(rec[0])
if err != nil {
return nil, fmt.Errorf("line %d: %w", i+1, err)
}
refs = append(refs, ref)
continue
}
if strings.TrimSpace(rec[0]) == "" || strings.TrimSpace(rec[1]) == "" {
return nil, fmt.Errorf("line %d: owner and repo must not be empty", i+1)
}
refs = append(refs, repoRef{Owner: strings.TrimSpace(rec[0]), Repo: strings.TrimSpace(rec[1])})
}
return refs, nil
}
func dedupeRepoRefs(refs []repoRef) []repoRef {
seen := make(map[string]bool, len(refs))
out := make([]repoRef, 0, len(refs))
for _, ref := range refs {
key := ref.Owner + "/" + ref.Repo
if seen[key] {
continue
}
seen[key] = true
out = append(out, ref)
}
return out
}

View File

@ -4,20 +4,21 @@ func registerProjectInit() {
register(&WorkflowDef{
Name: "project-init",
Category: "初始化",
Description: "项目一键初始化:仓库检查 → 文件/Issue/里程碑初始 → CI 配置",
Description: "项目一键初始化:创建仓库 → 脚手架文件 → 标签/里程碑/Issue → 许可证审计 → 健康报告",
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: StepTypeSkill, Name: "init-scaffold", Purpose: "根据描述创建仓库并初始化脚手架README/LICENSE/.gitignore/标签/里程碑/Issue", Target: "gitlink-init-scaffold"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建并获取基础信息", Target: "repo +info"},
{Type: StepTypeCommand, Name: "existing-files", Purpose: "验证 README/LICENSE 文件", Target: "file +list"},
{Type: StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
{Type: StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
{Type: StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
{Type: StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
{Type: StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
{Type: StepTypeSkill, Name: "repo-audit", Purpose: "AI 综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "labels", "milestones", "branches"}},
{Type: StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
},
})
}

View File

@ -0,0 +1,149 @@
package rules
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// AutoMergeRule merges PRs when code quality criteria are met.
// Criteria: no high-severity security findings + CI is healthy.
func AutoMergeRule(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{}{
"merged": 0,
"reason": "没有开放的 PR",
},
}, nil
}
// Check CI health from ci-diagnosis step result.
ciHealthy := true
if ciData, ok := upstream["ci-diagnosis"].(map[string]interface{}); ok {
if ciAnalysis, ok := ciData["analysis"].(map[string]interface{}); ok {
ciHealthy = IsCIHealthy(ciAnalysis)
}
}
// Check for high-severity security findings from review step.
hasHighSeverity := false
if reviewData, ok := upstream["review"].(map[string]interface{}); ok {
if reviewAnalysis, ok := reviewData["analysis"].(map[string]interface{}); ok {
hasHighSeverity = hasHighSeverityFindings(reviewAnalysis)
}
}
if hasHighSeverity {
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": 0,
"reason": "Review 发现高危安全问题,阻止自动合并",
},
}, nil
}
if !ciHealthy {
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": 0,
"reason": "CI 构建未通过,阻止自动合并",
},
}, nil
}
// All checks passed — merge each open PR.
var actions []workflow.AIAction
openCount := 0
for _, pr := range prs {
// Only merge open PRs.
status := str(pr, "pull_request_status", "pull_request_staus", "status", "state")
if status != "" && status != "open" {
continue
}
openCount++
prNum := interfaceToString(pr["pull_request_number"])
if prNum == "" {
prNum = interfaceToString(pr["id"])
}
if prNum == "" {
prNum = interfaceToString(pr["number"])
}
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
}
if prNum == "" {
continue
}
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "pr",
Command: "+merge",
Args: map[string]string{
"id": prNum,
"method": "squash",
},
})
}
if len(actions) == 0 {
reason := "无法解析 PR 编号"
if openCount == 0 {
reason = "没有开放的 PR"
}
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": 0,
"reason": reason,
},
}, nil
}
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": len(actions),
"reason": fmt.Sprintf("质量达标,已合并 %d 个 PR", len(actions)),
},
Actions: actions,
}, nil
}
// hasHighSeverityFindings checks if the review analysis contains any
// high-severity security findings that should block auto-merge.
func hasHighSeverityFindings(analysis map[string]interface{}) bool {
findings, ok := analysis["findings"]
if !ok || findings == nil {
return false
}
// Handle []finding (direct from rule engine).
if fList, ok := findings.([]finding); ok {
for _, f := range fList {
if f.Severity == "high" && f.Lens == "security" {
return true
}
}
return false
}
// Handle []interface{} (after JSON round-trip).
if fList, ok := findings.([]interface{}); ok {
for _, item := range fList {
if fm, ok := item.(map[string]interface{}); ok {
sev := ""
lens := ""
if s, ok := fm["severity"].(string); ok {
sev = s
}
if l, ok := fm["lens"].(string); ok {
lens = l
}
if sev == "high" && lens == "security" {
return true
}
}
}
}
return false
}

View File

@ -173,3 +173,19 @@ func containsAny(s string, patterns ...string) bool {
}
return false
}
// IsCIHealthy checks whether CI diagnosis results indicate no failures.
func IsCIHealthy(analysis map[string]interface{}) bool {
if msg, ok := analysis["message"].(string); ok && msg == "no failed builds found" {
return true
}
if tf, ok := analysis["total_failures"]; ok {
switch v := tf.(type) {
case float64:
return v == 0
case int:
return v == 0
}
}
return true
}

View File

@ -1,7 +1,10 @@
package rules
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
@ -125,7 +128,29 @@ func ContributorRankingRule(upstream map[string]interface{}, stepName string) (*
"churn_risk": filterByTag(rankings, "churn-risk"),
"new_stars": filterByTag(rankings, "new-star"),
}
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
// Build wiki page content.
wikiContent := buildContributorWiki(analysis, rankings)
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
actions := []workflow.AIAction{
{
Type: "cli", Module: "wiki", Command: "+create",
Args: map[string]string{
"name": pageName,
"content": wikiContent,
"message": "自动生成贡献者排行榜",
},
},
{
Type: "cli", Module: "wiki", Command: "+update",
Args: map[string]string{
"name": pageName,
"content": wikiContent,
"message": "自动更新贡献者排行榜",
},
},
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
func ensureEntry(stats map[string]*contributorEntry, login string, members map[string]string) *contributorEntry {
@ -189,6 +214,21 @@ func extractMembers(upstream map[string]interface{}) map[string]string {
if !ok {
return nil
}
// Unwrap envelope: {"ok": true, "data": {"collaborators": [...]}}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
// Unwrap inner key: {"members": [...]} or {"collaborators": [...]}
if m, ok := raw.(map[string]interface{}); ok {
for _, key := range []string{"members", "collaborators"} {
if list, ok := m[key]; ok {
raw = list
break
}
}
}
members := map[string]string{}
list, ok := raw.([]interface{})
if !ok {
@ -200,9 +240,9 @@ func extractMembers(upstream map[string]interface{}) map[string]string {
continue
}
login := str(m, "login", "username", "name")
name := str(m, "name", "full_name", "display_name")
if login != "" {
members[login] = name
id := fmt.Sprint(m["id"])
if login != "" && id != "" && id != "0" && id != "<nil>" {
members[login] = id
}
}
return members
@ -219,6 +259,24 @@ func extractList(upstream map[string]interface{}, key string) []map[string]inter
raw = data
}
}
// Some CLI commands return data as a JSON-encoded string; try to decode it.
if s, ok := raw.(string); ok {
var parsed interface{}
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
raw = parsed
}
}
// GitLink API wraps lists inside a map: {"issues": [...], "milestones": [...], ...}
if m, ok := raw.(map[string]interface{}); ok {
for _, listKey := range []string{"commits", "issues", "pull_requests", "issue_tags", "tags", "releases", "members", "items", "milestones", "branches"} {
if v, ok := m[listKey]; ok {
if arr, ok := v.([]interface{}); ok {
raw = arr
break
}
}
}
}
list, _ := raw.([]interface{})
var out []map[string]interface{}
for _, item := range list {
@ -230,12 +288,37 @@ func extractList(upstream map[string]interface{}, key string) []map[string]inter
}
func authorLogin(m map[string]interface{}) string {
return str(m, "author", "login", "username", "committer", "user")
// Try top-level keys first.
if s := str(m, "login", "username"); s != "" {
return s
}
// Try nested author/committer/user.
for _, key := range []string{"author", "committer", "user"} {
if a, ok := m[key].(map[string]interface{}); ok {
if s := str(a, "login", "username", "name"); s != "" {
return s
}
}
}
return ""
}
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"} {
// Try Unix timestamp (commit_time).
for _, key := range []string{"commit_time", "committed_date", "authored_date"} {
switch v := m[key].(type) {
case float64:
if v > 0 {
return time.Unix(int64(v), 0).UTC().Format(time.RFC3339)
}
case string:
if v != "" {
return v
}
}
}
// Try string timestamps.
for _, key := range []string{"created_at", "updated_at"} {
if s := str(m, key); s != "" {
return s
}
@ -267,4 +350,59 @@ func str(m map[string]interface{}, keys ...string) string {
}
}
return ""
}
// buildContributorWiki generates a markdown wiki page from ranking data.
func buildContributorWiki(analysis map[string]interface{}, rankings []map[string]interface{}) string {
var sb strings.Builder
sb.WriteString("# 贡献者排行榜\n\n")
sb.WriteString(fmt.Sprintf("> 自动生成于 %s\n\n", time.Now().Format("2006-01-02 15:04")))
sb.WriteString("## 总览\n\n")
sb.WriteString("| 排名 | 贡献者 | 提交 | Issue | PR | 总计 | 趋势 | 标签 |\n")
sb.WriteString("|------|--------|------|-------|-----|------|------|------|\n")
for _, r := range rankings {
name := fmt.Sprint(r["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(r["login"])
}
tags := ""
if t, ok := r["tags"].([]string); ok && len(t) > 0 {
tags = strings.Join(t, ", ")
}
sb.WriteString(fmt.Sprintf("| %v | %s | %v | %v | %v | %v | %.0f%% | %s |\n",
r["rank"], name, r["commits"], r["issues"], r["prs"], r["total"], r["trend"], tags))
}
// 新星
if newStars, ok := analysis["new_stars"].([]map[string]interface{}); ok && len(newStars) > 0 {
sb.WriteString("\n## 新星\n\n")
for _, s := range newStars {
name := fmt.Sprint(s["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(s["login"])
}
sb.WriteString(fmt.Sprintf("- **%s** — 趋势 +%.0f%%\n", name, s["trend"]))
}
}
// 流失风险
if churn, ok := analysis["churn_risk"].([]map[string]interface{}); ok && len(churn) > 0 {
sb.WriteString("\n## 流失风险\n\n")
for _, c := range churn {
name := fmt.Sprint(c["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(c["login"])
}
last := fmt.Sprint(c["last_activity"])
if t, err := time.Parse(time.RFC3339, last); err == nil {
last = t.Format("2006-01-02")
}
sb.WriteString(fmt.Sprintf("- **%s** — 最后活动 %s\n", name, last))
}
}
sb.WriteString("\n> 由 contributor-growth 工作流自动生成\n")
return sb.String()
}

View File

@ -1,7 +1,9 @@
package rules
import (
"fmt"
"math"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
@ -70,7 +72,173 @@ func HealthReportRule(upstream map[string]interface{}, stepName string) (*workfl
},
},
}
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
// Build wiki page action to publish the report.
body := buildHealthReportMarkdown(analysis, upstream)
actions := []workflow.AIAction{{
Type: "cli",
Module: "wiki",
Command: "+create",
Args: map[string]string{
"name": fmt.Sprintf("健康度报告-%s", now.Format("2006-01-02")),
"content": body,
"message": "自动生成项目健康度报告",
},
}}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
// buildHealthReportMarkdown renders the analysis and upstream data as a markdown report.
func buildHealthReportMarkdown(analysis map[string]interface{}, upstream map[string]interface{}) string {
var b strings.Builder
now := time.Now()
b.WriteString("# 项目健康度报告\n\n")
fmt.Fprintf(&b, "> 报告生成时间:%s\n\n", now.Format("2006-01-02 15:04"))
composite, _ := analysis["composite"].(float64)
grade, _ := analysis["grade"].(string)
fmt.Fprintf(&b, "## 总体评分:%.1f 分(%s\n\n", composite, grade)
dims, _ := analysis["dimensions"].(map[string]interface{})
b.WriteString("| 维度 | 得分 | 权重 | 等级 |\n")
b.WriteString("|------|------|------|------|\n")
dimDefs := []struct{ key, label string }{
{"issue_health", "Issue 健康度"},
{"pr_health", "PR 健康度"},
{"contributor_health", "贡献者活跃度"},
{"activity", "项目活跃度"},
}
for _, d := range dimDefs {
if dim, ok := dims[d.key].(map[string]interface{}); ok {
score, _ := dim["score"].(float64)
weight, _ := dim["weight"].(float64)
g, _ := dim["grade"].(string)
fmt.Fprintf(&b, "| %s | %.1f | %.0f%% | %s |\n", d.label, score, weight*100, g)
}
}
fmt.Fprintf(&b, "| **综合评分** | **%.1f** | — | **%s** |\n\n", composite, grade)
b.WriteString("等级标准:优秀(≥80) | 良好(60-79) | 需改进(<60)\n\n")
// Detail sections
b.WriteString("## 各项指标详情\n\n")
// Issue health
b.WriteString("### Issue 健康度\n\n")
openIssues := extractIssues(upstream, "open-issues")
fmt.Fprintf(&b, "- 开放 Issue 数:%d\n", len(openIssues))
staleCount := 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 {
staleCount++
}
}
fmt.Fprintf(&b, "- 超 30 天未关闭 Issue 数:%d\n\n", staleCount)
// PR health
b.WriteString("### PR 健康度\n\n")
mergedPRs := extractPRs(upstream, "merged-prs")
fmt.Fprintf(&b, "- 已合并 PR 数:%d\n", len(mergedPRs))
var totalHours float64
prCount := 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()
prCount++
}
if prCount > 0 {
fmt.Fprintf(&b, "- 平均合并耗时:%.1f 天\n\n", totalHours/float64(prCount)/24)
} else {
b.WriteString("- 平均合并耗时N/A\n\n")
}
// Contributor health
b.WriteString("### 贡献者活跃度\n\n")
commits := extractCommits(upstream)
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
}
}
fmt.Fprintf(&b, "- 近 30 天活跃贡献者:%d 人\n\n", len(authors))
// Activity
b.WriteString("### 项目活跃度\n\n")
recentCommits := countRecent(commits, now, 30)
releaseCount := 0
if repoInfo := extractFirst(upstream, "repo-info"); repoInfo != nil {
if v, ok := repoInfo["release_count"].(float64); ok {
releaseCount = int(v)
}
}
fmt.Fprintf(&b, "- 近 30 天提交数:%d\n", recentCommits)
fmt.Fprintf(&b, "- 发行版本数:%d\n\n", releaseCount)
// Improvement suggestions
b.WriteString("## 改进建议\n\n")
suggestions := []string{}
if composite < 60 {
suggestions = append(suggestions, "- 项目整体健康度较低,建议重点关注以下改进方向")
}
if dim, ok := dims["issue_health"].(map[string]interface{}); ok {
if score, _ := dim["score"].(float64); score < 60 {
suggestions = append(suggestions, "- **Issue 管理**:及时关闭已解决的 Issue减少超 30 天未响应的 Issue 堆积")
}
}
if dim, ok := dims["pr_health"].(map[string]interface{}); ok {
if score, _ := dim["score"].(float64); score < 60 {
suggestions = append(suggestions, "- **PR 审查**:加快 PR Review 速度,目标将平均合并时间控制在 3 天以内")
}
}
if dim, ok := dims["contributor_health"].(map[string]interface{}); ok {
if score, _ := dim["score"].(float64); score < 60 {
suggestions = append(suggestions, "- **社区建设**:吸引更多贡献者参与项目,可以标记 good-first-issue 降低新贡献者参与门槛")
}
}
if dim, ok := dims["activity"].(map[string]interface{}); ok {
if score, _ := dim["score"].(float64); score < 60 {
suggestions = append(suggestions, "- **项目活跃度**:保持定期提交和版本发布节奏,增加项目可见度")
}
}
if len(suggestions) == 0 {
suggestions = append(suggestions, "- 项目整体健康度良好,继续保持当前节奏")
}
for _, s := range suggestions {
b.WriteString(s)
b.WriteString("\n")
}
b.WriteString("\n> 由 community-ops 工作流自动生成\n")
return b.String()
}
func scoreIssueHealth(total int, openIssues []map[string]interface{}, now time.Time) float64 {
@ -199,8 +367,14 @@ func extractFirst(upstream map[string]interface{}, key string) map[string]interf
if len(list) > 0 {
return list[0]
}
// Try direct map.
// Try direct map — key may contain the envelope {ok, data, ...}.
if m, ok := upstream[key].(map[string]interface{}); ok {
// Unwrap envelope if present.
if data, ok := m["data"]; ok {
if inner, ok := data.(map[string]interface{}); ok {
return inner
}
}
return m
}
return nil
@ -216,5 +390,11 @@ func grade(score float64) string {
}
func clamp(v float64) float64 {
return min(max(v, 0), 100)
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}

View File

@ -58,8 +58,18 @@ func TestHealthReportScoring(t *testing.T) {
}
}
}
if resp.Actions != nil {
t.Fatal("expected nil Actions (read-only report)")
if len(resp.Actions) != 1 {
t.Fatalf("expected 1 wiki action, got %d", len(resp.Actions))
}
action := resp.Actions[0]
if action.Type != "cli" || action.Module != "wiki" || action.Command != "+create" {
t.Fatalf("expected wiki +create action, got %s %s %s", action.Type, action.Module, action.Command)
}
if action.Args["name"] == "" {
t.Fatal("expected non-empty wiki page name")
}
if action.Args["content"] == "" {
t.Fatal("expected non-empty wiki page content")
}
}

View File

@ -0,0 +1,315 @@
package rules
import (
"encoding/base64"
"fmt"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
const mitLicense = `MIT License
Copyright (c) %d
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`
const gitignoreGo = `# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
dist/
# Test binary
*.test
# Output of go coverage
*.out
# Go workspace
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.local
`
type labelDef struct{ Name, Color string }
var defaultLabels = []labelDef{
{"bug", "#d73a4a"},
{"enhancement", "#a2eeef"},
{"documentation", "#0075ca"},
{"good first issue", "#7057ff"},
{"question", "#d876e3"},
{"duplicate", "#cfd3d7"},
{"wontfix", "#ffffff"},
}
var defaultIssueTemplates = []struct {
Title string
Body string
}{
{
"项目初始化",
"# 项目初始化\n\n完成仓库基本配置和代码框架搭建。\n\n- [ ] README 文档\n- [ ] LICENSE 文件\n- [ ] .gitignore 配置\n- [ ] CI/CD 流水线",
},
{
"代码框架搭建",
"# 代码框架搭建\n\n搭建项目基本目录结构和核心代码框架。\n\n- [ ] 项目目录结构\n- [ ] 入口文件\n- [ ] 核心模块骨架",
},
{
"首个版本发布 v0.1.0",
"# v0.1.0 发布准备\n\n完成首个可用版本的开发和测试。\n\n- [ ] 核心功能开发\n- [ ] 单元测试\n- [ ] 发布说明",
},
}
// InitScaffoldRule creates a new repository and initializes it with standard
// project scaffolding based on a user-supplied description.
//
// Upstream keys used:
//
// _desc — project description (generates repo name + README)
// _repo — explicit repo name (overrides auto-generation)
// _owner — repository owner
func InitScaffoldRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
desc := str(upstream, "_desc")
owner := str(upstream, "_owner")
repo := str(upstream, "_repo")
if owner == "" {
return nil, fmt.Errorf("missing _owner in upstream")
}
// Generate repo name from description if not explicitly provided.
if repo == "" && desc != "" {
repo = deriveRepoName(desc)
}
if repo == "" {
repo = "new-project"
}
// Generate README from description.
readme := fmt.Sprintf("# %s\n\n%s\n", repo, desc)
if desc == "" {
readme = fmt.Sprintf("# %s\n\nProject description.\n", repo)
}
projectDesc := desc
if projectDesc == "" {
projectDesc = repo
}
var actions []workflow.AIAction
// 1. Create the repository via CLI (handles user_id resolution internally).
actions = append(actions, workflow.AIAction{
Type: "cli", Module: "repo", Command: "+create",
Args: map[string]string{
"name": repo,
"description": projectDesc,
},
})
// 2. README.md
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{base}/create_file",
Body: map[string]interface{}{
"filepath": "README.md",
"content": base64.StdEncoding.EncodeToString([]byte(readme)),
"message": "docs: add README.md",
"branch": "master",
},
})
// 3. LICENSE (MIT)
license := fmt.Sprintf(mitLicense, time.Now().Year())
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{base}/create_file",
Body: map[string]interface{}{
"filepath": "LICENSE",
"content": base64.StdEncoding.EncodeToString([]byte(license)),
"message": "docs: add MIT LICENSE",
"branch": "master",
},
})
// 4. .gitignore
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{base}/create_file",
Body: map[string]interface{}{
"filepath": ".gitignore",
"content": base64.StdEncoding.EncodeToString([]byte(gitignoreGo)),
"message": "chore: add .gitignore",
"branch": "master",
},
})
// 5. Default labels.
for _, l := range defaultLabels {
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{v1}/issue_tags",
Body: map[string]interface{}{
"name": l.Name,
"color": l.Color,
},
})
}
// 6. Initial milestone: v0.1.0, due 3 months from now.
due := time.Now().AddDate(0, 3, 0).Format("2006-01-02")
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{v1}/milestones",
Body: map[string]interface{}{
"name": "v0.1.0",
"description": "首个版本发布",
"effective_date": due,
},
})
// 7. Initial issues.
for _, tpl := range defaultIssueTemplates {
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{v1}/issues",
Body: map[string]interface{}{
"subject": tpl.Title,
"description": tpl.Body,
"status_id": 1, // open
"priority_id": 2, // normal
"done_ratio": 0,
},
})
}
analysis := map[string]interface{}{
"repo": fmt.Sprintf("%s/%s", owner, repo),
"description": projectDesc,
"files_created": 3,
"labels_created": len(defaultLabels),
"milestones_created": 1,
"issues_created": len(defaultIssueTemplates),
"summary": fmt.Sprintf(
"仓库 %s/%s 创建完成:%d 个文件,%d 个标签,%d 个里程碑,%d 个 Issue",
owner, repo, 3, len(defaultLabels), 1, len(defaultIssueTemplates),
),
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
// deriveRepoName generates a short repo name from a Chinese/English description.
func deriveRepoName(desc string) string {
// Extract English words first.
engWords := extractEnglishWords(desc)
if len(engWords) >= 2 {
return strings.ToLower(strings.Join(engWords[:min(3, len(engWords))], "-"))
}
// For pure Chinese: take the longest meaningful substring, up to ~20 chars.
chinese := extractChinese(desc)
if len([]rune(chinese)) > 0 {
r := []rune(chinese)
if len(r) > 5 {
r = r[:5]
}
return string(r)
}
// Fallback: take first 20 chars, sanitize.
name := desc
if len(name) > 20 {
name = name[:20]
}
name = strings.TrimSpace(name)
name = strings.ToLower(name)
name = regexp.MustCompile(`[^a-z0-9一-鿿-]`).ReplaceAllString(name, "-")
name = regexp.MustCompile(`-+`).ReplaceAllString(name, "-")
name = strings.Trim(name, "-")
if name == "" {
return "new-project"
}
return name
}
func extractEnglishWords(s string) []string {
re := regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9]*`)
words := re.FindAllString(s, -1)
// Filter out common stop words.
stop := map[string]bool{
"a": true, "an": true, "the": true, "is": true, "are": true,
"for": true, "of": true, "to": true, "in": true, "and": true,
"or": true, "it": true, "on": true, "at": true, "by": true,
}
var result []string
for _, w := range words {
if len(w) >= 2 && !stop[strings.ToLower(w)] {
result = append(result, w)
}
}
return result
}
func extractChinese(s string) string {
var result []rune
for _, r := range s {
if unicode.Is(unicode.Han, r) {
result = append(result, r)
}
}
if len(result) == 0 {
return ""
}
// Return at most 10 Chinese characters.
if len(result) > 10 {
result = result[:10]
}
return string(result)
}
func min(a, b int) int {
_ = utf8.RuneLen('a') // ensure unicode/utf8 import is used
if a < b {
return a
}
return b
}

View File

@ -0,0 +1,76 @@
package rules
import (
"testing"
)
func TestInitScaffoldRuleActions(t *testing.T) {
upstream := map[string]interface{}{
"_owner": "testuser",
"_repo": "test-project",
"_desc": "A test project for CI/CD",
}
resp, err := InitScaffoldRule(upstream, "init-scaffold")
if err != nil {
t.Fatalf("InitScaffoldRule failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
if resp.Analysis == nil {
t.Fatal("expected analysis in response")
}
// Actions: 1 repo + 3 files + 7 labels + 1 milestone + 3 issues = 15
if len(resp.Actions) != 15 {
t.Errorf("expected 15 actions, got %d", len(resp.Actions))
}
// First action should be repo creation (CLI action).
if resp.Actions[0].Type != "cli" || resp.Actions[0].Command != "+create" {
t.Errorf("first action should be cli +create for repo creation, got type=%s command=%s",
resp.Actions[0].Type, resp.Actions[0].Command)
}
analysis := resp.Analysis.(map[string]interface{})
if files := analysis["files_created"].(int); files != 3 {
t.Errorf("files_created = %d, want 3", files)
}
if labels := analysis["labels_created"].(int); labels != 7 {
t.Errorf("labels_created = %d, want 7", labels)
}
if milestones := analysis["milestones_created"].(int); milestones != 1 {
t.Errorf("milestones_created = %d, want 1", milestones)
}
if issues := analysis["issues_created"].(int); issues != 3 {
t.Errorf("issues_created = %d, want 3", issues)
}
}
func TestInitScaffoldRuleWithDescription(t *testing.T) {
upstream := map[string]interface{}{
"_owner": "testuser",
"_desc": "Docker 容器管理平台",
}
resp, err := InitScaffoldRule(upstream, "init-scaffold")
if err != nil {
t.Fatalf("InitScaffoldRule failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
// Repo name should be auto-generated from description.
analysis := resp.Analysis.(map[string]interface{})
repo := analysis["repo"].(string)
if repo == "" || repo == "testuser/new-project" {
t.Errorf("expected auto-generated repo name, got %q", repo)
}
}
func TestInitScaffoldRuleMissingOwner(t *testing.T) {
_, err := InitScaffoldRule(map[string]interface{}{}, "init-scaffold")
if err == nil {
t.Fatal("expected error when _owner is missing")
}
}

View File

@ -0,0 +1,459 @@
package rules
import (
"fmt"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func MultiRepoCoordinationRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
snapshot, err := extractMultiRepoSnapshot(upstream)
if err != nil {
return nil, err
}
now := time.Now()
release := mrString(snapshot["release"])
repos := extractSnapshotRepos(snapshot)
issueRows := make([]map[string]interface{}, 0, len(repos))
prRows := make([]map[string]interface{}, 0, len(repos))
releaseRows := make([]map[string]interface{}, 0, len(repos))
blockers := []map[string]interface{}{}
recommendations := []string{}
detailIssues := []map[string]interface{}{}
detailPRs := []map[string]interface{}{}
totalOpenIssues := 0
totalBlockerIssues := 0
totalStaleIssues := 0
totalHighPriorityIssues := 0
totalOpenPRs := 0
totalStalePRs := 0
totalConflictPRs := 0
alreadyReleasedRepos := 0
for _, repo := range repos {
owner := mrString(repo["owner"])
name := mrString(repo["repo"])
repoName := owner + "/" + name
if owner == "" {
repoName = name
}
issues := extractAnyList(repo["open_issues"])
prs := extractAnyList(repo["open_prs"])
releases := extractAnyList(repo["releases"])
blockerIssues := filterIssues(issues, isBlockerIssue)
highPriorityIssues := filterIssues(issues, isHighPriorityIssue)
staleIssues := filterStale(issues, now, 7)
stalePRs := filterStale(prs, now, 3)
conflictPRs := filterPRs(prs, isConflictPR)
totalOpenIssues += len(issues)
totalBlockerIssues += len(blockerIssues)
totalStaleIssues += len(staleIssues)
totalHighPriorityIssues += len(highPriorityIssues)
totalOpenPRs += len(prs)
totalStalePRs += len(stalePRs)
totalConflictPRs += len(conflictPRs)
issueRows = append(issueRows, map[string]interface{}{
"repo": repoName,
"open": len(issues),
"blockers": len(blockerIssues),
"stale_7d": len(staleIssues),
"high_priority": len(highPriorityIssues),
})
prRows = append(prRows, map[string]interface{}{
"repo": repoName,
"open": len(prs),
"stale_3d": len(stalePRs),
"conflicts": len(conflictPRs),
"needs_review": countNeedsReviewPRs(prs),
})
detailIssues = appendUniqueDetails(detailIssues, repoName, append(blockerIssues, highPriorityIssues...), issueDetail, now)
detailPRs = appendUniqueDetails(detailPRs, repoName, append(conflictPRs, stalePRs...), prDetail, now)
releaseExists := release != "" && hasTargetRelease(releases, release)
readyToRelease := release != "" && !releaseExists && len(blockerIssues) == 0 && len(conflictPRs) == 0 && len(stalePRs) == 0
releaseRows = append(releaseRows, map[string]interface{}{
"repo": repoName,
"target": release,
"release_exists": releaseExists,
"already_released": releaseExists,
"ready_to_release": readyToRelease,
"blocker_issues": len(blockerIssues),
"open_prs": len(prs),
"stale_or_conflict_pr": len(stalePRs) + len(conflictPRs),
})
if len(blockerIssues) > 0 {
blockers = append(blockers, map[string]interface{}{
"repo": repoName,
"type": "blocker_issues",
"count": len(blockerIssues),
"reason": fmt.Sprintf("%s 还有 %d 个阻塞 Issue", repoName, len(blockerIssues)),
})
recommendations = append(recommendations, fmt.Sprintf("优先处理 %s 的阻塞 Issue", repoName))
}
if len(conflictPRs) > 0 {
blockers = append(blockers, map[string]interface{}{
"repo": repoName,
"type": "conflict_prs",
"count": len(conflictPRs),
"reason": fmt.Sprintf("%s 还有 %d 个疑似冲突 PR", repoName, len(conflictPRs)),
})
recommendations = append(recommendations, fmt.Sprintf("先解决 %s 的冲突 PR", repoName))
}
if len(stalePRs) > 0 {
blockers = append(blockers, map[string]interface{}{
"repo": repoName,
"type": "stale_prs",
"count": len(stalePRs),
"reason": fmt.Sprintf("%s 还有 %d 个超过 3 天未合并 PR", repoName, len(stalePRs)),
})
}
if releaseExists {
alreadyReleasedRepos++
recommendations = append(recommendations, fmt.Sprintf("%s 已存在 %s Release确认是否属于重复发布检查", repoName, release))
}
}
errors := extractAnyList(snapshot["errors"])
readyToRelease := release != "" && len(blockers) == 0 && len(errors) == 0 && alreadyReleasedRepos == 0
if release == "" {
recommendations = append(recommendations, "指定 --release 可启用跨仓库发布协调检查")
}
if len(errors) > 0 {
blockers = append(blockers, map[string]interface{}{
"type": "collection_errors",
"count": len(errors),
"reason": fmt.Sprintf("采集过程中有 %d 个错误,需要先确认数据完整性", len(errors)),
})
recommendations = append(recommendations, "先处理采集失败的仓库或接口权限问题,再判断发布状态")
}
analysis := map[string]interface{}{
"title": "多仓库协同报告",
"summary": map[string]interface{}{
"repos": len(repos),
"target_release": release,
"open_issues": totalOpenIssues,
"blocker_issues": totalBlockerIssues,
"stale_issues_7d": totalStaleIssues,
"high_priority": totalHighPriorityIssues,
"open_prs": totalOpenPRs,
"stale_prs_3d": totalStalePRs,
"conflict_prs": totalConflictPRs,
"collection_errors": len(errors),
},
"issue_tracking": map[string]interface{}{
"total_open": totalOpenIssues,
"total_blockers": totalBlockerIssues,
"total_stale_7d": totalStaleIssues,
"total_high_priority": totalHighPriorityIssues,
"by_repo": issueRows,
"details": detailIssues,
},
"pr_board": map[string]interface{}{
"total_open": totalOpenPRs,
"total_stale_3d": totalStalePRs,
"total_conflicts": totalConflictPRs,
"by_repo": prRows,
"details": detailPRs,
},
"release_coordination": map[string]interface{}{
"target": release,
"ready": readyToRelease,
"ready_to_release": readyToRelease,
"by_repo": releaseRows,
"blockers": blockers,
},
"recommendations": uniqueStrings(recommendations),
}
return &workflow.AIResponse{Analysis: analysis}, nil
}
func extractMultiRepoSnapshot(upstream map[string]interface{}) (map[string]interface{}, error) {
raw, ok := upstream["multi-repo-snapshot"]
if !ok {
return nil, fmt.Errorf("missing multi-repo-snapshot upstream data")
}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"].(map[string]interface{}); ok {
return data, nil
}
return m, nil
}
return nil, fmt.Errorf("invalid multi-repo-snapshot upstream data")
}
func extractSnapshotRepos(snapshot map[string]interface{}) []map[string]interface{} {
return extractAnyList(snapshot["repos"])
}
func extractAnyList(raw interface{}) []map[string]interface{} {
for i := 0; i < 3; i++ {
m, ok := raw.(map[string]interface{})
if !ok {
break
}
unwrapped := false
if data, ok := m["data"]; ok {
raw = data
unwrapped = true
} else {
for _, key := range []string{"repos", "issues", "pull_requests", "releases", "milestones", "items", "errors"} {
if v, ok := m[key]; ok {
raw = v
unwrapped = true
break
}
}
}
if !unwrapped {
break
}
}
list, _ := raw.([]interface{})
out := make([]map[string]interface{}, 0, len(list))
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
func filterIssues(items []map[string]interface{}, pred func(map[string]interface{}) bool) []map[string]interface{} {
return filterMaps(items, pred)
}
func filterPRs(items []map[string]interface{}, pred func(map[string]interface{}) bool) []map[string]interface{} {
return filterMaps(items, pred)
}
func filterMaps(items []map[string]interface{}, pred func(map[string]interface{}) bool) []map[string]interface{} {
out := []map[string]interface{}{}
for _, item := range items {
if pred(item) {
out = append(out, item)
}
}
return out
}
func filterStale(items []map[string]interface{}, now time.Time, days int) []map[string]interface{} {
out := []map[string]interface{}{}
for _, item := range items {
t, ok := itemUpdatedAt(item)
if ok && now.Sub(t) >= time.Duration(days)*24*time.Hour {
out = append(out, item)
}
}
return out
}
func isBlockerIssue(issue map[string]interface{}) bool {
text := strings.ToLower(mrString(issue["subject"]) + " " + mrString(issue["title"]) + " " + mrString(issue["description"]) + " " + labelsText(issue))
return strings.Contains(text, "blocker") || strings.Contains(text, "阻塞") || strings.Contains(text, "critical") || strings.Contains(text, "严重")
}
func isHighPriorityIssue(issue map[string]interface{}) bool {
text := strings.ToLower(mrString(issue["subject"]) + " " + mrString(issue["title"]) + " " + mrString(issue["priority"]) + " " + labelsText(issue))
if strings.Contains(text, "high") || strings.Contains(text, "urgent") || strings.Contains(text, "高优先级") || strings.Contains(text, "紧急") {
return true
}
if id, ok := numberValue(issue["priority_id"]); ok && id >= 4 {
return true
}
return false
}
func isConflictPR(pr map[string]interface{}) bool {
text := strings.ToLower(mrString(pr["title"]) + " " + mrString(pr["body"]) + " " + mrString(pr["status"]) + " " + mrString(pr["merge_status"]))
return strings.Contains(text, "conflict") || strings.Contains(text, "冲突") || strings.Contains(text, "cannot merge")
}
func countNeedsReviewPRs(prs []map[string]interface{}) int {
count := 0
for _, pr := range prs {
text := strings.ToLower(mrString(pr["status"]) + " " + mrString(pr["review_status"]) + " " + labelsText(pr))
if text == "" || strings.Contains(text, "review") || strings.Contains(text, "待审") {
count++
}
}
return count
}
func hasTargetRelease(releases []map[string]interface{}, target string) bool {
for _, rel := range releases {
for _, key := range []string{"tag_name", "tag", "name", "title", "version"} {
if mrString(rel[key]) == target {
return true
}
}
}
return false
}
func issueDetail(repo string, issue map[string]interface{}, now time.Time) map[string]interface{} {
days := daysSince(issue, now)
return map[string]interface{}{
"repo": repo,
"number": firstNonEmpty(issue, "number", "id", "issue_id"),
"title": firstNonEmpty(issue, "subject", "title"),
"assignee": assigneeName(issue),
"stale_days": days,
"blocker": isBlockerIssue(issue),
"high": isHighPriorityIssue(issue),
}
}
func prDetail(repo string, pr map[string]interface{}, now time.Time) map[string]interface{} {
return map[string]interface{}{
"repo": repo,
"number": firstNonEmpty(pr, "pull_request_number", "number", "id", "pull_request_id"),
"title": firstNonEmpty(pr, "title", "subject"),
"assignee": assigneeName(pr),
"stale_days": daysSince(pr, now),
"conflict": isConflictPR(pr),
}
}
func appendUniqueDetails(
dst []map[string]interface{},
repo string,
items []map[string]interface{},
detailFn func(string, map[string]interface{}, time.Time) map[string]interface{},
now time.Time,
) []map[string]interface{} {
seen := make(map[string]bool, len(dst)+len(items))
for _, item := range dst {
seen[detailKey(item)] = true
}
for _, item := range items {
detail := detailFn(repo, item, now)
key := detailKey(detail)
if seen[key] {
continue
}
seen[key] = true
dst = append(dst, detail)
}
return dst
}
func detailKey(item map[string]interface{}) string {
repo := mrString(item["repo"])
number := mrString(item["number"])
if number == "" {
number = mrString(item["title"])
}
return repo + "#" + number
}
func itemUpdatedAt(item map[string]interface{}) (time.Time, bool) {
for _, key := range []string{"updated_at", "updated_on", "created_at", "created_on"} {
switch v := item[key].(type) {
case string:
if t, ok := parseTime(v); ok {
return t, true
}
case float64:
if v > 0 {
return time.Unix(int64(v), 0), true
}
}
}
return time.Time{}, false
}
func parseTime(raw string) (time.Time, bool) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z07:00", "2006-01-02 15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return t, true
}
}
return time.Time{}, false
}
func daysSince(item map[string]interface{}, now time.Time) int {
t, ok := itemUpdatedAt(item)
if !ok {
return 0
}
return int(now.Sub(t).Hours() / 24)
}
func firstNonEmpty(m map[string]interface{}, keys ...string) string {
for _, key := range keys {
if s := mrString(m[key]); s != "" {
return s
}
}
return ""
}
func labelsText(m map[string]interface{}) string {
var parts []string
for _, key := range []string{"labels", "tags", "issue_tags"} {
for _, label := range extractAnyList(m[key]) {
parts = append(parts, firstNonEmpty(label, "name", "title"))
}
}
return strings.Join(parts, " ")
}
func assigneeName(m map[string]interface{}) string {
for _, key := range []string{"assignee", "assigned_to", "user"} {
if nested, ok := m[key].(map[string]interface{}); ok {
if name := firstNonEmpty(nested, "name", "login", "username"); name != "" {
return name
}
}
}
return firstNonEmpty(m, "assignee", "assigned_to", "author_name")
}
func mrString(v interface{}) string {
if v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprint(v))
if s == "<nil>" {
return ""
}
return s
}
func numberValue(v interface{}) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
default:
return 0, false
}
}
func uniqueStrings(items []string) []string {
seen := map[string]bool{}
out := []string{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}

View File

@ -0,0 +1,123 @@
package rules
import "testing"
func TestMultiRepoCoordinationRuleBlocksRelease(t *testing.T) {
upstream := map[string]interface{}{
"multi-repo-snapshot": map[string]interface{}{
"release": "v1.4.0",
"repos": []interface{}{
map[string]interface{}{
"owner": "org",
"repo": "backend",
"open_issues": map[string]interface{}{
"issues": []interface{}{
map[string]interface{}{
"id": float64(23),
"subject": "登录接口超时 blocker",
"priority_id": float64(4),
"updated_at": "2026-06-20T00:00:00Z",
},
},
},
"open_prs": map[string]interface{}{
"pull_requests": []interface{}{},
},
"releases": []interface{}{
map[string]interface{}{"tag_name": "v1.4.0"},
},
},
map[string]interface{}{
"owner": "org",
"repo": "frontend",
"open_issues": map[string]interface{}{
"issues": []interface{}{},
},
"open_prs": map[string]interface{}{
"pull_requests": []interface{}{
map[string]interface{}{
"id": float64(39),
"title": "fix: 修复登录样式 conflict",
"updated_at": "2026-06-25T00:00:00Z",
"merge_status": "conflict",
},
},
},
"releases": []interface{}{},
},
},
},
}
resp, err := MultiRepoCoordinationRule(upstream, "multi-repo-coordination")
if err != nil {
t.Fatalf("MultiRepoCoordinationRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
release := analysis["release_coordination"].(map[string]interface{})
if release["ready_to_release"].(bool) {
t.Fatal("expected release ready_to_release=false")
}
blockers := release["blockers"].([]map[string]interface{})
if len(blockers) != 3 {
t.Fatalf("expected blocker issue plus conflict and stale PR blockers, got %v", blockers)
}
rows := release["by_repo"].([]map[string]interface{})
if !rows[0]["already_released"].(bool) {
t.Fatalf("expected backend to be marked already_released")
}
if rows[1]["release_exists"].(bool) {
t.Fatalf("expected frontend release_exists=false without treating it as blocker")
}
summary := analysis["summary"].(map[string]interface{})
if summary["blocker_issues"].(int) != 1 {
t.Fatalf("blocker_issues = %v, want 1", summary["blocker_issues"])
}
if summary["conflict_prs"].(int) != 1 {
t.Fatalf("conflict_prs = %v, want 1", summary["conflict_prs"])
}
issues := analysis["issue_tracking"].(map[string]interface{})["details"].([]map[string]interface{})
if len(issues) != 1 {
t.Fatalf("expected duplicate issue details to be deduped, got %v", issues)
}
}
func TestMultiRepoCoordinationRuleAlreadyReleasedIsNotReadyToRelease(t *testing.T) {
upstream := map[string]interface{}{
"multi-repo-snapshot": map[string]interface{}{
"release": "v1.4.0",
"repos": []interface{}{
map[string]interface{}{
"owner": "org",
"repo": "backend",
"open_issues": map[string]interface{}{
"issues": []interface{}{},
},
"open_prs": map[string]interface{}{
"pull_requests": []interface{}{},
},
"releases": []interface{}{
map[string]interface{}{"tag_name": "v1.4.0"},
},
},
},
},
}
resp, err := MultiRepoCoordinationRule(upstream, "multi-repo-coordination")
if err != nil {
t.Fatalf("MultiRepoCoordinationRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
release := analysis["release_coordination"].(map[string]interface{})
if release["ready_to_release"].(bool) {
t.Fatal("already released repo should not be marked ready_to_release")
}
rows := release["by_repo"].([]map[string]interface{})
if !rows[0]["release_exists"].(bool) || !rows[0]["already_released"].(bool) {
t.Fatalf("expected release existence flags, got %+v", rows[0])
}
if rows[0]["ready_to_release"].(bool) {
t.Fatalf("already released repo row should not be ready_to_release: %+v", rows[0])
}
}

View File

@ -5,9 +5,13 @@ import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
func init() {
workflow.RegisterRuleEngine("gitlink-triage", TriageRule)
workflow.RegisterRuleEngine("gitlink-health", HealthDispatchRule)
workflow.RegisterRuleEngine("gitlink-contributor-ranking", ContributorRankingRule)
workflow.RegisterRuleEngine("gitlink-changelog", ChangelogRule)
workflow.RegisterRuleEngine("gitlink-review", CodeReviewRule)
workflow.RegisterRuleEngine("gitlink-ci", CIDiagnosisRule)
workflow.RegisterRuleEngine("gitlink-license", LicenseCheckRule)
workflow.RegisterRuleEngine("gitlink-repo", RepoAuditRule)
workflow.RegisterRuleEngine("gitlink-init-scaffold", InitScaffoldRule)
workflow.RegisterRuleEngine("gitlink-auto-merge", AutoMergeRule)
workflow.RegisterRuleEngine("gitlink-multi-repo", MultiRepoCoordinationRule)
}

View File

@ -15,6 +15,10 @@ func TestRegistryAllRegistered(t *testing.T) {
"gitlink-ci",
"gitlink-license",
"gitlink-repo",
"gitlink-contributor-ranking",
"gitlink-init-scaffold",
"gitlink-auto-merge",
"gitlink-multi-repo",
}
for _, target := range expected {
if _, ok := workflow.RuleEngines[target]; !ok {

View File

@ -24,19 +24,9 @@ func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.
var dims []dimScore
missing := []string{}
// Readme check.
desc := ""
hasReadme := false
if repoInfo != nil {
desc = str(repoInfo, "description")
if v, ok := repoInfo["has_readme"].(bool); ok {
hasReadme = v
}
}
readmeScore := 0.0
if hasReadme || desc != "" {
readmeScore = 100
dims = append(dims, dimScore{Name: "README", Score: readmeScore, Weight: 0.25, Status: "ok", Detail: "README 已存在"})
// Readme check — use file list since repo +info API doesn't return has_readme.
if detectFileInList(upstream, "README.md", "readme.md", "README", "readme") {
dims = append(dims, dimScore{Name: "README", Score: 100, Weight: 0.25, Status: "ok", Detail: "README 已存在"})
} else {
dims = append(dims, dimScore{Name: "README", Score: 0, Weight: 0.25, Status: "missing", Detail: "缺少 README 文件"})
missing = append(missing, "README")
@ -115,14 +105,20 @@ func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.
}
func detectLicenseInFiles(upstream map[string]interface{}) bool {
return detectFileInList(upstream, "LICENSE", "license", "LICENSE.txt", "LICENSE.md", "COPYING")
}
func detectFileInList(upstream map[string]interface{}, names ...string) 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
for _, n := range names {
if strings.EqualFold(name, n) {
return true
}
}
}
return false

View File

@ -3,6 +3,7 @@ package rules
import (
"fmt"
"regexp"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
@ -31,21 +32,31 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
var findings []finding
var actions []workflow.AIAction
// Diffs may have been pre-fetched by the workflow engine.
prDiffsMap := extractDiffsFromUpstream(upstream)
for _, pr := range prs {
title := str(pr, "title", "name")
body := str(pr, "body", "description")
prNum := interfaceToString(pr["id"])
prNum := interfaceToString(pr["pull_request_number"])
if prNum == "" {
prNum = interfaceToString(pr["number"])
prNum = interfaceToString(pr["id"])
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
prNum = interfaceToString(pr["number"])
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
}
}
}
if prNum == "" {
continue
}
text := title + " " + body
diffText := prDiffsMap[prNum]
text := title + " " + body + " " + diffText
var prFindings []finding
// Security scan.
for _, p := range reviewSecurityPatterns {
@ -59,19 +70,8 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
Fix: p.fix,
}
prFindings = append(prFindings, f)
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),
},
})
}
}
}
@ -90,6 +90,7 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
Why: "大 PR 难以审查,增加合并风险和回滚难度",
Fix: "将改动按功能模块拆分为多个小 PR",
}
prFindings = append(prFindings, f)
findings = append(findings, f)
}
@ -103,8 +104,34 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
Why: "不清晰的 PR 描述增加审查时间,降低代码质量",
Fix: "添加 PR 描述,说明改动原因、影响范围和测试方式",
}
prFindings = append(prFindings, f)
findings = append(findings, f)
}
// Always post a review comment for every PR.
commentBody := buildReviewComment(title, prFindings)
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "pr",
Command: "+comment",
Args: map[string]string{
"id": prNum,
"body": commentBody,
},
})
// Auto-merge if no issues found.
if len(prFindings) == 0 {
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "pr",
Command: "+merge",
Args: map[string]string{
"id": prNum,
"method": "squash",
},
})
}
}
analysis := map[string]interface{}{
@ -162,6 +189,46 @@ var reviewSecurityPatterns = []reviewPattern{
},
}
// buildReviewComment generates a review comment for a PR.
func buildReviewComment(prTitle string, prFindings []finding) string {
var b strings.Builder
b.WriteString("🤖 **代码质量审查报告**\n\n")
if len(prFindings) == 0 {
b.WriteString("✅ **审查通过**:未发现安全风险或代码质量问题,正在自动合并。\n")
return b.String()
}
fmt.Fprintf(&b, "审查发现 **%d** 个问题:\n\n", len(prFindings))
for _, f := range prFindings {
icon := "🔴"
switch f.Severity {
case "medium":
icon = "🟡"
case "low":
icon = "🟢"
}
fmt.Fprintf(&b, "- %s **[%s] %s**%s\n", icon, f.Severity, f.What, f.Why)
if f.Fix != "" {
fmt.Fprintf(&b, " - 建议:%s\n", f.Fix)
}
}
hasHigh := false
for _, f := range prFindings {
if f.Severity == "high" {
hasHigh = true
break
}
}
if hasHigh {
b.WriteString("\n⚠ 存在高危问题,请修复后重新提交审查。\n")
} else {
b.WriteString("\n请评估以上问题是否需要修复。\n")
}
return b.String()
}
func interfaceToString(v interface{}) string {
if v == nil {
return ""
@ -177,3 +244,24 @@ func interfaceToString(v interface{}) string {
return fmt.Sprint(v)
}
}
// extractDiffsFromUpstream retrieves pre-fetched PR diffs from the upstream data.
func extractDiffsFromUpstream(upstream map[string]interface{}) map[string]string {
raw, ok := upstream["_pr_diffs"]
if !ok {
return nil
}
if m, ok := raw.(map[string]string); ok {
return m
}
if m, ok := raw.(map[string]interface{}); ok {
diffs := make(map[string]string)
for k, v := range m {
if s, ok := v.(string); ok {
diffs[k] = s
}
}
return diffs
}
return nil
}

View File

@ -9,16 +9,21 @@ func TestCodeReviewStaticAnalysis(t *testing.T) {
"open-prs": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"id": "1",
"title": "add feature",
"body": "password = 'hardcoded12345678'",
"id": "1",
"title": "add feature",
"body": "password = 'hardcoded12345678'",
},
map[string]interface{}{
"id": "2",
"title": "wip",
"body": "",
"id": "2",
"title": "wip",
"body": "",
"files_count": 60.0,
},
map[string]interface{}{
"id": "3",
"title": "clean refactor with proper description",
"body": "refactoring the auth module to use new token service",
},
},
},
}
@ -33,7 +38,6 @@ func TestCodeReviewStaticAnalysis(t *testing.T) {
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
@ -51,6 +55,25 @@ func TestCodeReviewStaticAnalysis(t *testing.T) {
if !hasMaint {
t.Error("expected maintainability finding for large PR or missing body")
}
// Every PR gets a comment.
commentCount := 0
mergeCount := 0
for _, a := range resp.Actions {
if a.Command == "+comment" {
commentCount++
}
if a.Command == "+merge" {
mergeCount++
}
}
if commentCount != 3 {
t.Fatalf("expected 3 comment actions (one per PR), got %d", commentCount)
}
// PR #3 has no findings, should be auto-merged.
if mergeCount != 1 {
t.Fatalf("expected 1 merge action (clean PR), got %d", mergeCount)
}
}
func TestCodeReviewNoPRs(t *testing.T) {

View File

@ -31,6 +31,8 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
title := str(issue, "title")
body := str(issue, "body", "description")
text := title + " " + body
open := isIssueOpen(issue)
processed := isIssueProcessed(issue)
cat := classifyIssue(text)
pri := assignPriority(text)
@ -42,16 +44,26 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
}
result := map[string]interface{}{
"number": num,
"title": title,
"category": cat,
"priority": pri,
"assignee": assignee,
"number": num,
"title": title,
"category": cat,
"priority": pri,
"assignee": assignee,
"processed": processed,
}
classified = append(classified, result)
// Build PATCH action if we have labels or assignee.
if processed {
continue
}
// Build PATCH action for fields supported by issue update.
body2 := map[string]interface{}{}
body2["subject"] = title
body2["description"] = body
if statusID := issueStatusID(issue); statusID != nil {
body2["status_id"] = statusID
}
if len(labelIDs) > 0 {
body2["issue_tag_ids"] = labelIDs
}
@ -61,7 +73,7 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
if pri > 0 {
body2["priority_id"] = pri
}
if len(body2) > 0 && num != "" {
if open && len(body2) > 0 && num != "" {
actions = append(actions, workflow.AIAction{
Type: "api", Method: "PATCH",
Path: fmt.Sprintf("{v1}/issues/%s", num),
@ -69,13 +81,13 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
})
}
if isGoodFirstIssue(text) {
if open && 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",
"body": "👋 感谢提交 Issue这个 Issue 已被标记为 **Good First Issue**,适合新贡献者参与。欢迎提交 PR\n\n---\n*此评论由社区运营工作流自动生成*",
},
})
}
@ -99,8 +111,8 @@ var catPatterns = []struct {
{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)重构|代码质量|refactor|clean\s*up|tech\s*debt`), "refactor"},
{regexp.MustCompile(`(?i)文档|README|帮助|doc|documentation|typo`), "docs"},
{regexp.MustCompile(`(?i)如何|怎么|请问|how\s*to|question|help|求助`), "question"},
}
@ -144,6 +156,68 @@ func isGoodFirstIssue(text string) bool {
len(strings.Fields(text)) < 200
}
func isIssueOpen(issue map[string]interface{}) bool {
for _, k := range []string{"state", "status", "issue_status", "status_name"} {
s := strings.ToLower(strings.TrimSpace(str(issue, k)))
switch s {
case "closed", "close", "resolved", "done", "已关闭", "关闭", "已解决":
return false
case "open", "opened", "active", "new", "新增", "开启", "打开":
return true
}
}
for _, k := range []string{"status_id", "state_id"} {
switch fmt.Sprint(issue[k]) {
case "3", "5":
return false
case "1", "2":
return true
}
}
return true
}
func issueStatusID(issue map[string]interface{}) interface{} {
for _, k := range []string{"status_id", "state_id"} {
if v := issue[k]; v != nil {
return v
}
}
return nil
}
func isIssueProcessed(issue map[string]interface{}) bool {
return commentCount(issue) > 0 || len(extractIssueTags(issue)) > 0 || hasAssignee(issue)
}
func hasAssignee(issue map[string]interface{}) bool {
for _, k := range []string{"assigners", "assignees", "assigned_to", "assignee", "assigned_to_id", "assigner_ids"} {
v, ok := issue[k]
if !ok || v == nil {
continue
}
switch x := v.(type) {
case []interface{}:
if len(x) > 0 {
return true
}
case []string:
if len(x) > 0 {
return true
}
case string:
if strings.TrimSpace(x) != "" {
return true
}
default:
if fmt.Sprint(x) != "" && fmt.Sprint(x) != "0" && fmt.Sprint(x) != "<nil>" {
return true
}
}
}
return false
}
// --- label matching ---
func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
@ -152,6 +226,7 @@ func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
func matchLabels(category string, labels []map[string]interface{}) []interface{} {
catLower := strings.ToLower(category)
aliases := labelAliases(catLower)
var ids []interface{}
for _, l := range labels {
name := strings.ToLower(str(l, "name", "title", "label"))
@ -159,7 +234,7 @@ func matchLabels(category string, labels []map[string]interface{}) []interface{}
continue
}
// Direct match or contains.
if name == catLower || strings.Contains(name, catLower) || strings.Contains(catLower, name) {
if matchesLabelName(catLower, aliases, name) {
if id := labelID(l); id != nil {
ids = append(ids, id)
}
@ -179,6 +254,40 @@ func matchLabels(category string, labels []map[string]interface{}) []interface{}
return ids
}
func labelAliases(category string) []string {
switch category {
case "bug":
return []string{"bug", "bugs", "fix", "修复", "疑修", "缺陷", "错误", "故障", "问题"}
case "security":
return []string{"security", "安全", "漏洞", "cve"}
case "performance":
return []string{"performance", "perf", "性能", "优化"}
case "refactor":
return []string{"refactor", "重构", "代码质量", "技术债"}
case "enhancement":
return []string{"enhancement", "feature", "功能", "需求", "新增", "改进"}
case "docs":
return []string{"docs", "documentation", "文档", "readme", "帮助"}
case "question":
return []string{"question", "help", "疑问", "问题", "求助"}
default:
return []string{category}
}
}
func matchesLabelName(category string, aliases []string, name string) bool {
if name == category || strings.Contains(name, category) || strings.Contains(category, name) {
return true
}
for _, alias := range aliases {
alias = strings.ToLower(alias)
if name == alias || strings.Contains(name, alias) || strings.Contains(alias, name) {
return true
}
}
return false
}
func labelID(l map[string]interface{}) interface{} {
for _, k := range []string{"id", "tag_id", "label_id"} {
if v := l[k]; v != nil {
@ -196,11 +305,11 @@ func leastLoaded(load map[string]int, members map[string]string) string {
}
best := ""
bestN := -1
for login := range members {
for login, id := range members {
n := load[login]
if bestN < 0 || n < bestN {
bestN = n
best = login
best = id
}
}
return best
@ -217,3 +326,30 @@ func issueNumber(issue map[string]interface{}) string {
}
return ""
}
// extractIssueTags returns the existing tags on an issue.
func extractIssueTags(issue map[string]interface{}) []interface{} {
for _, k := range []string{"issue_tags", "tags", "labels"} {
if v, ok := issue[k]; ok {
if arr, ok := v.([]interface{}); ok && len(arr) > 0 {
return arr
}
}
}
return nil
}
// commentCount returns the number of comments on an issue.
func commentCount(issue map[string]interface{}) int {
for _, k := range []string{"comment_journals_count", "comments_count", "comment_count"} {
if v, ok := issue[k]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
}
return 0
}

View File

@ -110,6 +110,101 @@ func TestTriageRuleGoodFirstIssue(t *testing.T) {
}
}
func TestTriageRuleLabelsWithChineseAlias(t *testing.T) {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"project_issues_index": "21", "title": "新增代码质量看门人工作流", "body": ""},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 323830, "name": "功能"},
},
},
"members": map[string]interface{}{"data": []interface{}{}},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
for _, a := range resp.Actions {
if a.Type == "api" {
ids, ok := a.Body["issue_tag_ids"].([]interface{})
if !ok || len(ids) != 1 || ids[0] != 323830 {
t.Fatalf("unexpected issue_tag_ids: %+v", a.Body["issue_tag_ids"])
}
return
}
}
t.Fatal("expected PATCH action with Chinese 功能 label")
}
func TestTriageRuleDoesNotCommentClosedIssue(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", "status": "closed"},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 1, "name": "docs"},
},
},
"members": map[string]interface{}{"data": []interface{}{}},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
for _, a := range resp.Actions {
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
t.Fatal("did not expect a cli comment action for closed issue")
}
if a.Type == "api" && a.Method == "PATCH" {
t.Fatal("did not expect a patch action for closed issue")
}
}
}
func TestTriageRuleSkipsProcessedIssue(t *testing.T) {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"project_issues_index": "11",
"title": "新增导出功能",
"body": "简单功能",
"comment_journals_count": 1,
"tags": []interface{}{map[string]interface{}{"id": 323830, "name": "功能"}},
"assigners": []interface{}{map[string]interface{}{"id": 148915, "login": "yetja"}},
},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 323830, "name": "功能"},
},
},
"members": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 148915, "login": "yetja"},
},
},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
if len(resp.Actions) != 0 {
t.Fatalf("expected processed issue to be ignored, got actions: %+v", resp.Actions)
}
}
func TestTriageRulePriority(t *testing.T) {
cases := []struct {
title string

View File

@ -18,8 +18,10 @@ func LoadState(name string) (*WorkflowState, error) {
if err != nil {
if os.IsNotExist(err) {
return &WorkflowState{
Workflow: name,
Snapshots: make(map[string]string),
Workflow: name,
Snapshots: make(map[string]string),
PhaseLastRun: make(map[string]string),
PhaseUpstream: make(map[string]string),
}, nil
}
return nil, err
@ -31,6 +33,12 @@ func LoadState(name string) (*WorkflowState, error) {
if s.Snapshots == nil {
s.Snapshots = make(map[string]string)
}
if s.PhaseLastRun == nil {
s.PhaseLastRun = make(map[string]string)
}
if s.PhaseUpstream == nil {
s.PhaseUpstream = make(map[string]string)
}
return &s, nil
}
@ -50,7 +58,7 @@ func (s *WorkflowState) Save() error {
}
// Diff compares current step results against stored snapshots.
// Returns the names of steps whose data changed since the last run.
// Does NOT mutate snapshots — call UpdateSnapshots separately to persist.
func (s *WorkflowState) Diff(results []StepResult) []string {
changed := []string{}
for _, sr := range results {
@ -61,11 +69,20 @@ func (s *WorkflowState) Diff(results []StepResult) []string {
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
changed = append(changed, sr.Step)
}
s.Snapshots[sr.Step] = hash
}
return changed
}
// UpdateSnapshots stores hashes of current step results for future diff.
func (s *WorkflowState) UpdateSnapshots(results []StepResult) {
for _, sr := range results {
if !sr.OK || sr.Data == nil {
continue
}
s.Snapshots[sr.Step] = hashData(sr.Data)
}
}
// hashData computes an MD5 hash of the JSON-encoded data.
func hashData(data interface{}) string {
b, err := json.Marshal(data)

View File

@ -1,24 +1,29 @@
package workflow
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// StepResult holds the outcome of executing one step.
type StepResult struct {
Step string `json:"step"`
Purpose string `json:"purpose"`
Type StepType `json:"type"`
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Step string `json:"step"`
Purpose string `json:"purpose"`
Type StepType `json:"type"`
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
SkipReason string `json:"skip_reason,omitempty"`
}
// ExecuteStep dispatches a step to the right executor based on its Type.
@ -58,6 +63,11 @@ func executeAPIStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
// executeCommandStep runs a gitlink-cli subcommand as a subprocess.
func executeCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
if strings.HasPrefix(step.Target, "workflow-internal:") {
executeInternalCommandStep(ctx, step, sr)
return
}
parts := parseCommandTarget(step.Target)
if len(parts) == 0 {
sr.OK = false
@ -98,12 +108,35 @@ func executeCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult
}
}
func executeInternalCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
switch strings.TrimPrefix(step.Target, "workflow-internal:") {
case "multi-repo-snapshot":
snapshot, err := BuildMultiRepoSnapshot(ctx)
if err != nil {
sr.OK = false
sr.Error = err.Error()
return
}
sr.OK = true
sr.Data = snapshot
default:
sr.OK = false
sr.Error = fmt.Sprintf("unknown internal workflow command: %q", step.Target)
}
}
// executeSkillStep runs a skill step. Depending on aiMode, it uses the AI API or
// falls back to a deterministic rule engine. Both paths produce the same AIResponse
// format, and actions from either source go through the same security whitelist.
func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult, dryRun bool) {
upstream := collectUpstream(ctx, step)
// For review step, fetch PR diffs and filter already-reviewed PRs.
if step.Target == "gitlink-review" {
enrichWithPRDiffs(upstream, ctx.Owner, ctx.Repo)
filterReviewedPRs(upstream, ctx)
}
if dryRun {
sr.OK = true
sr.Data = map[string]interface{}{
@ -120,6 +153,7 @@ func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult,
client := NewAIClient()
var aiResp *AIResponse
var usedAI bool
var aiAnalysis interface{}
switch aiMode {
case AIModeNoAI:
@ -139,7 +173,7 @@ func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult,
case AIModeAI:
if !client.HasKey() {
sr.OK = false
sr.Error = "AI 模式需要配置 API Key设置 ANTHROPIC_API_KEY 环境变量或 config set anthropic_api_key"
sr.Error = "AI 模式需要配置 API Key设置 DEEPSEEK_API_KEY 环境变量或 config set deepseek_api_key"
return
}
resp, err := callAI(client, step, upstream)
@ -176,17 +210,95 @@ func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult,
aiResp = resp
}
}
if usedAI && step.Target == "gitlink-triage" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
// Triage write actions must be deterministic: AI may explain, but
// labels/assignees/comments and UI counts come from the tested rule engine.
aiAnalysis = aiResp.Analysis
aiResp.Actions = ruleResp.Actions
aiResp.Analysis = ruleResp.Analysis
} else {
fmt.Fprintf(os.Stderr, "[workflow] triage rule action fallback failed: %v\n", err)
}
}
// In AI mode, supplement health-report with deterministic wiki action.
// AI provides richer semantic analysis; rule engine ensures wiki publishing.
if usedAI && step.Name == "health-report" {
fmt.Fprintf(os.Stderr, "[workflow] health-report AI supplement: upstream_keys=%v\n", mapKeys(upstream))
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
fmt.Fprintf(os.Stderr, "[workflow] health-report rule engine returned %d actions\n", len(ruleResp.Actions))
content := ""
if s, ok := aiResp.Analysis.(string); ok && s != "" {
content = s
}
if content == "" {
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 项目健康度报告\n\n" + string(b) + "\n"
}
}
// Rewrite the rule engine's wiki +create with AI-enhanced content.
for _, a := range ruleResp.Actions {
if a.Module == "wiki" && a.Command == "+create" {
if content != "" {
a.Args["content"] = content
}
aiResp.Actions = append(aiResp.Actions, a)
}
}
} else {
fmt.Fprintf(os.Stderr, "[workflow] health-report rule engine failed or empty: err=%v actions=%d\n", err, len(ruleResp.Actions))
}
}
// Supplement AI responses with wiki publishing so the full pipeline runs.
if usedAI && step.Name == "contributor-ranking" {
content := ""
if s, ok := aiResp.Analysis.(string); ok {
content = s
} else if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 贡献者排行榜\n\n```json\n" + string(b) + "\n```\n"
}
if content != "" {
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
aiResp.Actions = append(aiResp.Actions,
AIAction{
Type: "cli", Module: "wiki", Command: "+create",
Args: map[string]string{
"name": pageName,
"content": content,
"message": "自动生成贡献者排行榜",
},
},
AIAction{
Type: "cli", Module: "wiki", Command: "+update",
Args: map[string]string{
"name": pageName,
"content": content,
"message": "自动更新贡献者排行榜",
},
},
)
}
}
executed := executeActions(ctx, aiResp.Actions)
// After review, save PR fingerprints so we skip them next poll.
if step.Target == "gitlink-review" && !dryRun {
saveReviewedPRFingerprints(upstream, ctx)
}
sr.OK = true
sr.Data = map[string]interface{}{
data := map[string]interface{}{
"ok": true,
"analysis": aiResp.Analysis,
"executed": executed,
"_ai_used": usedAI,
"_skill": step.Target,
}
if aiAnalysis != nil {
data["ai_analysis"] = aiAnalysis
}
sr.Data = data
}
// executeActions runs allowed actions from an AIResponse. Returns count of
@ -194,9 +306,16 @@ func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult,
// through the same security whitelist.
func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
executed := 0
seen := make(map[string]bool, len(actions))
for _, action := range actions {
key := actionKey(action)
if seen[key] {
fmt.Fprintf(os.Stderr, "[workflow] skipped duplicate action: %s %s %s\n", action.Type, action.Module, action.Command)
continue
}
seen[key] = true
if !isActionAllowed(action) {
fmt.Fprintf(os.Stderr, "[workflow] blocked action: %s %s\n", action.Type, action.Command)
fmt.Fprintf(os.Stderr, "[workflow] blocked action: %s %s +%s\n", action.Type, action.Module, action.Command)
continue
}
if action.Type == "api" {
@ -228,6 +347,15 @@ func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
return executed
}
func actionKey(action AIAction) string {
raw, err := json.Marshal(action)
if err != nil {
return fmt.Sprintf("%s:%s:%s:%v:%s:%s", action.Type, action.Module, action.Command, action.Args, action.Method, action.Path)
}
sum := md5.Sum(raw)
return hex.EncodeToString(sum[:])
}
// resolveAIMode determines the effective AI mode from the context.
func resolveAIMode(ctx *common.RuntimeContext) AIMode {
switch ctx.AIMode {
@ -320,6 +448,7 @@ var allowedCLIModules = map[string]bool{
"issue": true, "pr": true, "release": true,
"wiki": true, "member": true, "label": true,
"milestone": true, "branch": true, "comment": true,
"repo": true, "file": true,
}
var blockedCLICommands = map[string]bool{
@ -346,6 +475,14 @@ func isActionAllowed(action AIAction) bool {
// parseCommandTarget splits a CLI command string into tokens,
// respecting quoted arguments.
func mapKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
func parseCommandTarget(target string) []string {
var parts []string
var current strings.Builder
@ -379,3 +516,226 @@ func parseCommandTarget(target string) []string {
}
return parts
}
// filterReviewedPRs removes PRs from upstream that were already reviewed
// with the same fingerprint, preventing re-review on every poll cycle.
func filterReviewedPRs(upstream map[string]interface{}, ctx *common.RuntimeContext) {
wfName := ctx.Arg("__wf_name")
if wfName == "" {
return
}
state, err := LoadState(wfName)
if err != nil || state == nil {
return
}
if state.ReviewedPRs == nil {
state.ReviewedPRs = make(map[string]string)
}
prs := extractPRListFromUpstream(upstream, "open-prs")
if len(prs) == 0 {
return
}
filtered := make([]interface{}, 0, len(prs))
skipped := 0
for _, pr := range prs {
prNum := prNumberFromMap(pr)
if prNum == "" {
filtered = append(filtered, pr)
continue
}
fp := prFingerprint(pr)
if stored, ok := state.ReviewedPRs[prNum]; ok && stored == fp {
skipped++
continue
}
filtered = append(filtered, pr)
}
if skipped > 0 {
fmt.Fprintf(os.Stderr, "[workflow] 跳过 %d 个已审查的 PR无变化\n", skipped)
}
upstream["open-prs"] = map[string]interface{}{"data": filtered}
}
// saveReviewedPRFingerprints stores PR fingerprints after a successful review.
func saveReviewedPRFingerprints(upstream map[string]interface{}, ctx *common.RuntimeContext) {
wfName := ctx.Arg("__wf_name")
if wfName == "" {
return
}
state, err := LoadState(wfName)
if err != nil || state == nil {
return
}
if state.ReviewedPRs == nil {
state.ReviewedPRs = make(map[string]string)
}
prs := extractPRListFromUpstream(upstream, "open-prs")
for _, pr := range prs {
prNum := prNumberFromMap(pr)
if prNum == "" {
continue
}
state.ReviewedPRs[prNum] = prFingerprint(pr)
}
state.Save()
}
// prFingerprint returns an MD5 hash of key PR fields for change detection.
func prFingerprint(pr map[string]interface{}) string {
var parts []string
if t := strFromMap(pr, "title", "name"); t != "" {
parts = append(parts, "title:"+t)
}
if b := strFromMap(pr, "body", "description"); b != "" {
parts = append(parts, "body:"+b)
}
if s := strFromMap(pr, "pull_request_status", "pull_request_staus", "status", "state"); s != "" {
parts = append(parts, "status:"+s)
}
h := md5.Sum([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(h[:])
}
// prNumberFromMap extracts a PR number string from a PR data map.
func prNumberFromMap(pr map[string]interface{}) string {
for _, k := range []string{"pull_request_number", "id", "number", "pull_request_id"} {
if v := pr[k]; v != nil {
s := fmt.Sprintf("%v", v)
if s != "" && s != "0" && s != "<nil>" {
return s
}
}
}
return ""
}
// strFromMap returns the first non-empty string value from the given keys.
func strFromMap(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return v
}
}
return ""
}
// extractPRListFromUpstream extracts PR list from upstream data.
func extractPRListFromUpstream(upstream map[string]interface{}, key string) []map[string]interface{} {
raw, ok := upstream[key]
if !ok {
return nil
}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
if m, ok := raw.(map[string]interface{}); ok {
for _, listKey := range []string{"issues", "pull_requests"} {
if v, ok := m[listKey]; ok {
if arr, ok := v.([]interface{}); ok {
raw = arr
break
}
}
}
}
list, _ := raw.([]interface{})
var out []map[string]interface{}
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
// enrichWithPRDiffs fetches diffs for open PRs and stores them in upstream
// so that both AI and the rule engine can analyze actual code changes.
func enrichWithPRDiffs(upstream map[string]interface{}, owner, repo string) {
prs := extractPRListFromUpstream(upstream, "open-prs")
if len(prs) == 0 {
return
}
diffs := make(map[string]string)
for _, pr := range prs {
// pull_request_status may be numeric (0=open, 1=merged, 2=closed) or string.
// The API also uses the typo key "pull_request_staus".
status := ""
switch v := pr["pull_request_status"].(type) {
case string:
status = v
case float64:
if v == 0 {
status = "open"
}
}
if status == "" {
if s, ok := pr["pull_request_staus"].(string); ok {
status = s
}
}
if status != "" && status != "open" {
continue
}
prNum := ""
if n, ok := pr["pull_request_number"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["id"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["number"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["pull_request_id"]; ok {
prNum = fmt.Sprintf("%v", n)
}
if prNum == "" {
continue
}
bin, _ := os.Executable()
if bin == "" {
bin = "gitlink-cli"
}
cmd := exec.Command(bin, "pr", "+diff", "--id", prNum, "--owner", owner, "--repo", repo, "--format", "json")
out, err := cmd.Output()
if err != nil {
continue
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Files []struct {
Sections []struct {
Lines []struct {
Content string `json:"content"`
} `json:"lines"`
} `json:"sections"`
} `json:"files"`
} `json:"data"`
}
if err := json.Unmarshal(out, &resp); err != nil || !resp.OK || len(resp.Data.Files) == 0 {
continue
}
var sb strings.Builder
for _, f := range resp.Data.Files {
for _, sec := range f.Sections {
for _, line := range sec.Lines {
sb.WriteString(line.Content)
sb.WriteString("\n")
}
}
}
diffText := sb.String()
if diffText == "" {
continue
}
diffs[prNum] = diffText
}
if len(diffs) > 0 {
upstream["_pr_diffs"] = diffs
}
}

View File

@ -55,7 +55,7 @@ func Watch(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration,
continue
}
state.Diff(result.Steps)
state.UpdateSnapshots(result.Steps)
state.TotalRuns++
state.Save()
@ -84,7 +84,7 @@ func Schedule(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duratio
// Run immediately on start (dry-run to establish baseline)
state, _ := LoadState(wf.Name)
dryResult, _ := Run(ctx, wf, true)
state.Diff(dryResult.Steps)
state.UpdateSnapshots(dryResult.Steps)
state.TotalRuns++
state.Save()
@ -102,6 +102,7 @@ func Schedule(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duratio
}
changed := state.Diff(dryResult.Steps)
state.UpdateSnapshots(dryResult.Steps)
state.TotalRuns++
state.Save()

View File

@ -43,6 +43,15 @@ const (
StepTypeAPI StepType = "api"
)
// RunWhen controls how often a step executes.
type RunWhen string
const (
RunAlways RunWhen = "always"
RunWeekly RunWhen = "weekly"
RunOnChange RunWhen = "on_change"
)
// StepDef defines a single step in a workflow.
//
// skill: Target = "gitlink-triage" → AI Agent reads the Skill doc
@ -54,6 +63,7 @@ type StepDef struct {
Purpose string `json:"purpose"`
Target string `json:"target"`
DependsOn []string `json:"depends_on,omitempty"`
RunWhen RunWhen `json:"run_when,omitempty"`
Method string `json:"method,omitempty"`
Query url.Values `json:"-"`
}
@ -87,8 +97,11 @@ type AIAction struct {
// 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)
Workflow string `json:"workflow"`
LastRun string `json:"last_run"`
TotalRuns int `json:"total_runs"`
Snapshots map[string]string `json:"snapshots"` // stepName → md5(json)
PhaseLastRun map[string]string `json:"phase_last_run,omitempty"` // stepName → RFC3339
PhaseUpstream map[string]string `json:"phase_upstream,omitempty"` // stepName → md5(upstream)
ReviewedPRs map[string]string `json:"reviewed_prs,omitempty"` // prNumber → fingerprint
}

View File

@ -102,6 +102,10 @@ func Shortcuts() []*common.Shortcut {
{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: "desc", Short: "d", Usage: "Project description (for project-init workflow)", Default: ""},
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow, e.g. org/backend,org/frontend", Default: ""},
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
{Name: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
},
@ -115,6 +119,11 @@ func Shortcuts() []*common.Shortcut {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
// Pass description to the init-scaffold rule engine.
if desc := ctx.Arg("desc"); desc != "" {
ctx.Args["_desc"] = desc
}
aiMode, err := resolveAIModeFromArgs(ctx)
if err != nil {
return err
@ -133,27 +142,6 @@ func Shortcuts() []*common.Shortcut {
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",
@ -173,6 +161,9 @@ func Shortcuts() []*common.Shortcut {
if wf == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
if wf.Name == "multi-repo" {
return fmt.Errorf("workflow +watch 不支持 multi-repo多仓库协同请求量较大请使用 workflow +run 手动检查,或 workflow +schedule --interval 6h/24h 做低频巡检")
}
intervalStr := ctx.Arg("interval")
interval, err := time.ParseDuration(intervalStr)
if err != nil {
@ -196,6 +187,9 @@ func Shortcuts() []*common.Shortcut {
{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},
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow", Default: ""},
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
@ -229,6 +223,9 @@ func Shortcuts() []*common.Shortcut {
{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},
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow", Default: ""},
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
@ -370,7 +367,7 @@ func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool
}
}
fmt.Fprintf(os.Stderr, "\n你可以:\n")
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set anthropic_api_key <key>\n")
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set deepseek_api_key <key>\n")
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
} else if ruleEngine > 0 {
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)

View File

@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/output"
@ -179,6 +180,85 @@ func TestSkillStepDependsOn(t *testing.T) {
}
}
func TestMultiRepoWorkflowShape(t *testing.T) {
wf := Get("multi-repo")
if wf == nil {
t.Fatal("multi-repo not found")
}
if len(wf.Steps) != 2 {
t.Fatalf("multi-repo should have 2 steps, got %d", len(wf.Steps))
}
if wf.Steps[0].Name != "multi-repo-snapshot" || wf.Steps[0].Target != "workflow-internal:multi-repo-snapshot" {
t.Fatalf("unexpected snapshot step: %+v", wf.Steps[0])
}
if wf.Steps[1].Target != "gitlink-multi-repo" {
t.Fatalf("multi-repo skill target = %q, want gitlink-multi-repo", wf.Steps[1].Target)
}
}
func TestParseMultiRepoRefs(t *testing.T) {
refs, err := parseMultiRepoRefs("org/backend, org/frontend,org/backend", "")
if err != nil {
t.Fatalf("parseMultiRepoRefs failed: %v", err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 deduped refs, got %d: %+v", len(refs), refs)
}
if refs[0].Owner != "org" || refs[0].Repo != "backend" {
t.Fatalf("unexpected first ref: %+v", refs[0])
}
}
func TestParseRepoCSV(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "repos.csv")
if err := os.WriteFile(path, []byte("owner,repo\norg,backend\norg/frontend\n"), 0600); err != nil {
t.Fatal(err)
}
refs, err := parseRepoCSV(path)
if err != nil {
t.Fatalf("parseRepoCSV failed: %v", err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 refs, got %d", len(refs))
}
if refs[1].Owner != "org" || refs[1].Repo != "frontend" {
t.Fatalf("unexpected second ref: %+v", refs[1])
}
}
func TestBuildDaemonArgsSkipsOwnerRepoForExplicitMultiRepo(t *testing.T) {
wf := Get("multi-repo")
ctx := &common.RuntimeContext{
Owner: "ignored",
Repo: "ignored",
Args: map[string]string{
"repos": "org/backend,org/frontend",
"release": "v1.4.0",
},
}
args := buildDaemonArgs(ctx, wf, 24*time.Hour, "no-ai")
if stringSliceContains(args, "--owner") || stringSliceContains(args, "--repo") {
t.Fatalf("explicit multi-repo daemon args should not include owner/repo: %v", args)
}
if !stringSliceContains(args, "--repos") || !stringSliceContains(args, "org/backend,org/frontend") {
t.Fatalf("daemon args missing repos: %v", args)
}
if !stringSliceContains(args, "--release") || !stringSliceContains(args, "v1.4.0") {
t.Fatalf("daemon args missing release: %v", args)
}
}
func stringSliceContains(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
func TestParseCommandTarget(t *testing.T) {
cases := []struct {
input string

View File

@ -85,6 +85,58 @@
.result-body tbody tr:hover { background: #2a2a2a; }
.result-body tbody tr:nth-child(even) { background: #252525; }
/* ---- workflow result report ---- */
.wf-result { margin: 0; }
.wf-result-header { display: flex; align-items: center; gap: 12px; padding: 14px 20px; background: linear-gradient(135deg, #f6f0ff 0%, #f0f5ff 100%); border-bottom: 2px solid #e8e0f0; }
.wf-result-header .wf-result-name { font-weight: 700; font-size: 1.05em; color: #722ed1; font-family: 'SFMono-Regular',Consolas,monospace; }
.wf-result-header .wf-result-repo { font-size: 0.82em; color: #999; }
.wf-result-header .wf-result-status { margin-left: auto; font-size: 0.82em; padding: 3px 10px; border-radius: 12px; font-weight: 600; }
.wf-result-header .wf-result-status.ok { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.wf-result-header .wf-result-status.err { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }
.wf-result-steps { padding: 0; }
.wf-step { border-bottom: 1px solid #f0f0f0; }
.wf-step:last-child { border-bottom: none; }
.wf-step-header { display: flex; align-items: center; gap: 8px; padding: 10px 20px; cursor: default; transition: background 0.15s; }
.wf-step-header:hover { background: #fafafa; }
.wf-step-header .wf-step-icon { font-size: 0.9em; width: 20px; text-align: center; flex-shrink: 0; }
.wf-step-header .wf-step-purpose { flex: 1; font-size: 0.85em; color: #333; }
.wf-step-header .wf-step-type { font-size: 0.7em; padding: 2px 8px; border-radius: 10px; font-weight: 600; flex-shrink: 0; }
.wf-step-header .wf-step-type.cmd { background: #e6f7ff; color: #1890ff; }
.wf-step-header .wf-step-type.skill { background: #fff7e6; color: #fa8c16; }
.wf-step-header .wf-step-type.skill.ai { background: #f6ffed; color: #52c41a; }
.wf-step-header .wf-step-summary { font-size: 0.78em; color: #888; flex-shrink: 0; white-space: nowrap; }
.wf-step-detail { padding: 0 20px 14px 48px; display: none; }
.wf-step-detail.show { display: block; }
.wf-step-header.clickable { cursor: pointer; }
.wf-step-header.clickable .wf-step-purpose { color: #1890ff; }
/* triage result table */
.wf-triage-table { width: 100%; border-collapse: collapse; font-size: 0.82em; margin-top: 6px; }
.wf-triage-table th { background: #fafafa; color: #666; text-align: left; padding: 6px 10px; border-bottom: 2px solid #e8e8e8; font-weight: 600; }
.wf-triage-table td { padding: 5px 10px; border-bottom: 1px solid #f0f0f0; }
.wf-triage-table tbody tr:hover { background: #fafafa; }
/* health score card */
.wf-health-card { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
.wf-health-total { display: flex; align-items: center; justify-content: center; flex-direction: column; width: 90px; height: 90px; border-radius: 50%; background: linear-gradient(135deg, #f6f0ff, #ede0ff); border: 3px solid #d3adf7; flex-shrink: 0; }
.wf-health-total .score { font-size: 1.6em; font-weight: 700; color: #722ed1; }
.wf-health-total .grade { font-size: 0.72em; color: #888; }
.wf-health-dims { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; flex: 1; }
.wf-health-dim { background: #fafafa; border-radius: 8px; padding: 8px 12px; text-align: center; }
.wf-health-dim .dim-name { font-size: 0.75em; color: #888; margin-bottom: 4px; }
.wf-health-dim .dim-score { font-size: 1.1em; font-weight: 700; color: #333; }
.wf-health-dim .dim-grade { font-size: 0.7em; }
.wf-health-dim .dim-grade.A { color: #52c41a; }
.wf-health-dim .dim-grade.B { color: #faad14; }
.wf-health-dim .dim-grade.C { color: #ff4d4f; }
/* changelog sections */
.wf-changelog-sections { margin-top: 6px; }
.wf-changelog-section { margin-bottom: 10px; }
.wf-changelog-section .cl-head { font-weight: 600; font-size: 0.85em; margin-bottom: 4px; color: #555; }
.wf-changelog-section .cl-items { padding-left: 16px; }
.wf-changelog-section .cl-items li { font-size: 0.78em; color: #888; line-height: 1.8; font-family: 'SFMono-Regular',Consolas,monospace; }
.wf-step-actions { margin-top: 8px; font-size: 0.75em; color: #999; }
.wf-step-actions .act-ok { color: #52c41a; }
.wf-step-actions .act-fail { color: #ff4d4f; }
.skills-category { margin-bottom: 24px; }
.skills-category h3 { font-size: 1.1em; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #f0f0f0; display: flex; align-items: center; gap: 8px; }
.skills-category h3 .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
@ -110,20 +162,58 @@
.example-btn { background: none; border: 1px solid #52c41a; color: #52c41a; padding: 2px 10px; border-radius: 4px; cursor: pointer; font-size: 0.75em; margin-top: 4px; }
.example-btn:hover { background: #52c41a; color: white; }
.wf-subsection { margin-bottom: 20px; }
.wf-subsection h3 { font-size: 1.05em; margin-bottom: 12px; color: #722ed1; }
.wf-def-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; margin-bottom: 28px; }
.wf-def-card { background: white; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); padding: 16px 18px; border-top: 4px solid #722ed1; }
.wf-def-card .wf-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.wf-def-card .wf-name { font-weight: 700; font-size: 1em; font-family: monospace; color: #722ed1; }
.wf-def-card .wf-cat { font-size: 0.7em; background: #f3e8ff; color: #722ed1; padding: 2px 8px; border-radius: 10px; }
.wf-def-card .wf-desc { color: #666; font-size: 0.85em; margin-bottom: 8px; line-height: 1.4; }
.wf-def-card .wf-trigger { font-size: 0.78em; color: #999; margin-bottom: 8px; }
.wf-def-card .wf-trigger code { background: #f5f5f5; padding: 1px 6px; border-radius: 3px; font-size: 0.95em; }
.wf-def-card .wf-steps { font-size: 0.78em; color: #888; }
.wf-def-card .wf-steps summary { cursor: pointer; color: #722ed1; font-weight: 500; margin-bottom: 4px; }
.wf-def-card .wf-steps ol { padding-left: 18px; line-height: 1.6; }
.wf-commands { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
.wf-panel { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 24px; overflow: hidden; }
.wf-panel-header { padding: 16px 20px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #f0f0f0; background: #faf6ff; }
.wf-panel-header:hover { background: #f3e8ff; }
.wf-panel-header .wf-name { font-weight: 700; font-size: 1.05em; font-family: 'SFMono-Regular', Consolas, monospace; color: #722ed1; }
.wf-panel-header .wf-meta { display: flex; align-items: center; gap: 10px; }
.wf-panel-header .wf-cat { font-size: 0.7em; background: #f3e8ff; color: #722ed1; padding: 2px 8px; border-radius: 10px; }
.wf-panel-header .wf-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.wf-panel-header .wf-dot.running { background: #52c41a; box-shadow: 0 0 4px #52c41a; }
.wf-panel-header .wf-dot.stopped { background: #d9d9d9; }
.wf-panel-header .arrow { transition: transform 0.3s; font-size: 0.8em; color: #999; }
.wf-panel-header .arrow.open { transform: rotate(180deg); }
.wf-panel-body.collapsed { display: none; }
.wf-panel-body { padding: 0; }
.wf-panel-desc { padding: 12px 20px; color: #666; font-size: 0.9em; border-bottom: 1px solid #f0f0f0; background: #fcfcfc; }
.wf-panel-trigger { padding: 8px 20px; font-size: 0.8em; color: #999; border-bottom: 1px solid #f0f0f0; }
.wf-panel-trigger code { background: #f5f5f5; padding: 1px 6px; border-radius: 3px; }
.wf-panel-steps { padding: 8px 20px; border-bottom: 1px solid #f0f0f0; }
.wf-panel-steps summary { cursor: pointer; color: #722ed1; font-weight: 500; font-size: 0.85em; }
.wf-panel-steps ol { padding-left: 18px; line-height: 1.6; font-size: 0.8em; color: #888; }
.wf-actions { padding: 12px 20px; display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
.wf-actions .action-btn { color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85em; white-space: nowrap; }
.wf-actions .action-btn:hover { opacity: 0.85; }
.wf-actions .action-btn:disabled { background: #d9d9d9; cursor: not-allowed; }
.wf-actions .btn-run { background: #1890ff; }
.wf-actions .btn-start { background: #52c41a; }
.wf-actions .btn-stop { background: #ff4d4f; }
.wf-actions .btn-info { background: #722ed1; }
.wf-actions .btn-status { background: #faad14; color: #333; }
.wf-actions .btn-logs { background: #666; }
.wf-actions .action-select { border: 1px solid #d9d9d9; border-radius: 4px; padding: 5px 8px; font-size: 0.85em; }
.mode-selector { display: flex; padding: 8px 20px; gap: 0; border-bottom: 1px solid #f0f0f0; background: #fafafa; }
.mode-selector .mode-label { font-size: 0.8em; color: #999; margin-right: 10px; display: flex; align-items: center; font-weight: 600; }
.mode-selector .mode-btn { flex: 1; max-width: 100px; padding: 6px 0; border: 1px solid #d9d9d9; background: white; color: #666; cursor: pointer; font-size: 0.82em; text-align: center; transition: all 0.2s; }
.mode-selector .mode-btn:first-of-type { border-radius: 6px 0 0 6px; }
.mode-selector .mode-btn:last-of-type { border-radius: 0 6px 6px 0; }
.mode-selector .mode-btn:not(:first-of-type) { border-left: none; }
.mode-selector .mode-btn:hover { color: #1890ff; border-color: #1890ff; }
.mode-selector .mode-btn.active { background: #1890ff; color: white; border-color: #1890ff; }
.mode-selector .mode-btn.active + .mode-btn { border-left-color: #1890ff; }
.wf-params { padding: 12px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.wf-params .param-group { display: flex; flex-direction: column; gap: 2px; }
.wf-params .param-group label { font-size: 0.78em; color: #999; font-weight: 500; }
.wf-params .param-group input, .wf-params .param-group select, .wf-params .param-group textarea { border: 1px solid #d9d9d9; border-radius: 4px; padding: 5px 8px; font-size: 0.85em; min-width: 160px; }
.wf-params .param-group textarea { min-width: 280px; min-height: 48px; resize: vertical; }
.wf-params .param-group input:focus, .wf-params .param-group select:focus, .wf-params .param-group textarea:focus { border-color: #1890ff; outline: none; box-shadow: 0 0 0 2px rgba(24,144,255,0.2); }
.wf-section-label { padding: 10px 20px 4px; font-size: 0.78em; color: #999; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; }
.wf-global { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 12px 20px; margin-bottom: 24px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.wf-global span { font-weight: 600; color: #722ed1; font-size: 0.95em; }
.wf-global .action-btn { color: white; border: none; padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 0.85em; white-space: nowrap; }
.placeholder-card { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 60px 40px; text-align: center; color: #999; }
.placeholder-card .ph-icon { font-size: 3em; margin-bottom: 16px; }
@ -427,94 +517,254 @@ const SKILLS = [
const WORKFLOWS = [
{ name:"community-ops", category:"运营", triggerType:"poll", triggerOn:"issue.created",
features: ["daemon", "execute"],
desc:"社区运营自动化全链路Issue 智能分拣 → 生成周报 → 生成 Release Notes",
steps:["获取所有开放 Issue","获取标签库","获取成员列表","[AI] 分拣表格并执行打标签/分配","获取项目基础信息","获取已合并 PR","获取提交历史","[AI] 根据指标生成周报","获取版本发布记录","[AI] 分类 commit 生成 Release Notes"] },
{ name:"code-quality", category:"质量", triggerType:"poll", triggerOn:"pr.opened",
features: ["daemon", "execute"],
desc:"PR 提交 → Review → CI 检查 → 结果汇总的全自动代码质量门禁",
steps:["获取开放 PR 列表","获取 CI 构建状态","获取 PR 变更文件列表","获取 PR 详情","[AI] 代码审查(安全/性能/可读性/测试/CI","[AI] 生成 Review 意见","[AI] 质量达标则自动提交 Review 评论"] },
{ name:"project-init", category:"初始化", triggerType:"manual", triggerOn:"manual",
features: ["params", "execute"],
desc:"输入仓库名 → 检查现状 → 补齐缺失README/LICENSE/标签/里程碑/Issue",
params: [
{k:"desc", l:"项目描述", t:"textarea", v:"一个基于 Go 的 GitLink CLI 工具提供命令行方式管理仓库、Issue、PR 等"},
],
steps:["获取仓库基本信息","检查文件结构","获取已有标签和里程碑","检查分支保护状态","[AI] 对比初始化清单,识别缺失项","[AI] 生成建议的初始化内容","[AI] 自动创建缺失的标签/里程碑/Issue","[AI] 生成项目初始化报告"] },
{ name:"multi-repo", category:"协同", triggerType:"cron", triggerOn:"0 9 * * 1",
features: ["execute"],
desc:"跨多个仓库的统一 Issue 追踪、PR 状态看板、Release 协调",
steps:["获取目标仓库列表","获取每个仓库的开放 Issue","获取每个仓库的开放 PR","获取每个仓库的最新 Release","获取每个仓库的里程碑进度","[AI] 生成跨仓库状态看板"] },
{ name:"contributor-growth", category:"成长", triggerType:"cron", triggerOn:"0 9 * * 1",
features: ["schedule", "execute"],
desc:"追踪贡献者活动 → 排行 → 识别新星与流失风险",
steps:["获取提交历史","获取 Issue/PR 贡献数据","获取成员列表","[AI] 统计贡献排行","[AI] 计算 30 天活跃度趋势","[AI] 识别新星贡献者和流失风险"] },
];
const WORKFLOW_COMMANDS = [
{ name:"list", desc:"列出所有可用工作流", detail:"按类别列出全部预置工作流,支持 --category 按类别过滤", params:[{k:"category",l:"类别过滤",v:""}] },
{ name:"info", desc:"查看工作流详情", detail:"查看指定工作流的步骤列表、触发方式和说明", params:[{k:"name",l:"工作流名称",v:"community-ops"}] },
{ name:"run", desc:"执行工作流", detail:"手动执行一个工作流。默认自动检测 AI Key无 Key 时用规则引擎", params:[{k:"name",l:"工作流名称",v:"community-ops"},{k:"no-ai",l:"仅规则引擎",v:"true",bool:true},{k:"dry-run",l:"预览模式",v:"false",bool:true}] },
{ name:"init", desc:"项目一键初始化", detail:"运行内置 project-init 工作流,检查并补齐仓库缺失项", params:[{k:"dry-run",l:"预览模式",v:"false",bool:true},{k:"no-ai",l:"仅规则引擎",v:"true",bool:true}] },
{ name:"watch", desc:"监听变化触发工作流", detail:"轮询检测数据变化,有变更时自动触发工作流", params:[{k:"name",l:"工作流名称",v:"community-ops"},{k:"interval",l:"轮询间隔",v:"5m"}] },
{ name:"schedule", desc:"定时执行工作流", detail:"按固定间隔周期执行工作流", params:[{k:"name",l:"工作流名称",v:"community-ops"},{k:"interval",l:"执行间隔",v:"24h"}] },
{ name:"start", desc:"后台启动守护进程", detail:"在后台以守护进程模式运行工作流,定时巡检", params:[{k:"name",l:"工作流名称",v:"community-ops"},{k:"interval",l:"轮询间隔",v:"5m"}] },
{ name:"stop", desc:"停止守护进程", detail:"停止指定工作流的后台守护进程", params:[{k:"name",l:"工作流名称",v:"community-ops"}] },
{ name:"status", desc:"查看守护进程状态", detail:"查看指定工作流的后台守护进程运行状态", params:[{k:"name",l:"工作流名称",v:"community-ops"}] },
{ name:"logs", desc:"查看守护进程日志", detail:"查看/跟踪指定工作流的后台守护进程输出日志", params:[{k:"name",l:"工作流名称",v:"community-ops"},{k:"follow",l:"持续跟踪",v:"false",bool:true}] },
{ name:"install-systemd", desc:"生成 systemd 服务", detail:"生成 Linux systemd unit 文件,用于开机自启和系统级管理", params:[{k:"name",l:"工作流名称",v:"community-ops"},{k:"interval",l:"轮询间隔",v:"5m"}] },
];
const WORKFLOW_COMMANDS = []; // unused
(function renderPart1() {
const container = document.getElementById('modules');
MODULES.forEach(mod => {
const card = document.createElement('div');
(function renderModules() {
var grid = document.getElementById('modules');
if (!grid) return;
MODULES.forEach(function(mod) {
var card = document.createElement('div');
card.className = 'module-card';
const cmdHTML = mod.commands.map(cmd => {
if (cmd.section) return '<div class="cmd-section">' + cmd.section + '</div>';
const uid = cmd.module ? mod.name + '-' + cmd.module + '-' + cmd.name : mod.name + '-' + cmd.name;
const paramsHTML = cmd.params.map(function(p) {
if (p.bool) return '<div class="param-group"><label>' + p.l + '</label><select id="p-' + uid + '-' + p.k + '"><option value="false"' + (p.v !== 'true' ? ' selected' : '') + '></option><option value="true"' + (p.v === 'true' ? ' selected' : '') + '></option></select></div>';
return '<div class="param-group"><label>' + p.l + '</label><input id="p-' + uid + '-' + p.k + '" value="' + p.v + '" placeholder="' + p.l + '"></div>';
}).join('');
return '<div class="cmd-row"><div class="cmd-top"><span class="cmd-name">+' + cmd.name + '</span><span class="cmd-desc">' + cmd.desc + '</span><button class="cmd-toggle" onclick="toggleDetail(\'' + uid + '\')">详情</button></div><div class="cmd-detail" id="detail-' + uid + '">' + cmd.detail + '</div><div class="cmd-params" id="params-' + uid + '">' + paramsHTML + '<button class="run-btn" onclick="runCommand(\'' + mod.name + '\',\'' + cmd.name + '\',\'' + uid + '\')" id="btn-' + uid + '">▶ 运行</button></div><div class="result-panel" id="result-' + uid + '"></div></div>';
}).join('');
card.innerHTML = '<div class="module-header" onclick="toggleModule(this)"><h3><span style="color:' + mod.color + ';font-size:1.3em;">&#9632;</span> ' + mod.title + ' <span class="tag">' + mod.commands.length + ' 个命令</span></h3><span class="arrow">&#9660;</span></div><div class="module-body collapsed"><p style="padding:12px 20px;color:#666;font-size:0.9em;border-bottom:1px solid #f0f0f0;background:#fafbfc;">' + mod.desc + '</p>' + (mod.repoSelector ? '<div style="padding:8px 20px;border-bottom:1px solid #f0f0f0;background:#f6ffed;display:flex;gap:10px;align-items:center;font-size:0.9em;"><label style="white-space:nowrap;color:#52c41a;">📦 仓库:</label><input id="repo-owner-' + mod.name + '" value="chroe" placeholder="owner" style="width:90px;padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;">/<input id="repo-name-' + mod.name + '" value="gitlink-cli" placeholder="repo" style="width:150px;padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;"></div>' : '') + cmdHTML + '</div>';
container.appendChild(card);
card.style.borderTop = '3px solid ' + (mod.color || '#1890ff');
var headerHTML = '<div class="module-header" onclick="toggleModule(this)">' +
'<h3>' + mod.title + ' <span class="tag">' + mod.commands.length + ' 命令</span></h3>' +
'<span class="arrow">&#9660;</span></div>';
var bodyHTML = '<div class="module-body collapsed">';
// Repo selector for member module
if (mod.repoSelector) {
bodyHTML += '<div style="padding:8px 20px;display:flex;gap:10px;align-items:flex-end;border-bottom:1px solid #f0f0f0">' +
'<div class="param-group"><label>Owner</label><input id="repo-owner-' + mod.name + '" value="chroe" style="width:120px"></div>' +
'<div class="param-group"><label>Repo</label><input id="repo-name-' + mod.name + '" value="gitlink-cli" style="width:140px"></div>' +
'</div>';
}
bodyHTML += '<div style="padding:10px 20px;color:#888;font-size:0.85em;border-bottom:1px solid #f0f0f0">' + mod.desc + '</div>';
// Group commands by section
var currentSection = '';
mod.commands.forEach(function(cmd) {
if (cmd.section) {
bodyHTML += '<div class="cmd-section">' + cmd.section + '</div>';
return;
}
var cmdUid = (cmd.module || mod.name) + '-' + cmd.name;
bodyHTML += '<div class="cmd-row">' +
'<div class="cmd-top">' +
'<span class="cmd-name">+' + cmd.name + '</span>' +
'<span class="cmd-desc">' + cmd.desc + '</span>' +
(cmd.detail ? '<button class="cmd-toggle" onclick="toggleDetail(\'' + cmdUid + '\')">展开 ▸</button>' : '') +
'</div>' +
(cmd.detail ? '<div class="cmd-detail" id="detail-' + cmdUid + '">' + cmd.detail + '</div>' : '');
if (cmd.params && cmd.params.length > 0) {
bodyHTML += '<div class="cmd-params" id="params-' + cmdUid + '">';
cmd.params.forEach(function(p) {
if (p.bool) {
bodyHTML += '<div class="param-group"><label>' + p.l + '</label><select id="p-' + cmdUid + '-' + p.k + '"><option value="true"></option><option value="false"></option></select></div>';
} else {
bodyHTML += '<div class="param-group"><label>' + p.l + '</label><input id="p-' + cmdUid + '-' + p.k + '" placeholder="' + p.l + '" value="' + (p.v||'') + '"></div>';
}
});
bodyHTML += '<button class="run-btn" id="btn-' + cmdUid + '" onclick="runCommand(\'' + (cmd.module || mod.name) + '\',\'' + cmd.name + '\',\'' + cmdUid + '\')">▶ 运行</button>';
bodyHTML += '</div>';
} else {
bodyHTML += '<div class="cmd-params" id="params-' + cmdUid + '">' +
'<button class="run-btn" id="btn-' + cmdUid + '" onclick="runCommand(\'' + (cmd.module || mod.name) + '\',\'' + cmd.name + '\',\'' + cmdUid + '\')">▶ 运行</button>' +
'</div>';
}
bodyHTML += '<div class="result-panel" id="result-' + cmdUid + '"></div>';
bodyHTML += '</div>';
});
bodyHTML += '</div>';
card.innerHTML = headerHTML + bodyHTML;
grid.appendChild(card);
});
})();
(function renderPart2() {
var cats = [
{ key:"core", label:"核心 Skills", desc:"覆盖 GitLink 平台核心操作(仓库/Issue/PR/分支/发布)", dotClass:"core" },
{ key:"smart", label:"智能 Skills", desc:"AI 增强分析(健康度/变更日志/智能分拣/代码审查)", dotClass:"smart" },
{ key:"aux", label:"辅助 Skills", desc:"搜索/用户/组织/CI/PM/工作流引擎/许可证合规", dotClass:"aux" },
];
(function renderSkills() {
var container = document.getElementById('skills-container');
cats.forEach(function(cat) {
var skills = SKILLS.filter(function(s) { return s.cat === cat.key; });
var cardsHTML = skills.map(function(s) {
var filesStr = s.files.join(', ');
var exampleHTML = s.example ? '<button class="example-btn" onclick="toggleExample(\'' + s.id + '\')">📄 查看示例</button><div class="skill-example" id="ex-' + s.id + '"><div class="ex-title">' + s.example.title + '</div><div class="ex-text">' + s.example.text + '</div></div>' : '';
return '<div class="skill-card ' + s.cat + '"><div class="skill-top"><span class="skill-name">gitlink-' + s.name + '</span><span class="skill-ver">v' + s.ver + '</span></div><div class="skill-desc">' + s.desc + '</div>' + exampleHTML + '<div class="skill-meta"><span>📁 ' + s.files.length + ' 个文件</span><span>✅ 已完成</span></div><div class="skill-files">' + filesStr + '</div></div>';
}).join('');
container.insertAdjacentHTML('beforeend', '<div class="skills-category"><h3><span class="dot ' + cat.dotClass + '"></span>' + cat.label + ' <span style="font-weight:400;color:#999;font-size:0.85em;">(' + skills.length + ' 个)</span></h3><p style="color:#999;font-size:0.85em;margin-bottom:12px;">' + cat.desc + '</p><div class="skill-grid">' + cardsHTML + '</div></div>');
if (!container) return;
var categories = {core:{label:'核心 Skills',dot:'core',skills:[]},smart:{label:'智能 Skills',dot:'smart',skills:[]},aux:{label:'辅助 Skills',dot:'aux',skills:[]}};
SKILLS.forEach(function(s) {
var cat = categories[s.cat] || categories.aux;
cat.skills.push(s);
});
['core','smart','aux'].forEach(function(catKey) {
var cat = categories[catKey];
if (cat.skills.length === 0) return;
var catHTML = '<div class="skills-category">' +
'<h3><span class="dot ' + cat.dot + '"></span>' + cat.label + ' (' + cat.skills.length + ')</h3>' +
'<div class="skill-grid">';
cat.skills.forEach(function(s) {
var filesDisplay = s.files ? s.files.slice(0, 3).join(', ') + (s.files.length > 3 ? '...' : '') : '';
catHTML += '<div class="skill-card ' + s.cat + '">' +
'<div class="skill-top"><span class="skill-name">' + s.displayName + '</span><span class="skill-ver">v' + s.ver + '</span></div>' +
'<div class="skill-desc">' + s.desc + '</div>' +
'<div class="skill-meta">' +
'<span>ID: <code>' + s.id + '</code></span>' +
(filesDisplay ? '<span>📄 ' + filesDisplay + '</span>' : '') +
'</div>';
if (s.example) {
var exId = 'skex-' + s.id;
catHTML += '<button class="example-btn" onclick="var e=document.getElementById(\'' + exId + '\');e.classList.toggle(\'show\');this.textContent=e.classList.contains(\'show\')?\'收起示例 ▴\':\'查看示例 ▸\'">查看示例 ▸</button>' +
'<div class="skill-example" id="' + exId + '"><div class="ex-title">' + s.example.title + '</div><div class="ex-text">' + s.example.text + '</div></div>';
}
catHTML += '</div>';
});
catHTML += '</div></div>';
container.insertAdjacentHTML('beforeend', catHTML);
});
})();
function toggleExample(id) { document.getElementById('ex-' + id).classList.toggle('show'); }
(function renderPart3() {
var container = document.getElementById('workflow-container');
var defCardsHTML = WORKFLOWS.map(function(w) {
var stepsHTML = w.steps.map(function(s,i) { return '<li>' + s + '</li>'; }).join('');
var triggerLabel = w.triggerType === 'manual' ? '手动触发' : w.triggerType === 'poll' ? '事件: ' + w.triggerOn : '定时: ' + w.triggerOn;
return '<div class="wf-def-card"><div class="wf-top"><span class="wf-name">' + w.name + '</span><span class="wf-cat">' + w.category + '</span></div><div class="wf-desc">' + w.desc + '</div><div class="wf-trigger">⚡ 触发: <code>' + w.triggerType + '</code> ' + triggerLabel + '</div><details class="wf-steps"><summary>' + w.steps.length + ' 个步骤</summary><ol>' + stepsHTML + '</ol></details></div>';
}).join('');
container.insertAdjacentHTML('beforeend', '<div class="wf-subsection"><h3>📋 预置工作流定义(' + WORKFLOWS.length + ' 个)</h3><div class="wf-def-grid">' + defCardsHTML + '</div></div>');
var cmdRowsHTML = WORKFLOW_COMMANDS.map(function(cmd) {
var uid = 'wf-' + cmd.name;
var paramsHTML = cmd.params.map(function(p) {
if (p.bool) return '<div class="param-group"><label>' + p.l + '</label><select id="p-' + uid + '-' + p.k + '"><option value="false"' + (p.v !== 'true' ? ' selected' : '') + '></option><option value="true"' + (p.v === 'true' ? ' selected' : '') + '></option></select></div>';
return '<div class="param-group"><label>' + p.l + '</label><input id="p-' + uid + '-' + p.k + '" value="' + p.v + '" placeholder="' + p.l + '"></div>';
}).join('');
return '<div class="cmd-row"><div class="cmd-top"><span class="cmd-name" style="color:#722ed1;">+' + cmd.name + '</span><span class="cmd-desc">' + cmd.desc + '</span><button class="cmd-toggle" onclick="toggleDetail(\'' + uid + '\')">详情</button></div><div class="cmd-detail" id="detail-' + uid + '">' + cmd.detail + '</div><div class="cmd-params" id="params-' + uid + '">' + paramsHTML + '<button class="run-btn" style="background:#722ed1;" onclick="runWorkflowCommand(\'' + cmd.name + '\',\'' + uid + '\')" id="btn-' + uid + '">▶ 运行</button></div><div class="result-panel" id="result-' + uid + '"></div></div>';
}).join('');
container.insertAdjacentHTML('beforeend', '<div class="wf-subsection"><h3>🖥 工作流 CLI 命令(' + WORKFLOW_COMMANDS.length + ' 个)</h3><div class="wf-commands">' + cmdRowsHTML + '</div></div>');
var container = document.getElementById("workflow-container");
// Global top bar
var globalHTML = '<div class="wf-global">' +
'<span>全局命令 </span>' +
'<button class="action-btn btn-info" onclick="runWfGlobal(\'list\',\'wf-global-list\')" id="btn-wf-global-list">列出所有工作流</button>' +
'<button class="action-btn btn-logs" onclick="runWfGlobal(\'install-systemd\',\'wf-global-install\')" id="btn-wf-global-install">生成 systemd 服务</button>' +
'</div>' +
'<div class="result-panel" id="result-wf-global-list"></div>' +
'<div class="result-panel" id="result-wf-global-install"></div>';
container.insertAdjacentHTML("beforeend", globalHTML);
WORKFLOWS.forEach(function(w) {
var uid = "wfpanel-" + w.name;
var stepsHTML = w.steps.map(function(s,i) { return "<li>" + s + "</li>"; }).join("");
var triggerLabel = w.triggerType === "manual" ? "手动触发" : w.triggerType === "poll" ? "事件触发: <code>" + w.triggerOn + "</code>" : "定时触发: <code>" + w.triggerOn + "</code>";
// Mode selector
var modeHTML = '<div class="mode-selector">' +
'<span class="mode-label">模式</span>' +
'<button class="mode-btn active" onclick="selectMode(\'' + uid + '\',\'no-ai\',this)">No AI</button>' +
'<button class="mode-btn" onclick="selectMode(\'' + uid + '\',\'ai\',this)">AI</button>' +
'<button class="mode-btn" onclick="selectMode(\'' + uid + '\',\'auto\',this)">Auto</button>' +
'</div>';
// Feature-specific controls
var controlsHTML = '';
var hasWf = function(f) { return w.features.indexOf(f) >= 0; };
if (hasWf("daemon")) {
controlsHTML += '<div class="wf-section-label">守护进程</div>' +
'<div class="wf-actions">' +
'<button class="action-btn btn-start" onclick="runWfDaemon(\'' + w.name + '\',\'start\',\'' + uid + '\')" id="btn-start-' + uid + '">▶ 启动守护</button>' +
'<button class="action-btn btn-stop" onclick="runWfDaemon(\'' + w.name + '\',\'stop\',\'' + uid + '\')" id="btn-stop-' + uid + '">⏹ 停止</button>' +
'<button class="action-btn btn-status" onclick="runWfDaemon(\'' + w.name + '\',\'status\',\'' + uid + '\')" id="btn-status-' + uid + '">📊 状态</button>' +
'<button class="action-btn btn-logs" onclick="runWfDaemon(\'' + w.name + '\',\'logs\',\'' + uid + '\')" id="btn-logs-' + uid + '">📋 查看日志</button>' +
'</div>';
}
if (hasWf("params")) {
controlsHTML += '<div class="wf-section-label">初始化参数</div>' +
'<div class="wf-params">';
w.params.forEach(function(p) {
if (p.t === "textarea") {
controlsHTML += '<div class="param-group" style="flex:1 1 100%; min-width:280px">' +
'<label>' + p.l + '</label>' +
'<textarea id="p-' + uid + '-' + p.k + '" placeholder="' + p.l + '">' + (p.v||'') + '</textarea>' +
'</div>';
} else if (p.t === "select") {
controlsHTML += '<div class="param-group">' +
'<label>' + p.l + '</label>' +
'<select id="p-' + uid + '-' + p.k + '">' +
p.opts.map(function(o) { return '<option>' + o + '</option>'; }).join('') +
'</select></div>';
} else {
controlsHTML += '<div class="param-group">' +
'<label>' + p.l + '</label>' +
'<input id="p-' + uid + '-' + p.k + '" placeholder="' + p.l + '" value="' + (p.v||'') + '">' +
'</div>';
}
});
controlsHTML += '</div>';
}
if (hasWf("schedule")) {
controlsHTML += '<div class="wf-section-label">定时运行</div>' +
'<div class="wf-actions">' +
'<select class="action-select" id="schedule-' + uid + '">' +
'<option value="30m">每 30 分钟</option>' +
'<option value="1h">每 1 小时</option>' +
'<option value="6h">每 6 小时</option>' +
'<option value="12h">每 12 小时</option>' +
'<option value="24h" selected>每 24 小时</option>' +
'<option value="0 9 * * 1">每周一 9:00</option>' +
'<option value="0 9 1 * *">每月 1 日 9:00</option>' +
'</select>' +
'<button class="action-btn btn-start" onclick="runWfSchedule(\'' + w.name + '\',\'' + uid + '\')" id="btn-schedule-' + uid + '">⏰ 设置定时运行</button>' +
'</div>';
}
if (hasWf("execute")) {
controlsHTML += '<div class="wf-section-label">手动执行</div>' +
'<div class="wf-actions">' +
'<button class="action-btn btn-run" onclick="runWfExecute(\'' + w.name + '\',\'' + uid + '\')" id="btn-run-' + uid + '">▶ 立即执行</button>' +
'</div>';
}
var panelHTML =
'<div class="wf-panel" id="' + uid + '" data-mode="no-ai">' +
'<div class="wf-panel-header" onclick="toggleWfPanel(this)">' +
'<div class="wf-meta">' +
'<span class="wf-dot stopped" id="dot-' + uid + '"></span>' +
'<span class="wf-name">' + w.name + '</span>' +
'<span class="wf-cat">' + w.category + '</span>' +
'</div>' +
'<span class="arrow">&#9660;</span>' +
'</div>' +
'<div class="wf-panel-body collapsed">' +
'<div class="wf-panel-desc">' + w.desc + '</div>' +
'<div class="wf-panel-trigger">⚡ ' + triggerLabel + '</div>' +
'<details class="wf-panel-steps"><summary>' + w.steps.length + ' 个步骤</summary><ol>' + stepsHTML + '</ol></details>' +
modeHTML +
controlsHTML +
'<div class="result-panel" id="result-' + uid + '"></div>' +
'</div>' +
'</div>';
container.insertAdjacentHTML("beforeend", panelHTML);
});
})();
function toggleWfPanel(header) {
var body = header.nextElementSibling;
var arrow = header.querySelector(".arrow");
body.classList.toggle("collapsed");
arrow.classList.toggle("open");
}
function toggleModule(header) {
var body = header.nextElementSibling;
var arrow = header.querySelector('.arrow');
@ -545,11 +795,17 @@ function getArgs(uid, params) {
async function runCommand(module, command, uid) {
var btn = document.getElementById('btn-' + uid);
var panel = document.getElementById('result-' + uid);
var mod = MODULES.find(function(m) { return m.name === module; });
var cmd = mod.commands.find(function(c) {
var expectedUid = c.module ? module + '-' + c.module + '-' + c.name : module + '-' + c.name;
return expectedUid === uid;
});
// Search all modules for the command (supports cross-module commands like org/info)
var cmd = null, mod = null;
for (var i = 0; i < MODULES.length; i++) {
var m = MODULES[i];
var found = m.commands.find(function(c) {
if (c.section) return false;
var expectedUid = (c.module || m.name) + '-' + c.name;
return expectedUid === uid;
});
if (found) { cmd = found; mod = m; break; }
}
if (!cmd) return;
var args = getArgs(uid, cmd.params);
var apiModule = cmd.module || module;
@ -593,42 +849,334 @@ async function runCommand(module, command, uid) {
btn.textContent = '▶ 运行';
}
async function runWorkflowCommand(command, uid) {
async function runWfGlobal(command, uid) {
var btn = document.getElementById('btn-' + uid);
var panel = document.getElementById('result-' + uid);
var cmd = WORKFLOW_COMMANDS.find(function(c) { return c.name === command; });
if (!cmd) return;
var args = getArgs(uid, cmd.params);
btn.disabled = true;
btn.textContent = '运行中...';
var url = '/api/run?module=workflow&command=' + command + '&format=' + currentFormat;
if (args) url += '&args=' + encodeURIComponent(args);
try {
var resp = await fetch(url);
var data = await resp.json();
var statusClass = data.ok ? 'ok' : 'err';
var statusText = data.ok ? '成功' : '失败';
var output, isTable = false;
if (!data.ok && data.error) {
output = data.error;
} else if (data.output === null || data.output === undefined) {
output = '(操作完成,无返回数据)';
} else if (typeof data.output === 'string') {
output = data.output || '(操作完成,无返回数据)';
if (currentFormat === 'table' && /^.+\n-{2,}/.test(output)) isTable = true;
} else {
var s = JSON.stringify(data.output, null, 2);
output = (s === 'null' || s === '{}' || s === '""') ? '(操作完成,无返回数据)' : s;
}
var bodyHTML = isTable ? '<div class="result-body" style="overflow-x:auto">' + tableToHtml(output) + '</div>' : '<div class="result-body"><pre>' + escapeHtml(output) + '</pre></div>';
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + (data.command || 'workflow +' + command) + '</span><span class="status ' + statusClass + '">' + statusText + '</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div>' + bodyHTML;
var sc = data.ok ? 'ok' : 'err', st = data.ok ? '成功' : '失败';
var out = formatOutput(data);
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + (data.command || 'workflow +' + command) + '</span><span class="status ' + sc + '">' + st + '</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div><div class="result-body"><pre>' + escapeHtml(out) + '</pre></div>';
panel.classList.add('show');
} catch (e) {
} catch(e) {
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ workflow +' + command + '</span><span class="status err">请求失败</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
panel.classList.add('show');
}
btn.disabled = false;
btn.textContent = '▶ 运行';
}
function selectMode(uid, mode, btn) {
var buttons = btn.parentElement.querySelectorAll('.mode-btn');
buttons.forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
var panel = document.getElementById(uid);
if (panel) panel.setAttribute('data-mode', mode);
}
function getSelectedMode(uid) {
var panel = document.getElementById(uid);
var mode = panel ? panel.getAttribute('data-mode') : null;
return mode || 'auto';
}
async function runWfExecute(wfName, uid) {
var btn = document.getElementById('btn-run-' + uid);
var panel = document.getElementById('result-' + uid);
btn.disabled = true;
btn.textContent = '执行中...';
var mode = getSelectedMode(uid);
var url = '/api/run?module=workflow&command=run&format=' + currentFormat + '&args=--name%20' + wfName;
if (mode === 'no-ai') url += '%20--no-ai=true';
else if (mode === 'ai') url += '%20--ai=true';
// Append extra params for project-init
var wf = WORKFLOWS.find(function(w) { return w.name === wfName; });
if (wf && wf.params) {
wf.params.forEach(function(p) {
var el = document.getElementById('p-' + uid + '-' + p.k);
if (el && el.value.trim()) {
url += '%20--' + p.k + '%20' + encodeURIComponent(el.value.trim());
}
});
}
try {
var resp = await fetch(url);
var data = await resp.json();
var sc = data.ok ? 'ok' : 'err', st = data.ok ? '成功' : '失败';
// 检测是否为工作流执行结果,走结构化渲染
var wfData = (data.output && data.output.data && data.output.data.workflow) ? data.output.data : null;
if (!wfData && data.output && data.output.workflow) wfData = data.output;
var bodyHTML = '';
if (wfData && Array.isArray(wfData.steps)) {
bodyHTML = '<div class="result-body" style="background:#fff;max-height:600px;">' + renderWorkflowResult(wfData) + '</div>';
} else {
var out = formatOutput(data);
bodyHTML = '<div class="result-body"><pre>' + escapeHtml(out) + '</pre></div>';
}
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + (data.command || 'workflow +run --name ' + wfName) + '</span><span class="status ' + sc + '">' + st + '</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div>' + bodyHTML;
panel.classList.add('show');
} catch(e) {
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ workflow +run --name ' + wfName + '</span><span class="status err">请求失败</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
panel.classList.add('show');
}
btn.disabled = false;
btn.textContent = '▶ 立即执行';
}
async function runWfDaemon(wfName, op, uid) {
var btnMap = {start:'btn-start-',stop:'btn-stop-',status:'btn-status-',logs:'btn-logs-'};
var btn = document.getElementById(btnMap[op] + uid);
var panel = document.getElementById('result-' + uid);
btn.disabled = true;
var url = '/api/run?module=workflow&command=' + op + '&format=' + currentFormat;
if (op === 'start') {
url += '&args=--name%20' + wfName + '%20--interval%205m';
} else {
url += '&args=--name%20' + wfName;
}
try {
var resp = await fetch(url);
var data = await resp.json();
var sc = data.ok ? 'ok' : 'err', st = data.ok ? '成功' : '失败';
var out = formatOutput(data);
if (op === 'start' && data.ok) {
var dot = document.getElementById('dot-' + uid);
if (dot) { dot.classList.remove('stopped'); dot.classList.add('running'); }
} else if (op === 'stop' && data.ok) {
var dot = document.getElementById('dot-' + uid);
if (dot) { dot.classList.remove('running'); dot.classList.add('stopped'); }
}
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + (data.command || 'workflow +' + op + ' --name ' + wfName) + '</span><span class="status ' + sc + '">' + st + '</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div><div class="result-body"><pre>' + escapeHtml(out) + '</pre></div>';
panel.classList.add('show');
} catch(e) {
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ workflow +' + op + ' --name ' + wfName + '</span><span class="status err">请求失败</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
panel.classList.add('show');
}
btn.disabled = false;
}
async function runWfSchedule(wfName, uid) {
var btn = document.getElementById('btn-schedule-' + uid);
var panel = document.getElementById('result-' + uid);
var scheduleEl = document.getElementById('schedule-' + uid);
var interval = scheduleEl ? scheduleEl.value : '24h';
btn.disabled = true;
btn.textContent = '设置中...';
var mode = getSelectedMode(uid);
var url = '/api/run?module=workflow&command=schedule&format=' + currentFormat;
url += '&args=--name%20' + wfName + '%20--interval%20' + encodeURIComponent(interval);
if (mode === 'no-ai') url += '%20--no-ai=true';
else if (mode === 'ai') url += '%20--ai=true';
try {
var resp = await fetch(url);
var data = await resp.json();
var sc = data.ok ? 'ok' : 'err', st = data.ok ? '成功' : '失败';
var out = formatOutput(data);
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + (data.command || 'workflow +schedule --name ' + wfName + ' --interval ' + interval) + '</span><span class="status ' + sc + '">' + st + '</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div><div class="result-body"><pre>' + escapeHtml(out) + '</pre></div>';
panel.classList.add('show');
} catch(e) {
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ workflow +schedule --name ' + wfName + '</span><span class="status err">请求失败</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">&times;</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
panel.classList.add('show');
}
btn.disabled = false;
btn.textContent = '⏰ 设置定时运行';
}
function renderWorkflowResult(wf) {
if (!wf || !wf.steps) return '';
var allOK = wf.steps.every(function(s) { return s.ok; });
var html = '<div class="wf-result">' +
'<div class="wf-result-header">' +
'<span class="wf-result-name">' + escapeHtml(wf.workflow || '') + '</span>' +
'<span class="wf-result-repo">' + escapeHtml((wf.owner||'') + '/' + (wf.repo||'')) + '</span>' +
'<span class="wf-result-status ' + (allOK ? 'ok' : 'err') + '">' + (allOK ? '✓ 完成' : '✗ 有错误') + '</span>' +
'</div>' +
'<div class="wf-result-steps">';
for (var i = 0; i < wf.steps.length; i++) {
var s = wf.steps[i];
var icon = s.ok ? '✅' : '❌';
var typeCls = s.type === 'skill' ? 'skill' : 'cmd';
var summary = stepSummary(s);
var detail = stepDetail(s);
var clickable = detail ? ' clickable' : '';
html += '<div class="wf-step">' +
'<div class="wf-step-header' + clickable + '"' + (detail ? ' onclick="this.nextElementSibling.classList.toggle(\'show\')"' : '') + '>' +
'<span class="wf-step-icon">' + icon + '</span>' +
'<span class="wf-step-purpose">' + escapeHtml(s.purpose || s.step) + '</span>' +
(s.type === 'skill' ? '<span class="wf-step-type skill' + (getAIUsed(s) ? ' ai' : '') + '">' + (getAIUsed(s) ? '🤖 AI' : '⚙️ 规则') + '</span>' : '<span class="wf-step-type cmd">命令</span>') +
(summary ? '<span class="wf-step-summary">' + summary + '</span>' : '') +
(detail ? '<span style="color:#bbb;font-size:0.75em;margin-left:4px;"></span>' : '') +
'</div>';
if (detail) {
html += '<div class="wf-step-detail">' + detail + '</div>';
}
html += '</div>';
}
html += '</div></div>';
return html;
}
function getAIUsed(step) {
var d = step.data;
if (!d) return false;
return d._ai_used === true;
}
function getSkillName(step) {
var d = step.data;
return (d && d._skill) ? d._skill : '';
}
function stepSummary(step) {
if (step.type === 'skill') {
var skill = getSkillName(step);
var d = step.data;
if (!d || !d.analysis) return null;
var a = d.analysis;
if (skill === 'gitlink-triage') return (a.classified || 0) + ' 个 Issue 已分类';
if (skill === 'gitlink-health') return '综合 ' + (a.composite || '?') + ' 分(' + (a.grade || '?') + '';
if (skill === 'gitlink-changelog') return (a.total_commits || 0) + ' 个 commit 已分类';
if (skill === 'gitlink-review') return a.summary ? (a.summary + '').substring(0, 30) : null;
if (skill === 'gitlink-ci') return a.summary ? (a.summary + '').substring(0, 30) : null;
if (skill === 'gitlink-license') return a.summary ? (a.summary + '').substring(0, 30) : null;
if (skill === 'gitlink-contributor-ranking') return a.summary ? (a.summary + '').substring(0, 30) : null;
if (skill === 'gitlink-init-scaffold') return a.summary ? (a.summary + '').substring(0, 30) : null;
if (skill === 'gitlink-repo') return a.summary ? (a.summary + '').substring(0, 30) : null;
if (skill === 'gitlink-auto-merge') return a.summary ? (a.summary + '').substring(0, 30) : null;
return null;
}
// command step: count items from data
var d = step.data;
if (!d) return null;
// unwrap {data: {...}} envelope
var inner = (d.data !== undefined) ? d.data : d;
// count array items for common list keys
var listKeys = ['issues','pull_requests','releases','milestones','branches','members','labels','tags','commits'];
for (var k = 0; k < listKeys.length; k++) {
var arr = inner[listKeys[k]];
if (Array.isArray(arr)) return arr.length + ' 个';
}
// if inner is an array itself
if (Array.isArray(inner)) return inner.length + ' 条';
// single object → key info
if (typeof inner === 'object' && inner !== null) {
if (inner.full_name) return escapeHtml(inner.full_name);
if (inner.name) return escapeHtml(inner.name);
return Object.keys(inner).length + ' 个字段';
}
return null;
}
function stepDetail(step) {
if (step.type !== 'skill') return '';
var d = step.data;
if (!d || !d.analysis) return '';
var skill = getSkillName(step);
var a = d.analysis;
if (skill === 'gitlink-triage') return renderTriageDetail(a, d);
if (skill === 'gitlink-health') return renderHealthDetail(a);
if (skill === 'gitlink-changelog') return renderChangelogDetail(a);
// generic fallback for other skills
return '<pre style="font-size:0.8em;color:#888;max-height:200px;overflow:auto;margin:0;">' + escapeHtml(JSON.stringify(a, null, 2)) + '</pre>';
}
function renderTriageDetail(a, d) {
var results = a.results || [];
if (results.length === 0) return '<div style="color:#999;font-size:0.85em;">无待分拣 Issue</div>';
var catNames = {bug:'🐛 缺陷',security:'🔒 安全',performance:'⚡ 性能',refactor:'🔧 重构',enhancement:'✨ 功能',docs:'📚 文档',question:'❓ 疑问'};
var html = '<div style="overflow-x:auto;"><table class="wf-triage-table"><thead><tr><th>#</th><th>标题</th><th>分类</th><th>优先级</th><th>分配</th><th>状态</th></tr></thead><tbody>';
for (var i = 0; i < results.length; i++) {
var r = results[i];
var priLabel = r.priority >= 4 ? '🔴 紧急' : r.priority >= 3 ? '🟡 重要' : r.priority >= 2 ? '🟢 普通' : '⚪ 低';
var statusLabel = r.processed ? '已处理' : '新 Issue';
html += '<tr>' +
'<td>' + (r.number || '-') + '</td>' +
'<td>' + escapeHtml((r.title || '').substring(0, 50)) + '</td>' +
'<td>' + (catNames[r.category] || r.category || '-') + '</td>' +
'<td>' + priLabel + '</td>' +
'<td>' + escapeHtml(r.assignee || '-') + '</td>' +
'<td>' + statusLabel + '</td>' +
'</tr>';
}
html += '</tbody></table></div>';
// actions feedback
var executed = d.executed || 0;
if (executed > 0) {
html += '<div class="wf-step-actions"><span class="act-ok">✓ 已执行 ' + executed + ' 个操作(打标签/分配责任人/评论)</span></div>';
} else if (results.every(function(r) { return r.processed === true; })) {
html += '<div class="wf-step-actions"><span class="act-ok">✓ Issue 已处理,无需重复执行操作</span></div>';
} else {
html += '<div class="wf-step-actions"><span class="act-fail">⚠ 操作未执行API 调用失败或无可执行操作)</span></div>';
}
return html;
}
function renderHealthDetail(a) {
var dims = a.dimensions || {};
var dimOrder = ['issue_health','pr_health','contributor_health','activity'];
var dimLabels = {issue_health:'Issue 健康度',pr_health:'PR 健康度',contributor_health:'贡献者',activity:'活跃度'};
var gradeClass = function(g) { return g === '优秀' ? 'A' : g === '良好' ? 'B' : 'C'; };
var html = '<div class="wf-health-card">' +
'<div class="wf-health-total"><span class="score">' + (a.composite || '?') + '</span><span class="grade">' + (a.grade || '') + '</span></div>' +
'<div class="wf-health-dims">';
for (var i = 0; i < dimOrder.length; i++) {
var dk = dimOrder[i];
var dim = dims[dk];
if (!dim) continue;
html += '<div class="wf-health-dim">' +
'<div class="dim-name">' + (dimLabels[dk] || dk) + '</div>' +
'<div class="dim-score">' + (dim.score || '?') + '</div>' +
'<div class="dim-grade ' + gradeClass(dim.grade) + '">' + (dim.grade || '') + '</div>' +
'</div>';
}
html += '</div></div>';
return html;
}
function renderChangelogDetail(a) {
var sections = a.sections || [];
if (sections.length === 0) return '<div style="color:#999;font-size:0.85em;">无分类 commit</div>';
var html = '<div class="wf-changelog-sections">';
for (var i = 0; i < sections.length; i++) {
var sec = sections[i];
var items = sec.items || [];
html += '<div class="wf-changelog-section">' +
'<div class="cl-head">' + escapeHtml(sec.category) + ' (' + (sec.count || items.length) + ')</div>';
if (items.length > 0) {
html += '<ul class="cl-items">';
for (var j = 0; j < Math.min(items.length, 8); j++) {
html += '<li>' + escapeHtml(items[j]) + '</li>';
}
if (items.length > 8) html += '<li style="color:#bbb;">... 还有 ' + (items.length - 8) + ' 条</li>';
html += '</ul>';
}
html += '</div>';
}
html += '</div>';
return html;
}
function formatOutput(data) {
if (!data.ok && data.error) return data.error;
if (data.output === null || data.output === undefined) return '(操作完成,无返回数据)';
if (typeof data.output === 'string') return data.output || '(操作完成,无返回数据)';
var s = JSON.stringify(data.output, null, 2);
return (s === 'null' || s === '{}' || s === '""') ? '(操作完成,无返回数据)' : s;
}
function tableToHtml(text) {

View File

@ -1,6 +1,7 @@
package main
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
@ -71,22 +72,31 @@ func main() {
log.Printf("Running: %s", cmdStr)
cmd := exec.Command(cliBin, args...)
output, err := cmd.CombinedOutput()
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
result := RunResult{
Command: cmdStr,
}
if err != nil {
result.Error = strings.TrimSpace(string(output))
if runErr != nil {
result.Error = strings.TrimSpace(stderr.String())
if result.Error == "" {
result.Error = strings.TrimSpace(stdout.String())
}
if result.Error == "" {
result.Error = runErr.Error()
}
result.Output = nil
} else {
result.OK = true
var parsed interface{}
if json.Unmarshal(output, &parsed) == nil {
if json.Unmarshal(stdout.Bytes(), &parsed) == nil {
result.Output = parsed
} else {
result.Output = strings.TrimSpace(string(output))
result.Output = strings.TrimSpace(stdout.String())
}
}

Binary file not shown.

View File

@ -0,0 +1,36 @@
# gitlink-auto-merge质量自动合并
基于 Review 和 CI 诊断结果,当代码质量达标时自动合并 PR。
## 合并条件
1. Review 中不存在 high-severity 的 security 类型发现问题
2. CI 构建健康(无失败构建,或仓库未配置 CI
## 操作
当条件满足时,为每个符合条件的 PR 执行:
```
pr +merge --id <number> --method squash
```
## 条件不满足时
不执行合并,返回原因:
- "Review 发现高危安全问题,阻止自动合并"
- "CI 构建未通过,阻止自动合并"
## 输出格式
```json
{
"analysis": {
"merged": 1,
"reason": "质量达标,已合并 1 个 PR"
},
"actions": [
{"type": "cli", "module": "pr", "command": "+merge", "args": {"id": "6", "method": "squash"}}
]
}
```

View File

@ -0,0 +1,85 @@
---
name: gitlink-contributor-ranking
version: 1.0.0
description: "贡献者排行与成长体系:统计贡献者活跃度、生成排行榜、识别新星与流失风险,并自动颁发成就徽章。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli --help"
---
# gitlink-contributor-ranking贡献者成长体系
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。**
## 概述
本 Skill 用于分析项目贡献者数据,生成贡献者排行榜,识别社区新星和流失风险,并为符合条件的贡献者颁发成就徽章。
## 输入数据
上游已采集以下数据(通过 JSON 传入):
- `commits` — 最近 50-100 条提交记录
- `open-issues` — 开放中的 Issue 列表
- `closed-issues` — 已关闭的 Issue 列表
- `merged-prs` — 已合并的 PR 列表
- `members` — 项目成员/协作者列表
每条记录包含作者信息、时间戳等元数据。
## 输出格式
**CRITICAL — 你只需要输出贡献者排行榜和徽章建议,不需要生成项目健康度报告(那是社区运营工作流的事)。**
```markdown
# 贡献者排行榜
> 统计周期:最近 30 天 | 生成时间YYYY-MM-DD HH:mm
## 总排行
| 排名 | 贡献者 | 提交 | Issue | PR | 总计 | 趋势 | 勋章 |
|------|--------|------|-------|-----|------|------|------|
| 1 | ... | N | N | N | N | ↑/↓/→ | 🥇 |
| 2 | ... | N | N | N | N | ↑/↓/→ | 🥈 |
## 新星 🌟
- **贡献者名** — 近期活跃度显著提升(趋势 > 50%),建议颁发「新星」徽章
## 流失风险 ⚠️
- **贡献者名** — 超过 30 天无活动记录,建议社区管理员关注
## 徽章颁发建议
根据分析结果,建议为以下贡献者颁发徽章:
| 贡献者 | 建议徽章 | 原因 |
|--------|----------|------|
| xxx | 代码贡献者 | 近 30 天提交 N 次 |
| yyy | Issue 猎手 | 关闭 N 个 Issue |
| zzz | 新星 | 活跃度上升 N% |
> 由 contributor-growth 工作流自动生成
```
## 徽章规则
根据数据自动判定:
| 条件 | 徽章 |
|------|------|
| 近 30 天提交 ≥ 10 次 | 代码贡献者 |
| 近 30 天关闭 Issue ≥ 5 个 | Issue 猎手 |
| 近 30 天合并 PR ≥ 3 个 | PR 达人 |
| 趋势上升 > 50% | 新星 |
## 注意事项
- 输出纯 Markdown不要包含 JSON 包装
- 如果某项数据为空(如无 PR跳过对应徽章
- 排行至少展示 Top 10如果总数不足则全部展示
- 趋势:近 30 天 vs 前 30 天对比,超过 30 天无活动标注「流失风险」

View File

@ -0,0 +1,54 @@
# gitlink-init-scaffold项目脚手架初始化
根据用户提供的项目描述,自动创建仓库并初始化标准项目脚手架。
## 输入参数
从上游数据中读取:
- `_owner` — 仓库所有者(必填)
- `_desc` — 项目描述,用于生成仓库名和 README
- `_repo` — 显式指定仓库名(可选,不提供则从描述自动生成)
## 操作
执行以下初始化步骤:
1. **创建仓库** — CLI `repo +create --name <name> --description <desc>`
2. **README.md** — 根据描述生成项目 READMEPOST `{base}/create_file`
3. **LICENSE** — 添加 MIT 许可证
4. **.gitignore** — 添加 Go 项目标准 .gitignore
5. **默认标签** — 创建 bug/enhancement/documentation 等 7 个标签
6. **里程碑** — 创建 v0.1.0 里程碑3 个月后到期)
7. **初始 Issue** — 创建「项目初始化」「代码框架搭建」「首个版本发布」3 个 Issue
## 仓库名自动生成规则
- 英文描述:取前 2-3 个有效英文单词,小写连字符拼接
- 中文描述:取前 5 个汉字
- 默认:`new-project`
## 输出格式
```json
{
"analysis": {
"repo": "owner/repo-name",
"description": "项目描述",
"files_created": 3,
"labels_created": 7,
"milestones_created": 1,
"issues_created": 3,
"summary": "仓库 owner/repo-name 创建完成3 个文件7 个标签1 个里程碑3 个 Issue"
},
"actions": [
{"type": "cli", "module": "repo", "command": "+create", "args": {"name": "repo-name", "description": "项目描述"}},
{"type": "api", "method": "POST", "path": "{base}/create_file", "body": {"filepath": "README.md", "content": "<base64>", "message": "docs: add README.md", "branch": "master"}},
{"type": "api", "method": "POST", "path": "{base}/create_file", "body": {"filepath": "LICENSE", "content": "<base64>", "message": "docs: add MIT LICENSE", "branch": "master"}},
{"type": "api", "method": "POST", "path": "{base}/create_file", "body": {"filepath": ".gitignore", "content": "<base64>", "message": "chore: add .gitignore", "branch": "master"}},
{"type": "api", "method": "POST", "path": "{v1}/issue_tags", "body": {"name": "bug", "color": "#d73a4a"}},
{"type": "api", "method": "POST", "path": "{v1}/milestones", "body": {"name": "v0.1.0", "description": "首个版本发布", "effective_date": "2026-10-04"}},
{"type": "api", "method": "POST", "path": "{v1}/issues", "body": {"subject": "项目初始化", "description": "...", "status_id": 1, "priority_id": 2, "done_ratio": 0}}
]
}
```

View File

@ -0,0 +1,26 @@
# gitlink-multi-repo
多仓库协同工作流:基于多个仓库的 Issue、PR、Release 和 Milestone 快照,生成统一 Issue 追踪、PR 状态看板和 Release 协调发布报告。
## 原则
- 核心统计和发布阻塞判断以规则引擎结果为准。
- AI 只做语义增强:总结风险、解释阻塞原因、生成行动清单和 Markdown 报告。
- 不执行写操作,不自动创建 Release不批量评论 Issue/PR。
## 输入
上游数据来自 `multi-repo-snapshot`,结构包含:
- `release`: 目标版本,例如 `v1.4.0`
- `repos`: 每个仓库的 `info`、`open_issues`、`open_prs`、`releases`、`milestones`
- `errors`: 采集失败项
## 输出建议
输出应包含:
- 统一 Issue 追踪:按仓库统计 open、blocker、超期、高优先级 Issue。
- PR 状态看板:按仓库统计 open、待 review、冲突、超期 PR。
- Release 协调:判断目标版本是否建议发布,列出阻塞原因。
- 行动建议:按优先级列出需要处理的仓库和事项。

View File

@ -101,6 +101,53 @@ AI 判断 Issue 是否适合新贡献者:
| 不涉及核心逻辑 | 修改不影响主要功能流程 |
| 有足够上下文 | 新人无需深入了解整个项目 |
### Good First Issue 个性化评论
当启用 AI 且判断某个 Issue 适合新贡献者时,不要使用固定模板评论。必须根据 Issue 的标题、描述、分类和可推断的修改范围,生成一条个性化新人引导评论,并通过 `actions` 返回 `issue +comment` 动作。
评论要求:
| 要求 | 说明 |
|------|------|
| 说明适合新人的原因 | 例如范围小、上下文清晰、主要是文档/测试/局部修复 |
| 给出 2-4 个入手步骤 | 结合 Issue 内容说明先看什么、改什么、如何验证 |
| 保持事实边界 | 不要编造不存在的文件路径、接口或负责人;不确定时用“可以先从相关模块/文档入手” |
| 控制风险 | 涉及安全、核心架构、数据迁移、跨模块重构时不要标为 good-first-issue也不要生成新人引导评论 |
| 只评论开放 Issue | 只有 `state/status=open``status_id=1/2` 的 Issue 才能生成评论;已关闭、已解决或 `status_id=3/5` 的 Issue 禁止生成 `issue +comment` action |
| 避免重复 | 如果 Issue 已有社区运营/Good First Issue 引导评论,则不要重复添加;仅已有标签或责任人时仍可添加个性化新人引导评论 |
| 保持简洁友好 | 建议 150-500 字Markdown 格式,语气欢迎但不要夸大 |
评论应包含:
1. 感谢或欢迎语
2. 为什么这个 Issue 适合新贡献者
3. 建议的处理步骤
4. 如何求助或提交 PR 的简短提示
#### AI 模式动作要求
当工作流启用 AI 时AI 不能只输出分析文字。对每个开放 Issue若符合以下任一条件应生成一条个性化 `issue +comment` action
- 标题或描述包含 `good first issue`、`beginner`、`easy`、`简单`、`新手`、`入门`
- 分类为 docs / question / enhancement优先级为中或低且描述范围不明显涉及安全、核心架构、数据迁移、跨模块重构
- 规则引擎会将其视为 Good First Issue 的简单开放 Issue
已有标签、已有负责人、已有优先级不是跳过评论的理由;这些字段只表示分拣已完成。只有以下情况必须跳过评论:
- Issue 已关闭、已解决,或 `status_id` 为 3/5
- Issue 已有明确的社区运营/Good First Issue 引导评论
- Issue 涉及安全风险、核心架构、数据迁移、跨模块重构,明显不适合新人
评论 action 必须使用这个 JSON 形状:
```json
{"type":"cli","module":"issue","command":"+comment","args":{"number":"<issue-number>","body":"<personalized markdown>"}}
```
评论必须结合该 Issue 的标题/描述生成,不要使用固定模板。若信息较少,给出保守且可执行的入手建议,例如先阅读相关工作流/命令模块、补充测试、在本地运行对应命令验证。
不要编造具体文件或目录路径。只有当上游数据中明确出现了相关路径时,才在评论中引用路径;否则使用“相关工作流模块”“对应命令模块”“测试用例”等保守表述。
### 责任人分配策略
AI 根据以下信息分配责任人:
@ -197,6 +244,38 @@ gitlink-cli issue +comment --owner <owner> --repo <repo> \
如果遇到问题,可以在这里回复,我们会尽快帮助你!"
```
### AI 模式下的个性化评论 Action
在工作流 AI 模式中,必须把个性化评论放入 JSON 输出的 `actions` 数组,让执行器自动调用 `issue +comment`。动作格式必须是:
```json
{
"type": "cli",
"module": "issue",
"command": "+comment",
"args": {
"number": "<issue-number>",
"body": "<根据该 Issue 内容生成的个性化 Markdown 评论>"
}
}
```
示例:
```json
{
"type": "cli",
"module": "issue",
"command": "+comment",
"args": {
"number": "10",
"body": "欢迎参与这个 Issue从描述看这个任务主要是补充 README 中的安装说明,范围比较清晰,不需要改动核心逻辑,因此适合作为 Good First Issue。\\n\\n建议可以从这几步开始\\n1. 先复现当前 README 中的安装流程,记录缺失或不清楚的地方。\\n2. 补充对应平台的命令示例,并保持和现有文档格式一致。\\n3. 本地检查 Markdown 渲染效果,确认命令块和链接都正常。\\n\\n如果推进过程中不确定写法可以在这个 Issue 下留言讨论;完成后欢迎提交 PR。"
}
}
```
如果同一个 Issue 同时需要打标签/分配责任人和新人引导评论,`actions` 中可以同时包含 PATCH action 和上述 comment action但只有确认是开放状态、低风险 good-first-issue 时才添加 comment action。已关闭或已解决的 Issue 即使内容适合新人,也只能在分析中说明,不得返回 `issue +comment` action。
## 分拣输出格式
AI 对每个 Issue 生成分拣建议:
@ -226,7 +305,8 @@ AI 对每个 Issue 生成分拣建议:
| **建议标签** | enhancement, good first issue |
| **建议责任人** | 未分配(适合新人) |
| **Good First Issue** | ✅ 是 |
| **AI 分析** | 功能建议明确,改动范围小(仅需修改帮助文档格式),适合新贡献者。建议添加引导评论。 |
| **AI 分析** | 功能建议明确,改动范围小(仅需修改帮助文档格式),适合新贡献者。需要生成与该 Issue 内容匹配的个性化引导评论,并在 actions 中返回 `issue +comment`。 |
| **个性化评论摘要** | 说明为什么适合新人,并给出 2-4 个可执行入手步骤。 |
```
## 使用场景