feat(pr): add pr +status showing pull requests relevant to you
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
parent
9749a4c832
commit
ceb34d2104
|
|
@ -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 文案与文档,不修改已有命令的参数或输出结构,对现有功能无破坏性影响。
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "查看拉取请求详情",
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue