gitlink-health 开源项目健康度 Skill 贡献 #17

Merged
wbtiger merged 4 commits from yingjie/gitlink-cli:master into master 2026-06-03 23:49:27 +08:00
15 changed files with 1314 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

11
go.mod
View File

@ -5,16 +5,27 @@ go 1.26.1
require (
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
)
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
)

51
go.sum
View File

@ -4,17 +4,31 @@ 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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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,12 +44,49 @@ 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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
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/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
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/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

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

@ -0,0 +1,108 @@
package health
import (
"fmt"
"net/url"
"os"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// v1RepoPath constructs the v1 API path for a repository.
// Issue operations use the v1 API (/v1/{owner}/{repo}) to match shortcuts/issue conventions,
// while PR operations use the v2 API via ctx.RepoPath() (/{owner}/{repo}/pulls) to match shortcuts/pr conventions.
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{}, error) {
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.Fprintf(os.Stderr, " CLI error: pr +list state=%s page=%d: %v\n", state, page, err)
return nil, err
}
if !env.OK {
err := fmt.Errorf("API error: pr +list state=%s page=%d", state, page)
fmt.Fprintf(os.Stderr, " %v\n", err)
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response type for pr +list")
}
issues, _ := data["issues"].([]interface{})
return issues, nil
}
// fetchPRDetail retrieves full PR detail to extract merged_at timestamp.
// The list API doesn't return merged_at; only the detail endpoint does.
func fetchPRDetail(ctx *common.RuntimeContext, prNumber int) (string, error) {
path := fmt.Sprintf("%s/pulls/%d", ctx.RepoPath(), prNumber)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("PR detail fetch #%d: %w", prNumber, err)
}
if !env.OK {
return "", fmt.Errorf("PR detail API error #%d", prNumber)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", nil
}
// Try multiple possible locations for merged_at in the detail response
for _, candidate := range []string{
"merged_at",
"mergedAt",
} {
// data.pull_request.merged_at
if pr, _ := data["pull_request"].(map[string]interface{}); pr != nil {
if v, _ := pr[candidate].(string); v != "" {
return v, nil
}
}
// data.issue.merged_at
if issue, _ := data["issue"].(map[string]interface{}); issue != nil {
if v, _ := issue[candidate].(string); v != "" {
return v, nil
}
}
// data.merged_at (flat)
if v, _ := data[candidate].(string); v != "" {
return v, nil
}
}
return "", nil
}
func fetchIssueListPage(ctx *common.RuntimeContext, owner, repo, state string, page, limit int) ([]interface{}, error) {
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.Fprintf(os.Stderr, " CLI error: issue +list state=%s page=%d: %v\n", state, page, err)
return nil, err
}
if !env.OK {
err := fmt.Errorf("API error: issue +list state=%s page=%d", state, page)
fmt.Fprintf(os.Stderr, " %v\n", err)
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response type for issue +list")
}
issues, _ := data["issues"].([]interface{})
return issues, nil
}

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

