feat(shortcut): rewrite gitlink-health as a Go shortcut

Migrate health analysis from Python scripts to a native Go shortcut
for better performance and single-binary distribution. Remove the
Python collector (fetcher.py) and move schema.sql into shortcuts/health/.
This commit is contained in:
Yingjie Shang 2026-05-25 11:21:15 +08:00
parent c7a3224c79
commit 0da45def2b
14 changed files with 968 additions and 0 deletions

View File

@ -596,6 +596,7 @@ See [skills/README.md](skills/README.md) for details.
| `gitlink-user` | User management (profile info, etc.) |
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) |
## Project Structure

9
go.mod
View File

@ -11,10 +11,19 @@ require (
require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.42.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.1 // indirect
)

19
go.sum
View File

@ -4,17 +4,27 @@ github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMF
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -30,6 +40,7 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
@ -39,3 +50,11 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

80
shortcuts/health/api.go Normal file
View File

@ -0,0 +1,80 @@
package health
import (
"fmt"
"net/url"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(owner, repo string) string {
return fmt.Sprintf("/v1/%s/%s", owner, repo)
}
func fetchPRListPage(ctx *common.RuntimeContext, state string, page, limit int) ([]interface{}, map[string]interface{}) {
q := url.Values{}
q.Set("page", fmt.Sprintf("%d", page))
q.Set("limit", fmt.Sprintf("%d", limit))
if state != "" {
q.Set("state", state)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil {
fmt.Printf(" CLI error: pr +list state=%s page=%d: %v\n", state, page, err)
return nil, nil
}
if !env.OK {
fmt.Printf(" API error: pr +list state=%s page=%d\n", state, page)
return nil, nil
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, nil
}
issues, _ := data["issues"].([]interface{})
return issues, data
}
func fetchIssueListPage(ctx *common.RuntimeContext, owner, repo, state string, page, limit int) ([]interface{}, map[string]interface{}) {
q := url.Values{}
q.Set("page", fmt.Sprintf("%d", page))
q.Set("limit", fmt.Sprintf("%d", limit))
if state != "" {
q.Set("state", state)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(owner, repo)+"/issues", q)
if err != nil {
fmt.Printf(" CLI error: issue +list state=%s page=%d: %v\n", state, page, err)
return nil, nil
}
if !env.OK {
fmt.Printf(" API error: issue +list state=%s page=%d\n", state, page)
return nil, nil
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, nil
}
issues, _ := data["issues"].([]interface{})
return issues, data
}
func fetchIssueDetail(ctx *common.RuntimeContext, owner, repo string, issueID int) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%d", v1RepoPath(owner, repo), issueID), nil)
if err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("API error fetching issue %d", issueID)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response format for issue %d", issueID)
}
return data, nil
}
func sleep() {
time.Sleep(300 * time.Millisecond)
}

168
shortcuts/health/db.go Normal file
View File

@ -0,0 +1,168 @@
package health
import (
"database/sql"
_ "embed"
"fmt"
_ "modernc.org/sqlite"
)
//go:embed schema.sql
var schemaSQL string
// extractLogin extracts a login string from either a nested object {"login": "..."} or a flat string field.
func extractLogin(data map[string]interface{}, objKey, flatKey string) string {
if obj, ok := data[objKey].(map[string]interface{}); ok {
if login, _ := obj["login"].(string); login != "" {
return login
}
}
if login, _ := data[flatKey].(string); login != "" {
return login
}
return ""
}
// extractStatusName extracts the status name from either a nested object {"name": "关闭"} or an issue_status string.
func extractStatusName(data map[string]interface{}) string {
if obj, ok := data["status"].(map[string]interface{}); ok {
if name, _ := obj["name"].(string); name != "" {
return name
}
}
if name, _ := data["issue_status"].(string); name != "" {
return name
}
return ""
}
func openDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("set WAL mode: %w", err)
}
if _, err := db.Exec(schemaSQL); err != nil {
db.Close()
return nil, fmt.Errorf("init schema: %w", err)
}
return db, nil
}
func getOrCreateUser(db *sql.DB, username string) (int, error) {
if username == "" {
return 0, nil
}
var id int
err := db.QueryRow("SELECT id FROM users WHERE user_name = ?", username).Scan(&id)
if err == nil {
return id, nil
}
res, err := db.Exec("INSERT INTO users (user_name) VALUES (?)", username)
if err != nil {
return 0, fmt.Errorf("insert user %q: %w", username, err)
}
lastID, _ := res.LastInsertId()
return int(lastID), nil
}
func getOrCreateRepo(db *sql.DB, repoName, owner string) (int, error) {
ownerID, err := getOrCreateUser(db, owner)
if err != nil {
return 0, err
}
var id int
err = db.QueryRow("SELECT id FROM repos WHERE repo_name = ? AND owner_id = ?", repoName, ownerID).Scan(&id)
if err == nil {
return id, nil
}
res, err := db.Exec("INSERT INTO repos (repo_name, owner_id) VALUES (?, ?)", repoName, ownerID)
if err != nil {
return 0, fmt.Errorf("insert repo %s/%s: %w", owner, repoName, err)
}
lastID, _ := res.LastInsertId()
return int(lastID), nil
}
func savePull(db *sql.DB, repoID int, pr map[string]interface{}) {
// id: pull_request_id preferred, fallback to id
var prID float64
if v, ok := pr["pull_request_id"].(float64); ok && v > 0 {
prID = v
} else if v, ok := pr["id"].(float64); ok {
prID = v
} else {
return
}
prNumber, _ := pr["pull_request_number"].(float64)
if prNumber == 0 {
return
}
author, _ := pr["author_login"].(string)
createrID, _ := getOrCreateUser(db, author)
statusCode := 0
if v, ok := pr["pull_request_status"].(float64); ok {
statusCode = int(v)
}
statusMap := map[int]string{0: "open", 1: "merged", 2: "closed"}
status := statusMap[statusCode]
if status == "" {
status = "open"
}
createTime, _ := pr["pr_full_time"].(string)
var processorID *int
if assignee, _ := pr["assign_user_login"].(string); assignee != "" {
pid, _ := getOrCreateUser(db, assignee)
processorID = &pid
}
db.Exec(`INSERT OR REPLACE INTO pulls (id, repo_id, number, creater_id, status, processor_id, create_time, close_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
int(prID), repoID, int(prNumber), createrID, status, processorID, createTime, nil)
}
func saveIssue(db *sql.DB, repoID int, issue map[string]interface{}, issueNumber int, listUpdatedAt string) {
issueID, ok := issue["id"].(float64)
if !ok || issueID == 0 {
return
}
createrID, _ := getOrCreateUser(db, extractLogin(issue, "author", "author_login"))
var processorID *int
if login := extractLogin(issue, "assign_user", "assign_user_login"); login != "" {
pid, _ := getOrCreateUser(db, login)
processorID = &pid
}
createTime, _ := issue["created_at"].(string)
statusName := extractStatusName(issue)
var status string
var closeTime interface{}
if statusName == "关闭" {
status = "close"
if v, _ := issue["closed_on"].(string); v != "" {
closeTime = v
} else if v, _ := issue["updated_at"].(string); v != "" {
closeTime = v
} else if listUpdatedAt != "" {
closeTime = listUpdatedAt
}
} else {
status = "open"
}
db.Exec(`INSERT OR REPLACE INTO issues (id, repo_id, number, creater_id, processor_id, create_time, close_time, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
int(issueID), repoID, issueNumber, createrID, processorID, createTime, closeTime, status)
}

164
shortcuts/health/health.go Normal file
View File

@ -0,0 +1,164 @@
package health
import (
"fmt"
"os"
"path/filepath"
"strconv"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return []*common.Shortcut{
{
Name: "fetch",
Description: "Fetch PR and Issue data into SQLite for health analysis",
Flags: []common.Flag{
{Name: "db", Short: "d", Usage: "SQLite database path (default: ~/.agents/skills/gitlink-health/data/gitlink_health.db)"},
{Name: "max-pages", Short: "M", Usage: "Maximum pages per query (default: unlimited)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// maxPages: 0 means unlimited
maxPages := 0
if v := ctx.Arg("max-pages"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
maxPages = n
}
}
dbPath := ctx.Arg("db")
if dbPath == "" {
home, _ := os.UserHomeDir()
dbPath = filepath.Join(home, ".agents", "skills", "gitlink-health", "data", "gitlink_health.db")
}
fmt.Fprintf(os.Stderr, "\nFetching data for %s/%s...\n", ctx.Owner, ctx.Repo)
db, err := openDB(dbPath)
if err != nil {
return err
}
defer db.Close()
repoID, err := getOrCreateRepo(db, ctx.Repo, ctx.Owner)
if err != nil {
return fmt.Errorf("create repo: %w", err)
}
// ── Fetch PRs ──
fmt.Fprintf(os.Stderr, "\n=== Fetching Pull Requests ===\n")
seenPRIDs := make(map[int]bool)
var prAgg map[string]interface{}
for _, state := range []string{"open", "closed", "merged"} {
page := 1
for maxPages == 0 || page <= maxPages {
fmt.Fprintf(os.Stderr, " PR list: state=%s, page=%d...\n", state, page)
prs, agg := fetchPRListPage(ctx, state, page, 20)
if agg != nil {
prAgg = agg
}
if len(prs) == 0 {
break
}
for _, item := range prs {
pr, ok := item.(map[string]interface{})
if !ok {
continue
}
var prID int
if v, ok := pr["pull_request_id"].(float64); ok && v > 0 {
prID = int(v)
} else if v, ok := pr["id"].(float64); ok {
prID = int(v)
} else {
continue
}
if seenPRIDs[prID] {
continue
}
seenPRIDs[prID] = true
savePull(db, repoID, pr)
}
if len(prs) < 20 {
break
}
page++
sleep()
}
}
fmt.Fprintf(os.Stderr, " Total PRs: %d\n", len(seenPRIDs))
if prAgg != nil {
fmt.Fprintf(os.Stderr, " API aggregates: total=%v, merged=%v, open=%v, closed=%v\n",
prAgg["search_count"], prAgg["merged_issues_size"],
prAgg["open_count"], prAgg["close_count"])
}
// ── Fetch Issues ──
fmt.Fprintf(os.Stderr, "\n=== Fetching Issues ===\n")
seenIssueIDs := make(map[int]bool)
var issueAgg map[string]interface{}
for _, state := range []string{"open", "closed"} {
page := 1
for maxPages == 0 || page <= maxPages {
fmt.Fprintf(os.Stderr, " Issue list: state=%s, page=%d...\n", state, page)
issues, agg := fetchIssueListPage(ctx, ctx.Owner, ctx.Repo, state, page, 20)
if agg != nil {
issueAgg = agg
}
if len(issues) == 0 {
break
}
for _, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
issueID, ok := issue["id"].(float64)
if !ok || seenIssueIDs[int(issueID)] {
continue
}
seenIssueIDs[int(issueID)] = true
// Get issue number (project_issues_index) from list item
issueNumber := int(issueID)
if v, ok := issue["project_issues_index"].(float64); ok && v > 0 {
issueNumber = int(v)
}
detail, err := fetchIssueDetail(ctx, ctx.Owner, ctx.Repo, int(issueID))
if err != nil {
fmt.Fprintf(os.Stderr, " Error fetching issue %d detail: %v\n", int(issueID), err)
continue
}
listUpdatedAt, _ := issue["updated_at"].(string)
saveIssue(db, repoID, detail, issueNumber, listUpdatedAt)
}
if len(issues) < 20 {
break
}
page++
sleep()
}
}
fmt.Fprintf(os.Stderr, " Total Issues: %d\n", len(seenIssueIDs))
if issueAgg != nil {
fmt.Fprintf(os.Stderr, " API aggregates: total=%v, open=%v, closed=%v\n",
issueAgg["all_count"], issueAgg["open_count"], issueAgg["close_count"])
}
fmt.Fprintf(os.Stderr, "\nData saved to %s\n", dbPath)
return nil
},
},
}
}

View File

@ -0,0 +1,51 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_name TEXT NOT NULL,
owner_id INTEGER NOT NULL,
FOREIGN KEY (owner_id) REFERENCES users(id),
UNIQUE(repo_name, owner_id)
);
CREATE TABLE IF NOT EXISTS issues (
id INTEGER PRIMARY KEY,
repo_id INTEGER NOT NULL,
number INTEGER,
creater_id INTEGER,
processor_id INTEGER,
create_time TIMESTAMP,
close_time TIMESTAMP,
status TEXT CHECK(status IN ('close', 'open')),
FOREIGN KEY (repo_id) REFERENCES repos(id),
FOREIGN KEY (creater_id) REFERENCES users(id),
FOREIGN KEY (processor_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id);
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
CREATE INDEX IF NOT EXISTS idx_issues_create_time ON issues(create_time);
CREATE INDEX IF NOT EXISTS idx_issues_close_time ON issues(close_time);
CREATE INDEX IF NOT EXISTS idx_issues_creater_id ON issues(creater_id);
CREATE TABLE IF NOT EXISTS pulls (
id INTEGER PRIMARY KEY,
repo_id INTEGER NOT NULL,
number INTEGER NOT NULL,
creater_id INTEGER,
status TEXT CHECK(status IN ('merged', 'closed', 'open')),
processor_id INTEGER,
create_time TIMESTAMP,
close_time TIMESTAMP,
FOREIGN KEY (repo_id) REFERENCES repos(id),
FOREIGN KEY (creater_id) REFERENCES users(id),
FOREIGN KEY (processor_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_pulls_repo_id ON pulls(repo_id);
CREATE INDEX IF NOT EXISTS idx_pulls_status ON pulls(status);
CREATE INDEX IF NOT EXISTS idx_pulls_create_time ON pulls(create_time);
CREATE INDEX IF NOT EXISTS idx_pulls_creater_id ON pulls(creater_id);

View File

@ -8,6 +8,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
"github.com/gitlink-org/gitlink-cli/shortcuts/health"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
@ -45,6 +46,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"webhook": webhook.Shortcuts(tr),
"health": health.Shortcuts(tr),
"workflow": workflow.Shortcuts(),
}
@ -64,6 +66,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"webhook": tr.T("cmd.webhook.short"),
"health": "Project health data collection",
"workflow": "AI agent workflow analysis",
}

View File

@ -112,6 +112,15 @@ skills/
│ └── SKILL.md # Pipeline 操作指南
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
├── gitlink-health/ # 项目健康度分析
│ ├── SKILL.md # 健康度分析指南
│ ├── collector/
│ │ ├── fetcher.py # 数据采集脚本
│ │ └── schema.sql # 建表 SQL
│ ├── references/
│ │ └── queries.md # SQL 查询参考
│ └── asset/
│ └── health_report_template.md # 报告模板
└── gitlink-workflow/ # AI 自动化工作流
└── SKILL.md # 工作流模板Issue 分类、PR Review、Release Notes
```
@ -143,6 +152,7 @@ skills/
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
| **gitlink-health** | 开源项目健康度 | 详情见SKILL.md |
---

View File

@ -0,0 +1,88 @@
---
name: gitlink-health
version: 1.0.1
description: "项目健康度分析(专用工作流):采集仓库 PR/Issue 数据到 SQLite 并计算聚合指标,生成健康度报告。当用户提到「项目怎么样」「项目健康度」「项目报告」「项目状况」「项目分析」「项目整体情况」等综合分析意图时,必须使用本 skill不要拆分为 repo/issue/pr 单独操作。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli health +fetch --help"
---
# gitlink-health开源项目健康度分析技能
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
## 何时使用 / 何时跳过
**必须使用本 skill不要拆分为 repo/issue/pr的场景**
- 用户说"项目怎么样""健康度""项目报告""项目状况""项目整体情况"
- 用户要求综合分析一个项目的 PR、Issue 等多项指标
- 用户要生成任何形式的项目评估报告
**跳过本 skill使用底层 skill的场景**
- 用户明确只操作仓库信息(查看分支、提交等)→ `gitlink-repo`
- 用户明确只操作 Issue创建、查看、关闭等`gitlink-issue`
- 用户明确只操作 PR创建、合并、查看 diff 等)→ `gitlink-pr`
## 目录结构
```
- `SKILL.md`: 本文件
- `references/queries.md`: SQL 查询参考与执行指南
- `asset/health_report_template.md`: 报告模板
- `data/gitlink_health.db`: SQLite 数据库
```
## 前置条件
首先确保已完成认证:
```bash
gitlink-cli auth login # 交互式登录(推荐)
# 或
export GITLINK_TOKEN="your-token" # 非交互环境设置 Token
```
可通过 `gitlink-cli auth status` 验证登录状态。
## 工作流程
### 1. 采集仓库数据
运行以下命令采集目标仓库的数据:
```bash
gitlink-cli health +fetch --owner OWNER --repo REPO
```
不传 `--owner`/`--repo` 时会自动从当前目录的 git remote 推断。命令运行后会自动生成 `~/.agents/skills/gitlink-health/data/gitlink_health.db` 文件。
### 2. 查询指标
读取 [references/queries.md](references/queries.md),按其中说明确定目标仓库的 `repo_id`,再逐条执行指标查询。
### 3. 生成报告
按照 [references/queries.md 底部的「报告组装清单」](references/queries.md#报告组装清单)逐项执行查询并填入 [asset/health_report_template.md](asset/health_report_template.md)。**必须逐项打勾核对,输出前确认报告包含全部 14 个模板字段,不允许省略任何一项。**
## 命令参考
### health +fetch
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `--owner` | 仓库所有者 | 自动从 git remote 推断 |
| `--repo` | 仓库名称 | 自动从 git remote 推断 |
| `--db` / `-d` | SQLite 数据库路径 | `~/.agents/skills/gitlink-health/data/gitlink_health.db` |
| `--max-pages` / `-M` | 最大页数(不限为全量) | 不限 |
## 数据库表
| 表名 | 用途 | 支持的指标 |
|------|------|-----------|
| `users` | 用户信息user_name | - |
| `repos` | 仓库信息repo_name, owner_id | - |
| `issues` | Issue 数据 | Issue 解决时长、状态分布 |
| `pulls` | PR 数据 | PR 合并率、贡献者活跃度 |
详细表结构与 API 字段映射规则见 [references/queries.md 表结构章节](references/queries.md#表结构)。

View File

@ -0,0 +1,15 @@
# 开源项目健康度报告
## Issue 解决时长
- 已关闭 Issue 数x
- 平均天数x中位数x最短x 天最长x 天
- 解决时长分布1天内 x1周内 x1个月内 x
## PR 合并效率
- 合并率x%
- 总数x已合并x开放x已关闭x
## 贡献者活跃度
- 活跃贡献者最近一个月x
- 新增贡献者最近一个月x
- TOP 贡献者(按 PR 数排名user1 x, user2 x, ...

2
skills/gitlink-health/data/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# Ignore runtime-generated SQLite database files
*.db

View File

View File

@ -0,0 +1,358 @@
# SQL 查询参考
所有查询使用 SQLite通过命令行逐条执行。将 `<repo_id>` 等占位符替换为实际值。
**每个查询都标注了它对应报告模板中的哪些字段。按照[底部的组装清单](#报告组装清单)逐项执行,确保不遗漏。**
## 表结构
### users
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL UNIQUE
);
```
### repos
```sql
CREATE TABLE repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_name TEXT NOT NULL,
owner_id INTEGER NOT NULL,
FOREIGN KEY (owner_id) REFERENCES users(id),
UNIQUE(repo_name, owner_id)
);
```
### issues
```sql
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
repo_id INTEGER NOT NULL,
number INTEGER, -- 仓库内 Issue 编号 (project_issues_index)
creater_id INTEGER, -- 创建者 → users.id
processor_id INTEGER, -- 处理人 → users.idnullable
create_time TIMESTAMP, -- 创建时间,格式 "YYYY-MM-DD HH:MM"
close_time TIMESTAMP, -- 关闭时间nullable
status TEXT CHECK(status IN ('close', 'open')),
FOREIGN KEY (repo_id) REFERENCES repos(id),
FOREIGN KEY (creater_id) REFERENCES users(id),
FOREIGN KEY (processor_id) REFERENCES users(id)
);
```
API 字段映射:`creater_id` ← `author.login`detail`author_login`list`status` ← `status.name`detail等于"关闭"→close。
### pulls
```sql
CREATE TABLE pulls (
id INTEGER PRIMARY KEY,
repo_id INTEGER NOT NULL,
number INTEGER NOT NULL, -- PR 编号
creater_id INTEGER, -- 创建者 → users.id
status TEXT CHECK(status IN ('merged', 'closed', 'open')),
processor_id INTEGER, -- 指派人 → users.idnullable来自 list 的 assign_user_login
create_time TIMESTAMP, -- 创建时间ISO 格式
close_time TIMESTAMP, -- 始终为 NULLAPI 不提供)
FOREIGN KEY (repo_id) REFERENCES repos(id),
FOREIGN KEY (creater_id) REFERENCES users(id),
FOREIGN KEY (processor_id) REFERENCES users(id)
);
```
API 字段映射:`status` ← `pull_request_status`0=open, 1=merged, 2=closed`creater_id` ← `author_login`
## 1. 确定目标仓库
数据库可能包含多个仓库的数据,查询前需先获取目标仓库的 `repo_id`
```bash
# 列出所有仓库
sqlite3 data/gitlink_health.db "SELECT r.id, u.user_name AS owner, r.repo_name FROM repos r JOIN users u ON r.owner_id = u.id;"
# 根据 owner/repo 精确查找
sqlite3 data/gitlink_health.db "SELECT r.id FROM repos r JOIN users u ON r.owner_id = u.id WHERE u.user_name = '<owner>' AND r.repo_name = '<repo>';"
```
## 2. Issue 解决时长
### 2.1 单个 Issue 解决时长(天)
*(补充查询,报告模板不使用)*
```sql
SELECT
i.id,
i.number,
(julianday(i.close_time) - julianday(i.create_time)) as resolution_days
FROM issues i
WHERE i.id = <issue_id>
AND i.status = 'close';
```
### 2.2 已关闭 Issue 数 + 平均/中位数/最短/最长解决天数
**对应模板字段:`已关闭 Issue 数`、`平均天数`、`中位数`、`最短`、`最长`**
```sql
SELECT
COUNT(*) as closed_issues_count,
AVG(resolution_days) as avg_days,
-- 中位数:排序后取中间值
(SELECT resolution_days FROM (
SELECT (julianday(close_time) - julianday(create_time)) as resolution_days,
ROW_NUMBER() OVER (ORDER BY julianday(close_time) - julianday(create_time)) as rn,
COUNT(*) OVER () as cnt
FROM issues
WHERE repo_id = <repo_id>
AND status = 'close'
AND close_time IS NOT NULL
AND create_time IS NOT NULL
) WHERE rn IN ((cnt + 1) / 2, (cnt + 2) / 2)
ORDER BY rn LIMIT 1) as median_days,
MIN(resolution_days) as min_days,
MAX(resolution_days) as max_days
FROM (
SELECT (julianday(close_time) - julianday(create_time)) as resolution_days
FROM issues
WHERE repo_id = <repo_id>
AND status = 'close'
AND close_time IS NOT NULL
AND create_time IS NOT NULL
);
```
### 2.3 解决时长分布
**对应模板字段:`解决时长分布1天内 x1周内 x1个月内 x`**
```sql
SELECT
CASE
WHEN resolution_days < 1 THEN '< 1天'
WHEN resolution_days < 3 THEN '1-3天'
WHEN resolution_days < 7 THEN '3-7天'
WHEN resolution_days < 30 THEN '7-30天'
ELSE '> 30天'
END as duration_range,
COUNT(*) as issue_count
FROM (
SELECT (julianday(close_time) - julianday(create_time)) as resolution_days
FROM issues
WHERE repo_id = <repo_id>
AND status = 'close'
AND close_time IS NOT NULL
AND create_time IS NOT NULL
)
GROUP BY duration_range
ORDER BY MIN(resolution_days);
```
> 填入模板时:`1天内` = `'< 1天'` 的数量,`1周内` = `'1-3天' + '3-7天'` 的数量之和,`1个月内` = `'7-30天'` 的数量。
### 2.4 按时间段统计 Issue 解决时长
*(补充查询,报告模板不使用)*
```sql
SELECT
strftime('%Y-%m', create_time) as month,
AVG(julianday(close_time) - julianday(create_time)) as avg_resolution_days,
COUNT(*) as closed_count
FROM issues
WHERE repo_id = <repo_id>
AND status = 'close'
AND close_time IS NOT NULL
AND create_time IS NOT NULL
GROUP BY month
ORDER BY month;
```
## 3. PR 合并效率
### 3.1 PR 合并率(整体)
**对应模板字段:`合并率`、`总数`、`已合并`、`开放`、`已关闭`**
```sql
SELECT
CAST(SUM(CASE WHEN status = 'merged' THEN 1 ELSE 0 END) AS REAL) /
NULLIF(COUNT(*), 0) as merge_rate,
COUNT(*) as total_prs,
SUM(CASE WHEN status = 'merged' THEN 1 ELSE 0 END) as merged_count,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed_count,
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count
FROM pulls
WHERE repo_id = <repo_id>;
```
> 填入模板时:`合并率` = `merge_rate * 100`(保留整数百分比)。
### 3.2 按时间段统计 PR 合并率
*(补充查询,报告模板不使用)*
```sql
SELECT
strftime('%Y-%m', create_time) as month,
CAST(SUM(CASE WHEN status = 'merged' THEN 1 ELSE 0 END) AS REAL) /
NULLIF(COUNT(*), 0) as merge_rate,
COUNT(*) as total_prs
FROM pulls
WHERE repo_id = <repo_id>
GROUP BY month
ORDER BY month;
```
### 3.3 PR 关闭率
*(补充查询,报告模板不使用)*
```sql
SELECT
CAST(COUNT(CASE WHEN status IN ('closed', 'merged') THEN 1 END) AS REAL) /
NULLIF(COUNT(*), 0) as closure_rate
FROM pulls
WHERE repo_id = <repo_id>;
```
## 4. 贡献者活跃度
### 4.1 活跃贡献者数量
**对应模板字段:`活跃贡献者(最近一个月)`**
```sql
SELECT
COUNT(DISTINCT creater_id) as active_contributors
FROM pulls
WHERE repo_id = <repo_id>
AND create_time BETWEEN '<start_date>' AND '<end_date>';
```
> `<start_date>` 设为 30 天前的日期,`<end_date>` 设为当天。
### 4.2 新贡献者(首次 PR 在最近一个月内)
**对应模板字段:`新增贡献者(最近一个月)`**
```sql
SELECT
u.user_name,
MIN(p.create_time) as first_pr_time
FROM pulls p
JOIN users u ON p.creater_id = u.id
WHERE p.repo_id = <repo_id>
GROUP BY p.creater_id
HAVING first_pr_time BETWEEN '<start_date>' AND '<end_date>';
```
> `<start_date>` 同上设为 30 天前。查询结果的行数 = 新增贡献者数user_name 列表可附在报告中。
### 4.3 TOP 贡献者(按 PR 数排名)
**对应模板字段:`TOP 贡献者(按 PR 数排名)`**
```sql
SELECT
u.user_name,
COUNT(*) as pr_count
FROM pulls p
JOIN users u ON p.creater_id = u.id
WHERE p.repo_id = <repo_id>
GROUP BY p.creater_id
ORDER BY pr_count DESC;
```
> 取前 5~10 名填入模板,格式:`user1 x, user2 x, ...`
### 4.4 指定时间段内贡献者活跃度
*(补充查询,报告模板不使用)*
```sql
SELECT
u.user_name,
COUNT(*) as pr_count
FROM pulls p
JOIN users u ON p.creater_id = u.id
WHERE p.repo_id = <repo_id>
AND p.create_time BETWEEN '<start_date>' AND '<end_date>'
GROUP BY p.creater_id
ORDER BY pr_count DESC;
```
### 4.5 贡献者月度活跃度趋势
*(补充查询,报告模板不使用)*
```sql
SELECT
strftime('%Y-%m', p.create_time) as month,
u.user_name,
COUNT(*) as pr_count
FROM pulls p
JOIN users u ON p.creater_id = u.id
WHERE p.repo_id = <repo_id>
GROUP BY month, p.creater_id
ORDER BY month, pr_count DESC;
```
## 5. 补充查询
*(以下查询不对应报告模板字段,用于数据验证或扩展分析)*
### Issue 状态汇总
```sql
SELECT status, COUNT(*) as count
FROM issues WHERE repo_id = <repo_id>
GROUP BY status;
```
### PR 状态汇总
```sql
SELECT status, COUNT(*) as count
FROM pulls WHERE repo_id = <repo_id>
GROUP BY status;
```
### 贡献者总数
```sql
SELECT COUNT(DISTINCT creater_id) as total_contributors
FROM (
SELECT creater_id FROM pulls WHERE repo_id = <repo_id> AND creater_id IS NOT NULL
UNION
SELECT creater_id FROM issues WHERE repo_id = <repo_id> AND creater_id IS NOT NULL
);
```
---
## 报告组装清单
**按以下顺序逐项执行查询、填入模板。每完成一项打勾,最终输出前核对所有 ✅ 是否齐全。**
- [ ] **已关闭 Issue 数** → 执行 2.2,取 `closed_issues_count`
- [ ] **平均天数** → 执行 2.2,取 `avg_days`(保留 1 位小数)
- [ ] **中位数** → 执行 2.2,取 `median_days`(保留 1 位小数)
- [ ] **最短 x 天** → 执行 2.2,取 `min_days`(保留 1 位小数)
- [ ] **最长 x 天** → 执行 2.2,取 `max_days`(保留 1 位小数)
- [ ] **解决时长分布** → 执行 2.3汇总1天内 = `'< 1天'`1周内 = `1-3天 + 3-7天`1个月内 = `7-30天`
- [ ] **合并率 x%** → 执行 3.1,取 `merge_rate * 100`(整数)
- [ ] **总数** → 执行 3.1,取 `total_prs`
- [ ] **已合并** → 执行 3.1,取 `merged_count`
- [ ] **开放** → 执行 3.1,取 `open_count`
- [ ] **已关闭** → 执行 3.1,取 `closed_count`
- [ ] **活跃贡献者(最近一个月)** → 执行 4.1,设 start_date = 30天前
- [ ] **新增贡献者(最近一个月)** → 执行 4.2,设 start_date = 30天前
- [ ] **TOP 贡献者** → 执行 4.3,取前 5~10 名
**输出前核对:报告必须包含上述全部 14 个字段,不允许省略任何一个。**