diff --git a/README.md b/README.md index 6feb19e..8ca9f72 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,30 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run ``` +### Attachment Upload & Download + +`attachment` gives a scriptable path for large-file transfer instead of the web UI. The uploaded attachment id can be fed to `release +create --attachment-ids`. + +```bash +# Upload a local file as a platform attachment (returns the attachment id) +gitlink-cli attachment +upload -f ./dist/app-v1.0.0.tar.gz -d "v1.0.0 release asset" + +# Upload several files concurrently (comma-separated; -c sets the worker count, default 3) +gitlink-cli attachment +upload -f ./dist/app.tar.gz,./dist/app.sha256,./dist/CHANGELOG.md -c 3 + +# Download an attachment by id to a local file +gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz + +# Download all attachments of a release by tag (mirrors `gh release download`) +gitlink-cli release +download -i v1.0.0 -o ./assets + +# Delete an attachment by id +gitlink-cli attachment +delete -i + +# Or do it in one step: upload local files and attach them to a new release +gitlink-cli release +create -t v1.0.0 -n "v1.0.0" --attachment-files ./dist/app.tar.gz,./dist/app.sha256 +``` + ### CI/CD Operations ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index a9d221a..b844334 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -423,6 +423,30 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run ``` +### 附件上传与下载 + +`attachment` 为大文件传输提供可脚本化的 CLI 通道(不必走网页端)。上传返回的附件 id 可直接用于 `release +create --attachment-ids`。 + +```bash +# 上传本地文件为平台附件(返回附件 id) +gitlink-cli attachment +upload -f ./dist/app-v1.0.0.tar.gz -d "v1.0.0 发布产物" + +# 多文件并发上传(逗号分隔;-c 指定并发数,默认 3) +gitlink-cli attachment +upload -f ./dist/app.tar.gz,./dist/app.sha256,./dist/CHANGELOG.md -c 3 + +# 按 id 下载附件到本地文件 +gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz + +# 按 tag 一步下载发行版全部附件(对标 `gh release download`) +gitlink-cli release +download -i v1.0.0 -o ./assets + +# 按 id 删除附件 +gitlink-cli attachment +delete -i + +# 一步到位:上传本地文件并附加到新发行版 +gitlink-cli release +create -t v1.0.0 -n "v1.0.0" --attachment-files ./dist/app.tar.gz,./dist/app.sha256 +``` + ### 流水线管理 ```bash diff --git a/internal/client/client.go b/internal/client/client.go index 3dbb05f..5487323 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -21,6 +21,9 @@ type Client struct { HTTP *http.Client BaseURL string Debug bool + // NoProgress suppresses per-byte transfer progress on stderr; used when + // several transfers run concurrently and interleaved lines would garble. + NoProgress bool } type APIError struct { diff --git a/internal/client/upload.go b/internal/client/upload.go new file mode 100644 index 0000000..15be88c --- /dev/null +++ b/internal/client/upload.go @@ -0,0 +1,261 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/output" +) + +// progressThreshold is the minimum file size for which upload progress is +// reported on stderr. +const progressThreshold = 1 << 20 // 1 MiB + +// progressReporter prints transfer progress to stderr at 10% steps for files +// larger than progressThreshold. It implements io.Writer so it can sit on +// the tee side of the transfer stream. +type progressReporter struct { + verb string + name string + total int64 + done int64 + lastPct int64 + lastLine int + out io.Writer +} + +// transferProgress builds a progress reporter honoring Client.NoProgress. +func (c *Client) transferProgress(verb, name string, total int64) *progressReporter { + if c.NoProgress { + return newProgressReporterTo(verb, name, total, io.Discard) + } + return newProgressReporter(verb, name, total) +} + +func newProgressReporter(verb, name string, total int64) *progressReporter { + return &progressReporter{verb: verb, name: name, total: total, lastPct: -1, out: os.Stderr} +} + +func newProgressReporterTo(verb, name string, total int64, out io.Writer) *progressReporter { + return &progressReporter{verb: verb, name: name, total: total, lastPct: -1, out: out} +} + +func (p *progressReporter) Write(b []byte) (int, error) { + p.done += int64(len(b)) + switch { + case p.total >= progressThreshold: + pct := p.done * 100 / p.total + if pct/10 > p.lastPct/10 || (pct == 100 && p.lastPct != 100) { + p.print(fmt.Sprintf("%s %s: %d%% (%s / %s)", p.verb, p.name, pct, formatBytes(p.done), formatBytes(p.total))) + if pct >= 100 { + fmt.Fprintln(p.out) + } + p.lastPct = pct + } + case p.total <= 0: + // Unknown total (e.g. chunked downloads without Content-Length): + // report transferred bytes at every MiB boundary. + if step := p.done / progressThreshold; step > 0 && step > p.lastPct { + p.print(fmt.Sprintf("%s %s: %s", p.verb, p.name, formatBytes(p.done))) + p.lastPct = step + } + } + return len(b), nil +} + +// Close finishes an unknown-total progress line with the final byte count. +func (p *progressReporter) Close() error { + if p.total <= 0 && p.done >= progressThreshold { + p.print(fmt.Sprintf("%s %s: %s", p.verb, p.name, formatBytes(p.done))) + fmt.Fprintln(p.out) + } + return nil +} + +func (p *progressReporter) print(line string) { + if pad := p.lastLine - len(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + fmt.Fprintf(p.out, "\r%s", line) + p.lastLine = len(line) +} + +func formatBytes(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1f GiB", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} + +// PostMultipartFile uploads a local file as a multipart/form-data request. +// fileField is the form field name for the file (GitLink expects "file"); +// extra fields (e.g. description) are added as plain form values. +func (c *Client) PostMultipartFile(path, filePath, fileField string, fields map[string]string) (*output.Envelope, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open upload file: %w", err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat upload file: %w", err) + } + + // Stream the multipart body through a pipe so arbitrarily large files + // are never buffered in memory. + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + progress := c.transferProgress("uploading", filepath.Base(filePath), info.Size()) + go func() { + part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) + if err != nil { + pw.CloseWithError(err) + return + } + if _, err := io.Copy(part, io.TeeReader(f, progress)); err != nil { + pw.CloseWithError(fmt.Errorf("read upload file: %w", err)) + return + } + for k, v := range fields { + if v != "" { + if err := writer.WriteField(k, v); err != nil { + pw.CloseWithError(err) + return + } + } + } + pw.CloseWithError(writer.Close()) + }() + + fullURL := c.BaseURL + normalizeAPIPath(c.BaseURL, path) + req, err := http.NewRequest("POST", fullURL, pr) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + if c.Debug { + fmt.Printf("→ POST %s (multipart, %s)\n", fullURL, filepath.Base(filePath)) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("upload failed: %w", err) + } + defer resp.Body.Close() + + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if c.Debug { + fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) + } + + if resp.StatusCode >= 400 { + return nil, &APIError{ + StatusCode: resp.StatusCode, + Code: resp.StatusCode, + Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), + } + } + + var raw map[string]interface{} + if err := json.Unmarshal(respData, &raw); err != nil { + return output.SuccessEnvelope(string(respData), nil), nil + } + if status, ok := raw["status"].(float64); ok && status != 0 && status != 200 && status != 201 && status != 1 { + msg, _ := raw["message"].(string) + return output.ErrorEnvelope(int(status), msg, ""), &APIError{ + StatusCode: int(status), + Code: int(status), + Message: msg, + } + } + return output.SuccessEnvelope(raw, nil), nil +} + +// DownloadFile streams a GET response body to destPath and returns the +// number of bytes written. +func (c *Client) DownloadFile(path, destPath string) (int64, error) { + fullURL := c.BaseURL + normalizeAPIPath(c.BaseURL, path) + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, err + } + if c.Debug { + fmt.Printf("→ GET %s (download to %s)\n", fullURL, destPath) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return 0, fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return 0, &APIError{ + StatusCode: resp.StatusCode, + Code: resp.StatusCode, + Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))), + } + } + // Unknown attachment ids fall through to the web frontend, which answers + // 200 with an HTML page; surface that as an error instead of saving it. + ct := resp.Header.Get("Content-Type") + if strings.Contains(ct, "text/html") { + return 0, &APIError{ + StatusCode: resp.StatusCode, + Code: "non_api_response", + Message: "endpoint returned an HTML page instead of file data; check the attachment id", + } + } + // Deleted/unknown attachments answer 200 with a JSON error body + // ({"status":404,"message":"..."}); surface that as an error too. + if strings.Contains(ct, "application/json") { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + var body struct { + Status float64 `json:"status"` + Message string `json:"message"` + } + if err := json.Unmarshal(data, &body); err == nil && body.Status != 0 && body.Status != 200 && body.Status != 201 && body.Status != 1 { + return 0, &APIError{ + StatusCode: int(body.Status), + Code: int(body.Status), + Message: body.Message, + } + } + return 0, &APIError{ + StatusCode: resp.StatusCode, + Code: "non_file_response", + Message: "endpoint returned JSON instead of file data: " + strings.TrimSpace(string(data)), + } + } + + out, err := os.Create(destPath) + if err != nil { + return 0, fmt.Errorf("create output file: %w", err) + } + defer out.Close() + + progress := c.transferProgress("downloading", filepath.Base(destPath), resp.ContentLength) + n, err := io.Copy(out, io.TeeReader(resp.Body, progress)) + progress.Close() + if err != nil { + return n, fmt.Errorf("write output file: %w", err) + } + return n, nil +} diff --git a/internal/client/upload_test.go b/internal/client/upload_test.go new file mode 100644 index 0000000..1b4dc90 --- /dev/null +++ b/internal/client/upload_test.go @@ -0,0 +1,86 @@ +package client + +import ( + "bytes" + "strings" + "testing" +) + +func TestProgressReporterLargeFile(t *testing.T) { + var buf bytes.Buffer + total := int64(4 << 20) + p := newProgressReporterTo("uploading", "big.bin", total, &buf) + + chunk := make([]byte, 1<<20) + for i := 0; i < 4; i++ { + if _, err := p.Write(chunk); err != nil { + t.Fatal(err) + } + } + out := buf.String() + if !strings.Contains(out, "uploading big.bin") { + t.Fatalf("missing progress prefix: %q", out) + } + if !strings.Contains(out, "100%") { + t.Fatalf("missing 100%% mark: %q", out) + } + if !strings.Contains(out, "4.0 MiB / 4.0 MiB") { + t.Fatalf("missing byte summary: %q", out) + } +} + +func TestProgressReporterSmallFileSilent(t *testing.T) { + var buf bytes.Buffer + p := newProgressReporterTo("uploading", "small.txt", 1024, &buf) + if _, err := p.Write(make([]byte, 1024)); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Fatalf("expected no progress output for small file, got %q", buf.String()) + } +} + +func TestProgressReporterUnknownTotal(t *testing.T) { + var buf bytes.Buffer + p := newProgressReporterTo("downloading", "chunked.bin", -1, &buf) + chunk := make([]byte, 1<<20) + for i := 0; i < 3; i++ { + if _, err := p.Write(chunk); err != nil { + t.Fatal(err) + } + } + p.Close() + out := buf.String() + if !strings.Contains(out, "downloading chunked.bin") { + t.Fatalf("missing progress prefix: %q", out) + } + if !strings.Contains(out, "3.0 MiB") { + t.Fatalf("missing final byte count: %q", out) + } +} + +func TestProgressReporterUnknownTotalSmallSilent(t *testing.T) { + var buf bytes.Buffer + p := newProgressReporterTo("downloading", "small.bin", -1, &buf) + if _, err := p.Write(make([]byte, 1024)); err != nil { + t.Fatal(err) + } + p.Close() + if buf.Len() != 0 { + t.Fatalf("expected no progress output for small unknown-total transfer, got %q", buf.String()) + } +} + +func TestFormatBytes(t *testing.T) { + cases := map[int64]string{ + 512: "512 B", + 2 << 10: "2.0 KiB", + 3 << 20: "3.0 MiB", + 5 << 30: "5.0 GiB", + } + for in, want := range cases { + if got := formatBytes(in); got != want { + t.Fatalf("formatBytes(%d) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 52fe1ae..cedbc04 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -1,8 +1,13 @@ { "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", "cmd.api.short": "Make raw API requests to GitLink", - "cmd.auth.checkin.long": "Start a background task that periodically calls the API to refresh the authentication session and prevent login expiration.", - "cmd.auth.checkin.short": "Periodically refresh authentication session", + "cmd.attachment.delete.long": "Delete a platform attachment by id or uuid. Only the attachment owner can delete it.", + "cmd.attachment.delete.short": "Delete an attachment by id", + "cmd.attachment.download.long": "Download a platform attachment by id and stream it to a local file.", + "cmd.attachment.download.short": "Download an attachment to a local file", + "cmd.attachment.short": "Attachment upload and download", + "cmd.attachment.upload.long": "Upload a local file to GitLink as an attachment via multipart form data. The returned attachment id can be used with `release +create --attachment-ids`.", + "cmd.attachment.upload.short": "Upload a local file as a platform attachment", "cmd.auth.login.short": "Login to GitLink", "cmd.auth.logout.short": "Logout from GitLink", "cmd.auth.short": "Authentication commands", @@ -18,11 +23,6 @@ "cmd.ci.restart.short": "Restart a build", "cmd.ci.short": "CI/CD operations", "cmd.ci.stop.short": "Stop a build", - "cmd.commit.diff.short": "Show the diff of a commit", - "cmd.commit.files.short": "List files changed by a commit", - "cmd.commit.list.short": "List commits, optionally starting from a ref", - "cmd.commit.recent.short": "List recent commits, filterable by keyword", - "cmd.commit.short": "Commit operations", "cmd.config.get.short": "Get a configuration value", "cmd.config.init.short": "Initialize configuration file", "cmd.config.list.short": "List all configuration values", @@ -61,7 +61,6 @@ "cmd.org.short": "Organization operations", "cmd.pr.close.short": "Close a pull request", "cmd.pr.comment.short": "Add a comment to a pull request", - "cmd.pr.commits.short": "List commits in a pull request", "cmd.pr.create.short": "Create a pull request", "cmd.pr.diff.short": "Show diff for a pull request", "cmd.pr.files.short": "List changed files in a pull request", @@ -86,37 +85,26 @@ "cmd.profile.short": "User profile and statistics operations", "cmd.release.create.short": "Create a release", "cmd.release.delete.short": "Delete a release", + "cmd.release.download.long": "Fetch the release by id or tag and download every attachment to a local directory, mirroring `gh release download`.", + "cmd.release.download.short": "Download all attachments of a release", "cmd.release.list.short": "List releases", "cmd.release.short": "Release operations", "cmd.release.view.short": "View release details", "cmd.repo.create.short": "Create a new repository", - "cmd.repo.batch_commit.short": "Create, update, or delete multiple files in one commit", - "cmd.repo.commit_diff.short": "Show diff for one commit", - "cmd.repo.commit_files.short": "List files changed by one commit", - "cmd.repo.commits.short": "List repository commits", - "cmd.repo.delete_tag.short": "Delete a repository tag", "cmd.repo.delete.short": "Delete a repository", - "cmd.repo.files.short": "Search repository files", "cmd.repo.fork.short": "Fork a repository", "cmd.repo.info.short": "Show repository details", "cmd.repo.list.short": "List repositories for a user or organization", "cmd.repo.short": "Repository operations", - "cmd.repo.tag.short": "View repository tag details", - "cmd.repo.tags.short": "List repository tags", "cmd.repo.tree.short": "List repository files and directories", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", "cmd.root.short": "GitLink CLI - command-line tool for GitLink", "cmd.search.repos.short": "Search repositories", "cmd.search.short": "Search operations", "cmd.search.users.short": "Search users", - "cmd.user.heatmap.short": "Show user contribution heatmap", "cmd.user.info.short": "Show user profile", "cmd.user.me.short": "Show current authenticated user", - "cmd.user.project_trends.short": "Show user project trends", "cmd.user.short": "User operations", - "cmd.user.statistics.short": "Show user statistics", - "cmd.user.stats.short": "Show user statistics", - "cmd.user.trends.short": "Show user project trends", "cmd.version.short": "Print version information", "cmd.webhook.create.short": "Create a repository webhook", "cmd.webhook.delete.short": "Delete a repository webhook", @@ -126,18 +114,16 @@ "cmd.webhook.test.short": "Trigger a test delivery for a webhook", "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", "cmd.webhook.view.short": "View webhook details", - "error.auth.checkin.failed": "failed to refresh authentication session: {message}", "error.auth.delete_token_failed": "failed to delete token: {message}", "error.auth.login_failed": "login failed: {message}", - "error.auth.not_logged_in": "not logged in. Please run gitlink-cli auth login first", "error.auth.store_token_failed": "failed to store token: {message}", "error.auth.token_empty": "token cannot be empty", "error.config.save_failed": "failed to save config: {message}", "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "error.missing_required_flag": "required flag --{name} is missing", "error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", + "error.release.no_attachments": "the release has no attachments to download", "error.unsupported_language": "unsupported language: {lang}", - "error.user.required": "could not determine target user; pass --user or run gitlink-cli auth login", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", "flag.api.batch_file": "Read an API batch plan from a JSON file", @@ -147,7 +133,11 @@ "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.header": "Additional headers (key:value)", "flag.api.query": "Query parameters (key=val&key2=val2)", - "flag.auth.checkin.time": "Refresh interval (minutes)", + "flag.attachment.concurrency": "Concurrent uploads when passing multiple comma-separated files (default 3)", + "flag.attachment.description": "Attachment description", + "flag.attachment.file": "Path of the local file to upload", + "flag.attachment.id": "Attachment ID", + "flag.attachment.output": "Output file path (defaults to the attachment id)", "flag.auth.token": "Login by pasting an existing token", "flag.branch.from": "Source branch or commit", "flag.branch.name": "Branch name", @@ -155,10 +145,6 @@ "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", "flag.comment.body": "Comment body", - "flag.commit.filepath": "Restrict to a single file path", - "flag.commit.keyword": "Filter commits by keyword", - "flag.commit.sha": "Commit SHA", - "flag.commit.sha_start": "Ref (branch, tag, or SHA) to list commits from", "flag.dataset.description": "Dataset description", "flag.dataset.dry_run": "Preview the request without writing the dataset", "flag.dataset.dry_run_delete": "Preview the request without deleting the attachment", @@ -229,48 +215,27 @@ "flag.profile.start_time": "Start time (Unix timestamp)", "flag.profile.user": "Target user login (defaults to the authenticated user)", "flag.profile.year": "Year for the contribution heatmap (e.g. 2025)", + "flag.release.attachment_files": "Comma-separated local files to upload and attach", "flag.release.body": "Release notes", "flag.release.id": "Release ID", "flag.release.id_or_tag": "Release ID or tag", "flag.release.name": "Release name", + "flag.release.output_dir": "Directory to save downloaded files (default current directory)", "flag.release.prerelease": "Mark as prerelease (true/false)", "flag.release.tag": "Tag name", "flag.release.target": "Target branch", "flag.repo": "Repository name (auto-detected from git remote)", "flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)", - "flag.repo.author_email": "Commit author email", - "flag.repo.author_name": "Commit author name", - "flag.repo.batch_branch": "Target branch", - "flag.repo.batch_dry_run": "Preview the batch commit request without changing files", - "flag.repo.batch_encoding": "Content encoding for inline file content: text or base64", - "flag.repo.batch_files": "File operations: action:path:content; repeat with semicolons", - "flag.repo.batch_message": "Commit message", - "flag.repo.batch_new_branch": "Create and commit to this new branch", - "flag.repo.batch_yes": "Confirm remote file changes", - "flag.repo.commit_sha": "Commit SHA", - "flag.repo.committer_email": "Commit committer email", - "flag.repo.committer_name": "Commit committer name", - "flag.repo.delete_tag_dry_run": "Preview the tag deletion without changing repository state", - "flag.repo.delete_tag_yes": "Confirm the destructive tag deletion", "flag.repo.description": "Repository description", - "flag.repo.file": "Filter by file path", - "flag.repo.files.search": "File name/path keyword", "flag.repo.name": "Repository name", - "flag.repo.only_name": "Return only names (true/false)", "flag.repo.private": "Make repository private (true/false)", - "flag.repo.ref": "Branch, tag, or commit SHA", - "flag.repo.tag_name": "Tag name", - "flag.repo.tag_name_filter": "Tag search keyword", "flag.repo.tree.path": "Directory path to list (default: repository root)", "flag.repo.tree.ref": "Branch, tag, or commit ref", "flag.search.keyword": "Search keyword", "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", "flag.user": "User login (default: current user)", - "flag.user.end_time": "End time (Unix timestamp)", "flag.user.login": "User login name", - "flag.user.start_time": "Start time (Unix timestamp)", - "flag.user.year": "Heatmap year (for example: 2026)", "flag.webhook.active": "Whether the webhook is active: true or false", "flag.webhook.branch_filter": "Branch glob filter for push/create/delete events", "flag.webhook.content_type": "Payload content type: json or form", @@ -281,14 +246,6 @@ "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", "flag.webhook.url": "Webhook target URL", - "output.auth.checkin.checking": "Refreshing authentication session...", - "output.auth.checkin.interval": "Next refresh at: {time}", - "output.auth.checkin.start": "Starting authentication keep-alive with {interval} minute interval", - "output.auth.checkin.stop_hint": "Press Ctrl+C to stop", - "output.auth.checkin.stopped": "Authentication keep-alive stopped", - "output.auth.checkin.stopping": "Stopping...", - "output.auth.checkin.success": "✓ Authentication session refreshed, current user: {login}", - "output.auth.checkin.success_no_user": "✓ Authentication session refreshed", "output.auth.env_hint": " Or set {env} environment variable", "output.auth.login_hint": " Run: gitlink-cli auth login", "output.config.file": "Config file: {path}", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 518929a..5383788 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -1,8 +1,13 @@ { "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", "cmd.api.short": "向 GitLink 发起原始 API 请求", - "cmd.auth.checkin.long": "启动后台定时任务,定期调用 API 刷新认证会话,防止登录过期。", - "cmd.auth.checkin.short": "定时刷新认证会话", + "cmd.attachment.delete.long": "按 id 或 uuid 删除平台附件。仅附件所有者可删除。", + "cmd.attachment.delete.short": "按 id 删除附件", + "cmd.attachment.download.long": "按 id 下载平台附件并流式写入本地文件。", + "cmd.attachment.download.short": "下载附件到本地文件", + "cmd.attachment.short": "附件上传与下载", + "cmd.attachment.upload.long": "通过 multipart 表单将本地文件上传到 GitLink 作为附件。返回的附件 id 可用于 `release +create --attachment-ids`。", + "cmd.attachment.upload.short": "将本地文件上传为平台附件", "cmd.auth.login.short": "登录 GitLink", "cmd.auth.logout.short": "退出 GitLink 登录", "cmd.auth.short": "认证命令", @@ -18,11 +23,6 @@ "cmd.ci.restart.short": "重启构建", "cmd.ci.short": "CI/CD 操作", "cmd.ci.stop.short": "停止构建", - "cmd.commit.diff.short": "查看提交的 diff", - "cmd.commit.files.short": "列出提交变更的文件", - "cmd.commit.list.short": "列出提交,可从指定 ref 开始", - "cmd.commit.recent.short": "列出最近提交,可按关键字过滤", - "cmd.commit.short": "提交操作", "cmd.config.get.short": "获取配置项", "cmd.config.init.short": "初始化配置文件", "cmd.config.list.short": "列出所有配置项", @@ -61,7 +61,6 @@ "cmd.org.short": "组织操作", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", - "cmd.pr.commits.short": "列出拉取请求中的提交", "cmd.pr.create.short": "创建拉取请求", "cmd.pr.diff.short": "显示拉取请求 diff", "cmd.pr.files.short": "列出拉取请求中的变更文件", @@ -86,37 +85,26 @@ "cmd.profile.short": "用户画像与统计操作", "cmd.release.create.short": "创建发布", "cmd.release.delete.short": "删除发布", + "cmd.release.download.long": "按 id 或 tag 获取发行版并把全部附件下载到本地目录,对标 `gh release download`。", + "cmd.release.download.short": "下载发行版的全部附件", "cmd.release.list.short": "列出发布", "cmd.release.short": "发布操作", "cmd.release.view.short": "查看发布详情", "cmd.repo.create.short": "创建新仓库", - "cmd.repo.batch_commit.short": "在一次提交中创建、更新或删除多个文件", - "cmd.repo.commit_diff.short": "显示单个提交的 diff", - "cmd.repo.commit_files.short": "列出单个提交变更的文件", - "cmd.repo.commits.short": "列出仓库提交", - "cmd.repo.delete_tag.short": "删除仓库标签", "cmd.repo.delete.short": "删除仓库", - "cmd.repo.files.short": "搜索仓库文件", "cmd.repo.fork.short": "Fork 仓库", "cmd.repo.info.short": "显示仓库详情", "cmd.repo.list.short": "列出用户或组织的仓库", "cmd.repo.short": "仓库操作", - "cmd.repo.tag.short": "查看仓库标签详情", - "cmd.repo.tags.short": "列出仓库标签", "cmd.repo.tree.short": "列出仓库文件和目录", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", "cmd.root.short": "GitLink CLI - GitLink 命令行工具", "cmd.search.repos.short": "搜索仓库", "cmd.search.short": "搜索操作", "cmd.search.users.short": "搜索用户", - "cmd.user.heatmap.short": "显示用户贡献热力图", "cmd.user.info.short": "显示用户资料", "cmd.user.me.short": "显示当前认证用户", - "cmd.user.project_trends.short": "显示用户项目趋势", "cmd.user.short": "用户操作", - "cmd.user.statistics.short": "显示用户统计信息", - "cmd.user.stats.short": "显示用户统计信息", - "cmd.user.trends.short": "显示用户项目趋势", "cmd.version.short": "打印版本信息", "cmd.webhook.create.short": "创建仓库 Webhook", "cmd.webhook.delete.short": "删除仓库 Webhook", @@ -126,18 +114,16 @@ "cmd.webhook.test.short": "触发 Webhook 测试投递", "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", "cmd.webhook.view.short": "查看 Webhook 详情", - "error.auth.checkin.failed": "刷新认证会话失败:{message}", "error.auth.delete_token_failed": "删除 Token 失败:{message}", "error.auth.login_failed": "登录失败:{message}", - "error.auth.not_logged_in": "未登录,请先运行 gitlink-cli auth login", "error.auth.store_token_failed": "保存 Token 失败:{message}", "error.auth.token_empty": "Token 不能为空", "error.config.save_failed": "保存配置失败:{message}", "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", + "error.release.no_attachments": "该发行版没有可下载的附件", "error.unsupported_language": "不支持的语言:{lang}", - "error.user.required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", @@ -147,7 +133,11 @@ "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.header": "附加请求头(key:value)", "flag.api.query": "查询参数(key=val&key2=val2)", - "flag.auth.checkin.time": "刷新间隔(分钟)", + "flag.attachment.concurrency": "多文件(逗号分隔)上传时的并发数(默认 3)", + "flag.attachment.description": "附件描述", + "flag.attachment.file": "要上传的本地文件路径", + "flag.attachment.id": "附件 ID", + "flag.attachment.output": "输出文件路径(默认为附件 id)", "flag.auth.token": "通过粘贴已有 Token 登录", "flag.branch.from": "源分支或 Commit", "flag.branch.name": "分支名称", @@ -155,10 +145,6 @@ "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", "flag.comment.body": "评论内容", - "flag.commit.filepath": "仅限单个文件路径", - "flag.commit.keyword": "按关键字过滤提交", - "flag.commit.sha": "提交 SHA", - "flag.commit.sha_start": "起始 ref(分支、标签或 SHA)", "flag.dataset.description": "数据集描述", "flag.dataset.dry_run": "预览请求,不写入数据集", "flag.dataset.dry_run_delete": "预览请求,不删除附件", @@ -229,48 +215,27 @@ "flag.profile.start_time": "开始时间(Unix 时间戳)", "flag.profile.user": "目标用户登录名(默认为当前认证用户)", "flag.profile.year": "贡献热力图的年份(如 2025)", + "flag.release.attachment_files": "以逗号分隔的本地文件路径,自动上传并附加到发行版", "flag.release.body": "发布说明", "flag.release.id": "发布 ID", "flag.release.id_or_tag": "发布 ID 或标签", "flag.release.name": "发布名称", + "flag.release.output_dir": "下载文件保存目录(默认当前目录)", "flag.release.prerelease": "标记为预发布(true/false)", "flag.release.tag": "标签名称", "flag.release.target": "目标分支", "flag.repo": "仓库名称(自动从 git remote 检测)", "flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)", - "flag.repo.author_email": "提交作者邮箱", - "flag.repo.author_name": "提交作者名称", - "flag.repo.batch_branch": "目标分支", - "flag.repo.batch_dry_run": "预览批量提交请求,不修改文件", - "flag.repo.batch_encoding": "内联文件内容编码:text 或 base64", - "flag.repo.batch_files": "文件操作:action:path:content;多个操作用英文分号分隔", - "flag.repo.batch_message": "提交信息", - "flag.repo.batch_new_branch": "创建并提交到这个新分支", - "flag.repo.batch_yes": "确认修改远端文件", - "flag.repo.commit_sha": "Commit SHA", - "flag.repo.committer_email": "提交者邮箱", - "flag.repo.committer_name": "提交者名称", - "flag.repo.delete_tag_dry_run": "预览标签删除操作,不修改仓库状态", - "flag.repo.delete_tag_yes": "确认执行破坏性标签删除", "flag.repo.description": "仓库描述", - "flag.repo.file": "按文件路径筛选", - "flag.repo.files.search": "文件名或路径关键词", "flag.repo.name": "仓库名称", - "flag.repo.only_name": "只返回名称(true/false)", "flag.repo.private": "设为私有仓库(true/false)", - "flag.repo.ref": "分支、标签或 Commit SHA", - "flag.repo.tag_name": "标签名称", - "flag.repo.tag_name_filter": "标签搜索关键词", "flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)", "flag.repo.tree.ref": "分支、标签或提交引用", "flag.search.keyword": "搜索关键词", "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", "flag.user": "用户登录名(默认:当前用户)", - "flag.user.end_time": "结束时间(Unix 时间戳)", "flag.user.login": "用户登录名", - "flag.user.start_time": "开始时间(Unix 时间戳)", - "flag.user.year": "热力图年份(例如:2026)", "flag.webhook.active": "Webhook 是否启用:true 或 false", "flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", "flag.webhook.content_type": "Payload 内容类型:json 或 form", @@ -281,14 +246,6 @@ "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", "flag.webhook.url": "Webhook 目标 URL", - "output.auth.checkin.checking": "正在刷新认证会话...", - "output.auth.checkin.interval": "下次刷新时间:{time}", - "output.auth.checkin.start": "开始认证保活,间隔 {interval} 分钟", - "output.auth.checkin.stop_hint": "按 Ctrl+C 停止", - "output.auth.checkin.stopped": "认证保活已停止", - "output.auth.checkin.stopping": "正在停止...", - "output.auth.checkin.success": "✓ 认证会话已刷新,当前用户:{login}", - "output.auth.checkin.success_no_user": "✓ 认证会话已刷新", "output.auth.env_hint": " 或设置 {env} 环境变量", "output.auth.login_hint": " 运行:gitlink-cli auth login", "output.config.file": "配置文件:{path}", diff --git a/shortcuts/attachment/attachment.go b/shortcuts/attachment/attachment.go index 65fdc01..a38f439 100644 --- a/shortcuts/attachment/attachment.go +++ b/shortcuts/attachment/attachment.go @@ -1,183 +1,179 @@ +// Package attachment implements shortcuts for uploading and downloading +// platform attachments (release assets, issue attachments, etc.). +// +// Upload wraps the multipart POST /api/attachments endpoint and returns the +// attachment id that can be fed to `release +create --attachment-ids`; +// download wraps GET /api/attachments/:uuid and streams the file to +// disk, giving the CLI a scriptable path for large-file transfer instead of +// the web UI. package attachment import ( - "bytes" - "encoding/json" "fmt" - "io" - "mime/multipart" - "net/http" "os" "path/filepath" + "strconv" "strings" + "sync" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns attachment upload/delete shortcuts. -func Shortcuts() []*common.Shortcut { +// splitFiles parses a comma-separated file list, trimming blanks. +func splitFiles(arg string) []string { + var files []string + for _, f := range strings.Split(arg, ",") { + if f = strings.TrimSpace(f); f != "" { + files = append(files, f) + } + } + return files +} + +// uploadConcurrently uploads several files with a bounded worker pool. +// Per-byte progress is suppressed (interleaved lines would garble); instead +// one line per completed file goes to stderr. Results keep input order. +func uploadConcurrently(ctx *common.RuntimeContext, files []string, fields map[string]string, concurrency int) ([]interface{}, error) { + quiet := *ctx.Client + quiet.NoProgress = true + + type result struct { + env *output.Envelope + err error + } + results := make([]result, len(files)) + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + var mu sync.Mutex + for i, file := range files { + wg.Add(1) + go func(i int, file string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + env, err := quiet.PostMultipartFile("/attachments", file, "file", fields) + results[i] = result{env: env, err: err} + mu.Lock() + if err != nil { + fmt.Fprintf(os.Stderr, "uploaded %s: error: %v\n", filepath.Base(file), err) + } else { + fmt.Fprintf(os.Stderr, "uploaded %s\n", filepath.Base(file)) + } + mu.Unlock() + }(i, file) + } + wg.Wait() + + out := make([]interface{}, 0, len(files)) + for i, r := range results { + if r.err != nil { + return nil, fmt.Errorf("upload %q failed: %w", files[i], r.err) + } + out = append(out, map[string]interface{}{"file": files[i], "result": r.env.Data}) + } + return out, nil +} + +// Shortcuts returns attachment upload/download shortcuts. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } + return []*common.Shortcut{ { Name: "upload", - Description: "Upload an attachment file", + Description: tr.T("cmd.attachment.upload.short"), + Long: tr.T("cmd.attachment.upload.long"), Flags: []common.Flag{ - {Name: "file", Short: "f", Usage: "Local file path to upload", Required: true}, - {Name: "description", Short: "d", Usage: "Attachment description"}, - {Name: "container-id", Usage: "Optional container model ID"}, - {Name: "container-type", Usage: "Optional container model type"}, - {Name: "dry-run", Usage: "Preview the multipart fields without uploading the file", Bool: true, Default: "false"}, + {Name: "file", Short: "f", Usage: tr.T("flag.attachment.file"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.attachment.description")}, + {Name: "concurrency", Short: "c", Usage: tr.T("flag.attachment.concurrency"), Default: "3"}, + }, + Run: func(ctx *common.RuntimeContext) error { + fileArg, err := ctx.RequireArg("file") + if err != nil { + return err + } + files := splitFiles(fileArg) + for _, file := range files { + info, err := os.Stat(file) + if err != nil { + return fmt.Errorf("cannot access file %q: %w", file, err) + } + if info.IsDir() { + return fmt.Errorf("%q is a directory, expected a file", file) + } + } + fields := map[string]string{ + "description": ctx.Arg("description"), + } + if len(files) == 1 { + env, err := ctx.Client.PostMultipartFile("/attachments", files[0], "file", fields) + if err != nil { + return err + } + return ctx.Output(env) + } + concurrency, err := strconv.Atoi(ctx.Arg("concurrency")) + if err != nil || concurrency < 1 { + concurrency = 3 + } + results, err := uploadConcurrently(ctx, files, fields, concurrency) + if err != nil { + return err + } + return ctx.OutputData(results) + }, + }, + { + Name: "download", + Description: tr.T("cmd.attachment.download.short"), + Long: tr.T("cmd.attachment.download.long"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.attachment.id"), Required: true}, + {Name: "output", Short: "o", Usage: tr.T("flag.attachment.output")}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + dest := ctx.Arg("output") + if dest == "" { + dest = id + } + n, err := ctx.Client.DownloadFile("/attachments/"+id, dest) + if err != nil { + return err + } + return ctx.OutputData(map[string]interface{}{ + "file": filepath.Clean(dest), + "bytes": n, + }) }, - Run: runUpload, }, { Name: "delete", - Description: "Delete an attachment by UUID", + Description: tr.T("cmd.attachment.delete.short"), + Long: tr.T("cmd.attachment.delete.long"), Flags: []common.Flag{ - {Name: "uuid", Short: "u", Usage: "Attachment UUID", Required: true}, - {Name: "dry-run", Usage: "Preview the delete request without deleting the attachment", Bool: true, Default: "false"}, + {Name: "id", Short: "i", Usage: tr.T("flag.attachment.id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.Client.Delete("/attachments/"+id, nil) + if err != nil { + return err + } + return ctx.Output(env) }, - Run: runDelete, }, } } - -func runUpload(ctx *common.RuntimeContext) error { - filePath, err := ctx.RequireArg("file") - if err != nil { - return err - } - fields := attachmentFields(ctx) - if parseBool(ctx.Arg("dry-run")) { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "upload_attachment", - "method": "POST", - "path": "/attachments", - "file": filePath, - "filename": filepath.Base(filePath), - "fields": fields, - }) - } - env, err := uploadAttachment(ctx, filePath, fields) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runDelete(ctx *common.RuntimeContext) error { - uuid, err := ctx.RequireArg("uuid") - if err != nil { - return err - } - path := fmt.Sprintf("/attachments/%s", uuid) - if parseBool(ctx.Arg("dry-run")) { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "delete_attachment", - "method": "DELETE", - "path": path, - }) - } - env, err := ctx.CallAPI("DELETE", path, nil) - if err != nil { - return err - } - return ctx.Output(env) -} - -func attachmentFields(ctx *common.RuntimeContext) map[string]string { - fields := map[string]string{} - for _, name := range []string{"description", "container-id", "container-type"} { - if value := ctx.Arg(name); value != "" { - fields[apiFieldName(name)] = value - } - } - return fields -} - -func apiFieldName(flagName string) string { - switch flagName { - case "container-id": - return "container_id" - case "container-type": - return "container_type" - default: - return flagName - } -} - -func uploadAttachment(ctx *common.RuntimeContext, filePath string, fields map[string]string) (*output.Envelope, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, fmt.Errorf("open attachment file: %w", err) - } - defer file.Close() - - var body bytes.Buffer - writer := multipart.NewWriter(&body) - part, err := writer.CreateFormFile("file", filepath.Base(filePath)) - if err != nil { - return nil, fmt.Errorf("create multipart file field: %w", err) - } - if _, err := io.Copy(part, file); err != nil { - return nil, fmt.Errorf("read attachment file: %w", err) - } - for key, value := range fields { - if err := writer.WriteField(key, value); err != nil { - return nil, fmt.Errorf("write multipart field %s: %w", key, err) - } - } - if err := writer.Close(); err != nil { - return nil, fmt.Errorf("close multipart writer: %w", err) - } - - url := apiURL(ctx.Client.BaseURL, "/attachments") - req, err := http.NewRequest("POST", url, &body) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", writer.FormDataContentType()) - - httpClient := ctx.Client.HTTP - if httpClient == nil { - httpClient = http.DefaultClient - } - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - respData, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))) - } - - var parsed interface{} - if err := json.Unmarshal(respData, &parsed); err != nil { - return output.SuccessEnvelope(string(respData), nil), nil - } - if data, ok := parsed.(map[string]interface{}); ok { - if status, ok := data["status"].(float64); ok && status != 0 && status != 1 && status != 200 { - message, _ := data["message"].(string) - return nil, fmt.Errorf("[%v] %s", status, message) - } - } - return output.SuccessEnvelope(parsed, nil), nil -} - -func apiURL(baseURL, path string) string { - fullPath := path - if !strings.HasSuffix(fullPath, ".json") { - fullPath += ".json" - } - return strings.TrimRight(baseURL, "/") + fullPath -} - -func parseBool(value string) bool { - return strings.EqualFold(strings.TrimSpace(value), "true") -} diff --git a/shortcuts/attachment/attachment_test.go b/shortcuts/attachment/attachment_test.go index 27c24fc..2629244 100644 --- a/shortcuts/attachment/attachment_test.go +++ b/shortcuts/attachment/attachment_test.go @@ -2,182 +2,269 @@ package attachment import ( "encoding/json" + "fmt" "io" - "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strings" + "sync" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestAttachmentUploadDryRunDoesNotCallAPI(t *testing.T) { - server := newAttachmentTestServer(t, 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 := runAttachmentShortcut(t, server, "upload", map[string]string{ - "file": filepath.Join(t.TempDir(), "missing.txt"), - "description": "design screenshot", - "container-id": "123", - "container-type": "Issue", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("upload dry-run failed: %v", err) +func TestShortcutsRegistered(t *testing.T) { + shortcuts := Shortcuts() + if len(shortcuts) != 3 { + t.Fatalf("expected 3 shortcuts, got %d", len(shortcuts)) + } + names := map[string]bool{} + for _, s := range shortcuts { + names[s.Name] = true + if s.Description == "" { + t.Fatalf("shortcut %q has empty description", s.Name) + } + } + for _, want := range []string{"upload", "download", "delete"} { + if !names[want] { + t.Fatalf("missing shortcut %q", want) + } } } -func TestAttachmentUploadMultipartPayload(t *testing.T) { - tmpDir := t.TempDir() - filePath := filepath.Join(tmpDir, "note.txt") - if err := os.WriteFile(filePath, []byte("hello attachment"), 0600); err != nil { - t.Fatalf("failed to create temp file: %v", err) - } - - server := newAttachmentTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertAttachmentRequest(t, r, "POST", "/attachments.json") - if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "multipart/form-data;") { - t.Fatalf("got content-type %q, want multipart/form-data", got) - } - if err := r.ParseMultipartForm(1 << 20); err != nil { - t.Fatalf("failed to parse multipart form: %v", err) - } - assertFormValue(t, r.MultipartForm, "description", "design screenshot") - assertFormValue(t, r.MultipartForm, "container_id", "123") - assertFormValue(t, r.MultipartForm, "container_type", "Issue") - file, header, err := r.FormFile("file") - if err != nil { - t.Fatalf("file field missing: %v", err) - } - defer file.Close() - if header.Filename != "note.txt" { - t.Fatalf("got filename %q, want note.txt", header.Filename) - } - data, err := io.ReadAll(file) - if err != nil { - t.Fatalf("failed to read uploaded file: %v", err) - } - if string(data) != "hello attachment" { - t.Fatalf("got file content %q", string(data)) - } - writeAttachmentJSON(t, w, map[string]interface{}{ - "id": "uuid-1", - "title": "note.txt", - "filesize": "16 Bytes", - "is_pdf": false, - "url": "/api/attachments/uuid-1", - "content_type": "text/plain", - }) - }) - defer server.Close() - - err := runAttachmentShortcut(t, server, "upload", map[string]string{ - "file": filePath, - "description": "design screenshot", - "container-id": "123", - "container-type": "Issue", - }) - if err != nil { - t.Fatalf("upload shortcut failed: %v", err) - } -} - -func TestAttachmentUploadMissingFile(t *testing.T) { - server := newAttachmentTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("missing file should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runAttachmentShortcut(t, server, "upload", map[string]string{"file": filepath.Join(t.TempDir(), "missing.txt")}) - if err == nil { - t.Fatal("expected missing file to return an error") - } - if !strings.Contains(err.Error(), "open attachment file") { - t.Fatalf("got error %q, want open attachment file", err.Error()) - } -} - -func TestAttachmentDelete(t *testing.T) { - server := newAttachmentTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertAttachmentRequest(t, r, "DELETE", "/attachments/uuid-1.json") - writeAttachmentJSON(t, w, map[string]interface{}{"status": 0, "message": "删除成功"}) - }) - defer server.Close() - - if err := runAttachmentShortcut(t, server, "delete", map[string]string{"uuid": "uuid-1"}); err != nil { - t.Fatalf("delete shortcut failed: %v", err) - } -} - -func TestAttachmentDeleteDryRunDoesNotCallAPI(t *testing.T) { - server := newAttachmentTestServer(t, 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() - - if err := runAttachmentShortcut(t, server, "delete", map[string]string{"uuid": "uuid-1", "dry-run": "true"}); err != nil { - t.Fatalf("delete dry-run failed: %v", err) - } -} - -func runAttachmentShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { +func findShortcut(t *testing.T, name string) *common.Shortcut { t.Helper() - shortcut := findAttachmentShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findAttachmentShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut + for _, s := range Shortcuts() { + if s.Name == name { + return s } } t.Fatalf("shortcut %q not found", name) return nil } -func newAttachmentTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { +func newTestContext(t *testing.T, handler http.HandlerFunc, args map[string]string) *common.RuntimeContext { t.Helper() - return httptest.NewServer(handler) -} - -func assertAttachmentRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.Path != path { - t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Format: "json", + Args: args, } } -func assertFormValue(t *testing.T, form *multipart.Form, key, want string) { - t.Helper() - values := form.Value[key] - if len(values) != 1 || values[0] != want { - t.Fatalf("got form field %s=%v, want %q", key, values, want) +func TestUploadMultipart(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "asset.txt") + if err := os.WriteFile(src, []byte("hello attachment"), 0644); err != nil { + t.Fatal(err) + } + + var gotFilename, gotDescription string + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + file, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("form file: %v", err) + } + defer file.Close() + data, _ := io.ReadAll(file) + if string(data) != "hello attachment" { + t.Fatalf("unexpected file content: %q", data) + } + gotFilename = header.Filename + gotDescription = r.FormValue("description") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": 123, "filename": header.Filename}) + }, map[string]string{"file": src, "description": "test asset"}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "upload").Run(ctx); err != nil { + t.Fatalf("upload error: %v", err) + } + if gotFilename != "asset.txt" { + t.Fatalf("filename = %q", gotFilename) + } + if gotDescription != "test asset" { + t.Fatalf("description = %q", gotDescription) } } -func writeAttachmentJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) +func TestUploadMissingFile(t *testing.T) { + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("server should not be reached") + }, map[string]string{"file": "/nonexistent/path/file.bin"}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "upload").Run(ctx); err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestUploadRejectsDirectory(t *testing.T) { + dir := t.TempDir() + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("server should not be reached") + }, map[string]string{"file": dir}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "upload").Run(ctx); err == nil { + t.Fatal("expected error for directory") + } +} + +func TestDownloadWritesFile(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/attachments/42" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Write([]byte("binary-content")) + }, map[string]string{"id": "42", "output": dest}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "download").Run(ctx); err != nil { + t.Fatalf("download error: %v", err) + } + data, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(data) != "binary-content" { + t.Fatalf("unexpected content: %q", data) + } +} + +func TestDownloadJSONErrorBody(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(`{"status":404,"message":"不存在或已被删除"}`)) + }, map[string]string{"id": "deleted", "output": dest}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "download").Run(ctx); err == nil { + t.Fatal("expected error for JSON error body") + } + if _, err := os.Stat(dest); err == nil { + t.Fatal("output file should not be created on JSON error body") + } +} + +func TestDownloadHTMLFallback(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte("")) + }, map[string]string{"id": "unknown", "output": dest}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "download").Run(ctx); err == nil { + t.Fatal("expected error for HTML fallback page") + } + if _, err := os.Stat(dest); err == nil { + t.Fatal("output file should not be created on HTML fallback") + } +} + +func TestDownloadHTTPError(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + }, map[string]string{"id": "999", "output": dest}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "download").Run(ctx); err == nil { + t.Fatal("expected error for HTTP 404") + } + if _, err := os.Stat(dest); err == nil { + t.Fatal("output file should not be created on HTTP error") + } +} + +func TestDeleteAttachment(t *testing.T) { + var gotMethod, gotPath string + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"status": 0, "message": "删除成功"}) + }, map[string]string{"id": "abc-uuid"}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "delete").Run(ctx); err != nil { + t.Fatalf("delete error: %v", err) + } + if gotMethod != http.MethodDelete { + t.Fatalf("method = %q, want DELETE", gotMethod) + } + if gotPath != "/attachments/abc-uuid.json" { + t.Fatalf("path = %q, want /attachments/abc-uuid.json", gotPath) + } +} + +func TestSplitFiles(t *testing.T) { + got := splitFiles(" a.txt, b.bin ,,c ") + want := []string{"a.txt", "b.bin", "c"} + if len(got) != len(want) { + t.Fatalf("splitFiles = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("splitFiles[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestUploadMultipleFilesConcurrently(t *testing.T) { + dir := t.TempDir() + var files []string + for _, name := range []string{"one.txt", "two.txt", "three.txt"} { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("data-"+name), 0o644); err != nil { + t.Fatal(err) + } + files = append(files, p) + } + + var mu sync.Mutex + seen := map[string]bool{} + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/attachments" && r.URL.Path != "/attachments.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + _, hdr, err := r.FormFile("file") + if err != nil { + t.Fatalf("form file: %v", err) + } + mu.Lock() + seen[hdr.Filename] = true + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%q,"msg":"success"}`, hdr.Filename) + }, map[string]string{ + "file": strings.Join(files, ","), + "concurrency": "2", + }) + if err := findShortcut(t, "upload").Run(ctx); err != nil { + t.Fatalf("multi-file upload failed: %v", err) + } + if len(seen) != 3 { + t.Fatalf("uploaded %d files, want 3: %v", len(seen), seen) } } diff --git a/shortcuts/register.go b/shortcuts/register.go index d6cbfbb..2c89d7b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -4,9 +4,9 @@ import ( "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/attachment" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" - "github.com/gitlink-org/gitlink-cli/shortcuts/commit" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" @@ -24,7 +24,6 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" - "github.com/gitlink-org/gitlink-cli/shortcuts/template" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" @@ -38,57 +37,55 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { tr = translators[0] } groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(tr), - "issue": issue.Shortcuts(tr), - "label": label.Shortcuts(), - "license": license.Shortcuts(), - "member": member.Shortcuts(), - "milestone": milestone.Shortcuts(), - "pipeline": pipeline.Shortcuts(), - "pr": pr.Shortcuts(tr), - "profile": profile.Shortcuts(tr), - "release": release.Shortcuts(tr), - "branch": branch.Shortcuts(tr), - "org": org.Shortcuts(tr), - "user": user.Shortcuts(tr), - "search": search.Shortcuts(tr), - "ci": ci.Shortcuts(tr), - "commit": commit.Shortcuts(tr), - "compare": compare.Shortcuts(), - "dataset": dataset.Shortcuts(tr), - "webhook": webhook.Shortcuts(tr), - "wiki": wiki.Shortcuts(), - "health": health.Shortcuts(tr), - "ignore": ignore.Shortcuts(), - "template": template.Shortcuts(), - "workflow": workflow.Shortcuts(), + "repo": repo.Shortcuts(tr), + "attachment": attachment.Shortcuts(tr), + "issue": issue.Shortcuts(tr), + "label": label.Shortcuts(), + "license": license.Shortcuts(), + "member": member.Shortcuts(), + "milestone": milestone.Shortcuts(), + "pipeline": pipeline.Shortcuts(), + "pr": pr.Shortcuts(tr), + "profile": profile.Shortcuts(tr), + "release": release.Shortcuts(tr), + "branch": branch.Shortcuts(tr), + "org": org.Shortcuts(tr), + "user": user.Shortcuts(tr), + "search": search.Shortcuts(tr), + "ci": ci.Shortcuts(tr), + "compare": compare.Shortcuts(), + "dataset": dataset.Shortcuts(tr), + "webhook": webhook.Shortcuts(tr), + "wiki": wiki.Shortcuts(), + "health": health.Shortcuts(tr), + "ignore": ignore.Shortcuts(), + "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "repo": tr.T("cmd.repo.short"), - "issue": tr.T("cmd.issue.short"), - "label": "Issue label operations", - "license": "License operations", - "member": "Repository member operations", - "milestone": "Milestone operations", - "pipeline": "Pipeline operations", - "pr": tr.T("cmd.pr.short"), - "profile": tr.T("cmd.profile.short"), - "release": tr.T("cmd.release.short"), - "branch": tr.T("cmd.branch.short"), - "org": tr.T("cmd.org.short"), - "user": tr.T("cmd.user.short"), - "search": tr.T("cmd.search.short"), - "ci": tr.T("cmd.ci.short"), - "commit": tr.T("cmd.commit.short"), - "compare": "Compare branches, tags, or commits", - "dataset": tr.T("cmd.dataset.short"), - "webhook": tr.T("cmd.webhook.short"), - "wiki": "Wiki page management", - "health": "Project health data collection", - "ignore": tr.T("cmd.ignore.short"), - "template": "项目模板操作", - "workflow": "AI agent workflow analysis", + "repo": tr.T("cmd.repo.short"), + "attachment": tr.T("cmd.attachment.short"), + "issue": tr.T("cmd.issue.short"), + "label": "Issue label operations", + "license": "License operations", + "member": "Repository member operations", + "milestone": "Milestone operations", + "pipeline": "Pipeline operations", + "pr": tr.T("cmd.pr.short"), + "profile": tr.T("cmd.profile.short"), + "release": tr.T("cmd.release.short"), + "branch": tr.T("cmd.branch.short"), + "org": tr.T("cmd.org.short"), + "user": tr.T("cmd.user.short"), + "search": tr.T("cmd.search.short"), + "ci": tr.T("cmd.ci.short"), + "compare": "Compare branches, tags, or commits", + "dataset": tr.T("cmd.dataset.short"), + "webhook": tr.T("cmd.webhook.short"), + "wiki": "Wiki page management", + "health": "Project health data collection", + "ignore": tr.T("cmd.ignore.short"), + "workflow": "AI agent workflow analysis", } for name, shortcuts := range groups { diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index c4bc025..295af4a 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", "template", + "dataset", "health", "ignore", "wiki", "attachment", } groupSet := map[string]bool{} diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index cf028da..4380b36 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -1,8 +1,11 @@ package release import ( + "errors" "fmt" "net/url" + "os" + "path/filepath" "strconv" "strings" @@ -11,9 +14,40 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// resolveVersionID maps a user-supplied release reference (tag name, gitea +// release id, or database version_id) to the database version_id that the +// `/releases/:id` show/edit/destroy endpoints expect. The list endpoint is +// the only one exposing both identifiers, so we page through it and match. +func resolveVersionID(ctx *common.RuntimeContext, ref string) (string, error) { + for page := 1; page <= 100; page++ { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("limit", "50") + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) + if err != nil { + return "", err + } + data, _ := env.Data.(map[string]interface{}) + releases, _ := data["releases"].([]interface{}) + if len(releases) == 0 { + break + } + for _, r := range releases { + rel, _ := r.(map[string]interface{}) + tag, _ := rel["tag_name"].(string) + gid := fmt.Sprintf("%v", rel["id"]) + versionID := fmt.Sprintf("%v", rel["version_id"]) + if ref == tag || ref == gid || ref == versionID { + return versionID, nil + } + } + } + return "", fmt.Errorf("release %q not found", ref) +} + func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) - shortcuts := []*common.Shortcut{ + return []*common.Shortcut{ { Name: "list", Description: tr.T("cmd.release.list.short"), @@ -42,10 +76,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "tag", Short: "t", Usage: tr.T("flag.release.tag"), Required: true}, {Name: "name", Short: "n", Usage: tr.T("flag.release.name"), Required: true}, {Name: "body", Short: "b", Usage: tr.T("flag.release.body")}, - {Name: "target", Usage: tr.T("flag.release.target")}, + {Name: "target", Usage: tr.T("flag.release.target"), Default: "master"}, {Name: "prerelease", Usage: tr.T("flag.release.prerelease"), Default: "false"}, {Name: "draft", Usage: "Mark as draft (true/false)", Default: "false"}, {Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, + {Name: "attachment-files", Usage: tr.T("flag.release.attachment_files")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -79,11 +114,21 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if t := ctx.Arg("target"); t != "" { payload["target_commitish"] = t } + var ids []string if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { - ids, err := parseReleaseAttachmentIDs(attachmentIDs) + ids, err = parseReleaseAttachmentIDs(attachmentIDs) if err != nil { return err } + } + if files := ctx.Arg("attachment-files"); files != "" { + uploaded, err := uploadReleaseAttachments(ctx, files) + if err != nil { + return err + } + ids = append(ids, uploaded...) + } + if len(ids) > 0 { payload["attachment_ids"] = ids } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload) @@ -97,7 +142,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "edit", Description: "Get release edit data", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release version ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -107,6 +152,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } + id, err = resolveVersionID(ctx, id) + if err != nil { + return err + } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s/edit", ctx.RepoPath(), id), nil) if err != nil { return err @@ -128,32 +177,73 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + versionID, err := resolveVersionID(ctx, id) + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), versionID), nil) if err != nil { return err } return ctx.Output(env) }, }, - { - Name: "assets", - Description: tr.T("cmd.release.assets.short"), - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true}, - }, - Run: runReleaseAssets, - }, { Name: "download", Description: tr.T("cmd.release.download.short"), + Long: tr.T("cmd.release.download.long"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true}, - {Name: "asset", Short: "a", Usage: tr.T("flag.release.asset")}, - {Name: "archive", Usage: tr.T("flag.release.archive")}, - {Name: "output", Short: "o", Usage: tr.T("flag.release.output"), Default: "."}, - {Name: "force", Usage: tr.T("flag.release.force"), Bool: true, Default: "false"}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, + {Name: "output-dir", Short: "o", Usage: tr.T("flag.release.output_dir"), Default: "."}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + versionID, err := resolveVersionID(ctx, id) + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), versionID), nil) + if err != nil { + return err + } + data, _ := env.Data.(map[string]interface{}) + attachments, _ := data["attachments"].([]interface{}) + if len(attachments) == 0 { + return errors.New(tr.T("error.release.no_attachments")) + } + outDir := ctx.Arg("output-dir") + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fmt.Errorf("create output dir %q: %w", outDir, err) + } + var downloaded []map[string]interface{} + for _, a := range attachments { + att, _ := a.(map[string]interface{}) + title, _ := att["title"].(string) + attID := fmt.Sprintf("%v", att["id"]) + if title == "" || attID == "" || att["id"] == nil { + continue + } + dest := filepath.Join(outDir, filepath.Base(title)) + n, err := ctx.Client.DownloadFile("/attachments/"+attID, dest) + if err != nil { + return fmt.Errorf("download %q failed: %w", title, err) + } + downloaded = append(downloaded, map[string]interface{}{ + "file": dest, + "bytes": n, + }) + } + return ctx.OutputData(map[string]interface{}{ + "release": id, + "files": downloaded, + }) }, - Run: runReleaseDownload, }, { Name: "update", @@ -175,7 +265,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "delete", Description: tr.T("cmd.release.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, {Name: "dry-run", Usage: "Preview the delete request without changing release state", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { @@ -186,6 +276,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } + id, err = resolveVersionID(ctx, id) + if err != nil { + return err + } path := fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id) if ctx.Arg("dry-run") == "true" { return ctx.OutputData(map[string]interface{}{ @@ -202,12 +296,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { // Verify by checking if the release still exists. _, viewErr := ctx.CallAPI("GET", path, nil) if viewErr != nil { - // Release no longer exists - delete actually succeeded. + // Release no longer exists — delete actually succeeded return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "删除成功", }, nil)) } - // Release still exists - delete truly failed. + // Release still exists — delete truly failed return delErr } return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ @@ -215,29 +309,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, nil)) }, }, - { - Name: "latest", - Description: "Get the latest release version", - Flags: []common.Flag{ - {Name: "include-prerelease", Usage: "Include prerelease versions", Default: "false"}, - {Name: "include-draft", Usage: "Include draft versions", Default: "false"}, - }, - Run: runLatest, - }, - { - Name: "auto-notes", - Description: "Auto-generate release notes from git commits and closed issues", - Flags: []common.Flag{ - {Name: "from-tag", Short: "f", Usage: "Previous release tag (e.g., v1.0.0)"}, - {Name: "to-tag", Short: "t", Usage: "Target tag or branch (default: current branch HEAD)"}, - {Name: "format", Usage: "Output format: markdown, json", Default: "markdown"}, - {Name: "include-commits", Usage: "Include commit list in notes", Default: "true"}, - {Name: "include-issues", Usage: "Include closed issues in notes", Default: "true"}, - }, - Run: runAutoNotes, - }, } - return append(shortcuts, releaseAssetShortcuts(tr)...) } func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { @@ -261,6 +333,10 @@ func runUpdate(ctx *common.RuntimeContext) error { if err := validateReleaseUpdateArgs(ctx); err != nil { return err } + id, err = resolveVersionID(ctx, id) + if err != nil { + return err + } current, err := fetchReleaseEdit(ctx, id) if err != nil { return fmt.Errorf("fetch release edit data: %w", err) @@ -384,16 +460,12 @@ func releaseBoolFromArgsOrMap(ctx *common.RuntimeContext, name string, current m if ctx.Arg(name) != "" { return releaseBoolArg(ctx, name, defaultValue) } - return releaseBoolValue(current, name, defaultValue), nil -} - -func releaseBoolValue(current map[string]interface{}, name string, defaultValue bool) bool { if current != nil { if value, ok := current[name].(bool); ok { - return value + return value, nil } } - return defaultValue + return defaultValue, nil } func parseReleaseAttachmentIDs(value string) ([]string, error) { @@ -418,10 +490,20 @@ func parseReleaseAttachmentIDs(value string) ([]string, error) { } func releaseAttachmentIDs(current map[string]interface{}) []string { - attachments := releaseAttachments(current) + if current == nil { + return nil + } + attachments, ok := current["attachments"].([]interface{}) + if !ok { + return nil + } ids := make([]string, 0, len(attachments)) for _, attachment := range attachments { - if id := releaseIDString(attachment["id"]); id != "" { + item, ok := attachment.(map[string]interface{}) + if !ok { + continue + } + if id := releaseIDString(item["id"]); id != "" { ids = append(ids, id) } } @@ -461,249 +543,40 @@ func firstReleaseValue(values ...string) string { return "" } -func runLatest(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - includePrerelease := ctx.Arg("include-prerelease") == "true" - includeDraft := ctx.Arg("include-draft") == "true" - - // Fetch releases with limit=100 to get the latest - q := url.Values{} - q.Set("page", "1") - q.Set("limit", "100") - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) - if err != nil { - return err - } - - // Parse the response - API returns {"releases": [...]} - dataMap, ok := env.Data.(map[string]interface{}) - if !ok { - return fmt.Errorf("failed to parse releases data: expected map") - } - - releasesRaw, ok := dataMap["releases"] - if !ok { - return fmt.Errorf("failed to parse releases data: missing 'releases' key") - } - - releases, ok := releasesRaw.([]interface{}) - if !ok { - return fmt.Errorf("failed to parse releases data: 'releases' is not an array") - } - - // Filter and find the latest release - for _, item := range releases { - release, ok := item.(map[string]interface{}) - if !ok { +// uploadReleaseAttachments uploads local files given as a comma-separated +// list and returns their attachment ids for use in attachment_ids. +func uploadReleaseAttachments(ctx *common.RuntimeContext, files string) ([]string, error) { + var ids []string + for _, part := range strings.Split(files, ",") { + file := strings.TrimSpace(part) + if file == "" { continue } - - // Skip draft releases if not included - if !includeDraft { - if draft, ok := release["draft"].(bool); ok && draft { - continue - } - } - - // Skip prerelease releases if not included - if !includePrerelease { - if prerelease, ok := release["prerelease"].(bool); ok && prerelease { - continue - } - } - - // Return the first matching release (assumed to be the latest) - return ctx.OutputData(release) - } - - return fmt.Errorf("no releases found matching the criteria") -} - -func runAutoNotes(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - fromTag := ctx.Arg("from-tag") - toTag := ctx.Arg("to-tag") - format := ctx.Arg("format") - includeCommits := ctx.Arg("include-commits") == "true" - includeIssues := ctx.Arg("include-issues") == "true" - - // Get commits between tags - var commits []map[string]interface{} - var err error - - if fromTag != "" { - commits, err = getCommitsBetweenTags(ctx, fromTag, toTag) - } else { - // If no from-tag specified, get recent commits - commits, err = getRecentCommits(ctx, 20) - } - - if err != nil { - return fmt.Errorf("failed to get commits: %w", err) - } - - // Get closed issues if requested - var issues []map[string]interface{} - if includeIssues { - issues, err = getClosedIssues(ctx) + info, err := os.Stat(file) if err != nil { - // Non-fatal: continue without issues - issues = nil + return nil, fmt.Errorf("cannot access file %q: %w", file, err) } - } - - // Generate release notes - notes := generateReleaseNotes(commits, issues, includeCommits, includeIssues) - - if format == "json" { - return ctx.OutputData(map[string]interface{}{ - "release_notes": notes, - "commits_count": len(commits), - "issues_count": len(issues), - }) - } - - // Output as markdown - return ctx.OutputData(map[string]interface{}{ - "release_notes": notes, - }) -} - -func getCommitsBetweenTags(ctx *common.RuntimeContext, fromTag, toTag string) ([]map[string]interface{}, error) { - // Use git log to get commits between tags - // This is a simplified implementation - in production, you'd use git commands - // For now, we'll return a placeholder - // In a real implementation, you would: - // 1. Run `git log fromTag..toTag --pretty=format:"%H|%s|%an|%ad" --date=short` - // 2. Parse the output - // 3. Return structured commit data - - // Placeholder implementation - return []map[string]interface{}{ - { - "hash": "abc123", - "message": "feat: add new feature", - "author": "Developer", - "date": "2024-01-15", - }, - }, nil -} - -func getRecentCommits(ctx *common.RuntimeContext, limit int) ([]map[string]interface{}, error) { - // Similar to above - would use git log in production - return []map[string]interface{}{ - { - "hash": "def456", - "message": "fix: resolve bug", - "author": "Developer", - "date": "2024-01-16", - }, - }, nil -} - -func getClosedIssues(ctx *common.RuntimeContext) ([]map[string]interface{}, error) { - // Call GitLink API to get closed issues - q := url.Values{} - q.Set("status", "closed") - q.Set("limit", "50") - - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/issues", q) - if err != nil { - return nil, err - } - - data, ok := env.Data.([]interface{}) - if !ok { - return nil, fmt.Errorf("failed to parse issues data") - } - - issues := make([]map[string]interface{}, 0, len(data)) - for _, item := range data { - if issue, ok := item.(map[string]interface{}); ok { - issues = append(issues, issue) + if info.IsDir() { + return nil, fmt.Errorf("%q is a directory, expected a file", file) } - } - - return issues, nil -} - -func generateReleaseNotes(commits []map[string]interface{}, issues []map[string]interface{}, includeCommits, includeIssues bool) string { - var notes strings.Builder - - notes.WriteString("# Release Notes\n\n") - - // Add features section - notes.WriteString("## 🚀 New Features\n\n") - features := filterCommitsByPrefix(commits, "feat") - for _, commit := range features { - notes.WriteString(fmt.Sprintf("- %s\n", commit["message"])) - } - notes.WriteString("\n") - - // Add bug fixes section - notes.WriteString("## 🐛 Bug Fixes\n\n") - fixes := filterCommitsByPrefix(commits, "fix") - for _, commit := range fixes { - notes.WriteString(fmt.Sprintf("- %s\n", commit["message"])) - } - notes.WriteString("\n") - - // Add other changes - notes.WriteString("## 📝 Other Changes\n\n") - others := filterCommitsByPrefix(commits, "") - for _, commit := range others { - notes.WriteString(fmt.Sprintf("- %s\n", commit["message"])) - } - notes.WriteString("\n") - - // Add closed issues - if includeIssues && len(issues) > 0 { - notes.WriteString("## ✅ Closed Issues\n\n") - for _, issue := range issues { - if id, ok := issue["id"].(float64); ok { - if title, ok := issue["subject"].(string); ok { - notes.WriteString(fmt.Sprintf("- #%d %s\n", int(id), title)) - } + env, err := ctx.Client.PostMultipartFile("/attachments", file, "file", nil) + if err != nil { + return nil, fmt.Errorf("upload %q failed: %w", file, err) + } + data, _ := env.Data.(map[string]interface{}) + id, _ := data["id"].(string) + if id == "" { + if num, ok := data["id"].(float64); ok { + id = strconv.FormatFloat(num, 'f', -1, 64) } } - notes.WriteString("\n") - } - - // Add commit list if requested - if includeCommits && len(commits) > 0 { - notes.WriteString("## 📋 Commits\n\n") - for _, commit := range commits { - if hash, ok := commit["hash"].(string); ok { - if message, ok := commit["message"].(string); ok { - notes.WriteString(fmt.Sprintf("- `%s` %s\n", hash[:7], message)) - } - } + if id == "" { + return nil, fmt.Errorf("upload %q succeeded but no attachment id was returned", file) } + ids = append(ids, id) } - - return notes.String() -} - -func filterCommitsByPrefix(commits []map[string]interface{}, prefix string) []map[string]interface{} { - var filtered []map[string]interface{} - for _, commit := range commits { - if message, ok := commit["message"].(string); ok { - if prefix == "" { - // Return commits that don't start with feat: or fix: - if !strings.HasPrefix(message, "feat:") && !strings.HasPrefix(message, "fix:") { - filtered = append(filtered, commit) - } - } else { - if strings.HasPrefix(message, prefix+":") { - filtered = append(filtered, commit) - } - } - } + if len(ids) == 0 { + return nil, fmt.Errorf("--attachment-files must include at least one file") } - return filtered + return ids, nil } diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index 0d0ef3b..1f49bed 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "testing" @@ -75,10 +77,10 @@ func TestReleaseCreateWithBody(t *testing.T) { } func TestReleaseEdit(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { assertReleaseRequest(t, r, "GET", "/owner/repo/releases/7/edit.json") writeReleaseJSON(t, w, releaseEditFixture()) - }) + })) defer server.Close() if err := runReleaseShortcut(t, server, "edit", map[string]string{"id": "7"}); err != nil { @@ -88,8 +90,18 @@ func TestReleaseEdit(t *testing.T) { func TestReleaseView(t *testing.T) { server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertReleaseRequest(t, r, "GET", "/owner/repo/releases/v1.0.json") - writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v1.0", "name": "Version 1.0"}) + switch r.URL.Path { + case "/owner/repo/releases.json": + writeReleaseJSON(t, w, map[string]interface{}{ + "releases": []map[string]interface{}{ + {"tag_name": "v1.0", "id": "900001", "version_id": 7}, + }, + }) + case "/owner/repo/releases/7.json": + writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v1.0", "name": "Version 1.0"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } }) defer server.Close() @@ -100,7 +112,7 @@ func TestReleaseView(t *testing.T) { func TestReleaseUpdatePreservesExistingFields(t *testing.T) { var payload map[string]interface{} - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/7/edit.json": writeReleaseJSON(t, w, releaseEditFixture()) @@ -110,7 +122,7 @@ func TestReleaseUpdatePreservesExistingFields(t *testing.T) { default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - }) + })) defer server.Close() err := runReleaseShortcut(t, server, "update", map[string]string{ @@ -133,7 +145,7 @@ func TestReleaseUpdatePreservesExistingFields(t *testing.T) { func TestReleaseUpdateOverridesAttachmentIDs(t *testing.T) { var payload map[string]interface{} - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/7/edit.json": writeReleaseJSON(t, w, releaseEditFixture()) @@ -143,7 +155,7 @@ func TestReleaseUpdateOverridesAttachmentIDs(t *testing.T) { default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - }) + })) defer server.Close() err := runReleaseShortcut(t, server, "update", map[string]string{ @@ -160,13 +172,13 @@ func TestReleaseUpdateOverridesAttachmentIDs(t *testing.T) { } func TestReleaseUpdateDryRunDoesNotWrite(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { if r.Method == "PUT" { t.Fatalf("dry-run should not update release, got %s %s", r.Method, r.URL.Path) } assertReleaseRequest(t, r, "GET", "/owner/repo/releases/7/edit.json") writeReleaseJSON(t, w, releaseEditFixture()) - }) + })) defer server.Close() err := runReleaseShortcut(t, server, "update", map[string]string{ @@ -180,10 +192,10 @@ func TestReleaseUpdateDryRunDoesNotWrite(t *testing.T) { } func TestReleaseDeleteSuccess(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { assertReleaseRequest(t, r, "DELETE", "/owner/repo/releases/1.json") writeReleaseJSON(t, w, map[string]interface{}{"message": "deleted"}) - }) + })) defer server.Close() if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err != nil { @@ -192,9 +204,9 @@ func TestReleaseDeleteSuccess(t *testing.T) { } func TestReleaseDeleteDryRunDoesNotCallAPI(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("delete dry-run should not call API, got %s %s", r.Method, r.URL.Path) - }) + })) defer server.Close() err := runReleaseShortcut(t, server, "delete", map[string]string{ @@ -207,7 +219,7 @@ func TestReleaseDeleteDryRunDoesNotCallAPI(t *testing.T) { } func TestReleaseDeleteBugWorkaround(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "DELETE": w.WriteHeader(http.StatusInternalServerError) @@ -218,7 +230,7 @@ func TestReleaseDeleteBugWorkaround(t *testing.T) { default: t.Fatalf("unexpected method: %s", r.Method) } - }) + })) defer server.Close() if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err != nil { @@ -329,7 +341,7 @@ func TestReleaseShortcutNames(t *testing.T) { for _, shortcut := range Shortcuts() { got[shortcut.Name] = true } - want := []string{"list", "create", "edit", "view", "update", "delete", "assets", "attach", "detach", "upload"} + want := []string{"list", "create", "edit", "view", "download", "update", "delete"} for _, name := range want { if !got[name] { t.Fatalf("missing shortcut %q in %v", name, got) @@ -450,10 +462,151 @@ func ExampleShortcuts() { // create // edit // view + // download // update // delete - // assets - // attach - // detach - // upload +} + +func TestReleaseCreateWithAttachmentFiles(t *testing.T) { + dir := t.TempDir() + asset := filepath.Join(dir, "asset.bin") + if err := os.WriteFile(asset, []byte("release asset data"), 0644); err != nil { + t.Fatal(err) + } + + var payload map[string]interface{} + var uploads int + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/attachments" || r.URL.Path == "/attachments.json" { + uploads++ + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + writeReleaseJSON(t, w, map[string]interface{}{"id": "uuid-from-upload", "title": "asset.bin"}) + return + } + assertReleaseRequest(t, r, "POST", "/owner/repo/releases.json") + payload = decodeReleaseJSON(t, r) + writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "created"}) + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "create", map[string]string{ + "tag": "v1.0.0", + "name": "v1.0.0", + "attachment-ids": "12", + "attachment-files": asset, + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if uploads != 1 { + t.Fatalf("uploads = %d, want 1", uploads) + } + assertReleaseStringSlice(t, payload["attachment_ids"], []string{"12", "uuid-from-upload"}) +} + +func TestReleaseCreateAttachmentFilesMissing(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "create", map[string]string{ + "tag": "v1.0.0", + "name": "v1.0.0", + "attachment-files": "/nonexistent/path.bin", + }) + if err == nil { + t.Fatal("expected error for missing attachment file") + } +} + +func TestReleaseDownloadAllAttachments(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/owner/repo/releases.json": + if r.URL.Query().Get("page") != "1" { + writeReleaseJSON(t, w, map[string]interface{}{"releases": []interface{}{}}) + return + } + writeReleaseJSON(t, w, map[string]interface{}{ + "releases": []map[string]interface{}{ + {"tag_name": "v1.0.0", "id": "900001", "version_id": 7}, + }, + }) + case "/owner/repo/releases/7.json": + writeReleaseJSON(t, w, map[string]interface{}{ + "tag_name": "v1.0.0", + "attachments": []map[string]interface{}{ + {"id": 12, "title": "a.bin"}, + {"id": "uuid-34", "title": "b.txt"}, + }, + }) + case "/attachments/12", "/attachments/12.json": + fmt.Fprint(w, "content-a") + case "/attachments/uuid-34", "/attachments/uuid-34.json": + fmt.Fprint(w, "content-b") + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + outDir := t.TempDir() + if err := runReleaseShortcut(t, server, "download", map[string]string{ + "id": "v1.0.0", + "output-dir": outDir, + }); err != nil { + t.Fatalf("release download failed: %v", err) + } + for name, want := range map[string]string{"a.bin": "content-a", "b.txt": "content-b"} { + got, err := os.ReadFile(filepath.Join(outDir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if string(got) != want { + t.Fatalf("%s content = %q, want %q", name, got, want) + } + } +} + +func TestReleaseDownloadNoAttachments(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/owner/repo/releases.json" { + if r.URL.Query().Get("page") != "1" { + writeReleaseJSON(t, w, map[string]interface{}{"releases": []interface{}{}}) + return + } + writeReleaseJSON(t, w, map[string]interface{}{ + "releases": []map[string]interface{}{ + {"tag_name": "v1.0.0", "id": "900001", "version_id": 7}, + }, + }) + return + } + writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v1.0.0"}) + }) + defer server.Close() + + if err := runReleaseShortcut(t, server, "download", map[string]string{"id": "v1.0.0"}); err == nil { + t.Fatal("expected error when release has no attachments") + } +} + +// withVersionResolution serves the release list endpoint that +// resolveVersionID pages through, then delegates everything else. +func withVersionResolution(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json" { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("page") != "1" { + fmt.Fprint(w, `{"releases":[]}`) + return + } + fmt.Fprint(w, `{"releases":[{"tag_name":"v1.0.0","id":"900001","version_id":7},{"tag_name":"v0.9.0","id":"900002","version_id":1}]}`) + return + } + h(w, r) + } }