From aa0a623a73799caee6700f1b620093123ae70d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E5=A4=A9=E7=BF=94?= Date: Tue, 2 Jun 2026 16:02:59 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Raw=20API=20HT?= =?UTF-8?q?ML=20=E5=93=8D=E5=BA=94=E8=87=AA=E5=8A=A8=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E4=B8=8E=E8=AF=8A=E6=96=AD=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 GitLink API 返回 HTML 页面(如登录页)而非 JSON 数据时, 自动识别并返回结构化错误信息,包含可能原因和修复建议。 Co-Authored-By: Claude Opus 4.7 --- internal/client/client.go | 36 ++++++++++++++++++- internal/client/client_test.go | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/internal/client/client.go b/internal/client/client.go index 1d20aed..94f0cb1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -107,10 +107,21 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } } + // Detect HTML responses (GitLink returns login pages when auth is missing) + if detectHTMLResponse(respData) { + msg := "服务器返回了 HTML 页面而非 JSON 数据" + suggestion := suggestHTMLFix() + return output.ErrorEnvelope(resp.StatusCode, msg, suggestion), + &APIError{ + StatusCode: resp.StatusCode, + Code: "HTML_RESPONSE", + Message: msg + "\n" + suggestion, + } + } + // Parse JSON var raw map[string]interface{} if err := json.Unmarshal(respData, &raw); err != nil { - // Not JSON, return as-is return output.SuccessEnvelope(string(respData), nil), nil } @@ -215,3 +226,26 @@ func suggestFix(code int) string { return "" } } + +func detectHTMLResponse(data []byte) bool { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return false + } + prefixes := []string{"...`, true}, + {"html 小写开头", `...`, true}, + {"HTML 大写开头", `...`, true}, + {"doctype 小写开头", ``, true}, + {"空响应体", "", false}, + {"纯文本", `just some text`, false}, + {"空白后 HTML", ` `, true}, + {"JSON 数组", `[1,2,3]`, false}, + {"HTML 片段(无前缀)", `content`, false}, + {"XML 声明后跟 HTML", ``, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := detectHTMLResponse([]byte(tt.body)); got != tt.wantHTML { + t.Errorf("detectHTMLResponse(%q) = %v, want %v", tt.body, got, tt.wantHTML) + } + }) + } +} + +func TestClientDoHTMLResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`Sign inPlease log in`)) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + env, err := c.Do("GET", "/api/test", nil, nil) + if err == nil { + t.Fatal("expected error for HTML response") + } + if env == nil { + t.Fatal("expected envelope for HTML response") + } + if env.OK { + t.Fatal("expected OK=false for HTML response") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.Code != "HTML_RESPONSE" { + t.Fatalf("Code = %v, want HTML_RESPONSE", apiErr.Code) + } +} + +func TestSuggestHTMLFix(t *testing.T) { + msg := suggestHTMLFix() + if msg == "" { + t.Fatal("suggestHTMLFix should return a non-empty message") + } + if !strings.Contains(msg, "gitlink-cli auth login") { + t.Fatal("suggestHTMLFix should mention auth login") + } +} From 9ea0dc71b90ee9cda1ccc0e9b95b3353c34896db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E5=A4=A9=E7=BF=94?= Date: Tue, 2 Jun 2026 16:03:40 +0800 Subject: [PATCH 2/5] =?UTF-8?q?ci:=20=E6=9B=B4=E6=96=B0=20CI=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=94=AF=E6=8C=81=20Go=201.26.1=20=E5=92=8C=E5=BB=BA?= =?UTF-8?q?=E6=9C=A8=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gitea Actions: 更新 Go 版本为 1.26.1 以匹配 go.mod - 建木流水线: 新增 .devops/ci.yml,push 到 jtx_branch 自动触发 Co-Authored-By: Claude Opus 4.7 --- .devops/ci.yml | 41 +++++++++++++++++++++++++++++++++++++++++ .gitea/workflows/ci.yml | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .devops/ci.yml diff --git a/.devops/ci.yml b/.devops/ci.yml new file mode 100644 index 0000000..7fdd6d7 --- /dev/null +++ b/.devops/ci.yml @@ -0,0 +1,41 @@ +version: 2 +name: gitlink_cli_ci +description: "gitlink-cli 代码提交时自动执行 CI 检查(构建、测试、格式化)" +trigger: + webhook: gitlink@1.0.0 + event: + - ref: push + ruleset-operator: AND +global: + concurrent: 1 +workflow: + - ref: start + name: 开始 + task: start + - ref: git_clone_0 + name: 拉取代码 + task: git_clone@1.2.9 + input: + remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' + ref: '"refs/heads/jtx_branch"' + commit_id: '""' + depth: 1 + needs: + - start + - ref: ssh_cmd_0 + name: CI 检查 + task: ssh_cmd@1.1.1 + input: + ssh_pass: ((gitlink_cli_ci.ssh_pass)) + ssh_ip: '"121.41.212.97"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_cmd: >- + "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b jtx_branch https://gitlink.org.cn/jiangtx/gitlink-cli.git && cd gitlink-cli && export PATH=$PATH:/usr/local/go/bin && export GOPROXY=https://goproxy.cn,direct && go version && go build ./... && go vet ./... && go test -race ./... && output=$(gofmt -s -l .) && if [ -n \"$output\" ]; then echo '格式化检查失败:' && echo \"$output\" && exit 1; fi && echo '所有 CI 检查通过'" + needs: + - git_clone_0 + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 94fb619..a835c17 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.26.1' - name: Build run: go build ./... From 9106be35d4e21aaff6bbdbd6456bddaa046ae5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E5=A4=A9=E7=BF=94?= Date: Tue, 2 Jun 2026 17:18:23 +0800 Subject: [PATCH 3/5] feat: add repo languages/contributors/files/tags/commits shortcuts with tests Add 5 new repo shortcuts: - languages: show language breakdown - contributors: list contributors with pagination - files: list directory contents with ref/path filters - tags: list tags with pagination - commits: list commits with sha/path filters Include 13 unit tests covering normal paths and HTTP error paths. Co-Authored-By: Claude Opus 4.7 --- shortcuts/repo/repo.go | 110 ++++++++++++++++ shortcuts/repo/repo_test.go | 244 ++++++++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 4d621ad..24f467c 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -147,5 +147,115 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "languages", + Description: "Show language breakdown of a repository", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "contributors", + Description: "List contributors of a repository", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/contributors", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "files", + Description: "List files in a repository directory", + Flags: []common.Flag{ + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, + {Name: "path", Short: "p", Usage: "Directory path (default: repository root)"}, + }, + 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 p := ctx.Arg("path"); p != "" { + q.Set("filepath", p) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "tags", + Description: "List tags of a repository", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/tags", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "commits", + Description: "List commits of a repository", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch name, tag, or commit SHA"}, + {Name: "path", Short: "p", Usage: "Filter commits by file path"}, + {Name: "page", Short: "P", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if sha := ctx.Arg("sha"); sha != "" { + q.Set("sha", sha) + } + if p := ctx.Arg("path"); p != "" { + q.Set("path", p) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/commits", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 43ed15a..2008664 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -300,3 +300,247 @@ func TestRepoCreateUserNoLogin(t *testing.T) { t.Fatal("expected error when user response has no login") } } + +// --- languages --- + +func TestRepoLanguages(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/languages.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, map[string]interface{}{ + "Go": float64(85.5), + "Shell": float64(14.5), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "languages", nil) + if err != nil { + t.Fatalf("languages failed: %v", err) + } +} + +func TestRepoLanguagesHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "languages", nil) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- contributors --- + +func TestRepoContributors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/contributors.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("page") != "1" { + t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) + } + if r.URL.Query().Get("limit") != "20" { + t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) + } + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "data": []interface{}{map[string]interface{}{"login": "alice", "contributions": float64(42)}}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "contributors", map[string]string{"page": "1", "limit": "20"}) + if err != nil { + t.Fatalf("contributors failed: %v", err) + } +} + +func TestRepoContributorsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "contributors", map[string]string{"page": "1", "limit": "20"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- files --- + +func TestRepoFiles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/files.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, []interface{}{ + map[string]interface{}{"name": "README.md", "type": "file"}, + map[string]interface{}{"name": "src", "type": "dir"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "files", nil) + if err != nil { + t.Fatalf("files failed: %v", err) + } +} + +func TestRepoFilesWithRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ref") != "main" { + t.Fatalf("expected ref=main, got %s", r.URL.Query().Get("ref")) + } + if r.URL.Query().Get("filepath") != "src" { + t.Fatalf("expected filepath=src, got %s", r.URL.Query().Get("filepath")) + } + writeJSON(w, []interface{}{}) + })) + defer server.Close() + + err := runShortcut(t, server, "files", map[string]string{"ref": "main", "path": "src"}) + if err != nil { + t.Fatalf("files with ref failed: %v", err) + } +} + +func TestRepoFilesHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "files", nil) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- tags --- + +func TestRepoTags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/tags.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("page") != "1" { + t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) + } + if r.URL.Query().Get("limit") != "20" { + t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) + } + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "data": []interface{}{map[string]interface{}{"name": "v1.0.0"}}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "tags", map[string]string{"page": "1", "limit": "20"}) + if err != nil { + t.Fatalf("tags failed: %v", err) + } +} + +func TestRepoTagsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "tags", map[string]string{"page": "1", "limit": "20"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- commits --- + +func TestRepoCommits(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/commits.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("page") != "1" { + t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) + } + if r.URL.Query().Get("limit") != "20" { + t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) + } + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "data": []interface{}{ + map[string]interface{}{"sha": "abc123", "message": "initial commit"}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "commits", map[string]string{"page": "1", "limit": "20"}) + if err != nil { + t.Fatalf("commits failed: %v", err) + } +} + +func TestRepoCommitsWithFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("sha") != "main" { + t.Fatalf("expected sha=main, got %s", r.URL.Query().Get("sha")) + } + if r.URL.Query().Get("path") != "src/main.go" { + t.Fatalf("expected path=src/main.go, got %s", r.URL.Query().Get("path")) + } + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "data": []interface{}{}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "commits", map[string]string{ + "sha": "main", + "path": "src/main.go", + "page": "1", + "limit": "20", + }) + if err != nil { + t.Fatalf("commits with filters failed: %v", err) + } +} + +func TestRepoCommitsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "commits", map[string]string{"page": "1", "limit": "20"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} From fd2517f2f8cb6ae932bf2db449ad71e2ec13050f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E5=A4=A9=E7=BF=94?= Date: Tue, 2 Jun 2026 23:47:59 +0800 Subject: [PATCH 4/5] feat: add pr commits/branches/check-merge shortcuts with tests Add three new shortcuts to the pr module: - commits: list commits in a pull request (v1 API) - branches: list branches for PR creation - check-merge: check if two branches can be merged Each shortcut includes unit tests covering both success and HTTP error paths. Co-Authored-By: Claude Opus 4.7 --- shortcuts/pr/pr.go | 66 +++++++++++++++++++++++ shortcuts/pr/pr_test.go | 114 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 5fd983b..0837ba6 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -364,6 +364,72 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "commits", + Description: "List commits in a pull request", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/commits", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "branches", + Description: "List branches for pull request creation", + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/pulls/get_branches", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "check-merge", + Description: "Check if two branches can be merged", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch", Required: true}, + {Name: "base", Usage: "Target branch", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base, err := ctx.RequireArg("base") + if err != nil { + return err + } + payload := map[string]interface{}{ + "head": head, + "base": base, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls/check_can_merge", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index 6a86afe..3df6671 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -426,6 +426,120 @@ func TestPRDiffHTTPError(t *testing.T) { } } +// --- commits --- + +func TestPRCommits(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/pulls/42/commits.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(t, w, []interface{}{ + map[string]interface{}{"sha": "abc1234", "message": "fix: bug"}, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "commits", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("commits failed: %v", err) + } +} + +func TestPRCommitsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runPRShortcut(t, server, "commits", map[string]string{"id": "42"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- branches --- + +func TestPRBranches(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/pulls/get_branches.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(t, w, []interface{}{ + map[string]interface{}{"name": "master"}, + map[string]interface{}{"name": "develop"}, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "branches", map[string]string{}) + if err != nil { + t.Fatalf("branches failed: %v", err) + } +} + +func TestPRBranchesHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runPRShortcut(t, server, "branches", map[string]string{}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- check-merge --- + +func TestPRCheckMerge(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/pulls/check_can_merge.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"can_merge": true}) + })) + defer server.Close() + + err := runPRShortcut(t, server, "check-merge", map[string]string{ + "head": "feature/x", + "base": "master", + }) + if err != nil { + t.Fatalf("check-merge failed: %v", err) + } + assertEqual(t, payload["head"], "feature/x") + assertEqual(t, payload["base"], "master") +} + +func TestPRCheckMergeHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runPRShortcut(t, server, "check-merge", map[string]string{ + "head": "feature/x", + "base": "master", + }) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findPRShortcut(t, name) From 70476a3a2a1cfa2d94edb04e6675eb2432bf6e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E5=A4=A9=E7=BF=94?= Date: Wed, 3 Jun 2026 00:21:02 +0800 Subject: [PATCH 5/5] feat: add heatmap, stats, trends shortcuts to user module - heatmap: show user contribution heatmap with optional --year flag - stats: show user development statistics with optional --start-time/--end-time - trends: show user project trends - includes 11 new unit tests covering normal paths, parameter validation, and HTTP error handling Co-Authored-By: Claude Opus 4.7 --- shortcuts/user/user.go | 83 ++++++++++++++++ shortcuts/user/user_test.go | 189 ++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index cfcaa2a..f9ecc69 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -2,6 +2,7 @@ package user import ( "fmt" + "net/url" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -37,5 +38,87 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "heatmap", + Description: "Show user contribution heatmap", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "year", Short: "y", Usage: "Year (e.g. 2026)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + path := fmt.Sprintf("/users/%s/headmaps", login) + if year := ctx.Arg("year"); year != "" { + q := url.Values{} + q.Set("year", year) + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "stats", + Description: "Show user development statistics", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "start-time", Usage: "Start date (YYYY-MM-DD)"}, + {Name: "end-time", Usage: "End date (YYYY-MM-DD)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + path := fmt.Sprintf("/users/%s/statistics/develop", login) + q := url.Values{} + if st := ctx.Arg("start-time"); st != "" { + q.Set("start_time", st) + } + if et := ctx.Arg("end-time"); et != "" { + q.Set("end_time", et) + } + if len(q) > 0 { + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "trends", + Description: "Show user project trends", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/project_trends", login), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 44d504f..74546e7 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -119,3 +119,192 @@ func TestUserInfoHTTPError(t *testing.T) { t.Fatal("expected error for HTTP 500") } } + +// --- heatmap --- + +func TestUserHeatmap(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "" { + t.Fatalf("expected no year query param, got %s", r.URL.Query().Get("year")) + } + writeJSON(w, map[string]interface{}{ + "contributions": []interface{}{ + map[string]interface{}{"date": "2026-01-01", "count": float64(5)}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("heatmap failed: %v", err) + } +} + +func TestUserHeatmapWithYear(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "2025" { + t.Fatalf("expected year=2025, got %s", r.URL.Query().Get("year")) + } + writeJSON(w, map[string]interface{}{ + "contributions": []interface{}{}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice", "year": "2025"}) + if err != nil { + t.Fatalf("heatmap with year failed: %v", err) + } +} + +func TestUserHeatmapMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserHeatmapHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- stats --- + +func TestUserStats(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/statistics/develop.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_time") != "" || r.URL.Query().Get("end_time") != "" { + t.Fatal("expected no time query params") + } + writeJSON(w, map[string]interface{}{ + "pull_request_count": float64(10), + "commit_count": float64(42), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("stats failed: %v", err) + } +} + +func TestUserStatsWithTimeRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/statistics/develop.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_time") != "2026-01-01" { + t.Fatalf("expected start_time=2026-01-01, got %s", r.URL.Query().Get("start_time")) + } + if r.URL.Query().Get("end_time") != "2026-03-31" { + t.Fatalf("expected end_time=2026-03-31, got %s", r.URL.Query().Get("end_time")) + } + writeJSON(w, map[string]interface{}{ + "pull_request_count": float64(5), + "commit_count": float64(20), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{ + "login": "alice", + "start-time": "2026-01-01", + "end-time": "2026-03-31", + }) + if err != nil { + t.Fatalf("stats with time range failed: %v", err) + } +} + +func TestUserStatsMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserStatsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- trends --- + +func TestUserTrends(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/project_trends.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, []interface{}{ + map[string]interface{}{"id": float64(1), "name": "created project"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("trends failed: %v", err) + } +} + +func TestUserTrendsMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserTrendsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +}