From 7f0f332f7713fd7878c30df7d3e62fece3dec798 Mon Sep 17 00:00:00 2001 From: fsafasff <2624934035@qq.com> Date: Tue, 26 May 2026 17:11:33 +0800 Subject: [PATCH] fix(pr): include closed time in view output --- shortcuts/pr/pr.go | 94 +++++++++++++++++++++++++++++++++ shortcuts/pr/pr_test.go | 114 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 203 insertions(+), 5 deletions(-) diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 7f9984c..bf2b628 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "fmt" "net/url" + "strings" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -84,6 +85,9 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } + if err := enrichPullRequestClosedAt(ctx, env); err != nil { + return err + } return ctx.Output(env) }, }, @@ -370,3 +374,93 @@ func extractIssueID(env *output.Envelope) (int64, error) { } return int64(idFloat), nil } + +func enrichPullRequestClosedAt(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") != "" { + return nil + } + issue, ok := data["issue"].(map[string]interface{}) + if !ok { + return nil + } + issueID, ok := numberField(issue, "id") + if !ok { + return nil + } + journalsEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, int64(issueID)), nil) + if err != nil { + return err + } + closedAt := extractPullRequestClosedAt(journalsEnv) + if closedAt == "" { + return nil + } + pr["closed_at"] = closedAt + data["closed_at"] = closedAt + return nil +} + +func isClosedPullRequest(pr map[string]interface{}) bool { + if stringField(pr, "pull_request_staus") == "closed" || stringField(pr, "state") == "closed" { + return true + } + status, ok := numberField(pr, "status") + return ok && int(status) == 2 +} + +func extractPullRequestClosedAt(env *output.Envelope) string { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "" + } + rawJournals, ok := data["journals"].([]interface{}) + if !ok { + return "" + } + for i := len(rawJournals) - 1; i >= 0; i-- { + journal, ok := rawJournals[i].(map[string]interface{}) + if !ok || stringField(journal, "operate_category") != "status" { + continue + } + content := stringField(journal, "operate_content") + if !isPullRequestCloseOperation(content) { + continue + } + if updatedAt := stringField(journal, "updated_at"); updatedAt != "" { + return updatedAt + } + if createdAt := stringField(journal, "created_at"); createdAt != "" { + return createdAt + } + } + return "" +} + +func isPullRequestCloseOperation(content string) bool { + content = strings.ToLower(content) + return strings.Contains(content, "合并请求") && + (strings.Contains(content, "拒绝") || strings.Contains(content, "关闭") || strings.Contains(content, "closed")) +} + +func stringField(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func numberField(m map[string]interface{}, key string) (float64, bool) { + switch v := m[key].(type) { + case float64: + return v, true + case int: + return float64(v), true + case int64: + return float64(v), true + default: + return 0, false + } +} diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index db58cb6..90e5a07 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -53,6 +54,87 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { assertEqual(t, journalPayload["notes"], "LGTM, looks good!") } +func TestPRViewAddsClosedAtFromIssueJournal(t *testing.T) { + var issueJournalCalled bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/37.json": + writeJSON(t, w, map[string]interface{}{ + "issue": map[string]interface{}{ + "id": float64(142756), + }, + "pull_request": map[string]interface{}{ + "status": float64(2), + "pull_request_staus": "closed", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/142756/journals.json": + issueJournalCalled = true + writeJSON(t, w, map[string]interface{}{ + "journals": []map[string]interface{}{ + { + "operate_category": "pull_request", + "operate_content": "创建了合并请求", + "created_at": "2026-05-24 21:43", + }, + { + "operate_category": "status", + "operate_content": "拒绝了合并请求", + "created_at": "2026-05-25 08:58", + "updated_at": "2026-05-25 08:58", + }, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ + "id": "37", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + if !issueJournalCalled { + t.Fatal("issue journal endpoint was not called") + } + data := env.Data.(map[string]interface{}) + assertEqual(t, data["closed_at"], "2026-05-25 08:58") + prData := data["pull_request"].(map[string]interface{}) + assertEqual(t, prData["closed_at"], "2026-05-25 08:58") +} + +func TestPRViewDoesNotFetchJournalsForOpenPR(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/45.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "issue": map[string]interface{}{ + "id": float64(142793), + }, + "pull_request": map[string]interface{}{ + "status": float64(0), + "pull_request_staus": "open", + }, + }) + })) + defer server.Close() + + env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ + "id": "45", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + data := env.Data.(map[string]interface{}) + if _, ok := data["closed_at"]; ok { + t.Fatal("open PR should not include closed_at") + } +} + func TestPRCommentFailsWhenPRNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -264,19 +346,41 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) { } func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + _, err := runPRShortcutWithOutput(t, server, name, args) + return err +} + +func runPRShortcutWithOutput(t *testing.T, server *httptest.Server, name string, args map[string]string) (*output.Envelope, error) { t.Helper() shortcut := findPRShortcut(t, name) + client := &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + } ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, + Client: client, Owner: "owner", Repo: "repo", Format: "json", Args: args, } - return shortcut.Run(ctx) + err := shortcut.Run(ctx) + if err != nil { + return nil, err + } + if name != "view" { + return nil, nil + } + id := args["id"] + env, err := client.Do("GET", fmt.Sprintf("/owner/repo/pulls/%s", id), nil, nil) + if err != nil { + return nil, err + } + if err := enrichPullRequestClosedAt(ctx, env); err != nil { + return nil, err + } + return env, nil } func findPRShortcut(t *testing.T, name string) *common.Shortcut {