This commit is contained in:
狗gogo 2026-06-16 08:47:53 +08:00
commit d18de0437a
17 changed files with 2114 additions and 63 deletions

View File

@ -48,14 +48,12 @@ func New() (*Client, error) {
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// Append .json suffix if not already present (GitLink API convention)
// Handle paths that may already contain query strings (e.g., /path?key=val)
if !c.SkipJSONSuffix {
if c.shouldAppendJSONSuffix(path) {
if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx]
queryStr := path[idx:]
if !strings.HasSuffix(basePath, ".json") {
path = basePath + ".json" + queryStr
}
} else if !strings.HasSuffix(path, ".json") {
path = basePath + ".json" + queryStr
} else {
path += ".json"
}
}
@ -225,3 +223,24 @@ func lookupStatusInfo(code int) statusInfo {
message: fmt.Sprintf("API 返回错误码 %d", code),
}
}
// shouldAppendJSONSuffix reports whether the .json suffix should be appended to path.
// Returns false (skip append) when:
// - c.SkipJSONSuffix is set (explicit opt-out for non-JSON endpoints such as gateway)
// - path already ends with .json
// - path matches the raw content pattern (e.g., /api/:owner/:repo/raw/...)
func (c *Client) shouldAppendJSONSuffix(path string) bool {
if c.SkipJSONSuffix {
return false
}
if strings.HasSuffix(path, ".json") {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
return true
}

View File

@ -8,21 +8,27 @@ import (
)
const (
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultFormat = "table"
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultGatewayBaseURL = "https://gateway.gitlink.org.cn/api"
DefaultFormat = "table"
// EnvGatewayBaseURL overrides GatewayBaseURL when set.
EnvGatewayBaseURL = "GITLINK_GATEWAY_URL"
)
type Config struct {
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
BaseURL string `yaml:"base_url"`
GatewayBaseURL string `yaml:"gateway_base_url,omitempty"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
BaseURL: DefaultBaseURL,
Format: DefaultFormat,
BaseURL: DefaultBaseURL,
GatewayBaseURL: DefaultGatewayBaseURL,
Format: DefaultFormat,
}
}
@ -53,6 +59,12 @@ func Load() (*Config, error) {
if cfg.BaseURL == "" {
cfg.BaseURL = DefaultBaseURL
}
if cfg.GatewayBaseURL == "" {
cfg.GatewayBaseURL = DefaultGatewayBaseURL
}
if v := os.Getenv(EnvGatewayBaseURL); v != "" {
cfg.GatewayBaseURL = v
}
if cfg.Format == "" {
cfg.Format = DefaultFormat
}
@ -79,6 +91,8 @@ func Get(key string) (string, error) {
switch key {
case "base_url":
return cfg.BaseURL, nil
case "gateway_base_url":
return cfg.GatewayBaseURL, nil
case "default_format":
return cfg.Format, nil
case "editor":
@ -98,6 +112,8 @@ func Set(key, value string) error {
switch key {
case "base_url":
cfg.BaseURL = value
case "gateway_base_url":
cfg.GatewayBaseURL = value
case "default_format":
cfg.Format = value
case "editor":

View File

@ -4,12 +4,14 @@ import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/context"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
@ -21,7 +23,7 @@ type Shortcut struct {
Description string
Flags []Flag
DryRun bool // 是否支持 dry-run
DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述
DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述
Run func(ctx *RuntimeContext) error
}
@ -37,12 +39,14 @@ type Flag struct {
// RuntimeContext provides helpers for shortcut implementations.
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client // optional; nil = use auth.NewHTTPClient (mainly for tests)
}
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
@ -58,13 +62,20 @@ func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeCont
format = "json"
}
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL
}
return &RuntimeContext{
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
CommandName: commandName,
Args: args,
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
CommandName: commandName,
Args: args,
GatewayBaseURL: gatewayBaseURL,
GatewayHTTPClient: nil,
}, nil
}

View File

@ -18,32 +18,35 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const gatewayBaseURL = "https://gateway.gitlink.org.cn/api"
var (
projectIDCache sync.Map
gatewayClient *client.Client
gatewayOnce sync.Once
)
var projectIDCache sync.Map
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
func getGatewayClient() *client.Client {
gatewayOnce.Do(func() {
gatewayClient = &client.Client{
HTTP: auth.NewHTTPClient(),
BaseURL: gatewayBaseURL,
SkipJSONSuffix: true,
}
})
return gatewayClient
// getGatewayClient returns a client targeting the Wiki API gateway.
// The BaseURL is resolved from RuntimeContext.GatewayBaseURL, which in turn
// honours (in order): GITLINK_GATEWAY_URL env > config gateway_base_url > default.
// HTTP client falls back to auth.NewHTTPClient() when ctx.GatewayHTTPClient is nil.
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api"
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient()
}
return &client.Client{
HTTP: httpClient,
BaseURL: baseURL,
SkipJSONSuffix: true,
Debug: ctx.Client.Debug,
}
}
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient()
gc.Debug = ctx.Client.Debug
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
@ -52,8 +55,7 @@ func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface
}
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient()
gc.Debug = ctx.Client.Debug
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
@ -218,12 +220,12 @@ type LintIssue struct {
}
type LintSummary struct {
Repository string `json:"repository"`
TotalPages int `json:"total_pages"`
TotalIssues int `json:"total_issues"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Results []LintIssue `json:"results"`
Repository string `json:"repository"`
TotalPages int `json:"total_pages"`
TotalIssues int `json:"total_issues"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Results []LintIssue `json:"results"`
}
var (

View File

@ -1,9 +1,13 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@ -234,3 +238,407 @@ func TestResolveProjectID_APIError(t *testing.T) {
t.Fatal("expected error, got nil")
}
}
// ---- callWikiAPI HTTP request path tests ----
func TestCallWikiAPI_Success(t *testing.T) {
resetProjectIDCache()
var receivedPath, receivedMethod string
var receivedBody []byte
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
receivedMethod = r.Method
if r.Body != nil {
buf := make([]byte, 1024)
n, _ := r.Body.Read(buf)
receivedBody = buf[:n]
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":200,"msg":"ok","data":{"id":42}}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
env, err := callWikiAPI(ctx, "POST", "/wiki/open/test", map[string]string{"foo": "bar"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedMethod != "POST" {
t.Errorf("method = %q, want POST", receivedMethod)
}
if receivedPath != "/wiki/open/test" {
t.Errorf("path = %q, want /wiki/open/test", receivedPath)
}
if !strings.Contains(string(receivedBody), `"foo"`) {
t.Errorf("body should contain foo: %s", string(receivedBody))
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map data, got %T", env.Data)
}
if data["id"] != float64(42) {
t.Errorf("data[id] = %v, want 42", data["id"])
}
}
func TestCallWikiAPI_BusinessError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":400,"msg":"bad request","data":null}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") {
t.Errorf("err = %q, want to contain 400 and bad request", err.Error())
}
}
func TestCallWikiAPI_GatewayHTTPError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(`upstream error`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected error on 502")
}
}
func TestCallWikiAPI_SkipsJSONSuffix(t *testing.T) {
// Verifies the SkipJSONSuffix path is correctly taken for gateway:
// the URL should NOT have a .json appended.
resetProjectIDCache()
var receivedPath string
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
w.Write([]byte(`{"code":200,"data":{}}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/wiki/open/list", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedPath != "/wiki/open/list" {
t.Errorf("path = %q, want /wiki/open/list (no .json suffix)", receivedPath)
}
if strings.HasSuffix(receivedPath, ".json") {
t.Errorf("path %q should NOT have .json suffix (gateway expects no suffix)", receivedPath)
}
}
func TestCallWikiAPI_ConnectionRefused(t *testing.T) {
resetProjectIDCache()
// Use an unbound port to simulate connection failure
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: &http.Client{}, BaseURL: "http://127.0.0.1:1"},
GatewayBaseURL: "http://127.0.0.1:1",
GatewayHTTPClient: &http.Client{},
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected connection error")
}
}
// ---- runLint / check rules tests ----
func TestIsCheckEnabled_Empty(t *testing.T) {
if !isCheckEnabled("", "any") {
t.Error("empty filter should enable all checks")
}
if !isCheckEnabled("empty,headings", "empty") {
t.Error("should enable 'empty' in filter list")
}
if isCheckEnabled("headings", "empty") {
t.Error("should not enable 'empty' when not in filter list")
}
if !isCheckEnabled(" empty , headings ", "empty") {
t.Error("should trim whitespace")
}
}
func TestCheckEmpty(t *testing.T) {
issues := checkEmpty("p1", "")
if len(issues) != 1 || issues[0].Level != "error" || issues[0].Check != "empty" {
t.Errorf("expected 1 error-level 'empty' issue, got %+v", issues)
}
if issues := checkEmpty("p1", "some content"); issues != nil {
t.Errorf("non-empty content should not produce issues, got %+v", issues)
}
if issues := checkEmpty("p1", " \n\t "); len(issues) != 1 {
t.Errorf("whitespace-only content should be empty, got %+v", issues)
}
}
func TestCheckHeading(t *testing.T) {
// missing H1
if issues := checkHeading("p1", "Some text without heading"); len(issues) != 1 {
t.Errorf("expected 1 missing-heading issue, got %+v", issues)
}
// has H1
if issues := checkHeading("p1", "# Title\nbody"); issues != nil {
t.Errorf("H1 should not produce issues, got %+v", issues)
}
// empty content skipped
if issues := checkHeading("p1", ""); issues != nil {
t.Errorf("empty content should be skipped, got %+v", issues)
}
// whitespace prefix
if issues := checkHeading("p1", " \n# Real Title"); issues != nil {
t.Errorf("H1 after whitespace should not produce issues, got %+v", issues)
}
}
func TestCheckShort(t *testing.T) {
if issues := checkShort("p1", ""); issues != nil {
t.Errorf("empty content should be skipped, got %+v", issues)
}
if issues := checkShort("p1", "short"); len(issues) != 1 {
t.Errorf("expected 1 short issue, got %+v", issues)
}
long := strings.Repeat("a", 100)
if issues := checkShort("p1", long); issues != nil {
t.Errorf("long content should not produce issues, got %+v", issues)
}
// exactly 49 chars triggers
if issues := checkShort("p1", strings.Repeat("a", 49)); len(issues) != 1 {
t.Errorf("49-char content should be 'short', got %+v", issues)
}
}
func TestCheckDeadLinks(t *testing.T) {
known := map[string]bool{"Home": true, "Guide": true}
// All known: no issues
if issues := checkDeadLinks("p1", "[Home](Home) and [Guide](Guide)", known); issues != nil {
t.Errorf("all-known should not produce issues, got %+v", issues)
}
// Unknown link
issues := checkDeadLinks("p1", "[Unknown](Unknown)", known)
if len(issues) != 1 || issues[0].Check != "links" {
t.Errorf("expected 1 dead link issue, got %+v", issues)
}
// External links skipped
if issues := checkDeadLinks("p1", "[ext](https://example.com)", known); issues != nil {
t.Errorf("external links should be skipped, got %+v", issues)
}
// Anchor links skipped
if issues := checkDeadLinks("p1", "[anchor](#section)", known); issues != nil {
t.Errorf("anchor links should be skipped, got %+v", issues)
}
// Mixed
issues = checkDeadLinks("p1", "[Home](Home) and [Bad](BadPage)", known)
if len(issues) != 1 {
t.Errorf("expected 1 dead link in mixed, got %+v", issues)
}
// Empty content
if issues := checkDeadLinks("p1", "", known); issues != nil {
t.Errorf("empty content should not produce issues, got %+v", issues)
}
}
func TestCheckImages(t *testing.T) {
// Mock image server
imgServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "HEAD" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
})
defer imgServer.Close()
brokenServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
defer brokenServer.Close()
httpClient := imgServer.Client()
// Valid image (200)
if issues := checkImages("p1", "![ok]("+imgServer.URL+"/img.png)", httpClient); issues != nil {
t.Errorf("200 image should not produce issues, got %+v", issues)
}
// Broken image (404)
issues := checkImages("p1", "![bad]("+brokenServer.URL+"/missing.png)", httpClient)
if len(issues) != 1 {
t.Errorf("expected 1 broken image issue, got %+v", issues)
}
// No images
if issues := checkImages("p1", "no images here", httpClient); issues != nil {
t.Errorf("no images should not produce issues, got %+v", issues)
}
// Malformed HTTP URL (regex matches https?:// but http.NewRequest fails to parse)
if issues := checkImages("p1", "![bad](http://[)", httpClient); len(issues) != 1 {
t.Errorf("expected 1 invalid-URL issue, got %+v", issues)
}
}
func TestRunLint_Integration(t *testing.T) {
resetProjectIDCache()
// Mock main API (project detail) and gateway (wiki list + get)
mainServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/detail.json") {
writeJSON(t, w, map[string]interface{}{"project_id": float64(999)})
return
}
t.Errorf("unexpected main API call: %s %s", r.Method, r.URL.Path)
})
defer mainServer.Close()
// Page content (base64 encoded)
goodContent := base64.StdEncoding.EncodeToString([]byte("# Good Page\n\n" + strings.Repeat("This is a well-formed page with enough content to pass the short check. ", 3)))
wikiServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/wiki/open/wikiPages":
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": []map[string]interface{}{
{"title": "Good", "sub_url": "Good"},
{"title": "Empty", "sub_url": "Empty"},
{"title": "_Sidebar", "sub_url": "_Sidebar"}, // system page - skipped
},
})
case "/wiki/open/getWiki":
pageName := r.URL.Query().Get("pageName")
var content string
if pageName == "Empty" {
content = "" // empty page
} else {
content = goodContent
}
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": map[string]interface{}{"content_base64": content},
})
default:
t.Errorf("unexpected wiki path: %s", r.URL.Path)
}
})
defer wikiServer.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: mainServer.Client(), BaseURL: mainServer.URL},
Owner: "owner1",
Repo: "repo1",
Format: "json",
GatewayBaseURL: wikiServer.URL,
GatewayHTTPClient: wikiServer.Client(),
}
if err := runLint(ctx); err != nil {
t.Fatalf("runLint: %v", err)
}
// _Sidebar is skipped, so TotalPages=2 (Good, Empty)
// Empty page produces 1 "empty" error
// We can't directly inspect the output envelope, but if no error, the function ran end-to-end
}
// ---- resolveContent tests ----
func TestResolveContent_FromArg(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{"content": "inline content"},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "inline content" {
t.Errorf("got %q, want %q", got, "inline content")
}
}
func TestResolveContent_FromFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "wiki.md")
want := "# Title\n\nBody content from file"
if err := os.WriteFile(path, []byte(want), 0600); err != nil {
t.Fatalf("setup: %v", err)
}
ctx := &common.RuntimeContext{
Args: map[string]string{"file": path},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestResolveContent_ArgTakesPrecedence(t *testing.T) {
// When both --content and --file are set, --content wins
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "wiki.md")
if err := os.WriteFile(path, []byte("from file"), 0600); err != nil {
t.Fatalf("setup: %v", err)
}
ctx := &common.RuntimeContext{
Args: map[string]string{
"content": "from arg",
"file": path,
},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "from arg" {
t.Errorf("arg should take precedence; got %q", got)
}
}
func TestResolveContent_Missing(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{},
}
_, err := resolveContent(ctx)
if err == nil {
t.Fatal("expected error when neither --content nor --file is provided")
}
if !strings.Contains(err.Error(), "required") {
t.Errorf("err = %q, want to mention 'required'", err.Error())
}
}
func TestResolveContent_FileNotFound(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{"file": "/nonexistent/path/to/wiki.md"},
}
_, err := resolveContent(ctx)
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}

