Merge PR #403: feat(shortcut): 新增 commit 命令模块(提交历史/diff/blame)
# Conflicts: # shortcuts/commit/commit.go # shortcuts/commit/commit_test.go # shortcuts/register_test.go
This commit is contained in:
commit
54b4fd2a4c
|
|
@ -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
|
||||
```
|
||||
|
|
@ -7,12 +7,11 @@ import (
|
|||
"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",
|
||||
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"},
|
||||
|
|
@ -20,43 +19,43 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", 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)
|
||||
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 err
|
||||
return fmt.Errorf("获取提交列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "files",
|
||||
Description: "List changed files for a commit",
|
||||
Name: "view",
|
||||
Description: "View files changed in 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
|
||||
return fmt.Errorf("解析仓库信息失败: %w", 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)
|
||||
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 err
|
||||
return fmt.Errorf("查看提交详情失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -69,57 +68,43 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", 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)
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取提交 Diff 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "blame",
|
||||
Description: "Show blame information for a file",
|
||||
Description: "Show blame 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},
|
||||
{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 err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filepath, err := ctx.RequireArg("filepath")
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
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)
|
||||
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 err
|
||||
return fmt.Errorf("获取 Blame 信息失败: %w", 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package commit
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
|
@ -11,91 +12,149 @@ import (
|
|||
)
|
||||
|
||||
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{}{}})
|
||||
})
|
||||
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()
|
||||
if err := runCommitShortcut(t, server, "list", map[string]string{"sha": "master", "page": "2", "limit": "20"}); err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
|
||||
err := runCommitShortcut(t, server, "list", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("list shortcut 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{}{}})
|
||||
})
|
||||
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()
|
||||
if err := runCommitShortcut(t, server, "files", map[string]string{"sha": "abc", "filepath": "README.md"}); err != nil {
|
||||
t.Fatalf("files failed: %v", err)
|
||||
|
||||
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 := 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{}{}})
|
||||
})
|
||||
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()
|
||||
if err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc"}); err != nil {
|
||||
t.Fatalf("diff failed: %v", err)
|
||||
|
||||
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 := 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"})
|
||||
})
|
||||
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()
|
||||
if err := runCommitShortcut(t, server, "blame", map[string]string{"sha": "master", "filepath": "README.md"}); err != nil {
|
||||
t.Fatalf("blame failed: %v", err)
|
||||
|
||||
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()
|
||||
var shortcut *common.Shortcut
|
||||
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 {
|
||||
shortcut = s
|
||||
return 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)
|
||||
}
|
||||
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("write json: %v", err)
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got, want interface{}) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/feedback"
|
||||
|
|
@ -48,6 +49,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(),
|
||||
"feedback": feedback.Shortcuts(),
|
||||
|
|
@ -71,6 +73,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",
|
||||
"feedback": "Submit feedback and suggestions",
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ func TestRegisterAll(t *testing.T) {
|
|||
RegisterAll(root)
|
||||
|
||||
expectedGroups := []string{
|
||||
"repo", "issue", "label", "license", "pr", "release", "branch",
|
||||
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
|
||||
"org", "user", "search", "ci", "workflow",
|
||||
"compare", "member", "milestone", "pipeline", "webhook",
|
||||
"health", "feedback",
|
||||
"dataset", "health", "ignore", "wiki", "commit",
|
||||
}
|
||||
|
||||
groupSet := map[string]bool{}
|
||||
|
|
|
|||
Loading…
Reference in New Issue