Merge PR #365: feat(pr): surface merged_at in pr +view
# Conflicts: # shortcuts/pr/pr.go
This commit is contained in:
commit
cf071a72dd
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue