From 1bd6e667a780412575c6a3b3092512a798ceb2b2 Mon Sep 17 00:00:00 2001 From: 1os21ka23r9navae6mrro <1os21ka23r9navae6mrro@gmail.com> Date: Wed, 8 Jul 2026 14:55:31 +0000 Subject: [PATCH] feat(commit): add commit group (+list, +recent, +diff, +files) The CLI had no way to read commit history at all. New group wrapping Api::V1::Projects::CommitsController: - commit +list [-s ref]: paged commit list, optionally from a branch/tag/SHA - commit +recent [-k keyword]: recent commits with keyword filter - commit +diff -s : full diff of a commit - commit +files -s [-f filepath]: files changed by a commit, optionally restricted to one path Production-verified on gitlink.org.cn against a 6700+-commit repo (list totals, keyword-filtered recent, real SHA diff/files). 3 unit tests, README section, bilingual i18n keys. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 15 ++++ internal/i18n/locales/en-US.json | 9 +++ internal/i18n/locales/zh-CN.json | 9 +++ shortcuts/commit/commit.go | 127 +++++++++++++++++++++++++++++++ shortcuts/commit/commit_test.go | 97 +++++++++++++++++++++++ shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- 7 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 shortcuts/commit/commit.go create mode 100644 shortcuts/commit/commit_test.go diff --git a/README.md b/README.md index e5e4318..d055449 100644 --- a/README.md +++ b/README.md @@ -872,3 +872,18 @@ See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/ ## License [MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2) + +### Commits + +```bash +# List commits (optionally from a ref) +gitlink-cli commit +list --owner Gitlink --repo forgeplus -s develop + +# Recent commits filtered by keyword +gitlink-cli commit +recent --owner Gitlink --repo forgeplus -k fix + +# Diff and changed files of a commit +gitlink-cli commit +diff --owner Gitlink --repo forgeplus -s +gitlink-cli commit +files --owner Gitlink --repo forgeplus -s -f app/models/user.rb +``` + diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..00f6cdd 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -16,6 +16,11 @@ "cmd.ci.restart.short": "Restart a build", "cmd.ci.short": "CI/CD operations", "cmd.ci.stop.short": "Stop a build", + "cmd.commit.diff.short": "Show the diff of a commit", + "cmd.commit.files.short": "List files changed by a commit", + "cmd.commit.list.short": "List commits, optionally starting from a ref", + "cmd.commit.recent.short": "List recent commits, filterable by keyword", + "cmd.commit.short": "Commit operations", "cmd.config.get.short": "Get a configuration value", "cmd.config.init.short": "Initialize configuration file", "cmd.config.list.short": "List all configuration values", @@ -130,6 +135,10 @@ "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", "flag.comment.body": "Comment body", + "flag.commit.filepath": "Restrict to a single file path", + "flag.commit.keyword": "Filter commits by keyword", + "flag.commit.sha": "Commit SHA", + "flag.commit.sha_start": "Ref (branch, tag, or SHA) to list commits from", "flag.dataset.description": "Dataset description", "flag.dataset.dry_run": "Preview the request without writing the dataset", "flag.dataset.dry_run_delete": "Preview the request without deleting the attachment", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..6b6cbf1 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -16,6 +16,11 @@ "cmd.ci.restart.short": "重启构建", "cmd.ci.short": "CI/CD 操作", "cmd.ci.stop.short": "停止构建", + "cmd.commit.diff.short": "查看提交的 diff", + "cmd.commit.files.short": "列出提交变更的文件", + "cmd.commit.list.short": "列出提交,可从指定 ref 开始", + "cmd.commit.recent.short": "列出最近提交,可按关键字过滤", + "cmd.commit.short": "提交操作", "cmd.config.get.short": "获取配置项", "cmd.config.init.short": "初始化配置文件", "cmd.config.list.short": "列出所有配置项", @@ -130,6 +135,10 @@ "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", "flag.comment.body": "评论内容", + "flag.commit.filepath": "仅限单个文件路径", + "flag.commit.keyword": "按关键字过滤提交", + "flag.commit.sha": "提交 SHA", + "flag.commit.sha_start": "起始 ref(分支、标签或 SHA)", "flag.dataset.description": "数据集描述", "flag.dataset.dry_run": "预览请求,不写入数据集", "flag.dataset.dry_run_delete": "预览请求,不删除附件", diff --git a/shortcuts/commit/commit.go b/shortcuts/commit/commit.go new file mode 100644 index 0000000..0155cdb --- /dev/null +++ b/shortcuts/commit/commit.go @@ -0,0 +1,127 @@ +package commit + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func v1RepoPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) +} + +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) + return []*common.Shortcut{ + { + Name: "list", + Description: tr.T("cmd.commit.list.short"), + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: tr.T("flag.commit.sha_start")}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return 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", v1RepoPath(ctx)+"/commits", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "recent", + Description: tr.T("cmd.commit.recent.short"), + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: tr.T("flag.commit.keyword")}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/commits/recent", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "diff", + Description: tr.T("cmd.commit.diff.short"), + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: tr.T("flag.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", v1RepoPath(ctx), url.PathEscape(sha)), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "files", + Description: tr.T("cmd.commit.files.short"), + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: tr.T("flag.commit.sha"), Required: true}, + {Name: "filepath", Short: "f", Usage: tr.T("flag.commit.filepath")}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), 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{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if fp := ctx.Arg("filepath"); fp != "" { + q.Set("filepath", fp) + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/commits/%s/files", v1RepoPath(ctx), url.PathEscape(sha)), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/commit/commit_test.go b/shortcuts/commit/commit_test.go new file mode 100644 index 0000000..539af86 --- /dev/null +++ b/shortcuts/commit/commit_test.go @@ -0,0 +1,97 @@ +package commit + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return s.Run(ctx) + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func TestCommitListForwardsShaAndPaging(t *testing.T) { + var query string + 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) + } + query = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"total_count":1,"commits":[]}`)) + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{"sha": "develop", "page": "2", "limit": "5"}); err != nil { + t.Fatalf("list failed: %v", err) + } + for _, want := range []string{"sha=develop", "page=2", "limit=5"} { + if !strings.Contains(query, want) { + t.Fatalf("expected %q in query, got %q", want, query) + } + } +} + +func TestCommitRecentForwardsKeyword(t *testing.T) { + var query string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits/recent.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + query = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"commits":[]}`)) + })) + defer server.Close() + + if err := runShortcut(t, server, "recent", map[string]string{"keyword": "fix", "page": "1", "limit": "20"}); err != nil { + t.Fatalf("recent failed: %v", err) + } + if !strings.Contains(query, "keyword=fix") { + t.Fatalf("expected keyword=fix in query, got %q", query) + } +} + +func TestCommitDiffAndFilesBuildShaPaths(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + if err := runShortcut(t, server, "diff", map[string]string{"sha": "abc123"}); err != nil { + t.Fatalf("diff failed: %v", err) + } + if err := runShortcut(t, server, "files", map[string]string{"sha": "abc123", "filepath": "app/x.rb", "page": "1", "limit": "20"}); err != nil { + t.Fatalf("files failed: %v", err) + } + if paths[0] != "/v1/owner/repo/commits/abc123/diff.json" { + t.Fatalf("diff path = %s", paths[0]) + } + if paths[1] != "/v1/owner/repo/commits/abc123/files.json" { + t.Fatalf("files path = %s", paths[1]) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..8ff31da 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" @@ -51,6 +52,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), "ci": ci.Shortcuts(tr), + "commit": commit.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), "webhook": webhook.Shortcuts(tr), @@ -76,6 +78,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), "ci": tr.T("cmd.ci.short"), + "commit": tr.T("cmd.commit.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), "webhook": tr.T("cmd.webhook.short"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..9e13a8a 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -13,7 +13,7 @@ func TestRegisterAll(t *testing.T) { expectedGroups := []string{ "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", - "compare", "member", "milestone", "pipeline", "webhook", + "commit", "compare", "member", "milestone", "pipeline", "webhook", "dataset", "health", "ignore", "wiki", }