Merge pull request '修正 Issue 和 PR 列表筛选' (#117) from Mengz/gitlink-cli:mengz/list-filter-fixes into master

This commit is contained in:
wbtiger 2026-06-08 02:24:41 +08:00
commit dd4ed35448
11 changed files with 273 additions and 13 deletions

View File

@ -255,6 +255,9 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope
# List issues
gitlink-cli issue +list --owner Gitlink --repo forgeplus
# Search and sort open issues
gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open --keyword login --sort-by issues.updated_on --sort-direction desc
# Create an issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
@ -325,6 +328,9 @@ gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
# List PRs
gitlink-cli pr +list --owner Gitlink --repo forgeplus
# Search merged PRs by keyword
gitlink-cli pr +list --owner Gitlink --repo forgeplus --state merged --keyword release --sort-by updated_at --sort-direction desc
# Create a PR (same-repo branch)
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: Search feature" --head feature/search --base master

View File

@ -266,6 +266,9 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope
# 列出 Issue
gitlink-cli issue +list --owner Gitlink --repo forgeplus
# 搜索并按更新时间倒序列出开启中的 Issue
gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open --keyword 登录 --sort-by issues.updated_on --sort-direction desc
# 创建 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
@ -335,6 +338,9 @@ gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
# 列出 PR
gitlink-cli pr +list --owner Gitlink --repo forgeplus
# 按关键字搜索已合并 PR
gitlink-cli pr +list --owner Gitlink --repo forgeplus --state merged --keyword release --sort-by updated_at --sort-direction desc
# 创建 PR同仓库分支
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 搜索功能" --head feature/search --base master

View File

@ -0,0 +1,12 @@
# Issue/PR 列表筛选增强
本次变更修正并增强 `issue +list``pr +list` 的筛选能力。
此前 `issue +list --state open` 会向服务端发送 `state=open`,但 GitLink v1 Issue 列表接口实际使用 `category=opened/closed/all`,因此列表可能仍返回关闭 Issue。`pr +list --state open` 也没有映射到 PR 列表接口实际使用的 `status=0/1/2` 参数Skill 文档中甚至需要提醒用户该参数可能只影响统计。现在两个命令都会保留原有 `--state` 用户体验,同时转换为服务端真实生效的参数。
新增筛选项:
- `issue +list` 支持 `--keyword`、`--participant`、`--author-id`、`--assignee-id`、`--milestone-id`、`--status-id`、`--tag-ids`、`--sort-by`、`--sort-direction`。
- `pr +list` 支持 `--keyword`、`--priority-id`、`--tag-id`、`--milestone-id`、`--reviewer-id`、`--assignee-id`、`--sort-by`、`--sort-direction`。
单元测试覆盖了状态映射、筛选参数透传和 `all` 状态兼容README、中文 README、Issue Skill 与 PR Skill 已同步更新。

View File

@ -119,6 +119,8 @@
"flag.issue.batch_label.state": "Filter by issue state",
"flag.issue.batch_list.limit": "Maximum issues to return, capped at 100",
"flag.issue.batch_process.limit": "Maximum issues to process, capped at 100",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.body": "Issue description",
"flag.issue.label": "Label ID",
"flag.issue.label_filter": "Filter by existing label",
@ -128,7 +130,10 @@
"flag.issue.new_title": "New title",
"flag.issue.number": "Issue number (as shown in the web URL)",
"flag.issue.older_than_days": "Only include issues inactive for at least this many days",
"flag.issue.participant": "Participant filter: all, aboutme, authoredme, assignedme, atme",
"flag.issue.state": "Filter by state: open, closed, all",
"flag.issue.status_id": "Issue status ID",
"flag.issue.tag_ids": "Comma-separated issue tag IDs",
"flag.issue.title": "Issue title",
"flag.lang": "Display language",
"flag.limit": "Items per page",
@ -137,17 +142,22 @@
"flag.org.name": "Organization name",
"flag.owner": "Repository owner (auto-detected from git remote)",
"flag.page": "Page number",
"flag.pr.assignee_id": "Assignee user ID",
"flag.pr.base": "Target branch",
"flag.pr.body": "PR description",
"flag.pr.file": "Filter diff by file path",
"flag.pr.head": "Source branch",
"flag.pr.id": "PR number",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
"flag.pr.priority_id": "Priority ID",
"flag.pr.review_commit": "Commit SHA to attach the review to",
"flag.pr.review_content": "Review content",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.review_status": "Review status: common, approved, rejected",
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
"flag.pr.state": "Filter: open, merged, closed",
"flag.pr.tag_id": "Issue tag ID",
"flag.pr.title": "PR title",
"flag.pr.version_id": "Patchset version ID",
"flag.release.body": "Release notes",
@ -163,6 +173,8 @@
"flag.repo.name": "Repository name",
"flag.repo.private": "Make repository private (true/false)",
"flag.search.keyword": "Search keyword",
"flag.sort_by": "Sort field",
"flag.sort_direction": "Sort direction: asc, desc",
"flag.user": "User login (default: current user)",
"flag.user.login": "User login name",
"flag.webhook.active": "Whether the webhook is active: true or false",

View File

@ -119,6 +119,8 @@
"flag.issue.batch_label.state": "按议题状态筛选",
"flag.issue.batch_list.limit": "最多返回的议题数,上限 100",
"flag.issue.batch_process.limit": "最多处理的议题数,上限 100",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.body": "议题描述",
"flag.issue.label": "标签 ID",
"flag.issue.label_filter": "按已有标签筛选",
@ -128,7 +130,10 @@
"flag.issue.new_title": "新标题",
"flag.issue.number": "议题编号(网页 URL 中显示的编号)",
"flag.issue.older_than_days": "只包含至少这么多天未活动的议题",
"flag.issue.participant": "参与范围all、aboutme、authoredme、assignedme、atme",
"flag.issue.state": "按状态筛选open、closed、all",
"flag.issue.status_id": "议题状态 ID",
"flag.issue.tag_ids": "逗号分隔的议题标签 ID",
"flag.issue.title": "议题标题",
"flag.lang": "显示语言",
"flag.limit": "每页条目数",
@ -137,17 +142,22 @@
"flag.org.name": "组织名称",
"flag.owner": "仓库所有者(自动从 git remote 检测)",
"flag.page": "页码",
"flag.pr.assignee_id": "指派人用户 ID",
"flag.pr.base": "目标分支",
"flag.pr.body": "PR 描述",
"flag.pr.file": "按文件路径筛选 diff",
"flag.pr.head": "源分支",
"flag.pr.id": "PR 编号",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.merge_method": "合并方式merge、rebase、squash",
"flag.pr.priority_id": "优先级 ID",
"flag.pr.review_commit": "关联评审的 Commit SHA",
"flag.pr.review_content": "评审内容",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.review_status": "评审状态common、approved、rejected",
"flag.pr.review_status_filter": "按评审状态筛选common、approved、rejected",
"flag.pr.state": "筛选open、merged、closed",
"flag.pr.tag_id": "议题标签 ID",
"flag.pr.title": "PR 标题",
"flag.pr.version_id": "补丁集版本 ID",
"flag.release.body": "发布说明",
@ -163,6 +173,8 @@
"flag.repo.name": "仓库名称",
"flag.repo.private": "设为私有仓库true/false",
"flag.search.keyword": "搜索关键词",
"flag.sort_by": "排序字段",
"flag.sort_direction": "排序方向asc、desc",
"flag.user": "用户登录名(默认:当前用户)",
"flag.user.login": "用户登录名",
"flag.webhook.active": "Webhook 是否启用true 或 false",

View File

@ -16,6 +16,19 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func normalizeIssueListState(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "opened"
case "closed":
return "closed"
case "all", "":
return "all"
default:
return state
}
}
type existingIssue struct {
Subject string
Description string
@ -37,6 +50,15 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Description: tr.T("cmd.issue.list.short"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"},
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")},
{Name: "participant", Usage: tr.T("flag.issue.participant")},
{Name: "author-id", Usage: tr.T("flag.issue.author_id")},
{Name: "assignee-id", Usage: tr.T("flag.issue.assignee_id")},
{Name: "milestone-id", Usage: tr.T("flag.issue.milestone")},
{Name: "status-id", Usage: tr.T("flag.issue.status_id")},
{Name: "tag-ids", Usage: tr.T("flag.issue.tag_ids")},
{Name: "sort-by", Usage: tr.T("flag.sort_by")},
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
@ -48,7 +70,34 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
q.Set("category", normalizeIssueListState(s))
}
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if participant := ctx.Arg("participant"); participant != "" {
q.Set("participant_category", participant)
}
if authorID := ctx.Arg("author-id"); authorID != "" {
q.Set("author_id", authorID)
}
if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" {
q.Set("assigner_id", assigneeID)
}
if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" {
q.Set("milestone_id", milestoneID)
}
if statusID := ctx.Arg("status-id"); statusID != "" {
q.Set("status_id", statusID)
}
if tagIDs := ctx.Arg("tag-ids"); tagIDs != "" {
q.Set("issue_tag_ids", tagIDs)
}
if sortBy := ctx.Arg("sort-by"); sortBy != "" {
q.Set("sort_by", sortBy)
}
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
q.Set("sort_direction", sortDirection)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
@ -599,4 +648,3 @@ func parseIssueID(value, flagName string) (int, error) {
}
return id, nil
}

