forked from Gitlink/gitlink-cli
Compare commits
10 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
c6ebe659bc | |
|
|
43eb36cb33 | |
|
|
8eaeced2e8 | |
|
|
04a990da31 | |
|
|
ca616101c7 | |
|
|
aa5e1ff6b9 | |
|
|
c60c30676f | |
|
|
25e1915579 | |
|
|
4e4438eed6 | |
|
|
cb2e401a83 |
|
|
@ -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`
|
- **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
|
- **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
|
- **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)
|
- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → Raw API (full coverage) → Config (configuration management)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BaseURL string `yaml:"base_url"`
|
BaseURL string `yaml:"base_url"`
|
||||||
Format string `yaml:"default_format"`
|
Format string `yaml:"default_format"`
|
||||||
Editor string `yaml:"editor,omitempty"`
|
Editor string `yaml:"editor,omitempty"`
|
||||||
Pager string `yaml:"pager,omitempty"`
|
Pager string `yaml:"pager,omitempty"`
|
||||||
AnthropicAPIKey string `yaml:"anthropic_api_key,omitempty"`
|
DeepSeekAPIKey string `yaml:"deepseek_api_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
|
|
@ -92,6 +92,8 @@ func Get(key string) (string, error) {
|
||||||
return cfg.Editor, nil
|
return cfg.Editor, nil
|
||||||
case "pager":
|
case "pager":
|
||||||
return cfg.Pager, nil
|
return cfg.Pager, nil
|
||||||
|
case "deepseek_api_key":
|
||||||
|
return cfg.DeepSeekAPIKey, nil
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +113,8 @@ func Set(key, value string) error {
|
||||||
cfg.Editor = value
|
cfg.Editor = value
|
||||||
case "pager":
|
case "pager":
|
||||||
cfg.Pager = value
|
cfg.Pager = value
|
||||||
|
case "deepseek_api_key":
|
||||||
|
cfg.DeepSeekAPIKey = value
|
||||||
}
|
}
|
||||||
return Save(cfg)
|
return Save(cfg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,16 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const anthropicBaseURL = "https://api.anthropic.com/v1/messages"
|
const deepseekBaseURL = "https://api.deepseek.com/v1/chat/completions"
|
||||||
const defaultModel = "claude-sonnet-4-6"
|
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 {
|
type AIClient struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
model string
|
model string
|
||||||
|
|
@ -34,13 +35,22 @@ type AIResponse struct {
|
||||||
Actions []AIAction `json:"actions"`
|
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.
|
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
|
||||||
func NewAIClient() *AIClient {
|
func NewAIClient() *AIClient {
|
||||||
key := os.Getenv("ANTHROPIC_API_KEY")
|
key := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
key = cfg.AnthropicAPIKey
|
key = cfg.DeepSeekAPIKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if key == "" {
|
if key == "" {
|
||||||
|
|
@ -49,23 +59,25 @@ func NewAIClient() *AIClient {
|
||||||
return &AIClient{
|
return &AIClient{
|
||||||
apiKey: key,
|
apiKey: key,
|
||||||
model: defaultModel,
|
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) {
|
func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
|
||||||
if c == nil {
|
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{}{
|
body := map[string]interface{}{
|
||||||
"model": c.model,
|
"model": c.model,
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"system": req.SystemPrompt,
|
"temperature": 0.1,
|
||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
|
{"role": "system", "content": req.SystemPrompt + jsonOutputInstruction},
|
||||||
{"role": "user", "content": req.UserData},
|
{"role": "user", "content": req.UserData},
|
||||||
},
|
},
|
||||||
|
"response_format": map[string]string{"type": "json_object"},
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := json.Marshal(body)
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
httpReq.Header.Set("x-api-key", c.apiKey)
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
|
||||||
|
|
||||||
resp, err := c.http.Do(httpReq)
|
resp, err := c.http.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -93,32 +104,108 @@ func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
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 {
|
var result struct {
|
||||||
Content []struct {
|
Choices []struct {
|
||||||
Text string `json:"text"`
|
Message struct {
|
||||||
} `json:"content"`
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
return nil, fmt.Errorf("parse response: %w", err)
|
return nil, fmt.Errorf("parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Content) == 0 {
|
if len(result.Choices) == 0 {
|
||||||
return nil, fmt.Errorf("empty response from Anthropic API")
|
return nil, fmt.Errorf("empty response from DeepSeek API")
|
||||||
}
|
}
|
||||||
|
|
||||||
text := result.Content[0].Text
|
text := result.Choices[0].Message.Content
|
||||||
var aiResp AIResponse
|
aiResp, err := parseAIResponse(text)
|
||||||
if err := json.Unmarshal([]byte(text), &aiResp); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
|
// 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.
|
// HasKey reports whether the AI client is configured.
|
||||||
func (c *AIClient) HasKey() bool {
|
func (c *AIClient) HasKey() bool {
|
||||||
return c != nil && c.apiKey != ""
|
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])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ func registerCodeQuality() {
|
||||||
},
|
},
|
||||||
Steps: []StepDef{
|
Steps: []StepDef{
|
||||||
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
{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: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
|
||||||
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
|
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
|
||||||
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
|
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,13 @@ func registerCommunityOps() {
|
||||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
|
{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: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
|
||||||
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +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: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
|
||||||
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
|
{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: 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: 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},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ func registerContributorGrowth() {
|
||||||
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
|
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
|
||||||
{Type: StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
|
{Type: StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
|
||||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据(Fork/Star/Watch)", Target: "repo +info"},
|
{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"}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
package workflow
|
package workflow
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -21,17 +23,7 @@ func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Dura
|
||||||
return fmt.Errorf("cannot find executable: %w", err)
|
return fmt.Errorf("cannot find executable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{
|
args := buildDaemonArgs(ctx, wf, interval, aiMode)
|
||||||
"workflow", "+run", "--name", wf.Name,
|
|
||||||
"--owner", ctx.Owner, "--repo", ctx.Repo,
|
|
||||||
"--format", "json", "--daemon-loop",
|
|
||||||
"--interval", interval.String(),
|
|
||||||
}
|
|
||||||
if aiMode == "ai" {
|
|
||||||
args = append(args, "--ai")
|
|
||||||
} else if aiMode == "no-ai" {
|
|
||||||
args = append(args, "--no-ai")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(bin, args...)
|
cmd := exec.Command(bin, args...)
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
|
@ -62,6 +54,32 @@ func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Dura
|
||||||
return nil
|
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.
|
// StopDaemon stops a running workflow daemon by name.
|
||||||
func StopDaemon(name string) error {
|
func StopDaemon(name string) error {
|
||||||
pid, err := readPID(name)
|
pid, err := readPID(name)
|
||||||
|
|
@ -143,15 +161,18 @@ func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
|
||||||
}
|
}
|
||||||
|
|
||||||
changed := state.Diff(dryResult.Steps)
|
changed := state.Diff(dryResult.Steps)
|
||||||
if len(changed) == 0 && state.TotalRuns > 0 {
|
if len(changed) == 0 {
|
||||||
// No data changes — save state, skip expensive run.
|
if state.TotalRuns > 0 {
|
||||||
state.TotalRuns++
|
fmt.Fprintf(os.Stderr, "[%s] 没有检测到变更\n", time.Now().Format(time.RFC3339))
|
||||||
state.Save()
|
state.TotalRuns++
|
||||||
return
|
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).
|
// Phase 2: full run (AI or rules based on context.AIMode).
|
||||||
fullResult, err := Run(ctx, wf, false)
|
fullResult, err := Run(ctx, wf, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -159,8 +180,11 @@ func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload state — Run() may have updated ReviewedPRs fingerprints.
|
||||||
|
state, _ = LoadState(wf.Name)
|
||||||
state.TotalRuns++
|
state.TotalRuns++
|
||||||
state.Diff(fullResult.Steps)
|
state.Diff(fullResult.Steps)
|
||||||
|
state.UpdateSnapshots(fullResult.Steps)
|
||||||
state.Save()
|
state.Save()
|
||||||
|
|
||||||
ok, total := 0, len(fullResult.Steps)
|
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)
|
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.
|
// daemonLogPath returns the log file path for a workflow daemon.
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,9 @@ package workflow
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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) {
|
func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if !shouldSkipOwnerRepoResolve(ctx, wf) {
|
||||||
return nil, fmt.Errorf("resolve repo: %w", err)
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve repo: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Args == nil {
|
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 {
|
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
|
||||||
ctx.Args["dry_run"] = "true"
|
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))
|
results := make([]StepResult, 0, len(wf.Steps))
|
||||||
for _, step := range 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)
|
sr := ExecuteStep(ctx, step, dryRun)
|
||||||
results = append(results, *sr)
|
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.
|
// 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)
|
raw, err := json.Marshal(sr.Data)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ctx.Args[step.Name] = string(raw)
|
ctx.Args[step.Name] = string(raw)
|
||||||
} else {
|
} else {
|
||||||
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
|
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{
|
return &WorkflowResult{
|
||||||
Workflow: wf.Name,
|
Workflow: wf.Name,
|
||||||
Owner: ctx.Owner,
|
Owner: ctx.Owner,
|
||||||
|
|
@ -69,6 +130,44 @@ func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowRes
|
||||||
}, nil
|
}, 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.
|
// resolvePath replaces template placeholders in a path string.
|
||||||
//
|
//
|
||||||
// {base} → /owner/repo
|
// {base} → /owner/repo
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,19 @@ func registerMultiRepo() {
|
||||||
Interval: "24h",
|
Interval: "24h",
|
||||||
},
|
},
|
||||||
Steps: []StepDef{
|
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,
|
||||||
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
Name: "multi-repo-snapshot",
|
||||||
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本信息协调跨仓库发布", Target: "release +list"},
|
Purpose: "采集多个仓库的 Issue/PR/Release/Milestone 状态",
|
||||||
{Type: StepTypeCommand, Name: "milestones", Purpose: "获取里程碑跨仓库对齐", Target: "milestone +list"},
|
Target: "workflow-internal:multi-repo-snapshot",
|
||||||
{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: StepTypeSkill,
|
||||||
|
Name: "multi-repo-coordination",
|
||||||
|
Purpose: "生成统一 Issue 追踪、PR 看板、Release 协调报告",
|
||||||
|
Target: "gitlink-multi-repo",
|
||||||
|
DependsOn: []string{"multi-repo-snapshot"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -4,20 +4,21 @@ func registerProjectInit() {
|
||||||
register(&WorkflowDef{
|
register(&WorkflowDef{
|
||||||
Name: "project-init",
|
Name: "project-init",
|
||||||
Category: "初始化",
|
Category: "初始化",
|
||||||
Description: "项目一键初始化:仓库检查 → 文件/Issue/里程碑初始 → CI 配置",
|
Description: "项目一键初始化:创建仓库 → 脚手架文件 → 标签/里程碑/Issue → 许可证审计 → 健康报告",
|
||||||
Trigger: TriggerDef{
|
Trigger: TriggerDef{
|
||||||
Type: "manual",
|
Type: "manual",
|
||||||
On: "manual",
|
On: "manual",
|
||||||
},
|
},
|
||||||
Steps: []StepDef{
|
Steps: []StepDef{
|
||||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库存在并获取基础信息", Target: "repo +info"},
|
{Type: StepTypeSkill, Name: "init-scaffold", Purpose: "根据描述创建仓库并初始化脚手架(README/LICENSE/.gitignore/标签/里程碑/Issue)", Target: "gitlink-init-scaffold"},
|
||||||
{Type: StepTypeCommand, Name: "existing-files", Purpose: "检查 README/LICENSE 是否已存在", Target: "file +list"},
|
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建并获取基础信息", Target: "repo +info"},
|
||||||
{Type: StepTypeCommand, Name: "labels", Purpose: "检查标签库是否齐全", Target: "label +list"},
|
{Type: StepTypeCommand, Name: "existing-files", Purpose: "验证 README/LICENSE 文件", Target: "file +list"},
|
||||||
{Type: StepTypeSkill, Name: "license-check", Purpose: "AI 检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
|
{Type: StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
|
||||||
{Type: StepTypeCommand, Name: "milestones", Purpose: "检查里程碑是否已创建", Target: "milestone +list"},
|
{Type: StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
|
||||||
{Type: StepTypeCommand, Name: "existing-issues", Purpose: "检查是否已有初始 Issue", Target: "issue +list --state all --limit 10"},
|
{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: 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"}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -173,3 +173,19 @@ func containsAny(s string, patterns ...string) bool {
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package rules
|
package rules
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
"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"),
|
"churn_risk": filterByTag(rankings, "churn-risk"),
|
||||||
"new_stars": filterByTag(rankings, "new-star"),
|
"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 {
|
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 {
|
if !ok {
|
||||||
return nil
|
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{}
|
members := map[string]string{}
|
||||||
list, ok := raw.([]interface{})
|
list, ok := raw.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -200,9 +240,9 @@ func extractMembers(upstream map[string]interface{}) map[string]string {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
login := str(m, "login", "username", "name")
|
login := str(m, "login", "username", "name")
|
||||||
name := str(m, "name", "full_name", "display_name")
|
id := fmt.Sprint(m["id"])
|
||||||
if login != "" {
|
if login != "" && id != "" && id != "0" && id != "<nil>" {
|
||||||
members[login] = name
|
members[login] = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return members
|
return members
|
||||||
|
|
@ -219,6 +259,24 @@ func extractList(upstream map[string]interface{}, key string) []map[string]inter
|
||||||
raw = data
|
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{})
|
list, _ := raw.([]interface{})
|
||||||
var out []map[string]interface{}
|
var out []map[string]interface{}
|
||||||
for _, item := range list {
|
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 {
|
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 {
|
func commitTimestamp(m map[string]interface{}) string {
|
||||||
// Commits and issues may be nested under author/committer.
|
// Try Unix timestamp (commit_time).
|
||||||
for _, key := range []string{"created_at", "committed_date", "updated_at", "authored_date"} {
|
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 != "" {
|
if s := str(m, key); s != "" {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
@ -268,3 +351,58 @@ func str(m map[string]interface{}, keys ...string) string {
|
||||||
}
|
}
|
||||||
return ""
|
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()
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package rules
|
package rules
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
"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 {
|
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 {
|
if len(list) > 0 {
|
||||||
return 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 {
|
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 m
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -216,5 +390,11 @@ func grade(score float64) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func clamp(v float64) float64 {
|
func clamp(v float64) float64 {
|
||||||
return min(max(v, 0), 100)
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,18 @@ func TestHealthReportScoring(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resp.Actions != nil {
|
if len(resp.Actions) != 1 {
|
||||||
t.Fatal("expected nil Actions (read-only report)")
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,13 @@ import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
func init() {
|
func init() {
|
||||||
workflow.RegisterRuleEngine("gitlink-triage", TriageRule)
|
workflow.RegisterRuleEngine("gitlink-triage", TriageRule)
|
||||||
workflow.RegisterRuleEngine("gitlink-health", HealthDispatchRule)
|
workflow.RegisterRuleEngine("gitlink-health", HealthDispatchRule)
|
||||||
|
workflow.RegisterRuleEngine("gitlink-contributor-ranking", ContributorRankingRule)
|
||||||
workflow.RegisterRuleEngine("gitlink-changelog", ChangelogRule)
|
workflow.RegisterRuleEngine("gitlink-changelog", ChangelogRule)
|
||||||
workflow.RegisterRuleEngine("gitlink-review", CodeReviewRule)
|
workflow.RegisterRuleEngine("gitlink-review", CodeReviewRule)
|
||||||
workflow.RegisterRuleEngine("gitlink-ci", CIDiagnosisRule)
|
workflow.RegisterRuleEngine("gitlink-ci", CIDiagnosisRule)
|
||||||
workflow.RegisterRuleEngine("gitlink-license", LicenseCheckRule)
|
workflow.RegisterRuleEngine("gitlink-license", LicenseCheckRule)
|
||||||
workflow.RegisterRuleEngine("gitlink-repo", RepoAuditRule)
|
workflow.RegisterRuleEngine("gitlink-repo", RepoAuditRule)
|
||||||
|
workflow.RegisterRuleEngine("gitlink-init-scaffold", InitScaffoldRule)
|
||||||
|
workflow.RegisterRuleEngine("gitlink-auto-merge", AutoMergeRule)
|
||||||
|
workflow.RegisterRuleEngine("gitlink-multi-repo", MultiRepoCoordinationRule)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ func TestRegistryAllRegistered(t *testing.T) {
|
||||||
"gitlink-ci",
|
"gitlink-ci",
|
||||||
"gitlink-license",
|
"gitlink-license",
|
||||||
"gitlink-repo",
|
"gitlink-repo",
|
||||||
|
"gitlink-contributor-ranking",
|
||||||
|
"gitlink-init-scaffold",
|
||||||
|
"gitlink-auto-merge",
|
||||||
|
"gitlink-multi-repo",
|
||||||
}
|
}
|
||||||
for _, target := range expected {
|
for _, target := range expected {
|
||||||
if _, ok := workflow.RuleEngines[target]; !ok {
|
if _, ok := workflow.RuleEngines[target]; !ok {
|
||||||
|
|
|
||||||
|
|
@ -24,19 +24,9 @@ func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.
|
||||||
var dims []dimScore
|
var dims []dimScore
|
||||||
missing := []string{}
|
missing := []string{}
|
||||||
|
|
||||||
// Readme check.
|
// Readme check — use file list since repo +info API doesn't return has_readme.
|
||||||
desc := ""
|
if detectFileInList(upstream, "README.md", "readme.md", "README", "readme") {
|
||||||
hasReadme := false
|
dims = append(dims, dimScore{Name: "README", Score: 100, Weight: 0.25, Status: "ok", Detail: "README 已存在"})
|
||||||
if repoInfo != nil {
|
|
||||||
desc = str(repoInfo, "description")
|
|
||||||
if v, ok := repoInfo["has_readme"].(bool); ok {
|
|
||||||
hasReadme = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
readmeScore := 0.0
|
|
||||||
if hasReadme || desc != "" {
|
|
||||||
readmeScore = 100
|
|
||||||
dims = append(dims, dimScore{Name: "README", Score: readmeScore, Weight: 0.25, Status: "ok", Detail: "README 已存在"})
|
|
||||||
} else {
|
} else {
|
||||||
dims = append(dims, dimScore{Name: "README", Score: 0, Weight: 0.25, Status: "missing", Detail: "缺少 README 文件"})
|
dims = append(dims, dimScore{Name: "README", Score: 0, Weight: 0.25, Status: "missing", Detail: "缺少 README 文件"})
|
||||||
missing = append(missing, "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 {
|
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")
|
files := extractList(upstream, "existing-files")
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
files = extractList(upstream, "files")
|
files = extractList(upstream, "files")
|
||||||
}
|
}
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
name := str(f, "name", "filename", "path", "file_name")
|
name := str(f, "name", "filename", "path", "file_name")
|
||||||
if isLicenseFile(name) {
|
for _, n := range names {
|
||||||
return true
|
if strings.EqualFold(name, n) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package rules
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
"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 findings []finding
|
||||||
var actions []workflow.AIAction
|
var actions []workflow.AIAction
|
||||||
|
|
||||||
|
// Diffs may have been pre-fetched by the workflow engine.
|
||||||
|
prDiffsMap := extractDiffsFromUpstream(upstream)
|
||||||
|
|
||||||
|
|
||||||
for _, pr := range prs {
|
for _, pr := range prs {
|
||||||
title := str(pr, "title", "name")
|
title := str(pr, "title", "name")
|
||||||
body := str(pr, "body", "description")
|
body := str(pr, "body", "description")
|
||||||
prNum := interfaceToString(pr["id"])
|
prNum := interfaceToString(pr["pull_request_number"])
|
||||||
if prNum == "" {
|
if prNum == "" {
|
||||||
prNum = interfaceToString(pr["number"])
|
prNum = interfaceToString(pr["id"])
|
||||||
if prNum == "" {
|
if prNum == "" {
|
||||||
prNum = interfaceToString(pr["pull_request_id"])
|
prNum = interfaceToString(pr["number"])
|
||||||
|
if prNum == "" {
|
||||||
|
prNum = interfaceToString(pr["pull_request_id"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if prNum == "" {
|
if prNum == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
text := title + " " + body
|
diffText := prDiffsMap[prNum]
|
||||||
|
text := title + " " + body + " " + diffText
|
||||||
|
|
||||||
|
var prFindings []finding
|
||||||
|
|
||||||
// Security scan.
|
// Security scan.
|
||||||
for _, p := range reviewSecurityPatterns {
|
for _, p := range reviewSecurityPatterns {
|
||||||
|
|
@ -59,19 +70,8 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
|
||||||
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
|
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
|
||||||
Fix: p.fix,
|
Fix: p.fix,
|
||||||
}
|
}
|
||||||
|
prFindings = append(prFindings, f)
|
||||||
findings = append(findings, 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 难以审查,增加合并风险和回滚难度",
|
Why: "大 PR 难以审查,增加合并风险和回滚难度",
|
||||||
Fix: "将改动按功能模块拆分为多个小 PR",
|
Fix: "将改动按功能模块拆分为多个小 PR",
|
||||||
}
|
}
|
||||||
|
prFindings = append(prFindings, f)
|
||||||
findings = append(findings, f)
|
findings = append(findings, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,8 +104,34 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
|
||||||
Why: "不清晰的 PR 描述增加审查时间,降低代码质量",
|
Why: "不清晰的 PR 描述增加审查时间,降低代码质量",
|
||||||
Fix: "添加 PR 描述,说明改动原因、影响范围和测试方式",
|
Fix: "添加 PR 描述,说明改动原因、影响范围和测试方式",
|
||||||
}
|
}
|
||||||
|
prFindings = append(prFindings, f)
|
||||||
findings = append(findings, 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{}{
|
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 {
|
func interfaceToString(v interface{}) string {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -177,3 +244,24 @@ func interfaceToString(v interface{}) string {
|
||||||
return fmt.Sprint(v)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,21 @@ func TestCodeReviewStaticAnalysis(t *testing.T) {
|
||||||
"open-prs": map[string]interface{}{
|
"open-prs": map[string]interface{}{
|
||||||
"data": []interface{}{
|
"data": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"title": "add feature",
|
"title": "add feature",
|
||||||
"body": "password = 'hardcoded12345678'",
|
"body": "password = 'hardcoded12345678'",
|
||||||
},
|
},
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"title": "wip",
|
"title": "wip",
|
||||||
"body": "",
|
"body": "",
|
||||||
"files_count": 60.0,
|
"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 {
|
if total == 0 {
|
||||||
t.Fatal("expected findings for hardcoded password and large PR")
|
t.Fatal("expected findings for hardcoded password and large PR")
|
||||||
}
|
}
|
||||||
// Should have at least one high-severity security finding.
|
|
||||||
findings := analysis["findings"].([]finding)
|
findings := analysis["findings"].([]finding)
|
||||||
hasSecurity := false
|
hasSecurity := false
|
||||||
hasMaint := false
|
hasMaint := false
|
||||||
|
|
@ -51,6 +55,25 @@ func TestCodeReviewStaticAnalysis(t *testing.T) {
|
||||||
if !hasMaint {
|
if !hasMaint {
|
||||||
t.Error("expected maintainability finding for large PR or missing body")
|
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) {
|
func TestCodeReviewNoPRs(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
|
||||||
title := str(issue, "title")
|
title := str(issue, "title")
|
||||||
body := str(issue, "body", "description")
|
body := str(issue, "body", "description")
|
||||||
text := title + " " + body
|
text := title + " " + body
|
||||||
|
open := isIssueOpen(issue)
|
||||||
|
processed := isIssueProcessed(issue)
|
||||||
|
|
||||||
cat := classifyIssue(text)
|
cat := classifyIssue(text)
|
||||||
pri := assignPriority(text)
|
pri := assignPriority(text)
|
||||||
|
|
@ -42,16 +44,26 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"number": num,
|
"number": num,
|
||||||
"title": title,
|
"title": title,
|
||||||
"category": cat,
|
"category": cat,
|
||||||
"priority": pri,
|
"priority": pri,
|
||||||
"assignee": assignee,
|
"assignee": assignee,
|
||||||
|
"processed": processed,
|
||||||
}
|
}
|
||||||
classified = append(classified, result)
|
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 := map[string]interface{}{}
|
||||||
|
body2["subject"] = title
|
||||||
|
body2["description"] = body
|
||||||
|
if statusID := issueStatusID(issue); statusID != nil {
|
||||||
|
body2["status_id"] = statusID
|
||||||
|
}
|
||||||
if len(labelIDs) > 0 {
|
if len(labelIDs) > 0 {
|
||||||
body2["issue_tag_ids"] = labelIDs
|
body2["issue_tag_ids"] = labelIDs
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +73,7 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
|
||||||
if pri > 0 {
|
if pri > 0 {
|
||||||
body2["priority_id"] = pri
|
body2["priority_id"] = pri
|
||||||
}
|
}
|
||||||
if len(body2) > 0 && num != "" {
|
if open && len(body2) > 0 && num != "" {
|
||||||
actions = append(actions, workflow.AIAction{
|
actions = append(actions, workflow.AIAction{
|
||||||
Type: "api", Method: "PATCH",
|
Type: "api", Method: "PATCH",
|
||||||
Path: fmt.Sprintf("{v1}/issues/%s", num),
|
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)
|
gfi = append(gfi, result)
|
||||||
actions = append(actions, workflow.AIAction{
|
actions = append(actions, workflow.AIAction{
|
||||||
Type: "cli", Module: "issue", Command: "+comment",
|
Type: "cli", Module: "issue", Command: "+comment",
|
||||||
Args: map[string]string{
|
Args: map[string]string{
|
||||||
"number": num,
|
"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)错误|失败|异常|崩溃|crash|error|bug|broken|404|500`), "bug"},
|
||||||
{regexp.MustCompile(`(?i)安全|漏洞|泄露|vulnerability|CVE|敏感`), "security"},
|
{regexp.MustCompile(`(?i)安全|漏洞|泄露|vulnerability|CVE|敏感`), "security"},
|
||||||
{regexp.MustCompile(`(?i)性能|慢|卡顿|优化|performance|speed`), "performance"},
|
{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)建议|希望|新增|支持|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)文档|README|帮助|doc|documentation|typo`), "docs"},
|
||||||
{regexp.MustCompile(`(?i)如何|怎么|请问|how\s*to|question|help|求助`), "question"},
|
{regexp.MustCompile(`(?i)如何|怎么|请问|how\s*to|question|help|求助`), "question"},
|
||||||
}
|
}
|
||||||
|
|
@ -144,6 +156,68 @@ func isGoodFirstIssue(text string) bool {
|
||||||
len(strings.Fields(text)) < 200
|
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 ---
|
// --- label matching ---
|
||||||
|
|
||||||
func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
|
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{} {
|
func matchLabels(category string, labels []map[string]interface{}) []interface{} {
|
||||||
catLower := strings.ToLower(category)
|
catLower := strings.ToLower(category)
|
||||||
|
aliases := labelAliases(catLower)
|
||||||
var ids []interface{}
|
var ids []interface{}
|
||||||
for _, l := range labels {
|
for _, l := range labels {
|
||||||
name := strings.ToLower(str(l, "name", "title", "label"))
|
name := strings.ToLower(str(l, "name", "title", "label"))
|
||||||
|
|
@ -159,7 +234,7 @@ func matchLabels(category string, labels []map[string]interface{}) []interface{}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Direct match or contains.
|
// 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 {
|
if id := labelID(l); id != nil {
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
|
|
@ -179,6 +254,40 @@ func matchLabels(category string, labels []map[string]interface{}) []interface{}
|
||||||
return ids
|
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{} {
|
func labelID(l map[string]interface{}) interface{} {
|
||||||
for _, k := range []string{"id", "tag_id", "label_id"} {
|
for _, k := range []string{"id", "tag_id", "label_id"} {
|
||||||
if v := l[k]; v != nil {
|
if v := l[k]; v != nil {
|
||||||
|
|
@ -196,11 +305,11 @@ func leastLoaded(load map[string]int, members map[string]string) string {
|
||||||
}
|
}
|
||||||
best := ""
|
best := ""
|
||||||
bestN := -1
|
bestN := -1
|
||||||
for login := range members {
|
for login, id := range members {
|
||||||
n := load[login]
|
n := load[login]
|
||||||
if bestN < 0 || n < bestN {
|
if bestN < 0 || n < bestN {
|
||||||
bestN = n
|
bestN = n
|
||||||
best = login
|
best = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
|
|
@ -217,3 +326,30 @@ func issueNumber(issue map[string]interface{}) string {
|
||||||
}
|
}
|
||||||
return ""
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestTriageRulePriority(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
title string
|
title string
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ func LoadState(name string) (*WorkflowState, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return &WorkflowState{
|
return &WorkflowState{
|
||||||
Workflow: name,
|
Workflow: name,
|
||||||
Snapshots: make(map[string]string),
|
Snapshots: make(map[string]string),
|
||||||
|
PhaseLastRun: make(map[string]string),
|
||||||
|
PhaseUpstream: make(map[string]string),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -31,6 +33,12 @@ func LoadState(name string) (*WorkflowState, error) {
|
||||||
if s.Snapshots == nil {
|
if s.Snapshots == nil {
|
||||||
s.Snapshots = make(map[string]string)
|
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
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +58,7 @@ func (s *WorkflowState) Save() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diff compares current step results against stored snapshots.
|
// 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 {
|
func (s *WorkflowState) Diff(results []StepResult) []string {
|
||||||
changed := []string{}
|
changed := []string{}
|
||||||
for _, sr := range results {
|
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 {
|
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
|
||||||
changed = append(changed, sr.Step)
|
changed = append(changed, sr.Step)
|
||||||
}
|
}
|
||||||
s.Snapshots[sr.Step] = hash
|
|
||||||
}
|
}
|
||||||
return changed
|
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.
|
// hashData computes an MD5 hash of the JSON-encoded data.
|
||||||
func hashData(data interface{}) string {
|
func hashData(data interface{}) string {
|
||||||
b, err := json.Marshal(data)
|
b, err := json.Marshal(data)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,29 @@
|
||||||
package workflow
|
package workflow
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StepResult holds the outcome of executing one step.
|
// StepResult holds the outcome of executing one step.
|
||||||
type StepResult struct {
|
type StepResult struct {
|
||||||
Step string `json:"step"`
|
Step string `json:"step"`
|
||||||
Purpose string `json:"purpose"`
|
Purpose string `json:"purpose"`
|
||||||
Type StepType `json:"type"`
|
Type StepType `json:"type"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Data interface{} `json:"data,omitempty"`
|
Data interface{} `json:"data,omitempty"`
|
||||||
Error string `json:"error,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.
|
// 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.
|
// executeCommandStep runs a gitlink-cli subcommand as a subprocess.
|
||||||
func executeCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
|
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)
|
parts := parseCommandTarget(step.Target)
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
sr.OK = false
|
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
|
// 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
|
// 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.
|
// format, and actions from either source go through the same security whitelist.
|
||||||
func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult, dryRun bool) {
|
func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult, dryRun bool) {
|
||||||
upstream := collectUpstream(ctx, step)
|
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 {
|
if dryRun {
|
||||||
sr.OK = true
|
sr.OK = true
|
||||||
sr.Data = map[string]interface{}{
|
sr.Data = map[string]interface{}{
|
||||||
|
|
@ -120,6 +153,7 @@ func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult,
|
||||||
client := NewAIClient()
|
client := NewAIClient()
|
||||||
var aiResp *AIResponse
|
var aiResp *AIResponse
|
||||||
var usedAI bool
|
var usedAI bool
|
||||||
|
var aiAnalysis interface{}
|
||||||
|
|
||||||
switch aiMode {
|
switch aiMode {
|
||||||
case AIModeNoAI:
|
case AIModeNoAI:
|
||||||
|
|
@ -139,7 +173,7 @@ func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult,
|
||||||
case AIModeAI:
|
case AIModeAI:
|
||||||
if !client.HasKey() {
|
if !client.HasKey() {
|
||||||
sr.OK = false
|
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
|
return
|
||||||
}
|
}
|
||||||
resp, err := callAI(client, step, upstream)
|
resp, err := callAI(client, step, upstream)
|
||||||
|
|
@ -176,17 +210,95 @@ func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult,
|
||||||
aiResp = resp
|
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)
|
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.OK = true
|
||||||
sr.Data = map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"analysis": aiResp.Analysis,
|
"analysis": aiResp.Analysis,
|
||||||
"executed": executed,
|
"executed": executed,
|
||||||
"_ai_used": usedAI,
|
"_ai_used": usedAI,
|
||||||
"_skill": step.Target,
|
"_skill": step.Target,
|
||||||
}
|
}
|
||||||
|
if aiAnalysis != nil {
|
||||||
|
data["ai_analysis"] = aiAnalysis
|
||||||
|
}
|
||||||
|
sr.Data = data
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeActions runs allowed actions from an AIResponse. Returns count of
|
// 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.
|
// through the same security whitelist.
|
||||||
func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
|
func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
|
||||||
executed := 0
|
executed := 0
|
||||||
|
seen := make(map[string]bool, len(actions))
|
||||||
for _, action := range 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) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if action.Type == "api" {
|
if action.Type == "api" {
|
||||||
|
|
@ -228,6 +347,15 @@ func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
|
||||||
return executed
|
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.
|
// resolveAIMode determines the effective AI mode from the context.
|
||||||
func resolveAIMode(ctx *common.RuntimeContext) AIMode {
|
func resolveAIMode(ctx *common.RuntimeContext) AIMode {
|
||||||
switch ctx.AIMode {
|
switch ctx.AIMode {
|
||||||
|
|
@ -320,6 +448,7 @@ var allowedCLIModules = map[string]bool{
|
||||||
"issue": true, "pr": true, "release": true,
|
"issue": true, "pr": true, "release": true,
|
||||||
"wiki": true, "member": true, "label": true,
|
"wiki": true, "member": true, "label": true,
|
||||||
"milestone": true, "branch": true, "comment": true,
|
"milestone": true, "branch": true, "comment": true,
|
||||||
|
"repo": true, "file": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var blockedCLICommands = map[string]bool{
|
var blockedCLICommands = map[string]bool{
|
||||||
|
|
@ -346,6 +475,14 @@ func isActionAllowed(action AIAction) bool {
|
||||||
|
|
||||||
// parseCommandTarget splits a CLI command string into tokens,
|
// parseCommandTarget splits a CLI command string into tokens,
|
||||||
// respecting quoted arguments.
|
// 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 {
|
func parseCommandTarget(target string) []string {
|
||||||
var parts []string
|
var parts []string
|
||||||
var current strings.Builder
|
var current strings.Builder
|
||||||
|
|
@ -379,3 +516,226 @@ func parseCommandTarget(target string) []string {
|
||||||
}
|
}
|
||||||
return parts
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func Watch(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration,
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Diff(result.Steps)
|
state.UpdateSnapshots(result.Steps)
|
||||||
state.TotalRuns++
|
state.TotalRuns++
|
||||||
state.Save()
|
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)
|
// Run immediately on start (dry-run to establish baseline)
|
||||||
state, _ := LoadState(wf.Name)
|
state, _ := LoadState(wf.Name)
|
||||||
dryResult, _ := Run(ctx, wf, true)
|
dryResult, _ := Run(ctx, wf, true)
|
||||||
state.Diff(dryResult.Steps)
|
state.UpdateSnapshots(dryResult.Steps)
|
||||||
state.TotalRuns++
|
state.TotalRuns++
|
||||||
state.Save()
|
state.Save()
|
||||||
|
|
||||||
|
|
@ -102,6 +102,7 @@ func Schedule(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duratio
|
||||||
}
|
}
|
||||||
|
|
||||||
changed := state.Diff(dryResult.Steps)
|
changed := state.Diff(dryResult.Steps)
|
||||||
|
state.UpdateSnapshots(dryResult.Steps)
|
||||||
state.TotalRuns++
|
state.TotalRuns++
|
||||||
state.Save()
|
state.Save()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@ const (
|
||||||
StepTypeAPI StepType = "api"
|
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.
|
// StepDef defines a single step in a workflow.
|
||||||
//
|
//
|
||||||
// skill: Target = "gitlink-triage" → AI Agent reads the Skill doc
|
// skill: Target = "gitlink-triage" → AI Agent reads the Skill doc
|
||||||
|
|
@ -54,6 +63,7 @@ type StepDef struct {
|
||||||
Purpose string `json:"purpose"`
|
Purpose string `json:"purpose"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
DependsOn []string `json:"depends_on,omitempty"`
|
DependsOn []string `json:"depends_on,omitempty"`
|
||||||
|
RunWhen RunWhen `json:"run_when,omitempty"`
|
||||||
Method string `json:"method,omitempty"`
|
Method string `json:"method,omitempty"`
|
||||||
Query url.Values `json:"-"`
|
Query url.Values `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
@ -87,8 +97,11 @@ type AIAction struct {
|
||||||
|
|
||||||
// WorkflowState tracks persistent run state and change detection snapshots.
|
// WorkflowState tracks persistent run state and change detection snapshots.
|
||||||
type WorkflowState struct {
|
type WorkflowState struct {
|
||||||
Workflow string `json:"workflow"`
|
Workflow string `json:"workflow"`
|
||||||
LastRun string `json:"last_run"`
|
LastRun string `json:"last_run"`
|
||||||
TotalRuns int `json:"total_runs"`
|
TotalRuns int `json:"total_runs"`
|
||||||
Snapshots map[string]string `json:"snapshots"` // stepName → md5(json)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
|
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
|
||||||
{Name: "ai", Usage: "Force AI mode (requires API key)", 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: "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: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
|
||||||
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
|
{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)
|
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)
|
aiMode, err := resolveAIModeFromArgs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -133,27 +142,6 @@ func Shortcuts() []*common.Shortcut {
|
||||||
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
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",
|
Name: "watch",
|
||||||
Description: "Poll for changes and trigger workflow on delta",
|
Description: "Poll for changes and trigger workflow on delta",
|
||||||
|
|
@ -173,6 +161,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if wf == nil {
|
if wf == nil {
|
||||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
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")
|
intervalStr := ctx.Arg("interval")
|
||||||
interval, err := time.ParseDuration(intervalStr)
|
interval, err := time.ParseDuration(intervalStr)
|
||||||
if err != nil {
|
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: "interval", Short: "i", Usage: "Run interval (e.g. 1h, 24h)", Default: "24h"},
|
||||||
{Name: "ai", Usage: "Force AI mode (requires API key)", 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: "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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
name, err := ctx.RequireArg("name")
|
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: "interval", Short: "i", Usage: "Poll interval (e.g. 5m, 1h)", Default: "5m"},
|
||||||
{Name: "ai", Usage: "Force AI mode (requires API key)", 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: "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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
name, err := ctx.RequireArg("name")
|
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, "\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")
|
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
|
||||||
} else if ruleEngine > 0 {
|
} else if ruleEngine > 0 {
|
||||||
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
|
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"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) {
|
func TestParseCommandTarget(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
input string
|
input string
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,58 @@
|
||||||
.result-body tbody tr:hover { background: #2a2a2a; }
|
.result-body tbody tr:hover { background: #2a2a2a; }
|
||||||
.result-body tbody tr:nth-child(even) { background: #252525; }
|
.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 { 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 { 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; }
|
.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 { 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; }
|
.example-btn:hover { background: #52c41a; color: white; }
|
||||||
|
|
||||||
.wf-subsection { margin-bottom: 20px; }
|
.wf-panel { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 24px; overflow: hidden; }
|
||||||
.wf-subsection h3 { font-size: 1.05em; margin-bottom: 12px; color: #722ed1; }
|
.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-def-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
.wf-panel-header:hover { background: #f3e8ff; }
|
||||||
.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-panel-header .wf-name { font-weight: 700; font-size: 1.05em; font-family: 'SFMono-Regular', Consolas, monospace; color: #722ed1; }
|
||||||
.wf-def-card .wf-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
.wf-panel-header .wf-meta { display: flex; align-items: center; gap: 10px; }
|
||||||
.wf-def-card .wf-name { font-weight: 700; font-size: 1em; font-family: monospace; color: #722ed1; }
|
.wf-panel-header .wf-cat { font-size: 0.7em; background: #f3e8ff; color: #722ed1; padding: 2px 8px; border-radius: 10px; }
|
||||||
.wf-def-card .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-def-card .wf-desc { color: #666; font-size: 0.85em; margin-bottom: 8px; line-height: 1.4; }
|
.wf-panel-header .wf-dot.running { background: #52c41a; box-shadow: 0 0 4px #52c41a; }
|
||||||
.wf-def-card .wf-trigger { font-size: 0.78em; color: #999; margin-bottom: 8px; }
|
.wf-panel-header .wf-dot.stopped { background: #d9d9d9; }
|
||||||
.wf-def-card .wf-trigger code { background: #f5f5f5; padding: 1px 6px; border-radius: 3px; font-size: 0.95em; }
|
.wf-panel-header .arrow { transition: transform 0.3s; font-size: 0.8em; color: #999; }
|
||||||
.wf-def-card .wf-steps { font-size: 0.78em; color: #888; }
|
.wf-panel-header .arrow.open { transform: rotate(180deg); }
|
||||||
.wf-def-card .wf-steps summary { cursor: pointer; color: #722ed1; font-weight: 500; margin-bottom: 4px; }
|
.wf-panel-body.collapsed { display: none; }
|
||||||
.wf-def-card .wf-steps ol { padding-left: 18px; line-height: 1.6; }
|
.wf-panel-body { padding: 0; }
|
||||||
.wf-commands { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
|
.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 { 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; }
|
.placeholder-card .ph-icon { font-size: 3em; margin-bottom: 16px; }
|
||||||
|
|
@ -427,94 +517,254 @@ const SKILLS = [
|
||||||
|
|
||||||
const WORKFLOWS = [
|
const WORKFLOWS = [
|
||||||
{ name:"community-ops", category:"运营", triggerType:"poll", triggerOn:"issue.created",
|
{ name:"community-ops", category:"运营", triggerType:"poll", triggerOn:"issue.created",
|
||||||
|
features: ["daemon", "execute"],
|
||||||
desc:"社区运营自动化全链路:Issue 智能分拣 → 生成周报 → 生成 Release Notes",
|
desc:"社区运营自动化全链路:Issue 智能分拣 → 生成周报 → 生成 Release Notes",
|
||||||
steps:["获取所有开放 Issue","获取标签库","获取成员列表","[AI] 分拣表格并执行打标签/分配","获取项目基础信息","获取已合并 PR","获取提交历史","[AI] 根据指标生成周报","获取版本发布记录","[AI] 分类 commit 生成 Release Notes"] },
|
steps:["获取所有开放 Issue","获取标签库","获取成员列表","[AI] 分拣表格并执行打标签/分配","获取项目基础信息","获取已合并 PR","获取提交历史","[AI] 根据指标生成周报","获取版本发布记录","[AI] 分类 commit 生成 Release Notes"] },
|
||||||
{ name:"code-quality", category:"质量", triggerType:"poll", triggerOn:"pr.opened",
|
{ name:"code-quality", category:"质量", triggerType:"poll", triggerOn:"pr.opened",
|
||||||
|
features: ["daemon", "execute"],
|
||||||
desc:"PR 提交 → Review → CI 检查 → 结果汇总的全自动代码质量门禁",
|
desc:"PR 提交 → Review → CI 检查 → 结果汇总的全自动代码质量门禁",
|
||||||
steps:["获取开放 PR 列表","获取 CI 构建状态","获取 PR 变更文件列表","获取 PR 详情","[AI] 代码审查(安全/性能/可读性/测试/CI)","[AI] 生成 Review 意见","[AI] 质量达标则自动提交 Review 评论"] },
|
steps:["获取开放 PR 列表","获取 CI 构建状态","获取 PR 变更文件列表","获取 PR 详情","[AI] 代码审查(安全/性能/可读性/测试/CI)","[AI] 生成 Review 意见","[AI] 质量达标则自动提交 Review 评论"] },
|
||||||
{ name:"project-init", category:"初始化", triggerType:"manual", triggerOn:"manual",
|
{ name:"project-init", category:"初始化", triggerType:"manual", triggerOn:"manual",
|
||||||
|
features: ["params", "execute"],
|
||||||
desc:"输入仓库名 → 检查现状 → 补齐缺失(README/LICENSE/标签/里程碑/Issue)",
|
desc:"输入仓库名 → 检查现状 → 补齐缺失(README/LICENSE/标签/里程碑/Issue)",
|
||||||
|
params: [
|
||||||
|
{k:"desc", l:"项目描述", t:"textarea", v:"一个基于 Go 的 GitLink CLI 工具,提供命令行方式管理仓库、Issue、PR 等"},
|
||||||
|
],
|
||||||
steps:["获取仓库基本信息","检查文件结构","获取已有标签和里程碑","检查分支保护状态","[AI] 对比初始化清单,识别缺失项","[AI] 生成建议的初始化内容","[AI] 自动创建缺失的标签/里程碑/Issue","[AI] 生成项目初始化报告"] },
|
steps:["获取仓库基本信息","检查文件结构","获取已有标签和里程碑","检查分支保护状态","[AI] 对比初始化清单,识别缺失项","[AI] 生成建议的初始化内容","[AI] 自动创建缺失的标签/里程碑/Issue","[AI] 生成项目初始化报告"] },
|
||||||
{ name:"multi-repo", category:"协同", triggerType:"cron", triggerOn:"0 9 * * 1",
|
{ name:"multi-repo", category:"协同", triggerType:"cron", triggerOn:"0 9 * * 1",
|
||||||
|
features: ["execute"],
|
||||||
desc:"跨多个仓库的统一 Issue 追踪、PR 状态看板、Release 协调",
|
desc:"跨多个仓库的统一 Issue 追踪、PR 状态看板、Release 协调",
|
||||||
steps:["获取目标仓库列表","获取每个仓库的开放 Issue","获取每个仓库的开放 PR","获取每个仓库的最新 Release","获取每个仓库的里程碑进度","[AI] 生成跨仓库状态看板"] },
|
steps:["获取目标仓库列表","获取每个仓库的开放 Issue","获取每个仓库的开放 PR","获取每个仓库的最新 Release","获取每个仓库的里程碑进度","[AI] 生成跨仓库状态看板"] },
|
||||||
{ name:"contributor-growth", category:"成长", triggerType:"cron", triggerOn:"0 9 * * 1",
|
{ name:"contributor-growth", category:"成长", triggerType:"cron", triggerOn:"0 9 * * 1",
|
||||||
|
features: ["schedule", "execute"],
|
||||||
desc:"追踪贡献者活动 → 排行 → 识别新星与流失风险",
|
desc:"追踪贡献者活动 → 排行 → 识别新星与流失风险",
|
||||||
steps:["获取提交历史","获取 Issue/PR 贡献数据","获取成员列表","[AI] 统计贡献排行","[AI] 计算 30 天活跃度趋势","[AI] 识别新星贡献者和流失风险"] },
|
steps:["获取提交历史","获取 Issue/PR 贡献数据","获取成员列表","[AI] 统计贡献排行","[AI] 计算 30 天活跃度趋势","[AI] 识别新星贡献者和流失风险"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const WORKFLOW_COMMANDS = [
|
const WORKFLOW_COMMANDS = []; // unused
|
||||||
{ 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"}] },
|
|
||||||
];
|
|
||||||
|
|
||||||
(function renderPart1() {
|
(function renderModules() {
|
||||||
const container = document.getElementById('modules');
|
var grid = document.getElementById('modules');
|
||||||
MODULES.forEach(mod => {
|
if (!grid) return;
|
||||||
const card = document.createElement('div');
|
MODULES.forEach(function(mod) {
|
||||||
|
var card = document.createElement('div');
|
||||||
card.className = 'module-card';
|
card.className = 'module-card';
|
||||||
const cmdHTML = mod.commands.map(cmd => {
|
card.style.borderTop = '3px solid ' + (mod.color || '#1890ff');
|
||||||
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;
|
var headerHTML = '<div class="module-header" onclick="toggleModule(this)">' +
|
||||||
const paramsHTML = cmd.params.map(function(p) {
|
'<h3>' + mod.title + ' <span class="tag">' + mod.commands.length + ' 命令</span></h3>' +
|
||||||
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>';
|
'<span class="arrow">▼</span></div>';
|
||||||
return '<div class="param-group"><label>' + p.l + '</label><input id="p-' + uid + '-' + p.k + '" value="' + p.v + '" placeholder="' + p.l + '"></div>';
|
|
||||||
}).join('');
|
var bodyHTML = '<div class="module-body collapsed">';
|
||||||
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('');
|
// Repo selector for member module
|
||||||
card.innerHTML = '<div class="module-header" onclick="toggleModule(this)"><h3><span style="color:' + mod.color + ';font-size:1.3em;">■</span> ' + mod.title + ' <span class="tag">' + mod.commands.length + ' 个命令</span></h3><span class="arrow">▼</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>';
|
if (mod.repoSelector) {
|
||||||
container.appendChild(card);
|
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() {
|
(function renderSkills() {
|
||||||
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" },
|
|
||||||
];
|
|
||||||
var container = document.getElementById('skills-container');
|
var container = document.getElementById('skills-container');
|
||||||
cats.forEach(function(cat) {
|
if (!container) return;
|
||||||
var skills = SKILLS.filter(function(s) { return s.cat === cat.key; });
|
|
||||||
var cardsHTML = skills.map(function(s) {
|
var categories = {core:{label:'核心 Skills',dot:'core',skills:[]},smart:{label:'智能 Skills',dot:'smart',skills:[]},aux:{label:'辅助 Skills',dot:'aux',skills:[]}};
|
||||||
var filesStr = s.files.join(', ');
|
SKILLS.forEach(function(s) {
|
||||||
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>' : '';
|
var cat = categories[s.cat] || categories.aux;
|
||||||
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>';
|
cat.skills.push(s);
|
||||||
}).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>');
|
|
||||||
|
['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() {
|
(function renderPart3() {
|
||||||
var container = document.getElementById('workflow-container');
|
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('');
|
// Global top bar
|
||||||
var triggerLabel = w.triggerType === 'manual' ? '手动触发' : w.triggerType === 'poll' ? '事件: ' + w.triggerOn : '定时: ' + w.triggerOn;
|
var globalHTML = '<div class="wf-global">' +
|
||||||
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>';
|
'<span>全局命令 </span>' +
|
||||||
}).join('');
|
'<button class="action-btn btn-info" onclick="runWfGlobal(\'list\',\'wf-global-list\')" id="btn-wf-global-list">列出所有工作流</button>' +
|
||||||
container.insertAdjacentHTML('beforeend', '<div class="wf-subsection"><h3>📋 预置工作流定义(' + WORKFLOWS.length + ' 个)</h3><div class="wf-def-grid">' + defCardsHTML + '</div></div>');
|
'<button class="action-btn btn-logs" onclick="runWfGlobal(\'install-systemd\',\'wf-global-install\')" id="btn-wf-global-install">生成 systemd 服务</button>' +
|
||||||
var cmdRowsHTML = WORKFLOW_COMMANDS.map(function(cmd) {
|
'</div>' +
|
||||||
var uid = 'wf-' + cmd.name;
|
'<div class="result-panel" id="result-wf-global-list"></div>' +
|
||||||
var paramsHTML = cmd.params.map(function(p) {
|
'<div class="result-panel" id="result-wf-global-install"></div>';
|
||||||
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>';
|
container.insertAdjacentHTML("beforeend", globalHTML);
|
||||||
return '<div class="param-group"><label>' + p.l + '</label><input id="p-' + uid + '-' + p.k + '" value="' + p.v + '" placeholder="' + p.l + '"></div>';
|
|
||||||
}).join('');
|
WORKFLOWS.forEach(function(w) {
|
||||||
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>';
|
var uid = "wfpanel-" + w.name;
|
||||||
}).join('');
|
var stepsHTML = w.steps.map(function(s,i) { return "<li>" + s + "</li>"; }).join("");
|
||||||
container.insertAdjacentHTML('beforeend', '<div class="wf-subsection"><h3>🖥 工作流 CLI 命令(' + WORKFLOW_COMMANDS.length + ' 个)</h3><div class="wf-commands">' + cmdRowsHTML + '</div></div>');
|
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">▼</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) {
|
function toggleModule(header) {
|
||||||
var body = header.nextElementSibling;
|
var body = header.nextElementSibling;
|
||||||
var arrow = header.querySelector('.arrow');
|
var arrow = header.querySelector('.arrow');
|
||||||
|
|
@ -545,11 +795,17 @@ function getArgs(uid, params) {
|
||||||
async function runCommand(module, command, uid) {
|
async function runCommand(module, command, uid) {
|
||||||
var btn = document.getElementById('btn-' + uid);
|
var btn = document.getElementById('btn-' + uid);
|
||||||
var panel = document.getElementById('result-' + uid);
|
var panel = document.getElementById('result-' + uid);
|
||||||
var mod = MODULES.find(function(m) { return m.name === module; });
|
// Search all modules for the command (supports cross-module commands like org/info)
|
||||||
var cmd = mod.commands.find(function(c) {
|
var cmd = null, mod = null;
|
||||||
var expectedUid = c.module ? module + '-' + c.module + '-' + c.name : module + '-' + c.name;
|
for (var i = 0; i < MODULES.length; i++) {
|
||||||
return expectedUid === uid;
|
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;
|
if (!cmd) return;
|
||||||
var args = getArgs(uid, cmd.params);
|
var args = getArgs(uid, cmd.params);
|
||||||
var apiModule = cmd.module || module;
|
var apiModule = cmd.module || module;
|
||||||
|
|
@ -593,42 +849,334 @@ async function runCommand(module, command, uid) {
|
||||||
btn.textContent = '▶ 运行';
|
btn.textContent = '▶ 运行';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runWorkflowCommand(command, uid) {
|
async function runWfGlobal(command, uid) {
|
||||||
var btn = document.getElementById('btn-' + uid);
|
var btn = document.getElementById('btn-' + uid);
|
||||||
var panel = document.getElementById('result-' + 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.disabled = true;
|
||||||
btn.textContent = '运行中...';
|
|
||||||
var url = '/api/run?module=workflow&command=' + command + '&format=' + currentFormat;
|
var url = '/api/run?module=workflow&command=' + command + '&format=' + currentFormat;
|
||||||
if (args) url += '&args=' + encodeURIComponent(args);
|
|
||||||
try {
|
try {
|
||||||
var resp = await fetch(url);
|
var resp = await fetch(url);
|
||||||
var data = await resp.json();
|
var data = await resp.json();
|
||||||
var statusClass = data.ok ? 'ok' : 'err';
|
var sc = data.ok ? 'ok' : 'err', st = data.ok ? '成功' : '失败';
|
||||||
var statusText = data.ok ? '成功' : '失败';
|
var out = formatOutput(data);
|
||||||
var output, isTable = false;
|
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\')">×</span></div><div class="result-body"><pre>' + escapeHtml(out) + '</pre></div>';
|
||||||
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\')">×</span></div>' + bodyHTML;
|
|
||||||
panel.classList.add('show');
|
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\')">×</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
|
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\')">×</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
|
||||||
panel.classList.add('show');
|
panel.classList.add('show');
|
||||||
}
|
}
|
||||||
btn.disabled = false;
|
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\')">×</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\')">×</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\')">×</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\')">×</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\')">×</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\')">×</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) {
|
function tableToHtml(text) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -71,22 +72,31 @@ func main() {
|
||||||
log.Printf("Running: %s", cmdStr)
|
log.Printf("Running: %s", cmdStr)
|
||||||
|
|
||||||
cmd := exec.Command(cliBin, args...)
|
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{
|
result := RunResult{
|
||||||
Command: cmdStr,
|
Command: cmdStr,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if runErr != nil {
|
||||||
result.Error = strings.TrimSpace(string(output))
|
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
|
result.Output = nil
|
||||||
} else {
|
} else {
|
||||||
result.OK = true
|
result.OK = true
|
||||||
var parsed interface{}
|
var parsed interface{}
|
||||||
if json.Unmarshal(output, &parsed) == nil {
|
if json.Unmarshal(stdout.Bytes(), &parsed) == nil {
|
||||||
result.Output = parsed
|
result.Output = parsed
|
||||||
} else {
|
} else {
|
||||||
result.Output = strings.TrimSpace(string(output))
|
result.Output = strings.TrimSpace(stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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 天无活动标注「流失风险」
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# gitlink-init-scaffold(项目脚手架初始化)
|
||||||
|
|
||||||
|
根据用户提供的项目描述,自动创建仓库并初始化标准项目脚手架。
|
||||||
|
|
||||||
|
## 输入参数
|
||||||
|
|
||||||
|
从上游数据中读取:
|
||||||
|
|
||||||
|
- `_owner` — 仓库所有者(必填)
|
||||||
|
- `_desc` — 项目描述,用于生成仓库名和 README
|
||||||
|
- `_repo` — 显式指定仓库名(可选,不提供则从描述自动生成)
|
||||||
|
|
||||||
|
## 操作
|
||||||
|
|
||||||
|
执行以下初始化步骤:
|
||||||
|
|
||||||
|
1. **创建仓库** — CLI `repo +create --name <name> --description <desc>`
|
||||||
|
2. **README.md** — 根据描述生成项目 README,POST `{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}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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 协调:判断目标版本是否建议发布,列出阻塞原因。
|
||||||
|
- 行动建议:按优先级列出需要处理的仓库和事项。
|
||||||
|
|
@ -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 根据以下信息分配责任人:
|
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 生成分拣建议:
|
AI 对每个 Issue 生成分拣建议:
|
||||||
|
|
@ -226,7 +305,8 @@ AI 对每个 Issue 生成分拣建议:
|
||||||
| **建议标签** | enhancement, good first issue |
|
| **建议标签** | enhancement, good first issue |
|
||||||
| **建议责任人** | 未分配(适合新人) |
|
| **建议责任人** | 未分配(适合新人) |
|
||||||
| **Good First Issue** | ✅ 是 |
|
| **Good First Issue** | ✅ 是 |
|
||||||
| **AI 分析** | 功能建议明确,改动范围小(仅需修改帮助文档格式),适合新贡献者。建议添加引导评论。 |
|
| **AI 分析** | 功能建议明确,改动范围小(仅需修改帮助文档格式),适合新贡献者。需要生成与该 Issue 内容匹配的个性化引导评论,并在 actions 中返回 `issue +comment`。 |
|
||||||
|
| **个性化评论摘要** | 说明为什么适合新人,并给出 2-4 个可执行入手步骤。 |
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用场景
|
## 使用场景
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue