fix(health): address code review issues 3-6,8

This commit is contained in:
Yingjie Shang 2026-05-26 22:16:42 +08:00
parent 41b89976aa
commit 17e8c113f7
7 changed files with 140 additions and 31 deletions

View File

@ -3,15 +3,20 @@ 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{}, map[string]interface{}) {
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))
@ -20,22 +25,64 @@ func fetchPRListPage(ctx *common.RuntimeContext, state string, page, limit int)
}
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
fmt.Fprintf(os.Stderr, " CLI error: pr +list state=%s page=%d: %v\n", state, page, err)
return nil, err
}
if !env.OK {
fmt.Printf(" API error: pr +list state=%s page=%d\n", state, page)
return nil, nil
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, nil
return nil, fmt.Errorf("unexpected response type for pr +list")
}
issues, _ := data["issues"].([]interface{})
return issues, data
return issues, nil
}
func fetchIssueListPage(ctx *common.RuntimeContext, owner, repo, state string, page, limit int) ([]interface{}, map[string]interface{}) {
// 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))
@ -44,17 +91,18 @@ func fetchIssueListPage(ctx *common.RuntimeContext, owner, repo, state string, p
}
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
fmt.Fprintf(os.Stderr, " CLI error: issue +list state=%s page=%d: %v\n", state, page, err)
return nil, err
}
if !env.OK {
fmt.Printf(" API error: issue +list state=%s page=%d\n", state, page)
return nil, nil
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, nil
return nil, fmt.Errorf("unexpected response type for issue +list")
}
issues, _ := data["issues"].([]interface{})
return issues, data
return issues, nil
}

View File

@ -4,6 +4,7 @@ import (
"database/sql"
_ "embed"
"fmt"
"os"
_ "modernc.org/sqlite"
)
@ -103,7 +104,9 @@ func savePullTags(db *sql.DB, pullID int, tagIDs []int) {
return
}
for _, tagID := range tagIDs {
db.Exec("INSERT OR IGNORE INTO pull_tags (pull_id, tag_id) VALUES (?, ?)", pullID, tagID)
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)
}
}
}
@ -112,7 +115,9 @@ func saveIssueTags(db *sql.DB, issueID int, tagIDs []int) {
return
}
for _, tagID := range tagIDs {
db.Exec("INSERT OR IGNORE INTO issue_tags (issue_id, tag_id) VALUES (?, ?)", issueID, tagID)
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)
}
}
}
@ -134,7 +139,22 @@ func extractTagNames(data map[string]interface{}, key string) []string {
return names
}
func savePull(db *sql.DB, repoID int, pr map[string]interface{}) {
// 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 {
@ -171,9 +191,19 @@ func savePull(db *sql.DB, repoID int, pr map[string]interface{}) {
processorID = &pid
}
db.Exec(`INSERT OR REPLACE INTO pulls (id, repo_id, number, creater_id, status, processor_id, create_time, close_time)
// 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, nil)
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")
@ -205,7 +235,7 @@ func saveIssue(db *sql.DB, repoID int, issue map[string]interface{}, issueNumber
statusName := extractStatusName(issue)
var status string
var closeTime interface{}
if statusName == "关闭" {
if statusName == "关闭" || statusName == "Closed" {
status = "close"
if v, _ := issue["closed_on"].(string); v != "" {
closeTime = v
@ -218,9 +248,11 @@ func saveIssue(db *sql.DB, repoID int, issue map[string]interface{}, issueNumber
status = "open"
}
db.Exec(`INSERT OR REPLACE INTO issues (id, repo_id, number, creater_id, processor_id, create_time, close_time, status)
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)
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")

View File

@ -113,7 +113,10 @@ func fetchPRs(ctx *common.RuntimeContext, db *sql.DB, repoID int, state string,
return nil
}
fmt.Fprintf(os.Stderr, " PR list: state=%s, page=%d...\n", state, page)
prs, _ := fetchPRListPage(ctx, state, page, 20)
prs, err := fetchPRListPage(ctx, state, page, 20)
if err != nil {
return err
}
if len(prs) == 0 {
break
}
@ -140,7 +143,28 @@ func fetchPRs(ctx *common.RuntimeContext, db *sql.DB, repoID int, state string,
if dup {
continue
}
savePull(db, repoID, pr)
// 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
@ -160,7 +184,10 @@ func fetchIssues(ctx *common.RuntimeContext, db *sql.DB, repoID int, state strin
return nil
}
fmt.Fprintf(os.Stderr, " Issue list: state=%s, page=%d...\n", state, page)
issues, _ := fetchIssueListPage(ctx, ctx.Owner, ctx.Repo, state, page, 20)
issues, err := fetchIssueListPage(ctx, ctx.Owner, ctx.Repo, state, page, 20)
if err != nil {
return err
}
if len(issues) == 0 {
break
}

View File

@ -39,12 +39,13 @@ CREATE TABLE IF NOT EXISTS pulls (
status TEXT CHECK(status IN ('merged', 'closed', 'open')),
processor_id INTEGER,
create_time TIMESTAMP,
close_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);

View File

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

View File

@ -114,9 +114,9 @@ skills/
│ └── SKILL.md # PM 操作指南
├── gitlink-health/ # 项目健康度分析
│ ├── SKILL.md # 健康度分析指南
│ ├── collector/
│ │ ├── fetcher.py # 数据采集脚本
│ │ └── schema.sql # 建表 SQL
│ ├── data/
│ │ ├── .gitignore # 忽略 *.db 文件
│ │ └── .gitkeep # 占位文件
│ ├── references/
│ │ └── queries.md # SQL 查询参考
│ └── asset/

View File

@ -58,14 +58,14 @@ CREATE TABLE pulls (
status TEXT CHECK(status IN ('merged', 'closed', 'open')),
processor_id INTEGER, -- 指派人 → users.idnullable来自 list 的 assign_user_login
create_time TIMESTAMP, -- 创建时间ISO 格式
close_time TIMESTAMP, -- 始终为 NULLAPI 不提供
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`
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