View File

@ -104,8 +104,8 @@ func TestIssueList(t *testing.T) {
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("state") != "open" {
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
if r.URL.Query().Get("category") != "opened" {
t.Fatalf("expected category=opened, got %s", r.URL.Query().Get("category"))
}
writeJSON(t, w, []interface{}{
map[string]interface{}{"id": float64(1), "subject": "bug"},
@ -119,6 +119,58 @@ func TestIssueList(t *testing.T) {
}
}
func TestIssueListWithFilters(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
query := r.URL.Query()
assertEqual(t, query.Get("category"), "closed")
assertEqual(t, query.Get("keyword"), "release")
assertEqual(t, query.Get("participant_category"), "assignedme")
assertEqual(t, query.Get("author_id"), "10")
assertEqual(t, query.Get("assigner_id"), "11")
assertEqual(t, query.Get("milestone_id"), "12")
assertEqual(t, query.Get("status_id"), "5")
assertEqual(t, query.Get("issue_tag_ids"), "1,2")
assertEqual(t, query.Get("sort_by"), "issues.updated_on")
assertEqual(t, query.Get("sort_direction"), "desc")
writeJSON(t, w, map[string]interface{}{"issues": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{
"state": "closed",
"keyword": "release",
"participant": "assignedme",
"author-id": "10",
"assignee-id": "11",
"milestone-id": "12",
"status-id": "5",
"tag-ids": "1,2",
"sort-by": "issues.updated_on",
"sort-direction": "desc",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
}
}
func TestIssueListStateAll(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertEqual(t, r.URL.Query().Get("category"), "all")
writeJSON(t, w, map[string]interface{}{"issues": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"state": "all", "page": "1", "limit": "20"})
if err != nil {
t.Fatalf("list all failed: %v", err)
}
}
// --- create ---
func TestIssueCreate(t *testing.T) {

View File

@ -10,6 +10,25 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func normalizePullRequestListState(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "0"
case "merged":
return "1"
case "closed":
return "2"
case "all", "":
return ""
default:
return state
}
}
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
@ -18,6 +37,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Description: tr.T("cmd.pr.list.short"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"},
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")},
{Name: "priority-id", Usage: tr.T("flag.pr.priority_id")},
{Name: "tag-id", Usage: tr.T("flag.pr.tag_id")},
{Name: "milestone-id", Usage: tr.T("flag.pr.milestone_id")},
{Name: "reviewer-id", Usage: tr.T("flag.pr.reviewer_id")},
{Name: "assignee-id", Usage: tr.T("flag.pr.assignee_id")},
{Name: "sort-by", Usage: tr.T("flag.sort_by")},
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
@ -28,10 +55,34 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
if s := normalizePullRequestListState(ctx.Arg("state")); s != "" {
q.Set("status", s)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if priorityID := ctx.Arg("priority-id"); priorityID != "" {
q.Set("priority_id", priorityID)
}
if tagID := ctx.Arg("tag-id"); tagID != "" {
q.Set("issue_tag_id", tagID)
}
if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" {
q.Set("version_id", milestoneID)
}
if reviewerID := ctx.Arg("reviewer-id"); reviewerID != "" {
q.Set("reviewer_id", reviewerID)
}
if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" {
q.Set("assign_user_id", assigneeID)
}
if sortBy := ctx.Arg("sort-by"); sortBy != "" {
q.Set("sort_by", sortBy)
}
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
q.Set("sort_direction", sortDirection)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/pulls", q)
if err != nil {
return err
}
@ -120,8 +171,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
},
},
{
Name: "refuse",
Description: "Refuse and close a pull request",
Name: "refuse",
Description: "Refuse and close a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},

View File

@ -99,11 +99,11 @@ func TestPRList(t *testing.T) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/pulls.json" {
if r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("state") != "open" {
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
if r.URL.Query().Get("status") != "0" {
t.Fatalf("expected status=0, got %s", r.URL.Query().Get("status"))
}
if r.URL.Query().Get("page") != "1" {
t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page"))
@ -120,6 +120,61 @@ func TestPRList(t *testing.T) {
}
}
func TestPRListWithFilters(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
query := r.URL.Query()
assertEqual(t, query.Get("status"), "1")
assertEqual(t, query.Get("keyword"), "release")
assertEqual(t, query.Get("priority_id"), "2")
assertEqual(t, query.Get("issue_tag_id"), "3")
assertEqual(t, query.Get("version_id"), "4")
assertEqual(t, query.Get("reviewer_id"), "5")
assertEqual(t, query.Get("assign_user_id"), "6")
assertEqual(t, query.Get("sort_by"), "updated_at")
assertEqual(t, query.Get("sort_direction"), "desc")
writeJSON(t, w, map[string]interface{}{"pulls": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "list", map[string]string{
"state": "merged",
"keyword": "release",
"priority-id": "2",
"tag-id": "3",
"milestone-id": "4",
"reviewer-id": "5",
"assignee-id": "6",
"sort-by": "updated_at",
"sort-direction": "desc",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
}
}
func TestPRListStateAllOmitsStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.URL.Query().Get("status"); got != "" {
t.Fatalf("status should be omitted for all, got %q", got)
}
writeJSON(t, w, map[string]interface{}{"pulls": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "list", map[string]string{"state": "all", "page": "1", "limit": "20"})
if err != nil {
t.Fatalf("list all failed: %v", err)
}
}
// --- create ---
func TestPRCreate(t *testing.T) {

View File

@ -39,6 +39,9 @@ metadata:
# 列出 Issue
gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open
# 搜索并排序 Issue
gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open --keyword 登录 --sort-by issues.updated_on --sort-direction desc
# 创建 Issue
gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤:..."

View File

@ -39,6 +39,9 @@ metadata:
# 列出 PR
gitlink-cli pr +list --owner Gitlink --repo forgeplus --state open
# 搜索并排序 PR
gitlink-cli pr +list --owner Gitlink --repo forgeplus --state merged --keyword release --sort-by updated_at --sort-direction desc
# 创建 PR源分支必须有实际代码变更
gitlink-cli pr +create --title "feat: 新增搜索功能" --head feature/search --base master --body "实现了全文搜索"
@ -166,6 +169,6 @@ gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff
- `pr +diff` 实际调用 `/pulls/:id/files` 端点,返回变更文件列表和 diff 内容
- `pr +versions` / `pr +version-diff` 使用 v1 API`--id` 为网页 URL `/pulls/N` 中的 PR 序号,`--version-id` 为 patchset/version id
- 同一个 PR 分支继续 push 新 commit 会生成新的 patchset/version正常根据 review 修改代码时,应优先在原 PR 分支继续 push不要关闭 PR 重开
- `pr +list``--state` 参数open/merged/closed仅影响统计计数API 返回的列表可能包含所有状态的 PR
- `pr +list``--state` 参数会映射为 GitLink API 的 `status` 筛选;需要查看所有 PR 时传 `--state all`
- PR 状态值:`pull_request_status` 0=open, 1=merged, 2=closed
- 关联已有 Issue 时,把 Issue 编号或 URL 写入 PR `--body`,或使用 `issue +comment` 留痕;不要用 Raw API 对 Issue 做不完整更新,否则可能清空 Issue 描述