Merge PR #353: feat(attachment): 新增 attachment +upload/+download 附件上传下载命令组

# Conflicts:
#	internal/i18n/locales/en-US.json
#	internal/i18n/locales/zh-CN.json
#	shortcuts/attachment/attachment.go
#	shortcuts/attachment/attachment_test.go
#	shortcuts/register.go
#	shortcuts/register_test.go
#	shortcuts/release/release.go
#	shortcuts/release/release_test.go
This commit is contained in:
wbtiger 2026-07-14 22:50:10 +08:00
commit 7a0f873643
13 changed files with 1203 additions and 785 deletions

View File

@ -432,6 +432,30 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --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 <attachment_id> -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 <attachment_id>
# 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

View File

@ -423,6 +423,30 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --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 <attachment_id> -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 <attachment_id>
# 一步到位:上传本地文件并附加到新发行版
gitlink-cli release +create -t v1.0.0 -n "v1.0.0" --attachment-files ./dist/app.tar.gz,./dist/app.sha256
```
### 流水线管理
```bash

View File

@ -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 {

261
internal/client/upload.go Normal file
View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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}",

View File

@ -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}",

View File

@ -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")
}

View File

@ -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("<!doctype html><html></html>"))
}, 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)
}
}

View File

@ -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 {

View File

@ -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{}

View File

@ -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
}

View File

@ -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)
}
}