diff --git a/README.md b/README.md index 289eb16..e46ae6f 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| -| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions | +| 📦 Repo | List, create, fork, delete repositories, view repo info, commits, insights, and interactions | | 🐛 Issue | Create, update, close, batch close, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | @@ -219,6 +219,11 @@ gitlink-cli repo +list # View repository info gitlink-cli repo +info --owner Gitlink --repo forgeplus +# Inspect commits and blame +gitlink-cli commit +list --owner Gitlink --repo forgeplus --sha master +gitlink-cli commit +diff --owner Gitlink --repo forgeplus --sha +gitlink-cli commit +blame --owner Gitlink --repo forgeplus --sha master --filepath README.md + # Read repository README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master diff --git a/README.zh-CN.md b/README.zh-CN.md index ce6f964..db865de 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -103,7 +103,7 @@ | 分类 | 能力 | |------|------| -| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 | +| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、提交、洞察数据和互动状态 | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | @@ -230,6 +230,11 @@ gitlink-cli repo +list # 查看仓库信息 gitlink-cli repo +info --owner Gitlink --repo forgeplus +# 查看提交、diff 和 blame +gitlink-cli commit +list --owner Gitlink --repo forgeplus --sha master +gitlink-cli commit +diff --owner Gitlink --repo forgeplus --sha +gitlink-cli commit +blame --owner Gitlink --repo forgeplus --sha master --filepath README.md + # 读取仓库 README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master diff --git a/doc/changes/commit-inspect-shortcuts.md b/doc/changes/commit-inspect-shortcuts.md new file mode 100644 index 0000000..5a0175f --- /dev/null +++ b/doc/changes/commit-inspect-shortcuts.md @@ -0,0 +1,18 @@ +# Commit Inspect Shortcuts + +Added `gitlink-cli commit` for read-only commit inspection. + +| Command | Endpoint | +| --- | --- | +| `commit +list` | `GET /v1/{owner}/{repo}/commits` | +| `commit +files` | `GET /v1/{owner}/{repo}/commits/{sha}/files` | +| `commit +diff` | `GET /v1/{owner}/{repo}/commits/{sha}/diff` | +| `commit +blame` | `GET /v1/{owner}/{repo}/blame` | + +Validation: + +```bash +GOPROXY=https://goproxy.cn,direct go test ./shortcuts/commit ./shortcuts +go vet ./shortcuts/commit ./shortcuts +go run . commit --help +``` diff --git a/shortcuts/commit/commit.go b/shortcuts/commit/commit.go new file mode 100644 index 0000000..1366b5b --- /dev/null +++ b/shortcuts/commit/commit.go @@ -0,0 +1,125 @@ +package commit + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns repository commit inspection shortcuts. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List repository commits", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA"}, + {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{} + setQuery(q, "sha", ctx.Arg("sha")) + setQuery(q, "page", ctx.Arg("page")) + setQuery(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", commitRepoPath(ctx)+"/commits", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "files", + Description: "List changed files for a commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, + {Name: "filepath", Short: "f", Usage: "Filter 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 + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + q := url.Values{} + setQuery(q, "filepath", ctx.Arg("filepath")) + setQuery(q, "page", ctx.Arg("page")) + setQuery(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/commits/%s/files", commitRepoPath(ctx), url.PathEscape(sha)), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "diff", + Description: "Show diff for a commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s/diff", commitRepoPath(ctx), url.PathEscape(sha)), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "blame", + Description: "Show blame information for a file", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Required: true}, + {Name: "filepath", Short: "f", Usage: "File path", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + filepath, err := ctx.RequireArg("filepath") + if err != nil { + return err + } + q := url.Values{} + q.Set("sha", sha) + q.Set("filepath", filepath) + env, err := ctx.CallAPIWithQuery("GET", commitRepoPath(ctx)+"/blame", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func commitRepoPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) +} + +func setQuery(q url.Values, key, value string) { + if value != "" { + q.Set(key, value) + } +} diff --git a/shortcuts/commit/commit_test.go b/shortcuts/commit/commit_test.go new file mode 100644 index 0000000..acb686b --- /dev/null +++ b/shortcuts/commit/commit_test.go @@ -0,0 +1,101 @@ +package commit + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestCommitList(t *testing.T) { + server := newCommitServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/commits.json") + assertEqual(t, r.URL.Query().Get("sha"), "master") + assertEqual(t, r.URL.Query().Get("page"), "2") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "commits": []interface{}{}}) + }) + defer server.Close() + if err := runCommitShortcut(t, server, "list", map[string]string{"sha": "master", "page": "2", "limit": "20"}); err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestCommitFiles(t *testing.T) { + server := newCommitServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/commits/abc/files.json") + assertEqual(t, r.URL.Query().Get("filepath"), "README.md") + writeJSON(t, w, map[string]interface{}{"files": []interface{}{}}) + }) + defer server.Close() + if err := runCommitShortcut(t, server, "files", map[string]string{"sha": "abc", "filepath": "README.md"}); err != nil { + t.Fatalf("files failed: %v", err) + } +} + +func TestCommitDiff(t *testing.T) { + server := newCommitServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/commits/abc/diff.json") + writeJSON(t, w, map[string]interface{}{"files": []interface{}{}}) + }) + defer server.Close() + if err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc"}); err != nil { + t.Fatalf("diff failed: %v", err) + } +} + +func TestCommitBlame(t *testing.T) { + server := newCommitServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/blame.json") + assertEqual(t, r.URL.Query().Get("sha"), "master") + assertEqual(t, r.URL.Query().Get("filepath"), "README.md") + writeJSON(t, w, map[string]interface{}{"file_name": "README.md"}) + }) + defer server.Close() + if err := runCommitShortcut(t, server, "blame", map[string]string{"sha": "master", "filepath": "README.md"}); err != nil { + t.Fatalf("blame failed: %v", err) + } +} + +func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + var shortcut *common.Shortcut + for _, s := range Shortcuts() { + if s.Name == name { + shortcut = s + } + } + if shortcut == nil { + t.Fatalf("shortcut %q not found", name) + } + return shortcut.Run(&common.RuntimeContext{Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "owner", Repo: "repo", Format: "json", Args: args}) +} + +func newCommitServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("write json: %v", err) + } +} + +func assertEqual(t *testing.T, got, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 00e2426..2debb23 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -6,6 +6,7 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" + "github.com/gitlink-org/gitlink-cli/shortcuts/commit" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" @@ -36,6 +37,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { } groups := map[string][]*common.Shortcut{ "repo": repo.Shortcuts(tr), + "commit": commit.Shortcuts(), "issue": issue.Shortcuts(tr), "label": label.Shortcuts(), "license": license.Shortcuts(), @@ -60,6 +62,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { descriptions := map[string]string{ "repo": tr.T("cmd.repo.short"), + "commit": "Commit inspection operations", "issue": tr.T("cmd.issue.short"), "label": "Issue label operations", "license": "License operations", diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 1c28241..53f00c0 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", + "dataset", "health", "ignore", "commit", } groupSet := map[string]bool{} diff --git a/skills/README.md b/skills/README.md index af78bca..77d668b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -135,6 +135,7 @@ skills/ |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | | **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | +| **gitlink-commit** | 提交审查 | `commit +list`, `commit +files`, `commit +diff`, `commit +blame` | | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | diff --git a/skills/gitlink-commit/SKILL.md b/skills/gitlink-commit/SKILL.md new file mode 100644 index 0000000..03c967e --- /dev/null +++ b/skills/gitlink-commit/SKILL.md @@ -0,0 +1,33 @@ +--- +name: gitlink-commit +version: 1.0.0 +description: "GitLink 提交审查:提交列表、单个提交文件、diff 与 blame 查询。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli commit --help" +--- + +# gitlink-commit + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。** + +## Shortcuts + +| Shortcut | 说明 | +| --- | --- | +| `commit +list` | 查询提交列表 | +| `commit +files` | 查询单个提交变更文件 | +| `commit +diff` | 查询单个提交 diff | +| `commit +blame` | 查询文件 blame | + +## 示例 + +```bash +gitlink-cli commit +list --owner Gitlink --repo forgeplus --sha master --page 1 --limit 20 +gitlink-cli commit +files --owner Gitlink --repo forgeplus --sha --filepath README.md +gitlink-cli commit +diff --owner Gitlink --repo forgeplus --sha +gitlink-cli commit +blame --owner Gitlink --repo forgeplus --sha master --filepath README.md +``` + +全部命令均为只读,适合代码审查、变更追踪、科研仓库复现和 Agent 预检。