feat(pr): 支持按编号搜索 PR

This commit is contained in:
Mengz 2026-06-25 18:13:39 +08:00
parent 71ca2bb683
commit ffb35a4f02
4 changed files with 188 additions and 31 deletions

View File

@ -0,0 +1,10 @@
## PR list supports direct lookup by PR number
`pr +list` previously only exposed keyword-based search, which made it awkward
to jump to a known PR from the web UI or from review notes. This change adds
`--number` / `-n` and a compatibility alias `--id` / `-i` to the list command.
When a PR number is provided, the CLI now reads that PR through the dedicated
detail endpoint and wraps the result into the usual list payload shape. This
keeps the output stable for automation while making exact-number lookup work
even when the target PR is not on the current list page.

View File

@ -38,6 +38,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
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: "number", Short: "n", Usage: "PR number shown in the web URL"},
{Name: "id", Short: "i", Usage: "Compatibility alias for --number; this is not the database ID"},
{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")},
@ -52,6 +54,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
if number := pullRequestListNumberArg(ctx); number != "" {
return outputPullRequestListByNumber(ctx, number)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
@ -470,6 +475,32 @@ func extractIssueID(env *output.Envelope) (int64, error) {
return int64(idFloat), nil
}
func pullRequestListNumberArg(ctx *common.RuntimeContext) string {
if number := strings.TrimSpace(ctx.Arg("number")); number != "" {
return number
}
return strings.TrimSpace(ctx.Arg("id"))
}
func outputPullRequestListByNumber(ctx *common.RuntimeContext, number string) error {
env, err := ctx.CallAPI("GET", prV1Path(ctx, number), nil)
if err != nil {
return err
}
pr, ok := env.Data.(map[string]interface{})
if !ok {
return fmt.Errorf("unexpected PR response format")
}
if normalizedNumber := firstPullRequestNumber(pr); normalizedNumber != nil {
pr["number"] = normalizedNumber
}
env.Data, env.Meta = wrapPullRequestListByNumberResult(pr)
return ctx.Output(env)
}
func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error {
data, ok := env.Data.(map[string]interface{})
if !ok {
@ -559,3 +590,25 @@ func numberField(m map[string]interface{}, key string) (float64, bool) {
return 0, false
}
}
func firstPullRequestNumber(pr map[string]interface{}) interface{} {
for _, key := range []string{"number", "pull_request_number", "index"} {
if value, ok := pr[key]; ok {
return value
}
}
return nil
}
func wrapPullRequestListByNumberResult(pr map[string]interface{}) (map[string]interface{}, *output.Meta) {
return map[string]interface{}{
"total_count": 1,
"page": 1,
"limit": 1,
"pulls": []interface{}{pr},
}, &output.Meta{
TotalCount: 1,
Page: 1,
Limit: 1,
}
}

View File

@ -175,6 +175,89 @@ func TestPRListStateAllOmitsStatus(t *testing.T) {
}
}
func TestPRListByNumberUsesDetailEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
if r.URL.Path != "/v1/owner/repo/pulls/42.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"id": float64(101),
"index": float64(42),
"title": "feat: search by number",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "list", map[string]string{"number": "42"})
if err != nil {
t.Fatalf("list by number failed: %v", err)
}
if requestedPath == "" {
t.Fatal("expected detail endpoint to be called")
}
}
func TestPRListByIDAliasUsesDetailEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
if r.URL.Path != "/v1/owner/repo/pulls/7.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"id": float64(202),
"index": float64(7),
"title": "feat: alias",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "list", map[string]string{"id": "7"})
if err != nil {
t.Fatalf("list by id alias failed: %v", err)
}
if requestedPath == "" {
t.Fatal("expected detail endpoint to be called")
}
}
func TestPullRequestListNumberArgPrefersNumber(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{
"number": "15",
"id": "9",
},
}
if got := pullRequestListNumberArg(ctx); got != "15" {
t.Fatalf("pullRequestListNumberArg() = %q, want 15", got)
}
}
func TestWrapPullRequestListByNumberResult(t *testing.T) {
pr := map[string]interface{}{
"id": float64(303),
"number": float64(88),
"title": "feat: wrapped number",
}
data, meta := wrapPullRequestListByNumberResult(pr)
assertEqual(t, data["total_count"], 1)
assertEqual(t, data["page"], 1)
assertEqual(t, data["limit"], 1)
pulls := data["pulls"].([]interface{})
wrapped := pulls[0].(map[string]interface{})
assertEqual(t, wrapped["number"], float64(88))
if meta == nil {
t.Fatal("expected meta to be set")
}
assertEqual(t, meta.TotalCount, 1)
assertEqual(t, meta.Page, 1)
assertEqual(t, meta.Limit, 1)
}
// --- create ---
func TestPRCreate(t *testing.T) {

View File

@ -1,49 +1,60 @@
# pr +list
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
> Read [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) first for
> authentication, global flags, and safety rules.
列出仓库的 Pull Request 列表。
List pull requests for a repository. The command supports state filtering,
keyword search, pagination, and direct lookup by PR number.
## 命令
## Examples
```bash
# 列出 PR默认 state=open
# List open pull requests for the current repository
gitlink-cli pr +list
# 指定仓库和状态
gitlink-cli pr +list --owner Gitlink --repo forgeplus --state open
# List merged pull requests for a specific repository
gitlink-cli pr +list --owner Gitlink --repo forgeplus --state merged
# 分页
# Search by keyword
gitlink-cli pr +list --keyword release --sort-by updated_at --sort-direction desc
# Search by PR number from the web URL
gitlink-cli pr +list --number 42
gitlink-cli pr +list --id 42
# Paginate results
gitlink-cli pr +list --page 2 --limit 10
# 查看已合并的 PR注意state 仅影响统计计数)
gitlink-cli pr +list --state merged --format json
```
## 参数
## Flags
| 参数 | 必填 | 说明 |
|------|------|------|
| `--state` / `-s` | 否 | 过滤状态:`open`、`merged`、`closed`(默认 `open` |
| `--page` / `-p` | 否 | 页码(默认 `1` |
| `--limit` / `-l` | 否 | 每页条数(默认 `20` |
| Flag | Required | Description |
|---|---|---|
| `--state`, `-s` | No | Filter by `open`, `merged`, `closed`, or `all`. Default: `open`. |
| `--keyword`, `-k` | No | Search PRs by keyword. |
| `--number`, `-n` | No | Fetch one PR by the PR number shown in the web URL. |
| `--id`, `-i` | No | Compatibility alias for `--number`. |
| `--priority-id` | No | Filter by priority ID. |
| `--tag-id` | No | Filter by issue tag ID. |
| `--milestone-id` | No | Filter by milestone/version ID. |
| `--reviewer-id` | No | Filter by reviewer ID. |
| `--assignee-id` | No | Filter by assignee ID. |
| `--sort-by` | No | Sort field, such as `updated_at` or `created_at`. |
| `--sort-direction` | No | Sort direction: `asc` or `desc`. |
| `--page`, `-p` | No | Page number. Default: `1`. |
| `--limit`, `-l` | No | Page size. Default: `20`. |
## API
## Behavior Notes
```
GET /{owner}/{repo}/pulls?state={state}&page={page}&limit={limit}
```
## 注意事项
- `--state` 参数**仅影响响应中的统计计数**open_count / merged_count / closed_count返回的 PR 列表可能包含所有状态的 PR
- 如需精确过滤,请在客户端通过 `pull_request_status` 字段二次过滤:
- `0` = open
- `1` = merged
- `2` = closed
- 返回的每条 PR 包含 `pull_request_number` 字段(即网页 URL `/pulls/N` 中的序号),用于 `pr +view`、`pr +merge`、`pr +close` 等操作
- Regular list mode uses `GET /api/v1/{owner}/{repo}/pulls.json`.
- `--number` / `--id` uses `GET /api/v1/{owner}/{repo}/pulls/{index}.json` and
returns the result in list form so scripts can keep using `pr +list`.
- Each returned PR includes a user-facing `number` field that matches the web UI
URL `/pulls/N`.
- The PR number is the project-level sequence number, not the global database
primary key.
## References
- [gitlink-shared SKILL.md](../../gitlink-shared/SKILL.md) -- 认证与全局参数
- [gitlink-pr SKILL.md](../SKILL.md) -- PR 操作总览
- [gitlink-shared SKILL.md](../../gitlink-shared/SKILL.md)
- [gitlink-pr SKILL.md](../SKILL.md)