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{}