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..7bc2245 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index af5e2c6..3027683 100644 --- a/go.sum +++ b/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= diff --git a/shortcuts/health/api.go b/shortcuts/health/api.go new file mode 100644 index 0000000..a5d3e64 --- /dev/null +++ b/shortcuts/health/api.go @@ -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) +} diff --git a/shortcuts/health/db.go b/shortcuts/health/db.go new file mode 100644 index 0000000..422b689 --- /dev/null +++ b/shortcuts/health/db.go @@ -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) +} diff --git a/shortcuts/health/health.go b/shortcuts/health/health.go new file mode 100644 index 0000000..6c9c606 --- /dev/null +++ b/shortcuts/health/health.go @@ -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 + }, + }, + } +} diff --git a/shortcuts/health/schema.sql b/shortcuts/health/schema.sql new file mode 100644 index 0000000..c09f956 --- /dev/null +++ b/shortcuts/health/schema.sql @@ -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); 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/skills/README.md b/skills/README.md index c7ed276..3ca3cf9 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 # 健康度分析指南 +│ ├── 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 | --- 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..a5e0f47 --- /dev/null +++ b/skills/gitlink-health/references/queries.md @@ -0,0 +1,358 @@ +# 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 格式 + 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 = '' 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 +); +``` + +--- + +## 报告组装清单 + +**按以下顺序逐项执行查询、填入模板。每完成一项打勾,最终输出前核对所有 ✅ 是否齐全。** + +- [ ] **已关闭 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 个字段,不允许省略任何一个。**