diff --git a/README.md b/README.md index 396012b..8c4a012 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod index 19ce345..476d9ee 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index af5e2c6..bd8c592 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/shortcuts/health/api.go b/shortcuts/health/api.go new file mode 100644 index 0000000..2d9f81b --- /dev/null +++ b/shortcuts/health/api.go @@ -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 +} diff --git a/shortcuts/health/db.go b/shortcuts/health/db.go new file mode 100644 index 0000000..78ea7e8 --- /dev/null +++ b/shortcuts/health/db.go @@ -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) +} diff --git a/shortcuts/health/health.go b/shortcuts/health/health.go new file mode 100644 index 0000000..04f26d1 --- /dev/null +++ b/shortcuts/health/health.go @@ -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 +} diff --git a/shortcuts/health/schema.sql b/shortcuts/health/schema.sql new file mode 100644 index 0000000..6535d18 --- /dev/null +++ b/shortcuts/health/schema.sql @@ -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); diff --git a/shortcuts/register.go b/shortcuts/register.go index 3c814e0..aff8cc1 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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", } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index e3f1351..e232fbe 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -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{} diff --git a/skills/README.md b/skills/README.md index c7ed276..f7e10ea 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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 | --- diff --git a/skills/gitlink-health/SKILL.md b/skills/gitlink-health/SKILL.md new file mode 100644 index 0000000..18e0abc --- /dev/null +++ b/skills/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#表结构)。 \ No newline at end of file diff --git a/skills/gitlink-health/asset/health_report_template.md b/skills/gitlink-health/asset/health_report_template.md new file mode 100644 index 0000000..ebe4b0f --- /dev/null +++ b/skills/gitlink-health/asset/health_report_template.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, ... diff --git a/skills/gitlink-health/data/.gitignore b/skills/gitlink-health/data/.gitignore new file mode 100644 index 0000000..3f07df5 --- /dev/null +++ b/skills/gitlink-health/data/.gitignore @@ -0,0 +1,2 @@ +# Ignore runtime-generated SQLite database files +*.db diff --git a/skills/gitlink-health/data/.gitkeep b/skills/gitlink-health/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/skills/gitlink-health/references/queries.md b/skills/gitlink-health/references/queries.md new file mode 100644 index 0000000..bda9d54 --- /dev/null +++ b/skills/gitlink-health/references/queries.md @@ -0,0 +1,448 @@ +# SQL 查询参考 + +所有查询使用 SQLite,通过命令行逐条执行。将 `` 等占位符替换为实际值。 + +**每个查询都标注了它对应报告模板中的哪些字段。按照[底部的组装清单](#报告组装清单)逐项执行,确保不遗漏。** + +## 表结构 + +### 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 格式 + 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 = '' AND r.repo_name = '';" +``` + +## 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 = + 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 = + 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 = + 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 = + 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 = + 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 = ; +``` + +> 填入模板时:`合并率` = `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 = +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 = ; +``` + +## 4. 贡献者活跃度 + +### 4.1 活跃贡献者数量 + +**对应模板字段:`活跃贡献者(最近一个月)`** + +```sql +SELECT + COUNT(DISTINCT creater_id) as active_contributors +FROM pulls +WHERE repo_id = + AND create_time BETWEEN '' AND ''; +``` + +> `` 设为 30 天前的日期,`` 设为当天。 + +### 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 = +GROUP BY p.creater_id +HAVING first_pr_time BETWEEN '' AND ''; +``` + +> `` 同上设为 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 = +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 = + AND p.create_time BETWEEN '' AND '' +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 = +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 = +GROUP BY status; +``` + +### PR 状态汇总 + +```sql +SELECT status, COUNT(*) as count +FROM pulls WHERE repo_id = +GROUP BY status; +``` + +### 贡献者总数 + +```sql +SELECT COUNT(DISTINCT creater_id) as total_contributors +FROM ( + SELECT creater_id FROM pulls WHERE repo_id = AND creater_id IS NOT NULL + UNION + SELECT creater_id FROM issues WHERE repo_id = AND creater_id IS NOT NULL +); +``` + +### 标签列表 + +```sql +SELECT name FROM tags WHERE 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 = +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 = +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 = ; +``` + +### 查询某 PR 的所有标签 + +```sql +SELECT t.name +FROM tags t +JOIN pull_tags pt ON t.id = pt.tag_id +WHERE pt.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 个字段,不允许省略任何一个。**