feat(pr): add pr +status showing pull requests relevant to you

Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
林晨 (Leo Cheng) 2026-07-08 13:49:42 +08:00
parent 9749a4c832
commit ceb34d2104
No known key found for this signature in database
GPG Key ID: 24FCF87A069356B9
5 changed files with 349 additions and 0 deletions

75
doc/changes/pr-status.md Normal file
View File

@ -0,0 +1,75 @@
# pr +status 与我相关的合并请求概览命令
## 背景
`gitlink-cli pr` 已经提供列表、创建、查看、合并、评审等能力,但缺少一个类似 `gh pr status` 的入口,用于快速回答“当前登录用户在本仓库里有哪些待处理的合并请求”。用户或 AI Agent 过去需要先查自己的身份,再手动拼 `pr +list` 的过滤参数,还要区分“我提的”和“等我评审的”。
本次变更把这一常见诉求封装为 `pr +status`,一次调用给出两组结果:你创建的、以及请求你评审的开启中合并请求。
## 变更内容
- 新增 `gitlink-cli pr +status` Shortcut只读
- 先调用 `GET /users/me` 解析当前用户的 `login` 与数值 `id`(复用 `user +me` 的接口)。
- 复用合并请求列表接口 `GET /v1/{owner}/{repo}/pulls`api_ref「获取合并请求列表」拉取数据
- **你创建的**:以 `status=0` 拉取开启中的合并请求,再按 `issue.author.login` 与当前用户在**客户端**匹配。该列表接口没有 author 过滤参数,故只能客户端过滤。
- **请求你评审的**:以 `status=0``reviewer_id={当前用户 id}` 由**服务端**过滤(`reviewer_id` 是列表接口文档化的审查人员过滤参数)。
- 输出统一封装为结构化数据:`login`、`created`、`review_requested` 两组合并请求数组沿用现有输出格式json/table/yaml
- 补充中英文 i18n 文案(`cmd.pr.status.short` / `cmd.pr.status.long`),避免命令帮助信息硬编码。
## 命令示例
```bash
# 查看与你相关的合并请求owner/repo 可从 git remote 自动解析)
gitlink-cli pr +status --owner Gitlink --repo forgeplus
# Agent 场景建议 JSON 输出
gitlink-cli pr +status --owner Gitlink --repo forgeplus --format json
```
## 输出结构
```json
{
"ok": true,
"data": {
"login": "currentuser",
"created": [ /* 你创建的开启中合并请求 */ ],
"review_requested": [ /* 请求你评审的开启中合并请求 */ ]
}
}
```
## 设计说明
- 该命令刻意只使用列表接口文档化的查询参数(`status`、`reviewer_id`),不引入未在 api_ref 中出现的参数。
- author 侧过滤放在客户端,是因为列表接口只支持 `reviewer_id` / `assign_user_id` 等数值过滤,没有 author 过滤参数;这一点在上文与代码注释中都做了说明。
- 全流程只读,不修改任何合并请求状态。
## 测试覆盖
`shortcuts/pr/pr_test.go` 中新增表驱动单元测试mock `/users/me` 与合并请求列表接口):
- 分组正确:混合作者的开启中合并请求被正确拆分为“你创建的”与“请求你评审的”。
- author 客户端过滤:他人创建的合并请求不进入“你创建的”分组。
- 空仓库:两组均为空。
- `reviewer_id` 断言:确认按当前用户数值 id 向服务端发起评审过滤查询。
- 错误路径:`/users/me` 返回 500、或响应缺少 `login` 时命令报错。
- 端到端:`pr +status` 走完整 Run 路径(含输出)不报错。
验证命令:
```bash
go build ./...
go test ./shortcuts/pr/
```
## 交付要求核对
- 功能代码:`shortcuts/pr/pr.go`
- 单元测试:`shortcuts/pr/pr_test.go`
- i18n 文案:`internal/i18n/locales/en-US.json`、`internal/i18n/locales/zh-CN.json`
- 变更说明文档:`doc/changes/pr-status.md`
## 兼容性
该变更只新增一个只读 Shortcut、对应单元测试、i18n 文案与文档,不修改已有命令的参数或输出结构,对现有功能无破坏性影响。

