diff --git a/doc/changes/commit-shortcut.md b/doc/changes/commit-shortcut.md new file mode 100644 index 0000000..50434be --- /dev/null +++ b/doc/changes/commit-shortcut.md @@ -0,0 +1,44 @@ +# Commit shortcut + +新增 `commit` Shortcut 组,封装 GitLink 仓库提交(Commit)相关 OpenAPI 的常用操作,支持查看提交列表、单条提交变更文件、提交 Diff 与文件 Blame: + +- `commit +list` +- `commit +view` +- `commit +diff` +- `commit +blame` + +实现要点: + +- `+list` 映射 `GET /v1/{owner}/{repo}/commits`,支持 `--sha`(分支 / 标签 / 提交 SHA 过滤)、`--page`(默认 `1`)、`--limit`(默认 `20`),通过查询参数传给 API。 +- `+view` 映射 `GET /v1/{owner}/{repo}/commits/{sha}/files`,返回某次提交涉及的文件清单;`--sha` 为必填,同时支持分页参数。 +- `+diff` 映射 `GET /v1/{owner}/{repo}/commits/{sha}/diff`,返回指定提交的 Diff 内容;`--sha` 为必填。 +- `+blame` 映射 `GET /v1/{owner}/{repo}/blame`,按文件展示逐行归属;`--path` 为必填,`--sha` 缺省为 `master`,二者以 `filepath` / `sha` 查询参数提交。 +- 四个命令均通过 `ResolveOwnerRepo()` 解析 `--owner` / `--repo`(支持 `-R owner/repo` 缩写与仓库默认推断),结果统一经 `ctx.Output(env)` 输出,兼容 `--format`(table / json)等全局参数。 +- 路径沿用 `/v1/` 前缀约定,与 webhook / milestone / label 等组保持一致;`.json` 后缀由底层 client 自动补全。 + +## Examples + +```bash +# 列出 develop 分支最近 10 条提交 +gitlink-cli commit +list --owner Gitlink --repo forgeplus --sha develop --limit 10 + +# 查看某次提交涉及的文件 +gitlink-cli commit +view --owner Gitlink --repo forgeplus --sha abc123def + +# 查看提交 Diff +gitlink-cli commit +diff --owner Gitlink --repo forgeplus --sha abc123def + +# 查看 README.md 在 master 分支的 Blame 信息 +gitlink-cli commit +blame --owner Gitlink --repo forgeplus --path README.md + +# 使用 -R 缩写并以 JSON 输出 +gitlink-cli commit +list -R Gitlink/forgeplus --page 2 --format json +``` + +## Tests + +```bash +GOPROXY=https://goproxy.cn,direct go test ./shortcuts/commit/... +go test ./... +go run . commit +list --help +``` diff --git a/shortcuts/commit/commit.go b/shortcuts/commit/commit.go new file mode 100644 index 0000000..7d4fcd0 --- /dev/null +++ b/shortcuts/commit/commit.go @@ -0,0 +1,110 @@ +package commit + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List commits in a repository", + 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 fmt.Errorf("解析仓库信息失败: %w", 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) + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q) + if err != nil { + return fmt.Errorf("获取提交列表失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "View files changed in a commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, + {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 fmt.Errorf("解析仓库信息失败: %w", err) + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q) + if err != nil { + return fmt.Errorf("查看提交详情失败: %w", 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 fmt.Errorf("解析仓库信息失败: %w", err) + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil) + if err != nil { + return fmt.Errorf("获取提交 Diff 失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "blame", + Description: "Show blame for a file", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", filePath) + q.Set("sha", ctx.Arg("sha")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q) + if err != nil { + return fmt.Errorf("获取 Blame 信息失败: %w", err) + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/commit/commit_test.go b/shortcuts/commit/commit_test.go new file mode 100644 index 0000000..e6db3f3 --- /dev/null +++ b/shortcuts/commit/commit_test.go @@ -0,0 +1,160 @@ +package commit + +import ( + "encoding/json" + "fmt" + "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 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "commits": []map[string]interface{}{ + {"sha": "abc123", "commit_message": "initial commit"}, + }, + }) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "list", map[string]string{}) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestCommitListWithSHA(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.URL.Query().Get("sha") != "develop" { + t.Fatalf("expected sha=develop, got %s", r.URL.Query().Get("sha")) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 0, + "commits": []map[string]interface{}{}, + }) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "list", map[string]string{"sha": "develop"}) + if err != nil { + t.Fatalf("list with sha failed: %v", err) + } +} + +func TestCommitView(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/files.json" { + writeJSON(t, w, map[string]interface{}{ + "file_nums": 1, + "files": []map[string]interface{}{ + {"filename": "main.go", "additions": 10, "deletions": 2}, + }, + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "view", map[string]string{"sha": "abc123"}) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestCommitDiff(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/diff.json" { + writeJSON(t, w, map[string]interface{}{ + "file_nums": 1, + "total_addition": 10, + "total_deletion": 2, + "files": []map[string]interface{}{{"name": "main.go"}}, + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc123"}) + if err != nil { + t.Fatalf("diff shortcut failed: %v", err) + } +} + +func TestCommitBlame(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/blame.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.URL.Query().Get("filepath") != "main.go" { + t.Fatalf("expected filepath=main.go, got %s", r.URL.Query().Get("filepath")) + } + writeJSON(t, w, map[string]interface{}{ + "file_name": "main.go", + "num_lines": 20, + }) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "blame", map[string]string{"path": "main.go", "sha": "master"}) + if err != nil { + t.Fatalf("blame shortcut failed: %v", err) + } +} + +// === helpers === + +func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findCommitShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findCommitShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +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("failed to write response: %v", err) + } +} + +func assertEqual(t *testing.T, got, want interface{}) { + t.Helper() + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..37e3c59 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" @@ -50,6 +51,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": org.Shortcuts(tr), "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), + "commit": commit.Shortcuts(), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), @@ -75,6 +77,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": tr.T("cmd.org.short"), "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), + "commit": "Commit history, view, diff and blame operations", "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"),