diff --git a/README.md b/README.md index e5e4318..ed1bd99 100644 --- a/README.md +++ b/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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..fb614ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 diff --git a/doc/changes/repo-file-batch-commit.md b/doc/changes/repo-file-batch-commit.md new file mode 100644 index 0000000..bb3ffc7 --- /dev/null +++ b/doc/changes/repo-file-batch-commit.md @@ -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 编码和无效参数不触网等关键路径。 diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6..44be0ad 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -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 diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 5c5f34b..73d90c7 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -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 {