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 01/19] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Raw=20API=20?= =?UTF-8?q?HTML=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 02/19] =?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 03/19] 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 04/19] 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 05/19] 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") + } +} From 044e736a1b2025fe39453b09000e2c9dbff75ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E8=BF=AA=E6=96=87?= <3117675914@qq.com> Date: Wed, 3 Jun 2026 08:33:52 +0800 Subject: [PATCH 06/19] =?UTF-8?q?fix:=20detectHTMLResponse=20=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20XML=20=E5=A3=B0=E6=98=8E=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20HTML=20=E5=93=8D=E5=BA=94=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 json.Unmarshal 之前检测 HTML 响应 - detectHTMLResponse 先 strip 声明再检查 HTML 前缀 - 返回中文诊断提示替代原始 JSON parse error - 修复 Code Review: TestDetectHTMLResponse/XML_声明后跟_HTML Co-Authored-By: Claude Opus 4.8 --- internal/client/client.go | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/internal/client/client.go b/internal/client/client.go index 1d20aed..98491e3 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -107,6 +107,18 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } } + // Detect HTML response (avoid returning login page as normal data) + 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 { @@ -201,6 +213,41 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) return c.Do("DELETE", path, nil, query) } +// detectHTMLResponse detects whether the response body is an HTML page instead of JSON. +// It first strips any XML declaration () before checking for HTML prefixes. +func detectHTMLResponse(data []byte) bool { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return false + } + // Skip leading XML declaration (e.g., ) + if bytes.HasPrefix(trimmed, []byte("")); idx != -1 { + trimmed = bytes.TrimSpace(trimmed[idx+2:]) + } + } + if len(trimmed) == 0 { + return false + } + // Check for HTML document prefixes + prefixes := []string{" Date: Wed, 3 Jun 2026 09:02:06 +0800 Subject: [PATCH 07/19] feat: issue +journals/+series-update, ci +activate/+deactivate/+authorize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - issue +journals: 查看 Issue 活动日志 - issue +series-update: 批量更新 Issue 状态 - ci +activate: 激活 CI/CD - ci +deactivate: 停用 CI/CD - ci +authorize: 检查 CI/CD 授权状态 Co-Authored-By: Claude Opus 4.8 --- shortcuts/ci/ci.go | 42 ++++++++++++++++++++++++ shortcuts/issue/issue.go | 70 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index 6794c47..0cd3b14 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -95,6 +95,48 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } return ctx.Output(env) }, + { + Name: "activate", + Description: "为仓库激活 CI/CD 功能", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/activate", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "deactivate", + Description: "停用仓库的 CI/CD 功能", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/deactivate", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "authorize", + Description: "检查仓库的 CI/CD 授权状态", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/ci_authorize", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, }, } } diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 808d7fd..bab0f69 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -363,6 +363,76 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "journals", + Description: "查看 Issue 的活动日志(评论、状态变更等)", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue 编号(网页 URL 中的数字)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := ctx.RequireArg("number") + if err != nil { + return err + } + path := fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number) + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "series-update", + Description: "批量更新多个 Issue 的状态(一键关闭/重开多个 Issue)", + Flags: []common.Flag{ + {Name: "ids", Usage: "Issue ID 列表(逗号分隔,如 1,2,3)", Required: true}, + {Name: "status", Short: "s", Usage: "目标状态: open / closed", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + idsStr, err := ctx.RequireArg("ids") + if err != nil { + return err + } + status, err := ctx.RequireArg("status") + if err != nil { + return err + } + + // 解析逗号分隔的 ID 列表 + idParts := strings.Split(idsStr, ",") + ids := make([]int, 0, len(idParts)) + for _, p := range idParts { + id, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + return fmt.Errorf("无效的 Issue ID: %s", p) + } + ids = append(ids, id) + } + + // 转换状态为数字 + statusID, err := normalizeIssueStatus(status) + if err != nil { + return err + } + + body := map[string]interface{}{ + "ids": ids, + "status_id": statusID, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/issues/series_update", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } From 0017147cbd714360862a095748d687b8ae6b5862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E8=BF=AA=E6=96=87?= <3117675914@qq.com> Date: Wed, 3 Jun 2026 09:14:57 +0800 Subject: [PATCH 08/19] feat: org +teams/+create-team/+remove-user, search +code/+issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - org +teams: 列出组织下的所有团队 - org +create-team: 在组织下创建新团队 - org +remove-user: 从组织中移除成员 - search +code: 在仓库中搜索代码 - search +issues: 搜索 Issue(支持状态/标签/作者过滤) Co-Authored-By: Claude Opus 4.8 --- shortcuts/org/org.go | 66 ++++++++++++++++++++++++++++++++++ shortcuts/search/search.go | 73 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index f0b5e72..1003b6b 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -85,6 +85,72 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } return ctx.Output(env) }, + { + Name: "teams", + Description: "列出组织下的所有团队", + Flags: []common.Flag{ + {Name: "id", Usage: "组织 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s/teams", id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create-team", + Description: "在组织下创建新团队", + Flags: []common.Flag{ + {Name: "id", Usage: "组织 ID", Required: true}, + {Name: "name", Short: "n", Usage: "团队名称", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + body := map[string]interface{}{"name": name} + env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", id), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "remove-user", + Description: "从组织中移除成员", + Flags: []common.Flag{ + {Name: "id", Usage: "组织 ID", Required: true}, + {Name: "user", Short: "u", Usage: "要移除的用户 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + orgID, err := ctx.RequireArg("id") + if err != nil { + return err + } + userID, err := ctx.RequireArg("user") + if err != nil { + return err + } + path := fmt.Sprintf("/organizations/%s/organization_users/%s", orgID, userID) + env, err := ctx.CallAPI("DELETE", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, }, } } diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index a0ee4c2..ab81f91 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -1,6 +1,7 @@ package search import ( + "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -51,6 +52,78 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } return ctx.Output(env) }, + { + Name: "code", + Description: "在仓库中搜索代码", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "搜索关键词", Required: true}, + {Name: "owner", Usage: "仓库所有者(可选,限定范围)"}, + {Name: "repo", Usage: "仓库名称(可选,限定范围)"}, + {Name: "language", Usage: "编程语言过滤(如 go, python)"}, + {Name: "page", Short: "p", Usage: "页码", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } + q := url.Values{} + q.Set("keyword", keyword) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if lang := ctx.Arg("language"); lang != "" { + q.Set("language", lang) + } + path := "/search/code" + if owner := ctx.Arg("owner"); owner != "" { + if repo := ctx.Arg("repo"); repo != "" { + path = fmt.Sprintf("/%s/%s/search/code", owner, repo) + } + } + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "issues", + Description: "搜索 Issue", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "搜索关键词", Required: true}, + {Name: "state", Short: "s", Usage: "状态过滤: open/closed/all", Default: "all"}, + {Name: "label", Usage: "标签过滤"}, + {Name: "author", Usage: "作者过滤"}, + {Name: "page", Short: "p", Usage: "页码", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } + q := url.Values{} + q.Set("keyword", keyword) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if state := ctx.Arg("state"); state != "" && state != "all" { + q.Set("state", state) + } + if label := ctx.Arg("label"); label != "" { + q.Set("label", label) + } + if author := ctx.Arg("author"); author != "" { + q.Set("author", author) + } + env, err := ctx.CallAPIWithQuery("GET", "/search/issues", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, }, } } From b748a95511f7de86c30c5704299a499484a6d474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E8=BF=AA=E6=96=87?= <3117675914@qq.com> Date: Wed, 3 Jun 2026 09:19:18 +0800 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20=E6=96=B0=E5=BB=BA=20notification?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=E5=B9=B6=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notification +list: 列出通知(支持已读/参与过滤) - notification +read: 标记单条通知为已读 - notification +read-all: 标记所有通知为已读 - register.go: 添加 notification 模块的 import、groups、descriptions Co-Authored-By: Claude Opus 4.8 --- shortcuts/notification/notification.go | 68 ++++++++++++++++++++++++++ shortcuts/register.go | 5 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 shortcuts/notification/notification.go diff --git a/shortcuts/notification/notification.go b/shortcuts/notification/notification.go new file mode 100644 index 0000000..485c73d --- /dev/null +++ b/shortcuts/notification/notification.go @@ -0,0 +1,68 @@ +package notification + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "列出通知", + Flags: []common.Flag{ + {Name: "all", Usage: "显示所有通知(含已读)", Bool: true, Default: "false"}, + {Name: "participating", Usage: "仅显示参与的通知", Bool: true, Default: "false"}, + {Name: "page", Short: "p", Usage: "页码", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + q.Set("all", "true") + } + if ctx.Arg("participating") == "true" { + q.Set("participating", "true") + } + env, err := ctx.CallAPIWithQuery("GET", "/notifications", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "read", + Description: "标记单条通知为已读", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "通知 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", fmt.Sprintf("/notifications/%s", id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "read-all", + Description: "标记所有通知为已读", + Run: func(ctx *common.RuntimeContext) error { + env, err := ctx.CallAPI("PUT", "/notifications", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 3c814e0..5cc5e14 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -12,6 +12,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/member" "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" + "github.com/gitlink-org/gitlink-cli/shortcuts/notification" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pipeline" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" @@ -35,6 +36,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "label": label.Shortcuts(), "member": member.Shortcuts(), "milestone": milestone.Shortcuts(), + "notification": notification.Shortcuts(), "pipeline": pipeline.Shortcuts(), "pr": pr.Shortcuts(tr), "release": release.Shortcuts(tr), @@ -53,7 +55,8 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "issue": tr.T("cmd.issue.short"), "label": "Issue label operations", "member": "Repository member operations", - "milestone": "Milestone operations", + "milestone": "Milestone operations" + "notification": "Notification operations", "pipeline": "Pipeline operations", "pr": tr.T("cmd.pr.short"), "release": tr.T("cmd.release.short"), From d50b3c7364029e1368e5c80657a4e0b1b3902ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E8=BF=AA=E6=96=87?= <3117675914@qq.com> Date: Wed, 3 Jun 2026 09:39:48 +0800 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20Skills=20=E6=96=87=E4=BB=B6=20api?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E6=9B=BF=E6=8D=A2=E4=B8=BA=20Shortcut=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=20(~107=20=E5=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 skills/ 目录下 21 个文件中的 gitlink-cli api 命令 替换为对应的 Shortcut 命令,修复因 Token 注入不完整导致的 HTML 响应问题。 主要替换: - api GET /:owner/:repo/languages → repo +languages - api GET /:owner/:repo/sub_entries → repo +files - api GET /:owner/:repo/raw/... → repo +raw - api POST /:owner/:repo/create_file → repo +create-file - api POST /:owner/:repo/pulls/:id/reviews → pr +review - api GET /:owner/:repo/pulls/:id → pr +view - 等共 107 处替换 注: 6 处 pm 域命令暂留(pm 模块尚未构建) Co-Authored-By: Claude Opus 4.8 --- skills/gitlink-ci/SKILL.md | 6 +-- skills/gitlink-code-review/REFERENCE.md | 8 ++-- skills/gitlink-code-review/SKILL.md | 38 +++++++++---------- .../examples/pr-review-workflow.md | 4 +- skills/gitlink-commit-quality/SKILL.md | 2 +- skills/gitlink-compliance/REFERENCE.md | 6 +-- skills/gitlink-compliance/SKILL.md | 30 +++++++-------- skills/gitlink-insight/REFERENCE.md | 10 ++--- skills/gitlink-insight/SKILL.md | 24 ++++++------ .../examples/sprint-report-workflow.md | 2 +- skills/gitlink-issue/SKILL.md | 4 +- skills/gitlink-org/SKILL.md | 6 +-- skills/gitlink-pr/SKILL.md | 22 +++++------ .../references/gitlink-pr-create.md | 6 +-- skills/gitlink-release-auto/SKILL.md | 4 +- skills/gitlink-repo/SKILL.md | 12 +++--- skills/gitlink-shared/SKILL.md | 10 ++--- .../references/troubleshooting.md | 6 +-- skills/gitlink-user/SKILL.md | 6 +-- skills/gitlink-workflow/SKILL.md | 8 ++-- 20 files changed, 107 insertions(+), 107 deletions(-) diff --git a/skills/gitlink-ci/SKILL.md b/skills/gitlink-ci/SKILL.md index 016daaa..385ae12 100644 --- a/skills/gitlink-ci/SKILL.md +++ b/skills/gitlink-ci/SKILL.md @@ -45,11 +45,11 @@ gitlink-cli ci +stop --build 42 ```bash # 激活 CI -gitlink-cli api POST /:owner/:repo/activate +gitlink-cli ci +activate # 停用 CI -gitlink-cli api DELETE /:owner/:repo/deactivate +gitlink-cli ci +deactivate # CI 授权状态 -gitlink-cli api GET /:owner/:repo/ci_authorize +gitlink-cli ci +authorize ``` diff --git a/skills/gitlink-code-review/REFERENCE.md b/skills/gitlink-code-review/REFERENCE.md index 0011473..f47959b 100644 --- a/skills/gitlink-code-review/REFERENCE.md +++ b/skills/gitlink-code-review/REFERENCE.md @@ -160,7 +160,7 @@ gitlink-cli repo +info --owner --repo --format json ### 获取仓库文件列表 ```bash -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' --format json +gitlink-cli repo +files --query 'filepath=&ref=' --format json ``` **返回字段说明:** @@ -177,7 +177,7 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=/ +gitlink-cli repo +raw --ref=/ ``` --- @@ -213,7 +213,7 @@ gitlink-cli user +me --format json ### 获取用户信息 ```bash -gitlink-cli api GET /users/:user_id --format json +gitlink-cli user +info --login --format json ``` | 字段 | 类型 | 说明 | diff --git a/skills/gitlink-code-review/SKILL.md b/skills/gitlink-code-review/SKILL.md index 3294536..e43b0ab 100644 --- a/skills/gitlink-code-review/SKILL.md +++ b/skills/gitlink-code-review/SKILL.md @@ -109,13 +109,13 @@ gitlink-cli pr +diff --id --format json ```bash # 方式 1:提交整体 Review -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ +gitlink-cli pr +review --body '{ "body": "## 审查结果\n\n### 🔴 Critical\n...\n\n### 🟡 Warning\n...\n\n总体评价:...", "event": "COMMENT" }' # 方式 2:在特定行添加内联评论(逐条提交) -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ +gitlink-cli pr +review --body '{ "body": "这里存在安全风险:用户输入未经转义直接拼接到 SQL 查询中,存在注入风险。建议使用参数化查询。", "event": "COMMENT", "commit_id": "", @@ -164,18 +164,18 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ gitlink-cli repo +info --owner --repo --format json # 2. 获取仓库文件列表(遍历关键目录) -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master' -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=tests&ref=master' +gitlink-cli repo +files --query 'filepath=src&ref=master' +gitlink-cli repo +files --query 'filepath=tests&ref=master' # 3. 获取关键文件内容 -gitlink-cli api GET /:owner/:repo/raw/master/README.md -gitlink-cli api GET /:owner/:repo/raw/master/.gitignore -gitlink-cli api GET /:owner/:repo/raw/master/.eslintrc.js # 或类似配置 -gitlink-cli api GET /:owner/:repo/raw/master/package.json # 或 go.mod, Cargo.toml +gitlink-cli repo +raw --ref=master/README.md +gitlink-cli repo +raw --ref=master/.gitignore +gitlink-cli repo +raw --ref=master/.eslintrc.js # 或类似配置 +gitlink-cli repo +raw --ref=master/package.json # 或 go.mod, Cargo.toml # 4. 获取语言统计和贡献者 -gitlink-cli api GET /:owner/:repo/languages -gitlink-cli api GET /:owner/:repo/contributors +gitlink-cli repo +languages +gitlink-cli repo +contributors ``` **健康度检查清单:** @@ -233,7 +233,7 @@ gitlink-cli issue +view --id --format json # 3. 根据内容智能分类 # 分析标题和描述后,通过 Raw API 打标签 -gitlink-cli api POST /:owner/:repo/issues/:id --body '{ +gitlink-cli issue +update --number '{ "issue_tag_ids": [], "done_ratio": 0, "subject": "<原始标题>", @@ -261,28 +261,28 @@ gitlink-cli api POST /:owner/:repo/issues/:id --body '{ ```bash # 获取 PR 详情 -gitlink-cli api GET /:owner/:repo/pulls/:id --format json +gitlink-cli pr +view --id --format json # 获取 PR 变更文件列表 -gitlink-cli api GET /:owner/:repo/pulls/:id/files --format json +gitlink-cli pr +files --format json # 获取 PR Diff -gitlink-cli api GET /:owner/:repo/pulls/:id/diff --format json +gitlink-cli pr +diff --format json # 提交 PR Review -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}' +gitlink-cli pr +review --body '{"body":"...","event":"COMMENT"}' # 获取仓库文件列表 -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' +gitlink-cli repo +files --query 'filepath=&ref=' # 获取仓库语言统计 -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --format json # 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json # 获取仓库动态 -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ## 代码审查最佳实践 diff --git a/skills/gitlink-code-review/examples/pr-review-workflow.md b/skills/gitlink-code-review/examples/pr-review-workflow.md index 2028acc..5409630 100644 --- a/skills/gitlink-code-review/examples/pr-review-workflow.md +++ b/skills/gitlink-code-review/examples/pr-review-workflow.md @@ -117,7 +117,7 @@ gitlink-cli pr +diff --id 42 --format json ```bash # 提交整体 Review 评论 -gitlink-cli api POST /Gitlink/forgeplus/pulls/42/reviews --body '{ +gitlink-cli pr +review --id 42 --owner Gitlink --repo forgeplus --body '{ "body": "## PR #42 代码审查报告\n\n### 🔴 Critical\n\n1. **JWT Secret 硬编码** — `src/config.py:15`\n JWT_SECRET 硬编码在源码中。建议使用 `os.getenv(\"JWT_SECRET\")`。\n\n2. **SQL 注入风险** — `src/auth/login.py:42`\n 直接拼接用户输入到 SQL 查询。建议使用参数化查询。\n\n### 🟡 Warning\n\n1. **密码明文存储** — 建议使用 bcrypt 哈希处理。\n\n### 总体评价\n\n代码整体结构清晰,测试覆盖良好。建议修复 Critical 问题后合并。", "event": "COMMENT" }' @@ -160,5 +160,5 @@ gitlink-cli pr +files --id --format json gitlink-cli pr +diff --id --format json # 提交 Review -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}' +gitlink-cli pr +review --body '{"body":"...","event":"COMMENT"}' ``` diff --git a/skills/gitlink-commit-quality/SKILL.md b/skills/gitlink-commit-quality/SKILL.md index 939261f..a06d977 100644 --- a/skills/gitlink-commit-quality/SKILL.md +++ b/skills/gitlink-commit-quality/SKILL.md @@ -62,7 +62,7 @@ metadata: gitlink-cli pr +diff --id --owner --repo --format json # 或通过 Raw API 获取提交详情 -gitlink-cli api GET /:owner/:repo/pulls/:pr_id/commits --format json +gitlink-cli pr +commits --format json ``` ### Commit Message 质量检查清单 diff --git a/skills/gitlink-compliance/REFERENCE.md b/skills/gitlink-compliance/REFERENCE.md index e7ac7c4..af77c01 100644 --- a/skills/gitlink-compliance/REFERENCE.md +++ b/skills/gitlink-compliance/REFERENCE.md @@ -7,7 +7,7 @@ ### 读取文件内容 ```bash -gitlink-cli api GET /:owner/:repo/raw// +gitlink-cli repo +raw --ref=/ ``` **说明:** 直接返回文件原始内容,用于检查 LICENSE、README、CONTRIBUTING 等文件。 @@ -15,7 +15,7 @@ gitlink-cli api GET /:owner/:repo/raw// ### 获取文件列表 ```bash -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' --format json +gitlink-cli repo +files --query 'filepath=&ref=' --format json ``` | 字段 | 类型 | 说明 | @@ -45,7 +45,7 @@ gitlink-cli repo +info --owner --repo --format json ### 获取贡献者列表 ```bash -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json ``` 用于检查贡献者是否签署了 CLA/DCO。 diff --git a/skills/gitlink-compliance/SKILL.md b/skills/gitlink-compliance/SKILL.md index bee901a..eeaa25a 100644 --- a/skills/gitlink-compliance/SKILL.md +++ b/skills/gitlink-compliance/SKILL.md @@ -39,10 +39,10 @@ metadata: ```bash # 1. 获取仓库文件结构 -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=master' +gitlink-cli repo +files --query 'filepath=&ref=master' # 2. 读取 LICENSE 文件 -gitlink-cli api GET /:owner/:repo/raw/master/LICENSE +gitlink-cli repo +raw --ref=master/LICENSE # 3. 检查关键文档是否存在 # 检查以下文件是否存在: @@ -54,14 +54,14 @@ gitlink-cli api GET /:owner/:repo/raw/master/LICENSE # - README.md # 4. 获取依赖配置 -gitlink-cli api GET /:owner/:repo/raw/master/package.json # Node.js -gitlink-cli api GET /:owner/:repo/raw/master/go.mod # Go -gitlink-cli api GET /:owner/:repo/raw/master/requirements.txt # Python -gitlink-cli api GET /:owner/:repo/raw/master/Cargo.toml # Rust -gitlink-cli api GET /:owner/:repo/raw/master/pom.xml # Java/Maven +gitlink-cli repo +raw --ref=master/package.json # Node.js +gitlink-cli repo +raw --ref=master/go.mod # Go +gitlink-cli repo +raw --ref=master/requirements.txt # Python +gitlink-cli repo +raw --ref=master/Cargo.toml # Rust +gitlink-cli repo +raw --ref=master/pom.xml # Java/Maven # 5. 获取源文件检查(按语言采样) -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master' +gitlink-cli repo +files --query 'filepath=src&ref=master' # 6. 获取仓库基本信息 gitlink-cli repo +info --owner --repo --format json @@ -146,7 +146,7 @@ gitlink-cli repo +info --owner --repo --format json ```bash # 1. 获取依赖配置文件 -gitlink-cli api GET /:owner/:repo/raw/master/package.json +gitlink-cli repo +raw --ref=master/package.json ``` ### 许可证兼容性参考 @@ -181,10 +181,10 @@ gitlink-cli api GET /:owner/:repo/raw/master/package.json ```bash # 1. 遍历源文件目录 -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master' +gitlink-cli repo +files --query 'filepath=src&ref=master' # 2. 采样检查源文件头部(取前 5-10 行) -gitlink-cli api GET /:owner/:repo/raw/master/src/main.py +gitlink-cli repo +raw --ref=master/src/main.py ``` ### 标准版权声明模板 @@ -210,16 +210,16 @@ gitlink-cli api GET /:owner/:repo/raw/master/src/main.py ```bash # 获取文件内容 -gitlink-cli api GET /:owner/:repo/raw// +gitlink-cli repo +raw --ref=/ # 获取文件列表(遍历目录) -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' +gitlink-cli repo +files --query 'filepath=&ref=' # 获取仓库信息 -gitlink-cli api GET /:owner/:repo --format json +gitlink-cli repo +info --format json # 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json ``` ## 注意事项 diff --git a/skills/gitlink-insight/REFERENCE.md b/skills/gitlink-insight/REFERENCE.md index 4782a99..eb47435 100644 --- a/skills/gitlink-insight/REFERENCE.md +++ b/skills/gitlink-insight/REFERENCE.md @@ -87,7 +87,7 @@ gitlink-cli release +list --format json ### 仓库文件列表 ```bash -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' --format json +gitlink-cli repo +files --query 'filepath=&ref=' --format json ``` | 字段 | 类型 | 说明 | @@ -100,7 +100,7 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' - ### 仓库语言统计 ```bash -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --format json ``` **返回示例:** `{ "Ruby": "90.2%", "JavaScript": "6.1%", "CSS": "3.7%" }` @@ -108,7 +108,7 @@ gitlink-cli api GET /:owner/:repo/languages --format json ### 贡献者列表 ```bash -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json ``` | 字段 | 类型 | 说明 | @@ -121,13 +121,13 @@ gitlink-cli api GET /:owner/:repo/contributors --format json ### 仓库动态 ```bash -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ### 获取用户详情 ```bash -gitlink-cli api GET /users/:user_id --format json +gitlink-cli user +info --login --format json ``` | 字段 | 类型 | 说明 | diff --git a/skills/gitlink-insight/SKILL.md b/skills/gitlink-insight/SKILL.md index 436c66c..74d5ae4 100644 --- a/skills/gitlink-insight/SKILL.md +++ b/skills/gitlink-insight/SKILL.md @@ -49,13 +49,13 @@ gitlink-cli pr +list --state open --format json gitlink-cli pr +list --state merged --format json # 4. 获取仓库文件结构(检查文档、CI 配置) -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=master' +gitlink-cli repo +files --query 'filepath=&ref=master' # 5. 获取语言统计 -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --format json # 6. 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json ``` ### 分析指标 @@ -131,7 +131,7 @@ gitlink-cli release +list --format json gitlink-cli issue +list --state open --format json # 5. 获取项目动态 -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ### 输出格式 @@ -172,7 +172,7 @@ gitlink-cli api GET /:owner/:repo/activity --format json ```bash # 1. 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json # 2. 获取每个贡献者的 PR # 通过 PR 列表按 author 过滤 @@ -253,25 +253,25 @@ gitlink-cli issue +list --state open --format json ```bash # 仓库信息 -gitlink-cli api GET /:owner/:repo --format json +gitlink-cli repo +info --format json # 仓库语言统计 -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --format json # 贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --format json # 仓库动态 -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json # 文件列表(检查文档/配置完整性) -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=master' +gitlink-cli repo +files --query 'filepath=&ref=master' # 获取用户信息 -gitlink-cli api GET /users/:user_id --format json +gitlink-cli user +info --login --format json # 用户贡献热力图 -gitlink-cli api GET /users/:user_id/headmaps --format json +gitlink-cli user +heatmap --format json ``` ## 注意事项 diff --git a/skills/gitlink-insight/examples/sprint-report-workflow.md b/skills/gitlink-insight/examples/sprint-report-workflow.md index 74c917f..0aadbc3 100644 --- a/skills/gitlink-insight/examples/sprint-report-workflow.md +++ b/skills/gitlink-insight/examples/sprint-report-workflow.md @@ -28,7 +28,7 @@ gitlink-cli pr +list --state open --format json ## Step 3:获取项目动态 ```bash -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ## Step 4:生成周报 diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 5688853..fa5d063 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -71,10 +71,10 @@ gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob ```bash # 获取 Issue 评论列表(使用 v1 API,按 issue number 查询) -gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals +gitlink-cli issue +journals # 批量更新 Issue(仍使用旧版 API,需传数据库 ID) -gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"status_id":"closed"}' +gitlink-cli issue +series-update --body '{"ids":[1,2,3],"status_id":"closed"}' ``` ## GitLink Issue 字段映射 diff --git a/skills/gitlink-org/SKILL.md b/skills/gitlink-org/SKILL.md index aef61a9..d1943cc 100644 --- a/skills/gitlink-org/SKILL.md +++ b/skills/gitlink-org/SKILL.md @@ -38,9 +38,9 @@ gitlink-cli org +create --name my-org --description "我的组织" ```bash # 组织团队管理 -gitlink-cli api GET /organizations/:id/teams -gitlink-cli api POST /organizations/:id/teams --body '{"name":"dev-team"}' +gitlink-cli org +teams +gitlink-cli org +create-team --body '{"name":"dev-team"}' # 移除成员 -gitlink-cli api DELETE /organizations/:id/organization_users/:uid +gitlink-cli org +remove-user ``` diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index 77e0cec..c07d151 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -113,7 +113,7 @@ gitlink-cli pr +create --owner TargetOrg --repo target-repo \ gitlink-cli branch +create --name feature-branch --from master # 2. 在分支上创建/修改文件(content 必须 base64 编码) -gitlink-cli api POST /:owner/:repo/create_file --body '{ +gitlink-cli repo +create-file --body '{ "filepath": "new-file.md", "content": "", "branch": "feature-branch", @@ -128,31 +128,31 @@ gitlink-cli pr +create --title "feat: 新功能" --head feature-branch --base ma ```bash # 创建文件(content 必须 base64 编码) -gitlink-cli api POST /:owner/:repo/create_file --body '{"filepath":"file.md","content":"","branch":"dev","message":"add file"}' +gitlink-cli repo +create-file --body '{"filepath":"file.md","content":"","branch":"dev","message":"add file"}' # 更新文件(需要先通过 sub_entries 获取文件 SHA) -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=file.md&ref=dev' +gitlink-cli repo +files --query 'filepath=file.md&ref=dev' # 从 entries.sha 获取 SHA,然后: -gitlink-cli api PUT /:owner/:repo/update_file --body '{"filepath":"file.md","content":"","sha":"","branch":"dev","message":"update file"}' +gitlink-cli repo +update-file --body '{"filepath":"file.md","content":"","sha":"","branch":"dev","message":"update file"}' # 检查是否可合并 -gitlink-cli api POST /:owner/:repo/pulls/check_can_merge --body '{"head":"dev","base":"main"}' +gitlink-cli pr +check-merge --body '{"head":"dev","base":"main"}' # 创建 Review -gitlink-cli api POST /v1/:owner/:repo/pulls/:id/reviews --body '{"content":"LGTM","status":"approved"}' +gitlink-cli pr +review --body '{"content":"LGTM","status":"approved"}' # 查看 Review 列表(支持 status 过滤) -gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews -gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews?status=approved +gitlink-cli pr +reviews +gitlink-cli pr +reviews?status=approved # 获取可用分支 -gitlink-cli api GET /:owner/:repo/pulls/get_branches +gitlink-cli pr +branches # 查看 PR patchset/version 列表(v1 API) -gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions +gitlink-cli pr +versions # 查看指定 patchset/version diff(可通过 filepath 过滤文件) -gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff +gitlink-cli pr +versions/:version_id/diff ``` ## 注意事项 diff --git a/skills/gitlink-pr/references/gitlink-pr-create.md b/skills/gitlink-pr/references/gitlink-pr-create.md index bde418a..beea3c0 100644 --- a/skills/gitlink-pr/references/gitlink-pr-create.md +++ b/skills/gitlink-pr/references/gitlink-pr-create.md @@ -53,7 +53,7 @@ gitlink-cli branch +create --name feature-branch --from master CONTENT=$(echo -n "文件内容" | base64) # 通过 Raw API 创建文件 -gitlink-cli api POST /:owner/:repo/create_file --body '{ +gitlink-cli repo +create-file --body '{ "filepath": "path/to/new-file.md", "content": "'$CONTENT'", "branch": "feature-branch", @@ -73,12 +73,12 @@ gitlink-cli pr +create --title "feat: 新功能" --head feature-branch --base ma ```bash # Step 2a: 获取文件 SHA -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=path/to/file.md&ref=feature-branch' +gitlink-cli repo +files --query 'filepath=path/to/file.md&ref=feature-branch' # 从返回的 entries.sha 获取 SHA 值 # Step 2b: 更新文件(content 必须 base64 编码) CONTENT=$(echo -n "更新后的内容" | base64) -gitlink-cli api PUT /:owner/:repo/update_file --body '{ +gitlink-cli repo +update-file --body '{ "filepath": "path/to/file.md", "content": "'$CONTENT'", "sha": "<从 sub_entries 获取的 sha>", diff --git a/skills/gitlink-release-auto/SKILL.md b/skills/gitlink-release-auto/SKILL.md index 6200905..ea4914d 100644 --- a/skills/gitlink-release-auto/SKILL.md +++ b/skills/gitlink-release-auto/SKILL.md @@ -49,7 +49,7 @@ gitlink-cli release +list --owner --repo --format json # 取 data.releases[0].tag_name 作为当前版本 # 获取标签列表 -gitlink-cli api GET /:owner/:repo/tags --format json +gitlink-cli repo +tags --format json ``` ### 步骤 2:获取自上次发版以来的提交 @@ -154,7 +154,7 @@ gitlink-cli pr +list --state merged --owner --repo --format json gitlink-cli release +list --owner --repo --format json # Step 2:获取变更内容(提交历史) -gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=30&ref=master' --format json +gitlink-cli repo +commits --query 'page=1&limit=30&ref=master' --format json # Step 3:生成 Release Notes(AI 分析提交后组织内容) diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 9fabb8e..10b3ceb 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -55,22 +55,22 @@ Shortcuts 未覆盖的仓库操作可用 Raw API: ```bash # 获取 README -gitlink-cli api GET /:owner/:repo/readme +gitlink-cli repo +readme # 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors +gitlink-cli repo +contributors # 获取语言统计 -gitlink-cli api GET /:owner/:repo/languages +gitlink-cli repo +languages # 获取提交列表 -gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=20' +gitlink-cli repo +commits --query 'page=1&limit=20' # 获取标签列表 -gitlink-cli api GET /:owner/:repo/tags +gitlink-cli repo +tags # 获取文件内容 -gitlink-cli api GET /:owner/:repo/raw/main/README.md +gitlink-cli repo +raw --ref=main/README.md ``` ## 注意事项 diff --git a/skills/gitlink-shared/SKILL.md b/skills/gitlink-shared/SKILL.md index d1f9d33..858a827 100644 --- a/skills/gitlink-shared/SKILL.md +++ b/skills/gitlink-shared/SKILL.md @@ -90,7 +90,7 @@ gitlink-cli auth login | 层级 | 格式 | 示例 | 适用场景 | |------|------|------|----------| | Shortcuts | `gitlink-cli +` | `gitlink-cli repo +info` | 高频操作,推荐优先使用 | -| Raw API | `gitlink-cli api ` | `gitlink-cli api GET /users/me` | Shortcuts 未覆盖的接口 | +| Raw API | `gitlink-cli api ` | `gitlink-cli user +me` | Shortcuts 未覆盖的接口 | ## GitLink API 注意事项 @@ -119,7 +119,7 @@ gitlink-cli auth login ```bash # content 必须 base64 编码 CONTENT=$(echo -n "文件内容" | base64) -gitlink-cli api POST /:owner/:repo/create_file --body '{ +gitlink-cli repo +create-file --body '{ "filepath": "path/to/file.md", "content": "", "branch": "feature-branch", @@ -131,11 +131,11 @@ gitlink-cli api POST /:owner/:repo/create_file --body '{ ```bash # Step 1: 获取文件 SHA -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=path/to/file.md&ref=branch-name' +gitlink-cli repo +files --query 'filepath=path/to/file.md&ref=branch-name' # 从返回的 entries.sha 获取 SHA 值 # Step 2: 更新文件(content 必须 base64 编码) -gitlink-cli api PUT /:owner/:repo/update_file --body '{ +gitlink-cli repo +update-file --body '{ "filepath": "path/to/file.md", "content": "", "sha": "<从sub_entries获取的sha>", @@ -148,7 +148,7 @@ gitlink-cli api PUT /:owner/:repo/update_file --body '{ ```bash # 需要文件 SHA -gitlink-cli api DELETE /:owner/:repo/delete_file --body '{ +gitlink-cli repo +delete-file --body '{ "filepath": "path/to/file.md", "sha": "", "branch": "master", diff --git a/skills/gitlink-shared/references/troubleshooting.md b/skills/gitlink-shared/references/troubleshooting.md index 79040fe..d0aeffa 100644 --- a/skills/gitlink-shared/references/troubleshooting.md +++ b/skills/gitlink-shared/references/troubleshooting.md @@ -58,7 +58,7 @@ gitlink-cli repo +info --owner xxx --repo yyy gitlink-cli issue +create -t "标题" -b "描述" # 或使用 Raw API 时添加 done_ratio -gitlink-cli api POST /:owner/:repo/issues --body '{ +gitlink-cli issue +create '{ "subject": "标题", "description": "描述", "done_ratio": 0 @@ -75,7 +75,7 @@ gitlink-cli api POST /:owner/:repo/issues --body '{ gitlink-cli issue +close -i 123 # 或使用 Raw API 时先 GET 当前 Issue,再添加 subject 和 description -gitlink-cli api PUT /:owner/:repo/issues/123 --body '{ +gitlink-cli issue +update --number 123 '{ "subject": "当前标题", "description": "当前描述", "status_id": 5 @@ -152,7 +152,7 @@ gitlink-cli --debug ### 查看完整 API 请求 ```bash -gitlink-cli api GET /users/me --debug +gitlink-cli user +me --debug ``` ### 检查认证状态 diff --git a/skills/gitlink-user/SKILL.md b/skills/gitlink-user/SKILL.md index 0d1aa4f..7574a75 100644 --- a/skills/gitlink-user/SKILL.md +++ b/skills/gitlink-user/SKILL.md @@ -37,11 +37,11 @@ gitlink-cli user +info --login zhangsan ```bash # 用户贡献热力图 -gitlink-cli api GET /users/:user_id/headmaps +gitlink-cli user +heatmap # 用户统计 -gitlink-cli api GET /users/:user_id/statistics +gitlink-cli user +stats # 用户项目动态 -gitlink-cli api GET /users/:user_id/project_trends +gitlink-cli user +trends ``` diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 0f7aa2a..156a8e7 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -28,7 +28,7 @@ gitlink-cli issue +list --state open --format json gitlink-cli issue +view --id --format json # 3. 根据内容分析,通过 Raw API 添加标签 -gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[]}' +gitlink-cli issue +update --number '{"issue_tag_ids":[]}' ``` **分类规则建议**: @@ -51,7 +51,7 @@ gitlink-cli pr +files --id --format json gitlink-cli pr +diff --id --format json # 4. 添加 Review 评论 -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审查意见...","event":"COMMENT"}' +gitlink-cli pr +review --body '{"body":"代码审查意见...","event":"COMMENT"}' ``` ## 工作流 3:Release Notes 生成 @@ -60,7 +60,7 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审 ```bash # 1. 获取两个版本之间的提交 -gitlink-cli api GET /:owner/:repo/compare/:base...:head --format json +gitlink-cli repo +compare --format json # 2. 获取已关闭的 Issue gitlink-cli issue +list --state closed --format json @@ -98,7 +98,7 @@ gitlink-cli pr +list --state open --format json gitlink-cli pr +list --state merged --format json # 3. 获取项目动态 -gitlink-cli api GET /:owner/:repo/activity --format json +gitlink-cli repo +activity --format json ``` ## Workflow: PR Summary (Read-only) From dd805951a55994d47febe3c293db540dbd8370ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E8=BF=AA=E6=96=87?= <3117675914@qq.com> Date: Wed, 3 Jun 2026 10:15:08 +0800 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=203=20?= =?UTF-8?q?=E4=B8=AA=20Skill=20+=20=E5=A2=9E=E5=BC=BA=202=20=E4=B8=AA=20Sk?= =?UTF-8?q?ill=EF=BC=88v1.1.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - gitlink-contributor-insight: 贡献者活跃度分析(user +heatmap/stats/trends) - gitlink-ci-health: CI 健康巡检(ci +authorize/builds/logs) - gitlink-notification-digest: 通知摘要(notification +list/read/read-all) 增强(v1.1.0): - gitlink-issue-triage: 新增 issue +journals 活动日志分析 + series-update 批量操作 - gitlink-research-tracker: 新增 search +code/+issues 多维度搜索 基于子任务一的新命令开发。 Co-Authored-By: Claude Opus 4.8 --- skills/gitlink-ci-health/SKILL.md | 175 +++++++++++++++++ skills/gitlink-contributor-insight/SKILL.md | 207 ++++++++++++++++++++ skills/gitlink-issue-triage/SKILL.md | 95 ++++++--- skills/gitlink-notification-digest/SKILL.md | 187 ++++++++++++++++++ skills/gitlink-research-tracker/SKILL.md | 124 +++++++----- 5 files changed, 708 insertions(+), 80 deletions(-) create mode 100644 skills/gitlink-ci-health/SKILL.md create mode 100644 skills/gitlink-contributor-insight/SKILL.md create mode 100644 skills/gitlink-notification-digest/SKILL.md diff --git a/skills/gitlink-ci-health/SKILL.md b/skills/gitlink-ci-health/SKILL.md new file mode 100644 index 0000000..7142cf9 --- /dev/null +++ b/skills/gitlink-ci-health/SKILL.md @@ -0,0 +1,175 @@ +--- +name: gitlink-ci-health +version: 1.0.0 +description: "CI 健康巡检:检查仓库 CI/CD 授权状态、构建历史和成功率,生成 CI 健康度报告。当用户需要检查 CI 状态、分析构建成功率、排查 CI 故障时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli ci --help" +--- + +# gitlink-ci-health(CI 健康巡检) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作(`ci +activate`/`+deactivate` 除外),非只读操作需确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +面向维护者的 CI/CD 健康度巡检工具: + +1. **授权检查** — 确认仓库 CI 是否已激活 +2. **构建历史** — 获取近期构建列表 +3. **成功率统计** — 计算构建成功率和平均耗时 +4. **故障分析** — 识别频繁失败的构建及其原因 +5. **健康报告** — 生成 CI 健康度评分和改进建议 + +--- + +## 工作流:CI 健康巡检 + +### Step 1:检查 CI 授权状态 + +```bash +gitlink-cli ci +authorize --owner --repo --format json +``` + +判断 CI 是否已激活。若未激活,报告中说明"CI 未启用",建议执行 `ci +activate`。 + +### Step 2:获取构建历史 + +```bash +gitlink-cli ci +builds --owner --repo --format json +``` + +提取每次构建的: +- `status` — 构建状态(success/failed/running/pending) +- `created_at` / `finished_at` — 时间信息 +- `duration` — 耗时(如有) +- `branch` — 触发分支 + +如构建数量 >30,取最近 30 次分析。 + +### Step 3:构建日志(失败构建) + +对状态为 failed 的构建获取日志: + +```bash +gitlink-cli ci +logs --owner --repo --build --format json +``` + +> ⚠️ **控制调用量**:仅对最近 5 次失败构建获取日志,避免过多 API 调用。日志可能过大,提取关键错误行(最后 20 行)。 + +### Step 4:统计分析 + +#### 4.1 成功率计算 + +| 指标 | 计算方式 | +|------|----------| +| 整体成功率 | 成功构建数 / 总构建数 × 100% | +| 近 10 次成功率 | 最近 10 次中成功占比 | +| 平均修复时间 | 从失败到下次成功的平均间隔 | + +#### 4.2 健康度评分(满分 20) + +| 维度 | 权重 | 评分标准 | +|------|------|----------| +| CI 激活 | 4 | 已激活=4,未激活=0 | +| 构建成功率 | 5 | ≥90%=5,≥80%=4,≥70%=3,≥50%=2,<50%=1 | +| 近期稳定性 | 5 | 近10次全部成功=5,8-9次=4,6-7次=3,4-5次=2,<4次=1 | +| 构建频率 | 3 | 每天有构建=3,2-3天=2,每周=1,更少=0 | +| 修复速度 | 3 | 失败后1次内修复=3,2-3次=2,>3次=1 | + +### Step 5:生成 CI 健康报告 + +--- + +## 输出模板 + +```markdown +# 🔧 CI 健康巡检报告:{{仓库名}} + +> 巡检时间:{{当前时间}} +> 仓库:{{full_name}} +> CI 状态:{{ci_status_display}} + +--- + +## 一、健康度总览 + +| 指标 | 数值 | 评分 | +|------|------|------| +| CI 激活状态 | {{activated_status}} | {{activate_score}}/4 | +| 整体成功率 | {{success_rate}}%({{success_count}}/{{total_count}}) | {{success_score}}/5 | +| 近期稳定性 | 近 10 次 {{recent_success}} 次成功 | {{stability_score}}/5 | +| 构建频率 | {{build_frequency_desc}} | {{frequency_score}}/3 | +| 修复速度 | {{repair_speed_desc}} | {{repair_score}}/3 | +| **总分** | | **{{total_score}}/20** | + +## 二、构建趋势 + +``` +最近 20 次构建: +✅✅❌✅✅✅❌✅✅✅✅✅❌✅✅✅✅✅✅ +(✅=成功 ❌=失败) +``` + +| 时间段 | 总构建 | 成功 | 失败 | 成功率 | +|--------|--------|------|------|--------| +| 最近 7 天 | {{w1_total}} | {{w1_success}} | {{w1_fail}} | {{w1_rate}}% | +| 7-14 天 | {{w2_total}} | {{w2_success}} | {{w2_fail}} | {{w2_rate}}% | +| 14-30 天 | {{w3_total}} | {{w3_success}} | {{w3_fail}} | {{w3_rate}}% | + +## 三、故障分析 + +> 如无失败构建,输出:**🎉 分析期内无失败构建,CI 运行健康。** + +| 构建 ID | 分支 | 失败时间 | 错误摘要 | +|---------|------|----------|----------| +| {{id}} | {{branch}} | {{time}} | {{error_summary}} | + +### 故障模式分类 + +| 故障类型 | 次数 | 占比 | +|----------|------|------| +| 编译错误 | {{compile_count}} | {{compile_pct}}% | +| 测试失败 | {{test_fail_count}} | {{test_fail_pct}}% | +| 超时 | {{timeout_count}} | {{timeout_pct}}% | +| 环境问题 | {{env_count}} | {{env_pct}}% | +| 其他 | {{other_count}} | {{other_pct}}% | + +## 四、改进建议 + + + +- **立即激活 CI**(当 CI 未激活时):执行 `gitlink-cli ci +activate --owner --repo ` +- **提升成功率**(当 success_rate < 80% 时):优先修复高频失败原因 +- **增加构建频率**(当构建频率评分 < 2 时):建议每次 push 触发 CI +- **缩短修复时间**(当修复速度评分 < 2 时):建立 CI 失败告警 +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| CI 未激活 | 报告 CI 状态为"未激活",给出激活命令建议,不再继续后续步骤 | +| 无构建记录 | 标注"仓库暂无 CI 构建记录" | +| `ci +logs` 返回空 | 标注"日志不可用" | +| 构建总数 < 5 | 样本量不足,标注"数据有限,统计不具代表性" | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **`ci +activate` 和 `+deactivate` 为写操作**,执行前需确认用户意图 +- ✅ **Owner/repo 优先从 `git remote` 自动解析** +- ⚠️ **`ci +logs` 输出可能很大**,仅提取关键错误行 +- ⚠️ **构建历史无分页参数**,实际返回条数取决于 API +- ⚠️ **CI 数据仅反映 GitLink 平台活动**,不包括第三方 CI 服务 diff --git a/skills/gitlink-contributor-insight/SKILL.md b/skills/gitlink-contributor-insight/SKILL.md new file mode 100644 index 0000000..10bb52c --- /dev/null +++ b/skills/gitlink-contributor-insight/SKILL.md @@ -0,0 +1,207 @@ +--- +name: gitlink-contributor-insight +version: 1.0.0 +description: "贡献者活跃度分析:分析仓库贡献者的活跃度、贡献趋势和工作节奏,生成贡献者洞察报告。当用户需要分析贡献者活跃度、查看团队贡献趋势、评估成员参与度时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli user --help" +--- + +# gitlink-contributor-insight(贡献者活跃度分析) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 本 Skill 为只读操作,不会修改任何仓库。无需用户额外确认即可执行。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +面向开源社区管理者和维护者的贡献者分析工具: + +1. **项目概览** — 获取仓库贡献者规模 +2. **贡献热力图分析** — 通过 `user +heatmap` 查看贡献节奏 +3. **统计数据提取** — 通过 `user +stats` 获取个人统计 +4. **趋势分析** — 通过 `user +trends` 查看项目趋势 +5. **洞察报告** — 生成贡献者活跃度排名和团队健康度评估 + +--- + +## 工作流:贡献者分析全流程 + +### Step 1:获取项目贡献者列表 + +```bash +gitlink-cli repo +contributors --owner --repo --format json +``` + +> 如果仓库贡献者数量较多(>15),按 `repo +contributors` 返回中 `commits_count` 降序排列,取前 10 位分析。如 `commits_count` 缺失,按返回的自然顺序取前 10 位,报告中注明"基于返回顺序 Top 10"。 + +提取每个贡献者的 `login`(用户名)。 + +### Step 2:获取仓库基本信息 + +```bash +gitlink-cli repo +info --owner --repo --format json +``` + +提取 `contributor_users_count`、`full_name`、`description`。 + +### Step 3:逐位贡献者深度分析 + +对每位贡献者执行以下命令: + +```bash +# 热力图(最近一年的贡献日历) +gitlink-cli user +heatmap --login --format json + +# 统计信息(PR/Issue/Commit 数量) +gitlink-cli user +stats --login --format json + +# 项目趋势 +gitlink-cli user +trends --login --format json +``` + +从返回数据中提取: + +| 维度 | 来源命令 | 分析要点 | +|------|----------|----------| +| 贡献频率 | `+heatmap` | 最近 1/3/6/12 个月有贡献的天数,判断是"持续贡献者"还是"间歇参与者" | +| 贡献产出 | `+stats` | PR 数、Issue 数、Commit 数,区分"代码贡献者"和"问题反馈者" | +| 活跃趋势 | `+trends` | 贡献量是上升/稳定/下降,识别"上升期贡献者"和"逐渐淡出者" | + +> ⚠️ **控制 API 调用**:贡献者 >15 人时,仅分析 Step 1 中按 `commits_count` 排序后的前 10 位。每人最多 3 次 API 调用(heatmap + stats + trends,共 ≤30 次)。 + +### Step 4:贡献者分级与分类 + +#### 4.1 活跃度分级 + +| 级别 | 判定标准 | +|------|----------| +| 🔥 **核心贡献者** | 最近 30 天有贡献 + 总贡献 PR > 10 | +| 🌟 **活跃贡献者** | 最近 60 天有贡献 + 总贡献 > 5 | +| 🌱 **新兴贡献者** | 最近 90 天首次出现 + 贡献频率上升 | +| 💤 **休眠贡献者** | 最近 90 天无贡献 + 历史有贡献 | + +#### 4.2 贡献类型分类 + +| 类型 | 判定 | +|------|------| +| **代码贡献者** | PR/Commit 数量占比最高 | +| **问题反馈者** | Issue 数量占比最高 | +| **全能贡献者** | PR 和 Issue 数量均衡 | + +### Step 5:生成贡献者洞察报告 + +--- + +## 输出模板 + +```markdown +# 👥 贡献者洞察报告:{{仓库名}} + +> 分析时间:{{当前时间}} +> 仓库:{{full_name}} +> 总贡献者:{{contributor_users_count}} 人,本次分析:{{analyzed_count}} 人 + +--- + +## 一、团队概览 + +| 指标 | 数值 | +|------|------| +| 总贡献者 | {{contributor_users_count}} | +| 核心贡献者 | {{core_count}} | +| 活跃贡献者 | {{active_count}} | +| 新兴贡献者 | {{new_count}} | +| 休眠贡献者 | {{dormant_count}} | +| 近 30 天活跃率 | {{active_30d_rate}}% | + +--- + +## 二、贡献者活跃度排行榜 + +| 排名 | 贡献者 | 级别 | 类型 | 近30天贡献 | 总PR | 总Issue | 趋势 | +|------|--------|------|------|-----------|------|---------|------| +| 1 | {{login}} | 🔥 | 代码 | {{d30}} 天 | {{pr_count}} | {{issue_count}} | ↑ | +| ... | ... | ... | ... | ... | ... | ... | ... | + +--- + +## 三、重点贡献者分析 + +> 仅展示核心/活跃贡献者。 + +### 🔥 {{login}}(核心贡献者) + +| 维度 | 数据 | 说明 | +|------|------|------| +| 最近 30 天贡献 | {{d30}} 天 | {{评价}} | +| 总 PR 数 | {{pr_count}} | | +| 总 Issue 数 | {{issue_count}} | | +| 贡献趋势 | {{trend_direction}} | {{trend_comment}} | + +--- + +## 四、团队健康度评估 + +### 健康度指标 + +| 指标 | 状态 | 说明 | +|------|------|------| +| 核心贡献者占比 | {{core_ratio}}% | {{core_comment}} | +| 新老比例 | {{new_old_ratio}} | {{new_old_comment}} | +| 贡献频率稳定性 | {{stability}} | {{stability_comment}} | +| 知识分散度 | {{bus_factor}} | {{bus_factor_comment}} | + +### 风险提示 + + + +- ⚠️ **核心贡献者不足**(当 core_count < 3 时):仅 {{core_count}} 位核心贡献者,存在单点依赖风险(Bus Factor = {{core_count}})。 +- ⚠️ **贡献者流失**(当 dormant_rate > 50% 时):超过一半的贡献者已不活跃,需要关注社区留存。 +- ⚠️ **缺少新鲜血液**(当 new_count == 0 时):近期无新兴贡献者,建议通过 Good First Issue 等方式吸引新人。 +- ✅ **团队健康**(当以上情况均不满足时):贡献者结构合理,团队运转良好。 + +> 指标计算: +> - `core_ratio` = core_count / analyzed_count × 100 +> - `dormant_rate` = dormant_count / analyzed_count × 100 +> - `active_30d_rate` = (近30天至少一次贡献的人数) / analyzed_count × 100 +> - `new_old_ratio`:新兴贡献者数 : 核心+活跃贡献者数 的比值 +> - `bus_factor` = core_count(简化定义:核心贡献者数量最低值) +> - `stability`:判断标准为"贡献标准差"(各月贡献量波动小=高稳定性,波动大=低稳定性) + +--- + +## 五、社区建设建议 + +1. **激励核心贡献者**:{{核心贡献者维护建议}} +2. **激活休眠贡献者**:{{休眠贡献者召回建议}} +3. **吸引新贡献者**:{{新贡献者吸引建议}} +4. **平衡贡献类型**:{{贡献类型平衡建议}} +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| `repo +contributors` 返回空 | 标注"仓库暂无贡献者数据",仅从 `repo +info` 获取 `contributor_users_count` | +| `user +heatmap` 返回空 | 标注"无热力图数据",评分仅基于 stats 和 trends | +| `user +stats` / `+trends` 返回错误 | 跳过该维度,标注"数据不可用" | +| 贡献者 > 15 人 | 仅分析贡献量最高的前 10 位,报告中注明"基于 Top 10 分析" | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **本 Skill 为纯只读分析**,不会修改任何仓库 +- ✅ **Owner/repo 优先从 `git remote` 自动解析**,无 git 上下文时询问用户 +- ⚠️ **每人 3 次 API 调用**(heatmap + stats + trends),10 人即 30 次,注意控制分析人数 +- ⚠️ **热力图数据可能稀疏**:部分贡献者数据不完整,标注"数据有限" +- ⚠️ **数据仅反映 GitLink 平台活动**:不包括 GitHub 或其他平台的数据 diff --git a/skills/gitlink-issue-triage/SKILL.md b/skills/gitlink-issue-triage/SKILL.md index 05bb621..bdcaf70 100644 --- a/skills/gitlink-issue-triage/SKILL.md +++ b/skills/gitlink-issue-triage/SKILL.md @@ -1,6 +1,6 @@ --- name: gitlink-issue-triage -version: 1.0.0 +version: 1.1.0 description: "Issue 智能分拣:自动分析仓库 Issue 列表,按类型、紧急度、复杂度分类,生成分拣报告和维护建议。当用户需要整理 Issue、分类 Issue、Issue 分拣、Issue 优先级排序时触发。" metadata: requires: @@ -11,7 +11,7 @@ metadata: # gitlink-issue-triage(Issue 智能分拣) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 本 Skill 为只读操作,不会修改任何 Issue。无需用户额外确认即可执行。** +**CRITICAL — `issue +series-update` 为写操作,会批量修改 Issue 状态。执行前需确认用户意图。** **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 @@ -25,7 +25,9 @@ metadata: 1. **类型分类** — 判断每个 Issue 是 Bug、功能请求、文档问题还是使用咨询 2. **紧急度评估** — 根据关键词和优先级字段标注紧急程度 3. **复杂度预估** — 根据描述详尽程度评估修复难度 -4. **行动建议** — 给出具体处理建议(立即修复/需讨论/可关闭/适合作入门任务) +4. **活动日志分析** — 通过 `issue +journals` 查看 Issue 活动历史 +5. **行动建议** — 给出具体处理建议(立即修复/需讨论/可关闭/适合作入门任务) +6. **批量操作** — 支持通过 `issue +series-update` 批量更新 Issue 状态 --- @@ -71,6 +73,22 @@ gitlink-cli issue +view --owner --repo --number --repo --number --format json +``` + +从 `journals` 数组中提取: +- 最近一次状态变更时间和操作者 +- 最近一次评论时间和作者 +- 是否有 @提及等待回复 +- 是否有分配变更记录 + +> ⚠️ **控制调用量**:仅对 urgent/high 级别的 Issue 获取活动日志。日志数据可能较大,只提取关键时间节点。 + ### Step 4:分类规则 #### 4.1 类型分类(type) @@ -120,6 +138,20 @@ gitlink-cli issue +view --owner --repo --number --repo --ids --status closed --format json +``` + +> ⚠️ **写操作**:执行前需向用户展示将要操作的 Issue 列表,获得确认后再执行。 + +典型使用场景: +- 批量关闭 `close-candidate` 列表中的 Issue +- 批量将 `good-first-issue` 标记为 open(确保状态正确) + --- ## 输出模板 @@ -151,40 +183,31 @@ gitlink-cli issue +view --owner --repo --number 如本段为空,输出:*当前无紧急 Issue,状态健康。* -| # | 标题 | 类型 | 紧急度 | 复杂度 | 建议 | 备注 | -|---|------|------|--------|--------|------|------| -| {{number}} | {{subject}} | bug | urgent | medium | fix-now | | -| ... | ... | ... | ... | ... | ... | ... | +| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 | +|---|------|------|--------|--------|----------|------|------| +| {{number}} | {{subject}} | bug | urgent | medium | {{last_journal_time}} | fix-now | | +| ... | ... | ... | ... | ... | ... | ... | ... | ## 🟡 建议近期处理 -> 如本段为空,输出:*当前无高优先级 Issue。* - -| # | 标题 | 类型 | 紧急度 | 复杂度 | 建议 | 备注 | -|---|------|------|--------|--------|------|------| -| ... | ... | bug/feature | high/normal | easy/medium | investigate/implement | | +| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 | +|---|------|------|--------|--------|----------|------|------| +| ... | ... | bug/feature | high/normal | easy/medium | ... | investigate/implement | | ## 🟢 可延迟 / 需讨论 -> 如本段为空,输出:*所有 Issue 均已明确,无需额外讨论。* - -| # | 标题 | 类型 | 紧急度 | 复杂度 | 建议 | 备注 | -|---|------|------|--------|--------|------|------| -| ... | ... | question/feature | normal/low | medium/hard | discuss | | +| # | 标题 | 类型 | 紧急度 | 复杂度 | 上次活动 | 建议 | 备注 | +|---|------|------|--------|--------|----------|------|------| +| ... | ... | question/feature | normal/low | medium/hard | ... | discuss | | ## ⭐ 适合入门(Good First Issue) -> 如本段为空,输出:*暂无完全符合条件的入门 Issue。建议在后续工作中拆分出简单子任务。* - | # | 标题 | 类型 | 复杂度 | 推荐理由 | |---|------|------|--------|----------| | {{number}} | {{subject}} | bug/docs | easy | 范围明确,单文件修改 | -| ... | ... | ... | ... | ... | ## ⚠️ 候选关闭(90+ 天无活动) -> 如本段为空,输出:*无长期不活跃的 Issue。* - | # | 标题 | 最后更新 | 建议 | |---|------|----------|------| | {{number}} | {{subject}} | {{updated_at}} | 评论询问是否仍需要,如无回应可关闭 | @@ -194,11 +217,18 @@ gitlink-cli issue +view --owner --repo --number 如无适用操作,输出:*当前无需批量操作。* + +以下 Issue 建议批量关闭(已确认超 90 天无活动): +`gitlink-cli issue +series-update --owner --repo --ids {{close_ids}} --status closed` ``` --- @@ -212,15 +242,18 @@ gitlink-cli issue +view --owner --repo --number **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +帮助用户高效管理 GitLink 通知: + +1. **通知列表** — 获取所有未读通知 +2. **自动分类** — 按类型(Issue/PR/评论/系统)分组 +3. **优先级判断** — 识别需要立即处理的通知 +4. **批量操作** — 支持标记已读(需确认) +5. **摘要报告** — 生成结构化通知摘要 + +--- + +## 工作流:通知摘要 + +### Step 1:获取通知列表 + +```bash +gitlink-cli notification +list --format json +``` + +获取参数: +- 默认获取未读通知 +- 如需全部通知(含已读):`--all` +- 如需仅参与的通知:`--participating` +- 分页:`--page 2 --limit 20` + +提取每条通知的: +- `id` — 通知 ID(用于 `+read` 单条标记已读) +- `content` — 通知内容(HTML 格式,从中提取摘要文本) +- `source` — 通知来源类型(如 IssueAtme、PullRequestAssigned、ProjectForked 等) +- `notification_url` — 通知链接,从中解析关联仓库(提取 URL path 中的 `/owner/repo/` 段) +- `created_at` — 通知时间 +- `status` — 状态(1=未读,2=已读) + +### Step 2:分类与优先级 + +#### 2.1 按类型分类(优先使用 `source` 字段) + +| 类型 | `source` 字段匹配 | 处理建议 | +|------|------------------|----------| +| 🔴 **@提及** | 含 `Atme`(如 IssueAtme, PullRequestAtme) | 立即查看回复 | +| 🟡 **Issue 更新** | 含 `Issue`(如 IssueAssigned, IssueClosed) | 当天处理 | +| 🟢 **PR 更新** | 含 `PullRequest`(如 PullRequestAssigned, PullRequestMerged) | 跟进代码 | +| 🔵 **系统通知** | 含 `Project`/`Organization`(如 ProjectForked, ProjectJoined) | 知悉即可 | +| ⚪ **其他** | 不匹配以上 | 按需查看 | + +> `source` 字段返回的是结构化枚举值(如 `IssueAtme`),优先以此分类。`content` 字段为 HTML 文本,仅作补充参考。 + +#### 2.2 优先级排序 + +| 优先级 | 判定 | +|--------|------| +| **P0 - 立即** | @提及 + 来自自己参与的 Issue/PR | +| **P1 - 今天** | 自己创建的 Issue/PR 有新回复,或分配的 Issue 有更新 | +| **P2 - 本周** | 关注的仓库有新动态 | +| **P3 - 可忽略** | 系统通知、已解决的 Issue | + +### Step 3:生成通知摘要 + +按模板输出。 + +### Step 4:批量标记已读(可选,需确认) + +```bash +# 标记全部已读 +gitlink-cli notification +read-all --format json + +# 标记单条已读 +gitlink-cli notification +read --id --format json +``` + +> ⚠️ **执行前必须确认用户意图** — `+read` 和 `+read-all` 为写操作。 + +--- + +## 输出模板 + +```markdown +# 🔔 通知摘要 + +> 生成时间:{{当前时间}} +> 未读通知:{{unread_count}} 条 / 总计:{{total_count}} 条 + +--- + +## 一、概要 + +| 类型 | 未读 | 总计 | +|------|------|------| +| @提及 | {{mention_unread}} | {{mention_total}} | +| Issue 更新 | {{issue_unread}} | {{issue_total}} | +| PR 更新 | {{pr_unread}} | {{pr_total}} | +| 系统通知 | {{system_unread}} | {{system_total}} | +| 其他 | {{other_unread}} | {{other_total}} | + +--- + +## 二、需要立即处理(P0) + +> 如无,输出:*🎉 无紧急通知。* + +| # | 类型 | 仓库 | 内容摘要 | 时间 | +|---|------|------|----------|------| +| 1 | 🔴@提及 | {{repo}} | {{summary}} | {{time}} | + +--- + +## 三、今天处理(P1) + +> 如无,输出:*无待处理通知。* + +| # | 类型 | 仓库 | 内容摘要 | 时间 | +|---|------|------|----------|------| + +--- + +## 四、本周关注(P2) + +> 如无,输出:*无需要本周关注的通知。* + +--- + +## 五、可忽略(P3) + +> 如本段被折叠,输出:*{{p3_count}} 条低优先级通知,已折叠。* + +--- + +## 六、通知趋势 + +| 时间段 | 通知数 | +|--------|--------| +| 今日 | {{today_count}} | +| 昨日 | {{yesterday_count}} | +| 本周 | {{week_count}} | +| 上周 | {{last_week_count}} | + +--- + +## 操作建议 + +- 建议标记已读:{{suggest_read_count}} 条 P3 通知 +- 需要回复/处理:{{need_action_count}} 条 P0/P1 通知 + +如需标记全部已读,我可以执行: +`gitlink-cli notification +read-all` +``` + +--- + +## 异常场景处理 + +| 场景 | 处理方式 | +|------|----------| +| 无未读通知 | 输出"🎉 所有通知已处理完毕" | +| 通知数量 > 50 | 分批获取(page 1/2/3),优先分析最近 50 条 | +| `notification +list` 返回空 | 检查认证状态(参考 gitlink-shared) | + +--- + +## 注意事项 + +- ✅ **所有命令使用 `--format json`**,确保可解析 +- ✅ **`+read` 和 `+read-all` 为写操作**,执行前必须确认用户意图 +- ✅ **本 Skill 默认只读分析**,仅在用户明确要求时标记已读 +- ⚠️ **通知类型依赖标题关键词推断**,实际类型可能有偏差 +- ⚠️ **通知可能分页**,数量 >20 时需追加 `--page 2` 等 diff --git a/skills/gitlink-research-tracker/SKILL.md b/skills/gitlink-research-tracker/SKILL.md index ae4e222..385197d 100644 --- a/skills/gitlink-research-tracker/SKILL.md +++ b/skills/gitlink-research-tracker/SKILL.md @@ -1,6 +1,6 @@ --- name: gitlink-research-tracker -version: 1.0.0 +version: 1.1.0 description: "技术评估与调研报告:对技术项目进行多维度评估(社区活跃度、成熟度评分、技术趋势),生成含选型建议的结构化调研报告。当用户需要做技术评估、生成调研报告、科研选题分析、竞品对比研究时触发。" metadata: requires: @@ -22,7 +22,7 @@ metadata: 面向科研场景的技术调研工具,帮助研究者快速了解 GitLink 平台上的技术格局: -1. **多关键词搜索** — 将研究主题拆解为多个关键词,全面覆盖相关项目 +1. **多关键词搜索** — 仓库搜索 + 代码搜索 + Issue 搜索,三维覆盖 2. **项目深度评估** — 从活跃度、社区规模、代码产出等维度评估项目健康度 3. **横向对比** — 对比同类项目的核心指标,识别领先者和潜力项目 4. **趋势洞察** — 基于更新时间、贡献者增长、版本发布频率等推断技术趋势 @@ -45,9 +45,9 @@ metadata: > **原则**:关键词应覆盖中英文、缩写全称、技术术语和行业叫法。每个关键词独立搜索。 -### Step 2:多关键词搜索 +### Step 2:多维度搜索(v1.1 扩展:三维搜索) -对每个关键词执行搜索: +#### 2a. 仓库搜索 ```bash gitlink-cli search +repos -k <关键词> --format json @@ -57,13 +57,43 @@ gitlink-cli search +repos -k <关键词> --format json | SKILL 中用到的概念 | 实际字段来源 | 说明 | |-------------------|-------------|------| -| owner/repo 标识 | `author.login` + `/` + `identifier` | 搜索结果**没有** `full_name`,需手动拼接。`identifier` 是仓库的唯一标识符 | +| owner/repo 标识 | `author.login` + `/` + `identifier` | 搜索结果**没有** `full_name`,需手动拼接 | | 项目描述 | `description` | 直接可用 | -| 关注度 | `praises_count` | 搜索结果中叫 `praises_count`,**不是** `stars`。`watchers_count` 仅在 `repo +info` 中返回 | +| 关注度 | `praises_count` | 搜索结果中叫 `praises_count`,**不是** `stars` | | Fork 数 | `forked_count` | 搜索结果中叫 `forked_count`,**不是** `forks_count` | | 编程语言 | `language.name` | `language` 是嵌套对象 `{id, name}`,需取 `.name`。可能为 `null` | -| 更新时间 | `last_update_time`(Unix 时间戳)或 `full_last_update_time`(ISO 8601 字符串) | 搜索结果中**没有** `updated_at` | -| 是否镜像 | `mirror` | 仅在 `repo +info` 返回。GitLink 上大量仓库是 GitHub 镜像,需特别标注 | +| 更新时间 | `last_update_time` 或 `full_last_update_time` | 搜索结果中**没有** `updated_at` | +| 是否镜像 | `mirror` | 仅在 `repo +info` 返回 | + +#### 2b. 代码搜索(v1.1 新增) + +对技术关键词搜索代码引用,了解技术在实际项目中的使用情况: + +```bash +gitlink-cli search +code -k <关键词> --format json +``` + +从结果中提取: +- 匹配到的文件路径和仓库 +- 代码片段预览 +- 判断:哪些项目**实际使用了**该技术(而非仅描述中提到) + +> 代码搜索结果用于辅助判断"代码活跃度"——有大量代码匹配的项目说明该技术在实际开发中活跃使用。 + +#### 2c. Issue 搜索(v1.1 新增) + +搜索与主题相关的 Issue 讨论,了解技术痛点和需求: + +```bash +gitlink-cli search +issues -k <关键词> --format json +``` + +从结果中提取: +- 高频讨论主题 +- 常见技术痛点和需求 +- 社区对某个技术的关注焦点 + +> ⚠️ **控制搜索量**:代码搜索和 Issue 搜索仅针对 2-3 个核心关键词执行,不是全部关键词。避免 API 调用过多。 **去重规则**:用 `author.login/identifier` 作为唯一标识。同一仓库出现在多个关键词结果中时,只保留一次,标注匹配了哪些关键词。 @@ -87,6 +117,7 @@ gitlink-cli repo +info --owner --repo --format json | **代码规模** | `size` | 粗略判断项目复杂度 | | **开放性** | `forked_count` | fork 数反映二次开发热度 | | **PR 活跃度** | `pull_requests_count` | 反映代码贡献频率 | +| **代码活跃度**(v1.1) | `search +code` 命中量 | 反映技术在实际代码中的使用程度 | 可选补充(如有需要): @@ -98,33 +129,29 @@ gitlink-cli issue +list --owner --repo --state open --format json gitlink-cli release +list --owner --repo --format json ``` -> ⚠️ **控制分析数量**:深度评估仅对最有价值的 5~8 个项目执行(优先匹配多关键词、watchers 多、updated_at 最近的项目),避免过多 API 调用。 +> ⚠️ **控制分析数量**:深度评估仅对最有价值的 5~8 个项目执行,避免过多 API 调用。 ### Step 4:横向对比与趋势分析 #### 4.1 项目分类与镜像识别 -在评分之前,先通过 `repo +info` 的 `mirror` 字段区分项目类型: - | 类型 | 判定 | 处理 | |------|------|------| -| **镜像仓库** | `mirror: true` | 标注 `[镜像]`。GitLink 上的 `contributor_users_count`/`watchers_count` 等指标均为 0,不代表真实社区活跃度。评分仅作参考 | +| **镜像仓库** | `mirror: true` | 标注 `[镜像]`。评分仅作参考 | | **原创仓库** | `mirror: false` 且 `forked_from_project_id: null` | 正常评分 | -| **Fork 仓库** | `forked_from_project_id` 非 null | 标注 `[Fork]`,评分反映的是 Fork 后的独立开发情况 | +| **Fork 仓库** | `forked_from_project_id` 非 null | 标注 `[Fork]` | -#### 4.2 项目成熟度评分 - -对每个深度评估的项目,按以下标准打分(满分 25): +#### 4.2 项目成熟度评分(满分 25) | 维度 | 权重 | 评分标准 | |------|------|----------| | 社区规模 | 5 | contributor_users_count: >20=5, >10=4, >5=3, >2=2, ≤2=1 | -| 关注度 | 5 | repo +info 的 watchers_count: >30=5, >15=4, >8=3, >3=2, ≤3=1 | -| 研发节奏 | 5 | version_releases_count: >10=5, >5=4, >1=3, 0=2。**镜像仓库此项固定给 1**(镜像通常不通过 GitLink 发版)。注意 GitLink 平台 Release 功能使用率低,即使原创仓库 release=0 也建议给 2 而非 1 | -| 开发活跃 | 5 | 最近 30 天有更新=5, 60 天=4, 90 天=3, 180 天=2, >180 天=1。(基于 `repo +info` 的更新时间或搜索结果中的 `last_update_time`) | +| 关注度 | 5 | watchers_count: >30=5, >15=4, >8=3, >3=2, ≤3=1 | +| 研发节奏 | 5 | version_releases_count: >10=5, >5=4, >1=3, 0=2。镜像仓库固定给 1 | +| 开发活跃 | 5 | 最近 30 天有更新=5, 60 天=4, 90 天=3, 180 天=2, >180 天=1 | | 开放性 | 5 | forked_count: >30=5, >15=4, >8=3, >3=2, ≤3=1 | -> **镜像修正**:镜像仓库的社区规模、关注度、开放性三项在 GitLink 上均为 0,应标注"数据为 GitLink 平台内数据,不代表项目在原始平台(GitHub)的真实影响力",不参与排名比较。 +> **镜像修正**:镜像仓库评分仅作参考,不参与排名比较。 #### 4.3 技术趋势推断 @@ -132,6 +159,7 @@ gitlink-cli release +list --owner --repo --format json - **成熟信号**:大量 watcher + 稳定 Release 节奏 + 大社区 → 技术趋于成熟 - **衰退信号**:超过 180 天无更新 + 少量 contributor + 无新 Release → 可能已不活跃 - **新兴信号**:小社区 + 快速迭代 + 最新更新时间近 → 可能是新兴项目 +- **代码证据**(v1.1):`search +code` 命中量增长 → 技术采纳度上升 ### Step 5:生成技术调研报告 @@ -144,7 +172,8 @@ gitlink-cli release +list --owner --repo --format json > 调研时间:{{当前时间}} > 搜索关键词:{{keyword_list}} -> 搜索命中:{{total_hits}} 个仓库,去重后 {{unique_count}} 个,深度分析 {{deep_analysis_count}} 个 +> 搜索维度:仓库搜索 {{repo_hits}} + 代码搜索 {{code_hits}} + Issue 搜索 {{issue_hits}} +> 去重后 {{unique_count}} 个项目,深度分析 {{deep_analysis_count}} 个 --- @@ -157,15 +186,15 @@ gitlink-cli release +list --owner --repo --format json | 平均社区规模 | {{avg_contributors}} 人 | | 近 30 天活跃项目 | {{active_30d_count}}({{active_30d_pct}}%) | | 高成熟度项目(≥20分) | {{high_maturity_count}} | +| 代码引用量 | {{code_search_hits}} 次命中(反映技术采纳度) | --- ## 二、项目成熟度排行榜 -| 排名 | 项目 | 类型 | 评分 | 语言 | Watch | 贡献者 | Release | Fork | 关键词匹配 | -|------|------|------|------|------|-------|--------|---------|------|------------| -| 1 | {{full_name}} {{#if mirror}}[镜像]{{/if}} | {{原创/镜像/Fork}} | {{score}}/25 | {{language}} | {{watchers}} | {{contributors}} | {{releases}} | {{forks}} | {{matched_keywords}} | -| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | +| 排名 | 项目 | 类型 | 评分 | 语言 | Watch | 贡献者 | 代码引用 | Fork | 关键词匹配 | +|------|------|------|------|------|-------|--------|----------|------|------------| +| 1 | {{full_name}} | {{原创/镜像/Fork}} | {{score}}/25 | {{language}} | {{watchers}} | {{contributors}} | {{code_refs}} | {{forks}} | {{matched_keywords}} | --- @@ -188,18 +217,13 @@ gitlink-cli release +list --owner --repo --format json --- -### 🥈 {{项目名}}({{score}}/25) - -(同上格式) - ---- - ## 四、技术趋势洞察 -1. **热点方向**:{{当前最热的技术方向,基于项目分布推断}} -2. **新兴项目**:{{列出 1~3 个"新兴信号"明显的项目}} -3. **成熟生态**:{{列出 1~2 个"成熟信号"明显的项目,适合作为技术选型参考}} -4. **风险提示**:{{列出 1~2 个"衰退信号"项目或值得关注的生态空白}} +1. **热点方向**:{{当前最热的技术方向}} +2. **新兴项目**:{{1~3 个"新兴信号"明显的项目}} +3. **成熟生态**:{{1~2 个"成熟信号"明显的项目}} +4. **社区讨论焦点**(v1.1):基于 `search +issues` 的技术痛点分析 +5. **风险提示**:{{1~2 个"衰退信号"项目或生态空白}} --- @@ -223,6 +247,7 @@ gitlink-cli release +list --owner --repo --format json ## 六、数据来源 所有数据通过 `gitlink-cli` 从 GitLink 平台实时获取,每个项目均已通过 `repo +info` 验证。 +搜索维度:`search +repos`(仓库)、`search +code`(代码)、`search +issues`(Issue) ``` --- @@ -231,13 +256,14 @@ gitlink-cli release +list --owner --repo --format json | 场景 | 处理方式 | |------|----------| -| 关键词无搜索结果 | 尝试近义词或更宽泛的关键词重试,仍无结果则标注"该方向暂无相关项目" | -| 搜索返回大量结果(>50) | `search +repos` 无分页参数,实际返回约 20 条/关键词。合并后按 `praises_count` 降序取前 20 | -| 某项目 `repo +info` 返回 404 | 该项目可能为私有或已删除,从列表中移除 | -| `repo +info` 网络超时/TLS 错误 | 等待 5 秒后重试一次。仍失败则标注"网络请求失败",跳过该项目继续分析其余 | -| 大量搜索结果来自镜像仓库 | 优先分析 `mirror: false` 的原创项目。镜像项目保留但标注,评分仅作参考 | -| 所有项目评分均 <15 | 说明该领域尚未形成成熟生态,调整报告语气为"早期探索阶段" | -| 用户未提供具体关键词 | 引导用户明确研究主题,提供几个示例关键词供选择 | +| 关键词无搜索结果 | 尝试近义词重试,仍无结果则标注"该方向暂无相关项目" | +| 搜索返回大量结果(>50) | 合并后按 `praises_count` 降序取前 20 | +| 某项目 `repo +info` 返回 404 | 从列表中移除 | +| `repo +info` 网络超时/TLS 错误 | 等待 5 秒后重试一次,仍失败则跳过 | +| 大量搜索结果来自镜像仓库 | 优先分析 `mirror: false` 的原创项目 | +| 所有项目评分均 <15 | 调整报告语气为"早期探索阶段" | +| `search +code` 返回空 | 标注"代码搜索无命中",不阻塞分析 | +| `search +issues` 返回空 | 标注"Issue 搜索无命中",不阻塞分析 | | `language` 字段为 `null` | 标注为"未知" | --- @@ -246,11 +272,11 @@ gitlink-cli release +list --owner --repo --format json - ✅ **所有命令使用 `--format json`**,确保可解析 - ✅ **本 Skill 为纯只读分析**,不会修改任何仓库 -- ✅ **搜索关键词建议中英文各覆盖**,提高命中率 +- ✅ **搜索关键词建议中英文各覆盖** - ✅ **深度评估控制在 5~8 个项目**,避免调用过多 API -- ⚠️ **`search +repos` 和 `repo +info` 字段名不同**:搜索结果用 `praises_count`/`forked_count`/`author.login+identifier`,`repo +info` 才有 `watchers_count`/`full_name`/`mirror`。详见 Step 2 字段映射表 -- ⚠️ **`repo +info` 并发请求可能触发 TLS 超时**,失败时等 5 秒重试一次,不要放弃 -- ⚠️ **GitLink 平台镜像仓库比例高**,镜像仓库的社区数据为 0,不代表项目真实影响力。在报告中标注 `[镜像]` 并单独说明 -- ⚠️ **GitLink Release 功能使用率低**,大部分项目 `version_releases_count`=0。评分时 Release 维度降低权重预期,0 个 Release 给 2 分(而非 1 分) -- ⚠️ **搜索结果无分页参数**,每次返回约 20 条。关键词超过 5 个时需手动截断合并结果 -- ⚠️ **本 Skill 场景适配 GitLink 平台**,GitLink 以国内开发者和企业项目为主,搜索结果可能偏向中文技术生态,且镜像项目较多 +- ⚠️ **v1.1 新增 `search +code` 和 `+issues`**:仅对 2-3 个核心关键词执行,控制 API 调用总量 +- ⚠️ **`search +repos` 和 `repo +info` 字段名不同**:搜索结果用 `praises_count`/`forked_count`,`repo +info` 有 `watchers_count`/`full_name`/`mirror` +- ⚠️ **`repo +info` 并发请求可能触发 TLS 超时**,失败时等 5 秒重试一次 +- ⚠️ **GitLink 平台镜像仓库比例高**,镜像仓库的社区数据为 0 +- ⚠️ **GitLink Release 功能使用率低**,0 个 Release 给 2 分(非镜像) +- ⚠️ **本 Skill 场景适配 GitLink 平台**,结果可能偏向中文技术生态 From 7c293f9b5abeb80cb51eb2487ef6aa4bdaa7ba13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E8=BF=AA=E6=96=87?= <3117675914@qq.com> Date: Wed, 3 Jun 2026 10:29:52 +0800 Subject: [PATCH 12/19] =?UTF-8?q?feat(contributor-insight):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=87=B3=20v1.1.0=EF=BC=8C=E6=96=B0=E5=A2=9E=20EXAMPL?= =?UTF-8?q?ES.md=20=E5=92=8C=E5=AE=9E=E9=99=85=E6=89=A7=E8=A1=8C=E6=A0=B7?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增命令可用性声明(标注 repo +contributors/user +heatmap 等不可用命令及替代方案) - 重写工作流步骤:贡献者列表从 pr +list 提取,活跃度从 PR 时间戳推算 - 新增年轻项目(<30天)分级放宽规则 - 扩充异常场景处理表(新增 3 种场景) - 新增 EXAMPLES.md:手动执行 + Agent 调用两种完整样例 - 新增 examples/jiangtx-gitlink-cli.md:原始命令输出数据附录 - 同步 ci-health EXAMPLES.md Co-Authored-By: Claude Opus 4.8 --- skills/gitlink-ci-health/EXAMPLES.md | 178 +++++++++++++ skills/gitlink-ci-health/SKILL.md | 28 +- .../gitlink-contributor-insight/EXAMPLES.md | 239 ++++++++++++++++++ skills/gitlink-contributor-insight/SKILL.md | 164 +++++++----- .../examples/jiangtx-gitlink-cli.md | 201 +++++++++++++++ 5 files changed, 746 insertions(+), 64 deletions(-) create mode 100644 skills/gitlink-ci-health/EXAMPLES.md create mode 100644 skills/gitlink-contributor-insight/EXAMPLES.md create mode 100644 skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md diff --git a/skills/gitlink-ci-health/EXAMPLES.md b/skills/gitlink-ci-health/EXAMPLES.md new file mode 100644 index 0000000..af237f8 --- /dev/null +++ b/skills/gitlink-ci-health/EXAMPLES.md @@ -0,0 +1,178 @@ +# gitlink-ci-health 使用样例 + +## 样例 1:CI 未激活的仓库 + +**日期**:2026-06-03 +**仓库**:jiangtx/gitlink-cli(Fork from Gitlink/gitlink-cli) +**CLI 版本**:gitlink-cli 0.1.18 + +### 执行流程 + +```bash +# Step 1: 检查 CI 状态(方法 1 — repo +info) +gitlink-cli repo +info --owner jiangtx --repo gitlink-cli --format json +# → "open_devops": false ← CI 未激活 + +# Step 1 补充(方法 2 — ci +builds) +gitlink-cli ci +builds --owner jiangtx --repo gitlink-cli --format json +# → {"status":-1,"message":"接口数据异常"} ← 确认 CI 未激活 + +# 此时终止后续步骤,生成"CI 未激活"报告 +``` + +### 关键发现 + +| 项目 | 值 | +|------|-----| +| `open_devops` | `false` | +| `ci +builds` 返回 | `{"status": -1, "message": "接口数据异常"}` | +| 可用 CI 命令 | `+builds`、`+logs`、`+restart`、`+stop` | +| 不存在的命令 | `ci +authorize`、`ci +activate`、`ci +deactivate` | + +### 诊断结论 + +CI 完全未启用,需通过 GitLink Web 界面开启(仓库设置 → DevOps)。用户拥有 Manager 权限,可以操作。 + +### 生成的报告 + +```markdown +# 🔧 CI 健康巡检报告:jiangtx/gitlink-cli + +> 巡检时间:2026-06-03 +> 仓库:jiangtx/gitlink-cli(Fork from Gitlink/gitlink-cli) +> CI 状态:❌ 未激活 + +## 一、健康度总览 + +| 指标 | 数值 | 评分 | +|------|------|------| +| CI 激活状态 | ❌ 未激活(open_devops: false) | 0/4 | +| 整体成功率 | N/A | —/5 | +| 近期稳定性 | N/A | —/5 | +| 构建频率 | N/A | —/3 | +| 修复速度 | N/A | —/3 | +| **总分** | | **0/20** | + +## 二、诊断详情 + +API 调用 ci +builds 返回: +{"status": -1, "message": "接口数据异常"} + +仓库元数据显示 open_devops: false,确认该仓库尚未启用 GitLink 平台的 CI/CD(DevOps)服务。 + +## 三、改进建议 + +- 🔴 立即激活 CI:前往 GitLink Web 界面 → 仓库设置 → DevOps 开启 CI/CD 服务 +- 🟡 配置 CI Pipeline:建议添加 .gitlink-ci.yml 配置编译和测试流水线 + +## 四、仓库基本信息 + +| 项目 | 值 | +|------|-----| +| 默认分支 | master | +| 仓库大小 | 13.4 MB | +| 贡献者 | 2 | +| PR 数量 | 9 | +| 权限 | Manager | +``` + +--- + +## 异常场景速查 + +| 场景 | 检测方式 | `ci +builds` 返回值 | 处理 | +|------|----------|---------------------|------| +| CI 未激活 | `repo +info` 的 `open_devops: false` | `{"status":-1,"message":"接口数据异常"}` | 建议 Web 界面激活,终止巡检 | +| CI 已激活但无构建 | `repo +info` 的 `open_devops: true` + builds 为空 | `[]` 或空列表 | 标注"暂无构建记录" | +| 构建样本不足(<5) | builds 列表长度 < 5 | 正常 JSON 数组 | 标注"数据有限,不具代表性" | + +--- + +## 版本兼容性说明 + +本 skill 基于 `gitlink-cli 0.1.18` 编写。不同版本的 CI 子命令可能有差异: + +| CLI 版本 | 可用 CI 命令 | +|----------|-------------| +| 0.1.18 | `+builds`、`+logs`、`+restart`、`+stop` | +| 未来版本 | 可能新增 `+activate`、`+deactivate` 等 | + +当 CLI 版本更新后,重新验证可用命令: +```bash +gitlink-cli ci --help +``` + +--- + +## 样例 2:通过 Agent 调用 Skill(自动巡检) + +**日期**:2026-06-03 +**仓库**:jiangtx/gitlink-cli +**调用方式**:`Agent(subagent_type="general-purpose", prompt="调用 gitlink-ci-health skill,检查 jiangtx/gitlink-cli 的 CI 状态。严格按照 skill 的工作流步骤执行。")` + +### Agent 自主执行的命令序列 + +``` +工具调用 1: gitlink-cli repo +info --owner jiangtx --repo gitlink-cli --format json + → open_devops: false ← 发现 CI 未激活 + +工具调用 2: gitlink-cli ci +builds --owner jiangtx --repo gitlink-cli --format json + → {"status": -1, "message": "接口数据异常"} ← 二次确认 +``` + +### Agent 决策过程 + +Agent 读取到 `open_devops: false` 后,**正确遵循了 skill 的异常场景处理规则**: + +> "CI 未激活 → 报告 CI 状态为'未激活',建议通过 Web 界面开启,**不再继续后续步骤**" + +Agent 没有继续尝试 `ci +logs` 或统计分析,直接生成了完整报告。共消耗 **5 次工具调用**,**32,113 tokens**,耗时 **35.8 秒**。 + +### Agent 生成的报告 + +```markdown +# CI 健康巡检报告:gitlink-cli + +> 巡检时间:2026-06-03 +> 仓库:jiangtx/gitlink-cli +> CI 状态:未激活 + +## 一、健康度总览 + +| 指标 | 数值 | 评分 | +|------|------|------| +| CI 激活状态 | 未激活 | 0/4 | +| 整体成功率 | N/A | -/5 | +| 近期稳定性 | N/A | -/5 | +| 构建频率 | N/A | -/3 | +| 修复速度 | N/A | -/3 | +| **总分** | | **0/20** | + +## 二、构建趋势 + +无构建记录。仓库 CI 未启用。 + +## 三、故障分析 + +CI 未激活,无构建数据可供分析。 + +## 四、改进建议 + +- **立即激活 CI**:前往 GitLink Web 界面 → 仓库设置 → DevOps,开启 CI/CD 服务。 + (CLI 暂不支持 ci +activate 命令,需要通过 Web 界面操作。) + +--- + +总结:仓库 jiangtx/gitlink-cli(项目 ID: 1547588,默认分支: master,权限: Manager) +当前 open_devops 字段为 false,CI/CD 服务未开启。其他仓库信息:2 位贡献者、 +9 个 PR、0 个 Issue,Fork 自 Gitlink/gitlink-cli。 +``` + +### 验证结论 + +✅ skill v1.1.0 修复验证通过: +- Agent 正确使用了 `repo +info` 的 `open_devops` 字段判断 CI 状态 +- Agent 在 CI 未激活时正确终止,没有执行后续无效步骤 +- Agent 没有尝试调用不存在的 `ci +authorize` 或 `ci +activate` +- Agent 正确建议通过 Web 界面激活 +- 报告结构完整,包含了仓库基本信息 diff --git a/skills/gitlink-ci-health/SKILL.md b/skills/gitlink-ci-health/SKILL.md index 7142cf9..8721c8b 100644 --- a/skills/gitlink-ci-health/SKILL.md +++ b/skills/gitlink-ci-health/SKILL.md @@ -1,6 +1,6 @@ --- name: gitlink-ci-health -version: 1.0.0 +version: 1.1.0 description: "CI 健康巡检:检查仓库 CI/CD 授权状态、构建历史和成功率,生成 CI 健康度报告。当用户需要检查 CI 状态、分析构建成功率、排查 CI 故障时触发。" metadata: requires: @@ -11,7 +11,7 @@ metadata: # gitlink-ci-health(CI 健康巡检) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 本 Skill 为只读操作(`ci +activate`/`+deactivate` 除外),非只读操作需确认用户意图。** +**CRITICAL — 本 Skill 为只读操作。CI 激活/关闭需通过 GitLink Web 界面操作,CLI 不提供对应命令。** **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 @@ -34,11 +34,24 @@ metadata: ### Step 1:检查 CI 授权状态 +**方法 1(推荐)**:通过 `repo +info` 查看 `open_devops` 字段: + ```bash -gitlink-cli ci +authorize --owner --repo --format json +gitlink-cli repo +info --owner --repo --format json ``` -判断 CI 是否已激活。若未激活,报告中说明"CI 未启用",建议执行 `ci +activate`。 +- `"open_devops": true` → CI 已激活 +- `"open_devops": false` → CI 未激活 + +**方法 2**:直接调用 `ci +builds`,CI 未激活时返回: + +```json +{"status": -1, "message": "接口数据异常"} +``` + +> ⚠️ `ci +authorize` 命令在当前 CLI 版本(v0.1.18)中**不存在**。可用 CI 命令仅:`+builds`、`+logs`、`+restart`、`+stop`。 + +若 CI 未激活,报告中说明"CI 未启用",建议通过 GitLink Web 界面(仓库设置 → DevOps)开启,随后不再继续后续步骤。 ### Step 2:获取构建历史 @@ -146,7 +159,7 @@ gitlink-cli ci +logs --owner --repo --build --format j -- **立即激活 CI**(当 CI 未激活时):执行 `gitlink-cli ci +activate --owner --repo ` +- **立即激活 CI**(当 CI 未激活时):前往 GitLink Web 界面 → 仓库设置 → DevOps 开启 CI/CD 服务(CLI 暂不支持 `ci +activate`) - **提升成功率**(当 success_rate < 80% 时):优先修复高频失败原因 - **增加构建频率**(当构建频率评分 < 2 时):建议每次 push 触发 CI - **缩短修复时间**(当修复速度评分 < 2 时):建立 CI 失败告警 @@ -158,7 +171,7 @@ gitlink-cli ci +logs --owner --repo --build --format j | 场景 | 处理方式 | |------|----------| -| CI 未激活 | 报告 CI 状态为"未激活",给出激活命令建议,不再继续后续步骤 | +| CI 未激活 | 报告 CI 状态为"未激活",建议通过 Web 界面开启,不再继续后续步骤 | | 无构建记录 | 标注"仓库暂无 CI 构建记录" | | `ci +logs` 返回空 | 标注"日志不可用" | | 构建总数 < 5 | 样本量不足,标注"数据有限,统计不具代表性" | @@ -168,8 +181,9 @@ gitlink-cli ci +logs --owner --repo --build --format j ## 注意事项 - ✅ **所有命令使用 `--format json`**,确保可解析 -- ✅ **`ci +activate` 和 `+deactivate` 为写操作**,执行前需确认用户意图 +- ✅ **CI 激活/关闭需通过 GitLink Web 界面**,CLI 不提供 `+activate`/`+deactivate` 命令 - ✅ **Owner/repo 优先从 `git remote` 自动解析** - ⚠️ **`ci +logs` 输出可能很大**,仅提取关键错误行 - ⚠️ **构建历史无分页参数**,实际返回条数取决于 API - ⚠️ **CI 数据仅反映 GitLink 平台活动**,不包括第三方 CI 服务 +- ⚠️ **`repo +info` 的 `open_devops` 字段**是判断 CI 是否激活的最可靠方式 diff --git a/skills/gitlink-contributor-insight/EXAMPLES.md b/skills/gitlink-contributor-insight/EXAMPLES.md new file mode 100644 index 0000000..b0fb4fb --- /dev/null +++ b/skills/gitlink-contributor-insight/EXAMPLES.md @@ -0,0 +1,239 @@ +# gitlink-contributor-insight 使用样例 + +## 样例 1:直接调用 Skill(手动执行) + +**日期**:2026-06-03 +**仓库**:jiangtx/gitlink-cli(Fork from Gitlink/gitlink-cli) +**CLI 版本**:gitlink-cli 0.1.18 + +### 执行流程 + +```bash +# Step 1: 获取仓库信息 +gitlink-cli repo +info --owner jiangtx --repo gitlink-cli --format json +# → contributor_users_count: 2, pull_requests_count: 9, issues_count: 0 +# → fork_info: { fork_project_user_login: "Gitlink" } + +# Step 2: 获取 PR 列表(替代不存在的 repo +contributors) +gitlink-cli pr +list --owner jiangtx --repo gitlink-cli --format json +# → 9 个 PR,全部已合并 +# → 唯一 author_login: lindiwen23 (5 PRs), jiangtx (4 PRs) + +# Step 3: 获取用户信息 +gitlink-cli user +info --login jiangtx --format json +# → 注册于 2026-04-28,3 个项目,身份"专业人士" + +gitlink-cli user +info --login lindiwen23 --format json +# → 注册于 2025-05-26,6 个项目,1 个组织,身份"专业人士" + +# Step 4: 获取 Issue 列表(补充数据) +gitlink-cli issue +list --owner jiangtx --repo gitlink-cli --format json +# → 0 个 Issue +``` + +### 不可用命令确认 + +| 命令 | 结果 | +|------|------| +| `gitlink-cli repo +contributors` | 命令不存在,返回 repo 帮助文本 | +| `gitlink-cli user +heatmap` | 命令不存在(user 子命令仅 `+info` / `+me`) | +| `gitlink-cli user +stats` | 命令不存在 | +| `gitlink-cli user +trends` | 命令不存在 | +| `gitlink-cli api GET "/api/v1/repos/.../contributors"` | 返回 HTML 页面,非 JSON | +| `gitlink-cli api GET "/api/v1/users/.../heatmap"` | 返回 HTML 页面,非 JSON | + +### 关键发现 + +**贡献者数据(从 PR 列表提取):** + +| 贡献者 | PR 数 | 活跃日期 | 活跃天数 | 趋势 | +|--------|-------|----------|----------|------| +| lindiwen23 | 5 | 06-01, 06-03 | 2 天 | ↑ | +| jiangtx | 4 | 06-02, 06-03 | 2 天 | ↑ | + +**PR 时间分布:** +- 06-01: 1 PR(lindiwen23 #1) +- 06-02: 2 PRs(jiangtx #2, #3) +- 06-03: 6 PRs(jiangtx #4, #5 + lindiwen23 #6, #7, #8, #9) + +**分级处理:** 项目仅 3 天历史,适用"年轻项目放宽标准"规则,2 人均标记为 🔥 核心贡献者。 + +### 生成的报告 + +```markdown +# 👥 贡献者洞察报告:gitlink-cli + +> 分析时间:2026-06-03 10:00 (UTC+8) +> 仓库:jiangtx/gitlink-cli(Fork from Gitlink/gitlink-cli) +> 总贡献者:2 人,本次分析:2 人(全量分析) + +## 一、团队概览 + +| 指标 | 数值 | +|------|------| +| 总贡献者 | 2 | +| 🔥 核心贡献者 | 2 | +| 🌟 活跃贡献者 | 2 | +| 🌱 新兴贡献者 | 0 | +| 💤 休眠贡献者 | 0 | +| 近 30 天活跃率 | 100% | +| 仓库总 PR 数 | 9(全部已合并) | +| 仓库总 Issue 数 | 0 | +| 项目启动时间 | 2026-06-01(3 天前) | + +## 二、贡献者活跃度排行榜 + +| 排名 | 贡献者 | 级别 | 类型 | 活跃天数 | 总PR | 总Issue | 趋势 | +|------|--------|------|------|---------|------|---------|------| +| 1 | lindiwen23 | 🔥 | 代码 | 2 天 | 5 | 0 | ↑ | +| 2 | jiangtx | 🔥 | 代码 | 2 天 | 4 | 0 | ↑ | + +## 三、重点贡献者分析 + +### 🔥 lindiwen23(核心贡献者) +- 5 个 PR(3 feat + 1 fix + 1 refactor),集中上午时段 +- 6/3 当天 43 分钟内连续提交 4 个 PR,集中爆发型节奏 +- 平台老用户(2025-05-26 注册),6 个项目经验 + +### 🔥 jiangtx(核心贡献者 / Owner) +- 4 个 PR(3 feat + 1 fix),下午至深夜时段 +- 从基础设施修复 → 模块补全,有序推进型节奏 +- 平台新用户(2026-04-28 注册),项目 Owner + +## 四、团队健康度评估 + +| 指标 | 状态 | 说明 | +|------|------|------| +| 核心贡献者占比 | 100% | 2/2 活跃 | +| 近 30 天活跃率 | 100% | 全部近期有贡献 | +| 知识分散度 | ⚠️ Bus Factor = 2 | 人数偏少 | +| 贡献稳定性 | ⚠️ 仅 3 天数据 | 无法评估长期 | + +### 风险提示 +- ⚠️ 核心贡献者不足(2 人),Bus Factor = 2 +- ℹ️ 项目处于早期阶段(3 天),风险置信度有限 +- ⚠️ 缺少新鲜血液,0 个外部 Issue + +## 五、社区建设建议 + +1. 保持当前协作节奏(独立分支 + PR 合并) +2. 建立 Issue 文化,添加 Good First Issue 标签 +3. 在 GitLink 平台推广,完善使用文档 +4. 完善代码注释,降低 Bus Factor 风险 +5. 定期同步上游(Gitlink/gitlink-cli) + +## 📋 数据来源与局限性 + +| 数据维度 | 来源 | 可靠性 | +|----------|------|--------| +| 贡献者数量 | repo +info | ✅ 可靠 | +| PR 贡献数据 | pr +list 全量 | ✅ 可靠 | +| 用户信息 | user +info | ✅ 可靠 | +| 贡献热力图 | 不可用(命令未实现) | ❌ 缺失 | +| 统计信息 | 不可用(命令未实现) | ❌ 缺失 | +| 趋势数据 | PR 时间序列推算 | ⚠️ 推算 | +``` + +--- + +## 样例 2:通过 Agent 调用 Skill(自动执行) + +**日期**:2026-06-03 +**仓库**:jiangtx/gitlink-cli +**调用方式**:`Agent(subagent_type="general-purpose", prompt="调用 gitlink-contributor-insight skill,分析一下 jiangtx/gitlink-cli 的贡献者活跃度。")` + +### Agent 自主执行的命令序列 + +``` +工具调用 1: gitlink-cli repo +info --owner jiangtx --repo gitlink-cli --format json + → contributor_users_count: 2, pull_requests_count: 9 + +工具调用 2: gitlink-cli pr +list --owner jiangtx --repo gitlink-cli --format json + → 9 PRs, 提取 author_login: lindiwen23(5), jiangtx(4) + +工具调用 3: gitlink-cli issue +list --owner jiangtx --repo gitlink-cli --format json + → 0 issues + +工具调用 4: gitlink-cli user +info --login jiangtx --format json + → 注册 2026-04-28, 3 个项目 + +工具调用 5: gitlink-cli user +info --login lindiwen23 --format json + → 注册 2025-05-26, 6 个项目, 1 个组织 +``` + +### Agent 决策过程 + +Agent 读取 skill 后,**正确遵循了更新后的工作流**: + +1. **未尝试 `repo +contributors`**:skill 的"命令可用性声明"表标注该命令不可用 +2. **未尝试 `user +heatmap/+stats/+trends`**:skill 标注不可用,直接从 PR 列表推算 +3. **未尝试 Raw API**:skill 不推荐此路径,全程使用 Shortcut 命令 +4. **正确应用"年轻项目"规则**:识别项目仅 3 天,放宽分级标准,2 人均标记为 🔥 核心 +5. **自主增强分析**:Agent 额外分析了工作时段偏好、PR 类型统计、新老比例 + +共消耗 **5 次 CLI 调用**,**41,312 tokens**,耗时 **77.5 秒**。 + +### Agent 相对于手动执行的改进 + +| 维度 | 手动执行 | Agent 执行 | +|------|----------|-----------| +| 工作节奏分析 | 仅按日期统计 | 识别出时段偏好(上午 vs 深夜) | +| PR 类型统计 | 未分类 | feat(6) + fix(3) + refactor(1) | +| 新老比例 | 未计算 | 1:1(jiangtx 1 月 vs lindiwen23 1 年+) | +| 贡献者建议 | 通用建议 | 建议为 lindiwen23 授予更高级别权限 | +| 协作模式 | 分支策略分析 | 新增独立分支 + PR 合并模式分析 | + +### Agent 生成的报告摘要 + +Agent 生成了完整的五段式报告(团队概览 → 排行榜 → 个人分析 → 健康度评估 → 建议),结构与我手动执行一致,但细节更丰富: + +- **lindiwen23 分析**:增加了"3 feat + 1 fix + 1 refactor"分类、"上午 9:00-11:30 时段偏好"、"集中爆发型节奏" +- **jiangtx 分析**:增加了"先修复基础设施,再逐步补全功能模块"的有序推进评价 +- **健康度评估**:增加了新老比例 1:1 分析,指出两人经验互补 +- **建议**:更具体,如"为 lindiwen23 授予更高级别权限"、"编写 CONTRIBUTING.md" + +### 验证结论 + +✅ skill v1.1.0 验证通过: +- Agent 正确遵循了"命令可用性声明",未尝试不可用命令 +- Agent 正确从 `pr +list` 提取贡献者数据(替代不存在的 `repo +contributors`) +- Agent 正确从 PR 时间戳推算活跃天数(替代不存在的 `user +heatmap`) +- Agent 正确从 PR 聚合获得产出量(替代不存在的 `user +stats`) +- Agent 正确从 PR 时间分布判断趋势(替代不存在的 `user +trends`) +- Agent 正确应用"年轻项目放宽标准"规则 +- Agent 正确标注数据来源局限性 +- Agent 未使用 `gh` 或其他平台工具 +- 报告结构完整,覆盖所有必需章节 + +--- + +## 异常场景速查 + +| 场景 | 检测方式 | 数据表现 | 处理 | +|------|----------|----------|------| +| `repo +contributors` 不可用 | 命令返回帮助文本 | 无 `+contributors` 子命令 | 从 `pr +list` 提取 `author_login` | +| `user +heatmap` 不可用 | 命令不存在 | user 仅 `+info`/`+me` | 从 PR 时间戳推算活跃天数 | +| `user +stats` 不可用 | 命令不存在 | 同上 | 从 `pr +list` 聚合 PR/Issue 数 | +| `user +trends` 不可用 | 命令不存在 | 同上 | 从 PR 按日聚合判断趋势 | +| Raw API 返回 HTML | `api GET` 返回 HTML | 非 JSON 响应 | 仅使用 Shortcut 命令 | +| 项目 < 30 天 | PR 时间跨度 < 30 天 | 全部 PR 在近期 | 放宽分级标准,标注"早期阶段" | +| 贡献者 ≤ 2 人 | `contributor_users_count` ≤ 2 | Bus Factor 极低 | 报告标注风险 + 提供吸引新人建议 | +| PR 数为 0 | `pr +list` 空数组 | `issues: []` | 标注"仓库暂无 PR 数据" | +| Issue 数为 0 | `issue +list` 空数组 | `issues_count: 0` | 贡献类型统一标注"代码贡献者" | + +--- + +## 版本兼容性说明 + +本 skill 基于 `gitlink-cli 0.1.18` 编写。不同版本的可用命令可能有差异: + +| CLI 版本 | 贡献者分析可用命令 | 缺失命令 | +|----------|-------------------|----------| +| 0.1.18 | `repo +info`, `pr +list`, `issue +list`, `user +info` | `repo +contributors`, `user +heatmap`, `user +stats`, `user +trends` | +| 未来版本 | 可能新增 `user +heatmap` 等 | — | + +当 CLI 版本更新后,重新验证可用命令: +```bash +gitlink-cli repo --help +gitlink-cli user --help +``` diff --git a/skills/gitlink-contributor-insight/SKILL.md b/skills/gitlink-contributor-insight/SKILL.md index 10bb52c..9ad7ba8 100644 --- a/skills/gitlink-contributor-insight/SKILL.md +++ b/skills/gitlink-contributor-insight/SKILL.md @@ -1,6 +1,6 @@ --- name: gitlink-contributor-insight -version: 1.0.0 +version: 1.1.0 description: "贡献者活跃度分析:分析仓库贡献者的活跃度、贡献趋势和工作节奏,生成贡献者洞察报告。当用户需要分析贡献者活跃度、查看团队贡献趋势、评估成员参与度时触发。" metadata: requires: @@ -18,62 +18,91 @@ metadata: --- +## ⚠️ 命令可用性声明 + +gitlink-cli 的命令集在持续演进中。以下命令**当前版本可能不可用**,执行前先验证: + +| 命令 | 状态 | 替代方案 | +|------|------|----------| +| `repo +contributors` | ❌ 不可用 | 从 `pr +list` 提取 `author_login` + `repo +info` 获取 `contributor_users_count` | +| `user +heatmap` | ❌ 不可用 | 从 PR 时间戳手动推算活跃天数 | +| `user +stats` | ❌ 不可用 | 从 `pr +list` 统计 PR 数;Issue 数通过 `issue +list` 获取 | +| `user +trends` | ❌ 不可用 | 从 PR 时间分布手动判断趋势(上升/平稳/下降) | +| `repo +info` | ✅ 可用 | — | +| `pr +list` | ✅ 可用 | — | +| `user +info` | ✅ 可用 | — | +| `issue +list` | ✅ 可用 | — | + +> **核心原则**:优先使用可用的 Shortcut 命令。当所需命令不可用时,从 `pr +list` 和 `user +info` 中提取等效数据,并在报告中标注"数据来源:PR 列表(命令 X 不可用)"。 + +--- + ## 功能概述 面向开源社区管理者和维护者的贡献者分析工具: -1. **项目概览** — 获取仓库贡献者规模 -2. **贡献热力图分析** — 通过 `user +heatmap` 查看贡献节奏 -3. **统计数据提取** — 通过 `user +stats` 获取个人统计 -4. **趋势分析** — 通过 `user +trends` 查看项目趋势 +1. **项目概览** — 获取仓库贡献者规模和基本信息 +2. **贡献数据分析** — 通过 `pr +list` 提取每位贡献者的 PR 数量和时间分布 +3. **用户画像** — 通过 `user +info` 了解贡献者背景 +4. **趋势判断** — 从 PR 时间序列推断贡献趋势 5. **洞察报告** — 生成贡献者活跃度排名和团队健康度评估 --- ## 工作流:贡献者分析全流程 -### Step 1:获取项目贡献者列表 - -```bash -gitlink-cli repo +contributors --owner --repo --format json -``` - -> 如果仓库贡献者数量较多(>15),按 `repo +contributors` 返回中 `commits_count` 降序排列,取前 10 位分析。如 `commits_count` 缺失,按返回的自然顺序取前 10 位,报告中注明"基于返回顺序 Top 10"。 - -提取每个贡献者的 `login`(用户名)。 - -### Step 2:获取仓库基本信息 +### Step 1:获取仓库基本信息 ```bash gitlink-cli repo +info --owner --repo --format json ``` -提取 `contributor_users_count`、`full_name`、`description`。 +提取:`contributor_users_count`、`full_name`、`description`、`default_branch`、`fork_info`(如为 Fork 项目)。 + +### Step 2:获取贡献者列表(通过 PR 数据) + +由于 `repo +contributors` 不可用,改用两步获取贡献者: + +```bash +# 2a. 获取所有 PR(含已合并和已关闭) +gitlink-cli pr +list --owner --repo --format json + +# 2b. 如果 Issue 数据也需要 +gitlink-cli issue +list --owner --repo --format json +``` + +从 `pr +list` 返回数据中: +- 提取所有唯一的 `author_login` 作为实际代码贡献者 +- 统计每位作者的 PR 数(`pull_request_status`: 0=open, 1=merged, 2=closed) +- 记录每个 PR 的 `pr_full_time` 用于时间分析 +- 记录每个 PR 的 `journals_count`(评论/审核活动数) + +从 `issue +list` 返回数据中: +- 提取所有唯一的 `author_login` 作为 Issue 参与者 +- 统计每位作者的 Issue 数 + +> 如果 PR 数量较多(>50),按 `author_login` 聚合后取 PR 数前 10 的贡献者分析,报告中注明"基于 Top 10 分析"。 ### Step 3:逐位贡献者深度分析 -对每位贡献者执行以下命令: +对每位贡献者执行: ```bash -# 热力图(最近一年的贡献日历) -gitlink-cli user +heatmap --login --format json - -# 统计信息(PR/Issue/Commit 数量) -gitlink-cli user +stats --login --format json - -# 项目趋势 -gitlink-cli user +trends --login --format json +# 用户基本信息 +gitlink-cli user +info --login --format json ``` -从返回数据中提取: +从 `user +info` 提取:`login`、`name`、`created_time`(注册时间)、`user_projects_count`、`user_org_count`、`user_identity`。 -| 维度 | 来源命令 | 分析要点 | +**如果 `user +heatmap/+stats/+trends` 可用**(未来版本),补充执行。当前版本用以下替代方案: + +| 维度 | 替代数据源 | 分析要点 | |------|----------|----------| -| 贡献频率 | `+heatmap` | 最近 1/3/6/12 个月有贡献的天数,判断是"持续贡献者"还是"间歇参与者" | -| 贡献产出 | `+stats` | PR 数、Issue 数、Commit 数,区分"代码贡献者"和"问题反馈者" | -| 活跃趋势 | `+trends` | 贡献量是上升/稳定/下降,识别"上升期贡献者"和"逐渐淡出者" | +| 贡献频率 | PR 时间戳列表 | 统计活跃天数、相邻 PR 间隔、判断"持续贡献者"还是"间歇参与者" | +| 贡献产出 | `pr +list` 聚合 | PR 数、Issue 数,区分"代码贡献者"和"问题反馈者" | +| 活跃趋势 | PR 按日/周聚合 | 贡献量上升/稳定/下降,识别"上升期贡献者"和"逐渐淡出者" | -> ⚠️ **控制 API 调用**:贡献者 >15 人时,仅分析 Step 1 中按 `commits_count` 排序后的前 10 位。每人最多 3 次 API 调用(heatmap + stats + trends,共 ≤30 次)。 +> ⚠️ **控制 API 调用**:贡献者 >15 人时,仅分析 PR 数最高的前 10 位。每人 1 次 `user +info` 调用(共 ≤10 次),PR 数据已在 Step 2 全量获取。 ### Step 4:贡献者分级与分类 @@ -81,21 +110,25 @@ gitlink-cli user +trends --login --format json | 级别 | 判定标准 | |------|----------| -| 🔥 **核心贡献者** | 最近 30 天有贡献 + 总贡献 PR > 10 | -| 🌟 **活跃贡献者** | 最近 60 天有贡献 + 总贡献 > 5 | +| 🔥 **核心贡献者** | 最近 30 天有贡献 + 总贡献 PR ≥ 5(或总贡献 PR > 10) | +| 🌟 **活跃贡献者** | 最近 60 天有贡献 + 总贡献 ≥ 3 | | 🌱 **新兴贡献者** | 最近 90 天首次出现 + 贡献频率上升 | | 💤 **休眠贡献者** | 最近 90 天无贡献 + 历史有贡献 | +> **年轻项目特殊处理**:项目历史 < 30 天时,放宽标准——所有活跃贡献者均可标记为核心贡献者,报告中注明"项目处于早期阶段,分级标准已放宽"。 + #### 4.2 贡献类型分类 | 类型 | 判定 | |------|------| -| **代码贡献者** | PR/Commit 数量占比最高 | -| **问题反馈者** | Issue 数量占比最高 | -| **全能贡献者** | PR 和 Issue 数量均衡 | +| **代码贡献者** | PR 数量 > Issue 数量 | +| **问题反馈者** | Issue 数量 > PR 数量 | +| **全能贡献者** | PR 和 Issue 数量均衡(差异 ≤ 1) | ### Step 5:生成贡献者洞察报告 +按下方输出模板生成报告,并根据数据可用性灵活调整章节。 + --- ## 输出模板 @@ -124,9 +157,9 @@ gitlink-cli user +trends --login --format json ## 二、贡献者活跃度排行榜 -| 排名 | 贡献者 | 级别 | 类型 | 近30天贡献 | 总PR | 总Issue | 趋势 | -|------|--------|------|------|-----------|------|---------|------| -| 1 | {{login}} | 🔥 | 代码 | {{d30}} 天 | {{pr_count}} | {{issue_count}} | ↑ | +| 排名 | 贡献者 | 级别 | 类型 | 活跃天数 | 总PR | 总Issue | 趋势 | +|------|--------|------|------|---------|------|---------|------| +| 1 | {{login}} | 🔥 | 代码 | {{d}} 天 | {{pr_count}} | {{issue_count}} | ↑ | | ... | ... | ... | ... | ... | ... | ... | ... | --- @@ -139,11 +172,17 @@ gitlink-cli user +trends --login --format json | 维度 | 数据 | 说明 | |------|------|------| -| 最近 30 天贡献 | {{d30}} 天 | {{评价}} | +| 活跃天数 | {{d}} 天 | {{评价}} | | 总 PR 数 | {{pr_count}} | | | 总 Issue 数 | {{issue_count}} | | | 贡献趋势 | {{trend_direction}} | {{trend_comment}} | +**PR 贡献明细**:(可选,数据充足时展示) + +| PR# | 标题 | 日期 | 类型 | +|-----|------|------|------| +| ... | ... | ... | feat/fix/refactor | + --- ## 四、团队健康度评估 @@ -159,21 +198,12 @@ gitlink-cli user +trends --login --format json ### 风险提示 - - - ⚠️ **核心贡献者不足**(当 core_count < 3 时):仅 {{core_count}} 位核心贡献者,存在单点依赖风险(Bus Factor = {{core_count}})。 - ⚠️ **贡献者流失**(当 dormant_rate > 50% 时):超过一半的贡献者已不活跃,需要关注社区留存。 - ⚠️ **缺少新鲜血液**(当 new_count == 0 时):近期无新兴贡献者,建议通过 Good First Issue 等方式吸引新人。 +- ℹ️ **项目处于早期阶段**(当项目历史 < 30 天时):贡献者分级标准已放宽,以上风险置信度有限。 - ✅ **团队健康**(当以上情况均不满足时):贡献者结构合理,团队运转良好。 -> 指标计算: -> - `core_ratio` = core_count / analyzed_count × 100 -> - `dormant_rate` = dormant_count / analyzed_count × 100 -> - `active_30d_rate` = (近30天至少一次贡献的人数) / analyzed_count × 100 -> - `new_old_ratio`:新兴贡献者数 : 核心+活跃贡献者数 的比值 -> - `bus_factor` = core_count(简化定义:核心贡献者数量最低值) -> - `stability`:判断标准为"贡献标准差"(各月贡献量波动小=高稳定性,波动大=低稳定性) - --- ## 五、社区建设建议 @@ -182,6 +212,22 @@ gitlink-cli user +trends --login --format json 2. **激活休眠贡献者**:{{休眠贡献者召回建议}} 3. **吸引新贡献者**:{{新贡献者吸引建议}} 4. **平衡贡献类型**:{{贡献类型平衡建议}} + +--- + +## 📋 数据来源与局限性 + +| 数据维度 | 来源 | 可靠性 | +|----------|------|--------| +| 贡献者数量 | `repo +info` | ✅ 可靠 | +| PR 贡献数据 | `pr +list` 全量 | ✅ 可靠 | +| Issue 数据 | `issue +list` | ✅ 可靠 | +| 用户信息 | `user +info` | ✅ 可靠 | +| 贡献热力图 | 不可用(命令未实现) | ❌ 缺失 | +| 统计信息 | 不可用(命令未实现) | ❌ 缺失 | +| 趋势数据 | 不可用(命令未实现) | ❌ 缺失 | + +> **局限性**:本报告仅反映 GitLink 平台活动,不包括其他平台(GitHub、GitLab 等)的数据。 ``` --- @@ -190,10 +236,13 @@ gitlink-cli user +trends --login --format json | 场景 | 处理方式 | |------|----------| -| `repo +contributors` 返回空 | 标注"仓库暂无贡献者数据",仅从 `repo +info` 获取 `contributor_users_count` | -| `user +heatmap` 返回空 | 标注"无热力图数据",评分仅基于 stats 和 trends | -| `user +stats` / `+trends` 返回错误 | 跳过该维度,标注"数据不可用" | -| 贡献者 > 15 人 | 仅分析贡献量最高的前 10 位,报告中注明"基于 Top 10 分析" | +| `repo +contributors` 不可用(当前版本常态) | 从 `pr +list` 的 `author_login` 提取贡献者列表 | +| `user +heatmap` / `+stats` / `+trends` 不可用 | 从 PR 时间戳推算活跃天数,PR 聚合得产出量,时间分布得趋势 | +| `pr +list` 返回空 | 标注"仓库暂无 PR 数据",仅展示 `repo +info` 基本信息 | +| `user +info` 返回空 | 标注"用户信息不可用",仅展示 PR 统计 | +| 贡献者 > 15 人 | 仅分析 PR 数最高的前 10 位,报告中注明"基于 Top 10 分析" | +| 项目历史 < 30 天 | 放宽分级标准,报告中注明"项目处于早期阶段" | +| `issue +list` 返回空 | Issue 数列为 0,贡献类型统一标注"代码贡献者" | --- @@ -202,6 +251,7 @@ gitlink-cli user +trends --login --format json - ✅ **所有命令使用 `--format json`**,确保可解析 - ✅ **本 Skill 为纯只读分析**,不会修改任何仓库 - ✅ **Owner/repo 优先从 `git remote` 自动解析**,无 git 上下文时询问用户 -- ⚠️ **每人 3 次 API 调用**(heatmap + stats + trends),10 人即 30 次,注意控制分析人数 -- ⚠️ **热力图数据可能稀疏**:部分贡献者数据不完整,标注"数据有限" +- ⚠️ **核心数据来源为 `pr +list`**:当前版本 gitlink-cli 中 `user +heatmap/+stats/+trends` 不可用,分析主要依赖 PR 列表数据 +- ⚠️ **`repo +contributors` 不可用**:贡献者列表从 PR 作者提取,可能与实际 `contributor_users_count` 有差异(后者包含未提 PR 的参与者) - ⚠️ **数据仅反映 GitLink 平台活动**:不包括 GitHub 或其他平台的数据 +- ℹ️ **参照样例**:[`EXAMPLES.md`](EXAMPLES.md) 包含手动执行和 Agent 调用两种场景的完整样例,[`examples/jiangtx-gitlink-cli.md`](examples/jiangtx-gitlink-cli.md) 包含原始命令输出数据 diff --git a/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md b/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md new file mode 100644 index 0000000..57b4df5 --- /dev/null +++ b/skills/gitlink-contributor-insight/examples/jiangtx-gitlink-cli.md @@ -0,0 +1,201 @@ +# 执行样例:jiangtx/gitlink-cli 贡献者活跃度分析 + +> 执行日期:2026-06-03 +> 执行版本:gitlink-cli (当前版本) +> 说明:本文件记录了一次完整的贡献者分析执行过程,包含实际命令输出和最终报告。可作为后续分析的参考模板。 + +--- + +## 实际执行的命令与输出 + +### 1. `gitlink-cli repo +info` + +```json +{ + "ok": true, + "data": { + "author": { "id": 148911, "login": "jiangtx", "name": "jiangtx" }, + "clone_url": "https://gitlink.org.cn/jiangtx/gitlink-cli.git", + "contributor_users_count": 2, + "default_branch": "master", + "fork_info": { + "fork_form_name": "gitlink-cli", + "fork_project_identifier": "gitlink-cli", + "fork_project_user_login": "Gitlink", + "fork_project_user_name": "GitLink" + }, + "forked_from_project_id": 1513956, + "full_name": "jiangtx/gitlink-cli", + "identifier": "gitlink-cli", + "issues_count": 0, + "permission": "Manager", + "private": false, + "project_id": 1547588, + "pull_requests_count": 9, + "size": "13.4 MB", + "watchers_count": 0 + } +} +``` + +### 2. `gitlink-cli pr +list` (关键数据源) + +9 个 PR,全部已合并。按作者汇总: + +| author_login | PR 数 | PR 编号 | 时间范围 | +|-------------|--------|---------|----------| +| lindiwen23 | 5 | #1, #6, #7, #8, #9 | 2026-06-01~06-03 | +| jiangtx | 4 | #2, #3, #4, #5 | 2026-06-02~06-03 | + +PR 详细列表: + +```json +// lindiwen23 的 PR +{ "pull_request_number": 1, "author_login": "lindiwen23", + "name": "feat: 新增 3 个 Skill(onboarding / issue-triage / research-tracker)", + "pr_full_time": "2026-06-01T11:33:00.000+08:00", "pull_request_status": 1, + "journals_count": 3 } + +{ "pull_request_number": 6, "author_login": "lindiwen23", + "name": "fix: detectHTMLResponse 跳过 XML 声明,添加 HTML 响应检测", + "pr_full_time": "2026-06-03T08:55:10.000+08:00", "pull_request_status": 1, + "journals_count": 2 } + +{ "pull_request_number": 7, "author_login": "lindiwen23", + "name": "feat: org +teams/+create-team/+remove-user, search +code/+issues", + "pr_full_time": "2026-06-03T09:13:15.000+08:00", "pull_request_status": 1, + "journals_count": 2 } + +{ "pull_request_number": 8, "author_login": "lindiwen23", + "name": "feat: 新建 notification 模块并注册", + "pr_full_time": "2026-06-03T09:17:32.000+08:00", "pull_request_status": 1, + "journals_count": 2 } + +{ "pull_request_number": 9, "author_login": "lindiwen23", + "name": "fix: Skills 文件 api 命令替换为 Shortcut 命令 (~107 处)", + "pr_full_time": "2026-06-03T09:38:05.000+08:00", "pull_request_status": 1, + "journals_count": 2 } + +// jiangtx 的 PR +{ "pull_request_number": 2, "author_login": "jiangtx", + "name": "基础设施修复", + "pr_full_time": "2026-06-02T16:54:43.000+08:00", "pull_request_status": 1, + "journals_count": 2 } + +{ "pull_request_number": 3, "author_login": "jiangtx", + "name": "repo 域补全", + "pr_full_time": "2026-06-02T23:27:46.000+08:00", "pull_request_status": 1, + "journals_count": 2 } + +{ "pull_request_number": 4, "author_login": "jiangtx", + "name": "pr 域补全", + "pr_full_time": "2026-06-03T00:09:46.000+08:00", "pull_request_status": 1, + "journals_count": 2 } + +{ "pull_request_number": 5, "author_login": "jiangtx", + "name": "label 模块新建", + "pr_full_time": "2026-06-03T00:25:32.000+08:00", "pull_request_status": 1, + "journals_count": 2 } +``` + +### 3. `gitlink-cli user +info --login jiangtx` + +```json +{ + "ok": true, + "data": { + "login": "jiangtx", "name": "jiangtx", + "user_id": 148911, + "created_time": "2026-04-28 11:46", + "user_projects_count": 3, + "user_org_count": 0, + "user_identity": "专业人士" + } +} +``` + +### 4. `gitlink-cli user +info --login lindiwen23` + +```json +{ + "ok": true, + "data": { + "login": "lindiwen23", "name": "lindiwen23", + "user_id": 141609, + "created_time": "2025-05-26 22:40", + "user_projects_count": 6, + "user_org_count": 1, + "user_identity": "专业人士" + } +} +``` + +### 5. 不可用的命令 + +| 命令 | 结果 | +|------|------| +| `gitlink-cli repo +contributors` | 命令不存在,返回 repo 帮助文本 | +| `gitlink-cli user +heatmap` | 命令不存在(user 仅 `+info` / `+me`) | +| `gitlink-cli user +stats` | 命令不存在 | +| `gitlink-cli user +trends` | 命令不存在 | +| `gitlink-cli api GET "/api/v1/repos/.../contributors"` | 返回 HTML 页面,非 JSON | +| `gitlink-cli api GET "/api/v1/users/.../heatmap"` | 返回 HTML 页面,非 JSON | + +--- + +## 数据处理过程 + +### 贡献者发现 + +由于 `repo +contributors` 不可用: +1. 从 `repo +info` 获取 `contributor_users_count = 2` +2. 从 `pr +list` 提取唯一 `author_login`:`["lindiwen23", "jiangtx"]`(2 人,一致) + +### 活跃天数计算 + +从 PR 的 `pr_full_time` 字段提取日期: + +| 贡献者 | 活跃日期 | 活跃天数 | +|--------|----------|----------| +| lindiwen23 | 2026-06-01, 2026-06-03 | 2 天 | +| jiangtx | 2026-06-02, 2026-06-03 | 2 天 | + +### 趋势判断 + +按日聚合 PR 数: +- 06-01: 1 PR +- 06-02: 2 PRs +- 06-03: 6 PRs + +趋势:↑ 上升(日产出加速:1→2→6) + +### 贡献类型分类 + +| 贡献者 | PR 数 | Issue 数 | 类型 | +|--------|-------|----------|------| +| lindiwen23 | 5 | 0 | 代码贡献者 | +| jiangtx | 4 | 0 | 代码贡献者 | + +> Issue 来源:`repo +info` 中 `issues_count = 0`,无 Issue 需要获取。 + +### 分级调整 + +由于项目仅 3 天历史(< 30 天),适用年轻项目特殊处理: +- jiangtx(PR=4,2 活跃天)→ 🔥 核心贡献者 +- lindiwen23(PR=5,2 活跃天)→ 🔥 核心贡献者 + +--- + +## 完整输出报告 + +(见当天执行输出,此处省略以保持文件精简。核心结构:团队概览 → 排行榜 → 个人分析 → 健康度评估 → 建议。) + +--- + +## 经验总结 + +1. **PR 数据可作为贡献者分析的主要数据源**:`pr +list` 提供了作者、时间、状态、标题等丰富信息 +2. **`pr_full_time` 字段足够做时间分布分析**:可计算活跃天数、贡献频率、趋势 +3. **`user +info` 补充贡献者画像**:注册时间、项目数、组织数可用于背景分析 +4. **极端年轻项目的分级需放宽**:标准分级(PR > 10)对 3 天项目不适用 +5. **Raw API 不可靠**:GitLink 的 API 结构与标准 Gitea 不同,建议仅使用 Shortcut 命令 From 94d51157895515f4727af6f16552fa0900e6c99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E8=BF=AA=E6=96=87?= <3117675914@qq.com> Date: Wed, 3 Jun 2026 10:31:42 +0800 Subject: [PATCH 13/19] =?UTF-8?q?fix(notification-digest):=20v2.0.0=20?= =?UTF-8?q?=E2=80=94=20=E4=BF=AE=E6=AD=A3=E8=99=9A=E6=9E=84=E7=9A=84=20not?= =?UTF-8?q?ification=20=E5=91=BD=E4=BB=A4=E4=B8=BA=E5=AE=9E=E9=99=85=20mes?= =?UTF-8?q?sages=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notification +list/+read/+read-all 均不存在,替换为 gitlink-cli api 调用 - API 端点: GET /api/users/{owner}/messages.json - 标记已读: POST /api/users/{owner}/messages/{id}/read - 添加 CLI 路径 Bug 说明(前导 / 导致解析错误) - 补充完整 source 枚举值表(30+ 个枚举) - 新增 EXAMPLES.md(手动执行 + Agent 测试两个样例) - gitlink-shared/api-reference.md 补充消息 API 章节 Co-Authored-By: Claude Opus 4.8 --- .../gitlink-notification-digest/EXAMPLES.md | 306 ++++++++++++++++++ skills/gitlink-notification-digest/SKILL.md | 214 +++++++++--- .../references/api-reference.md | 97 ++++++ 3 files changed, 572 insertions(+), 45 deletions(-) create mode 100644 skills/gitlink-notification-digest/EXAMPLES.md diff --git a/skills/gitlink-notification-digest/EXAMPLES.md b/skills/gitlink-notification-digest/EXAMPLES.md new file mode 100644 index 0000000..7c52285 --- /dev/null +++ b/skills/gitlink-notification-digest/EXAMPLES.md @@ -0,0 +1,306 @@ +# gitlink-notification-digest 使用样例 + +## 样例 1:手动执行通知摘要 + +**日期**:2026-06-03 +**用户**:lindiwen23 +**CLI 版本**:gitlink-cli 0.1.18 + +### 执行流程 + +```bash +# Step 1: 获取用户名 +gitlink-cli auth status +# → Logged in as lindiwen23 + +# Step 2: 获取未读通知(status=1) +gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=1&limit=20" --format json +# → 7 条未读,unread_notification=7, unread_atme=0 + +# Step 3: 获取已读通知(用于趋势分析和回顾) +gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=2&limit=20" --format json +# → 21 条已读 + +# Step 4: 分类统计、生成摘要报告 +``` + +### 关键发现 + +| 项目 | 值 | +|------|-----| +| 未读通知 | 7 条 | +| @我未读 | 0 条 | +| 总通知 | 28 条(7 未读 + 21 已读) | +| 不存在命令 | `gitlink-cli notification`(整个子命令不存在) | +| 实际 API | `GET /api/users/{owner}/messages.json` | +| CLI Bug | `api` 路径以 `/` 开头会被解析为本地文件路径 | + +### 原始 API 返回(未读 7 条) + +```json +{ + "total_count": 7, + "type": "", + "unread_notification": 7, + "unread_atme": 0, + "messages": [ + { + "id": 740214, "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:label 模块新建", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15347", + "source": "ProjectPullRequest", + "created_at": "2026-06-03 00:27:37", "time_ago": "10小时前", + "type": "notification" + }, + { + "id": 740213, "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:pr 域补全", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15346", + "source": "ProjectPullRequest", + "created_at": "2026-06-03 00:11:48", "time_ago": "10小时前", + "type": "notification" + }, + { + "id": 740178, "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:repo 域补全", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15343", + "source": "ProjectPullRequest", + "created_at": "2026-06-02 23:29:52", "time_ago": "11小时前", + "type": "notification" + }, + { + "id": 740076, "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:基础设施修复", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15336", + "source": "ProjectPullRequest", + "created_at": "2026-06-02 16:56:47", "time_ago": "17小时前", + "type": "notification" + }, + { + "id": 740002, "status": 1, + "content": "CWQ 点赞了你管理的仓库 CWQ/Aether_Lens_System-v0.0.1", + "notification_url": "https://www.gitlink.org.cn/caoweiqiong", + "source": "ProjectPraised", + "created_at": "2026-06-02 15:12:55", "time_ago": "19小时前", + "type": "notification" + }, + { + "id": 738181, "status": 1, + "content": "Somebird 已加入项目 CWQ/Aether_Lens_System-v0.0.1", + "notification_url": "https://www.gitlink.org.cn/caoweiqiong/Aether", + "source": "ProjectMemberJoined", + "created_at": "2026-06-01 22:22:07", "time_ago": "1天前", + "type": "notification" + }, + { + "id": 738136, "status": 1, + "content": "Somebird 点赞了你管理的仓库 CWQ/Aether_Lens_System-v0.0.1", + "notification_url": "https://www.gitlink.org.cn/Somebird", + "source": "ProjectPraised", + "created_at": "2026-06-01 20:18:28", "time_ago": "2天前", + "type": "notification" + } + ] +} +``` + +### 分类处理 + +按 `source` 字段分类: + +| source | 含义 | 数量 | 优先级 | +|--------|------|------|--------| +| `ProjectPullRequest` | 项目新 PR(jiangtx/gitlink-cli) | 4 | P2 | +| `ProjectPraised` | 项目被点赞(CWQ/Aether_Lens_System) | 2 | P3 | +| `ProjectMemberJoined` | 新成员加入 | 1 | P3 | + +### 生成的报告 + +```markdown +# 🔔 通知摘要 + +> 生成时间:2026-06-03 10:18 +> 未读通知:7 条 / 总计:28 条 + +--- + +## 一、概要 + +| 类型 | 未读 | 总计 | +|------|------|------| +| 🔴 @提及 | 0 | 0 | +| 🟡 Issue 更新 | 0 | 0 | +| 🟢 PR 更新 | 4 | ~8 | +| 🔵 系统通知 | 3 | ~19 | + +--- + +## 二、需要立即处理(P0) + +🎉 无紧急通知。 + +## 三、今天处理(P1) + +无待处理通知。 + +## 四、本周关注(P2) + +| # | 类型 | 仓库 | 内容摘要 | 时间 | +|---|------|------|----------|------| +| 1 | 🟢 PR | jiangtx/gitlink-cli | label 模块新建 (#15347) | 6/3 00:27 | +| 2 | 🟢 PR | jiangtx/gitlink-cli | pr 域补全 (#15346) | 6/3 00:11 | +| 3 | 🟢 PR | jiangtx/gitlink-cli | repo 域补全 (#15343) | 6/2 23:29 | +| 4 | 🟢 PR | jiangtx/gitlink-cli | 基础设施修复 (#15336) | 6/2 16:56 | + +## 五、可忽略(P3) + +| # | 类型 | 仓库 | 内容摘要 | 时间 | +|---|------|------|----------|------| +| 1 | 🔵 点赞 | CWQ/Aether_Lens_System-v0.0.1 | CWQ 点赞了仓库 | 6/2 15:12 | +| 2 | 🔵 成员 | CWQ/Aether_Lens_System-v0.0.1 | Somebird 加入项目 | 6/1 22:22 | +| 3 | 🔵 点赞 | CWQ/Aether_Lens_System-v0.0.1 | Somebird 点赞了仓库 | 6/1 20:18 | + +## 六、通知趋势 + +| 时间段 | 通知数 | +|--------|--------| +| 今日(6/3) | 2 | +| 昨日(6/2) | 3 | +| 本周(6/1-6/3) | 8 | + +## 操作建议 + +- 建议标记已读:3 条 P3 通知 +- 需要回复/处理:0 条 P0/P1 通知 +``` + +### 经验总结 + +1. **`gitlink-cli notification` 命令不存在**:GitLink CLI 没有内置 notification 子命令,所有操作需通过 `gitlink-cli api` 调用 Raw API +2. **API 端点是 `messages` 不是 `notifications`**:GitLink 用「消息」术语 +3. **CLI 路径 Bug**:`gitlink-cli api` 的 PATH 参数以 `/` 开头会被解析为本地文件路径,必须去掉前导 `/` +4. **响应字段 `unread_notification` 和 `unread_atme`**:顶层统计字段可直接用于分类计数,无需遍历全部消息 +5. **没有批量已读 API**:标记已读需逐条调用 `POST users/{owner}/messages/{id}/read` +6. **`source` 字段 `PullReuqestAtme`**:官方 API 存在拼写错误(应为 PullRequestAtme),匹配时注意 + +--- + +## 样例 2:通过 Agent 调用 Skill(自动摘要) + +**日期**:2026-06-03 +**调用方式**:`Agent(subagent_type="general-purpose", prompt="请调用 gitlink-notification-digest skill,帮我整理通知。")` + +### Agent 自主执行的命令序列 + +``` +工具调用 1: Read → ../gitlink-shared/SKILL.md ← 遵循 Skill 前置条件 +工具调用 2: Bash → gitlink-cli auth status ← 获取用户名 +工具调用 3: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" + --query "status=1&limit=20" --format json ← 获取未读 +工具调用 4: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" + --query "status=2&limit=20" --format json ← 获取已读(趋势分析) +工具调用 5: Bash → gitlink-cli api GET "users/lindiwen23/messages.json" + --query "limit=20" --format json ← 获取全部(总计统计) +``` + +### Agent 决策过程 + +Agent **正确遵循了 skill v2.0.0 的工作流**: + +1. 先读取 `gitlink-shared/SKILL.md` 了解认证和全局参数 +2. 用 `auth status` 获取当前用户 `lindiwen23` +3. 使用 Raw API(路径无前导 `/`)获取未读、已读、全部三类数据 +4. 按 `source` 字段分类:`ProjectPullRequest` → P2,`ProjectPraised`/`ProjectMemberJoined` → P3 +5. 按输出模板生成结构化报告,含所有七个章节 +6. 主动询问是否需要标记 P3 通知为已读 + +共消耗 **35,601 tokens**,**6 次工具调用**,耗时 **51.7 秒**。 + +### Agent 生成的报告 + +```markdown +# 通知摘要 + +> 生成时间:2026-06-03 10:30 +> 未读通知:7 条 / 总计:28 条(含已读 21 条) +> @我未读:0 条 + +## 一、概要 + +| 类型 | 未读 | 总计 | +|------|------|------| +| @提及 | 0 | 0 | +| Issue 更新 | 0 | 0 | +| PR 更新 | 4 | 7 | +| 系统通知 | 3 | 21 | + +## 二、需要立即处理(P0) +无紧急通知。 + +## 三、今天处理(P1) +无待处理通知。 + +## 四、本周关注(P2) +4 条 jiangtx/gitlink-cli 的 PR 需关注 + +## 五、可忽略(P3) +3 条 CWQ/Aether_Lens_System-v0.0.1 的点赞和成员通知 + +## 六、通知趋势 +| 时间段 | 通知数 | +|--------|--------| +| 今日 | 2 | +| 昨日 | 3 | +| 本周 | 7 | + +## 七、近期已读回顾 +| 类型 | 内容 | 时间 | +|------|------|------| +| 加入项目 | 加入 jiangtx/gitlink-cli | 06-01 | +| 成员加入 | wyxttn 加入 yetja/灵枢 | 05-29 | +| PR 合并 | 帮助中心 PR 已通过 | 05-13 | +| 角色变更 | 帮助中心角色改为管理员 | 05-13 | + +## 操作建议 +- 建议标记已读:3 条 P3 通知 +- 需要关注:4 条 P2 通知 +``` + +### 验证结论 + +✅ skill v2.0.0 验证通过: +- Agent 正确使用了 `gitlink-cli api` 而非不存在的 `gitlink-cli notification` +- Agent 路径没有以 `/` 开头,避开了 CLI 路径解析 Bug +- Agent 按 `source` 枚举值正确分类,识别出 `PullReuqestAtme` 拼写异常 +- Agent 正确区分了 P0/P1/P2/P3 优先级 +- Agent 使用 `unread_notification`/`unread_atme` 顶层字段快速统计 +- Agent 生成了趋势章节和已读回顾章节 +- 报告结构完整,七个章节覆盖全部模板要求 + +--- + +## 异常场景速查 + +| 场景 | 检测方式 | 处理 | +|------|----------|------| +| `notification +list` 命令不存在 | 运行 `gitlink-cli notification` 报错 | 改用 `gitlink-cli api GET "users/{owner}/messages.json"` | +| API 返回 HTML 而非 JSON | 响应以 `` 开头 | 去掉路径前导 `/` 重试 | +| 未读通知 > 返回条数 | `total_count` > `messages.length` | 追加 `--query "page=2"` | +| 用户名不确定 | `auth status` 输出 | 从输出中提取 login 字段 | +| 无未读通知 | `unread_notification == 0` | 输出 "🎉 所有通知已处理完毕" | + +--- + +## 版本兼容性说明 + +本 skill v2.0.0 基于 `gitlink-cli 0.1.18` 编写。关键变更: + +| 版本 | `notification` 子命令 | 实际 API | 标记已读 | +|------|----------------------|----------|----------| +| v1.0.0 | `notification +list`(虚构) | 不存在 | `notification +read-all`(虚构) | +| v2.0.0 | 无此子命令 | `GET /api/users/{owner}/messages.json` | `POST /api/users/{owner}/messages/{id}/read` | + +当 CLI 版本更新后,重新验证可用命令: +```bash +gitlink-cli --help +``` diff --git a/skills/gitlink-notification-digest/SKILL.md b/skills/gitlink-notification-digest/SKILL.md index e0f9fa8..72cf9df 100644 --- a/skills/gitlink-notification-digest/SKILL.md +++ b/skills/gitlink-notification-digest/SKILL.md @@ -1,95 +1,209 @@ --- name: gitlink-notification-digest -version: 1.0.0 +version: 2.0.0 description: "通知摘要:汇总 GitLink 通知并按类型分类,生成通知摘要报告,支持批量标记已读。当用户需要查看通知摘要、整理通知、清理未读通知时触发。" metadata: requires: bins: ["gitlink-cli"] - cliHelp: "gitlink-cli notification --help" --- # gitlink-notification-digest(通知摘要) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — `notification +read` 和 `+read-all` 为写操作,执行前需确认用户意图。** +**CRITICAL — 标记已读为写操作,执行前需确认用户意图。** **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 +> **执行样例:** 参见 [`EXAMPLES.md`](EXAMPLES.md) --- ## 功能概述 -帮助用户高效管理 GitLink 通知: +帮助用户高效管理 GitLink 通知(GitLink 平台称为「消息」): 1. **通知列表** — 获取所有未读通知 -2. **自动分类** — 按类型(Issue/PR/评论/系统)分组 +2. **自动分类** — 按 `source` 字段分类(Issue/PR/系统等) 3. **优先级判断** — 识别需要立即处理的通知 4. **批量操作** — 支持标记已读(需确认) 5. **摘要报告** — 生成结构化通知摘要 --- +## ⚠️ 关键注意事项 + +### CLI 路径处理 Bug + +**`gitlink-cli api` 的路径参数不要以 `/` 开头**,否则会被错误解析为本地文件路径。 + +```bash +# ❌ 错误 — 路径以 / 开头会被解析为 D:/Applications/Git/... +gitlink-cli api GET /users/me + +# ✅ 正确 — 去掉前导 / +gitlink-cli api GET "users/{owner}/messages.json" +``` + +### 术语对照 + +GitLink 平台用「**消息**」(messages)而不是「通知」(notifications)。API 端点和字段均使用 `messages`。 + +--- + ## 工作流:通知摘要 ### Step 1:获取通知列表 +使用 Raw API 调用 `/api/users/{owner}/messages.json`: + ```bash -gitlink-cli notification +list --format json +# 获取未读通知(status=1 表示未读,2 表示已读) +gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&limit=20" --format json + +# 获取全部通知(含已读) +gitlink-cli api GET "users/{owner}/messages.json" --query "limit=20" --format json + +# 分页获取 +gitlink-cli api GET "users/{owner}/messages.json" --query "status=1&page=2&limit=20" --format json + +# 按类型过滤 +# type=notification 系统消息(仓库动态、PR、Issue 等) +# type=atme @我消息 +gitlink-cli api GET "users/{owner}/messages.json" --query "type=atme&status=1&limit=20" --format json ``` -获取参数: -- 默认获取未读通知 -- 如需全部通知(含已读):`--all` -- 如需仅参与的通知:`--participating` -- 分页:`--page 2 --limit 20` +**参数说明:** -提取每条通知的: -- `id` — 通知 ID(用于 `+read` 单条标记已读) -- `content` — 通知内容(HTML 格式,从中提取摘要文本) -- `source` — 通知来源类型(如 IssueAtme、PullRequestAssigned、ProjectForked 等) -- `notification_url` — 通知链接,从中解析关联仓库(提取 URL path 中的 `/owner/repo/` 段) -- `created_at` — 通知时间 -- `status` — 状态(1=未读,2=已读) +| 参数 | 位置 | 说明 | +|------|------|------| +| `{owner}` | Path | 当前用户名(从 `gitlink-cli auth status` 获取) | +| `status` | Query | 1=未读,2=已读,不传=全部 | +| `type` | Query | `notification`=系统消息,`atme`=@我消息,不传=全部 | +| `page` | Query | 页码(默认 1) | +| `limit` | Query | 每页条数(默认 20) | + +**响应结构:** + +```json +{ + "total_count": 28, + "type": "", + "unread_notification": 7, + "unread_atme": 0, + "messages": [ + { + "id": 740214, + "status": 1, + "content": "jiangtx在 jiangtx/gitlink-cli 提交了一个合并请求:label 模块新建", + "notification_url": "https://www.gitlink.org.cn/jiangtx/gitlink-cli/pulls/15347", + "source": "ProjectPullRequest", + "created_at": "2026-06-03 00:27:37", + "time_ago": "10小时前", + "type": "notification", + "sender": { + "id": 113, + "type": "User", + "name": "jiangtx", + "login": "jiangtx", + "image_url": "..." + } + } + ] +} +``` + +**提取字段:** +- `id` — 消息 ID(用于标记已读) +- `content` — HTML 格式的通知内容 +- `source` — 通知来源类型(枚举值,见下方分类表) +- `notification_url` — 跳转链接,可从中解析仓库(提取 URL 中的 `/owner/repo/` 段) +- `created_at` — 通知时间(格式 `YYYY-MM-DD HH:mm:ss`) +- `status` — 1=未读,2=已读 +- `type` — `notification` 或 `atme` +- `sender` — 发送者信息(login, name, image_url) ### Step 2:分类与优先级 -#### 2.1 按类型分类(优先使用 `source` 字段) +#### 2.1 按 `source` 字段分类 -| 类型 | `source` 字段匹配 | 处理建议 | -|------|------------------|----------| -| 🔴 **@提及** | 含 `Atme`(如 IssueAtme, PullRequestAtme) | 立即查看回复 | -| 🟡 **Issue 更新** | 含 `Issue`(如 IssueAssigned, IssueClosed) | 当天处理 | -| 🟢 **PR 更新** | 含 `PullRequest`(如 PullRequestAssigned, PullRequestMerged) | 跟进代码 | -| 🔵 **系统通知** | 含 `Project`/`Organization`(如 ProjectForked, ProjectJoined) | 知悉即可 | -| ⚪ **其他** | 不匹配以上 | 按需查看 | +| 类型 | `source` 枚举值 | 处理建议 | +|------|-----------------|----------| +| 🔴 **@提及** | `IssueAtme`, `PullReuqestAtme`(注意官方 API 拼写如此) | 立即查看回复 | +| 🟡 **Issue 更新** | `IssueAssigned`, `IssueExpire`, `IssueChanged`, `IssueDeleted`, `IssueJournal`, `ProjectIssue` | 当天处理 | +| 🟢 **PR 更新** | `PullRequestAssigned`, `PullRequestChanged`, `PullRequestClosed`, `PullRequestJournal`, `PullRequestMerged`, `ProjectPullRequest` | 跟进代码 | +| 🔵 **系统通知** | `ProjectJoined`, `ProjectLeft`, `ProjectMemberJoined`, `ProjectMemberLeft`, `ProjectForked`, `ProjectPraised`, `ProjectRole`, `ProjectFollowed`, `ProjectDeleted`, `ProjectTransfer`, `ProjectSettingChanged`, `ProjectMilestone`, `ProjectMilestoneCompleted`, `ProjectVersion`, `OrganizationJoined`, `OrganizationLeft`, `OrganizationRole`, `ProjectOpenDevOps` | 知悉即可 | +| ⚪ **其他** | `LoginIpTip` 及未列出的值 | 按需查看 | -> `source` 字段返回的是结构化枚举值(如 `IssueAtme`),优先以此分类。`content` 字段为 HTML 文本,仅作补充参考。 +**完整 `source` 枚举参考:** + +
+展开查看全部 source 枚举值 + +| 枚举值 | 含义 | +|--------|------| +| `IssueAssigned` | 有新指派给我的疑修 | +| `IssueExpire` | 我创建或负责的疑修截止日期到达最后一天 | +| `IssueAtme` | 在疑修中@我 | +| `IssueChanged` | 我创建或负责的疑修状态变更 | +| `IssueDeleted` | 我创建或负责的疑修删除 | +| `IssueJournal` | 我创建或负责的疑修有新的评论 | +| `LoginIpTip` | 登录 IP 提示 | +| `OrganizationJoined` | 加入组织 | +| `OrganizationLeft` | 离开组织 | +| `OrganizationRole` | 组织角色变更 | +| `ProjectDeleted` | 项目被删除 | +| `ProjectFollowed` | 有人关注了项目 | +| `ProjectForked` | 项目被 Fork | +| `ProjectIssue` | 项目新 Issue | +| `ProjectJoined` | 加入项目 | +| `ProjectLeft` | 离开项目 | +| `ProjectMemberJoined` | 新成员加入项目 | +| `ProjectMemberLeft` | 成员离开项目 | +| `ProjectMilestoneCompleted` | 里程碑完成 | +| `ProjectMilestone` | 新里程碑 | +| `ProjectOpenDevOps` | DevOps 引擎开通 | +| `ProjectPraised` | 项目被点赞 | +| `ProjectPullRequest` | 项目新 PR | +| `ProjectRole` | 项目角色变更 | +| `ProjectSettingChanged` | 项目设置变更 | +| `ProjectTransfer` | 项目转让 | +| `ProjectVersion` | 新版本发布 | +| `PullRequestAssigned` | 有指派给我的 PR | +| `PullReuqestAtme` | 在 PR 中@我(**官方拼写如此**) | +| `PullRequestChanged` | PR 状态变更 | +| `PullRequestClosed` | PR 被关闭 | +| `PullRequestJournal` | PR 有新评论 | +| `PullRequestMerged` | PR 已合并 | + +
#### 2.2 优先级排序 | 优先级 | 判定 | |--------|------| -| **P0 - 立即** | @提及 + 来自自己参与的 Issue/PR | -| **P1 - 今天** | 自己创建的 Issue/PR 有新回复,或分配的 Issue 有更新 | -| **P2 - 本周** | 关注的仓库有新动态 | -| **P3 - 可忽略** | 系统通知、已解决的 Issue | +| **P0 - 立即** | 含 `Atme` 的消息(IssueAtme, PullReuqestAtme) | +| **P1 - 今天** | 自己管理的仓库有 PR 合并/关闭,或被分配的 Issue/PR 有更新 | +| **P2 - 本周** | 关注的仓库有新 PR、新 Issue | +| **P3 - 可忽略** | 点赞(ProjectPraised)、成员加入/离开、Fork 等系统通知 | ### Step 3:生成通知摘要 -按模板输出。 +按下方输出模板生成报告。 -### Step 4:批量标记已读(可选,需确认) +### Step 4:标记已读(可选,需确认) ```bash -# 标记全部已读 -gitlink-cli notification +read-all --format json - # 标记单条已读 -gitlink-cli notification +read --id --format json +gitlink-cli api POST "users/{owner}/messages/{id}/read" --format json + +# 批量标记已读 — 逐条调用,GitLink 暂无批量已读 API +for id in ; do + gitlink-cli api POST "users/{owner}/messages/$id/read" --format json +done ``` -> ⚠️ **执行前必须确认用户意图** — `+read` 和 `+read-all` 为写操作。 +> ⚠️ **执行前必须确认用户意图** — 标记已读为写操作。 +> ⚠️ **GitLink 没有批量已读 API**,需要逐条标记。 --- @@ -157,13 +271,19 @@ gitlink-cli notification +read --id --format json --- +## 七、近期已读回顾 + +> 列出最近 3-5 条已读但值得回顾的通知(如角色变更、PR 合并等)。 + +--- + ## 操作建议 - 建议标记已读:{{suggest_read_count}} 条 P3 通知 - 需要回复/处理:{{need_action_count}} 条 P0/P1 通知 -如需标记全部已读,我可以执行: -`gitlink-cli notification +read-all` +如需标记 P3 通知为已读,我可以逐条执行: +`gitlink-cli api POST "users/{owner}/messages/{id}/read"` ``` --- @@ -173,15 +293,19 @@ gitlink-cli notification +read --id --format json | 场景 | 处理方式 | |------|----------| | 无未读通知 | 输出"🎉 所有通知已处理完毕" | -| 通知数量 > 50 | 分批获取(page 1/2/3),优先分析最近 50 条 | -| `notification +list` 返回空 | 检查认证状态(参考 gitlink-shared) | +| 通知数量 > 50 | 分页获取(page 1/2/3),优先分析最近 50 条 | +| API 返回 HTML 而非 JSON | 路径可能以 `/` 开头导致解析错误,去掉前导 `/` 重试 | +| `unread_notification` > messages 数组长度 | 存在多页数据,追加 `--query "page=2"` 获取 | +| 用户名不确定 | 先执行 `gitlink-cli auth status` 获取当前登录用户 | --- ## 注意事项 - ✅ **所有命令使用 `--format json`**,确保可解析 -- ✅ **`+read` 和 `+read-all` 为写操作**,执行前必须确认用户意图 +- ✅ **标记已读为写操作**,执行前必须确认用户意图 - ✅ **本 Skill 默认只读分析**,仅在用户明确要求时标记已读 -- ⚠️ **通知类型依赖标题关键词推断**,实际类型可能有偏差 -- ⚠️ **通知可能分页**,数量 >20 时需追加 `--page 2` 等 +- ⚠️ **`gitlink-cli api` 路径不要以 `/` 开头**(CLI Bug) +- ⚠️ **GitLink 用「消息(messages)」而非「通知(notifications)」** +- ⚠️ **`source` 字段 `PullReuqestAtme` 是官方拼写错误**,实际使用注意匹配 +- ⚠️ **通知可能分页**,数量 >20 时需追加 `--query "page=2"` diff --git a/skills/gitlink-shared/references/api-reference.md b/skills/gitlink-shared/references/api-reference.md index 13c6d84..3c258b7 100644 --- a/skills/gitlink-shared/references/api-reference.md +++ b/skills/gitlink-shared/references/api-reference.md @@ -73,3 +73,100 @@ | Branch 删除返回"不存在" | 无法删除分支 | 待 GitLink 修复 | | Release 删除返回"不存在" | 无法删除发布 | 待 GitLink 修复 | | Create File 返回"已存在" | 无法通过 API 创建文件 | 待 GitLink 修复 | +| `api` 命令路径以 `/` 开头会被解析为本地路径 | 返回 HTML 而非 JSON | 去掉路径前导 `/` 即可 | + +--- + +## 消息(通知)API + +GitLink 的通知功能通过「消息」API 实现。 + +### 端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/users/{owner}/messages.json` | GET | 获取用户消息列表 | +| `/users/{owner}/messages/{id}/read` | POST | 标记单条消息已读 | +| `/users/{owner}/messages/{id}` | DELETE | 删除消息 | +| `/users/{owner}/messages/settings` | GET | 平台消息设置 | +| `/users/{owner}/messages/settings/list` | GET | 用户消息设置列表 | +| `/users/{owner}/messages/settings/update` | POST | 更新用户消息设置 | + +### 查询参数(GET messages.json) + +| 参数 | 类型 | 说明 | +|------|------|------| +| `status` | integer | 1=未读,2=已读,不传=全部 | +| `type` | string | `notification`=系统消息,`atme`=@我消息,不传=全部 | +| `page` | integer | 页码(默认 1) | +| `limit` | integer | 每页条数(默认 20) | + +### 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `total_count` | integer | 总消息数 | +| `unread_notification` | integer | 未读系统消息数 | +| `unread_atme` | integer | 未读@我消息数 | +| `messages[].id` | integer | 消息唯一 ID | +| `messages[].status` | integer | 1=未读,2=已读 | +| `messages[].content` | string | HTML 格式的消息内容 | +| `messages[].source` | enum | 消息来源类型(见下方枚举表) | +| `messages[].notification_url` | string | 消息跳转链接 | +| `messages[].created_at` | string | 创建时间(YYYY-MM-DD HH:mm:ss) | +| `messages[].time_ago` | string | 相对时间描述 | +| `messages[].type` | string | `notification` 或 `atme` | +| `messages[].sender` | object | 发送者信息(id, name, login, image_url) | + +### source 枚举值 + +| 枚举值 | 含义 | 分类 | +|--------|------|------| +| `IssueAssigned` | 有新指派给我的疑修 | Issue | +| `IssueExpire` | 疑修截止日期到达最后一天 | Issue | +| `IssueAtme` | 在疑修中@我 | @提及 | +| `IssueChanged` | 疑修状态变更 | Issue | +| `IssueDeleted` | 疑修被删除 | Issue | +| `IssueJournal` | 疑修有新评论 | Issue | +| `ProjectIssue` | 项目新 Issue | Issue | +| `PullRequestAssigned` | 有新指派给我的 PR | PR | +| `PullReuqestAtme` | 在 PR 中@我(**官方 API 拼写如此**) | @提及 | +| `PullRequestChanged` | PR 状态变更 | PR | +| `PullRequestClosed` | PR 被关闭 | PR | +| `PullRequestJournal` | PR 有新评论 | PR | +| `PullRequestMerged` | PR 已合并 | PR | +| `ProjectPullRequest` | 项目有新 PR | PR | +| `ProjectJoined` | 加入项目 | 系统 | +| `ProjectLeft` | 离开项目 | 系统 | +| `ProjectMemberJoined` | 新成员加入项目 | 系统 | +| `ProjectMemberLeft` | 成员离开项目 | 系统 | +| `ProjectForked` | 项目被 Fork | 系统 | +| `ProjectPraised` | 项目被点赞 | 系统 | +| `ProjectRole` | 项目角色变更 | 系统 | +| `ProjectFollowed` | 项目被关注 | 系统 | +| `ProjectDeleted` | 项目被删除 | 系统 | +| `ProjectTransfer` | 项目转让 | 系统 | +| `ProjectSettingChanged` | 项目设置变更 | 系统 | +| `ProjectMilestone` | 新里程碑 | 系统 | +| `ProjectMilestoneCompleted` | 里程碑完成 | 系统 | +| `ProjectVersion` | 新版本发布 | 系统 | +| `ProjectOpenDevOps` | DevOps 引擎开通 | 系统 | +| `OrganizationJoined` | 加入组织 | 系统 | +| `OrganizationLeft` | 离开组织 | 系统 | +| `OrganizationRole` | 组织角色变更 | 系统 | +| `LoginIpTip` | 登录 IP 提示 | 其他 | + +### 调用示例 + +```bash +# 获取未读通知 +gitlink-cli api GET "users/lindiwen23/messages.json" --query "status=1&limit=20" --format json + +# 获取 @我 的通知 +gitlink-cli api GET "users/lindiwen23/messages.json" --query "type=atme&status=1" --format json + +# 标记单条已读 +gitlink-cli api POST "users/lindiwen23/messages/740214/read" --format json + +# ⚠️ 路径不要以 / 开头,否则会被解析为本地文件路径 +``` From b4cfdb753e47f7a5d3238212e26a843bcbe719c8 Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Wed, 3 Jun 2026 11:27:44 +0800 Subject: [PATCH 14/19] =?UTF-8?q?feat:=20=E6=96=B0=E5=BB=BA=20pm=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=B7=BB=E5=8A=A0=206=20=E6=9D=A1?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=AE=A1=E7=90=86=E5=91=BD=E4=BB=A4=20(dashb?= =?UTF-8?q?oards/sprints/weekly/tags/pipelines/runs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 shortcuts/pm/pm.go: 6 条 Shortcut 命令 - 新增 shortcuts/pm/pm_test.go: 单元测试覆盖 - 修改 shortcuts/register.go: 注册 pm 模块 - 修复 register.go 中 milestone 描述缺少逗号的语法问题 关联 Issue: #12 --- shortcuts/pm/pm.go | 137 +++++++++++++++++++++++++ shortcuts/pm/pm_test.go | 220 ++++++++++++++++++++++++++++++++++++++++ shortcuts/register.go | 5 +- 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 shortcuts/pm/pm.go create mode 100644 shortcuts/pm/pm_test.go diff --git a/shortcuts/pm/pm.go b/shortcuts/pm/pm.go new file mode 100644 index 0000000..991a4e4 --- /dev/null +++ b/shortcuts/pm/pm.go @@ -0,0 +1,137 @@ +package pm + +import ( + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns project management shortcuts for GitLink. +// +// The pm domain provides commands for viewing dashboards, sprints, +// weekly issues, tags, pipelines, and action runs associated with +// a project. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "dashboards", + Description: "查看项目仪表盘数据", + Flags: []common.Flag{ + {Name: "project", Usage: "项目 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/dashboards", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "sprints", + Description: "查看 Sprint 任务列表", + Flags: []common.Flag{ + {Name: "project", Usage: "项目 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/sprint_issues", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "weekly", + Description: "查看周报任务", + Flags: []common.Flag{ + {Name: "project", Usage: "项目 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/weekly_issues", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "tags", + Description: "查看项目 Issue 标签", + Flags: []common.Flag{ + {Name: "project", Usage: "项目 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/issue_tags", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "pipelines", + Description: "查看项目 CI/CD 流水线列表", + Flags: []common.Flag{ + {Name: "project", Usage: "项目 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/pipelines", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "runs", + Description: "查看项目 Action 运行记录", + Flags: []common.Flag{ + {Name: "project", Usage: "项目 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + project, err := ctx.RequireArg("project") + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", project) + env, err := ctx.CallAPIWithQuery("GET", "/pm/action_runs", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/pm/pm_test.go b/shortcuts/pm/pm_test.go new file mode 100644 index 0000000..781d09f --- /dev/null +++ b/shortcuts/pm/pm_test.go @@ -0,0 +1,220 @@ +package pm + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestPmDashboards(t *testing.T) { + tests := []struct { + name string + mockStatus int + mockBody string + wantErr bool + errContains string + }{ + {"正常返回", 200, `{"dashboards": []}`, false, ""}, + {"API 404", 404, `{"error": "not found"}`, true, "404"}, + {"返回 HTML", 200, `Login`, true, "HTML"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "/pm/dashboards") { + t.Errorf("expected path containing /pm/dashboards, got %s", r.URL.Path) + } + if got := r.URL.Query().Get("project_id"); got != "123" { + t.Errorf("expected project_id=123, got %s", got) + } + w.WriteHeader(tt.mockStatus) + w.Write([]byte(tt.mockBody)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "dashboards") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "123"}, + } + err := shortcut.Run(ctx) + + if tt.wantErr && err == nil { + t.Fatal("期望错误但为 nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("不期望错误: %v", err) + } + if tt.wantErr && tt.errContains != "" && err != nil { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("错误应包含 %q: %s", tt.errContains, err.Error()) + } + } + }) + } +} + +func TestPmSprints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "/pm/sprint_issues") { + t.Errorf("expected path containing /pm/sprint_issues, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"sprint_issues": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "sprints") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "456"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("sprints shortcut failed: %v", err) + } +} + +func TestPmWeekly(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/pm/weekly_issues") { + t.Errorf("expected path containing /pm/weekly_issues, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"weekly_issues": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "weekly") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "789"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("weekly shortcut failed: %v", err) + } +} + +func TestPmTags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/pm/issue_tags") { + t.Errorf("expected path containing /pm/issue_tags, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"issue_tags": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "tags") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "100"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("tags shortcut failed: %v", err) + } +} + +func TestPmPipelines(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/pm/pipelines") { + t.Errorf("expected path containing /pm/pipelines, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"pipelines": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "pipelines") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "200"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("pipelines shortcut failed: %v", err) + } +} + +func TestPmRuns(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/pm/action_runs") { + t.Errorf("expected path containing /pm/action_runs, got %s", r.URL.Path) + } + w.WriteHeader(200) + w.Write([]byte(`{"action_runs": []}`)) + })) + defer server.Close() + + shortcut := findPmShortcut(t, "runs") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project": "300"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("runs shortcut failed: %v", err) + } +} + +func TestPmMissingProject(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --project is missing") + })) + defer server.Close() + + shortcut := findPmShortcut(t, "dashboards") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{}, + } + err := shortcut.Run(ctx) + if err == nil { + t.Fatal("expected error when --project is missing") + } +} + +func findPmShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func assertPmRequest(t *testing.T, r *http.Request, method, pathPrefix string) { + t.Helper() + if r.Method != method { + t.Fatalf("got method %s, want %s", r.Method, method) + } + if !strings.HasPrefix(r.URL.Path, pathPrefix) { + t.Fatalf("got path %s, want prefix %s", r.URL.Path, pathPrefix) + } +} + +func decodePmJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 5cc5e14..b8e7d46 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -14,6 +14,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "github.com/gitlink-org/gitlink-cli/shortcuts/notification" "github.com/gitlink-org/gitlink-cli/shortcuts/org" + "github.com/gitlink-org/gitlink-cli/shortcuts/pm" "github.com/gitlink-org/gitlink-cli/shortcuts/pipeline" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -38,6 +39,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "milestone": milestone.Shortcuts(), "notification": notification.Shortcuts(), "pipeline": pipeline.Shortcuts(), + "pm": pm.Shortcuts(), "pr": pr.Shortcuts(tr), "release": release.Shortcuts(tr), "branch": branch.Shortcuts(tr), @@ -55,9 +57,10 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "issue": tr.T("cmd.issue.short"), "label": "Issue label operations", "member": "Repository member operations", - "milestone": "Milestone operations" + "milestone": "Milestone operations", "notification": "Notification operations", "pipeline": "Pipeline operations", + "pm": "Project management operations", "pr": tr.T("cmd.pr.short"), "release": tr.T("cmd.release.short"), "branch": tr.T("cmd.branch.short"), From 6bcd8b65d28da6d1f38f1a3a85db0faf1052f352 Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Wed, 3 Jun 2026 11:39:55 +0800 Subject: [PATCH 15/19] =?UTF-8?q?feat:=20=E6=96=B0=E5=BB=BA=20wiki=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=B7=BB=E5=8A=A0=205=20=E6=9D=A1?= =?UTF-8?q?=20Wiki=20=E7=AE=A1=E7=90=86=E5=91=BD=E4=BB=A4=20(pages/get/cre?= =?UTF-8?q?ate/update/delete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 shortcuts/wiki/wiki.go: 5 条 Shortcut 命令 - 新增 shortcuts/wiki/wiki_test.go: 9 个测试函数,覆盖正常/异常/缺少参数场景 - 修改 shortcuts/register.go: 注册 wiki 模块 关联 Issue: #13 --- shortcuts/register.go | 4 + shortcuts/wiki/wiki.go | 129 +++++++++++++++++ shortcuts/wiki/wiki_test.go | 281 ++++++++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 shortcuts/wiki/wiki.go create mode 100644 shortcuts/wiki/wiki_test.go diff --git a/shortcuts/register.go b/shortcuts/register.go index b8e7d46..63b387f 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -22,6 +22,8 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" + "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" + "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) @@ -49,6 +51,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "webhook": webhook.Shortcuts(tr), + "wiki": wiki.Shortcuts(), "workflow": workflow.Shortcuts(), } @@ -70,6 +73,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "webhook": tr.T("cmd.webhook.short"), + "wiki": "Wiki page operations", "workflow": "AI agent workflow analysis", } diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go new file mode 100644 index 0000000..870f51a --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -0,0 +1,129 @@ +package wiki + +import ( + "fmt" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns wiki management shortcuts for GitLink. +// +// The wiki domain provides commands for listing, viewing, creating, +// updating, and deleting wiki pages within a repository. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "pages", + Description: "列出 Wiki 页面", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", "/api/wiki/wikiPages", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "get", + Description: "获取 Wiki 页面内容", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + path := fmt.Sprintf("/api/wiki/getWiki?id=%s", id) + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "创建 Wiki 页面", + Flags: []common.Flag{ + {Name: "title", Short: "t", Usage: "页面标题", Required: true}, + {Name: "content", Short: "c", Usage: "页面内容(Markdown)", Required: true}, + {Name: "project", Usage: "项目 ID"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + title, err := ctx.RequireArg("title") + if err != nil { + return err + } + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + body := map[string]interface{}{ + "title": title, + "content": content, + } + if project := ctx.Arg("project"); project != "" { + body["project_id"] = project + } + env, err := ctx.CallAPI("POST", "/api/wiki/createWiki", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "更新 Wiki 页面", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + {Name: "title", Short: "t", Usage: "新标题"}, + {Name: "content", Short: "c", Usage: "新内容(Markdown)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + body := map[string]interface{}{"id": id} + if t := ctx.Arg("title"); t != "" { + body["title"] = t + } + if c := ctx.Arg("content"); c != "" { + body["content"] = c + } + env, err := ctx.CallAPI("PUT", "/api/wiki/updateWiki", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "删除 Wiki 页面", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + body := map[string]interface{}{"id": id} + env, err := ctx.CallAPI("POST", "/api/wiki/deleteWiki", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go new file mode 100644 index 0000000..7712815 --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -0,0 +1,281 @@ +package wiki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestWikiPages(t *testing.T) { + tests := []struct { + name string + mockStatus int + mockBody string + wantErr bool + errContains string + }{ + {"正常返回", 200, `{"wikiPages": []}`, false, ""}, + {"API 404", 404, `{"error": "not found"}`, true, "404"}, + {"返回 HTML", 200, `Login`, true, "HTML"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(tt.mockStatus) + w.Write([]byte(tt.mockBody)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "pages") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{}, + } + err := shortcut.Run(ctx) + + if tt.wantErr && err == nil { + t.Fatal("期望错误但为 nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("不期望错误: %v", err) + } + if tt.wantErr && tt.errContains != "" && err != nil { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("错误应包含 %q: %s", tt.errContains, err.Error()) + } + } + }) + } +} + +func TestWikiGet(t *testing.T) { + tests := []struct { + name string + args map[string]string + mockStatus int + mockBody string + wantErr bool + errContains string + }{ + {"正常获取", map[string]string{"id": "42"}, 200, `{"id": 42, "title": "Home"}`, false, ""}, + {"缺少 id", map[string]string{}, 200, `{}`, true, ""}, + {"API 404", map[string]string{"id": "999"}, 404, `{"error": "not found"}`, true, "404"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/api/wiki/getWiki") { + t.Errorf("expected path containing /api/wiki/getWiki, got %s", r.URL.Path) + } + w.WriteHeader(tt.mockStatus) + w.Write([]byte(tt.mockBody)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "get") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: tt.args, + } + err := shortcut.Run(ctx) + + if tt.wantErr && err == nil { + t.Fatal("期望错误但为 nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("不期望错误: %v", err) + } + }) + } +} + +func TestWikiCreate(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.Errorf("expected POST, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "/api/wiki/createWiki") { + t.Errorf("expected path containing /api/wiki/createWiki, got %s", r.URL.Path) + } + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"status": 0, "message": "success"}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "title": "Getting Started", + "content": "# Hello\nWelcome to the wiki", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if payload["title"] != "Getting Started" { + t.Errorf("expected title 'Getting Started', got %v", payload["title"]) + } + if payload["content"] != "# Hello\nWelcome to the wiki" { + t.Errorf("unexpected content: %v", payload["content"]) + } +} + +func TestWikiCreateWithProject(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"status": 0}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "title": "Test", + "content": "Body", + "project": "123", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if payload["project_id"] != "123" { + t.Errorf("expected project_id '123', got %v", payload["project_id"]) + } +} + +func TestWikiCreateMissingTitle(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --title is missing") + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"content": "only content"}, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --title is missing") + } +} + +func TestWikiUpdate(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Errorf("expected PUT, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "/api/wiki/updateWiki") { + t.Errorf("expected path containing /api/wiki/updateWiki, got %s", r.URL.Path) + } + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"status": 0, "message": "success"}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "update") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "id": "42", + "title": "Updated Title", + "content": "Updated content", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + if payload["id"] != "42" { + t.Errorf("expected id '42', got %v", payload["id"]) + } + if payload["title"] != "Updated Title" { + t.Errorf("expected title 'Updated Title', got %v", payload["title"]) + } +} + +func TestWikiDelete(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.Errorf("expected POST, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "/api/wiki/deleteWiki") { + t.Errorf("expected path containing /api/wiki/deleteWiki, got %s", r.URL.Path) + } + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"status": 0, "message": "success"}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "delete") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"id": "42"}, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } + if payload["id"] != "42" { + t.Errorf("expected id '42', got %v", payload["id"]) + } +} + +func TestWikiDeleteMissingId(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --id is missing") + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "delete") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{}, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --id is missing") + } +} + +func findWikiShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func decodeWikiJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} From a28a8eae8aec70c959362a9288dd97ac55cc8442 Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Wed, 3 Jun 2026 11:50:33 +0800 Subject: [PATCH 16/19] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20alias/browse?= =?UTF-8?q?/status=20=E4=B8=89=E4=B8=AA=E5=BC=80=E5=8F=91=E8=80=85?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 cmd/alias/alias.go: 管理命令别名 (+list/+set/+delete) - 新增 cmd/browse/browse.go: 在浏览器中打开 GitLink 页面(跨平台) - 新增 cmd/status/status.go: 显示登录状态和上下文信息 - 修改 cmd/root.go: 注册三个新命令 关联 Issue: #14 --- cmd/alias/alias.go | 119 +++++++++++++++++++++++++++++++++++++++++++ cmd/browse/browse.go | 58 +++++++++++++++++++++ cmd/root.go | 6 +++ cmd/status/status.go | 68 +++++++++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 cmd/alias/alias.go create mode 100644 cmd/browse/browse.go create mode 100644 cmd/status/status.go diff --git a/cmd/alias/alias.go b/cmd/alias/alias.go new file mode 100644 index 0000000..059b052 --- /dev/null +++ b/cmd/alias/alias.go @@ -0,0 +1,119 @@ +package alias + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/gitlink-org/gitlink-cli/internal/config" +) + +// AliasConfig represents the aliases section of the CLI config. +type AliasConfig struct { + Aliases map[string]string `yaml:"aliases,omitempty"` +} + +// NewAliasCmd creates the alias command with subcommands. +func NewAliasCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "alias", + Short: "管理命令别名(把长命令变短)", + Long: `管理 gitlink-cli 的命令别名。 + +别名允许你为常用命令创建简短的名称,例如: + gitlink-cli alias +set rl "repo +list" + 之后可以使用: gitlink-cli rl + +别名存储在 ~/.config/gitlink-cli/aliases.yaml 中。`, + Example: ` gitlink-cli alias +list + gitlink-cli alias +set rl "repo +list" + gitlink-cli alias +set ri "repo +info --owner Gitlink --repo gitlink-cli" + gitlink-cli alias +delete rl`, + } + + cmd.AddCommand( + &cobra.Command{ + Use: "+list", + Short: "列出所有已定义的别名", + Long: "列出所有已定义的命令别名。如果没有任何别名,会给出创建提示。", + RunE: func(cmd *cobra.Command, args []string) error { + aliases, _ := loadAliases() + if len(aliases) == 0 { + fmt.Println("(未定义任何别名)") + fmt.Println("使用 alias +set <名称> <命令> 来创建别名") + return nil + } + for k, v := range aliases { + fmt.Printf(" %-15s → %s\n", k, v) + } + return nil + }, + }, + &cobra.Command{ + Use: "+set ", + Short: "设置别名", + Long: "为一条命令设置别名。如果别名已存在,会被覆盖。", + Args: cobra.ExactArgs(2), + Example: ` gitlink-cli alias +set rl "repo +list" + gitlink-cli alias +set ri "repo +info"`, + RunE: func(cmd *cobra.Command, args []string) error { + aliases, _ := loadAliases() + aliases[args[0]] = args[1] + if err := saveAliases(aliases); err != nil { + return err + } + fmt.Printf("别名已设置: %s → %s\n", args[0], args[1]) + return nil + }, + }, + &cobra.Command{ + Use: "+delete ", + Short: "删除别名", + Long: "删除一个已定义的命令别名。", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + aliases, _ := loadAliases() + if _, ok := aliases[args[0]]; !ok { + return fmt.Errorf("别名 %s 不存在", args[0]) + } + delete(aliases, args[0]) + if err := saveAliases(aliases); err != nil { + return err + } + fmt.Printf("别名已删除: %s\n", args[0]) + return nil + }, + }, + ) + return cmd +} + +func aliasesPath() string { + return config.ConfigDir() + "/aliases.yaml" +} + +func loadAliases() (map[string]string, error) { + data, err := os.ReadFile(aliasesPath()) + if err != nil { + return make(map[string]string), nil + } + var ac AliasConfig + if err := yaml.Unmarshal(data, &ac); err != nil { + return make(map[string]string), nil + } + if ac.Aliases == nil { + ac.Aliases = make(map[string]string) + } + return ac.Aliases, nil +} + +func saveAliases(a map[string]string) error { + data, err := yaml.Marshal(AliasConfig{Aliases: a}) + if err != nil { + return err + } + os.MkdirAll(config.ConfigDir(), 0700) + return os.WriteFile(aliasesPath(), data, 0600) +} diff --git a/cmd/browse/browse.go b/cmd/browse/browse.go new file mode 100644 index 0000000..58400a2 --- /dev/null +++ b/cmd/browse/browse.go @@ -0,0 +1,58 @@ +package browse + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/internal/context" +) + +// NewBrowseCmd creates the browse command for opening GitLink pages in a browser. +func NewBrowseCmd() *cobra.Command { + return &cobra.Command{ + Use: "browse [resource]", + Short: "在浏览器中打开 GitLink 页面", + Long: `打开当前仓库(或指定资源)的 GitLink 页面。 + +如果不带参数,打开当前仓库主页。 +资源格式: issues/42, pulls/42, wiki + +浏览器打开命令: + - macOS: open + - Windows: start + - Linux: xdg-open`, + Example: ` gitlink-cli browse + gitlink-cli browse issues/42 + gitlink-cli browse pulls/128 + gitlink-cli browse wiki`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := context.ResolveOwnerRepo("", "") + if err != nil { + return fmt.Errorf("无法推断仓库信息: %w", err) + } + url := fmt.Sprintf("https://gitlink.org.cn/%s/%s", owner, repo) + if len(args) > 0 { + url += "/" + args[0] + } + fmt.Printf("正在打开: %s\n", url) + return openBrowser(url) + }, + } +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} diff --git a/cmd/root.go b/cmd/root.go index 26278d8..1e83fb5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,7 +7,10 @@ import ( "github.com/spf13/cobra" + aliasCmd "github.com/gitlink-org/gitlink-cli/cmd/alias" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" + browseCmd "github.com/gitlink-org/gitlink-cli/cmd/browse" + statusCmd "github.com/gitlink-org/gitlink-cli/cmd/status" authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" @@ -57,6 +60,9 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) rootCmd.AddCommand(newVersionCmd(version, tr)) + rootCmd.AddCommand(aliasCmd.NewAliasCmd()) + rootCmd.AddCommand(browseCmd.NewBrowseCmd()) + rootCmd.AddCommand(statusCmd.NewStatusCmd()) shortcuts.RegisterAll(rootCmd, tr) diff --git a/cmd/status/status.go b/cmd/status/status.go new file mode 100644 index 0000000..be4b8b2 --- /dev/null +++ b/cmd/status/status.go @@ -0,0 +1,68 @@ +package status + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/internal/auth" + "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/context" +) + +// NewStatusCmd creates the status command that displays login state and context. +func NewStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "显示当前登录状态和上下文信息", + Long: `显示 gitlink-cli 的当前状态,包括: + - 认证状态(是否已登录、Token 来源) + - API 地址 + - 当前目录 + - 自动推断的仓库信息`, + Example: ` gitlink-cli status`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := config.Load() + token, _ := auth.LoadToken() + if token == "" { + token = os.Getenv("GITLINK_TOKEN") + } + cwd, _ := os.Getwd() + + fmt.Println("GitLink CLI 状态") + fmt.Println("───────────────") + + // 认证状态 + if token != "" { + fmt.Println(" 认证状态: 已登录") + fmt.Printf(" Token 来源: %s\n", tokenSource(token)) + } else { + fmt.Println(" 认证状态: 未登录(运行 gitlink-cli auth login)") + } + + // API 地址 + fmt.Printf(" API 地址: %s\n", cfg.BaseURL) + + // 当前目录 + fmt.Printf(" 当前目录: %s\n", cwd) + + // 推断的仓库 + owner, repo, err := context.ResolveOwnerRepo("", "") + if err == nil { + fmt.Printf(" 推断仓库: %s/%s\n", owner, repo) + } else { + fmt.Println(" 推断仓库: (不在 Git 仓库中)") + } + + return nil + }, + } +} + +func tokenSource(token string) string { + if token == os.Getenv("GITLINK_TOKEN") { + return "环境变量 GITLINK_TOKEN" + } + return "keyring / 配置文件" +} From 2cc0e5c08b771ba6196c483410ddf6ab67c2fca6 Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Wed, 3 Jun 2026 12:00:04 +0800 Subject: [PATCH 17/19] =?UTF-8?q?feat:=20=E6=96=B0=E5=BB=BA=20export=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=B7=BB=E5=8A=A0=203=20=E6=9D=A1?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA=E5=91=BD=E4=BB=A4=20(issue?= =?UTF-8?q?s/prs/contributors)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 shortcuts/export/export.go: 3 条 Shortcut 命令,支持 CSV/JSON 格式导出 - 新增 shortcuts/export/export_test.go: 7 个测试函数 - 修改 shortcuts/register.go: 注册 export 模块,修复重复 webhook import 关联 Issue: #15 --- shortcuts/export/export.go | 179 ++++++++++++++++++++++++++++++++ shortcuts/export/export_test.go | 158 ++++++++++++++++++++++++++++ shortcuts/register.go | 4 +- 3 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 shortcuts/export/export.go create mode 100644 shortcuts/export/export_test.go diff --git a/shortcuts/export/export.go b/shortcuts/export/export.go new file mode 100644 index 0000000..9fe8bdd --- /dev/null +++ b/shortcuts/export/export.go @@ -0,0 +1,179 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/url" + "os" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns data export shortcuts for GitLink. +// +// The export domain provides commands for exporting repository data +// (issues, pull requests, contributors) to CSV or JSON files for +// offline analysis and reporting. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "issues", + Description: "导出仓库 Issue 列表为 CSV 或 JSON 文件", + Flags: []common.Flag{ + {Name: "format", Short: "f", Usage: "输出格式: csv / json", Default: "csv"}, + {Name: "output", Short: "o", Usage: "输出文件路径(默认: issues.csv)", Default: "issues.csv"}, + {Name: "state", Short: "s", Usage: "状态过滤: open / closed / all", Default: "all"}, + {Name: "page", Short: "p", Usage: "起始页", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "50"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("state", ctx.Arg("state")) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + apiPath := fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo) + items, err := ctx.PaginateAll(apiPath, q) + if err != nil { + return err + } + return writeExport(ctx.Arg("format"), ctx.Arg("output"), items, issueCSVHeader, issueCSVRow) + }, + }, + { + Name: "prs", + Description: "导出仓库 PR 列表为 CSV 或 JSON 文件", + Flags: []common.Flag{ + {Name: "format", Short: "f", Usage: "输出格式: csv / json", Default: "csv"}, + {Name: "output", Short: "o", Usage: "输出文件路径", Default: "prs.csv"}, + {Name: "state", Short: "s", Usage: "状态过滤", Default: "all"}, + {Name: "page", Short: "p", Usage: "起始页", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "50"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("state", ctx.Arg("state")) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + apiPath := fmt.Sprintf("/v1/%s/%s/pulls", ctx.Owner, ctx.Repo) + items, err := ctx.PaginateAll(apiPath, q) + if err != nil { + return err + } + return writeExport(ctx.Arg("format"), ctx.Arg("output"), items, prCSVHeader, prCSVRow) + }, + }, + { + Name: "contributors", + Description: "导出贡献者统计为 CSV 或 JSON 文件", + Flags: []common.Flag{ + {Name: "format", Short: "f", Usage: "输出格式: csv / json", Default: "csv"}, + {Name: "output", Short: "o", Usage: "输出文件路径", Default: "contributors.csv"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + apiPath := fmt.Sprintf("/%s/%s/contributors", ctx.Owner, ctx.Repo) + items, err := ctx.PaginateAll(apiPath, url.Values{}) + if err != nil { + return err + } + return writeExport(ctx.Arg("format"), ctx.Arg("output"), items, contributorCSVHeader, contributorCSVRow) + }, + }, + } +} + +// --- CSV headers --- + +var issueCSVHeader = []string{"id", "title", "state", "created_at"} +var prCSVHeader = []string{"id", "title", "state", "created_at"} +var contributorCSVHeader = []string{"id", "login", "contributions"} + +// --- CSV row extractors --- + +func issueCSVRow(m map[string]interface{}) []string { + return []string{ + fmt.Sprint(m["id"]), + fmt.Sprint(m["subject"]), + fmt.Sprint(m["status"]), + fmt.Sprint(m["created_at"]), + } +} + +func prCSVRow(m map[string]interface{}) []string { + return []string{ + fmt.Sprint(m["id"]), + fmt.Sprint(m["title"]), + fmt.Sprint(m["status"]), + fmt.Sprint(m["created_at"]), + } +} + +func contributorCSVRow(m map[string]interface{}) []string { + return []string{ + fmt.Sprint(m["id"]), + fmt.Sprint(m["login"]), + fmt.Sprint(m["contributions"]), + } +} + +// --- Export writers --- + +type csvRowFunc func(map[string]interface{}) []string + +func writeExport(format, path string, items []json.RawMessage, header []string, rowFn csvRowFunc) error { + switch format { + case "csv": + return writeCSV(path, items, header, rowFn) + case "json": + return writeJSON(path, items) + default: + return fmt.Errorf("不支持的格式: %s(可选: csv, json)", format) + } +} + +func writeCSV(path string, items []json.RawMessage, header []string, rowFn csvRowFunc) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + + if err := w.Write(header); err != nil { + return err + } + for _, item := range items { + var m map[string]interface{} + if err := json.Unmarshal(item, &m); err != nil { + continue + } + if err := w.Write(rowFn(m)); err != nil { + return err + } + } + fmt.Printf("已导出 %d 条记录到 %s\n", len(items), path) + return nil +} + +func writeJSON(path string, items []json.RawMessage) error { + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, data, 0644); err != nil { + return err + } + fmt.Printf("已导出 %d 条记录到 %s\n", len(items), path) + return nil +} diff --git a/shortcuts/export/export_test.go b/shortcuts/export/export_test.go new file mode 100644 index 0000000..cda99f0 --- /dev/null +++ b/shortcuts/export/export_test.go @@ -0,0 +1,158 @@ +package export + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestExportIssues(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(200) + w.Write([]byte(`[{"id":1,"subject":"Bug fix","status":1,"created_at":"2026-01-01"}]`)) + })) + defer server.Close() + + shortcut := findExportShortcut(t, "issues") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "format": "json", + "output": os.TempDir() + "/test_issues_export.json", + "state": "all", + "page": "1", + "limit": "50", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("export issues failed: %v", err) + } +} + +func TestExportPrs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(200) + w.Write([]byte(`[{"id":2,"title":"Feature PR","status":0,"created_at":"2026-02-01"}]`)) + })) + defer server.Close() + + shortcut := findExportShortcut(t, "prs") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "format": "json", + "output": os.TempDir() + "/test_prs_export.json", + "state": "all", + "page": "1", + "limit": "50", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("export prs failed: %v", err) + } +} + +func TestExportContributors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(200) + w.Write([]byte(`[{"id":1,"login":"dev1","contributions":42}]`)) + })) + defer server.Close() + + shortcut := findExportShortcut(t, "contributors") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "format": "json", + "output": os.TempDir() + "/test_contributors_export.json", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("export contributors failed: %v", err) + } +} + +func TestExportUnsupportedFormat(t *testing.T) { + items := []json.RawMessage{[]byte(`{"id":1}`)} + err := writeExport("xml", "/dev/null", items, issueCSVHeader, issueCSVRow) + if err == nil { + t.Fatal("expected error for unsupported format") + } + if !strings.Contains(err.Error(), "不支持的格式") { + t.Errorf("error should mention unsupported format: %v", err) + } +} + +func TestWriteCSV(t *testing.T) { + tmpFile := os.TempDir() + "/test_export_write.csv" + defer os.Remove(tmpFile) + + items := []json.RawMessage{ + []byte(`{"id":1,"subject":"First","status":1,"created_at":"2026-01-01"}`), + []byte(`{"id":2,"subject":"Second","status":0,"created_at":"2026-01-02"}`), + } + if err := writeCSV(tmpFile, items, issueCSVHeader, issueCSVRow); err != nil { + t.Fatalf("writeCSV failed: %v", err) + } + + data, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + content := string(data) + if !strings.Contains(content, "id,title,state,created_at") { + t.Errorf("CSV header missing in output: %s", content) + } + if !strings.Contains(content, "First") { + t.Errorf("expected 'First' in CSV output: %s", content) + } +} + +func TestWriteJSON(t *testing.T) { + tmpFile := os.TempDir() + "/test_export_write.json" + defer os.Remove(tmpFile) + + items := []json.RawMessage{ + []byte(`{"id":1,"name":"test"}`), + } + if err := writeJSON(tmpFile, items); err != nil { + t.Fatalf("writeJSON failed: %v", err) + } + + data, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if !strings.Contains(string(data), `"id": 1`) && !strings.Contains(string(data), `"id":1`) { + t.Errorf("JSON content unexpected: %s", string(data)) + } +} + +func findExportShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 63b387f..85ac32d 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -8,6 +8,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" + "github.com/gitlink-org/gitlink-cli/shortcuts/export" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/member" @@ -22,7 +23,6 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" - "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) @@ -50,6 +50,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "search": search.Shortcuts(tr), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), + "export": export.Shortcuts(), "webhook": webhook.Shortcuts(tr), "wiki": wiki.Shortcuts(), "workflow": workflow.Shortcuts(), @@ -72,6 +73,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "search": tr.T("cmd.search.short"), "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", + "export": "Data export to CSV/JSON", "webhook": tr.T("cmd.webhook.short"), "wiki": "Wiki page operations", "workflow": "AI agent workflow analysis", From b887e5053d1ea0ee7bbf8ef370efe4fcd133dd65 Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Wed, 3 Jun 2026 12:07:13 +0800 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E8=84=9A=E6=9C=AC=20+=20CI=20=E9=85=8D=E7=BD=AE=20+?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E5=B8=AE=E5=8A=A9=E6=96=87=E6=A1=A3=20+?= =?UTF-8?q?=20=E8=B7=A8=E5=B9=B3=E5=8F=B0=E9=AA=8C=E8=AF=81=E6=8A=A5?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/install.sh: Linux/macOS 一键安装脚本 - 新增 scripts/install.ps1: Windows PowerShell 安装脚本 - 修改 .devops/ci.yml: 更新 CI 支持 wyx_branch 分支 - 新增 doc/commands/pm.md: pm 模块命令参考手册 - 新增 doc/commands/wiki.md: wiki 模块命令参考手册 - 新增 doc/commands/alias-browse-status.md: alias/browse/status 命令参考手册 - 新增 doc/commands/export.md: export 模块命令参考手册 - 新增 doc/commands/cross-platform.md: 跨平台兼容性验证报告 关联 Issue: #16 --- .devops/ci.yml | 4 +- doc/commands/alias-browse-status.md | 104 ++++++++++++++++++++++++++++ doc/commands/cross-platform.md | 47 +++++++++++++ doc/commands/export.md | 68 ++++++++++++++++++ doc/commands/pm.md | 49 +++++++++++++ doc/commands/wiki.md | 49 +++++++++++++ scripts/install.ps1 | 40 +++++++++++ scripts/install.sh | 49 +++++++++++++ 8 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 doc/commands/alias-browse-status.md create mode 100644 doc/commands/cross-platform.md create mode 100644 doc/commands/export.md create mode 100644 doc/commands/pm.md create mode 100644 doc/commands/wiki.md create mode 100644 scripts/install.ps1 create mode 100644 scripts/install.sh diff --git a/.devops/ci.yml b/.devops/ci.yml index 7fdd6d7..5acd27d 100644 --- a/.devops/ci.yml +++ b/.devops/ci.yml @@ -17,7 +17,7 @@ workflow: task: git_clone@1.2.9 input: remote_url: '"https://gitlink.org.cn/jiangtx/gitlink-cli.git"' - ref: '"refs/heads/jtx_branch"' + ref: '"refs/heads/wyx_branch"' commit_id: '""' depth: 1 needs: @@ -31,7 +31,7 @@ workflow: 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 检查通过'" + "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b wyx_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 diff --git a/doc/commands/alias-browse-status.md b/doc/commands/alias-browse-status.md new file mode 100644 index 0000000..f9caea6 --- /dev/null +++ b/doc/commands/alias-browse-status.md @@ -0,0 +1,104 @@ +# alias — 命令别名管理 + +> 关联 Issue: #14 | PR: #11 + +## 概述 + +alias 命令用于管理 gitlink-cli 的命令别名,将常用长命令缩短为简短别名,提升日常使用效率。对标 `gh alias`。 + +## 命令列表 + +### alias +list +- **用途**: 列出所有已定义的命令别名 +- **示例**: `gitlink-cli alias +list` + +### alias +set \ \ +- **用途**: 设置命令别名 +- **参数**: name(别名名称)和 command(实际命令) +- **示例**: + - `gitlink-cli alias +set rl "repo +list"` + - `gitlink-cli alias +set ri "repo +info --owner Gitlink --repo gitlink-cli"` + +### alias +delete \ +- **用途**: 删除已定义的命令别名 +- **参数**: name(要删除的别名名称) +- **示例**: `gitlink-cli alias +delete rl` + +## 存储 + +别名存储在 `~/.config/gitlink-cli/aliases.yaml` 文件中,格式为 YAML。 + +--- + +# browse — 浏览器打开 GitLink 页面 + +> 关联 Issue: #14 | PR: #11 + +## 概述 + +browse 命令用于在浏览器中快速打开当前仓库或指定资源的 GitLink 页面。对标 `gh browse`。 + +## 用法 + +``` +gitlink-cli browse [resource] +``` + +- 不带参数:打开当前仓库主页 +- 带参数:打开指定资源页面 + +## 示例 + +```bash +# 打开当前仓库主页 +gitlink-cli browse + +# 打开指定 Issue +gitlink-cli browse issues/42 + +# 打开指定 PR +gitlink-cli browse pulls/128 + +# 打开 Wiki 页面 +gitlink-cli browse wiki +``` + +## 跨平台支持 + +- macOS: 使用 `open` 命令 +- Windows: 使用 `start` 命令 +- Linux: 使用 `xdg-open` 命令 + +--- + +# status — 显示当前状态 + +> 关联 Issue: #14 | PR: #11 + +## 概述 + +status 命令显示 gitlink-cli 的当前状态信息,包括认证状态、API 地址、当前目录和自动推断的仓库信息。对标 `gh auth status`。 + +## 用法 + +```bash +gitlink-cli status +``` + +## 输出示例 + +``` +GitLink CLI 状态 +─────────────── + 认证状态: 已登录 + Token 来源: keyring / 配置文件 + API 地址: https://www.gitlink.org.cn/api + 当前目录: /home/user/projects/gitlink-cli + 推断仓库: Gitlink/gitlink-cli +``` + +## 功能说明 + +- 自动检测 Token 来源(环境变量 GITLINK_TOKEN / keyring / 配置文件) +- 自动从 git remote 推断 owner/repo +- 未登录时给出 `gitlink-cli auth login` 提示 diff --git a/doc/commands/cross-platform.md b/doc/commands/cross-platform.md new file mode 100644 index 0000000..05ee4c4 --- /dev/null +++ b/doc/commands/cross-platform.md @@ -0,0 +1,47 @@ +# 跨平台兼容性验证报告 + +> 关联 Issue: #16 | PR: #13 + +## 测试矩阵 + +| 验证项 | Windows 11 | macOS | Ubuntu | +|--------|:---:|:---:|:---:| +| `git clone` + `go build ./...` | 待验证 | 待验证 | 待验证 | +| `go test -race ./...` 全部通过 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli auth login` 登录 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli repo +list` 可用 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli pm +dashboards` 可用 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli wiki +pages` 可用 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli alias +list` 可用 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli browse` 打开浏览器 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli status` 显示状态 | 待验证 | 待验证 | 待验证 | +| `gitlink-cli export +issues` 导出 | 待验证 | 待验证 | 待验证 | +| Token 存储(keyring)正常 | 待验证 | 待验证 | 待验证 | + +## 安装脚本 + +| 脚本 | 平台 | 路径 | +|------|------|------| +| install.sh | Linux / macOS | `scripts/install.sh` | +| install.ps1 | Windows | `scripts/install.ps1` | + +## CI 配置 + +| 配置文件 | 说明 | +|---------|------| +| `.devops/ci.yml` | 建木流水线:push 到 wyx_branch 时自动触发构建+测试+格式化检查 | + +### CI 检查内容 + +| 检查项 | 命令 | 说明 | +|--------|------|------| +| 构建 | `go build ./...` | 确保代码编译通过 | +| 静态分析 | `go vet ./...` | 检测常见代码问题 | +| 测试 | `go test -race ./...` | 运行全部测试,含竞态检测 | +| 格式化 | `gofmt -s -l .` | 确保代码格式符合 Go 标准 | + +## 已知问题 + +1. **Windows keyring**: Windows Credential Manager 可能需要额外配置 +2. **Linux keyring**: 需要 dbus 服务支持,无桌面环境时可能不可用 +3. **browse 命令**: Linux 环境需要安装 xdg-utils 包 diff --git a/doc/commands/export.md b/doc/commands/export.md new file mode 100644 index 0000000..64a10b8 --- /dev/null +++ b/doc/commands/export.md @@ -0,0 +1,68 @@ +# export — 数据导出命令 + +> 关联 Issue: #15 | PR: #12 + +## 概述 + +export 模块提供将仓库数据(Issue、PR、贡献者)导出为 CSV 或 JSON 文件的能力,支持离线分析和科研用途。 + +## 命令列表 + +### export +issues +- **用途**: 导出仓库 Issue 列表为 CSV 或 JSON 文件 +- **API**: GET /v1/:owner/:repo/issues +- **参数**: + - --format, -f (可选) 输出格式: csv / json,默认 csv + - --output, -o (可选) 输出文件路径,默认 issues.csv + - --state, -s (可选) 状态过滤: open / closed / all,默认 all + - --page, -p (可选) 起始页,默认 1 + - --limit, -l (可选) 每页数量,默认 50 +- **示例**: + - `gitlink-cli export +issues --format csv --output my_issues.csv` + - `gitlink-cli export +issues --format json --state open` + +### export +prs +- **用途**: 导出仓库 PR 列表为 CSV 或 JSON 文件 +- **API**: GET /v1/:owner/:repo/pulls +- **参数**: + - --format, -f (可选) 输出格式: csv / json,默认 csv + - --output, -o (可选) 输出文件路径,默认 prs.csv + - --state, -s (可选) 状态过滤,默认 all + - --page, -p (可选) 起始页,默认 1 + - --limit, -l (可选) 每页数量,默认 50 +- **示例**: + - `gitlink-cli export +prs --format json` + - `gitlink-cli export +prs --state closed --output closed_prs.csv` + +### export +contributors +- **用途**: 导出贡献者统计为 CSV 或 JSON 文件 +- **API**: GET /:owner/:repo/contributors +- **参数**: + - --format, -f (可选) 输出格式: csv / json,默认 csv + - --output, -o (可选) 输出文件路径,默认 contributors.csv +- **示例**: + - `gitlink-cli export +contributors --format json` + +## CSV 输出格式 + +### issues.csv +```csv +id,title,state,created_at +1,Bug fix,1,2026-01-01 +``` + +### prs.csv +```csv +id,title,state,created_at +2,Feature PR,0,2026-02-01 +``` + +### contributors.csv +```csv +id,login,contributions +1,dev1,42 +``` + +## 向后兼容性 + +无破坏性变更。所有命令通过 export 域组 + 前缀添加。 diff --git a/doc/commands/pm.md b/doc/commands/pm.md new file mode 100644 index 0000000..009726a --- /dev/null +++ b/doc/commands/pm.md @@ -0,0 +1,49 @@ +# pm — 项目管理命令 + +> 关联 Issue: #12 | PR: #9 + +## 概述 + +pm 模块提供 GitLink 项目管理相关的命令,包括仪表盘、Sprint 任务、周报、标签、流水线和 Action 运行记录的查看。 + +## 命令列表 + +### pm +dashboards +- **用途**: 查看项目仪表盘数据 +- **API**: GET /pm/dashboards?project_id=\ +- **参数**: --project (必填) 项目 ID +- **示例**: `gitlink-cli pm +dashboards --project 123` + +### pm +sprints +- **用途**: 查看 Sprint 任务列表 +- **API**: GET /pm/sprint_issues?project_id=\ +- **参数**: --project (必填) 项目 ID +- **示例**: `gitlink-cli pm +sprints --project 123` + +### pm +weekly +- **用途**: 查看周报任务 +- **API**: GET /pm/weekly_issues?project_id=\ +- **参数**: --project (必填) 项目 ID +- **示例**: `gitlink-cli pm +weekly --project 123` + +### pm +tags +- **用途**: 查看项目 Issue 标签 +- **API**: GET /pm/issue_tags?project_id=\ +- **参数**: --project (必填) 项目 ID +- **示例**: `gitlink-cli pm +tags --project 123` + +### pm +pipelines +- **用途**: 查看项目 CI/CD 流水线列表 +- **API**: GET /pm/pipelines?project_id=\ +- **参数**: --project (必填) 项目 ID +- **示例**: `gitlink-cli pm +pipelines --project 123` + +### pm +runs +- **用途**: 查看项目 Action 运行记录 +- **API**: GET /pm/action_runs?project_id=\ +- **参数**: --project (必填) 项目 ID +- **示例**: `gitlink-cli pm +runs --project 123` + +## 向后兼容性 + +无破坏性变更。所有命令通过 pm 域组 + 前缀添加。 diff --git a/doc/commands/wiki.md b/doc/commands/wiki.md new file mode 100644 index 0000000..00d52bd --- /dev/null +++ b/doc/commands/wiki.md @@ -0,0 +1,49 @@ +# wiki — Wiki 管理命令 + +> 关联 Issue: #13 | PR: #10 + +## 概述 + +wiki 模块提供 GitLink 仓库 Wiki 页面的管理命令,支持列出、查看、创建、更新和删除 Wiki 页面。 + +## 命令列表 + +### wiki +pages +- **用途**: 列出仓库所有 Wiki 页面 +- **API**: GET /api/wiki/wikiPages +- **参数**: 无(自动从 git remote 推断 owner/repo) +- **示例**: `gitlink-cli wiki +pages` + +### wiki +get +- **用途**: 获取指定 Wiki 页面内容 +- **API**: GET /api/wiki/getWiki?id=\ +- **参数**: --id, -i (必填) Wiki 页面 ID +- **示例**: `gitlink-cli wiki +get --id 42` + +### wiki +create +- **用途**: 创建新的 Wiki 页面 +- **API**: POST /api/wiki/createWiki +- **参数**: + - --title, -t (必填) 页面标题 + - --content, -c (必填) 页面内容(Markdown) + - --project (可选) 项目 ID +- **示例**: `gitlink-cli wiki +create --title "Getting Started" --content "# Welcome"` + +### wiki +update +- **用途**: 更新已有 Wiki 页面 +- **API**: PUT /api/wiki/updateWiki +- **参数**: + - --id, -i (必填) Wiki 页面 ID + - --title, -t (可选) 新标题 + - --content, -c (可选) 新内容(Markdown) +- **示例**: `gitlink-cli wiki +update --id 42 --title "Updated Title"` + +### wiki +delete +- **用途**: 删除 Wiki 页面 +- **API**: POST /api/wiki/deleteWiki +- **参数**: --id, -i (必填) Wiki 页面 ID +- **示例**: `gitlink-cli wiki +delete --id 42` + +## 向后兼容性 + +无破坏性变更。所有命令通过 wiki 域组 + 前缀添加。 diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..8bac24a --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,40 @@ +# GitLink CLI 一键安装脚本 (Windows PowerShell) +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host " GitLink CLI 安装脚本 (Windows)" -ForegroundColor Cyan +Write-Host "=========================================" -ForegroundColor Cyan + +$binary = "gitlink-cli-windows-amd64.exe" +$url = "https://gitlink.org.cn/Gitlink/gitlink-cli/releases/download/latest/$binary" +$installDir = "$env:LOCALAPPDATA\gitlink-cli" +$dest = "$installDir\gitlink-cli.exe" + +Write-Host "下载地址: $url" + +# 创建安装目录 +if (-not (Test-Path $installDir)) { + New-Item -ItemType Directory -Path $installDir -Force | Out-Null +} + +# 下载 +try { + Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing + Write-Host "下载完成" -ForegroundColor Green +} catch { + Write-Host "下载失败: $_" -ForegroundColor Red + exit 1 +} + +# 添加到 PATH(用户级别) +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($userPath -notlike "*$installDir*") { + [Environment]::SetEnvironmentVariable( + "Path", + "$installDir;$userPath", + "User" + ) + Write-Host "已添加到用户 PATH" -ForegroundColor Green +} + +Write-Host "" +Write-Host "安装完成!" -ForegroundColor Green +Write-Host "请重新打开终端,运行 gitlink-cli --help 验证安装" -ForegroundColor Yellow diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..d1c19f4 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# GitLink CLI 一键安装脚本 (Linux / macOS) +set -e + +echo "=========================================" +echo " GitLink CLI 安装脚本" +echo "=========================================" + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +# 转换架构名称 +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + armv7l) ARCH="armv7" ;; + *) echo "不支持的架构: $ARCH"; exit 1 ;; +esac + +echo "检测到系统: ${OS} ${ARCH}" + +BINARY="gitlink-cli-${OS}-${ARCH}" +URL="https://gitlink.org.cn/Gitlink/gitlink-cli/releases/download/latest/${BINARY}" + +echo "下载地址: $URL" + +# 下载 +if command -v curl &> /dev/null; then + curl -fsSL "$URL" -o /tmp/gitlink-cli +elif command -v wget &> /dev/null; then + wget -q "$URL" -O /tmp/gitlink-cli +else + echo "错误: 需要 curl 或 wget" + exit 1 +fi + +# 安装 +chmod +x /tmp/gitlink-cli + +if [ "$(id -u)" -eq 0 ]; then + mv /tmp/gitlink-cli /usr/local/bin/gitlink-cli +else + echo "需要 sudo 权限安装到 /usr/local/bin/" + sudo mv /tmp/gitlink-cli /usr/local/bin/gitlink-cli +fi + +echo "" +echo "✅ 安装完成!" +echo " 运行 gitlink-cli --help 验证安装" From 9474c1f432971e2028a782ae79b3e2b0c00f6fe1 Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Wed, 3 Jun 2026 12:39:48 +0800 Subject: [PATCH 19/19] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20alias/browse?= =?UTF-8?q?/status=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 cmd/alias/alias_test.go: 8 个测试(加载/保存/覆盖/删除/无效YAML/子命令结构) - 新增 cmd/browse/browse_test.go: 5 个测试(命令结构/参数校验/示例内容) - 新增 cmd/status/status_test.go: 5 个测试(命令结构/token来源判断) 关联 Issue: #14 --- cmd/alias/alias_test.go | 193 ++++++++++++++++++++++++++++++++++++++ cmd/browse/browse_test.go | 58 ++++++++++++ cmd/status/status_test.go | 58 ++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 cmd/alias/alias_test.go create mode 100644 cmd/browse/browse_test.go create mode 100644 cmd/status/status_test.go diff --git a/cmd/alias/alias_test.go b/cmd/alias/alias_test.go new file mode 100644 index 0000000..0c54cd6 --- /dev/null +++ b/cmd/alias/alias_test.go @@ -0,0 +1,193 @@ +package alias + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestLoadAliasesEmpty(t *testing.T) { + // 设置临时配置目录 + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + aliases, err := loadAliases() + if err != nil { + t.Fatalf("loadAliases failed: %v", err) + } + if len(aliases) != 0 { + t.Fatalf("expected empty aliases, got %d", len(aliases)) + } +} + +func TestSaveAndLoadAliases(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + // 保存 + original := map[string]string{ + "rl": "repo +list", + "ri": "repo +info", + } + if err := saveAliases(original); err != nil { + t.Fatalf("saveAliases failed: %v", err) + } + + // 加载 + loaded, err := loadAliases() + if err != nil { + t.Fatalf("loadAliases failed: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("expected 2 aliases, got %d", len(loaded)) + } + if loaded["rl"] != "repo +list" { + t.Errorf("expected rl -> repo +list, got %s", loaded["rl"]) + } + if loaded["ri"] != "repo +info" { + t.Errorf("expected ri -> repo +info, got %s", loaded["ri"]) + } +} + +func TestSaveAliasesOverwrite(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + // 第一次保存 + saveAliases(map[string]string{"rl": "repo +list"}) + + // 覆盖保存 + saveAliases(map[string]string{"rl": "repo +list --owner Gitlink"}) + + loaded, _ := loadAliases() + if loaded["rl"] != "repo +list --owner Gitlink" { + t.Errorf("alias should be overwritten, got %s", loaded["rl"]) + } +} + +func TestLoadAliasesInvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + // 写入无效 YAML + os.WriteFile(tmpDir+"/aliases.yaml", []byte("{{invalid yaml}}"), 0600) + + aliases, err := loadAliases() + if err != nil { + t.Fatalf("should not error on invalid YAML, got: %v", err) + } + if len(aliases) != 0 { + t.Fatalf("should return empty map on invalid YAML, got %d", len(aliases)) + } +} + +func TestNewAliasCmd(t *testing.T) { + cmd := NewAliasCmd() + if cmd.Use != "alias" { + t.Errorf("expected Use 'alias', got %s", cmd.Use) + } + if !cmd.HasSubCommands() { + t.Error("alias command should have subcommands") + } + subcmds := cmd.Commands() + if len(subcmds) != 3 { + t.Fatalf("expected 3 subcommands, got %d", len(subcmds)) + } +} + +func TestAliasListSubcommand(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + cmd := NewAliasCmd() + // 找到 +list 子命令 + var listCmd *cobra.Command + for _, sub := range cmd.Commands() { + if sub.Use == "+list" { + listCmd = sub + break + } + } + if listCmd == nil { + t.Fatal("+list subcommand not found") + } + + // 无别名时运行 + buf := new(bytes.Buffer) + listCmd.SetOut(buf) + listCmd.SetArgs([]string{}) + if err := listCmd.Execute(); err != nil { + t.Fatalf("list failed: %v", err) + } + if !strings.Contains(buf.String(), "未定义任何别名") { + t.Errorf("expected hint for no aliases, got: %s", buf.String()) + } +} + +func TestAliasSetAndDeleteSubcommands(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + cmd := NewAliasCmd() + + // 找到 +set 子命令 + var setCmd, deleteCmd *cobra.Command + for _, sub := range cmd.Commands() { + if strings.HasPrefix(sub.Use, "+set") { + setCmd = sub + } + if strings.HasPrefix(sub.Use, "+delete") { + deleteCmd = sub + } + } + + // +set + setCmd.SetArgs([]string{"rl", "repo +list"}) + if err := setCmd.Execute(); err != nil { + t.Fatalf("set failed: %v", err) + } + + // 验证文件写入 + aliases, _ := loadAliases() + if aliases["rl"] != "repo +list" { + t.Fatalf("alias not saved correctly: %v", aliases) + } + + // +delete + deleteCmd.SetArgs([]string{"rl"}) + if err := deleteCmd.Execute(); err != nil { + t.Fatalf("delete failed: %v", err) + } + + // 验证已删除 + aliases, _ = loadAliases() + if _, ok := aliases["rl"]; ok { + t.Fatal("alias should have been deleted") + } +} + +func TestAliasDeleteNonExistent(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + cmd := NewAliasCmd() + var deleteCmd *cobra.Command + for _, sub := range cmd.Commands() { + if strings.HasPrefix(sub.Use, "+delete") { + deleteCmd = sub + break + } + } + + deleteCmd.SetArgs([]string{"nonexistent"}) + err := deleteCmd.Execute() + if err == nil { + t.Fatal("expected error when deleting nonexistent alias") + } + if !strings.Contains(err.Error(), "不存在") { + t.Errorf("error should mention alias does not exist: %v", err) + } +} diff --git a/cmd/browse/browse_test.go b/cmd/browse/browse_test.go new file mode 100644 index 0000000..812e911 --- /dev/null +++ b/cmd/browse/browse_test.go @@ -0,0 +1,58 @@ +package browse + +import ( + "strings" + "testing" +) + +func TestNewBrowseCmd(t *testing.T) { + cmd := NewBrowseCmd() + if cmd.Use != "browse [resource]" { + t.Errorf("expected Use 'browse [resource]', got %s", cmd.Use) + } + if cmd.Short == "" { + t.Error("Short description should not be empty") + } + if cmd.Long == "" { + t.Error("Long description should not be empty") + } +} + +func TestBrowseCmdHasCorrectArgs(t *testing.T) { + cmd := NewBrowseCmd() + // MaximumNArgs(1) should allow 0 or 1 args + if err := cmd.Args(cmd, []string{}); err != nil { + t.Errorf("should accept 0 args: %v", err) + } + if err := cmd.Args(cmd, []string{"issues/42"}); err != nil { + t.Errorf("should accept 1 arg: %v", err) + } + if err := cmd.Args(cmd, []string{"a", "b"}); err == nil { + t.Error("should reject more than 1 arg") + } +} + +func TestBrowseCmdSubcommandStructure(t *testing.T) { + cmd := NewBrowseCmd() + // browse 不应该有子命令 + if cmd.HasSubCommands() { + t.Error("browse should not have subcommands") + } +} + +func TestBrowseCmdExample(t *testing.T) { + cmd := NewBrowseCmd() + if cmd.Example == "" { + t.Error("Example should not be empty") + } + if !strings.Contains(cmd.Example, "browse") { + t.Error("Example should contain 'browse'") + } +} + +func TestOpenBrowserReturnsNoError(t *testing.T) { + // openBrowser 在所有平台都应该返回 nil 或一个 error + // 在无头环境下可能会失败,但不应该 panic + _ = openBrowser("https://gitlink.org.cn") + // 只要不 panic 就行 +} diff --git a/cmd/status/status_test.go b/cmd/status/status_test.go new file mode 100644 index 0000000..b88a176 --- /dev/null +++ b/cmd/status/status_test.go @@ -0,0 +1,58 @@ +package status + +import ( + "strings" + "testing" +) + +func TestNewStatusCmd(t *testing.T) { + cmd := NewStatusCmd() + if cmd.Use != "status" { + t.Errorf("expected Use 'status', got %s", cmd.Use) + } + if cmd.Short == "" { + t.Error("Short description should not be empty") + } + if cmd.Long == "" { + t.Error("Long description should not be empty") + } +} + +func TestNewStatusCmdExample(t *testing.T) { + cmd := NewStatusCmd() + if !strings.Contains(cmd.Example, "status") { + t.Errorf("Example should contain 'status', got: %s", cmd.Example) + } +} + +func TestNewStatusCmdHasNoSubcommands(t *testing.T) { + cmd := NewStatusCmd() + if cmd.HasSubCommands() { + t.Error("status should not have subcommands") + } +} + +func TestTokenSourceFromEnv(t *testing.T) { + t.Setenv("GITLINK_TOKEN", "test-token-123") + result := tokenSource("test-token-123") + if result != "环境变量 GITLINK_TOKEN" { + t.Errorf("expected env source, got: %s", result) + } +} + +func TestTokenSourceFromKeyring(t *testing.T) { + // 不设置环境变量,或用不同的值 + t.Setenv("GITLINK_TOKEN", "") + result := tokenSource("some-stored-token") + if result != "keyring / 配置文件" { + t.Errorf("expected keyring source, got: %s", result) + } +} + +func TestTokenSourceMismatch(t *testing.T) { + t.Setenv("GITLINK_TOKEN", "env-token") + result := tokenSource("different-token") + if result != "keyring / 配置文件" { + t.Errorf("should fallback to keyring when token differs from env, got: %s", result) + } +}