新增仓库洞察快捷命令 #116

Open
Mengz wants to merge 1 commits from Mengz/gitlink-cli:mengz/repo-insight-shortcuts into master
10 changed files with 205 additions and 20 deletions

View File

@ -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"

View File

@ -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 "项目描述"

View File

@ -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

View File

@ -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 文档。实现过程中也实测了公开仓库接口,保留稳定可用的仓库洞察能力,没有暴露当前服务端会返回业务失败的贡献者代码行统计接口。

View File

@ -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
}

View File

@ -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",

View File

@ -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": "仓库描述",

View File

@ -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"),

View File

@ -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)

View File

@ -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'