Merge PR #330: feat(file): 新增 file 快捷命令组,无需克隆即可读写仓库文件
# Conflicts: # README.zh-CN.md # shortcuts/file/file.go # shortcuts/file/file_test.go # shortcuts/register.go # shortcuts/register_test.go
This commit is contained in:
commit
9308bef3cb
31
README.md
31
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 <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:
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@
|
|||
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
|
||||
| 🔍 搜索 | 搜索仓库、用户 |
|
||||
| 📊 数据集 | 按项目查询科研数据集 |
|
||||
| 📄 文件 | 无需克隆即可查看、搜索、创建、更新、删除仓库文件 |
|
||||
| 👤 用户 | 查看用户资料和信息 |
|
||||
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
|
||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
||||
|
|
@ -550,23 +551,27 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <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 等) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`<action> <path>` 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` 全链路
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "负责人登录名",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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` 缺省为 `<action> <path>`;`--new-branch` 从 `--branch` 新建分支并提交到新分支。
|
||||
- 修改公共仓库文件时,优先 `--new-branch` + `pr +create`(见 [`../gitlink-pr/SKILL.md`](../gitlink-pr/SKILL.md)),避免直接推主分支。
|
||||
Loading…
Reference in New Issue