diff --git a/README.md b/README.md index f3924b2..497eb4c 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔔 Webhook | Manage repo webhooks and test deliveries | | 🔍 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 | @@ -705,6 +706,36 @@ 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" + +# 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 For endpoints not covered by shortcuts, use the Raw API directly: diff --git a/README.zh-CN.md b/README.zh-CN.md index 9b773ee..5f9e2f1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -116,6 +116,7 @@ | 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | +| 📄 文件 | 无需克隆即可查看、搜索、创建、更新、删除仓库文件 | | 👤 用户 | 查看用户资料和信息 | | 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 | | 📋 项目管理 | Sprint 管理、看板、周报 | @@ -550,23 +551,27 @@ 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 -# 查看当前登录用户 -gitlink-cli user +me +# 查看文件(--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 -# 列出 SSH 公钥 -gitlink-cli user +keys +# 按文件名搜索 +gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller -# 通过内容或文件添加 SSH 公钥 -gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..." -gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub -gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub +# 创建 / 更新文件(内容可内联或来自本地文件) +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 -# 删除 SSH 公钥 -gitlink-cli user +delete-key --id 123 +# 提交到从 --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 @@ -639,7 +644,7 @@ git push gitlink | `gitlink-ci` | CI/CD 操作(构建、日志等) | | `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) | | `gitlink-search` | 搜索功能(仓库、用户等) | -| `gitlink-user` | 用户管理(个人信息、SSH 公钥等) | +| `gitlink-user` | 用户管理(个人信息等) | | `gitlink-pm` | 项目管理(Sprint、看板、周报等) | | `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) | diff --git a/doc/changes/file-shortcuts.md b/doc/changes/file-shortcuts.md new file mode 100644 index 0000000..bc61f07 --- /dev/null +++ b/doc/changes/file-shortcuts.md @@ -0,0 +1,64 @@ +# 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 的功能表和使用示例。 + +### 国际化 + +命令与全部 flag 文案已接入 i18n(`cmd.file.*` / `flag.file.*`,含 en-US 与 +zh-CN 两套 locale),`GITLINK_LANG=zh-CN` 下 `file --help` 输出中文帮助。 + +### 验证 + +- `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/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 067d948..0c2e37d 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -43,6 +43,13 @@ "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", + "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", @@ -160,6 +167,15 @@ "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", + "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 94fcfda..5eb1afc 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -43,6 +43,13 @@ "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": "按文件名搜索仓库文件", + "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", @@ -160,6 +167,15 @@ "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": "从本地文件读取内容", + "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 145b3b6..5ee6be2 100644 --- a/shortcuts/file/file.go +++ b/shortcuts/file/file.go @@ -2,178 +2,334 @@ package file import ( "encoding/base64" + "encoding/json" "fmt" "net/url" + "os" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +// Shortcuts returns all file shortcuts. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ - { - Name: "list", - Description: "List repository files", - Flags: []common.Flag{ - {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, - {Name: "search", Short: "s", Usage: "Search keyword"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - if ref := ctx.Arg("ref"); ref != "" { - q.Set("ref", ref) - } - if search := ctx.Arg("search"); search != "" { - q.Set("search", search) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) - if err != nil { - return err - } - return ctx.Output(env) - }, + viewShortcut(tr), + searchShortcut(tr), + 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}, }, - { - Name: "tree", - Description: "List file tree for a branch or commit", - Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"}, - {Name: "recursive", Usage: "Recursively list all files", Bool: true, Default: "false"}, - {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 + 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) } - sha := ctx.Arg("sha") - if sha == "" { - sha = "master" + if path, _ := f["file_path"].(string); path == "" { + return fmt.Errorf("files[%d]: file_path is required", i) } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("recursive") == "true" { - q.Set("recursive", "true") + if _, ok := f["content"]; !ok { + f["content"] = "" } - env, err := ctx.CallAPIWithQuery("GET", - fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q) - if err != nil { - return err + if _, ok := f["encoding"]; !ok { + f["encoding"] = "text" } - return ctx.Output(env) - }, - }, - { - Name: "get", - Description: "Get file or directory contents", - Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: "File or directory path", Required: true}, - {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - filePath, err := ctx.RequireArg("path") - if err != nil { - return err - } - q := url.Values{} - q.Set("filepath", filePath) - q.Set("ref", ctx.Arg("ref")) - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "create", - Description: "Create a new file in the repository", - Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: "File path", Required: true}, - {Name: "content", Short: "c", Usage: "File content (plain text, auto Base64 encoded)", Required: true}, - {Name: "message", Short: "m", Usage: "Commit message", Required: true}, - {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - filePath, err := ctx.RequireArg("path") - if err != nil { - return err - } - content, err := ctx.RequireArg("content") - if err != nil { - return err - } - message, err := ctx.RequireArg("message") - if err != nil { - return err - } - branch := ctx.Arg("branch") - if branch == "" { - branch = "master" - } - body := map[string]interface{}{ - "filepath": filePath, - "content": base64.StdEncoding.EncodeToString([]byte(content)), - "message": message, - "branch": branch, - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "delete", - Description: "Delete a file from the repository", - Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: "File path", Required: true}, - {Name: "sha", Short: "s", Usage: "File blob SHA (from file +list)", Required: true}, - {Name: "message", Short: "m", Usage: "Commit message", Required: true}, - {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - filePath, err := ctx.RequireArg("path") - if err != nil { - return err - } - sha, err := ctx.RequireArg("sha") - if err != nil { - return err - } - message, err := ctx.RequireArg("message") - if err != nil { - return err - } - branch := ctx.Arg("branch") - if branch == "" { - branch = "master" - } - body := map[string]interface{}{ - "filepath": filePath, - "sha": sha, - "message": message, - "branch": branch, - } - env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body) - if err != nil { - return err - } - return ctx.Output(env) - }, + } + 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) }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + +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: tr.T("cmd.file.view.short"), + Flags: []common.Flag{ + {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 { + 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(tr *i18n.Translator) *common.Shortcut { + return &common.Shortcut{ + Name: "search", + Description: tr.T("cmd.file.search.short"), + Flags: []common.Flag{ + {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 { + 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(tr *i18n.Translator, action string) *common.Shortcut { + return &common.Shortcut{ + Name: action, + Description: tr.T("cmd.file." + action + ".short"), + Flags: []common.Flag{ + {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 { + 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(tr *i18n.Translator) *common.Shortcut { + return &common.Shortcut{ + Name: "delete", + Description: tr.T("cmd.file.delete.short"), + Flags: []common.Flag{ + {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 { + 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 index 3ef1bf8..4ba62bf 100644 --- a/shortcuts/file/file_test.go +++ b/shortcuts/file/file_test.go @@ -4,191 +4,208 @@ 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 TestFileList(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) - } - writeJSON(t, w, []map[string]interface{}{ - {"name": "README.md", "path": "README.md", "type": "file"}, - {"name": "src", "path": "src", "type": "dir"}, - }) - })) - defer server.Close() - - if err := runFileShortcut(t, server, "list", map[string]string{}); err != nil { - t.Fatalf("list failed: %v", err) - } -} - -func TestFileListWithRef(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("ref") != "dev" { - t.Fatalf("expected ref=dev, got %s", r.URL.Query().Get("ref")) - } - writeJSON(t, w, []map[string]interface{}{}) - })) - defer server.Close() - - if err := runFileShortcut(t, server, "list", map[string]string{"ref": "dev"}); err != nil { - t.Fatalf("list with ref failed: %v", err) - } -} - -func TestFileTree(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/git/trees/master.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - writeJSON(t, w, map[string]interface{}{ - "total_count": 1, - "entries": []map[string]interface{}{{"name": "main.go", "type": "file"}}, - }) - })) - defer server.Close() - - if err := runFileShortcut(t, server, "tree", map[string]string{}); err != nil { - t.Fatalf("tree failed: %v", err) - } -} - -func TestFileTreeRecursive(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("recursive") != "true" { - t.Fatalf("expected recursive=true") - } - writeJSON(t, w, map[string]interface{}{"entries": []map[string]interface{}{}}) - })) - defer server.Close() - - if err := runFileShortcut(t, server, "tree", map[string]string{"recursive": "true"}); err != nil { - t.Fatalf("tree recursive failed: %v", err) - } -} - -func TestFileGet(t *testing.T) { +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 r.URL.Query().Get("filepath") != "README.md" { - t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath")) + 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{}{"name": "README.md", "type": "file"}) + writeJSON(t, w, map[string]interface{}{ + "entries": map[string]interface{}{ + "name": "README.md", "type": "file", "content": "# hello", + }, + }) })) defer server.Close() - err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"}) - if err != nil { - t.Fatalf("get failed: %v", err) + if err := runFileShortcut(t, server, "view", map[string]string{"path": "README.md"}); err != nil { + t.Fatalf("view shortcut failed: %v", err) } } -func TestFileGetRequiresPath(t *testing.T) { +func TestFileSearch(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no request should be made without --path") + 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() - err := runFileShortcut(t, server, "get", map[string]string{}) - if err == nil { - t.Fatal("expected error for missing --path") + 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 == "/owner/repo/create_file.json" { - var payload map[string]interface{} - json.NewDecoder(r.Body).Decode(&payload) - if payload["filepath"] != "test.txt" { - t.Fatalf("expected filepath=test.txt, got %v", payload["filepath"]) - } - if payload["message"] != "add test" { - t.Fatalf("expected message=add test, got %v", payload["message"]) - } - if payload["branch"] != "master" { - t.Fatalf("expected branch=master, got %v", payload["branch"]) - } - if _, ok := payload["content"].(string); !ok || payload["content"] == "" { - t.Fatal("content should be a non-empty Base64 string") - } - writeJSON(t, w, map[string]interface{}{"name": "test.txt", "sha": "abc123"}) - return + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - 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": "test.txt", "content": "hello world", "message": "add test", + "path": "notes.md", "content": "hello", "branch": "master", "message": "add notes", }) if err != nil { - t.Fatalf("create failed: %v", err) + 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 == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json" { - var payload map[string]interface{} - json.NewDecoder(r.Body).Decode(&payload) - if payload["filepath"] != "old.txt" { - t.Fatalf("expected filepath=old.txt, got %v", payload["filepath"]) - } - if payload["sha"] != "def456" { - t.Fatalf("expected sha=def456, got %v", payload["sha"]) - } - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - return + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - 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": "old.txt", "sha": "def456", "message": "remove old", + "path": "notes.md", "branch": "master", }) if err != nil { - t.Fatalf("delete failed: %v", err) + 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 TestFileDeleteRequiresPath(t *testing.T) { +func TestFileCreateContentConflicts(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no request should be made without --path") + t.Fatal("should not reach server") })) defer server.Close() - err := runFileShortcut(t, server, "delete", map[string]string{}) + 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 missing --path") + 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") } } -// === helpers === +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, + 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 _, s := range Shortcuts() { - if s.Name == name { - return s + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut } } t.Fatalf("shortcut %q not found", name) @@ -198,5 +215,49 @@ func findFileShortcut(t *testing.T, name string) *common.Shortcut { func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { t.Helper() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(payload) + if err := json.NewEncoder(w).Encode(payload); err != nil { + 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") + } } diff --git a/shortcuts/register.go b/shortcuts/register.go index c16356a..1a8340b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -4,28 +4,29 @@ import ( "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/action" "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" + "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" "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/license" "github.com/gitlink-org/gitlink-cli/shortcuts/member" - "github.com/gitlink-org/gitlink-cli/shortcuts/message" "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pipeline" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" + "github.com/gitlink-org/gitlink-cli/shortcuts/profile" "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" + "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) @@ -36,52 +37,54 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { tr = translators[0] } groups := map[string][]*common.Shortcut{ - "action": action.Shortcuts(tr), "repo": repo.Shortcuts(tr), "issue": issue.Shortcuts(tr), "label": label.Shortcuts(), "license": license.Shortcuts(), "member": member.Shortcuts(), - "message": message.Shortcuts(), "milestone": milestone.Shortcuts(), "pipeline": pipeline.Shortcuts(), "pr": pr.Shortcuts(tr), + "profile": profile.Shortcuts(tr), "release": release.Shortcuts(tr), "branch": branch.Shortcuts(tr), "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(), + "dataset": dataset.Shortcuts(tr), + "file": file.Shortcuts(tr), "webhook": webhook.Shortcuts(tr), + "wiki": wiki.Shortcuts(), "health": health.Shortcuts(tr), + "ignore": ignore.Shortcuts(), "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "action": tr.T("cmd.action.short"), "repo": tr.T("cmd.repo.short"), "issue": tr.T("cmd.issue.short"), "label": "Issue label operations", "license": "License operations", "member": "Repository member operations", - "message": "Message center operations", "milestone": "Milestone operations", "pipeline": "Pipeline operations", "pr": tr.T("cmd.pr.short"), + "profile": tr.T("cmd.profile.short"), "release": tr.T("cmd.release.short"), "branch": tr.T("cmd.branch.short"), "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", + "dataset": tr.T("cmd.dataset.short"), + "file": tr.T("cmd.file.short"), "webhook": tr.T("cmd.webhook.short"), + "wiki": "Wiki page management", "health": "Project health data collection", + "ignore": tr.T("cmd.ignore.short"), "workflow": "AI agent workflow analysis", } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 8e6f24e..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", "action", + "dataset", "file", "health", "ignore", "wiki", } groupSet := map[string]bool{} diff --git a/skills/README.md b/skills/README.md index b4d6d5b..16174d9 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)),避免直接推主分支。