From 29e9e927f387cd47a8a0959f05d4a8a761cff911 Mon Sep 17 00:00:00 2001 From: farmyobutu5233 Date: Sun, 5 Jul 2026 09:41:32 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(file):=20=E6=96=B0=E5=A2=9E=20file=20?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E5=91=BD=E4=BB=A4=E7=BB=84=EF=BC=88+view/+se?= =?UTF-8?q?arch/+create/+update/+delete=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 26 ++++ README.zh-CN.md | 24 ++++ doc/changes/file-shortcuts.md | 59 ++++++++ shortcuts/file/file.go | 253 ++++++++++++++++++++++++++++++++++ shortcuts/file/file_test.go | 221 +++++++++++++++++++++++++++++ shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- 7 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 doc/changes/file-shortcuts.md create mode 100644 shortcuts/file/file.go create mode 100644 shortcuts/file/file_test.go diff --git a/README.md b/README.md index e5e4318..8f52be1 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 📖 Wiki | List, view, create, update, and delete wiki pages | | 🔍 Search | Search repositories, users | | 📊 Dataset | Query research datasets by project | +| 📄 File | View, search, create, update, and delete repository files without cloning | | 👤 User | View user profiles and info | | 📊 Profile | User ability, role, major, activity, and contribution statistics | | 📋 PM | Sprint management, kanban boards, weekly reports | @@ -676,6 +677,31 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --ye > published OpenAPI contract but are not yet deployed on production (they return > 404 there); they will work once the platform enables them. +### File Operations + +`file` reads and writes repository file contents without cloning — ideal for +AI agents that need to read or patch a single file. For directory listings and +README viewing, see `repo +tree` and `repo +readme`. + +```bash +# View a file (--raw prints only the decoded content, for piping) +gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md +gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md --raw > README.md + +# Search files by name +gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller + +# Create / update a file (content inline or from a local file) +gitlink-cli file +create --owner me --repo proj --path docs/note.md -c "# Note" -b master -m "add note" +gitlink-cli file +update --owner me --repo proj --path docs/note.md --content-file note.md -b master + +# Commit to a new branch created from --branch +gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b master --new-branch feature/docs + +# Delete a file +gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note" +``` + ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..5f9e2f1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -116,6 +116,7 @@ | 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | +| 📄 文件 | 无需克隆即可查看、搜索、创建、更新、删除仓库文件 | | 👤 用户 | 查看用户资料和信息 | | 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 | | 📋 项目管理 | Sprint 管理、看板、周报 | @@ -550,6 +551,29 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --ye ``` > 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。 +### 文件操作 + +`file` 无需克隆即可读写仓库文件内容,非常适合需要读取或修改单个文件的 AI Agent。目录列表和 README 查看请使用 `repo +tree` 和 `repo +readme`。 + +```bash +# 查看文件(--raw 仅输出解码后的文件内容,方便管道处理) +gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md +gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md --raw > README.md + +# 按文件名搜索 +gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller + +# 创建 / 更新文件(内容可内联或来自本地文件) +gitlink-cli file +create --owner me --repo proj --path docs/note.md -c "# 笔记" -b master -m "add note" +gitlink-cli file +update --owner me --repo proj --path docs/note.md --content-file note.md -b master + +# 提交到从 --branch 新建的分支 +gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b master --new-branch feature/docs + +# 删除文件 +gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note" +``` + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: diff --git a/doc/changes/file-shortcuts.md b/doc/changes/file-shortcuts.md new file mode 100644 index 0000000..16a0f49 --- /dev/null +++ b/doc/changes/file-shortcuts.md @@ -0,0 +1,59 @@ +# File Content Shortcuts + +## Summary + +Adds a `file` shortcut group so users and AI agents can read, search, and write +repository file contents without cloning or falling back to Raw API calls. +Directory listing and README viewing remain covered by `repo +tree` and +`repo +readme`. + +## Commands + +| Command | Purpose | +|---------|---------| +| `gitlink-cli file +view` | View a file's contents; `--raw` prints only the decoded content | +| `gitlink-cli file +search` | Search repository files by name | +| `gitlink-cli file +create` | Create a file and commit it to a branch | +| `gitlink-cli file +update` | Update a file and commit it to a branch | +| `gitlink-cli file +delete` | Delete a file and commit the removal to a branch | + +## Validation + +- `file +view` accepts `--ref` (branch, tag, or commit SHA) and `--raw`; `--raw` + fails with a clear error when the path is a directory. +- Write commands require `--path` and `--branch`; `--message` defaults to + ` ` when omitted. +- `file +create` / `file +update` accept exactly one of `--content` or + `--content-file`; providing both or neither is rejected before any request. +- `--new-branch` commits the change to a new branch created from `--branch`. +- File content is transported with `text` encoding (verified against production + gitlink.org.cn; the documented `base64` encoding is rejected there). + +## Tests + +Unit tests cover endpoint paths, query parameter mapping, request payload +construction, content-source validation, default commit messages, `--new-branch` +propagation, and raw content extraction from entries/README-shaped responses. + +## 中文说明 + +### 变更内容 + +- 新增 `file` 快捷命令组:`+view`(查看文件内容,`--raw` 仅输出解码后的正文)、 + `+search`(按文件名搜索)、`+create` / `+update` / `+delete`(通过 + contents/batch API 直接提交文件增删改)。 +- 无需克隆仓库即可读写文件,适合 AI Agent 读取 README、修改单个文件等场景 + (响应社区 issue:API 是否支持自动读取仓库内文件)。 +- 内容支持 `--content` 内联或 `--content-file` 从本地文件读取(text 编码, + 已在生产环境验证,文档中的 base64 编码在生产环境会被拒绝);支持 + `--new-branch` 提交到新分支。 +- 更新 README 与 README.zh-CN 的功能表和使用示例。 + +### 验证 + +- `go test ./...` +- `go vet ./...` +- `go run . file --help` +- `go run . file +view --help` +- 在生产 gitlink.org.cn 真实仓库验证 `+view --raw`、`+search`、`+create`、 + `+update`、`+delete` 全链路 diff --git a/shortcuts/file/file.go b/shortcuts/file/file.go new file mode 100644 index 0000000..1a62b2d --- /dev/null +++ b/shortcuts/file/file.go @@ -0,0 +1,253 @@ +package file + +import ( + "encoding/base64" + "fmt" + "net/url" + "os" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns all file shortcuts. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + viewShortcut(), + searchShortcut(), + writeShortcut("create", "Create a new file in the repository"), + writeShortcut("update", "Update an existing file in the repository"), + deleteShortcut(), + } +} + +func refFlag() common.Flag { + return common.Flag{Name: "ref", Usage: "Branch, tag, or commit SHA (defaults to the default branch)"} +} + +func viewShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "view", + Description: "View the contents of a file", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + refFlag(), + {Name: "raw", Usage: "Print only the decoded file content", Bool: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", path) + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + if ctx.Arg("raw") == "true" { + return printRawContent(env.Data) + } + return ctx.Output(env) + }, + } +} + +func searchShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "search", + Description: "Search files in the repository by name", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, + refFlag(), + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } + q := url.Values{} + q.Set("search", keyword) + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func writeShortcut(action, description string) *common.Shortcut { + return &common.Shortcut{ + Name: action, + Description: description, + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "content", Short: "c", Usage: "File content"}, + {Name: "content-file", Usage: "Read file content from a local file"}, + {Name: "branch", Short: "b", Usage: "Branch to commit to", Required: true}, + {Name: "new-branch", Usage: "Create a new branch from --branch for the commit"}, + {Name: "message", Short: "m", Usage: "Commit message"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + branch, err := ctx.RequireArg("branch") + if err != nil { + return err + } + content, err := resolveContent(ctx) + if err != nil { + return err + } + message := ctx.Arg("message") + if message == "" { + message = fmt.Sprintf("%s %s", action, path) + } + payload := map[string]interface{}{ + "files": []map[string]interface{}{ + { + "action_type": action, + "file_path": path, + "content": content, + "encoding": "text", + }, + }, + "branch": branch, + "message": message, + } + if nb := ctx.Arg("new-branch"); nb != "" { + payload["new_branch"] = nb + } + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func deleteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "delete", + Description: "Delete a file from the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "branch", Short: "b", Usage: "Branch to commit to", Required: true}, + {Name: "new-branch", Usage: "Create a new branch from --branch for the commit"}, + {Name: "message", Short: "m", Usage: "Commit message"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + branch, err := ctx.RequireArg("branch") + if err != nil { + return err + } + message := ctx.Arg("message") + if message == "" { + message = fmt.Sprintf("delete %s", path) + } + payload := map[string]interface{}{ + "files": []map[string]interface{}{ + { + "action_type": "delete", + "file_path": path, + "content": "", + "encoding": "text", + }, + }, + "branch": branch, + "message": message, + } + if nb := ctx.Arg("new-branch"); nb != "" { + payload["new_branch"] = nb + } + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +// resolveContent reads file content from --content or --content-file. +func resolveContent(ctx *common.RuntimeContext) (string, error) { + content := ctx.Arg("content") + contentFile := ctx.Arg("content-file") + if content != "" && contentFile != "" { + return "", fmt.Errorf("use only one of --content or --content-file") + } + if contentFile != "" { + data, err := os.ReadFile(contentFile) + if err != nil { + return "", fmt.Errorf("read content file: %w", err) + } + return string(data), nil + } + if content == "" { + return "", fmt.Errorf("one of --content or --content-file is required") + } + return content, nil +} + +// printRawContent extracts and prints the decoded file content from an API +// response (entries object, readme object, or a bare content field). +func printRawContent(data interface{}) error { + content, encoding, ok := extractContent(data) + if !ok { + return fmt.Errorf("no file content in response (is the path a directory?)") + } + if encoding == "base64" { + if decoded, err := base64.StdEncoding.DecodeString(content); err == nil { + fmt.Print(string(decoded)) + return nil + } + } + fmt.Print(content) + return nil +} + +func extractContent(data interface{}) (content, encoding string, ok bool) { + m, isMap := data.(map[string]interface{}) + if !isMap { + return "", "", false + } + if entries, has := m["entries"]; has { + if em, isEM := entries.(map[string]interface{}); isEM { + m = em + } + } + c, has := m["content"].(string) + if !has { + return "", "", false + } + if t, hasType := m["type"].(string); hasType && t != "file" { + return "", "", false + } + enc, _ := m["encoding"].(string) + return c, enc, true +} diff --git a/shortcuts/file/file_test.go b/shortcuts/file/file_test.go new file mode 100644 index 0000000..ef3e5f4 --- /dev/null +++ b/shortcuts/file/file_test.go @@ -0,0 +1,221 @@ +package file + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestFileView(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("filepath"); got != "README.md" { + t.Fatalf("filepath query = %q, want README.md", got) + } + writeJSON(t, w, map[string]interface{}{ + "entries": map[string]interface{}{ + "name": "README.md", "type": "file", "content": "# hello", + }, + }) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "view", map[string]string{"path": "README.md"}); err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestFileSearch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/files.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("search"); got != "main" { + t.Fatalf("search query = %q, want main", got) + } + writeJSON(t, w, []interface{}{}) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "search", map[string]string{"keyword": "main"}); err != nil { + t.Fatalf("search shortcut failed: %v", err) + } +} + +func TestFileCreate(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&payload) + writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc"}}) + })) + defer server.Close() + + err := runFileShortcut(t, server, "create", map[string]string{ + "path": "notes.md", "content": "hello", "branch": "master", "message": "add notes", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if payload["branch"] != "master" || payload["message"] != "add notes" { + t.Fatalf("payload = %v", payload) + } + files := payload["files"].([]interface{}) + f := files[0].(map[string]interface{}) + if f["action_type"] != "create" || f["file_path"] != "notes.md" || f["encoding"] != "text" { + t.Fatalf("file entry = %v", f) + } + if f["content"] != "hello" { + t.Fatalf("content = %v, want hello", f["content"]) + } +} + +func TestFileUpdateFromContentFile(t *testing.T) { + dir := t.TempDir() + local := filepath.Join(dir, "input.txt") + os.WriteFile(local, []byte("updated"), 0600) + + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&payload) + writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "def"}}) + })) + defer server.Close() + + err := runFileShortcut(t, server, "update", map[string]string{ + "path": "notes.md", "content-file": local, "branch": "master", "new-branch": "feature/x", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + if payload["new_branch"] != "feature/x" { + t.Fatalf("new_branch = %v", payload["new_branch"]) + } + f := payload["files"].([]interface{})[0].(map[string]interface{}) + if f["action_type"] != "update" { + t.Fatalf("action_type = %v", f["action_type"]) + } + if f["content"] != "updated" { + t.Fatalf("content = %v, want updated", f["content"]) + } +} + +func TestFileDelete(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&payload) + writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "ghi"}}) + })) + defer server.Close() + + err := runFileShortcut(t, server, "delete", map[string]string{ + "path": "notes.md", "branch": "master", + }) + if err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } + f := payload["files"].([]interface{})[0].(map[string]interface{}) + if f["action_type"] != "delete" || f["file_path"] != "notes.md" { + t.Fatalf("file entry = %v", f) + } + if payload["message"] != "delete notes.md" { + t.Fatalf("default message = %v", payload["message"]) + } +} + +func TestFileCreateContentConflicts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not reach server") + })) + defer server.Close() + + err := runFileShortcut(t, server, "create", map[string]string{ + "path": "a", "content": "x", "content-file": "y", "branch": "master", + }) + if err == nil { + t.Fatal("expected error for both --content and --content-file") + } + + err = runFileShortcut(t, server, "create", map[string]string{ + "path": "a", "branch": "master", + }) + if err == nil { + t.Fatal("expected error when no content source is provided") + } +} + +func TestExtractContent(t *testing.T) { + tests := []struct { + name string + data interface{} + wantContent string + wantEncoding string + wantOK bool + }{ + {"entries object", map[string]interface{}{"entries": map[string]interface{}{"type": "file", "content": "abc"}}, "abc", "", true}, + {"readme object", map[string]interface{}{"type": "file", "content": "abc", "encoding": "base64"}, "abc", "base64", true}, + {"directory", map[string]interface{}{"type": "dir", "content": "x"}, "", "", false}, + {"no content", map[string]interface{}{"type": "file"}, "", "", false}, + {"not a map", []interface{}{}, "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, encoding, ok := extractContent(tt.data) + if content != tt.wantContent || encoding != tt.wantEncoding || ok != tt.wantOK { + t.Fatalf("extractContent() = (%q, %q, %v), want (%q, %q, %v)", + content, encoding, ok, tt.wantContent, tt.wantEncoding, tt.wantOK) + } + }) + } +} + +func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findFileShortcut(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 findFileShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + 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) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..05fa84f 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -9,6 +9,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" + "github.com/gitlink-org/gitlink-cli/shortcuts/file" "github.com/gitlink-org/gitlink-cli/shortcuts/health" "github.com/gitlink-org/gitlink-cli/shortcuts/ignore" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" @@ -53,6 +54,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), + "file": file.Shortcuts(), "webhook": webhook.Shortcuts(tr), "wiki": wiki.Shortcuts(), "health": health.Shortcuts(tr), @@ -78,6 +80,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), + "file": "Repository file content operations", "webhook": tr.T("cmd.webhook.short"), "wiki": "Wiki page management", "health": "Project health data collection", diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..9238ebd 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", "wiki", + "dataset", "file", "health", "ignore", "wiki", } groupSet := map[string]bool{} From 27053f66d4f60c8f787f7ea933a20d5435a55420 Mon Sep 17 00:00:00 2001 From: farmyobutu5233 Date: Sun, 5 Jul 2026 09:43:57 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs(skills):=20=E6=96=B0=E5=A2=9E=20gitlin?= =?UTF-8?q?k-file=20Skill=20=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- skills/README.md | 3 ++ skills/gitlink-file/SKILL.md | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 skills/gitlink-file/SKILL.md diff --git a/skills/README.md b/skills/README.md index d507074..5ccc902 100644 --- a/skills/README.md +++ b/skills/README.md @@ -103,6 +103,8 @@ skills/ │ └── SKILL.md # Pipeline 操作指南 ├── gitlink-wiki/ # Wiki 页面管理 │ └── SKILL.md # Wiki 操作指南 +├── gitlink-file/ # 文件内容操作 +│ └── SKILL.md # 文件操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 ├── gitlink-health/ # 项目健康度分析 @@ -144,6 +146,7 @@ skills/ | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | | **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` | | **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | +| **gitlink-file** | 文件内容操作(无需克隆) | `file +view`, `file +search`, `file +create`, `file +update`, `file +delete` | | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | | **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | | **gitlink-health** | 开源项目健康度 | 详情见SKILL.md | diff --git a/skills/gitlink-file/SKILL.md b/skills/gitlink-file/SKILL.md new file mode 100644 index 0000000..a4eb90f --- /dev/null +++ b/skills/gitlink-file/SKILL.md @@ -0,0 +1,57 @@ +--- +name: gitlink-file +version: 1.0.0 +description: "文件内容操作:无需克隆即可查看、搜索、创建、更新、删除 GitLink 仓库文件。当用户需要读取或修改仓库中的单个文件(如 README、配置文件)而不想克隆仓库时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli file --help" +--- + +# gitlink-file(文件内容操作) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — `file +create` / `+update` / `+delete` 会直接产生提交,执行前务必先确认用户意图,并优先使用 `--new-branch` 提交到新分支。** + +> 目录列表和 README 查看请使用 `repo +tree` 和 `repo +readme`(见 [`../gitlink-repo/SKILL.md`](../gitlink-repo/SKILL.md))。 + +## Shortcuts + +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `file +view` | 查看文件内容;`--raw` 仅输出解码后的正文 | 否(公开项目) | +| `file +search` | 按文件名搜索仓库文件 | 否(公开项目) | +| `file +create` | 创建文件并提交到指定分支 | 是 | +| `file +update` | 更新文件并提交到指定分支 | 是 | +| `file +delete` | 删除文件并提交 | 是 | + +## 示例 + +```bash +# 查看文件(--raw 直接输出正文,可管道/重定向) +gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md +gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md --raw > README.md + +# 指定分支/标签/提交 +gitlink-cli file +view --owner Gitlink --repo forgeplus --path app/models/user.rb --ref develop + +# 按文件名搜索 +gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller + +# 创建文件(内容内联或来自本地文件,二选一) +gitlink-cli file +create --owner me --repo proj --path docs/note.md -c "# 笔记" -b master -m "add note" +gitlink-cli file +create --owner me --repo proj --path docs/note.md --content-file note.md -b master + +# 更新文件并提交到从 master 新建的分支(推荐,便于走 PR 流程) +gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b master --new-branch feature/docs + +# 删除文件 +gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note" +``` + +## Notes + +- `file +view` 调用 `/api/{owner}/{repo}/sub_entries`;`+search` 调用 `/api/{owner}/{repo}/files`。 +- 写操作调用 `/api/v1/{owner}/{repo}/contents/batch`,内容使用 `text` 编码传输(生产环境不接受 base64)。 +- `--message` 缺省为 ` `;`--new-branch` 从 `--branch` 新建分支并提交到新分支。 +- 修改公共仓库文件时,优先 `--new-branch` + `pr +create`(见 [`../gitlink-pr/SKILL.md`](../gitlink-pr/SKILL.md)),避免直接推主分支。 From 40bfc64b8bdf59731c6317d1e58bec36fd01a8bf Mon Sep 17 00:00:00 2001 From: farmyobutu5233 Date: Sun, 5 Jul 2026 10:50:12 +0000 Subject: [PATCH 3/5] feat(file): wire file shortcuts into i18n with en-US/zh-CN locales Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/i18n/locales/en-US.json | 14 +++++++ internal/i18n/locales/zh-CN.json | 14 +++++++ shortcuts/file/file.go | 71 ++++++++++++++++++-------------- shortcuts/register.go | 4 +- 4 files changed, 70 insertions(+), 33 deletions(-) diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..26476b2 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -34,6 +34,12 @@ "cmd.dataset.view.short": "View a repository's dataset", "cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.", "cmd.doctor.short": "Diagnose gitlink-cli environment problems", + "cmd.file.create.short": "Create a new file in the repository", + "cmd.file.delete.short": "Delete a file from the repository", + "cmd.file.search.short": "Search files in the repository by name", + "cmd.file.short": "Repository file content operations", + "cmd.file.update.short": "Update an existing file in the repository", + "cmd.file.view.short": "View the contents of a file", "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", @@ -145,6 +151,14 @@ "flag.description": "Description", "flag.doctor.skip_network": "Skip authenticated API connectivity checks", "flag.dry_run": "Preview the request without creating it", + "flag.file.branch": "Branch to commit to", + "flag.file.content": "File content", + "flag.file.content_file": "Read file content from a local file", + "flag.file.message": "Commit message", + "flag.file.new_branch": "Create a new branch from --branch for the commit", + "flag.file.path": "File path", + "flag.file.raw": "Print only the decoded file content", + "flag.file.ref": "Branch, tag, or commit SHA (defaults to the default branch)", "flag.format": "Output format: json, table, yaml (default: table)", "flag.issue.add_label": "Label to add to each matching issue", "flag.issue.assignee": "Assignee login", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..b283f9d 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -34,6 +34,12 @@ "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", + "cmd.file.create.short": "在仓库中创建文件", + "cmd.file.delete.short": "删除仓库中的文件", + "cmd.file.search.short": "按文件名搜索仓库文件", + "cmd.file.short": "仓库文件内容操作", + "cmd.file.update.short": "更新仓库中的文件", + "cmd.file.view.short": "查看文件内容", "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", @@ -145,6 +151,14 @@ "flag.description": "描述", "flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查", "flag.dry_run": "预览请求,不实际创建", + "flag.file.branch": "提交到的分支", + "flag.file.content": "文件内容", + "flag.file.content_file": "从本地文件读取内容", + "flag.file.message": "提交信息", + "flag.file.new_branch": "从 --branch 新建分支并提交", + "flag.file.path": "文件路径", + "flag.file.raw": "仅输出解码后的文件正文", + "flag.file.ref": "分支、标签或提交 SHA(默认使用默认分支)", "flag.format": "输出格式:json、table、yaml(默认:table)", "flag.issue.add_label": "要添加到每个匹配议题的标签", "flag.issue.assignee": "负责人登录名", diff --git a/shortcuts/file/file.go b/shortcuts/file/file.go index 1a62b2d..bccf48c 100644 --- a/shortcuts/file/file.go +++ b/shortcuts/file/file.go @@ -6,32 +6,41 @@ import ( "net/url" "os" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) // Shortcuts returns all file shortcuts. -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ - viewShortcut(), - searchShortcut(), - writeShortcut("create", "Create a new file in the repository"), - writeShortcut("update", "Update an existing file in the repository"), - deleteShortcut(), + viewShortcut(tr), + searchShortcut(tr), + writeShortcut(tr, "create"), + writeShortcut(tr, "update"), + deleteShortcut(tr), } } -func refFlag() common.Flag { - return common.Flag{Name: "ref", Usage: "Branch, tag, or commit SHA (defaults to the default branch)"} +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() } -func viewShortcut() *common.Shortcut { +func refFlag(tr *i18n.Translator) common.Flag { + return common.Flag{Name: "ref", Usage: tr.T("flag.file.ref")} +} + +func viewShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "view", - Description: "View the contents of a file", + Description: tr.T("cmd.file.view.short"), Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: "File path", Required: true}, - refFlag(), - {Name: "raw", Usage: "Print only the decoded file content", Bool: true}, + {Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true}, + refFlag(tr), + {Name: "raw", Usage: tr.T("flag.file.raw"), Bool: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -58,13 +67,13 @@ func viewShortcut() *common.Shortcut { } } -func searchShortcut() *common.Shortcut { +func searchShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "search", - Description: "Search files in the repository by name", + Description: tr.T("cmd.file.search.short"), Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, - refFlag(), + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, + refFlag(tr), }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -88,17 +97,17 @@ func searchShortcut() *common.Shortcut { } } -func writeShortcut(action, description string) *common.Shortcut { +func writeShortcut(tr *i18n.Translator, action string) *common.Shortcut { return &common.Shortcut{ Name: action, - Description: description, + Description: tr.T("cmd.file." + action + ".short"), Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: "File path", Required: true}, - {Name: "content", Short: "c", Usage: "File content"}, - {Name: "content-file", Usage: "Read file content from a local file"}, - {Name: "branch", Short: "b", Usage: "Branch to commit to", Required: true}, - {Name: "new-branch", Usage: "Create a new branch from --branch for the commit"}, - {Name: "message", Short: "m", Usage: "Commit message"}, + {Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true}, + {Name: "content", Short: "c", Usage: tr.T("flag.file.content")}, + {Name: "content-file", Usage: tr.T("flag.file.content_file")}, + {Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true}, + {Name: "new-branch", Usage: tr.T("flag.file.new_branch")}, + {Name: "message", Short: "m", Usage: tr.T("flag.file.message")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -144,15 +153,15 @@ func writeShortcut(action, description string) *common.Shortcut { } } -func deleteShortcut() *common.Shortcut { +func deleteShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "delete", - Description: "Delete a file from the repository", + Description: tr.T("cmd.file.delete.short"), Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: "File path", Required: true}, - {Name: "branch", Short: "b", Usage: "Branch to commit to", Required: true}, - {Name: "new-branch", Usage: "Create a new branch from --branch for the commit"}, - {Name: "message", Short: "m", Usage: "Commit message"}, + {Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true}, + {Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true}, + {Name: "new-branch", Usage: tr.T("flag.file.new_branch")}, + {Name: "message", Short: "m", Usage: tr.T("flag.file.message")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { diff --git a/shortcuts/register.go b/shortcuts/register.go index 05fa84f..1a8340b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -54,7 +54,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), - "file": file.Shortcuts(), + "file": file.Shortcuts(tr), "webhook": webhook.Shortcuts(tr), "wiki": wiki.Shortcuts(), "health": health.Shortcuts(tr), @@ -80,7 +80,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), - "file": "Repository file content operations", + "file": tr.T("cmd.file.short"), "webhook": tr.T("cmd.webhook.short"), "wiki": "Wiki page management", "health": "Project health data collection", From 2d1e75da16e58de83c4d6ec692159e63f907d8da Mon Sep 17 00:00:00 2001 From: farmyobutu5233 Date: Sun, 5 Jul 2026 10:52:35 +0000 Subject: [PATCH 4/5] docs: note file shortcuts i18n coverage in change log Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/file-shortcuts.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/changes/file-shortcuts.md b/doc/changes/file-shortcuts.md index 16a0f49..bc61f07 100644 --- a/doc/changes/file-shortcuts.md +++ b/doc/changes/file-shortcuts.md @@ -49,6 +49,11 @@ propagation, and raw content extraction from entries/README-shaped responses. `--new-branch` 提交到新分支。 - 更新 README 与 README.zh-CN 的功能表和使用示例。 +### 国际化 + +命令与全部 flag 文案已接入 i18n(`cmd.file.*` / `flag.file.*`,含 en-US 与 +zh-CN 两套 locale),`GITLINK_LANG=zh-CN` 下 `file --help` 输出中文帮助。 + ### 验证 - `go test ./...` From 9588c113cf5cd0fa57549adbef6caec46e2d4d42 Mon Sep 17 00:00:00 2001 From: 1os21ka23r9navae6mrro <1os21ka23r9navae6mrro@gmail.com> Date: Wed, 8 Jul 2026 14:52:09 +0000 Subject: [PATCH 5/5] feat(file): add +batch for multi-file single-commit operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contents/batch endpoint accepts multiple file operations per commit, but each existing shortcut sent exactly one. file +batch takes a JSON spec (array of {action_type, file_path, content, encoding}) and applies them atomically in one commit: gitlink-cli file +batch -s spec.json -b master -m 'batch ops' Client-side validation: action_type must be create|update|delete, file_path required. content/encoding are normalized for delete entries — the server rejects entries missing content with '请输入正确的文件参数Files' (production-reproduced). Production-verified on gitlink.org.cn: 2-file create in one commit, then 2-file delete in one commit. Unit test covers payload shape, delete normalization, and action_type validation. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 5 +++ internal/i18n/locales/en-US.json | 2 + internal/i18n/locales/zh-CN.json | 2 + shortcuts/file/file.go | 73 ++++++++++++++++++++++++++++++++ shortcuts/file/file_test.go | 42 ++++++++++++++++++ 5 files changed, 124 insertions(+) diff --git a/README.md b/README.md index 8f52be1..172f348 100644 --- a/README.md +++ b/README.md @@ -700,6 +700,11 @@ gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b # Delete a file gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note" + +# Multiple file operations in a single commit (JSON spec) +# spec.json: [{"action_type":"create","file_path":"a.txt","content":"A"}, +# {"action_type":"delete","file_path":"old.txt"}] +gitlink-cli file +batch --owner me --repo proj -s spec.json -b master -m "batch ops" ``` ### Raw API diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 26476b2..b7a8977 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -34,6 +34,7 @@ "cmd.dataset.view.short": "View a repository's dataset", "cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.", "cmd.doctor.short": "Diagnose gitlink-cli environment problems", + "cmd.file.batch.short": "Apply multiple file operations in a single commit", "cmd.file.create.short": "Create a new file in the repository", "cmd.file.delete.short": "Delete a file from the repository", "cmd.file.search.short": "Search files in the repository by name", @@ -151,6 +152,7 @@ "flag.description": "Description", "flag.doctor.skip_network": "Skip authenticated API connectivity checks", "flag.dry_run": "Preview the request without creating it", + "flag.file.batch_spec": "Path to a JSON array of file operations: [{action_type, file_path, content, encoding}]", "flag.file.branch": "Branch to commit to", "flag.file.content": "File content", "flag.file.content_file": "Read file content from a local file", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index b283f9d..6e3a419 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -34,6 +34,7 @@ "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", + "cmd.file.batch.short": "在单个提交中应用多个文件操作", "cmd.file.create.short": "在仓库中创建文件", "cmd.file.delete.short": "删除仓库中的文件", "cmd.file.search.short": "按文件名搜索仓库文件", @@ -151,6 +152,7 @@ "flag.description": "描述", "flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查", "flag.dry_run": "预览请求,不实际创建", + "flag.file.batch_spec": "文件操作 JSON 数组路径:[{action_type, file_path, content, encoding}]", "flag.file.branch": "提交到的分支", "flag.file.content": "文件内容", "flag.file.content_file": "从本地文件读取内容", diff --git a/shortcuts/file/file.go b/shortcuts/file/file.go index bccf48c..5ee6be2 100644 --- a/shortcuts/file/file.go +++ b/shortcuts/file/file.go @@ -2,6 +2,7 @@ package file import ( "encoding/base64" + "encoding/json" "fmt" "net/url" "os" @@ -19,6 +20,78 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { writeShortcut(tr, "create"), writeShortcut(tr, "update"), deleteShortcut(tr), + batchShortcut(tr), + } +} + +func batchShortcut(tr *i18n.Translator) *common.Shortcut { + return &common.Shortcut{ + Name: "batch", + Description: tr.T("cmd.file.batch.short"), + Flags: []common.Flag{ + {Name: "spec", Short: "s", Usage: tr.T("flag.file.batch_spec"), Required: true}, + {Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true}, + {Name: "new-branch", Usage: tr.T("flag.file.new_branch")}, + {Name: "message", Short: "m", Usage: tr.T("flag.file.message"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + specPath, err := ctx.RequireArg("spec") + if err != nil { + return err + } + branch, err := ctx.RequireArg("branch") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + data, err := os.ReadFile(specPath) + if err != nil { + return fmt.Errorf("read spec file: %w", err) + } + var files []map[string]interface{} + if err := json.Unmarshal(data, &files); err != nil { + return fmt.Errorf("spec must be a JSON array of file operations: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("spec contains no file operations") + } + for i, f := range files { + action, _ := f["action_type"].(string) + switch action { + case "create", "update", "delete": + default: + return fmt.Errorf("files[%d]: action_type must be create, update, or delete; got %q", i, action) + } + if path, _ := f["file_path"].(string); path == "" { + return fmt.Errorf("files[%d]: file_path is required", i) + } + if _, ok := f["content"]; !ok { + f["content"] = "" + } + if _, ok := f["encoding"]; !ok { + f["encoding"] = "text" + } + } + payload := map[string]interface{}{ + "files": files, + "branch": branch, + "message": message, + } + if nb := ctx.Arg("new-branch"); nb != "" { + payload["new_branch"] = nb + } + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, } } diff --git a/shortcuts/file/file_test.go b/shortcuts/file/file_test.go index ef3e5f4..4ba62bf 100644 --- a/shortcuts/file/file_test.go +++ b/shortcuts/file/file_test.go @@ -219,3 +219,45 @@ func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { t.Fatalf("failed to write response: %v", err) } } + +func TestFileBatchPostsAllOperations(t *testing.T) { + dir := t.TempDir() + spec := filepath.Join(dir, "spec.json") + os.WriteFile(spec, []byte(`[ + {"action_type": "create", "file_path": "a.txt", "content": "A"}, + {"action_type": "delete", "file_path": "b.txt"} +]`), 0600) + + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&payload) + writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc"}}) + })) + defer server.Close() + + err := runFileShortcut(t, server, "batch", map[string]string{ + "spec": spec, "branch": "master", "message": "batch ops", + }) + if err != nil { + t.Fatalf("batch shortcut failed: %v", err) + } + files := payload["files"].([]interface{}) + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d", len(files)) + } + del := files[1].(map[string]interface{}) + if del["action_type"] != "delete" || del["content"] != "" || del["encoding"] != "text" { + t.Fatalf("delete entry not normalized: %v", del) + } + + bad := filepath.Join(dir, "bad.json") + os.WriteFile(bad, []byte(`[{"action_type": "rename", "file_path": "x"}]`), 0600) + if err := runFileShortcut(t, server, "batch", map[string]string{ + "spec": bad, "branch": "master", "message": "m", + }); err == nil { + t.Fatal("expected error for invalid action_type") + } +}