diff --git a/internal/client/client.go b/internal/client/client.go index d147d96..831caa7 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -42,6 +42,8 @@ func New() (*Client, error) { } func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { + path = normalizeAPIPath(c.BaseURL, path) + // Append .json suffix if not already present (GitLink API convention) // Handle paths that may already contain query strings (e.g., /path?key=val) if idx := strings.Index(path, "?"); idx != -1 { @@ -158,6 +160,18 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o return output.SuccessEnvelope(raw, meta), nil } +func normalizeAPIPath(baseURL, path string) string { + if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") { + switch { + case path == "/api": + return "" + case strings.HasPrefix(path, "/api/"): + return strings.TrimPrefix(path, "/api") + } + } + return path +} + func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) { return c.Do("GET", path, nil, query) } diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..87fb91d --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,27 @@ +package client + +import "testing" + +func TestNormalizeAPIPathStripsDuplicateAPIPrefix(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/api/v1/repos/Gitlink/gitlink-cli/contents/README.md") + want := "/v1/repos/Gitlink/gitlink-cli/contents/README.md" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} + +func TestNormalizeAPIPathKeepsRegularPath(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/projects") + want := "/projects" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} + +func TestNormalizeAPIPathKeepsAPIPrefixForNonAPIBaseURL(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn", "/api/v1/repos/Gitlink/gitlink-cli") + want := "/api/v1/repos/Gitlink/gitlink-cli" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} 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 { diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 75091a4..4d621ad 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -52,6 +52,31 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "readme", + Description: "Show repository README content", + Flags: []common.Flag{ + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, + {Name: "path", Usage: "README directory path"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + if path := ctx.Arg("path"); path != "" { + q.Set("filepath", path) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: "Create a new repository", diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go new file mode 100644 index 0000000..41305c4 --- /dev/null +++ b/shortcuts/repo/repo_test.go @@ -0,0 +1,63 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/readme.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("ref"); got != "main" { + t.Fatalf("ref query = %q, want main", got) + } + if got := r.URL.Query().Get("filepath"); got != "docs" { + t.Fatalf("filepath query = %q, want docs", got) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "file", + "name": "README.md", + "content": "# docs\n", + }); err != nil { + t.Fatalf("write response: %v", err) + } + })) + defer server.Close() + + err := runRepoShortcut(server, "readme", map[string]string{ + "ref": "main", + "path": "docs", + }) + if err != nil { + t.Fatalf("readme shortcut failed: %v", err) + } +} + +func runRepoShortcut(server *httptest.Server, name string, args map[string]string) error { + for _, shortcut := range Shortcuts() { + if shortcut.Name != name { + continue + } + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) + } + return fmt.Errorf("shortcut %q not found", name) +}