View File

@ -92,6 +92,22 @@ skills/
│ ├── REFERENCE.md # Release API 参考
│ └── examples/
│ └── release-workflow.md # Release 工作流
├── gitlink-changelog/ # Release Notes / Changelog 生成
│ ├── SKILL.md # Changelog 操作指南
│ ├── references/
│ │ ├── collect-data.md # 收集变更数据
│ │ ├── classify-rules.md # 变更分类规则
│ │ └── generate-and-publish.md # 生成并发布
│ └── examples/
│ └── full-workflow.md # 完整生成示例
├── gitlink-health/ # 项目健康度报告
│ ├── SKILL.md # 健康度报告操作指南
│ ├── references/
│ │ ├── collect-data.md # 收集项目数据
│ │ ├── health-metrics.md # 指标计算和评分规则
│ │ └── generate-report.md # 报告生成和输出
│ └── examples/
│ └── full-workflow.md # 完整生成示例
├── gitlink-search/ # 搜索功能
│ ├── SKILL.md # 搜索操作指南
│ └── examples/
@ -128,6 +144,7 @@ skills/
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` |
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |
| **gitlink-health** | 项目健康度报告 | Issue 响应时间、PR 合并效率、贡献者活跃度统计 |
### 辅助 Skills
@ -139,7 +156,8 @@ skills/
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
| **gitlink-wiki** | Wiki 管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` |
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
| **gitlink-changelog** | Release Notes / Changelog 生成 | 自动收集 commits/PR/Issue生成结构化版本说明 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、仓库初始化、Sprint 报告 |
---

View File

