From 59b2727feb7af3ce851e3d57597814ce1fe049b7 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Sat, 6 Jun 2026 11:12:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Release=20?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E4=B8=8B=E8=BD=BD=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- doc/changes/release-download-shortcuts.md | 7 + internal/client/client.go | 86 ++++++- internal/client/client_test.go | 94 ++++++++ internal/i18n/locales/en-US.json | 6 + internal/i18n/locales/zh-CN.json | 6 + shortcuts/common/types.go | 5 + shortcuts/release/assets.go | 224 ++++++++++++++++++ shortcuts/release/release.go | 24 +- shortcuts/release/release_test.go | 166 ++++++++++++- skills/gitlink-release/SKILL.md | 2 + .../references/gitlink-release-assets.md | 27 +++ .../references/gitlink-release-download.md | 42 ++++ 13 files changed, 690 insertions(+), 6 deletions(-) create mode 100644 doc/changes/release-download-shortcuts.md create mode 100644 shortcuts/release/assets.go create mode 100644 skills/gitlink-release/references/gitlink-release-assets.md create mode 100644 skills/gitlink-release/references/gitlink-release-download.md diff --git a/README.md b/README.md index e5e4318..3d28a5a 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,11 @@ gitlink-cli release +view --owner Gitlink --repo forgeplus -i gitlink-cli release +edit --owner Gitlink --repo forgeplus -i gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b "Updated changelog" --dry-run +# List and download release assets +gitlink-cli release +assets --owner Gitlink --repo forgeplus -i +gitlink-cli release +download --owner Gitlink --repo forgeplus -i --asset gitlink-cli_linux_amd64.tar.gz -o dist/ +gitlink-cli release +download --owner Gitlink --repo forgeplus -i --archive zip -o dist/source.zip + # Preview release deletion before executing it gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --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.) | diff --git a/doc/changes/release-download-shortcuts.md b/doc/changes/release-download-shortcuts.md new file mode 100644 index 0000000..7aef6d2 --- /dev/null +++ b/doc/changes/release-download-shortcuts.md @@ -0,0 +1,7 @@ +# 新增 Release 资产下载能力 + +Release 命令组现在支持 `release +assets` 和 `release +download`,可以先查看某个发布包含的附件、zip/tar 源码包,再按附件 ID、附件文件名或源码包类型下载到本地目录。 + +下载链路新增了 client 层原始字节获取能力,并通过 `RuntimeContext.Download()` 暴露给 shortcut,避免 release 命令直接绕过统一客户端封装。附件 URL 支持 GitLink 返回的 `/api/attachments/` 形式,源码包也支持完整 URL。 + +`release +download` 默认不覆盖已有文件;发布只有一个附件时可以直接下载,存在多个附件时要求显式传入 `--asset`,减少自动化脚本误下文件的风险。本次变更补充了 client、release shortcut、帮助文案、README 和 Skill reference 测试与文档。 diff --git a/internal/client/client.go b/internal/client/client.go index 1fb9d80..3178dd6 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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 diff --git a/internal/client/client_test.go b/internal/client/client_test.go index c46226b..b7d08e9 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -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") diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..25bacf4 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..5bebf81 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -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": "目标分支", diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 87a052a..2c601e8 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -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) diff --git a/shortcuts/release/assets.go b/shortcuts/release/assets.go new file mode 100644 index 0000000..768da55 --- /dev/null +++ b/shortcuts/release/assets.go @@ -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 "" + } +} diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 21d7ec4..00bf3c4 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -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{}{ diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index aa31664..13c6fe6 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "testing" @@ -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 } diff --git a/skills/gitlink-release/SKILL.md b/skills/gitlink-release/SKILL.md index 1fbe3ae..68d5bb4 100644 --- a/skills/gitlink-release/SKILL.md +++ b/skills/gitlink-release/SKILL.md @@ -65,5 +65,7 @@ gitlink-cli release +delete --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) diff --git a/skills/gitlink-release/references/gitlink-release-assets.md b/skills/gitlink-release/references/gitlink-release-assets.md new file mode 100644 index 0000000..bb9e14d --- /dev/null +++ b/skills/gitlink-release/references/gitlink-release-assets.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) diff --git a/skills/gitlink-release/references/gitlink-release-download.md b/skills/gitlink-release/references/gitlink-release-download.md new file mode 100644 index 0000000..c256723 --- /dev/null +++ b/skills/gitlink-release/references/gitlink-release-download.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)