diff --git a/doc/changes/feat-pr-view-merged-at.md b/doc/changes/feat-pr-view-merged-at.md new file mode 100644 index 0000000..73d6172 --- /dev/null +++ b/doc/changes/feat-pr-view-merged-at.md @@ -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 +} +``` diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..85d3c83 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -138,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) @@ -470,13 +470,22 @@ func extractIssueID(env *output.Envelope) (int64, error) { return int64(idFloat), nil } -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{}) diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index eece6d9..ffb67a1 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -249,6 +249,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) {