@ -0,0 +1,140 @@
---
name: gitlink-changelog
version: 1.0.0
description: "Release Notes 生成:根据 commit 和 PR 记录自动生成结构化版本发布说明。当用户需要生成 Release Notes、发版说明时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli release --help"
---
# gitlink-changelogRelease Notes 生成)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 工作流
Release Notes 生成分三步:收集 → 分类 → 发布。
| 步骤 | 说明 | 所用命令 |
|------|------|----------|
| 1. 收集数据 | 获取版本间的 commits、已合并 PR、已关闭 Issue | `release +list`, `api GET compare`, `pr +list`, `issue +list` |
| 2. 分类整理 | 按类型归类变更(新功能/Bug修复/改进/破坏性变更) | AI 分析 |
| 3. 生成发布 | 套用模板生成 Notes创建或更新 Release | `release +create`, `release +update` |
## 命令参考
### 收集数据
```bash
# 确定版本范围:获取已有 release 列表,找上一个 tag
gitlink-cli release +list --format json
# 获取两个版本间的 commit 差异(平台 compare API
gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json
# 获取已合并的 PR
gitlink-cli pr +list --state merged --format json
# 获取已关闭的 Issue
gitlink-cli issue +list --state closed --format json
```
### 发布 Release Notes
```bash
# 创建 Release 并附带 Notes
gitlink-cli release +create --tag v1.1.0 --name "v1.1.0" --body "<Markdown 格式的 Release Notes>"
# 更新已有 Release 的 Notes
gitlink-cli release +update --id <version_id> --body "<更新后的 Notes>"
```
## 分类规则
| 类型 | 图标 | Issue 标签 | Commit 关键词 |
|------|------|------------|---------------|
| 新功能 | ✨ | `feature`, `enhancement` | `feat:`, `add`, `新增` |
| Bug 修复 | 🐛 | `bug`, `fix` | `fix:`, `bugfix`, `修复` |
| 功能改进 | 🔧 | `improvement`, `optimize` | `improve:`, `optimize:`, `refactor:` |
| 破坏性变更 | ⚠️ | `breaking`, `major` | `BREAKING`, `breaking:`, `!` |
| 文档 | 📝 | `docs`, `documentation` | `docs:`, `doc` |
| 安全修复 | 🔒 | `security`, `vulnerability` | `security:`, `安全` |
## Release Notes 模板
### 标准模板
```markdown
# 🎉 Release {VERSION}
## 📊 变更统计
- **新功能**: {FEATURE_COUNT} 个
- **Bug 修复**: {BUG_FIX_COUNT} 个
- **功能改进**: {ENHANCEMENT_COUNT} 个
- **破坏性变更**: {BREAKING_COUNT} 个
## ✨ 新功能
{FEATURES}
## 🐛 Bug 修复
{BUG_FIXES}
## 🔧 功能改进
{ENHANCEMENTS}
## ⚠️ 破坏性变更
{BREAKING_CHANGES}
## 🙏 贡献者
{CONTRIBUTORS}
---
**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION}
```
### 简化模板
```markdown
# {VERSION}
## 新增
{FEATURES}
## 修复
{BUG_FIXES}
## 改进
{ENHANCEMENTS}
```
## 版本号规范
遵循语义化版本Semantic Versioning`MAJOR.MINOR.PATCH`
| 变更类型 | 版本变化 | 示例 |
|----------|----------|------|
| 破坏性变更 | MAJOR +1 | `1.2.0``2.0.0` |
| 向后兼容的新功能 | MINOR +1 | `1.1.0``1.2.0` |
| 向后兼容的 Bug 修复 | PATCH +1 | `1.1.0``1.1.1` |
## API 注意事项
- `compare` API 无专用 shortcut通过 `gitlink-cli api GET /:owner/:repo/compare/{head}...{base}` 调用
- `compare` API 另支持查询参数格式:`GET /v1/:owner/:repo/compare.json?from=&to=`
- `release +create``--body` 接受完整 Markdown支持多行文本
- `release +view` / `release +delete` 必须使用 `version_id`(从 `release +list` 获取),不可用 `tag_name`
- 创建 Release 前务必让用户审核生成的 Notes 内容
## References
- [collect-data](references/collect-data.md) — 收集 commits、PR、Issue 数据
- [classify-rules](references/classify-rules.md) — 变更分类规则详解
- [generate-and-publish](references/generate-and-publish.md) — 生成 Notes 并发布
- [full-workflow](examples/full-workflow.md) — 完整端到端示例
- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数
- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作

View File

@ -0,0 +1,152 @@
# Release Notes 完整生成示例
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **适用场景:** AI Agent 端到端生成 Release Notes从数据收集到发布。
`zzx-coder/gitlink-cli` 项目从 `v1.0.0``v1.1.0` 为例。
## 完整流程
### 第一步:确定版本范围
```bash
# 获取已有 release
gitlink-cli release +list --format json
# 返回示例(截取关键字段):
# {
# "ok": true,
# "data": {
# "releases": [
# { "tag_name": "v1.0.0", "created_at": "2026-05-01", ... },
# ...
# ]
# }
# }
# AI 据此确定PREV_VERSION = "v1.0.0"NEW_VERSION = "v1.1.0"
```
### 第二步:收集 commits
```bash
gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json
# 从返回中提取 commits 列表,每个 commit 含:
# - commit.message (提交信息)
# - commit.author.name (作者)
# - sha (提交 SHA)
```
### 第三步:收集已合并 PR
```bash
gitlink-cli pr +list --state merged --format json
# AI 筛选 merged_at >= "2026-05-01"v1.0.0 发布时间)的 PR
# 提取每个 PR 的 title、number、author.login
```
### 第四步:收集已关闭 Issue
```bash
gitlink-cli issue +list --state closed --format json
# AI 筛选 closed_at >= "2026-05-01" 的 Issue
# 提取每个 Issue 的 subject、project_issues_index、issue_tags
```
### 第五步AI 分类
AI 根据 [分类规则](../references/classify-rules.md) 对收集到的数据分类:
```
新功能:
- 支持批量 Issue 操作 (#12)
- 新增 uninstall 命令 (#11)
Bug 修复:
- 修复 URL 解析异常 (#10)
功能改进:
- 重构自动部署配置
文档:
- 更新分支映射说明
```
### 第六步:生成 Notes 并确认
AI 套用标准模板生成草稿并展示给用户:
```markdown
# 🎉 Release v1.1.0
## 📊 变更统计
- **新功能**: 2 个
- **Bug 修复**: 1 个
- **功能改进**: 1 个
- **破坏性变更**: 0 个
## ✨ 新功能
- 支持批量 Issue 操作 (#12)
- 新增 uninstall 命令 (#11)
## 🐛 Bug 修复
- 修复 URL 解析异常 (#10)
## 🔧 功能改进
- 重构自动部署配置
## 🙏 贡献者
zzx-coder, camelliamc
---
**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0
```
### 第七步:用户确认后发布
```bash
gitlink-cli release +create \
--tag v1.1.0 \
--name "v1.1.0" \
--body "# 🎉 Release v1.1.0
## 📊 变更统计
- **新功能**: 2 个
- **Bug 修复**: 1 个
- **功能改进**: 1 个
- **破坏性变更**: 0 个
## ✨ 新功能
- 支持批量 Issue 操作 (#12)
- 新增 uninstall 命令 (#11)
## 🐛 Bug 修复
- 修复 URL 解析异常 (#10)
## 🔧 功能改进
- 重构自动部署配置
## 🙏 贡献者
zzx-coder, camelliamc
---
**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0"
```
## AI Agent 执行要点
1. **自动解析 `--owner` / `--repo`**在仓库目录下执行CLI 自动从 git remote 解析
2. **始终使用 `--format json`**:所有命令加此参数,便于 AI 解析返回值
3. **时间筛选**:用上一个 Release 的 `created_at` 作为 PR/Issue 的时间筛选基线
4. **去重**PR 和 Issue 描述同一变更时合并为一条
5. **确认优先**:生成 Notes 后必须展示给用户,收到确认才执行 `release +create`
## References
- [SKILL.md](../SKILL.md) — 工作流和模板总览
- [collect-data](../references/collect-data.md) — 数据收集详细说明
- [classify-rules](../references/classify-rules.md) — 分类规则
- [generate-and-publish](../references/generate-and-publish.md) — 生成和发布

View File

@ -0,0 +1,110 @@
# 变更分类规则
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
将收集到的 commits、PRs 和 Issues 按类型归类,为生成结构化 Release Notes 做准备。
## 分类维度
变更按以下维度分类:
| 类型 | 图标 | 标题 |
|------|------|------|
| 新功能 | ✨ | 新功能 |
| Bug 修复 | 🐛 | Bug 修复 |
| 功能改进 | 🔧 | 功能改进 |
| 破坏性变更 | ⚠️ | 破坏性变更 |
| 文档 | 📝 | 文档 |
| 安全修复 | 🔒 | 安全修复 |
## 分类依据
### 按 Issue 标签分类(最可靠)
`issue +list` 返回的 `issue_tags` 字段匹配:
| Issue 标签 | 对应类型 |
|------------|----------|
| `feature`, `enhancement` | 新功能 |
| `bug`, `fix` | Bug 修复 |
| `improvement`, `optimize` | 功能改进 |
| `breaking`, `major` | 破坏性变更 |
| `docs`, `documentation` | 文档 |
| `security`, `vulnerability` | 安全修复 |
### 按 Commit 关键词分类(辅助)
`compare` API 返回的 `commit.message` 第一行匹配:
| 关键词 | 对应类型 |
|--------|----------|
| `feat:`, `add`, `新增` | 新功能 |
| `fix:`, `bugfix`, `修复` | Bug 修复 |
| `improve:`, `optimize:`, `refactor:`, `优化`, `重构` | 功能改进 |
| `BREAKING`, `breaking:`, `!` | 破坏性变更 |
| `docs:`, `doc` | 文档 |
| `security:`, `安全` | 安全修复 |
### 按 PR 标题分类(辅助)
PR 标题通常遵循 Conventional Commits 格式,按前缀匹配:
| PR 标题前缀 | 对应类型 |
|-------------|----------|
| `feat:` / `feature:` | 新功能 |
| `fix:` | Bug 修复 |
| `refactor:` / `perf:` | 功能改进 |
| `docs:` | 文档 |
## 分类优先级
1. **Issue 标签**(最准确,优先采用)
2. **PR 标题前缀**(次之)
3. **Commit 关键词**(兜底)
对于同一个变更,如果 Issue 标签和 commit 关键词都在,以 Issue 标签为准。
## 去重
以下情况会产生重复条目,需去重:
- PR 和 Issue 关联同一个变更 → 合并为一条:`变更描述 (#PR编号, #Issue编号)`
- 同一变更的多个 commit → 只保留摘要最清晰的一条
- PR 标题与关联 Issue 主题高度相似 → 合并,优先使用 Issue 的 subject
## 输出格式
分类完成后,整理为结构化数据供模板填充:
```
新功能:
- 支持批量关闭 Issue (#123)
- 新增 Wiki 管理命令 (#130)
Bug 修复:
- 修复 Windows 登录 token 存储失败 (#118)
功能改进:
- 优化 API 请求性能 (#125)
破坏性变更:
- 重构认证模块接口(不向下兼容)(#140)
文档:
- 补充分支映射说明 (#115)
```
## 贡献者收集
从 commit 和 PR 数据中提取贡献者列表:
- Commit: `author.name``author.login`
- PR: `author.login`
去重后生成贡献者名单,写入 Release Notes 末尾。
## References
- [collect-data](collect-data.md) — 数据收集步骤
- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布
- [SKILL.md](../SKILL.md) — 分类规则速查表

View File

@ -0,0 +1,84 @@
# 收集变更数据
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
收集 Release Notes 所需的三类数据版本范围、commits、PRs 和 Issues。
## 命令
### 步骤 1确定版本范围
```bash
# 获取已有 release 列表,找到上一个版本 tag
gitlink-cli release +list --format json
# 从返回的 releases 中提取最后一个 tag_name 作为 PREV_VERSION
# 用户指定或 AI 推断新版本号 NEW_VERSION
```
### 步骤 2获取 commits平台 compare API
```bash
# 获取两个 tag 之间的 commit 比较
gitlink-cli api GET /:owner/:repo/compare/{PREV_VERSION}...{NEW_VERSION} --format json
# 也可用查询参数格式
gitlink-cli api GET /v1/:owner/:repo/compare.json --query "from={PREV_VERSION}&to={NEW_VERSION}" --format json
```
返回数据包含:`commits`(提交列表含 message/author/date/sha、`total_commits`(提交总数)、`files`(变更文件)等。
### 步骤 3获取已合并的 PR
```bash
# 获取已合并的 PR 列表
gitlink-cli pr +list --state merged --format json
# 从返回的 PRs 中按 merged_at 时间筛选:
# 只保留 merged_at >= 上一个版本发布时间的 PR
```
返回数据包含:每个 PR 的 `title`、`number`、`author`、`merged_at`、`pull_request_number` 等。
### 步骤 4获取已关闭的 Issue
```bash
# 获取已关闭的 Issue 列表
gitlink-cli issue +list --state closed --format json
# 从返回的 Issues 中按 closed_at 时间筛选:
# 只保留 closed_at >= 上一个版本发布时间的 Issue
```
返回数据包含:每个 Issue 的 `subject`、`project_issues_index`、`issue_tags`(标签)、`author`、`closed_at` 等。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--format` | 否 | 始终建议 `json`,便于 AI 解析 |
| `--state` | 否 | PR: `merged`Issue: `closed` |
| `--page` | 否 | 大量数据时分页获取 |
| `--limit` | 否 | 每页条数 |
## 数据整合
收集完成后AI 整合三类数据:
1. **Commits** → 提取 commit message 第一行作为变更摘要
2. **PRs** → 用 `title``number` 生成条目:`- 功能描述 (#PR编号)`
3. **Issues** → 用 `subject``project_issues_index` 生成条目:`- Issue 描述 (#编号)`
时间筛选逻辑:从 `release +list` 获取上一个版本的发布时间,只取该时间之后的 PR/Issue。
## 注意事项
- 如果是**第一个版本**(无上一版本),只收集当前版本时间范围内的 PR/Issuecommits 用全量最近提交
- `compare` API 的 tag 需要真实存在,否则返回 404
- PR 和 Issue 的返回可能超过单页,注意分页获取全部数据
- `--owner` / `--repo` 在仓库目录下可自动解析
## References
- [classify-rules](classify-rules.md) — 收集完成后对变更进行分类
- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布
- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作

View File

@ -0,0 +1,133 @@
# 生成并发布 Release Notes
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **CRITICAL — 此为写入操作,执行前务必确认用户已审核 Release Notes 内容。**
将分类整理后的变更数据套用模板,生成 Markdown 格式的 Release Notes并发布到 GitLink。
## 模板
### 标准模板
```markdown
# 🎉 Release {VERSION}
## 📊 变更统计
- **新功能**: {FEATURE_COUNT} 个
- **Bug 修复**: {BUG_FIX_COUNT} 个
- **功能改进**: {ENHANCEMENT_COUNT} 个
- **破坏性变更**: {BREAKING_COUNT} 个
## ✨ 新功能
{FEATURES}
## 🐛 Bug 修复
{BUG_FIXES}
## 🔧 功能改进
{ENHANCEMENTS}
## ⚠️ 破坏性变更
{BREAKING_CHANGES}
## 🙏 贡献者
{CONTRIBUTORS}
---
**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION}
```
### 简化模板(适用于 patch 版本或小型发布)
```markdown
# {VERSION}
## 新增
{FEATURES}
## 修复
{BUG_FIXES}
## 改进
{ENHANCEMENTS}
## 贡献者
{CONTRIBUTORS}
[完整变更](https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION})
```
## 模板占位符说明
| 占位符 | 来源 |
|--------|------|
| `{VERSION}` | 用户指定或 AI 推断的新版本号(如 `v1.2.0` |
| `{PREV}` | `release +list` 获取的上一个版本 tag |
| `{OWNER}` | 仓库所有者,从 git remote 解析 |
| `{REPO}` | 仓库名称,从 git remote 解析 |
| `{FEATURE_COUNT}` 等 | 分类后的各类变更数量 |
| `{FEATURES}` 等 | 分类后的各类变更条目,每条一行 `- 描述 (#编号)` |
| `{CONTRIBUTORS}` | 从 commits/PRs 去重后的贡献者列表 |
## 命令
### 新建 Release
```bash
gitlink-cli release +create \
--tag v1.2.0 \
--name "v1.2.0" \
--body "<Markdown 格式的 Release Notes>"
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--tag` | **是** | 版本 tag`v1.2.0` |
| `--name` | **是** | Release 名称,通常与 tag 一致 |
| `--body` | 否 | Release Notes 正文Markdown支持多行 |
| `--target` | 否 | 目标分支,默认 `master` |
| `--prerelease` | 否 | 标记为预发布版本 |
### 更新已有 Release
```bash
gitlink-cli release +update \
--id <version_id> \
--body "<更新后的 Release Notes>"
```
> ⚠️ `release +update` 使用 `version_id`(数字 ID`release +list` 获取),不是 `tag_name`
## Workflow
> [!CAUTION]
> Release Notes 发布是 **Write Operation**,执行前必须让用户审核内容。
1. **生成** Release Notes 草稿(套用模板填充数据)
2. **展示**草稿给用户审核
3. **确认**用户同意后才执行 `release +create``release +update`
4. **报告**创建的 Release URL 给用户
## 发布前检查清单
- [ ] 版本号遵循语义化版本规范
- [ ] 变更统计与实际一致
- [ ] 破坏性变更已明确标注
- [ ] 贡献者列表完整
- [ ] 无敏感信息泄露
- [ ] 对比链接可访问
## 注意事项
- `release +create --body` 接受完整 Markdown换行和格式由模板控制
- `release +view` / `release +delete` 使用 `version_id`(数字),不是 `tag_name`
- 可用 `release +update --body` 修正已发布的 Notes
- 首个版本(无上一版本)省略对比链接
## References
- [collect-data](collect-data.md) — 收集变更数据
- [classify-rules](classify-rules.md) — 变更分类规则
- [full-workflow](../examples/full-workflow.md) — 完整端到端示例
- [gitlink-release](../../gitlink-release/SKILL.md) — Release 操作
- [release +create](../../gitlink-release/references/gitlink-release-create.md) — 创建 Release 详细参数

View File

@ -0,0 +1,256 @@
---
name: gitlink-health
version: 1.0.0
description: "项目健康度报告:统计 Issue 响应时间、PR 合并效率、贡献者活跃度。当用户需要项目健康分析、开发效率报告、团队活跃度统计时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli issue --help"
---
# gitlink-health项目健康度报告
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 工作流
健康度报告生成分四步:收集 → 计算 → 评分 → 输出。
| 步骤 | 说明 | 所用命令 |
|------|------|----------|
| 1. 收集数据 | 获取 Issueopen/closed、PRopen/merged、contributor 统计 | `issue +list`, `pr +list`, `api GET contributors` |
| 2. 计算指标 | Issue 响应时间、PR 合并效率、贡献者活跃度 | AI 解析 JSON 计算 |
| 3. 健康评分 | 100 分制综合评分,扣分项 6 条 | AI 套用评分规则 |
| 4. 输出报告 | 生成 Markdown 报告,展示给用户 | 终端预览 / Issue 发布 / 文件导出 |
## 命令参考
### 收集数据
```bash
# Issue 数据(两个状态都需要)
gitlink-cli issue +list --state open --format json
gitlink-cli issue +list --state closed --format json
# PR 数据(两个状态都需要)
gitlink-cli pr +list --state open --format json
gitlink-cli pr +list --state merged --format json
# 贡献者统计Raw API
gitlink-cli api GET /:owner/:repo/contributors --format json
# 项目活动Raw API可选
gitlink-cli api GET /:owner/:repo/activity --format json
```
## 三大指标
### 1. Issue 响应时间
支持两种口径,**推荐优先使用响应时间**(新增→已解决):
| 口径 | 计算方式 | 说明 |
|------|----------|------|
| **响应时间(推荐)** | `avg(resolved_at - created_at)` | 从创建到解决,反映实际处理效率 |
| 全周期时间 | `avg(closed_at - created_at)` | 从创建到正式关闭,反映完整生命周期 |
| 指标 | 计算方式 | 说明 |
|------|----------|------|
| 平均响应时间 | `avg(resolved_at - created_at)``avg(closed_at - created_at)` | 无 `resolved_at` 时用历史 `updated_at` 推断 |
| 中位数响应时间 | `median(...)` | 排除极端值影响 |
| Issue 积压数 | `count(open Issues)` | 当前待处理的 Issue 数量 |
| 按优先级分布 | 按 `priority_id` 分组:低(1)/正常(2)/高(3)/紧急(4) | 高优先级积压更值得关注 |
> **注意**GitLink API 不返回 `closed_at`/`resolved_at` 字段。若项目有"先解决后批量关闭"的工作流,`updated_at` 可能被关闭操作覆盖而虚高,应优先从会话历史推断解决时间。
### 2. PR 合并效率
PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**
| 时间 | 名称 | 公式 | 适用对象 |
|------|------|------|----------|
| ① | 已合并 PR 平均合并时长 | `avg(merged_at - created_at)` | status=1已合并 |
| ② | 开放 PR 平均等待时长 | `avg(now - created_at)` | status=0开放中 |
| ③ | 加权总平均处理时长 | `(merged/total) * ① + (open/total) * ②` | 已合并 开放 |
**总样本数 = 已合并数 + 开放数**(已关闭未合并的 PR 不参与时间计算,仅参与合并率分母)。
| 指标 | 计算方式 | 说明 |
|------|----------|------|
| ① 已合并平均合并时长 | `avg(merged_at - created_at)` | **仅对已合并 PR**`status=1`)计算,从创建到合并成功的时长 |
| ② 开放平均等待时长 | `avg(now - created_at)` | 开放 PR 从创建到当前的等待时长,反映积压压力 |
| ③ 加权总平均 | `(merged/total) * ① + (open/total) * ②` | 综合 PR 处理节奏的总体指标 |
| PR 积压数 | `count(open PRs)` | 当前待合并的 PR 数量 |
| 合并率 | `merged / (merged + closed)` | 已合并占所有已关闭 PR合并+关闭)的比例 |
**统计对象规则**
| PR 状态 | 计入时间计算 | 计入合并率分母 | 计入积压 |
|---------|------------|---------------|---------|
| 已合并status=1 | ① | ✅ | ❌ |
| 已关闭未合并status=2 | ❌ | ✅ | ❌ |
| 开放中status=0 | ② | ❌ | ✅ |
> **数据获取**`merged_at` 仅在 `pr +view --id <pull_request_number>` 单个 PR 详情中返回(路径 `data.pull_request.merged_at`),列表接口不暴露。
> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。开放 PR 用此公式是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。
### 3. 贡献者活跃度
| 指标 | 数据源 | 说明 |
|------|--------|------|
| 贡献者总数 | `contributors.author_count` | 项目总贡献者数 |
| 每人 commits | `contributors.authors[].commits` | 按 commit 数排名 |
| 每人增删行数 | `contributors.authors[].additions / deletions` | 代码贡献量 |
| 活跃度分级 | 综合 commits + PRs + Issues | 高频 / 正常 / 低频 |
活跃度分级标准:
| 级别 | 条件 |
|------|------|
| 🔥 高频 | 近 30 天 commits ≥ 5 或 PRs ≥ 2 |
| 🟢 正常 | 近 30 天 commits ≥ 1 或 PRs ≥ 1 |
| 🟡 低频 | 近 30 天无 commit 和 PR但有近期 Issue 活动 |
| ⚪ 不活跃 | 近 60 天无任何活动记录 |
## 健康度综合评分100 分制)
### 核心指标75 分)
| 扣分项 | 扣分 | 条件 |
|--------|------|------|
| Issue 响应慢 | -25 | Issue 平均响应时间(新增→已解决)> 3 天 |
| PR 合并慢 | -25 | PR 加权总平均处理时长 > 2 天(③) |
| 贡献者活跃度低 | -25 | 近 30 天有活跃行为的贡献者 < 2 或单一贡献者占总 commit > 70% |
### 辅助指标25 分)
| 扣分项 | 扣分 | 条件 |
|--------|------|------|
| Issue 积压严重 | -10 | open 状态 Issue 数量 > 20 |
| PR 积压严重 | -10 | open 状态 PR 数量 > 10 |
| 近期无发布 | -5 | 最近 30 天无新 Release |
评分等级:
| 分数 | 等级 | 图标 |
|------|------|------|
| 90-100 | 优秀 | 🟢 |
| 70-89 | 良好 | 🔵 |
| 50-69 | 一般 | 🟡 |
| 30-49 | 需关注 | 🟠 |
| 0-29 | 严重 | 🔴 |
## 报告模板
### 标准模板
```markdown
# 🏥 项目健康度报告
**项目**: {OWNER}/{REPO}
**报告时间**: {DATE}
**统计周期**: {PERIOD_DAYS} 天
---
## 📊 综合评分
| 评分 | 等级 |
|------|------|
| {SCORE}/100 | {GRADE_ICON} {GRADE} |
| 扣分明细 | 扣分 | 结果 |
|----------|------|------|
| Issue 积压(当前 {OPEN_ISSUES} 个) | {DEDUCTION} | {ISSUE_BACKLOG_STATUS} |
| PR 积压(当前 {OPEN_PRS} 个) | {DEDUCTION} | {PR_BACKLOG_STATUS} |
| Issue 响应时间(平均 {RESPONSE_TIME} | {DEDUCTION} | {RESPONSE_STATUS} |
| PR 合并时间(平均 {MERGE_TIME} | {DEDUCTION} | {MERGE_STATUS} |
| 贡献者集中度(最高占比 {TOP_SHARE} | {DEDUCTION} | {CONCENTRATION_STATUS} |
| 近期发布(最近 {LAST_RELEASE} | {DEDUCTION} | {RELEASE_STATUS} |
---
## 🐛 Issue 分析
| 指标 | 数值 |
|------|------|
| 全部 Issue | {TOTAL_ISSUES} |
| 已关闭 | {CLOSED_ISSUES} |
| 未关闭 | {OPEN_ISSUES} |
| 平均关闭时长 | {AVG_RESPONSE_TIME} |
| 中位数关闭时长 | {MEDIAN_RESPONSE_TIME} |
### 按优先级分布
| 优先级 | 数量 | 占比 |
|--------|------|------|
| 🔴 紧急 | {URGENT_COUNT} | {URGENT_PCT}% |
| 🟠 高 | {HIGH_COUNT} | {HIGH_PCT}% |
| 🟡 正常 | {NORMAL_COUNT} | {NORMAL_PCT}% |
| 🟢 低 | {LOW_COUNT} | {LOW_PCT}% |
---
## 🔀 PR 分析
| 指标 | 数值 |
|------|------|
| 全部 PR | {TOTAL_PRS} |
| 已合并 | {MERGED_PRS} |
| 未合并 | {OPEN_PRS} |
| 合并率 | {MERGE_RATE}% |
| 平均合并时长 | {AVG_MERGE_TIME} |
| 中位数合并时长 | {MEDIAN_MERGE_TIME} |
---
## 👥 贡献者活跃度
| 贡献者 | Commits | PRs | Issues | 增删行数 | 活跃度 |
|---------|---------|-----|--------|----------|--------|
| {NAME} | {COMMITS} | {PRS} | {ISSUES} | +{ADD}/-{DEL} | {ACTIVITY_ICON} {LEVEL} |
| ... | ... | ... | ... | ... | ... |
**总贡献者**: {TOTAL_CONTRIBUTORS} | **总 commits**: {TOTAL_COMMITS} | **总代码变更**: +{TOTAL_ADD}/-{TOTAL_DEL}
---
## 💡 改进建议
{SUGGESTIONS}
```
### 简化模板
```markdown
# 🏥 {OWNER}/{REPO} 健康度: {SCORE}/100 {GRADE}
| 指标 | 数值 | 状态 |
|------|------|------|
| Issue 响应 | avg {RESPONSE_TIME} | {RESPONSE_STATUS} |
| PR 合并 | avg {MERGE_TIME} | {MERGE_STATUS} |
| 贡献者 | {ACTIVE}/{TOTAL} 活跃 | {CONTRIBUTOR_STATUS} |
| 积压 | {OPEN_ISSUES} issues + {OPEN_PRS} PRs | {BACKLOG_STATUS} |
| 发布 | 最新 {LAST_RELEASE} | {RELEASE_STATUS} |
```
## API 注意事项
- `GET /:owner/:repo/contributors` 无 Shortcut通过 `gitlink-cli api GET` 调用
- PR list 的 `--state` 仅影响统计计数,返回列表需客户端按 `pull_request_status` 过滤
- Issue 字段名:网页编号为 `project_issues_index`,数据库 ID 为 `id`
- Issue 时间字段API **不返回**独立的 `closed_at``resolved_at`,仅有 `created_at``updated_at`。`updated_at` 是最后更新时间(可能反映解决时间或关闭时间,需根据上下文判断)
- 贡献者统计基于默认分支,不含其他分支的 commit
- `contributors` 返回 `author_count`(总数)和 `authors[]`(每人明细)
## References
- [collect-data](references/collect-data.md) — 数据收集详细说明
- [health-metrics](references/health-metrics.md) — 指标计算和评分规则
- [generate-report](references/generate-report.md) — 报告生成和输出
- [full-workflow](examples/full-workflow.md) — 完整端到端示例
- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数

View File

@ -0,0 +1,185 @@
# 项目健康度报告 — 完整生成示例
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **适用场景:** AI Agent 端到端生成项目健康度报告,以 `zzx-coder/gitlink-cli` 为例。
## 完整流程
### 第一步:收集 Issue 数据
```bash
# 获取未关闭 Issue
gitlink-cli issue +list --state open --format json
# 获取已关闭 Issue
gitlink-cli issue +list --state closed --format json
# AI 从返回中提取:
# - open count: 28
# - closed count: 1 (我们之前创建的 #29,已关闭)
# - priority 分布: 大部分为 normal (priority_id=2)
# - 平均关闭时间: #29 创建后约 1 分钟关闭(测试 issue
```
### 第二步:收集 PR 数据
```bash
# 获取未合并 PR
gitlink-cli pr +list --state open --format json
# 获取已合并 PR
gitlink-cli pr +list --state merged --format json
# AI 从返回中提取:
# - merged count: 10
# - open count: 0
# - 合并率: 10/11 = 91%
# - 最新合并 PR: #11 (2026-06-04)
# - PR 平均合并时间需逐条计算
```
### 第三步:收集贡献者统计
```bash
# Raw API 获取贡献者数据
gitlink-cli api GET /:owner/:repo/contributors --format json
# AI 从返回中提取:
# - author_count: 4+
# - 各贡献者 commit 数和增删行数
# - 集中度计算
```
### 第四步AI 计算指标
AI 根据 [指标计算规则](../references/health-metrics.md) 处理数据:
```
Issue 响应时间:
平均 0.1 天(仅 1 个已关闭 Issue样本量不足
积压 28 个 → 触发扣分
PR 合并效率:
平均合并时间 ≈ 3.5 天(估算,需 merge_at 字段)
积压 0 个 ✓
合并率 91%
贡献者活跃度:
mengcheng (camelliamc) — 🔥 高频
zzx-coder — 🔥 高频
wbtiger — 🟢 正常
wangyue789 — 🟡 低频
综合评分:
起始 100
核心指标: 0 扣分(响应时间样本不足/PR合并正常/贡献者活跃)
辅助指标: -10 Issue 积压 (28 open), -5 无近期发布 (已解决)
最终: 85/100良好 🔵)
```
### 第五步:生成报告草稿
AI 套用模板生成并展示给用户:
```markdown
# 🏥 项目健康度报告
**项目**: zzx-coder/gitlink-cli
**报告时间**: 2026-06-14
**统计周期**: 最近 30 天
---
## 📊 综合评分
| 评分 | 等级 |
|------|------|
| 85/100 | 🔵 良好 |
| 扣分明细 | 扣分 | 状态 |
|----------|------|------|
| **核心指标** | | |
| Issue 响应时间(样本不足) | 0 | ✅ 跳过 |
| PR 合并时间(平均 ~3.5 天) | 0 | ✅ 正常 |
| 贡献者活跃度2 位高频活跃) | 0 | ✅ 正常 |
| **辅助指标** | | |
| Issue 积压(当前 28 个) | -10 | ⚠️ 需关注 |
| PR 积压(当前 0 个) | 0 | ✅ 正常 |
| 近期发布v0.1.18, 今天) | 0 | ✅ 正常 |
---
## 🐛 Issue 分析
| 指标 | 数值 |
|------|------|
| 全部 Issue | 29 |
| 已关闭 | 1 |
| 待处理 | 28 |
| 平均关闭时长 | 样本不足 |
### 按优先级分布
| 优先级 | 数量 | 占比 |
|--------|------|------|
| 🔴 紧急 | 0 | 0% |
| 🟠 高 | 0 | 0% |
| 🟡 正常 | 28 | 100% |
| 🟢 低 | 0 | 0% |
---
## 🔀 PR 分析
| 指标 | 数值 |
|------|------|
| 全部 PR | 11 |
| 已合并 | 10 |
| 待合并 | 0 |
| 合并率 | 91% |
| 平均合并时长 | ~3.5 天 |
---
## 👥 贡献者活跃度
| 贡献者 | Commits | PRs | 增/删 | 活跃度 |
|---------|---------|-----|-------|--------|
| mengcheng | 120 | 5 | +8500/-3200 | 🔥 高频 |
| zzx-coder | 65 | 4 | +3200/-1800 | 🔥 高频 |
| wbtiger | 40 | 1 | +2100/-900 | 🟢 正常 |
| wangyue789 | 5 | 0 | +300/-150 | 🟡 低频 |
**总贡献者**: 4 | **总 commits**: 230+ | **代码变更**: +14100/-6050
---
## 💡 改进建议
- **Issue 积压28 个)**:建议安排 Issue Triage对 28 个 open Issue 进行分类,关闭不再需要的,优先处理高优先级 Issue
- **样本量不足**:仅 1 个已关闭 Issue无法评估响应时间。建议保持关注积累更多数据后重新评估
- **贡献者持续良好**4 位贡献者中 2 位高频活跃,项目 bus factor 健康
```
### 第六步:用户选择输出
生成后给用户三个选择:
1. 终端预览(展示即可)
2. 发布为 Issue`gitlink-cli issue +create -t "项目健康度报告 — 2026-06-14" -b "..."`
3. 导出本地文件
## AI Agent 执行要点
1. **数据收集顺序**:先收集 Issue 和 PRShortcut 命令),再收集 contributorsRaw API避免一次性大量 API 调用
2. **分页处理**Issue 和 PR 数量超过单页限制时,用 `--page` 逐页获取
3. **时间计算**ISO 8601 格式解析优先Unix 时间戳更可靠但 PR 的 `pr_created_unix` 仅部分返回
4. **指标计算容错**:样本不足时标注而非报错,新项目可能仅有少量数据
5. **评分可按需调整**:新项目无 Release 时,"无近期发布"项自动跳过
6. **避免 GIGO**:数据异常时(如极长的响应时间),标注并排除 outlier
## References
- [SKILL.md](../SKILL.md) — 工作流和模板总览
- [collect-data](../references/collect-data.md) — 数据收集详细说明
- [health-metrics](../references/health-metrics.md) — 指标计算和评分规则
- [generate-report](../references/generate-report.md) — 报告生成和输出

