feat(health): remove per-issue detail API, add tag tables and persistence

- 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
This commit is contained in:
Yingjie Shang 2026-05-26 07:01:40 +08:00
parent 0da45def2b
commit 0a578bbab0
5 changed files with 194 additions and 22 deletions

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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);

View File

@ -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 = <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 = <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 = <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 = <issue_id>;
```
### 查询某 PR 的所有标签
```sql
SELECT t.name
FROM tags t
JOIN pull_tags pt ON t.id = pt.tag_id
WHERE pt.pull_id = <pull_id>;
```
---
## 报告组装清单