diff --git a/README.md b/README.md index 797e6c7..bfce8c8 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,15 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # Read repository README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master +# Show repository language statistics +gitlink-cli repo +languages --owner Gitlink --repo forgeplus + +# List repository contributors +gitlink-cli repo +contributors --owner Gitlink --repo forgeplus + +# Show repository code statistics +gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master + # Create a repository gitlink-cli repo +create -n my-project -d "Project description" diff --git a/README.zh-CN.md b/README.zh-CN.md index 88c6119..2371c91 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -207,6 +207,15 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # 读取仓库 README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master +# 查看仓库语言统计 +gitlink-cli repo +languages --owner Gitlink --repo forgeplus + +# 列出仓库贡献者 +gitlink-cli repo +contributors --owner Gitlink --repo forgeplus + +# 查看仓库代码统计 +gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master + # 创建仓库 gitlink-cli repo +create -n my-project -d "项目描述" diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 87fc445..c856083 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -59,9 +59,19 @@ func TestLoginTokenFlag(t *testing.T) { } } +func setTempHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + t.Setenv("HOMEDRIVE", "") + t.Setenv("HOMEPATH", "") + return dir +} + func TestStatusCmdNotLoggedIn(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setTempHome(t) t.Setenv("GITLINK_TOKEN", "") _ = internalAuth.DeleteToken() @@ -76,7 +86,7 @@ func TestStatusCmdNotLoggedIn(t *testing.T) { func TestStatusCmdEnvToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setTempHome(t) t.Setenv("GITLINK_TOKEN", "env-token-123") _ = internalAuth.DeleteToken() @@ -86,8 +96,7 @@ func TestStatusCmdEnvToken(t *testing.T) { func TestStatusCmdStoredToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - dir := t.TempDir() - t.Setenv("HOME", dir) + dir := setTempHome(t) t.Setenv("GITLINK_TOKEN", "") os.MkdirAll(dir+"/.config/gitlink-cli", 0700) @@ -99,8 +108,7 @@ func TestStatusCmdStoredToken(t *testing.T) { func TestStatusCmdEnvAndStoredToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - dir := t.TempDir() - t.Setenv("HOME", dir) + dir := setTempHome(t) t.Setenv("GITLINK_TOKEN", "env-token") os.MkdirAll(dir+"/.config/gitlink-cli", 0700) @@ -114,7 +122,7 @@ func TestStatusCmdEnvAndStoredToken(t *testing.T) { func TestStatusCmdStoredTokenButLoadFails(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setTempHome(t) t.Setenv("GITLINK_TOKEN", "") // Don't create credentials file — LoadToken returns empty @@ -126,7 +134,7 @@ func TestStatusCmdStoredTokenButLoadFails(t *testing.T) { func TestLogoutCmdError(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setTempHome(t) t.Setenv("GITLINK_TOKEN", "") // Don't create credentials dir — DeleteToken will fail @@ -139,8 +147,7 @@ func TestLogoutCmdError(t *testing.T) { func TestLogoutCmd(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := t.TempDir() - t.Setenv("HOME", home) + home := setTempHome(t) t.Setenv("GITLINK_TOKEN", "") // Store a token first so DeleteToken has something to delete @@ -159,8 +166,7 @@ func TestLogoutCmd(t *testing.T) { func TestLoginWithToken(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - home := t.TempDir() - t.Setenv("HOME", home) + home := setTempHome(t) t.Setenv("GITLINK_TOKEN", "") // Mock stdin @@ -196,7 +202,7 @@ func TestLoginWithToken(t *testing.T) { func TestLoginWithTokenEmpty(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setTempHome(t) t.Setenv("GITLINK_TOKEN", "") oldStdin := os.Stdin @@ -219,7 +225,7 @@ func TestLoginWithTokenEmpty(t *testing.T) { func TestLoginWithPasswordNoTerminal(t *testing.T) { keyring.MockInitWithError(errors.New("keychain unavailable")) - t.Setenv("HOME", t.TempDir()) + setTempHome(t) t.Setenv("GITLINK_TOKEN", "") // term.ReadPassword will fail because test has no terminal diff --git a/doc/changes/repo-insight-shortcuts.md b/doc/changes/repo-insight-shortcuts.md new file mode 100644 index 0000000..8155a44 --- /dev/null +++ b/doc/changes/repo-insight-shortcuts.md @@ -0,0 +1,11 @@ +# 仓库洞察 Shortcut + +本次变更新增一组只读仓库洞察命令,让用户和 AI Agent 不必记忆 Raw API 路径即可快速获取仓库语言、贡献者和代码统计信息。 + +新增命令: + +- `repo +languages`:查看仓库语言占比。 +- `repo +contributors`:列出仓库贡献者及贡献次数。 +- `repo +code-stats`:查看仓库提交、作者、新增/删除行等代码统计,支持 `--ref` 筛选。 + +这些命令都复用现有 `RuntimeContext` 的仓库解析、API 调用和输出封装,保持 Shortcut 实现风格一致。单元测试覆盖了 API path、HTTP method、query 参数透传和错误返回路径,并同步更新 README、中文 README 与 `gitlink-repo` Skill 文档。实现过程中也实测了公开仓库接口,保留稳定可用的仓库洞察能力,没有暴露当前服务端会返回业务失败的贡献者代码行统计接口。 diff --git a/internal/auth/token_store_test.go b/internal/auth/token_store_test.go index 49bcc5c..91633fb 100644 --- a/internal/auth/token_store_test.go +++ b/internal/auth/token_store_test.go @@ -13,6 +13,9 @@ func tempHome(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + t.Setenv("HOMEDRIVE", "") + t.Setenv("HOMEPATH", "") return dir } diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index d9f3c34..d261932 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -58,9 +58,12 @@ "cmd.release.short": "Release operations", "cmd.release.view.short": "View release details", "cmd.repo.create.short": "Create a new repository", + "cmd.repo.code_stats.short": "Show repository code statistics", + "cmd.repo.contributors.short": "List repository contributors", "cmd.repo.delete.short": "Delete a repository", "cmd.repo.fork.short": "Fork a repository", "cmd.repo.info.short": "Show repository details", + "cmd.repo.languages.short": "Show repository language statistics", "cmd.repo.list.short": "List repositories for a user or organization", "cmd.repo.short": "Repository operations", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", @@ -150,6 +153,7 @@ "flag.release.prerelease": "Mark as prerelease (true/false)", "flag.release.tag": "Tag name", "flag.release.target": "Target branch", + "flag.ref": "Branch, tag, or commit SHA", "flag.repo": "Repository name (auto-detected from git remote)", "flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)", "flag.repo.description": "Repository description", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 848e605..2d70127 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -58,9 +58,12 @@ "cmd.release.short": "发布操作", "cmd.release.view.short": "查看发布详情", "cmd.repo.create.short": "创建新仓库", + "cmd.repo.code_stats.short": "显示仓库代码统计", + "cmd.repo.contributors.short": "列出仓库贡献者", "cmd.repo.delete.short": "删除仓库", "cmd.repo.fork.short": "Fork 仓库", "cmd.repo.info.short": "显示仓库详情", + "cmd.repo.languages.short": "显示仓库语言统计", "cmd.repo.list.short": "列出用户或组织的仓库", "cmd.repo.short": "仓库操作", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", @@ -150,6 +153,7 @@ "flag.release.prerelease": "标记为预发布(true/false)", "flag.release.tag": "标签名称", "flag.release.target": "目标分支", + "flag.ref": "分支、标签或 Commit SHA", "flag.repo": "仓库名称(自动从 git remote 检测)", "flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)", "flag.repo.description": "仓库描述", diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index ea4f6ee..6d52a72 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -79,6 +79,55 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "languages", + Description: tr.T("cmd.repo.languages.short"), + 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: tr.T("cmd.repo.contributors.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/contributors", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "code-stats", + Description: tr.T("cmd.repo.code_stats.short"), + Flags: []common.Flag{ + {Name: "ref", Usage: tr.T("flag.ref")}, + }, + 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) + } + env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/code_stats", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: tr.T("cmd.repo.create.short"), diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 43ed15a..e653084 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -112,6 +112,77 @@ func TestRepoInfo(t *testing.T) { } } +// --- insights --- + +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": "100.0%", + }) + })) + defer server.Close() + + err := runShortcut(t, server, "languages", nil) + if err != nil { + t.Fatalf("languages failed: %v", err) + } +} + +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) + } + writeJSON(w, map[string]interface{}{ + "total_count": float64(1), + "list": []interface{}{ + map[string]interface{}{"login": "alice", "contributions": float64(3)}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "contributors", nil) + if err != nil { + t.Fatalf("contributors failed: %v", err) + } +} + +func TestRepoCodeStatsWithRef(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/code_stats.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("ref"); got != "release/1.0" { + t.Fatalf("ref = %q", got) + } + writeJSON(w, map[string]interface{}{ + "author_count": float64(2), + "commit_count": float64(17), + "additions": float64(120), + "deletions": float64(8), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "code-stats", map[string]string{"ref": "release/1.0"}) + if err != nil { + t.Fatalf("code-stats failed: %v", err) + } +} + // --- fork --- func TestRepoFork(t *testing.T) { @@ -250,6 +321,19 @@ func TestRepoInfoHTTPError(t *testing.T) { } } +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") + } +} + func TestRepoForkHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 9fabb8e..e4f2723 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -22,6 +22,9 @@ metadata: |----------|------|----------| | `repo +list` | 仓库列表 | 否(公开项目) | | `repo +info` | 仓库详情 | 否(公开项目) | +| `repo +languages` | 仓库语言统计 | 否(公开项目) | +| `repo +contributors` | 仓库贡献者列表 | 否(公开项目) | +| `repo +code-stats` | 仓库代码统计,可按分支/标签/Commit 筛选 | 否(公开项目) | | `repo +create` | 创建仓库 | 是 | | `repo +fork` | Fork 仓库 | 是 | | `repo +delete` | 删除仓库 | 是 | @@ -39,6 +42,15 @@ gitlink-cli repo +info # 列出用户的仓库 gitlink-cli repo +list --user zhangsan +# 查看仓库语言统计 +gitlink-cli repo +languages --owner Gitlink --repo forgeplus + +# 列出仓库贡献者 +gitlink-cli repo +contributors --owner Gitlink --repo forgeplus + +# 查看指定 ref 的代码统计 +gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master + # 创建仓库 gitlink-cli repo +create --name my-project --description "项目描述" @@ -57,12 +69,6 @@ Shortcuts 未覆盖的仓库操作可用 Raw API: # 获取 README gitlink-cli api GET /:owner/:repo/readme -# 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors - -# 获取语言统计 -gitlink-cli api GET /:owner/:repo/languages - # 获取提交列表 gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=20'