feat: 新增 Release 资产下载命令
This commit is contained in:
parent
71ca2bb683
commit
898af8cd80
|
|
@ -469,6 +469,11 @@ gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
|||
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
|
||||
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "Updated changelog" --dry-run
|
||||
|
||||
# List and download release assets
|
||||
gitlink-cli release +assets --owner Gitlink --repo forgeplus -i <version_id>
|
||||
gitlink-cli release +download --owner Gitlink --repo forgeplus -i <version_id> --asset gitlink-cli_linux_amd64.tar.gz -o dist/
|
||||
gitlink-cli release +download --owner Gitlink --repo forgeplus -i <version_id> --archive zip -o dist/source.zip
|
||||
|
||||
# Preview release deletion before executing it
|
||||
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
|
||||
```
|
||||
|
|
@ -742,7 +747,7 @@ See [skills/README.md](./skills/README.md) for details.
|
|||
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
|
||||
| `gitlink-member` | Repository member and invite link management |
|
||||
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
|
||||
| `gitlink-release` | Release management (create, edit, update, view, delete, etc.) |
|
||||
| `gitlink-release` | Release management (create, edit, update, view, delete, asset download, etc.) |
|
||||
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
|
||||
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
|
||||
| `gitlink-search` | Search (repositories, users, etc.) |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# 新增 Release 资产下载能力
|
||||
|
||||
Release 命令组现在支持 `release +assets` 和 `release +download`,可以先查看某个发布包含的附件、zip/tar 源码包,再按附件 ID、附件文件名或源码包类型下载到本地目录。
|
||||
|
||||
下载链路新增了 client 层原始字节获取能力,并通过 `RuntimeContext.Download()` 暴露给 shortcut,避免 release 命令直接绕过统一客户端封装。附件 URL 支持 GitLink 返回的 `/api/attachments/<id>` 形式,源码包也支持完整 URL。
|
||||
|
||||
`release +download` 默认不覆盖已有文件;发布只有一个附件时可以直接下载,存在多个附件时要求显式传入 `--asset`,减少自动化脚本误下文件的风险。本次变更补充了 client、release shortcut、帮助文案、README 和 Skill reference 测试与文档。
|
||||
|
|
@ -26,6 +26,12 @@ type APIError struct {
|
|||
Message string
|
||||
}
|
||||
|
||||
type DownloadResult struct {
|
||||
Data []byte
|
||||
ContentType string
|
||||
ContentDisposition string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||||
}
|
||||
|
|
@ -80,7 +86,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
}
|
||||
|
||||
if c.Debug {
|
||||
fmt.Printf("→ %s %s\n", method, fullURL)
|
||||
fmt.Printf("-> %s %s\n", method, fullURL)
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
|
|
@ -95,7 +101,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
}
|
||||
|
||||
if c.Debug {
|
||||
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
|
||||
fmt.Printf("<- %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
|
||||
}
|
||||
|
||||
// Check HTTP-level errors
|
||||
|
|
@ -173,6 +179,82 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
return output.SuccessEnvelope(raw, meta), nil
|
||||
}
|
||||
|
||||
func (c *Client) Download(path string) (*DownloadResult, error) {
|
||||
fullURL, err := c.resolveDownloadURL(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Debug {
|
||||
fmt.Printf("-> GET %s\n", fullURL)
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if c.Debug {
|
||||
fmt.Printf("<- %d %d bytes\n", resp.StatusCode, len(data))
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))),
|
||||
}
|
||||
}
|
||||
|
||||
return &DownloadResult{
|
||||
Data: data,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentDisposition: resp.Header.Get("Content-Disposition"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) resolveDownloadURL(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("download url is empty")
|
||||
}
|
||||
if u, err := url.Parse(path); err == nil && u.IsAbs() {
|
||||
return path, nil
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/") || path == "/api" {
|
||||
return apiDownloadURL(c.BaseURL, path), nil
|
||||
}
|
||||
if strings.HasPrefix(path, "/") {
|
||||
return webBaseURL(c.BaseURL) + path, nil
|
||||
}
|
||||
return c.BaseURL + normalizeAPIPath(c.BaseURL, path), nil
|
||||
}
|
||||
|
||||
func apiDownloadURL(baseURL, path string) string {
|
||||
base := strings.TrimRight(baseURL, "/")
|
||||
if strings.HasSuffix(base, "/api") {
|
||||
return base + strings.TrimPrefix(path, "/api")
|
||||
}
|
||||
return webBaseURL(base) + path
|
||||
}
|
||||
|
||||
func webBaseURL(baseURL string) string {
|
||||
base := strings.TrimRight(baseURL, "/")
|
||||
for _, suffix := range []string{"/api/v1", "/api"} {
|
||||
if strings.HasSuffix(base, suffix) {
|
||||
return strings.TrimSuffix(base, suffix)
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func shouldAppendJSONSuffix(path string) bool {
|
||||
if strings.HasSuffix(path, ".json") {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -547,6 +547,100 @@ func TestPaginateAllNotOK(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestClientDownload(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/attachments/7" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Write([]byte("asset bytes"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL + "/api"}
|
||||
result, err := c.Download("/api/attachments/7")
|
||||
if err != nil {
|
||||
t.Fatalf("Download error: %v", err)
|
||||
}
|
||||
if string(result.Data) != "asset bytes" {
|
||||
t.Fatalf("data = %q", result.Data)
|
||||
}
|
||||
if result.ContentType != "application/octet-stream" {
|
||||
t.Fatalf("content type = %q", result.ContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDownloadAPIPathWithV1BaseURL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/attachments/7" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Write([]byte("asset bytes"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL + "/api/v1"}
|
||||
result, err := c.Download("/api/attachments/7")
|
||||
if err != nil {
|
||||
t.Fatalf("Download error: %v", err)
|
||||
}
|
||||
if string(result.Data) != "asset bytes" {
|
||||
t.Fatalf("data = %q", result.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDownloadAbsoluteURL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/files/release.zip" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Write([]byte("zip bytes"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: "https://gitlink.example.com/api"}
|
||||
result, err := c.Download(server.URL + "/files/release.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("Download error: %v", err)
|
||||
}
|
||||
if string(result.Data) != "zip bytes" {
|
||||
t.Fatalf("data = %q", result.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDownloadWebRelativeURL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/owner/repo/releases/download/v1/app.zip" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Write([]byte("asset bytes"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL + "/api"}
|
||||
result, err := c.Download("/owner/repo/releases/download/v1/app.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("Download error: %v", err)
|
||||
}
|
||||
if string(result.Data) != "asset bytes" {
|
||||
t.Fatalf("data = %q", result.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDownloadHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("missing"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Download("/missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected download error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAppendJSONSuffixSkipsRawFilePath(t *testing.T) {
|
||||
if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") {
|
||||
t.Fatal("raw file path should not get .json suffix")
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@
|
|||
"cmd.profile.short": "User profile and statistics operations",
|
||||
"cmd.release.create.short": "Create a release",
|
||||
"cmd.release.delete.short": "Delete a release",
|
||||
"cmd.release.assets.short": "List release attachments and source archives",
|
||||
"cmd.release.download.short": "Download a release attachment or source archive",
|
||||
"cmd.release.list.short": "List releases",
|
||||
"cmd.release.short": "Release operations",
|
||||
"cmd.release.view.short": "View release details",
|
||||
|
|
@ -201,9 +203,13 @@
|
|||
"flag.profile.user": "Target user login (defaults to the authenticated user)",
|
||||
"flag.profile.year": "Year for the contribution heatmap (e.g. 2025)",
|
||||
"flag.release.body": "Release notes",
|
||||
"flag.release.archive": "Source archive type to download: zip or tar",
|
||||
"flag.release.asset": "Attachment ID or filename to download",
|
||||
"flag.release.force": "Allow overwriting an existing output file",
|
||||
"flag.release.id": "Release ID",
|
||||
"flag.release.id_or_tag": "Release ID or tag",
|
||||
"flag.release.name": "Release name",
|
||||
"flag.release.output": "Output file or directory path",
|
||||
"flag.release.prerelease": "Mark as prerelease (true/false)",
|
||||
"flag.release.tag": "Tag name",
|
||||
"flag.release.target": "Target branch",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@
|
|||
"cmd.profile.short": "用户画像与统计操作",
|
||||
"cmd.release.create.short": "创建发布",
|
||||
"cmd.release.delete.short": "删除发布",
|
||||
"cmd.release.assets.short": "列出发布附件和源码包",
|
||||
"cmd.release.download.short": "下载发布附件或源码包",
|
||||
"cmd.release.list.short": "列出发布",
|
||||
"cmd.release.short": "发布操作",
|
||||
"cmd.release.view.short": "查看发布详情",
|
||||
|
|
@ -201,9 +203,13 @@
|
|||
"flag.profile.user": "目标用户登录名(默认为当前认证用户)",
|
||||
"flag.profile.year": "贡献热力图的年份(如 2025)",
|
||||
"flag.release.body": "发布说明",
|
||||
"flag.release.archive": "下载源码包类型:zip 或 tar",
|
||||
"flag.release.asset": "要下载的附件 ID 或文件名",
|
||||
"flag.release.force": "允许覆盖已存在的输出文件",
|
||||
"flag.release.id": "发布 ID",
|
||||
"flag.release.id_or_tag": "发布 ID 或标签",
|
||||
"flag.release.name": "发布名称",
|
||||
"flag.release.output": "输出文件或目录路径",
|
||||
"flag.release.prerelease": "标记为预发布(true/false)",
|
||||
"flag.release.tag": "标签名称",
|
||||
"flag.release.target": "目标分支",
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R
|
|||
return ctx.Client.PaginateAll(path, params)
|
||||
}
|
||||
|
||||
// Download fetches raw bytes from an API, attachment, or archive URL.
|
||||
func (ctx *RuntimeContext) Download(path string) (*client.DownloadResult, error) {
|
||||
return ctx.Client.Download(path)
|
||||
}
|
||||
|
||||
// Output prints the envelope in the configured format.
|
||||
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
|
||||
return output.Print(env, ctx.Format)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
package release
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type releaseAsset struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Filesize string `json:"filesize,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func runReleaseAssets(ctx *common.RuntimeContext) error {
|
||||
release, err := fetchRelease(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(releaseAssetsPayload(release), nil))
|
||||
}
|
||||
|
||||
func runReleaseDownload(ctx *common.RuntimeContext) error {
|
||||
release, err := fetchRelease(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceURL, filename, sourceType, err := selectDownloadSource(ctx, release)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := ctx.Download(sourceURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outPath, err := resolveOutputPath(ctx.Arg("output"), filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Arg("force") != "true" {
|
||||
if _, err := os.Stat(outPath); err == nil {
|
||||
return fmt.Errorf("output file already exists: %s (use --force to overwrite)", outPath)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(outPath, result.Data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"path": outPath,
|
||||
"bytes": len(result.Data),
|
||||
"source": sourceURL,
|
||||
"source_type": sourceType,
|
||||
"content_type": result.ContentType,
|
||||
}, nil))
|
||||
}
|
||||
|
||||
func fetchRelease(ctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected release response shape")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func releaseAssetsPayload(release map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"version_id": stringValue(release["version_id"]),
|
||||
"tag_name": stringValue(release["tag_name"]),
|
||||
"name": stringValue(release["name"]),
|
||||
"attachments": releaseAttachments(release),
|
||||
"archives": map[string]string{
|
||||
"zip": stringValue(release["zipball_url"]),
|
||||
"tar": stringValue(release["tarball_url"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func selectDownloadSource(ctx *common.RuntimeContext, release map[string]interface{}) (string, string, string, error) {
|
||||
archive := strings.ToLower(strings.TrimSpace(ctx.Arg("archive")))
|
||||
assetSelector := strings.TrimSpace(ctx.Arg("asset"))
|
||||
if archive != "" && assetSelector != "" {
|
||||
return "", "", "", fmt.Errorf("--asset and --archive cannot be used together")
|
||||
}
|
||||
if archive != "" {
|
||||
return selectArchiveSource(ctx, release, archive)
|
||||
}
|
||||
|
||||
assets := releaseAttachments(release)
|
||||
asset, err := selectAsset(assets, assetSelector)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return asset.URL, safeFilename(asset.Title, "attachment-"+asset.ID), "asset", nil
|
||||
}
|
||||
|
||||
func selectArchiveSource(ctx *common.RuntimeContext, release map[string]interface{}, archive string) (string, string, string, error) {
|
||||
tag := stringValue(release["tag_name"])
|
||||
if tag == "" {
|
||||
tag = ctx.Arg("id")
|
||||
}
|
||||
switch archive {
|
||||
case "zip":
|
||||
u := stringValue(release["zipball_url"])
|
||||
if u == "" {
|
||||
return "", "", "", fmt.Errorf("release does not provide zipball_url")
|
||||
}
|
||||
return u, safeFilename(fmt.Sprintf("%s-%s.zip", ctx.Repo, tag), "release.zip"), "archive", nil
|
||||
case "tar", "tar.gz", "tgz":
|
||||
u := stringValue(release["tarball_url"])
|
||||
if u == "" {
|
||||
return "", "", "", fmt.Errorf("release does not provide tarball_url")
|
||||
}
|
||||
return u, safeFilename(fmt.Sprintf("%s-%s.tar.gz", ctx.Repo, tag), "release.tar.gz"), "archive", nil
|
||||
default:
|
||||
return "", "", "", fmt.Errorf("--archive must be zip or tar")
|
||||
}
|
||||
}
|
||||
|
||||
func selectAsset(assets []releaseAsset, selector string) (releaseAsset, error) {
|
||||
if len(assets) == 0 {
|
||||
return releaseAsset{}, fmt.Errorf("release has no attachments")
|
||||
}
|
||||
if selector == "" {
|
||||
if len(assets) == 1 {
|
||||
return assets[0], nil
|
||||
}
|
||||
return releaseAsset{}, fmt.Errorf("release has multiple attachments; specify --asset by id or title")
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if asset.ID == selector || asset.Title == selector {
|
||||
return asset, nil
|
||||
}
|
||||
}
|
||||
return releaseAsset{}, fmt.Errorf("release attachment %q not found", selector)
|
||||
}
|
||||
|
||||
func releaseAttachments(release map[string]interface{}) []releaseAsset {
|
||||
raw, ok := release["attachments"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
assets := make([]releaseAsset, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
assets = append(assets, releaseAsset{
|
||||
ID: stringValue(m["id"]),
|
||||
Title: stringValue(m["title"]),
|
||||
Filesize: stringValue(m["filesize"]),
|
||||
Description: stringValue(m["description"]),
|
||||
URL: stringValue(m["url"]),
|
||||
})
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func resolveOutputPath(out, filename string) (string, error) {
|
||||
if strings.TrimSpace(out) == "" {
|
||||
out = "."
|
||||
}
|
||||
if info, err := os.Stat(out); err == nil && info.IsDir() {
|
||||
return filepath.Join(out, filename), nil
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasSuffix(out, string(os.PathSeparator)) || strings.HasSuffix(out, "/") {
|
||||
return filepath.Join(out, filename), nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func safeFilename(name, fallback string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = fallback
|
||||
}
|
||||
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||
if name == "." || name == string(os.PathSeparator) || name == "" {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func stringValue(v interface{}) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return strconv.FormatInt(int64(x), 10)
|
||||
}
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(x)
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
|
@ -135,6 +135,26 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
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"),
|
||||
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"},
|
||||
},
|
||||
Run: runReleaseDownload,
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a release while preserving unspecified fields",
|
||||
|
|
@ -182,12 +202,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{}{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
|
|
@ -98,6 +100,147 @@ func TestReleaseView(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReleaseAssets(t *testing.T) {
|
||||
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertReleaseRequest(t, r, "GET", "/owner/repo/releases/7.json")
|
||||
writeReleaseJSON(t, w, releaseDetail(serverURL(r), []map[string]interface{}{
|
||||
{"id": float64(10), "title": "app.zip", "filesize": "12 KB", "url": "/api/attachments/10"},
|
||||
}))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runReleaseShortcut(t, server, "assets", map[string]string{"id": "7"}); err != nil {
|
||||
t.Fatalf("assets failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadAssetByName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/owner/repo/releases/7.json":
|
||||
writeReleaseJSON(t, w, releaseDetail(serverURL(r), []map[string]interface{}{
|
||||
{"id": float64(10), "title": "app.zip", "filesize": "12 KB", "url": "/api/attachments/10"},
|
||||
}))
|
||||
case "/api/attachments/10":
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write([]byte("asset bytes"))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||
"id": "7",
|
||||
"asset": "app.zip",
|
||||
"output": dir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download asset failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "app.zip"))
|
||||
if err != nil {
|
||||
t.Fatalf("read downloaded file: %v", err)
|
||||
}
|
||||
if string(data) != "asset bytes" {
|
||||
t.Fatalf("downloaded data = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadSingleAssetByDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/owner/repo/releases/7.json":
|
||||
writeReleaseJSON(t, w, releaseDetail(serverURL(r), []map[string]interface{}{
|
||||
{"id": float64(10), "title": "../unsafe.txt", "url": "/owner/repo/releases/download/v1/unsafe.txt"},
|
||||
}))
|
||||
case "/owner/repo/releases/download/v1/unsafe.txt":
|
||||
_, _ = w.Write([]byte("safe"))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||
"id": "7",
|
||||
"output": dir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download default asset failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "unsafe.txt")); err != nil {
|
||||
t.Fatalf("expected sanitized file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadArchiveZip(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "source.zip")
|
||||
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/owner/repo/releases/7.json":
|
||||
writeReleaseJSON(t, w, releaseDetail(serverURL(r), nil))
|
||||
case "/archive/v1.0.zip":
|
||||
_, _ = w.Write([]byte("zip bytes"))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||
"id": "7",
|
||||
"archive": "zip",
|
||||
"output": out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download archive failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("read archive: %v", err)
|
||||
}
|
||||
if string(data) != "zip bytes" {
|
||||
t.Fatalf("archive data = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadRequiresAssetWhenMultipleAttachments(t *testing.T) {
|
||||
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeReleaseJSON(t, w, releaseDetail(serverURL(r), []map[string]interface{}{
|
||||
{"id": float64(10), "title": "a.zip", "url": "/api/attachments/10"},
|
||||
{"id": float64(11), "title": "b.zip", "url": "/api/attachments/11"},
|
||||
}))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{"id": "7", "output": t.TempDir()})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when multiple attachments need --asset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadDoesNotOverwriteWithoutForce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "app.zip")
|
||||
if err := os.WriteFile(out, []byte("exists"), 0644); err != nil {
|
||||
t.Fatalf("seed output file: %v", err)
|
||||
}
|
||||
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeReleaseJSON(t, w, releaseDetail(serverURL(r), []map[string]interface{}{
|
||||
{"id": float64(10), "title": "app.zip", "url": "/api/attachments/10"},
|
||||
}))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{"id": "7", "output": dir})
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite protection error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseUpdatePreservesExistingFields(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -329,7 +472,7 @@ func TestReleaseShortcutNames(t *testing.T) {
|
|||
for _, shortcut := range Shortcuts() {
|
||||
got[shortcut.Name] = true
|
||||
}
|
||||
want := []string{"list", "create", "edit", "view", "update", "delete"}
|
||||
want := []string{"list", "create", "edit", "view", "assets", "download", "update", "delete"}
|
||||
for _, name := range want {
|
||||
if !got[name] {
|
||||
t.Fatalf("missing shortcut %q in %v", name, got)
|
||||
|
|
@ -391,6 +534,25 @@ func releaseEditFixture() map[string]interface{} {
|
|||
}
|
||||
}
|
||||
|
||||
func releaseDetail(baseURL string, attachments []map[string]interface{}) map[string]interface{} {
|
||||
rawAttachments := make([]interface{}, 0, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
rawAttachments = append(rawAttachments, attachment)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"version_id": float64(7),
|
||||
"tag_name": "v1.0",
|
||||
"name": "v1.0",
|
||||
"tarball_url": baseURL + "/archive/v1.0.tar.gz",
|
||||
"zipball_url": baseURL + "/archive/v1.0.zip",
|
||||
"attachments": rawAttachments,
|
||||
}
|
||||
}
|
||||
|
||||
func serverURL(r *http.Request) string {
|
||||
return "http://" + r.Host
|
||||
}
|
||||
|
||||
func assertReleaseRequest(t *testing.T, r *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if r.Method != method || r.URL.Path != path {
|
||||
|
|
@ -450,6 +612,8 @@ func ExampleShortcuts() {
|
|||
// create
|
||||
// edit
|
||||
// view
|
||||
// assets
|
||||
// download
|
||||
// update
|
||||
// delete
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,5 +65,7 @@ gitlink-cli release +delete --id <version_id>
|
|||
- [release +create](references/gitlink-release-create.md)
|
||||
- [release +edit](references/gitlink-release-edit.md)
|
||||
- [release +update](references/gitlink-release-update.md)
|
||||
- [release +assets](references/gitlink-release-assets.md)
|
||||
- [release +download](references/gitlink-release-download.md)
|
||||
- [release +view](references/gitlink-release-view.md)
|
||||
- [release +delete](references/gitlink-release-delete.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
# release +assets
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出指定发布的附件和源码包下载地址。`--id` 必须使用 `version_id`,可从 `release +list` 的返回中获取。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
gitlink-cli release +assets --owner someone --repo myrepo --id 12345
|
||||
gitlink-cli release +assets --id 12345 --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--id, -i` | 是 | 发布 `version_id` |
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## References
|
||||
- [gitlink-release](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# release +download
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
下载指定发布的附件或源码包。`--asset` 可传附件 ID 或文件名,`--archive` 可传 `zip` 或 `tar`;两者不能同时使用。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 下载附件
|
||||
gitlink-cli release +download --owner someone --repo myrepo --id 12345 --asset app.zip --output dist/
|
||||
|
||||
# 下载源码 zip
|
||||
gitlink-cli release +download --owner someone --repo myrepo --id 12345 --archive zip --output dist/source.zip
|
||||
|
||||
# 允许覆盖已有文件
|
||||
gitlink-cli release +download --id 12345 --asset 441826 --output app.zip --force
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--id, -i` | 是 | 发布 `version_id` |
|
||||
| `--asset, -a` | 否 | 要下载的附件 ID 或文件名 |
|
||||
| `--archive` | 否 | 下载源码包,取值 `zip` 或 `tar` |
|
||||
| `--output, -o` | 否 | 输出文件或目录路径,默认当前目录 |
|
||||
| `--force` | 否 | 允许覆盖已存在的输出文件 |
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 如果发布只有一个附件,不传 `--asset` 时会默认下载该附件。
|
||||
- 如果发布有多个附件,必须传 `--asset` 指定 ID 或文件名,避免下载错文件。
|
||||
- 默认不会覆盖已存在文件,需要覆盖时传 `--force`。
|
||||
|
||||
## References
|
||||
- [gitlink-release](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
Loading…
Reference in New Issue