@ -0,0 +1,266 @@
package health
import (
"database/sql"
_ "embed"
"fmt"
"os"
_ "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
}
if _, err := db.Exec("INSERT OR IGNORE INTO users (user_name) VALUES (?)", username); err != nil {
return 0, fmt.Errorf("insert user %q: %w", username, err)
}
var id int
err := db.QueryRow("SELECT id FROM users WHERE user_name = ?", username).Scan(&id)
if err != nil {
return 0, fmt.Errorf("get user %q: %w", username, err)
}
return id, 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 getOrCreateTag(db *sql.DB, repoID int, tagName string) (int, error) {
if _, err := db.Exec("INSERT OR IGNORE INTO tags (repo_id, name) VALUES (?, ?)", repoID, tagName); err != nil {
return 0, fmt.Errorf("insert tag %q for repo %d: %w", tagName, repoID, err)
}
var id int
err := db.QueryRow("SELECT id FROM tags WHERE repo_id = ? AND name = ?", repoID, tagName).Scan(&id)
if err != nil {
return 0, fmt.Errorf("get tag %q for repo %d: %w", tagName, repoID, err)
}
return id, nil
}
func savePullTags(db *sql.DB, pullID int, tagIDs []int) {
if len(tagIDs) == 0 {
return
}
for _, tagID := range tagIDs {
if _, err := db.Exec("INSERT OR IGNORE INTO pull_tags (pull_id, tag_id) VALUES (?, ?)", pullID, tagID); err != nil {
fmt.Fprintf(os.Stderr, " DB error: save pull_tags (pull=%d tag=%d): %v\n", pullID, tagID, err)
}
}
}
func saveIssueTags(db *sql.DB, issueID int, tagIDs []int) {
if len(tagIDs) == 0 {
return
}
for _, tagID := range tagIDs {
if _, err := db.Exec("INSERT OR IGNORE INTO issue_tags (issue_id, tag_id) VALUES (?, ?)", issueID, tagID); err != nil {
fmt.Fprintf(os.Stderr, " DB error: save issue_tags (issue=%d tag=%d): %v\n", issueID, tagID, err)
}
}
}
func extractTagNames(data map[string]interface{}, key string) []string {
rawTags, ok := data[key].([]interface{})
if !ok {
return nil
}
var names []string
for _, item := range rawTags {
tag, ok := item.(map[string]interface{})
if !ok {
continue
}
if name, ok := tag["name"].(string); ok && name != "" {
names = append(names, name)
}
}
return names
}
// mergeTimeFromList tries to extract merged_at directly from the list API response
// (preferred, avoids an extra API call per PR).
func mergeTimeFromList(pr map[string]interface{}) string {
for _, key := range []string{"merged_at", "mergedAt", "pr_merge_time", "merge_time"} {
if v, _ := pr[key].(string); v != "" {
return v
}
// Some APIs return numeric timestamps
if v, ok := pr[key].(float64); ok && v > 0 {
return fmt.Sprintf("%.0f", v)
}
}
return ""
}
func savePull(db *sql.DB, repoID int, pr map[string]interface{}, mergedAt string) {
// 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
}
// Priority: explicit mergedAt arg > list response field > nil
var mergedAtVal interface{} = nil
if mergedAt != "" {
mergedAtVal = mergedAt
} else if ma := mergeTimeFromList(pr); ma != "" {
mergedAtVal = ma
}
if _, err := db.Exec(`INSERT OR REPLACE INTO pulls (id, repo_id, number, creater_id, status, processor_id, create_time, merged_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
int(prID), repoID, int(prNumber), createrID, status, processorID, createTime, mergedAtVal); err != nil {
fmt.Fprintf(os.Stderr, " DB error: save pull %d: %v\n", int(prID), err)
}
// Save tags
tagNames := extractTagNames(pr, "issue_tags")
var tagIDs []int
for _, name := range tagNames {
if tid, err := getOrCreateTag(db, repoID, name); err == nil {
tagIDs = append(tagIDs, tid)
}
}
savePullTags(db, int(prID), tagIDs)
}
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 == "关闭" || statusName == "Closed" {
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"
}
if _, err := 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); err != nil {
fmt.Fprintf(os.Stderr, " DB error: save issue %d: %v\n", int(issueID), err)
}
// Save tags
tagNames := extractTagNames(issue, "tags")
var tagIDs []int
for _, name := range tagNames {
if tid, err := getOrCreateTag(db, repoID, name); err == nil {
tagIDs = append(tagIDs, tid)
}
}
saveIssueTags(db, int(issueID), tagIDs)
}

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

@ -0,0 +1,226 @@
package health
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"golang.org/x/sync/errgroup"
"golang.org/x/time/rate"
)
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
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)
}
limiter := rate.NewLimiter(rate.Limit(1.0/0.6), 5)
s := &sharedState{
seenPRs: make(map[int]bool),
seenIssues: make(map[int]bool),
}
fmt.Fprintf(os.Stderr, "\n=== Fetching Pull Requests ===\n")
fmt.Fprintf(os.Stderr, "=== Fetching Issues ===\n")
g, egCtx := errgroup.WithContext(context.Background())
for _, state := range []string{"open", "closed", "merged"} {
state := state
g.Go(func() error {
return fetchPRs(ctx, db, repoID, state, maxPages, limiter, egCtx, s)
})
}
for _, state := range []string{"open", "closed"} {
state := state
g.Go(func() error {
return fetchIssues(ctx, db, repoID, state, maxPages, limiter, egCtx, s)
})
}
if err := g.Wait(); err != nil {
return err
}
fmt.Fprintf(os.Stderr, " Total PRs: %d\n", s.prCount)
fmt.Fprintf(os.Stderr, " Total Issues: %d\n", s.issueCount)
fmt.Fprintf(os.Stderr, "\nData saved to %s\n", dbPath)
return nil
},
},
}
}
type sharedState struct {
mu sync.Mutex
seenPRs map[int]bool
seenIssues map[int]bool
prCount int
issueCount int
}
func fetchPRs(ctx *common.RuntimeContext, db *sql.DB, repoID int, state string, maxPages int, limiter *rate.Limiter, egCtx context.Context, s *sharedState) error {
page := 1
for maxPages == 0 || page <= maxPages {
if egCtx.Err() != nil {
return nil
}
if err := limiter.Wait(egCtx); err != nil {
return nil
}
fmt.Fprintf(os.Stderr, " PR list: state=%s, page=%d...\n", state, page)
prs, err := fetchPRListPage(ctx, state, page, 20)
if err != nil {
return err
}
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
}
s.mu.Lock()
dup := s.seenPRs[prID]
if !dup {
s.seenPRs[prID] = true
s.prCount++
}
s.mu.Unlock()
if dup {
continue
}
// For merged PRs: try list response first, fall back to detail API
mergedAt := ""
if state == "merged" {
mergedAt = mergeTimeFromList(pr)
if mergedAt == "" {
if prNumFloat, ok := pr["pull_request_number"].(float64); ok && prNumFloat > 0 {
prNum := int(prNumFloat)
if err := limiter.Wait(egCtx); err != nil {
return nil
}
fmt.Fprintf(os.Stderr, " PR detail #%d...\n", prNum)
if ma, err := fetchPRDetail(ctx, prNum); err != nil {
fmt.Fprintf(os.Stderr, " PR detail #%d: %v\n", prNum, err)
} else {
mergedAt = ma
}
}
}
}
savePull(db, repoID, pr, mergedAt)
}
if len(prs) < 20 {
break
}
page++
}
return nil
}
func fetchIssues(ctx *common.RuntimeContext, db *sql.DB, repoID int, state string, maxPages int, limiter *rate.Limiter, egCtx context.Context, s *sharedState) error {
page := 1
for maxPages == 0 || page <= maxPages {
if egCtx.Err() != nil {
return nil
}
if err := limiter.Wait(egCtx); err != nil {
return nil
}
fmt.Fprintf(os.Stderr, " Issue list: state=%s, page=%d...\n", state, page)
issues, err := fetchIssueListPage(ctx, ctx.Owner, ctx.Repo, state, page, 20)
if err != nil {
return err
}
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 {
continue
}
s.mu.Lock()
dup := s.seenIssues[int(issueID)]
if !dup {
s.seenIssues[int(issueID)] = true
s.issueCount++
}
s.mu.Unlock()
if dup {
continue
}
issueNumber := int(issueID)
if v, ok := issue["project_issues_index"].(float64); ok && v > 0 {
issueNumber = int(v)
}
listUpdatedAt, _ := issue["updated_at"].(string)
saveIssue(db, repoID, issue, issueNumber, listUpdatedAt)
}
if len(issues) < 20 {
break
}
page++
}
return nil
}

View File

@ -0,0 +1,84 @@
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,
merged_at 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_merged_at ON pulls(merged_at);
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);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (repo_id) REFERENCES repos(id),
UNIQUE(repo_id, name)
);
CREATE INDEX IF NOT EXISTS idx_tags_repo_id ON tags(repo_id);
CREATE TABLE IF NOT EXISTS issue_tags (
issue_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (issue_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_issue_tags_issue_id ON issue_tags(issue_id);
CREATE INDEX IF NOT EXISTS idx_issue_tags_tag_id ON issue_tags(tag_id);
CREATE TABLE IF NOT EXISTS pull_tags (
pull_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (pull_id) REFERENCES pulls(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (pull_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_pull_tags_pull_id ON pull_tags(pull_id);
CREATE INDEX IF NOT EXISTS idx_pull_tags_tag_id ON pull_tags(tag_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

@ -14,6 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "pr", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"health",
}
groupSet := map[string]bool{}

View File

@ -112,6 +112,15 @@ skills/
│ └── SKILL.md # Pipeline 操作指南
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
├── gitlink-health/ # 项目健康度分析
│ ├── SKILL.md # 健康度分析指南
│ ├── data/
│ │ ├── .gitignore # 忽略 *.db 文件
│ │ └── .gitkeep # 占位文件
│ ├── 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,448 @@
# 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 格式
merged_at TIMESTAMP, -- 合并时间(从 PR 详情 API 获取)
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``merged_at` 需要调用 PR 详情 API`GET /:owner/:repo/pulls/:number`)获取。
### tags
```sql
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (repo_id) REFERENCES repos(id),
UNIQUE(repo_id, name)
);
```
API 字段映射:`name` ← `name`,先调用 `GET /:owner/:repo/tags` 批量写入,后续 PR/Issue 保存时只做关联查询。
### issue_tags
```sql
CREATE TABLE issue_tags (
issue_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (issue_id, tag_id)
);
```
关系表:一个 Issue 可关联多个 tag。写入来源Issue detail 中的 `tags` 数组。
### pull_tags
```sql
CREATE TABLE pull_tags (
pull_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (pull_id) REFERENCES pulls(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (pull_id, tag_id)
);
```
关系表:一个 PR 可关联多个 tag。写入来源PR list 中的 `issue_tags` 数组。
## 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
);
```
### 标签列表
```sql
SELECT name FROM tags WHERE repo_id = <repo_id> ORDER BY name;
```
### 按标签统计 Issue 数量
```sql
SELECT t.name, COUNT(*) as issue_count
FROM tags t
JOIN issue_tags it ON t.id = it.tag_id
JOIN issues i ON it.issue_id = i.id
WHERE t.repo_id = <repo_id>
GROUP BY t.id
ORDER BY issue_count DESC;
```
### 按标签统计 PR 数量
```sql
SELECT t.name, COUNT(*) as pr_count
FROM tags t
JOIN pull_tags pt ON t.id = pt.tag_id
JOIN pulls p ON pt.pull_id = p.id
WHERE t.repo_id = <repo_id>
GROUP BY t.id
ORDER BY pr_count DESC;
```
### 查询某 Issue 的所有标签
```sql
SELECT t.name
FROM tags t
JOIN issue_tags it ON t.id = it.tag_id
WHERE it.issue_id = <issue_id>;
```
### 查询某 PR 的所有标签
```sql
SELECT t.name
FROM tags t
JOIN pull_tags pt ON t.id = pt.tag_id
WHERE pt.pull_id = <pull_id>;
```
---
## 报告组装清单
**按以下顺序逐项执行查询、填入模板。每完成一项打勾,最终输出前核对所有 ✅ 是否齐全。**
- [ ] **已关闭 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 个字段,不允许省略任何一个。**