Merge PR #365: feat(pr): surface merged_at in pr +view

# Conflicts:
#	shortcuts/pr/pr.go
This commit is contained in:
wbtiger 2026-07-14 22:49:53 +08:00
commit cf071a72dd
3 changed files with 81 additions and 241 deletions

View File

@ -0,0 +1,28 @@
# PR View Merged Timestamp
## Summary
`pr +view` now surfaces the merge timestamp at the top level of its output.
The non-v1 detail endpoint `/{owner}/{repo}/pulls/{id}` nests the merge time under
`pull_request.merged_at` (an ISO-8601 string such as `2026-07-05T12:52:05+08:00`),
but the CLI previously only lifted `closed_at`. Merged PRs therefore showed no merge
time, mirroring upstream issue #14.
The `closed_at` enrichment is renamed to `enrichPullRequestTimestamps` and extended so
that, when `pull_request.merged_at` is present, it is copied to `merged_at` at the top
level (and the boolean `merged`, when present, is surfaced alongside it). This matches
`gh pr view`, which exposes `mergedAt`. The existing `closed_at` behavior is unchanged.
## Example
```bash
gitlink-cli pr +view --owner Gitlink --repo forgeplus --id 42
```
```json
{
"merged_at": "2026-07-05T12:52:05+08:00",
"merged": true
}
```

View File

@ -3,7 +3,6 @@ package pr
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
@ -87,25 +86,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err != nil {
return err
}
normalizePullRequestListNumbers(env)
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"),
@ -113,7 +96,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{Name: "title", Short: "t", Usage: tr.T("flag.pr.title"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.pr.body")},
{Name: "head", Usage: tr.T("flag.pr.head"), Required: true},
{Name: "base", Usage: tr.T("flag.pr.base")},
{Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -123,10 +106,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
head, _ := ctx.RequireArg("head")
base := ctx.Arg("base")
if base == "" {
var err error
if base, err = ctx.DefaultBranch(); err != nil {
return err
}
base = "master"
}
payload := map[string]interface{}{
"title": title,
@ -158,7 +138,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err != nil {
return err
}
if err := enrichPullRequestClosedAt(ctx, env); err != nil {
if err := enrichPullRequestTimestamps(ctx, env); err != nil {
return err
}
return ctx.Output(env)
@ -410,7 +390,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
"approved": "approved", "rejected": "rejected", "common": "commented",
}[status]
summary := fmt.Sprintf("## Review: %s\n\n%s", statusLabel, content)
_, _ = ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID),
ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID),
map[string]interface{}{"notes": summary})
}
}
@ -451,93 +431,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "comments",
Description: tr.T("cmd.pr.comments.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/journals", v1RepoPath(ctx), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "comment-edit",
Description: tr.T("cmd.pr.comment_edit.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "comment-id", Short: "c", Usage: tr.T("flag.pr.comment_id"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
{Name: "state", Short: "s", Usage: tr.T("flag.pr.comment_state"), Default: "opened"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
commentID, err := requireIntFlag(ctx, "comment-id")
if err != nil {
return err
}
body, _ := ctx.RequireArg("body")
state := ctx.Arg("state")
switch state {
case "opened", "resolved", "disabled":
default:
return fmt.Errorf("--state must be one of opened, resolved, disabled; got %q", state)
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/pulls/%s/journals/%s", v1RepoPath(ctx), id, commentID), map[string]interface{}{"note": body, "state": state})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "comment-delete",
Description: tr.T("cmd.pr.comment_delete.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "comment-id", Short: "c", Usage: tr.T("flag.pr.comment_id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
commentID, err := requireIntFlag(ctx, "comment-id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/pulls/%s/journals/%s", v1RepoPath(ctx), id, commentID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func requireIntFlag(ctx *common.RuntimeContext, name string) (string, error) {
value, err := ctx.RequireArg(name)
if err != nil {
return "", err
}
if _, err := strconv.Atoi(value); err != nil {
return "", fmt.Errorf("--%s must be an integer, got %q", name, value)
}
return value, nil
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
@ -549,101 +445,6 @@ 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":
@ -669,40 +470,22 @@ func extractIssueID(env *output.Envelope) (int64, error) {
return int64(idFloat), nil
}
func normalizePullRequestListNumbers(env *output.Envelope) {
if env == nil {
return
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return
}
pulls, ok := data["pulls"].([]interface{})
if !ok {
return
}
for i, item := range pulls {
pr, ok := item.(map[string]interface{})
if !ok {
continue
}
if number := firstPullRequestNumber(pr); number != nil {
pr["number"] = number
}
pulls[i] = pr
}
}
func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error {
func enrichPullRequestTimestamps(ctx *common.RuntimeContext, env *output.Envelope) error {
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil
}
pr, ok := data["pull_request"].(map[string]interface{})
if !ok || !isClosedPullRequest(pr) || stringField(pr, "closed_at") != "" {
if !ok {
return nil
}
if mergedAt := stringField(pr, "merged_at"); mergedAt != "" {
data["merged_at"] = mergedAt
if merged, ok := pr["merged"].(bool); ok {
data["merged"] = merged
}
}
if !isClosedPullRequest(pr) || stringField(pr, "closed_at") != "" {
return nil
}
issue, ok := data["issue"].(map[string]interface{})
@ -785,12 +568,3 @@ 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
}

View File

@ -300,6 +300,44 @@ func TestPRView(t *testing.T) {
}
}
func TestPRViewSurfacesMergedAt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/pulls/42.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"pull_request": map[string]interface{}{
"merged_at": "2026-07-05T12:52:05+08:00",
"merged": true,
"pull_request_staus": "merged",
},
})
}))
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
}
env, err := ctx.CallAPI("GET", "/owner/repo/pulls/42", nil)
if err != nil {
t.Fatalf("CallAPI error: %v", err)
}
if err := enrichPullRequestTimestamps(ctx, env); err != nil {
t.Fatalf("enrich error: %v", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("unexpected data type: %T", env.Data)
}
assertEqual(t, data["merged_at"], "2026-07-05T12:52:05+08:00")
assertEqual(t, data["merged"], true)
}
// --- merge ---
func TestPRMerge(t *testing.T) {