feat(repo): 新增仓库文件批量提交命令
This commit is contained in:
parent
71ca2bb683
commit
31accd64d2
15
README.md
15
README.md
|
|
@ -103,7 +103,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
|
||||
| Category | Capabilities |
|
||||
|----------|-------------|
|
||||
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
|
||||
| 📦 Repo | List, create, fork, delete repositories, search files, batch commit file changes, view repo info, insights, and interactions |
|
||||
| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues |
|
||||
| 🔖 Label | Create, list, update, delete issue labels |
|
||||
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
||||
|
|
@ -227,6 +227,19 @@ gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
|||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
|
||||
|
||||
# Search repository files by name
|
||||
gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref main
|
||||
|
||||
# Commit one file change
|
||||
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
|
||||
--branch main --message "docs: update README" \
|
||||
--path README.md --content "# Project"
|
||||
|
||||
# Commit several file changes from a JSON operations file
|
||||
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
|
||||
--branch main --new-branch docs/batch-update \
|
||||
--message "docs: batch update" --ops changes.json --dry-run
|
||||
|
||||
# Show language breakdown
|
||||
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@
|
|||
|
||||
| 分类 | 能力 |
|
||||
|------|------|
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 |
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,搜索文件、批量提交文件变更,查看仓库信息、洞察数据和互动状态 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
|
||||
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
|
||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||
|
|
@ -238,6 +238,19 @@ gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
|||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
|
||||
|
||||
# 按文件名搜索仓库文件
|
||||
gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref main
|
||||
|
||||
# 提交单个文件变更
|
||||
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
|
||||
--branch main --message "docs: update README" \
|
||||
--path README.md --content "# Project"
|
||||
|
||||
# 从 JSON 操作文件一次提交多个文件变更
|
||||
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
|
||||
--branch main --new-branch docs/batch-update \
|
||||
--message "docs: batch update" --ops changes.json --dry-run
|
||||
|
||||
# 查看语言占比
|
||||
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
# Repository File Search and Batch Commit Shortcuts
|
||||
|
||||
## Summary
|
||||
|
||||
This change adds repository file workflow shortcuts for users and automation agents that need to find files and commit multiple file changes without manually assembling Raw API calls.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `gitlink-cli repo +files` | Search repository files by name with optional branch, tag, or commit filtering |
|
||||
| `gitlink-cli repo +commit-files` | Commit one file operation or a batch JSON operation list through the `contents/batch` API |
|
||||
|
||||
## Examples
|
||||
|
||||
Search files on a branch:
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref main
|
||||
```
|
||||
|
||||
Update one text file:
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
|
||||
--branch main --message "docs: update README" \
|
||||
--path README.md --content "# Project"
|
||||
```
|
||||
|
||||
Create or update a binary file by reading local bytes and encoding them as base64:
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
|
||||
--branch main --message "assets: update logo" \
|
||||
--action update --path assets/logo.png --from ./logo.png --encoding base64
|
||||
```
|
||||
|
||||
Preview a multi-file commit before sending it:
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
|
||||
--branch main --new-branch docs/batch-update \
|
||||
--message "docs: batch update" --ops changes.json --dry-run
|
||||
```
|
||||
|
||||
`changes.json` can be either an array of file operations:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action_type": "create",
|
||||
"file_path": "docs/guide.md",
|
||||
"content": "# Guide\n",
|
||||
"encoding": "text"
|
||||
},
|
||||
{
|
||||
"action_type": "delete",
|
||||
"file_path": "docs/old-guide.md"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
or an object with a `files` array. The CLI supplies `branch`, `message`, optional author and committer fields, and optional `new_branch` from command flags.
|
||||
|
||||
## Validation
|
||||
|
||||
- `repo +commit-files` requires `--branch` and `--message`.
|
||||
- Single-file mode requires `--path`; `create` and `update` require exactly one of `--content` or `--from`.
|
||||
- `delete` operations reject `content` and `encoding` so the request body matches the API intent.
|
||||
- `--encoding` accepts only `text` and `base64`.
|
||||
- `--ops` cannot be combined with single-file flags.
|
||||
- Author and committer names must be provided together with their matching email fields.
|
||||
- `--dry-run` prints the resolved request and does not call the remote API.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests cover file search query mapping, single-file request bodies, base64 local file reading, JSON batch operation files, dry-run behavior, and validation failures that must not perform an API request.
|
||||
|
||||
## 中文说明
|
||||
|
||||
本次变更补齐了仓库文件工作流中常用的两个能力:先用 `repo +files` 按文件名和分支搜索仓库文件,再用 `repo +commit-files` 把单个或多个文件变更提交到目标分支。批量提交支持创建新分支、设置提交信息、指定作者和提交者、从本地文件读取内容、对二进制内容做 base64 编码,并提供 `--dry-run` 预览请求体,适合脚本、CI 和 AI Agent 在真正写入仓库前检查即将提交的内容。验证覆盖了端点路径、查询参数、请求体字段、JSON 批量文件、base64 编码和无效参数不触网等关键路径。
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
|
@ -108,6 +111,36 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "files",
|
||||
Description: "Search repository files by name",
|
||||
Flags: []common.Flag{
|
||||
{Name: "search", Short: "s", Usage: "File name keyword"},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: runFiles,
|
||||
},
|
||||
{
|
||||
Name: "commit-files",
|
||||
Description: "Commit one file operation or a batch JSON file through contents/batch",
|
||||
Flags: []common.Flag{
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "new-branch", Usage: "Create and commit to a new branch"},
|
||||
{Name: "action", Short: "a", Usage: "Single-file action: create, update, or delete; defaults to update"},
|
||||
{Name: "path", Short: "p", Usage: "Repository file path for single-file mode"},
|
||||
{Name: "content", Short: "c", Usage: "Inline file content for single-file mode"},
|
||||
{Name: "from", Usage: "Read single-file content from a local file"},
|
||||
{Name: "encoding", Usage: "Content encoding for single-file mode: text or base64; defaults to text"},
|
||||
{Name: "ops", Usage: "Read batch file operations from a JSON file"},
|
||||
{Name: "author-name", Usage: "Commit author name"},
|
||||
{Name: "author-email", Usage: "Commit author email"},
|
||||
{Name: "committer-name", Usage: "Committer name"},
|
||||
{Name: "committer-email", Usage: "Committer email"},
|
||||
{Name: "dry-run", Usage: "Preview the request without committing files", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runCommitFiles,
|
||||
},
|
||||
{
|
||||
Name: "languages",
|
||||
Description: "Show repository language statistics",
|
||||
|
|
@ -263,6 +296,242 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
|||
return i18n.Default()
|
||||
}
|
||||
|
||||
type repoBatchCommitRequest struct {
|
||||
Files []repoFileOperation `json:"files"`
|
||||
AuthorEmail string `json:"author_email,omitempty"`
|
||||
AuthorName string `json:"author_name,omitempty"`
|
||||
CommitterEmail string `json:"committer_email,omitempty"`
|
||||
CommitterName string `json:"committer_name,omitempty"`
|
||||
Branch string `json:"branch"`
|
||||
NewBranch string `json:"new_branch,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type repoFileOperation struct {
|
||||
ActionType string `json:"action_type"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Encoding string `json:"encoding,omitempty"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
func runFiles(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
setRepoQueryIfPresent(q, "search", ctx.Arg("search"))
|
||||
setRepoQueryIfPresent(q, "ref", ctx.Arg("ref"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runCommitFiles(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := buildRepoBatchCommitRequest(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"method": "POST",
|
||||
"path": "/v1" + ctx.RepoPath() + "/contents/batch",
|
||||
"request": req,
|
||||
})
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func buildRepoBatchCommitRequest(ctx *common.RuntimeContext) (repoBatchCommitRequest, error) {
|
||||
branch, err := requiredRepoString(ctx, "branch")
|
||||
if err != nil {
|
||||
return repoBatchCommitRequest{}, err
|
||||
}
|
||||
message, err := requiredRepoString(ctx, "message")
|
||||
if err != nil {
|
||||
return repoBatchCommitRequest{}, err
|
||||
}
|
||||
|
||||
opsFile := strings.TrimSpace(ctx.Arg("ops"))
|
||||
files, err := repoCommitFileOperations(ctx, opsFile)
|
||||
if err != nil {
|
||||
return repoBatchCommitRequest{}, err
|
||||
}
|
||||
|
||||
req := repoBatchCommitRequest{
|
||||
Files: files,
|
||||
Branch: branch,
|
||||
Message: message,
|
||||
NewBranch: strings.TrimSpace(ctx.Arg("new-branch")),
|
||||
AuthorName: strings.TrimSpace(ctx.Arg("author-name")),
|
||||
AuthorEmail: strings.TrimSpace(ctx.Arg("author-email")),
|
||||
CommitterName: strings.TrimSpace(ctx.Arg("committer-name")),
|
||||
CommitterEmail: strings.TrimSpace(ctx.Arg("committer-email")),
|
||||
}
|
||||
if err := validateRepoIdentityPair("author", req.AuthorName, req.AuthorEmail); err != nil {
|
||||
return repoBatchCommitRequest{}, err
|
||||
}
|
||||
if err := validateRepoIdentityPair("committer", req.CommitterName, req.CommitterEmail); err != nil {
|
||||
return repoBatchCommitRequest{}, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func repoCommitFileOperations(ctx *common.RuntimeContext, opsFile string) ([]repoFileOperation, error) {
|
||||
if opsFile != "" {
|
||||
if repoHasSingleFileArgs(ctx) {
|
||||
return nil, fmt.Errorf("--ops cannot be combined with --path, --content, --from, --action, or --encoding")
|
||||
}
|
||||
return readRepoFileOperations(opsFile)
|
||||
}
|
||||
op, err := singleRepoFileOperation(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []repoFileOperation{op}, nil
|
||||
}
|
||||
|
||||
func repoHasSingleFileArgs(ctx *common.RuntimeContext) bool {
|
||||
for _, name := range []string{"path", "content", "from", "action", "encoding"} {
|
||||
if strings.TrimSpace(ctx.Arg(name)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readRepoFileOperations(path string) ([]repoFileOperation, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read --ops file: %w", err)
|
||||
}
|
||||
var files []repoFileOperation
|
||||
if err := json.Unmarshal(data, &files); err == nil {
|
||||
return validateRepoFileOperations(files)
|
||||
}
|
||||
var req repoBatchCommitRequest
|
||||
if err := json.Unmarshal(data, &req); err != nil {
|
||||
return nil, fmt.Errorf("parse --ops JSON: expected an array of file operations or an object with files: %w", err)
|
||||
}
|
||||
return validateRepoFileOperations(req.Files)
|
||||
}
|
||||
|
||||
func singleRepoFileOperation(ctx *common.RuntimeContext) (repoFileOperation, error) {
|
||||
filePath, err := requiredRepoString(ctx, "path")
|
||||
if err != nil {
|
||||
return repoFileOperation{}, err
|
||||
}
|
||||
action := strings.TrimSpace(ctx.Arg("action"))
|
||||
if action == "" {
|
||||
action = "update"
|
||||
}
|
||||
op := repoFileOperation{
|
||||
ActionType: action,
|
||||
FilePath: filePath,
|
||||
Encoding: strings.TrimSpace(ctx.Arg("encoding")),
|
||||
}
|
||||
|
||||
hasContent := ctx.Arg("content") != ""
|
||||
fromPath := strings.TrimSpace(ctx.Arg("from"))
|
||||
hasFrom := fromPath != ""
|
||||
if hasContent && hasFrom {
|
||||
return repoFileOperation{}, fmt.Errorf("--content and --from cannot be used together")
|
||||
}
|
||||
if hasContent {
|
||||
content := ctx.Arg("content")
|
||||
op.Content = &content
|
||||
}
|
||||
if hasFrom {
|
||||
content, err := readRepoFileContent(fromPath, op.Encoding)
|
||||
if err != nil {
|
||||
return repoFileOperation{}, err
|
||||
}
|
||||
op.Content = &content
|
||||
}
|
||||
return validateRepoFileOperation(op, 0)
|
||||
}
|
||||
|
||||
func readRepoFileContent(path, encoding string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read --from file: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(encoding) == "base64" {
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func validateRepoFileOperations(files []repoFileOperation) ([]repoFileOperation, error) {
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("at least one file operation is required")
|
||||
}
|
||||
for i := range files {
|
||||
op, err := validateRepoFileOperation(files[i], i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files[i] = op
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func validateRepoFileOperation(op repoFileOperation, index int) (repoFileOperation, error) {
|
||||
prefix := fmt.Sprintf("files[%d]", index)
|
||||
op.ActionType = strings.TrimSpace(op.ActionType)
|
||||
op.FilePath = strings.TrimSpace(op.FilePath)
|
||||
op.Encoding = strings.TrimSpace(op.Encoding)
|
||||
|
||||
switch op.ActionType {
|
||||
case "create", "update", "delete":
|
||||
default:
|
||||
return repoFileOperation{}, fmt.Errorf("%s.action_type must be create, update, or delete", prefix)
|
||||
}
|
||||
if op.FilePath == "" {
|
||||
return repoFileOperation{}, fmt.Errorf("%s.file_path is required", prefix)
|
||||
}
|
||||
if op.ActionType == "delete" {
|
||||
if op.Content != nil || op.Encoding != "" {
|
||||
return repoFileOperation{}, fmt.Errorf("%s delete operation must not include content or encoding", prefix)
|
||||
}
|
||||
return op, nil
|
||||
}
|
||||
if op.Content == nil {
|
||||
return repoFileOperation{}, fmt.Errorf("%s content is required for create and update operations", prefix)
|
||||
}
|
||||
if op.Encoding == "" {
|
||||
op.Encoding = "text"
|
||||
}
|
||||
if op.Encoding != "text" && op.Encoding != "base64" {
|
||||
return repoFileOperation{}, fmt.Errorf("%s.encoding must be text or base64", prefix)
|
||||
}
|
||||
return op, nil
|
||||
}
|
||||
|
||||
func validateRepoIdentityPair(name, personName, email string) error {
|
||||
if (personName == "") != (email == "") {
|
||||
return fmt.Errorf("--%s-name and --%s-email must be provided together", name, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredRepoString(ctx *common.RuntimeContext, name string) (string, error) {
|
||||
value := strings.TrimSpace(ctx.Arg(name))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("missing required flag: --%s", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func runLanguages(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
|
|
@ -218,6 +220,198 @@ func TestRepoTreeShortcutRegistersHelpFlags(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRepoFilesBuildsSearchAndRefQuery(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/files.json")
|
||||
assertEqual(t, r.URL.Query().Get("search"), "README")
|
||||
assertEqual(t, r.URL.Query().Get("ref"), "release/v1")
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"name": "README.md", "path": "README.md", "type": "file"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "files", map[string]string{
|
||||
"search": " README ",
|
||||
"ref": " release/v1 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("files shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCommitFilesSingleInlineUpdatePostsBatch(t *testing.T) {
|
||||
var body repoBatchCommitRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json")
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc123"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "commit-files", map[string]string{
|
||||
"branch": "main",
|
||||
"new-branch": "docs/update-readme",
|
||||
"message": "update README",
|
||||
"path": "README.md",
|
||||
"content": "# hello\n",
|
||||
"author-name": "Alice",
|
||||
"author-email": "alice@example.com",
|
||||
"committer-name": "Bob",
|
||||
"committer-email": "bob@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("commit-files shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, body.Branch, "main")
|
||||
assertEqual(t, body.NewBranch, "docs/update-readme")
|
||||
assertEqual(t, body.Message, "update README")
|
||||
assertEqual(t, body.AuthorName, "Alice")
|
||||
assertEqual(t, body.AuthorEmail, "alice@example.com")
|
||||
assertEqual(t, len(body.Files), 1)
|
||||
assertEqual(t, body.Files[0].ActionType, "update")
|
||||
assertEqual(t, body.Files[0].FilePath, "README.md")
|
||||
assertEqual(t, body.Files[0].Encoding, "text")
|
||||
if body.Files[0].Content == nil || *body.Files[0].Content != "# hello\n" {
|
||||
t.Fatalf("unexpected content: %#v", body.Files[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCommitFilesReadsLocalFileAsBase64(t *testing.T) {
|
||||
tempFile := writeTempFile(t, []byte{0x00, 0x01, 0x02, 0xff})
|
||||
|
||||
var body repoBatchCommitRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json")
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "def456"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "commit-files", map[string]string{
|
||||
"branch": "main",
|
||||
"message": "add binary",
|
||||
"action": "create",
|
||||
"path": "assets/logo.bin",
|
||||
"from": tempFile,
|
||||
"encoding": "base64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("commit-files shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, body.Files[0].ActionType, "create")
|
||||
assertEqual(t, body.Files[0].Encoding, "base64")
|
||||
want := base64.StdEncoding.EncodeToString([]byte{0x00, 0x01, 0x02, 0xff})
|
||||
if body.Files[0].Content == nil || *body.Files[0].Content != want {
|
||||
t.Fatalf("content = %#v, want %q", body.Files[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCommitFilesReadsBatchOpsFile(t *testing.T) {
|
||||
opsFile := writeTempFile(t, []byte(`[
|
||||
{"action_type":"create","file_path":"docs/a.md","content":"hello","encoding":"text"},
|
||||
{"action_type":"delete","file_path":"docs/old.md"}
|
||||
]`))
|
||||
|
||||
var body repoBatchCommitRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json")
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "789"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "commit-files", map[string]string{
|
||||
"branch": "main",
|
||||
"message": "batch docs",
|
||||
"ops": opsFile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("commit-files shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, len(body.Files), 2)
|
||||
assertEqual(t, body.Files[0].ActionType, "create")
|
||||
assertEqual(t, body.Files[0].Encoding, "text")
|
||||
assertEqual(t, body.Files[1].ActionType, "delete")
|
||||
if body.Files[1].Content != nil || body.Files[1].Encoding != "" {
|
||||
t.Fatalf("delete operation should omit content and encoding: %+v", body.Files[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCommitFilesDryRunDoesNotCallAPI(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "commit-files", map[string]string{
|
||||
"branch": "main",
|
||||
"message": "preview",
|
||||
"path": "README.md",
|
||||
"content": "hello",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCommitFilesValidation(t *testing.T) {
|
||||
opsFile := writeTempFile(t, []byte(`[{"action_type":"create","file_path":"docs/a.md","content":"hello"}]`))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]string
|
||||
}{
|
||||
{
|
||||
name: "missing path",
|
||||
args: map[string]string{"branch": "main", "message": "msg", "content": "hello"},
|
||||
},
|
||||
{
|
||||
name: "missing content for update",
|
||||
args: map[string]string{"branch": "main", "message": "msg", "path": "README.md"},
|
||||
},
|
||||
{
|
||||
name: "content and from together",
|
||||
args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "from": opsFile},
|
||||
},
|
||||
{
|
||||
name: "delete with content",
|
||||
args: map[string]string{"branch": "main", "message": "msg", "action": "delete", "path": "README.md", "content": "hello"},
|
||||
},
|
||||
{
|
||||
name: "invalid encoding",
|
||||
args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "encoding": "gzip"},
|
||||
},
|
||||
{
|
||||
name: "ops with single-file args",
|
||||
args: map[string]string{"branch": "main", "message": "msg", "ops": opsFile, "path": "README.md"},
|
||||
},
|
||||
{
|
||||
name: "partial author identity",
|
||||
args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "author-name": "Alice"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := runShortcut(t, server, "commit-files", tc.args); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoLanguagesUsesLanguagesEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/languages.json")
|
||||
|
|
@ -621,6 +815,21 @@ func TestRepoCreateUserNoLogin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func writeTempFile(t *testing.T, data []byte) string {
|
||||
t.Helper()
|
||||
file, err := os.CreateTemp(t.TempDir(), "repo-file-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close temp file: %v", err)
|
||||
}
|
||||
return file.Name()
|
||||
}
|
||||
|
||||
func assertRequest(t *testing.T, r *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if r.Method != method || r.URL.Path != path {
|
||||
|
|
|
|||
Loading…
Reference in New Issue