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 5af53de..f3dce12 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" @@ -37,6 +38,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(), @@ -62,6 +64,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": tr.T("cmd.label.short"), "license": tr.T("cmd.license.short"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 9555bd4..1a2c5fe 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -15,7 +15,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", "wiki", + "dataset", "health", "ignore", "wiki", "commit", } groupSet := map[string]bool{} 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 预检。