View File

@ -62,6 +62,8 @@
"cmd.pr.review.short": "Create a pull request review",
"cmd.pr.reviews.short": "List pull request reviews",
"cmd.pr.short": "Pull request operations",
"cmd.pr.status.long": "Show open pull requests in this repository that are relevant to the current authenticated user, grouped into ones you created and ones requesting your review.",
"cmd.pr.status.short": "Show pull requests relevant to you",
"cmd.pr.version_diff.short": "Show diff for a pull request patchset version",
"cmd.pr.versions.short": "List pull request patchset versions",
"cmd.pr.view.short": "View pull request details",

View File

@ -62,6 +62,8 @@
"cmd.pr.review.short": "创建拉取请求评审",
"cmd.pr.reviews.short": "列出拉取请求评审",
"cmd.pr.short": "拉取请求操作",
"cmd.pr.status.long": "显示当前登录用户在本仓库相关的开启中拉取请求,分为你创建的和请求你评审的两组。",
"cmd.pr.status.short": "显示与你相关的拉取请求",
"cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff",
"cmd.pr.versions.short": "列出拉取请求补丁集版本",
"cmd.pr.view.short": "查看拉取请求详情",

View File

@ -3,6 +3,7 @@ package pr
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
@ -89,6 +90,21 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "status",
Description: tr.T("cmd.pr.status.short"),
Long: tr.T("cmd.pr.status.long"),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
result, err := collectPullStatus(ctx)
if err != nil {
return err
}
return ctx.OutputData(result)
},
},
{
Name: "create",
Description: tr.T("cmd.pr.create.short"),
@ -445,6 +461,101 @@ func prV1Path(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id)
}
// collectPullStatus groups the current user's relevant open pull requests into
// those they authored and those requesting their review. The pulls list
// endpoint (api_ref "获取合并请求列表") exposes a reviewer_id filter but no author
// filter, so review requests are narrowed server-side by the numeric user id
// while authorship is matched client-side on the author login.
func collectPullStatus(ctx *common.RuntimeContext) (map[string]interface{}, error) {
login, userID, err := currentUserIdentity(ctx)
if err != nil {
return nil, err
}
openQuery := url.Values{}
openQuery.Set("status", "0")
openPulls, err := fetchPulls(ctx, openQuery)
if err != nil {
return nil, err
}
reviewQuery := url.Values{}
reviewQuery.Set("status", "0")
reviewQuery.Set("reviewer_id", userID)
reviewRequested, err := fetchPulls(ctx, reviewQuery)
if err != nil {
return nil, err
}
return map[string]interface{}{
"login": login,
"created": filterPullsByAuthorLogin(openPulls, login),
"review_requested": reviewRequested,
}, nil
}
func currentUserIdentity(ctx *common.RuntimeContext) (login string, id string, err error) {
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return "", "", err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected /users/me response format")
}
login = stringField(data, "login")
if login == "" {
return "", "", fmt.Errorf("/users/me response missing login")
}
idNum, ok := numberField(data, "id")
if !ok {
return "", "", fmt.Errorf("/users/me response missing id")
}
return login, strconv.FormatInt(int64(idNum), 10), nil
}
func fetchPulls(ctx *common.RuntimeContext, query url.Values) ([]interface{}, error) {
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/pulls", query)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return []interface{}{}, nil
}
pulls, ok := data["pulls"].([]interface{})
if !ok {
return []interface{}{}, nil
}
return pulls, nil
}
func filterPullsByAuthorLogin(pulls []interface{}, login string) []interface{} {
matched := make([]interface{}, 0, len(pulls))
for _, raw := range pulls {
pull, ok := raw.(map[string]interface{})
if !ok {
continue
}
if pullAuthorLogin(pull) == login {
matched = append(matched, raw)
}
}
return matched
}
func pullAuthorLogin(pull map[string]interface{}) string {
issue, ok := pull["issue"].(map[string]interface{})
if !ok {
return ""
}
author, ok := issue["author"].(map[string]interface{})
if !ok {
return ""
}
return stringField(author, "login")
}
func validatePRReviewStatus(status string) error {
switch status {
case "common", "approved", "rejected":

View File

@ -481,6 +481,165 @@ func TestPRDiffHTTPError(t *testing.T) {
}
}
// --- status ---
func TestPRStatusGrouping(t *testing.T) {
tests := []struct {
name string
openPulls []interface{}
reviewPulls []interface{}
wantCreated []string
wantReview []string
}{
{
name: "groups created and review-requested",
openPulls: []interface{}{
makePull(1, "mine one", "currentuser"),
makePull(2, "theirs", "someoneelse"),
makePull(3, "mine two", "currentuser"),
},
reviewPulls: []interface{}{
makePull(4, "review me", "author4"),
},
wantCreated: []string{"mine one", "mine two"},
wantReview: []string{"review me"},
},
{
name: "author filter excludes other people",
openPulls: []interface{}{
makePull(2, "theirs", "someoneelse"),
},
reviewPulls: []interface{}{},
wantCreated: []string{},
wantReview: []string{},
},
{
name: "empty repository yields empty groups",
openPulls: []interface{}{},
reviewPulls: []interface{}{},
wantCreated: []string{},
wantReview: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var reviewerID string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/users/me.json":
writeJSON(t, w, map[string]interface{}{"login": "currentuser", "id": float64(7)})
case r.URL.Path == "/v1/owner/repo/pulls.json":
if got := r.URL.Query().Get("status"); got != "0" {
t.Fatalf("expected status=0, got %q", got)
}
if rid := r.URL.Query().Get("reviewer_id"); rid != "" {
reviewerID = rid
writeJSON(t, w, map[string]interface{}{"pulls": tt.reviewPulls})
return
}
writeJSON(t, w, map[string]interface{}{"pulls": tt.openPulls})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
result, err := collectPullStatus(statusContext(server))
if err != nil {
t.Fatalf("collectPullStatus failed: %v", err)
}
assertEqual(t, result["login"], "currentuser")
assertPullTitles(t, result["created"], tt.wantCreated)
assertPullTitles(t, result["review_requested"], tt.wantReview)
if reviewerID != "7" {
t.Fatalf("expected reviewer_id=7 sent to API, got %q", reviewerID)
}
})
}
}
func TestPRStatusPropagatesUserError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
if _, err := collectPullStatus(statusContext(server)); err == nil {
t.Fatal("expected error when /users/me fails")
}
}
func TestPRStatusMissingLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]interface{}{"id": float64(7)})
}))
defer server.Close()
if _, err := collectPullStatus(statusContext(server)); err == nil {
t.Fatal("expected error when login is missing")
}
}
func TestPRStatusRun(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/me.json":
writeJSON(t, w, map[string]interface{}{"login": "currentuser", "id": float64(7)})
case "/v1/owner/repo/pulls.json":
writeJSON(t, w, map[string]interface{}{"pulls": []interface{}{}})
default:
t.Fatalf("unexpected request: %s", r.URL.Path)
}
}))
defer server.Close()
if err := runPRShortcut(t, server, "status", nil); err != nil {
t.Fatalf("status run failed: %v", err)
}
}
func statusContext(server *httptest.Server) *common.RuntimeContext {
return &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
}
}
func makePull(id int, title, authorLogin string) map[string]interface{} {
return map[string]interface{}{
"id": float64(id),
"title": title,
"issue": map[string]interface{}{
"author": map[string]interface{}{"login": authorLogin},
},
}
}
func assertPullTitles(t *testing.T, raw interface{}, want []string) {
t.Helper()
items, ok := raw.([]interface{})
if !ok {
t.Fatalf("expected []interface{}, got %T", raw)
}
if len(items) != len(want) {
t.Fatalf("expected %d pulls, got %d", len(want), len(items))
}
for i, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
t.Fatalf("pull %d not a map: %T", i, it)
}
if got := stringField(m, "title"); got != want[i] {
t.Fatalf("pull %d title = %q, want %q", i, got, want[i])
}
}
}
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findPRShortcut(t, name)