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:
parent
c7a3224c79
commit
0da45def2b
|
|
@ -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
9
go.mod
|
|
@ -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
19
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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#表结构)。
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# 开源项目健康度报告
|
||||
|
||||
## Issue 解决时长
|
||||
- 已关闭 Issue 数:x
|
||||
- 平均天数:x,中位数:x,最短:x 天,最长:x 天
|
||||
- 解决时长分布:1天内 x,1周内 x,1个月内 x
|
||||
|
||||
## PR 合并效率
|
||||
- 合并率:x%
|
||||
- 总数:x,已合并:x,开放:x,已关闭:x
|
||||
|
||||
## 贡献者活跃度
|
||||
- 活跃贡献者(最近一个月):x
|
||||
- 新增贡献者(最近一个月):x
|
||||
- TOP 贡献者(按 PR 数排名):user1 x, user2 x, ...
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# Ignore runtime-generated SQLite database files
|
||||
*.db
|
||||
|
|
@ -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.id(nullable)
|
||||
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.id(nullable,来自 list 的 assign_user_login)
|
||||
create_time TIMESTAMP, -- 创建时间,ISO 格式
|
||||
close_time TIMESTAMP, -- 始终为 NULL(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`。
|
||||
|
||||
## 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天内 x,1周内 x,1个月内 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 个字段,不允许省略任何一个。**
|
||||
Loading…
Reference in New Issue