diff --git a/README.md b/README.md index 271a29f..bfd49b0 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, read and 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 | @@ -225,10 +225,26 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # Read repository README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master +# Read one repository file with metadata +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path README.md --ref main + # List repository files at root or a directory 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 bb85099..a61a528 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -103,7 +103,7 @@ | 分类 | 能力 | |------|------| -| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 | +| 📦 仓库 | 列出、创建、Fork、删除仓库,读取和搜索文件、批量提交文件变更,查看仓库信息、洞察数据和互动状态 | | 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | @@ -234,10 +234,26 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # 读取仓库 README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master +# 读取指定仓库文件及元数据 +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path README.md --ref main + # 列出仓库根目录或指定目录文件 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..bf10024 --- /dev/null +++ b/doc/changes/repo-file-batch-commit.md @@ -0,0 +1,88 @@ +# Repository File Read, Search, and Batch Commit Shortcuts + +## Summary + +This change adds repository file workflow shortcuts for users and automation agents that need to read file content, find files, and commit multiple file changes without manually assembling Raw API calls. + +## Commands + +| Command | Purpose | +|---------|---------| +| `gitlink-cli repo +file` | Read one repository file with content, commit info, and download metadata | +| `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 +``` + +Read one file from a branch: + +```bash +gitlink-cli repo +file --owner Gitlink --repo forgeplus --path README.md --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 direct file reads through `sub_entries`, 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 +file` 直接读取指定文件内容和元数据,用 `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 8cbd96e..bae3cf2 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -1,16 +1,15 @@ package repo import ( - "errors" + "encoding/base64" + "encoding/json" "fmt" "net/url" "os" - "os/exec" "strconv" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -46,51 +45,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "clone", - Description: tr.T("cmd.repo.clone.short"), - Long: tr.T("cmd.repo.clone.long"), - Flags: []common.Flag{ - {Name: "dir", Short: "d", Usage: tr.T("flag.repo.clone_dir")}, - {Name: "branch", Short: "b", Usage: tr.T("flag.repo.clone_branch")}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) - if err != nil { - return err - } - data, _ := env.Data.(map[string]interface{}) - cloneURL, _ := data["clone_url"].(string) - if cloneURL == "" { - return errors.New(tr.T("error.repo.clone_url_missing")) - } - args := []string{"clone", cloneURL} - if branch := ctx.Arg("branch"); branch != "" { - args = append(args, "--branch", branch) - } - if dir := ctx.Arg("dir"); dir != "" { - args = append(args, dir) - } - gitCmd := exec.Command("git", args...) - gitCmd.Stdout = os.Stderr - gitCmd.Stderr = os.Stderr - if err := gitCmd.Run(); err != nil { - return fmt.Errorf("git clone failed: %w", err) - } - dest := ctx.Arg("dir") - if dest == "" { - dest = strings.TrimSuffix(cloneURL[strings.LastIndex(cloneURL, "/")+1:], ".git") - } - return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ - "message": "cloned", - "clone_url": cloneURL, - "dir": dest, - }, nil)) - }, - }, { Name: "info", Description: tr.T("cmd.repo.info.short"), @@ -105,112 +59,22 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "edit", - Description: tr.T("cmd.repo.edit.short"), - Flags: []common.Flag{ - {Name: "description", Short: "d", Usage: tr.T("flag.repo.description")}, - {Name: "website", Usage: tr.T("flag.repo.edit.website")}, - {Name: "private", Usage: tr.T("flag.repo.private")}, - {Name: "default-branch", Usage: tr.T("flag.repo.edit.default_branch")}, - {Name: "category-id", Usage: tr.T("flag.repo.edit.category_id")}, - {Name: "language-id", Usage: tr.T("flag.repo.edit.language_id")}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - private := ctx.Arg("private") - if private != "" && private != "true" && private != "false" { - return fmt.Errorf("--private must be true or false, got %q", private) - } - ids := map[string]interface{}{} - for flag, key := range map[string]string{"category-id": "project_category_id", "language-id": "project_language_id"} { - if v := ctx.Arg(flag); v != "" { - id, err := strconv.Atoi(v) - if err != nil { - return fmt.Errorf("--%s must be an integer, got %q", flag, v) - } - ids[key] = id - } - } - description := ctx.Arg("description") - website := ctx.Arg("website") - defaultBranch := ctx.Arg("default-branch") - if description == "" && website == "" && defaultBranch == "" && private == "" && len(ids) == 0 { - return fmt.Errorf("nothing to update: pass at least one of --description, --website, --private, --default-branch, --category-id, --language-id") - } - - detail, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) - if err != nil { - return err - } - data, _ := detail.Data.(map[string]interface{}) - name, _ := data["name"].(string) - identifier, _ := data["identifier"].(string) - if name == "" || identifier == "" { - return fmt.Errorf("cannot resolve repository name/identifier from %s", ctx.RepoPath()) - } - base := func() map[string]interface{} { - return map[string]interface{}{"name": name, "identifier": identifier} - } - - // The server dispatches on which key is present (website, - // default_branch, or general metadata), so each group goes - // out as its own request. - if defaultBranch != "" { - payload := base() - payload["default_branch"] = defaultBranch - if _, err := ctx.CallAPI("PATCH", ctx.RepoPath(), payload); err != nil { - return err - } - } - if website != "" { - payload := base() - payload["website"] = website - if _, err := ctx.CallAPI("PATCH", ctx.RepoPath(), payload); err != nil { - return err - } - } - if description != "" || private != "" || len(ids) > 0 { - payload := base() - if description != "" { - payload["description"] = description - } - if private != "" { - payload["private"] = private == "true" - } - for k, v := range ids { - payload[k] = v - } - if _, err := ctx.CallAPI("PATCH", ctx.RepoPath(), payload); err != nil { - return err - } - } - - env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, { Name: "readme", - Description: tr.T("cmd.repo.readme.short"), + Description: "Show repository README content", Flags: []common.Flag{ - {Name: "ref", Usage: tr.T("flag.repo.readme_ref")}, - {Name: "path", Usage: tr.T("flag.repo.readme_path")}, + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, + {Name: "path", Usage: "README directory path"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } q := url.Values{} - if ref := strings.TrimSpace(ctx.Arg("ref")); ref != "" { + if ref := ctx.Arg("ref"); ref != "" { q.Set("ref", ref) } - if path := strings.Trim(strings.TrimSpace(ctx.Arg("path")), "/"); path != "" { + if path := ctx.Arg("path"); path != "" { q.Set("filepath", path) } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q) @@ -222,11 +86,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "file", - Description: "Show repository file content", + Description: "Show repository file content and metadata", Flags: []common.Flag{ {Name: "path", Short: "p", Usage: "Repository file path", Required: true}, {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, - {Name: "content-only", Usage: "Output file content only", Bool: true, Default: "false"}, }, Run: runFile, }, @@ -235,19 +98,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.repo.tree.short"), Flags: []common.Flag{ {Name: "path", Short: "p", Usage: tr.T("flag.repo.tree.path")}, - {Name: "ref", Short: "r", Usage: tr.T("flag.repo.tree.ref")}, + {Name: "ref", Short: "r", Usage: tr.T("flag.repo.tree.ref"), Default: "master"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - q := url.Values{} - if path := ctx.Arg("path"); path != "" { - q.Set("filepath", path) - } - if ref := ctx.Arg("ref"); ref != "" { - q.Set("ref", ref) - } + q := repoSubEntriesQuery(ctx.Arg("path"), ctx.Arg("ref")) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) if err != nil { return err @@ -256,33 +113,34 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "blame", - Description: tr.T("cmd.repo.blame.short"), + Name: "files", + Description: "Search repository files by name", Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: tr.T("flag.repo.blame.path"), Required: true}, - {Name: "ref", Short: "r", Usage: tr.T("flag.repo.tree.ref"), Default: "master"}, + {Name: "search", Short: "s", Usage: "File name keyword"}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - path, err := ctx.RequireArg("path") - if err != nil { - return err - } - ref := ctx.Arg("ref") - if ref == "" { - ref = "master" - } - q := url.Values{} - q.Set("filepath", path) - q.Set("sha", ref) - env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/blame", q) - if err != nil { - return err - } - return ctx.Output(env) + 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", @@ -294,42 +152,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: "List repository contributors", Run: runContributors, }, - { - Name: "activity", - Description: tr.T("cmd.repo.activity.short"), - Flags: []common.Flag{ - {Name: "type", Short: "t", Usage: tr.T("flag.repo.activity.type")}, - {Name: "status", Short: "s", Usage: tr.T("flag.repo.activity.status")}, - {Name: "time", Usage: tr.T("flag.repo.activity.time")}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if trendType := ctx.Arg("type"); trendType != "" { - q.Set("type", trendType) - } - if status := ctx.Arg("status"); status != "" { - q.Set("status", status) - } - if timeDays := ctx.Arg("time"); timeDays != "" { - if _, err := strconv.Atoi(timeDays); err != nil { - return fmt.Errorf("--time must be an integer number of days, got %q", timeDays) - } - q.Set("time", timeDays) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/activity", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, { Name: "contributor-stats", Description: "List contributor statistics with code line counts", @@ -363,28 +185,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return runCommunityList(ctx, "stargazers") }, }, - { - Name: "forks", - Description: tr.T("cmd.repo.forks.short"), - Flags: communityListFlags(), - Run: func(ctx *common.RuntimeContext) error { - return runCommunityList(ctx, "forks") - }, - }, - { - Name: "top-counts", - Description: tr.T("cmd.repo.top_counts.short"), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/top_counts", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, { Name: "follow", Description: "Follow a repository", @@ -473,90 +273,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - - { - Name: "transfer-orgs", - Description: tr.T("cmd.repo.transfer_orgs.short"), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", repoTransferPath(ctx, "organizations"), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "transfer", - Description: tr.T("cmd.repo.transfer.short"), - Flags: []common.Flag{ - {Name: "target-owner", Usage: tr.T("flag.repo.target_owner"), Required: true}, - {Name: "dry-run", Usage: tr.T("flag.repo.transfer_dry_run"), Bool: true, Default: "false"}, - {Name: "yes", Usage: tr.T("flag.repo.transfer_yes"), Bool: true, Default: "false"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - targetOwner, err := ctx.RequireArg("target-owner") - if err != nil { - return err - } - targetOwner = strings.TrimSpace(targetOwner) - if targetOwner == "" { - return fmt.Errorf("required flag --target-owner is missing") - } - payload := map[string]interface{}{"owner_name": targetOwner} - path := repoTransferPath(ctx, "") - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "method": "POST", - "path": path, - "payload": payload, - }) - } - if err := requireRepoTransferConfirmation(ctx, "transfer"); err != nil { - return err - } - env, err := ctx.CallAPI("POST", path, payload) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "transfer-cancel", - Description: tr.T("cmd.repo.transfer_cancel.short"), - Flags: []common.Flag{ - {Name: "dry-run", Usage: tr.T("flag.repo.transfer_cancel_dry_run"), Bool: true, Default: "false"}, - {Name: "yes", Usage: tr.T("flag.repo.transfer_yes"), Bool: true, Default: "false"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - path := repoTransferPath(ctx, "cancel") - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "method": "POST", - "path": path, - }) - } - if err := requireRepoTransferConfirmation(ctx, "transfer-cancel"); err != nil { - return err - } - env, err := ctx.CallAPI("POST", path, nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, { Name: "delete", Description: tr.T("cmd.repo.delete.short"), @@ -571,134 +287,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "languages", - Description: "Show language breakdown of a repository", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "contributors", - Description: "List contributors of a repository", - Flags: []common.Flag{ - {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 - } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/contributors", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "files", - Description: "List files in a repository directory", - Flags: []common.Flag{ - {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, - {Name: "path", Short: "p", Usage: "Directory path (default: repository root)"}, - }, - 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 p := ctx.Arg("path"); p != "" { - q.Set("filepath", p) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "tags", - Description: "List tags of a repository", - Flags: []common.Flag{ - {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 - } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/tags", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "commits", - Description: "List commits of a repository", - Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: "Branch name, tag, or commit SHA"}, - {Name: "path", Short: "p", Usage: "Filter commits by file path"}, - {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 - } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if sha := ctx.Arg("sha"); sha != "" { - q.Set("sha", sha) - } - if p := ctx.Arg("path"); p != "" { - q.Set("path", p) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/commits", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, } } -func repoTransferPath(ctx *common.RuntimeContext, action string) string { - base := ctx.RepoPath() + "/applied_transfer_projects" - if action == "" { - return base - } - return fmt.Sprintf("%s/%s", base, action) -} - -func requireRepoTransferConfirmation(ctx *common.RuntimeContext, shortcut string) error { - if ctx.Arg("yes") == "true" { - return nil - } - return fmt.Errorf("refusing to run repo +%s without --yes; use --dry-run to preview the request first", shortcut) -} - func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] @@ -706,6 +297,271 @@ 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 runFile(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := requiredRepoString(ctx, "path") + if err != nil { + return err + } + q := repoSubEntriesQuery(filePath, ctx.Arg("ref")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + return ctx.Output(env) +} + +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 repoSubEntriesQuery(path, ref string) url.Values { + q := url.Values{} + if path = strings.TrimSpace(path); path != "" { + q.Set("filepath", path) + } + ref = strings.TrimSpace(ref) + if ref == "" { + ref = "master" + } + q.Set("ref", ref) + return q +} + func runLanguages(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -728,44 +584,6 @@ func runContributors(ctx *common.RuntimeContext) error { return ctx.Output(env) } -func runFile(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - path, err := normalizeRepoFilePath(ctx) - if err != nil { - return err - } - - ref := strings.TrimSpace(ctx.Arg("ref")) - if ref == "" { - ref = "master" - } - - q := url.Values{} - q.Set("filepath", path) - q.Set("ref", ref) - - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) - if err != nil { - return err - } - - entry, err := extractRepoFileEntry(env.Data, path) - if err != nil { - return err - } - if ctx.Arg("content-only") == "true" { - content, _ := entry["content"].(string) - if content == "" { - return fmt.Errorf("file response did not include content for %q", path) - } - return ctx.OutputData(content) - } - - return ctx.OutputData(buildRepoFileResult(entry, path, ref)) -} - func runContributorStats(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -963,58 +781,6 @@ func setRepoQueryIfPresent(q url.Values, key, value string) { } } -func normalizeRepoFilePath(ctx *common.RuntimeContext) (string, error) { - path, err := ctx.RequireArg("path") - if err != nil { - return "", err - } - path = strings.TrimLeft(strings.TrimSpace(path), "/") - if path == "" { - return "", fmt.Errorf("invalid --path %q: provide a repository file path", ctx.Arg("path")) - } - return path, nil -} - -func extractRepoFileEntry(data interface{}, path string) (map[string]interface{}, error) { - payload, ok := data.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("unexpected file response format") - } - - entry, ok := payload["entries"] - if !ok { - return nil, fmt.Errorf("unexpected file response format") - } - - if _, isDir := entry.([]interface{}); isDir { - return nil, fmt.Errorf("path %q is a directory; use repo +tree instead", path) - } - - fileEntry, ok := entry.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("unexpected file response format") - } - - if entryType, _ := fileEntry["type"].(string); entryType != "" && entryType != "file" { - return nil, fmt.Errorf("path %q is not a file; use repo +tree instead", path) - } - - return fileEntry, nil -} - -func buildRepoFileResult(entry map[string]interface{}, path, ref string) map[string]interface{} { - result := map[string]interface{}{ - "path": path, - "ref": ref, - } - for _, key := range []string{"name", "type", "size", "sha", "content"} { - if value, ok := entry[key]; ok { - result[key] = value - } - } - return result -} - func parseOptionalRepoNonNegativeInt(value, name string) (int, bool, error) { if strings.TrimSpace(value) == "" { return 0, false, nil diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 2008664..76e052a 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -1,9 +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" @@ -14,12 +17,18 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st t.Helper() s := findShortcut(t, name) ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", Format: "json", Args: args, } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } return s.Run(ctx) } @@ -34,9 +43,20 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("write response: %v", err) + } +} + +func writeText(t *testing.T, w http.ResponseWriter, code int, text string) { + t.Helper() + w.WriteHeader(code) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatalf("write response: %v", err) + } } // --- list --- @@ -49,7 +69,7 @@ func TestRepoListDefault(t *testing.T) { if r.URL.Path != "/projects.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "total_count": float64(2), "data": []interface{}{map[string]interface{}{"name": "repo1"}}, }) @@ -67,7 +87,7 @@ func TestRepoListForUser(t *testing.T) { if r.URL.Path != "/users/alice/projects.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}}) + writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}}) })) defer server.Close() @@ -82,7 +102,7 @@ func TestRepoListWithCategory(t *testing.T) { if r.URL.Query().Get("category") != "mirror" { t.Fatalf("expected category=mirror, got %s", r.URL.Query().Get("category")) } - writeJSON(w, map[string]interface{}{"data": []interface{}{}}) + writeJSON(t, w, map[string]interface{}{"data": []interface{}{}}) })) defer server.Close() @@ -92,14 +112,14 @@ func TestRepoListWithCategory(t *testing.T) { } } -// --- info --- +// --- info/readme/insights --- func TestRepoInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/owner/repo.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "name": "repo", "description": "test repo", }) @@ -112,7 +132,497 @@ func TestRepoInfo(t *testing.T) { } } -// --- fork --- +func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/readme.json") + assertEqual(t, r.URL.Query().Get("ref"), "main") + assertEqual(t, r.URL.Query().Get("filepath"), "docs") + writeJSON(t, w, map[string]interface{}{ + "type": "file", + "name": "README.md", + "content": "# docs\n", + }) + })) + defer server.Close() + + err := runShortcut(t, server, "readme", map[string]string{ + "ref": "main", + "path": "docs", + }) + if err != nil { + t.Fatalf("readme shortcut failed: %v", err) + } +} + +func TestRepoFileUsesSubEntriesFileEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") + assertEqual(t, r.URL.Query().Get("filepath"), "README.md") + assertEqual(t, r.URL.Query().Get("ref"), "main") + writeJSON(t, w, map[string]interface{}{ + "entries": map[string]interface{}{ + "name": "README.md", + "path": "README.md", + "type": "file", + "content": "# docs\n", + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "file", map[string]string{ + "path": "README.md", + "ref": "main", + }) + if err != nil { + t.Fatalf("file shortcut failed: %v", err) + } +} + +func TestRepoFileRequiresPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("missing path should not call API, got: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + if err := runShortcut(t, server, "file", map[string]string{"ref": "main"}); err == nil { + t.Fatal("expected missing path error") + } +} + +func TestRepoTreeListsRootOnDefaultRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") + if _, ok := r.URL.Query()["filepath"]; ok { + t.Fatalf("did not expect filepath query for repository root, got %q", r.URL.Query().Get("filepath")) + } + assertEqual(t, r.URL.Query().Get("ref"), "master") + writeJSON(t, w, map[string]interface{}{ + "entries": []map[string]interface{}{ + {"name": "README.md", "type": "file"}, + }, + }) + })) + defer server.Close() + + if err := runShortcut(t, server, "tree", nil); err != nil { + t.Fatalf("tree shortcut failed: %v", err) + } +} + +func TestRepoTreeUsesPathAndRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") + assertEqual(t, r.URL.Query().Get("filepath"), "cmd") + assertEqual(t, r.URL.Query().Get("ref"), "main") + writeJSON(t, w, map[string]interface{}{ + "entries": []map[string]interface{}{ + {"name": "main.go", "type": "file"}, + }, + }) + })) + defer server.Close() + + if err := runShortcut(t, server, "tree", map[string]string{"path": "cmd", "ref": "main"}); err != nil { + t.Fatalf("tree shortcut failed: %v", err) + } +} + +func TestRepoTreeShortcutRegistersHelpFlags(t *testing.T) { + tree := findShortcut(t, "tree") + if tree.Description == "" { + t.Fatal("tree shortcut description is empty") + } + + flags := map[string]common.Flag{} + for _, flag := range tree.Flags { + flags[flag.Name] = flag + } + + pathFlag, ok := flags["path"] + if !ok { + t.Fatal("tree shortcut missing path flag") + } + if pathFlag.Short != "p" || pathFlag.Usage == "" { + t.Fatalf("unexpected path flag: %+v", pathFlag) + } + + refFlag, ok := flags["ref"] + if !ok { + t.Fatal("tree shortcut missing ref flag") + } + if refFlag.Short != "r" || refFlag.Default != "master" || refFlag.Usage == "" { + t.Fatalf("unexpected ref flag: %+v", refFlag) + } +} + +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") + writeJSON(t, w, map[string]interface{}{"Go": "92.4%", "Shell": "7.6%"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "languages", nil); err != nil { + t.Fatalf("languages shortcut failed: %v", err) + } +} + +func TestRepoContributorsUsesContributorsEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/contributors.json") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "list": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "contributors", nil); err != nil { + t.Fatalf("contributors shortcut failed: %v", err) + } +} + +func TestRepoContributorStatsBuildsQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/contributors/stat.json") + assertEqual(t, r.URL.Query().Get("ref"), "main") + assertEqual(t, r.URL.Query().Get("pass_year"), "2") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "contributors": []interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "contributor-stats", map[string]string{ + "ref": " main ", + "pass-year": "2", + }) + if err != nil { + t.Fatalf("contributor-stats shortcut failed: %v", err) + } +} + +func TestRepoCodeStatsUsesRefQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/code_stats.json") + assertEqual(t, r.URL.Query().Get("ref"), "release/v1") + writeJSON(t, w, map[string]interface{}{"author_count": 1, "commit_count": 3}) + })) + defer server.Close() + + if err := runShortcut(t, server, "code-stats", map[string]string{"ref": "release/v1"}); err != nil { + t.Fatalf("code-stats shortcut failed: %v", err) + } +} + +func TestRepoWatchersBuildsTimeRangeQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/watchers.json") + assertEqual(t, r.URL.Query().Get("start_at"), "1714521600") + assertEqual(t, r.URL.Query().Get("end_at"), "1717200000") + writeJSON(t, w, map[string]interface{}{"count": 1, "users": []interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "watchers", map[string]string{ + "start-at": "1714521600", + "end-at": "1717200000", + }) + if err != nil { + t.Fatalf("watchers shortcut failed: %v", err) + } +} + +func TestRepoStargazersUsesStargazersEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/stargazers.json") + writeJSON(t, w, map[string]interface{}{"count": 0, "users": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "stargazers", nil); err != nil { + t.Fatalf("stargazers shortcut failed: %v", err) + } +} + +func TestRepoFollowResolvesProjectID(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/owner/repo.json") + writeJSON(t, w, map[string]interface{}{"id": float64(123)}) + case 2: + assertRequest(t, r, "POST", "/watchers/follow.json") + assertEqual(t, r.URL.Query().Get("target_type"), "project") + assertEqual(t, r.URL.Query().Get("id"), "123") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": true}) + default: + t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runShortcut(t, server, "follow", nil); err != nil { + t.Fatalf("follow shortcut failed: %v", err) + } + assertEqual(t, requests, 2) +} + +func TestRepoUnfollowUsesExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/watchers/unfollow.json") + assertEqual(t, r.URL.Query().Get("target_type"), "project") + assertEqual(t, r.URL.Query().Get("id"), "456") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": false}) + })) + defer server.Close() + + if err := runShortcut(t, server, "unfollow", map[string]string{"project-id": "456"}); err != nil { + t.Fatalf("unfollow shortcut failed: %v", err) + } +} + +func TestRepoLikeUsesExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/projects/456/praise_tread/like.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "like", map[string]string{"project-id": "456"}); err != nil { + t.Fatalf("like shortcut failed: %v", err) + } +} + +func TestRepoUnlikeResolvesStringProjectID(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/owner/repo.json") + writeJSON(t, w, map[string]interface{}{"project_id": "789"}) + case 2: + assertRequest(t, r, "DELETE", "/projects/789/praise_tread/unlike.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + default: + t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runShortcut(t, server, "unlike", nil); err != nil { + t.Fatalf("unlike shortcut failed: %v", err) + } + assertEqual(t, requests, 2) +} + +func TestRepoInteractionDryRunDoesNotCallAPIWithExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run with explicit project-id should not call API, got: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "like", map[string]string{ + "project-id": "456", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("dry-run shortcut failed: %v", err) + } +} + +// --- fork/delete/create --- func TestRepoFork(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -122,7 +632,7 @@ func TestRepoFork(t *testing.T) { if r.URL.Path != "/owner/repo/forks.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"message": "forked"}) + writeJSON(t, w, map[string]interface{}{"message": "forked"}) })) defer server.Close() @@ -132,8 +642,6 @@ func TestRepoFork(t *testing.T) { } } -// --- delete --- - func TestRepoDelete(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { @@ -142,7 +650,7 @@ func TestRepoDelete(t *testing.T) { if r.URL.Path != "/owner/repo.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"message": "deleted"}) + writeJSON(t, w, map[string]interface{}{"message": "deleted"}) })) defer server.Close() @@ -152,18 +660,16 @@ func TestRepoDelete(t *testing.T) { } } -// --- create --- - func TestRepoCreate(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/users/me.json" && r.Method == "GET": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "login": "creator", "user_id": float64(42), }) case r.URL.Path == "/creator/new-repo.json" && r.Method == "POST": - writeJSON(w, map[string]interface{}{"name": "new-repo"}) + writeJSON(t, w, map[string]interface{}{"name": "new-repo"}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } @@ -181,13 +687,15 @@ func TestRepoCreateWithOptions(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/users/me.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "login": "creator", "user_id": float64(42), }) case "/creator/my-repo.json": - json.NewDecoder(r.Body).Decode(&body) - writeJSON(w, map[string]interface{}{"name": "my-repo"}) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + writeJSON(t, w, map[string]interface{}{"name": "my-repo"}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } @@ -210,6 +718,55 @@ func TestRepoCreateWithOptions(t *testing.T) { } } +// --- validation/error paths --- + +func TestRepoInsightValidation(t *testing.T) { + 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 + shortcut string + args map[string]string + }{ + { + name: "invalid pass year", + shortcut: "contributor-stats", + args: map[string]string{"pass-year": "0"}, + }, + { + name: "invalid start timestamp", + shortcut: "watchers", + args: map[string]string{"start-at": "abc"}, + }, + { + name: "start after end", + shortcut: "stargazers", + args: map[string]string{"start-at": "20", "end-at": "10"}, + }, + { + name: "invalid project id", + shortcut: "follow", + args: map[string]string{"project-id": "repo"}, + }, + { + name: "negative project id", + shortcut: "unlike", + args: map[string]string{"project-id": "-1"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runShortcut(t, server, tc.shortcut, tc.args); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + func TestRepoCreateFailsWithoutName(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call should be made") @@ -222,12 +779,9 @@ func TestRepoCreateFailsWithoutName(t *testing.T) { } } -// --- HTTP error paths --- - func TestRepoListHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -239,8 +793,7 @@ func TestRepoListHTTPError(t *testing.T) { func TestRepoInfoHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -252,8 +805,7 @@ func TestRepoInfoHTTPError(t *testing.T) { func TestRepoForkHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -265,8 +817,7 @@ func TestRepoForkHTTPError(t *testing.T) { func TestRepoDeleteHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -278,8 +829,7 @@ func TestRepoDeleteHTTPError(t *testing.T) { func TestRepoCreateGetUserHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -291,7 +841,7 @@ func TestRepoCreateGetUserHTTPError(t *testing.T) { func TestRepoCreateUserNoLogin(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{"user_id": float64(42)}) + writeJSON(t, w, map[string]interface{}{"user_id": float64(42)}) })) defer server.Close() @@ -301,246 +851,31 @@ func TestRepoCreateUserNoLogin(t *testing.T) { } } -// --- languages --- - -func TestRepoLanguages(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/owner/repo/languages.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{ - "Go": float64(85.5), - "Shell": float64(14.5), - }) - })) - defer server.Close() - - err := runShortcut(t, server, "languages", nil) +func writeTempFile(t *testing.T, data []byte) string { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "repo-file-*") if err != nil { - t.Fatalf("languages failed: %v", err) + 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 { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } } -func TestRepoLanguagesHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) - defer server.Close() - - err := runShortcut(t, server, "languages", nil) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -// --- contributors --- - -func TestRepoContributors(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/owner/repo/contributors.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if r.URL.Query().Get("page") != "1" { - t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) - } - if r.URL.Query().Get("limit") != "20" { - t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) - } - writeJSON(w, map[string]interface{}{ - "total_count": float64(1), - "data": []interface{}{map[string]interface{}{"login": "alice", "contributions": float64(42)}}, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "contributors", map[string]string{"page": "1", "limit": "20"}) - if err != nil { - t.Fatalf("contributors failed: %v", err) - } -} - -func TestRepoContributorsHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) - defer server.Close() - - err := runShortcut(t, server, "contributors", map[string]string{"page": "1", "limit": "20"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -// --- files --- - -func TestRepoFiles(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/owner/repo/files.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, []interface{}{ - map[string]interface{}{"name": "README.md", "type": "file"}, - map[string]interface{}{"name": "src", "type": "dir"}, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "files", nil) - if err != nil { - t.Fatalf("files failed: %v", err) - } -} - -func TestRepoFilesWithRef(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("ref") != "main" { - t.Fatalf("expected ref=main, got %s", r.URL.Query().Get("ref")) - } - if r.URL.Query().Get("filepath") != "src" { - t.Fatalf("expected filepath=src, got %s", r.URL.Query().Get("filepath")) - } - writeJSON(w, []interface{}{}) - })) - defer server.Close() - - err := runShortcut(t, server, "files", map[string]string{"ref": "main", "path": "src"}) - if err != nil { - t.Fatalf("files with ref failed: %v", err) - } -} - -func TestRepoFilesHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) - defer server.Close() - - err := runShortcut(t, server, "files", nil) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -// --- tags --- - -func TestRepoTags(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/owner/repo/tags.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if r.URL.Query().Get("page") != "1" { - t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) - } - if r.URL.Query().Get("limit") != "20" { - t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) - } - writeJSON(w, map[string]interface{}{ - "total_count": float64(1), - "data": []interface{}{map[string]interface{}{"name": "v1.0.0"}}, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "tags", map[string]string{"page": "1", "limit": "20"}) - if err != nil { - t.Fatalf("tags failed: %v", err) - } -} - -func TestRepoTagsHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) - defer server.Close() - - err := runShortcut(t, server, "tags", map[string]string{"page": "1", "limit": "20"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - -// --- commits --- - -func TestRepoCommits(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/owner/repo/commits.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if r.URL.Query().Get("page") != "1" { - t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page")) - } - if r.URL.Query().Get("limit") != "20" { - t.Fatalf("expected limit=20, got %s", r.URL.Query().Get("limit")) - } - writeJSON(w, map[string]interface{}{ - "total_count": float64(1), - "data": []interface{}{ - map[string]interface{}{"sha": "abc123", "message": "initial commit"}, - }, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "commits", map[string]string{"page": "1", "limit": "20"}) - if err != nil { - t.Fatalf("commits failed: %v", err) - } -} - -func TestRepoCommitsWithFilters(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("sha") != "main" { - t.Fatalf("expected sha=main, got %s", r.URL.Query().Get("sha")) - } - if r.URL.Query().Get("path") != "src/main.go" { - t.Fatalf("expected path=src/main.go, got %s", r.URL.Query().Get("path")) - } - writeJSON(w, map[string]interface{}{ - "total_count": float64(1), - "data": []interface{}{}, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "commits", map[string]string{ - "sha": "main", - "path": "src/main.go", - "page": "1", - "limit": "20", - }) - if err != nil { - t.Fatalf("commits with filters failed: %v", err) - } -} - -func TestRepoCommitsHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) - defer server.Close() - - err := runShortcut(t, server, "commits", map[string]string{"page": "1", "limit": "20"}) - if err == nil { - t.Fatal("expected error for HTTP 500") +func assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } }