From ceb34d210417026fd6ba58fa972047c105101a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Wed, 8 Jul 2026 13:49:42 +0800 Subject: [PATCH] feat(pr): add pr +status showing pull requests relevant to you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- doc/changes/pr-status.md | 75 +++++++++++++++ internal/i18n/locales/en-US.json | 2 + internal/i18n/locales/zh-CN.json | 2 + shortcuts/pr/pr.go | 111 +++++++++++++++++++++ shortcuts/pr/pr_test.go | 159 +++++++++++++++++++++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 doc/changes/pr-status.md diff --git a/doc/changes/pr-status.md b/doc/changes/pr-status.md new file mode 100644 index 0000000..92f005b --- /dev/null +++ b/doc/changes/pr-status.md @@ -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 文案与文档,不修改已有命令的参数或输出结构,对现有功能无破坏性影响。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..9906137 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..a6edc56 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -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": "查看拉取请求详情", diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..2c79cc8 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -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": diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index eece6d9..dfef817 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -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)