View File

@ -0,0 +1,122 @@
# 收集健康度数据
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
收集项目健康度报告所需的四类数据Issue、PR、贡献者和项目活动。
## 命令
### 步骤 1收集 Issue 数据
```bash
# 获取未关闭 Issue用于统计积压
gitlink-cli issue +list --state open --format json
# 获取已关闭 Issue用于计算响应时间
gitlink-cli issue +list --state closed --format json
```
Issue 返回的关键字段:
| 字段 | 用途 |
|------|------|
| `project_issues_index` | Issue 编号 |
| `subject` | Issue 标题 |
| `status_id` | 状态1=新增, 2=正在解决, 3=已解决, 5=关闭 |
| `priority_id` | 优先级1=低, 2=正常, 3=高, 4=紧急 |
| `created_at` | 创建时间 (ISO 8601) |
| `closed_at` | 关闭时间(仅已关闭 Issue 有此字段) |
| `author.login` | 创建者 |
| `assigners[]` | 负责人列表 |
### 步骤 2收集 PR 数据
```bash
# 获取未合并 PR用于统计积压
gitlink-cli pr +list --state open --format json
# 获取已合并 PR用于计算合并效率
gitlink-cli pr +list --state merged --format json
```
PR 返回的关键字段:
| 字段 | 用途 |
|------|------|
| `pull_request_number` | PR 编号 |
| `name` (title) | PR 标题 |
| `pull_request_status` | 状态0=open, 1=merged, 2=closed |
| `author_login` | 作者 |
| `pr_full_time` | 创建时间 (ISO 8601) |
| `pr_merged_at` | 合并时间 |
| `pr_created_unix` | 创建时间 (Unix timestamp) |
> ⚠️ `--state` 参数仅影响 `merged_count`/`open_count`/`closed_count` 汇总计数API 返回的 issues 列表可能包含所有状态的 PR。需按 `pull_request_status` 客户端过滤。
### 步骤 3收集贡献者统计
```bash
# 获取贡献者统计Raw API无 Shortcut
gitlink-cli api GET /:owner/:repo/contributors --format json
```
返回关键字段:
| 字段 | 用途 |
|------|------|
| `author_count` | 总贡献者数 |
| `commit_count` | 总 commit 数 |
| `commit_count_in_all_branches` | 全部分支的 commit 总数 |
| `additions` / `deletions` | 总增删行数 |
| `authors[]` | 每位贡献者的明细 |
每位贡献者(`authors[]`)字段:
| 字段 | 用途 |
|------|------|
| `login` / `name` | 贡献者 ID 和昵称 |
| `commits` | commit 数量 |
| `additions` / `deletions` | 增删行数 |
### 步骤 4收集项目活动可选
```bash
# 获取项目活动 feed
gitlink-cli api GET /:owner/:repo/activity --format json
```
用于补充近期事件issue 创建/关闭、PR 创建/合并的时间线)。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--format` | 否 | 始终建议 `json`,便于 AI 解析 |
| `--state` | 否 | Issue: `open`/`closed`PR: `open`/`merged`/`closed` |
| `--page` | 否 | 大量数据时分页获取 |
| `--limit` | 否 | 每页条数 |
## 数据覆盖范围
数据收集覆盖以下时间范围:
- **Issue**: 所有未关闭 + 近期已关闭(默认取最近 100 条)
- **PR**: 所有未合并 + 近期已合并(默认取最近 100 条)
- **Contributors**: 项目全量历史数据
- **Activity**: 最近 30 天
对于大型项目,可通过 `--page` 分页获取更多数据。
## 注意事项
- 计算响应时间需要 Issue 有 `closed_at` 字段,仅已关闭 Issue 才会返回此字段
- PR 合并时间需通过 `pr_full_time` 与当前时间对比估算,或结合 PM `/weekly_issues` API
- `contributors` 端点统计基于默认分支(通常为 `master`),不含其他分支
- `--owner` / `--repo` 在仓库目录下可自动解析
- 时间计算使用 Unix 时间戳(`pr_created_unix`)比解析字符串格式(`pr_full_time`)更可靠
## References
- [health-metrics](health-metrics.md) — 收集完成后进行指标计算
- [generate-report](generate-report.md) — 生成报告并输出
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数

View File

@ -0,0 +1,158 @@
# 生成并输出健康度报告
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
将计算的指标和评分套用模板,生成 Markdown 格式的健康度报告通过终端、Issue 或文件三种方式输出。
## 输出方式
### 方式 1终端预览默认
直接在对话中展示 Markdown 报告,用户确认后决定是否持久化。
### 方式 2创建报告 Issue需认证
将报告发布为项目的 Review Issue便于团队讨论和跟踪
```bash
gitlink-cli issue +create \
-t "项目健康度报告 — {DATE}" \
-b "<Markdown 格式的完整报告>" \
--label 文档
```
> ⚠️ 此为 Write Operation创建前必须确认用户意图。
### 方式 3导出 Markdown 文件(本地)
AI Agent 将报告内容写入本地文件:
```
health_reports/health_report_{DATE}.md
```
## 模板
### 标准模板
```markdown
# 🏥 项目健康度报告
**项目**: {OWNER}/{REPO}
**报告时间**: {DATE}
**统计周期**: 最近 {PERIOD_DAYS} 天
---
## 📊 综合评分
| 评分 | 等级 |
|------|------|
| {SCORE}/100 | {GRADE_ICON} {GRADE} |
| 扣分明细 | 扣分 | 状态 |
|----------|------|------|
| Issue 积压(当前 {OPEN_ISSUES} 个) | -{DEDUCTION} | {ISSUE_BACKLOG_STATUS} |
| PR 积压(当前 {OPEN_PRS} 个) | -{DEDUCTION} | {PR_BACKLOG_STATUS} |
| Issue 响应时间(平均 {RESPONSE_TIME} | -{DEDUCTION} | {RESPONSE_STATUS} |
| PR 合并时间(平均 {MERGE_TIME} | -{DEDUCTION} | {MERGE_STATUS} |
| 贡献者集中度(最高占比 {TOP_SHARE} | -{DEDUCTION} | {CONCENTRATION_STATUS} |
| 近期发布(最新 {LAST_RELEASE} | -{DEDUCTION} | {RELEASE_STATUS} |
---
## 🐛 Issue 分析
| 指标 | 数值 |
|------|------|
| 全部 Issue | {TOTAL_ISSUES} |
| 已关闭 | {CLOSED_ISSUES} |
| 待处理 | {OPEN_ISSUES} |
| 平均关闭时长 | {AVG_RESPONSE_TIME} |
| 中位数关闭时长 | {MEDIAN_RESPONSE_TIME} |
### 按优先级分布
| 优先级 | 数量 | 占比 |
|--------|------|------|
| 🔴 紧急 | {URGENT_COUNT} | {URGENT_PCT}% |
| 🟠 高 | {HIGH_COUNT} | {HIGH_PCT}% |
| 🟡 正常 | {NORMAL_COUNT} | {NORMAL_PCT}% |
| 🟢 低 | {LOW_COUNT} | {LOW_PCT}% |
---
## 🔀 PR 分析
| 指标 | 数值 |
|------|------|
| 全部 PR | {TOTAL_PRS} |
| 已合并 | {MERGED_PRS} |
| 待合并 | {OPEN_PRS} |
| 合并率 | {MERGE_RATE}% |
| 平均合并时长 | {AVG_MERGE_TIME} |
| 中位数合并时长 | {MEDIAN_MERGE_TIME} |
---
## 👥 贡献者活跃度
| 贡献者 | Commits | PRs | Issues | 增/删 | 活跃度 |
|---------|---------|-----|--------|-------|--------|
| {NAME} | {COMMITS} | {PRS} | {ISSUES} | +{ADD}/-{DEL} | {ACTIVITY_ICON} {LEVEL} |
**总贡献者**: {TOTAL_CONTRIBUTORS} | **总 commits**: {TOTAL_COMMITS} | **代码变更**: +{TOTAL_ADD}/-{TOTAL_DEL}
---
## 💡 改进建议
{SUGGESTIONS}
---
*报告由 [gitlink-health skill](...) 自动生成*
```
## 占位符说明
| 占位符 | 来源 |
|--------|------|
| `{OWNER}` / `{REPO}` | git remote 解析 |
| `{DATE}` | 当前日期 |
| `{PERIOD_DAYS}` | 数据覆盖天数(默认 30 |
| `{SCORE}` | 综合评分0-100 |
| `{GRADE}` / `{GRADE_ICON}` | 评分等级 + 图标 |
| `{OPEN_ISSUES}` | 未关闭 Issue 数量 |
| `{CLOSED_ISSUES}` | 已关闭 Issue 数量 |
| `{AVG_RESPONSE_TIME}` / `{MEDIAN_RESPONSE_TIME}` | Issue 响应时间 |
| `{OPEN_PRS}` | 未合并 PR 数量 |
| `{MERGED_PRS}` | 已合并 PR 数量 |
| `{AVG_MERGE_TIME}` / `{MEDIAN_MERGE_TIME}` | PR 合并时间 |
| `{MERGE_RATE}` | PR 合并率(% |
| `{TOTAL_CONTRIBUTORS}` | 总贡献者数 |
| `{TOP_SHARE}` | 最高单贡献者占比 |
| `{LAST_RELEASE}` | 最近一次 Release 时间或 "无" |
| `{SUGGESTIONS}` | AI 生成的改进建议列表 |
## Workflow
1. **计算**所有指标和评分(参见 [health-metrics](health-metrics.md)
2. **填充**标准模板,生成 Markdown 报告
3. **展示**报告草稿给用户审核
4. **确认**用户选择输出方式(终端 / Issue / 文件)
5. 如需发布为 Issue确认后执行 `issue +create`
## 注意事项
- 报告的时间范围默认取最近 30 天,用户可指定自定义范围
- 若项目无 Release如新项目扣分项"无近期发布"不适用,总分自动调整
- 贡献者活跃度需结合近 30 天的活动时间线判断
- 改进建议应具体、可执行,避免空泛描述
- 如果数据量不足(如新项目仅有少量 Issue/PR在报告中标注"样本量小,仅供参考"
## References
- [collect-data](collect-data.md) — 收集项目数据
- [health-metrics](health-metrics.md) — 指标计算和评分规则
- [full-workflow](../examples/full-workflow.md) — 完整端到端示例
- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数

View File

@ -0,0 +1,246 @@
# 健康度指标计算
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
基于收集到的数据,计算 Issue 响应时间、PR 合并效率、贡献者活跃度三大指标,并给出综合健康评分。
## 指标 1Issue 响应时间
### 两种评价方式
Issue 响应时间支持两种计算口径AI Agent 可根据数据可用性和项目工作流选择:
| 口径 | 计算方式 | 含义 | 适用场景 |
|------|----------|------|----------|
| **响应时间** | `resolved_at - created_at` | 从创建到解决的时间 | 衡量团队对 Issue 的实际响应速度 |
| **全周期时间** | `closed_at - created_at` | 从创建到正式关闭的时间 | 衡量 Issue 的完整生命周期 |
**推荐优先使用响应时间**(新增→已解决),因为它反映真实处理效率。全周期时间受"解决后批量关闭"等工作流影响,可能虚高。
### 计算所需数据
`issue +list --state closed` 返回的 Issue 列表中提取:
- `created_at` — 创建时间
- `updated_at` — 最后更新时间(当无独立 `closed_at`/`resolved_at` 时的降级代理字段)
- `status_id` — 状态1=新增, 2=正在解决, 3=已解决, 5=关闭
- `status_name` — 状态名称(辅助判断)
> **注意**GitLink API 不直接返回 `closed_at``resolved_at` 字段。AI Agent 需根据上下文推断:
> - 若 Issue 的 `status_id=5`(关闭)且 `updated_at` 在近期批量操作中突变,则 `updated_at` 反映的是关闭时间而非解决时间,应尝试从历史会话或其他来源获取解决时间
> - 若项目工作流为"解决即关闭"(单步),则 `updated_at` 同时代表解决和关闭时间,可直接使用
### 计算公式
```
# 方式 A响应时间推荐
time_to_resolve = resolved_at - created_at
avg_response = sum(time_to_resolve) / count
median_response = sorted(time_to_resolve)[count / 2]
# 方式 B全周期时间
time_to_close = closed_at - created_at
avg_close = sum(time_to_close) / count
median_close = sorted(time_to_close)[count / 2]
```
### 阈值
| 指标 | 优秀 | 良好 | 需改进 |
|------|------|------|--------|
| 平均响应/关闭时间 | ≤ 2 天 | ≤ 7 天 | > 7 天 |
| 中位数响应/关闭时间 | ≤ 1 天 | ≤ 5 天 | > 5 天 |
| Issue 积压数 | ≤ 10 | ≤ 20 | > 20 |
### 时间计算注意
- 时间字段为 ISO 8601 格式(如 `"2026-06-04 14:45"`),需解析后求差值
- 无 `closed_at` 的 Issueopen 状态)不计入响应时间,计入积压统计
- 当两种口径结果差异显著时(如响应时间 1.5 天 vs 全周期 15 天),以响应时间为评分依据,在全周期时间处标注"受工作流影响"
## 指标 2PR 合并效率
### 三段时间计算
PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**
| 时间编号 | 名称 | 公式 | 适用对象 |
|----------|------|------|----------|
| ① | 已合并 PR 平均合并时长 | `avg(merged_at - created_at)` | status=1已合并 |
| ② | 开放 PR 平均等待时长 | `avg(now - created_at)` | status=0开放中 |
| ③ | 加权总平均处理时长 | 见下方公式 | status=1 status=0 |
### 计算公式
```
# ① 已合并 PR 平均合并时长
time_merged_i = merged_at_i - created_at_i
avg_merged = sum(time_merged_i) / merged_count
# ② 开放 PR 平均等待时长
time_open_i = now - created_at_i
avg_open = sum(time_open_i) / open_count
# ③ 加权总平均处理时长
total_count = merged_count + open_count
weight_merged = merged_count / total_count
weight_open = open_count / total_count
weighted_total = weight_merged * avg_merged + weight_open * avg_open
```
### 状态分类
| 状态 | 含义 | 是否计入 | 计入哪类 |
|------|------|---------|---------|
| `pull_request_status=1` | 已合并 | ✅ | ①已合并 |
| `pull_request_status=0` | 开放中 | ✅ | ②开放等待 |
| `pull_request_status=2` | 已关闭(未合并) | ❌ | 不参与时间计算,但参与合并率分母 |
> **设计依据**
> - 已合并 PR 已有"完成时间"merged_at用真实合并耗时
> - 开放 PR 尚未合并,"等待时长"= 从创建到当前(持续增长中),反映积压压力
> - 加权平均综合体现项目处理 PR 的整体节奏,比单一指标更准确
### 合并率(独立指标)
```
merge_rate = merged_count / (merged_count + closed_count) × 100%
```
仅使用「已合并」与「已关闭未合并」作为分母,开放 PR 不参与。
### 阈值
| 指标 | 优秀 | 良好 | 需改进 |
|------|------|------|--------|
| ① 已合并 PR 平均合并时长 | ≤ 1 小时 | ≤ 1 天 | > 1 天 |
| ② 开放 PR 平均等待时长 | ≤ 1 天 | ≤ 7 天 | > 7 天 |
| **③ 加权总平均处理时长** | **≤ 6 小时** | **≤ 2 天** | **> 2 天** |
| PR 积压数 | ≤ 3 | ≤ 10 | > 10 |
| 合并率 | ≥ 90% | ≥ 70% | < 70% |
> **可调阈值**:此表为默认值。不同项目工作流差异大(如 fork 端预审型 vs 上游社区 PR 型),可在 `health-metrics.md` 中按项目特征调整。
### 数据可用性
| 字段 | 来源命令 | 路径 |
|------|---------|------|
| `created_at` | `pr +list` | `data.issues[].pr_full_time``pr_created_unix` |
| `merged_at` | `pr +view --id <pull_request_number>` | `data.pull_request.merged_at` |
> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。已合并 PR 必须用真实的 `merged_at`
> 开放 PR 用「当前时间 - 创建时间」是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。
### 数据可用性
PR 合并时间**只与 PR 自身时间相关**(创建时间 + 合并时间),**与当前时间无关**。
实际数据源:
| 字段 | 来源命令 | 路径 |
|------|---------|------|
| `created_at` | `pr +list --state merged` | `data.issues[].pr_full_time``pr_created_unix` |
| `merged_at` | `pr +view --id <pull_request_id>` | `data.pull_request.merged_at` |
**注意**`merged_at` 只在 `pr +view` 单个 PR 详情中暴露,列表接口不返回。需要对每个已合并 PR 调用一次详情接口。
> **绝对禁止**:使用「当前时间 - 创建时间」作为合并时间估算。这种做法会把开放中或近期合并的 PR 误判为"超长合并时间",与 PR 合并效率的真实含义相悖。
## 指标 3贡献者活跃度
### 计算所需数据
`GET /:owner/:repo/contributors` 返回:
- `authors[].login` — 贡献者登录名
- `authors[].commits` — 提交数
- `authors[].additions / deletions` — 增删行数
### 活跃度分级
| 级别 | 图标 | 条件 |
|------|------|------|
| 高频活跃 | 🔥 | 近 30 天 commits ≥ 5 或 PRs ≥ 2 |
| 正常活跃 | 🟢 | 近 30 天 commits ≥ 1 或 PRs ≥ 1 |
| 低频活跃 | 🟡 | 近 30 天无 commit 但有 Issue 活动 |
| 不活跃 | ⚪ | 近 60 天无任何贡献活动 |
### 贡献者集中度
```
top_contributor_share = max(authors[].commits) / total_commits × 100%
```
集中度 > 70% 视为过度集中bus factor 低,有单点风险)。
## 综合健康评分100 分制)
### 计分规则
起始分 **100 分**逐项扣分。核心三指标Issue 响应时间、PR 合并效率、贡献者活跃度)占 75 分,辅助指标占 25 分。
### 核心指标75 分)
| 扣分项 | 扣分 | 触发条件 | 说明 |
|--------|------|----------|------|
| Issue 响应慢 | -25 | `avg_response > 7天`(使用响应时间口径,即新增→已解决) | Issue 平均解决时间超过一周 |
| PR 合并慢 | -25 | `avg_merge > 1天` | PR 平均合并时间超过一天 |
| 贡献者活跃度低 | -25 | `active_contributors < 2``top_contributor_share > 70%` | 活跃贡献者过少或过度依赖单一贡献者 |
### 辅助指标25 分)
| 扣分项 | 扣分 | 触发条件 | 说明 |
|--------|------|----------|------|
| Issue 积压 | -10 | `open Issues > 20` | 待处理 Issue 数量过多 |
| PR 积压 | -10 | `open PRs > 10` | 待合并 PR 数量过多 |
| 无近期发布 | -5 | `latest_release > 30天` | 近 30 天无新发布 |
### 评分等级
| 分数 | 等级 | 图标 | 说明 |
|------|------|------|------|
| 90-100 | 优秀 | 🟢 | 项目运转非常健康 |
| 70-89 | 良好 | 🔵 | 整体正常,有小问题 |
| 50-69 | 一般 | 🟡 | 需要关注多项指标 |
| 30-49 | 需关注 | 🟠 | 存在明显瓶颈 |
| 0-29 | 严重 | 🔴 | 需要立即干预 |
### 改进建议生成
根据扣分项自动生成改进建议:
| 扣分项 | 改进建议 |
|--------|----------|
| Issue 积压 | 建议安排 Issue Triage优先处理高优先级 Issue |
| PR 积压 | 建议增加 Code Review 资源,缩短 PR 等待时间 |
| Issue 响应慢 | 建议建立 Issue 处理 SLA落实责任人 |
| PR 合并慢 | 建议设 PR 合并时效目标(如 48 小时内) |
| 贡献者集中 | 建议鼓励多人参与核心模块,避免单点风险 |
| 无近期发布 | 建议建立定期发布节奏(如每 2 周发一次) |
## 输出格式
计算完成后,整理为结构化数据供模板填充:
```
综合评分: 70/100良好 🟡)
核心扣分: Issue 响应慢 -25 (avg 8天), 贡献者活跃度低 -25 (仅1位活跃)
辅助扣分: Issue 积压 -10 (当前 25 个)
Issue 响应时间:
平均 4.2 天, 中位数 2.1 天, 积压 25 个
PR 合并效率:
平均 1.8 天, 中位数 0.9 天, 积压 12 个, 合并率 85%
贡献者活跃度:
总贡献者 5, 总 commits 247
前三: mengcheng(120), zzx-coder(65), wbtiger(40)
集中度: mengcheng 占 48.6%(正常)
```
## References
- [collect-data](collect-data.md) — 数据收集步骤
- [generate-report](generate-report.md) — 报告生成和输出
- [SKILL.md](../SKILL.md) — 评分规则速查表

View File

@ -58,16 +58,7 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审
**场景**:从提交历史自动生成版本发布说明。
```bash
# 1. 获取两个版本之间的提交
gitlink-cli api GET /:owner/:repo/compare/:base...:head --format json
# 2. 获取已关闭的 Issue
gitlink-cli issue +list --state closed --format json
# 3. 生成 Release Notes 并创建发布
gitlink-cli release +create --tag v1.2.0 --name "v1.2.0" --body "## What's Changed\n- feat: 新功能 (#123)\n- fix: 修复问题 (#456)"
```
> 此工作流已独立为 [`gitlink-changelog`](../gitlink-changelog/SKILL.md) skill包含完整的数据收集、分类规则、模板和发布流程。详见该 skill 的 [references/](../gitlink-changelog/references/) 和 [examples/](../gitlink-changelog/examples/)。
## 工作流 4Repo Setup仓库初始化