From 0a578bbab0b8192d754973e6fb31615e23f01b1d Mon Sep 17 00:00:00 2001 From: Yingjie Shang Date: Tue, 26 May 2026 07:01:40 +0800 Subject: [PATCH] feat(health): remove per-issue detail API, add tag tables and persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop fetchIssueDetail — issue list response already contains all needed fields - Add tags/issue_tags/pull_tags tables with indexes - Persist PR and Issue tags during savePull/saveIssue - Fix issue aggregate field names (total_count/opened_count/closed_count) - Document tag tables and queries in queries.md --- shortcuts/health/api.go | 15 ---- shortcuts/health/db.go | 70 ++++++++++++++++ shortcuts/health/health.go | 9 +-- shortcuts/health/schema.sql | 32 ++++++++ skills/gitlink-health/references/queries.md | 90 +++++++++++++++++++++ 5 files changed, 194 insertions(+), 22 deletions(-) diff --git a/shortcuts/health/api.go b/shortcuts/health/api.go index a5d3e64..6918537 100644 --- a/shortcuts/health/api.go +++ b/shortcuts/health/api.go @@ -60,21 +60,6 @@ func fetchIssueListPage(ctx *common.RuntimeContext, owner, repo, state string, p 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 index 422b689..3ccbcf7 100644 --- a/shortcuts/health/db.go +++ b/shortcuts/health/db.go @@ -88,6 +88,56 @@ func getOrCreateRepo(db *sql.DB, repoName, owner string) (int, error) { return int(lastID), nil } +func getOrCreateTag(db *sql.DB, repoID int, tagName string) (int, error) { + var id int + err := db.QueryRow("SELECT id FROM tags WHERE repo_id = ? AND name = ?", repoID, tagName).Scan(&id) + if err == nil { + return id, nil + } + res, err := db.Exec("INSERT INTO tags (repo_id, name) VALUES (?, ?)", repoID, tagName) + if err != nil { + return 0, fmt.Errorf("insert tag %q for repo %d: %w", tagName, repoID, err) + } + lastID, _ := res.LastInsertId() + return int(lastID), nil +} + +func savePullTags(db *sql.DB, pullID int, tagIDs []int) { + if len(tagIDs) == 0 { + return + } + for _, tagID := range tagIDs { + db.Exec("INSERT OR IGNORE INTO pull_tags (pull_id, tag_id) VALUES (?, ?)", pullID, tagID) + } +} + +func saveIssueTags(db *sql.DB, issueID int, tagIDs []int) { + if len(tagIDs) == 0 { + return + } + for _, tagID := range tagIDs { + db.Exec("INSERT OR IGNORE INTO issue_tags (issue_id, tag_id) VALUES (?, ?)", issueID, tagID) + } +} + +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 +} + func savePull(db *sql.DB, repoID int, pr map[string]interface{}) { // id: pull_request_id preferred, fallback to id var prID float64 @@ -128,6 +178,16 @@ func savePull(db *sql.DB, repoID int, pr map[string]interface{}) { 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) + + // 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) { @@ -165,4 +225,14 @@ func saveIssue(db *sql.DB, repoID int, issue map[string]interface{}, issueNumber 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) + + // 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 index 6c9c606..fede76f 100644 --- a/shortcuts/health/health.go +++ b/shortcuts/health/health.go @@ -134,13 +134,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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) + saveIssue(db, repoID, issue, issueNumber, listUpdatedAt) } if len(issues) < 20 { break @@ -153,7 +148,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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"]) + issueAgg["total_count"], issueAgg["opened_count"], issueAgg["closed_count"]) } fmt.Fprintf(os.Stderr, "\nData saved to %s\n", dbPath) diff --git a/shortcuts/health/schema.sql b/shortcuts/health/schema.sql index c09f956..941b2a5 100644 --- a/shortcuts/health/schema.sql +++ b/shortcuts/health/schema.sql @@ -49,3 +49,35 @@ 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/skills/gitlink-health/references/queries.md b/skills/gitlink-health/references/queries.md index a5e0f47..7bc8337 100644 --- a/skills/gitlink-health/references/queries.md +++ b/skills/gitlink-health/references/queries.md @@ -67,6 +67,48 @@ CREATE TABLE pulls ( API 字段映射:`status` ← `pull_request_status`(0=open, 1=merged, 2=closed),`creater_id` ← `author_login`。 +### 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`: @@ -334,6 +376,54 @@ FROM ( ); ``` +### 标签列表 + +```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 = ; +``` + --- ## 报告组装清单