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:
commit
7a0f873643
24
README.md
24
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue