From 2d963ebf41b0329e40974e866dee35b0fad1c0dc Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Wed, 27 May 2026 22:38:21 +0800 Subject: [PATCH] feat(release): add edit and update shortcuts --- README.md | 15 +- README.zh-CN.md | 15 +- doc/changes/release-update-shortcuts.md | 23 + shortcuts/release/release.go | 318 +++++++++++- shortcuts/release/release_test.go | 464 +++++++++++++----- skills/README.md | 2 +- skills/gitlink-release/SKILL.md | 26 +- .../references/gitlink-release-create.md | 7 +- .../references/gitlink-release-delete.md | 9 +- .../references/gitlink-release-edit.md | 27 + .../references/gitlink-release-update.md | 49 ++ 11 files changed, 812 insertions(+), 143 deletions(-) create mode 100644 doc/changes/release-update-shortcuts.md create mode 100644 skills/gitlink-release/references/gitlink-release-edit.md create mode 100644 skills/gitlink-release/references/gitlink-release-update.md diff --git a/README.md b/README.md index eed6478..039d3ab 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | | 🌿 Branch | Create, delete, list, protect, unprotect branches | -| 🏷️ Release | Create, view, delete releases | +| 🏷️ Release | Create, edit, update, view, delete releases | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | @@ -406,11 +406,18 @@ gitlink-cli branch +unprotect --name main # List releases gitlink-cli release +list --owner Gitlink --repo forgeplus -# Create a release -gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..." +# Create a release with release notes and optional assets +gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..." --attachment-ids 12,34 # View a release gitlink-cli release +view --owner Gitlink --repo forgeplus -i + +# Get edit data and update while preserving unspecified fields +gitlink-cli release +edit --owner Gitlink --repo forgeplus -i +gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b "Updated changelog" --dry-run + +# Preview release deletion before executing it +gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run ``` ### CI/CD Operations @@ -623,7 +630,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, view, delete, etc.) | +| `gitlink-release` | Release management (create, edit, update, view, delete, 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/README.zh-CN.md b/README.zh-CN.md index 7336d68..fe3f0d0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -93,7 +93,7 @@ | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | | 🌿 分支 | 创建、删除、保护分支 | -| 🏷️ 发布 | 创建、查看、删除 Release | +| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | @@ -397,11 +397,18 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved # 列出 Release gitlink-cli release +list --owner Gitlink --repo forgeplus -# 创建 Release -gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..." +# 创建 Release,可附带附件 ID +gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..." --attachment-ids 12,34 # 查看 Release 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 "更新后的内容" --dry-run + +# 删除前先预览请求 +gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run ``` ### 流水线管理 @@ -501,7 +508,7 @@ git push gitlink | `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | | `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | | `gitlink-member` | 仓库成员与邀请链接管理 | -| `gitlink-release` | 发布管理(创建、查看、删除等) | +| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) | | `gitlink-org` | 组织管理(成员、团队等) | | `gitlink-ci` | CI/CD 操作(构建、日志等) | | `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) | diff --git a/doc/changes/release-update-shortcuts.md b/doc/changes/release-update-shortcuts.md new file mode 100644 index 0000000..b71bbbb --- /dev/null +++ b/doc/changes/release-update-shortcuts.md @@ -0,0 +1,23 @@ +# Release Update Shortcuts + +Submitter: Wang Yue + +This change completes the release shortcut coverage for the release edit/update OpenAPI endpoints and improves release write safety. + +## Commands + +- Add `release +edit` for `/api/{owner}/{repo}/releases/{id}/edit.json`. +- Add `release +update` for `PUT /api/{owner}/{repo}/releases/{id}.json`. +- Extend `release +create` with `--draft` and `--attachment-ids`. +- Extend `release +delete` with `--dry-run`. + +## Behavior + +- `release +update` fetches current edit data first, then preserves unspecified fields such as `name`, `tag_name`, `body`, `target_commitish`, `draft`, `prerelease`, and existing attachment IDs. +- `release +update` validates boolean flags before reading remote data. +- `release +update` and `release +delete` support `--dry-run` to preview write/delete requests. +- `release +create` validates boolean flags and de-duplicates comma-separated attachment IDs. + +## Verification + +- Unit tests cover create payloads, edit endpoint routing, update field preservation, attachment overrides, dry-run behavior, and invalid argument validation. diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index c93d6a0..21d7ec4 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -3,6 +3,8 @@ package release import ( "fmt" "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" @@ -42,16 +44,34 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "body", Short: "b", Usage: tr.T("flag.release.body")}, {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"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - tag, _ := ctx.RequireArg("tag") - name, _ := ctx.RequireArg("name") + tag, err := ctx.RequireArg("tag") + if err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + draft, err := releaseBoolArg(ctx, "draft", false) + if err != nil { + return err + } + prerelease, err := releaseBoolArg(ctx, "prerelease", false) + if err != nil { + return err + } payload := map[string]interface{}{ - "tag_name": tag, - "name": name, + "tag_name": tag, + "name": name, + "draft": draft, + "prerelease": prerelease, } if b := ctx.Arg("body"); b != "" { payload["body"] = b @@ -59,8 +79,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if t := ctx.Arg("target"); t != "" { payload["target_commitish"] = t } - if ctx.Arg("prerelease") == "true" { - payload["prerelease"] = true + if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { + ids, err := parseReleaseAttachmentIDs(attachmentIDs) + if err != nil { + return err + } + payload["attachment_ids"] = ids } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload) if err != nil { @@ -69,6 +93,27 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "edit", + Description: "Get release edit data", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Release version ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("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 + } + return ctx.Output(env) + }, + }, { Name: "view", Description: tr.T("cmd.release.view.short"), @@ -79,7 +124,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id") + if err != nil { + return err + } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) if err != nil { return err @@ -87,22 +135,52 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "update", + Description: "Update a release while preserving unspecified fields", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Release version ID", Required: true}, + {Name: "tag", Short: "t", Usage: "Tag name"}, + {Name: "name", Short: "n", Usage: "Release name"}, + {Name: "body", Short: "b", Usage: "Release notes"}, + {Name: "target", Usage: "Target branch"}, + {Name: "prerelease", Usage: "Mark as prerelease (true/false)"}, + {Name: "draft", Usage: "Mark as draft (true/false)"}, + {Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, + {Name: "dry-run", Usage: "Preview the update request without changing release state", Bool: true, Default: "false"}, + }, + Run: runUpdate, + }, { 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: "dry-run", Usage: "Preview the delete request without changing release state", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - _, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("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{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "delete_release", + "method": "DELETE", + "path": path, + }) + } + _, delErr := ctx.CallAPI("DELETE", path, nil) if delErr != nil { // GitLink API bug: delete succeeds but returns error status. // Verify by checking if the release still exists. - _, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + _, viewErr := ctx.CallAPI("GET", path, nil) if viewErr != nil { // Release no longer exists — delete actually succeeded return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ @@ -126,3 +204,223 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { } return i18n.Default() } + +func runUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + if !hasReleaseUpdateArgs(ctx) { + return fmt.Errorf("at least one of --tag, --name, --body, --target, --draft, --prerelease, or --attachment-ids is required") + } + if err := validateReleaseUpdateArgs(ctx); err != nil { + return err + } + current, err := fetchReleaseEdit(ctx, id) + if err != nil { + return fmt.Errorf("fetch release edit data: %w", err) + } + payload, err := releaseUpdatePayload(ctx, current) + 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{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "update_release", + "method": "PUT", + "path": path, + "payload": payload, + }) + } + env, err := ctx.CallAPI("PUT", path, payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func fetchReleaseEdit(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s/edit", ctx.RepoPath(), id), nil) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to parse release edit data") + } + return data, nil +} + +func releaseUpdatePayload(ctx *common.RuntimeContext, current map[string]interface{}) (map[string]interface{}, error) { + name := firstReleaseValue(ctx.Arg("name"), releaseString(current, "name")) + if name == "" { + return nil, fmt.Errorf("required release name is missing; pass --name") + } + tag := firstReleaseValue(ctx.Arg("tag"), releaseString(current, "tag_name")) + if tag == "" { + return nil, fmt.Errorf("required release tag is missing; pass --tag") + } + body := firstReleaseValue(ctx.Arg("body"), releaseString(current, "body")) + target := firstReleaseValue(ctx.Arg("target"), releaseString(current, "target_commitish")) + + draft, err := releaseBoolFromArgsOrMap(ctx, "draft", current, false) + if err != nil { + return nil, err + } + prerelease, err := releaseBoolFromArgsOrMap(ctx, "prerelease", current, false) + if err != nil { + return nil, err + } + + payload := map[string]interface{}{ + "name": name, + "tag_name": tag, + "body": body, + "target_commitish": target, + "draft": draft, + "prerelease": prerelease, + } + if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { + ids, err := parseReleaseAttachmentIDs(attachmentIDs) + if err != nil { + return nil, err + } + payload["attachment_ids"] = ids + } else if ids := releaseAttachmentIDs(current); len(ids) > 0 { + payload["attachment_ids"] = ids + } + return payload, nil +} + +func hasReleaseUpdateArgs(ctx *common.RuntimeContext) bool { + for _, name := range []string{"tag", "name", "body", "target", "draft", "prerelease", "attachment-ids"} { + if ctx.Arg(name) != "" { + return true + } + } + return false +} + +func validateReleaseUpdateArgs(ctx *common.RuntimeContext) error { + for _, name := range []string{"draft", "prerelease"} { + if ctx.Arg(name) == "" { + continue + } + if _, err := releaseBoolArg(ctx, name, false); err != nil { + return err + } + } + if ctx.Arg("attachment-ids") != "" { + _, err := parseReleaseAttachmentIDs(ctx.Arg("attachment-ids")) + return err + } + return nil +} + +func releaseBoolArg(ctx *common.RuntimeContext, name string, defaultValue bool) (bool, error) { + value := strings.TrimSpace(ctx.Arg(name)) + if value == "" { + return defaultValue, nil + } + switch strings.ToLower(value) { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid --%s value %q: use true or false", name, value) + } +} + +func releaseBoolFromArgsOrMap(ctx *common.RuntimeContext, name string, current map[string]interface{}, defaultValue bool) (bool, error) { + if ctx.Arg(name) != "" { + return releaseBoolArg(ctx, name, defaultValue) + } + if current != nil { + if value, ok := current[name].(bool); ok { + return value, nil + } + } + return defaultValue, nil +} + +func parseReleaseAttachmentIDs(value string) ([]string, error) { + parts := strings.Split(value, ",") + ids := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + id := strings.TrimSpace(part) + if id == "" { + continue + } + if seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, fmt.Errorf("--attachment-ids must include at least one ID") + } + return ids, nil +} + +func releaseAttachmentIDs(current map[string]interface{}) []string { + if current == nil { + return nil + } + attachments, ok := current["attachments"].([]interface{}) + if !ok { + return nil + } + ids := make([]string, 0, len(attachments)) + for _, attachment := range attachments { + item, ok := attachment.(map[string]interface{}) + if !ok { + continue + } + if id := releaseIDString(item["id"]); id != "" { + ids = append(ids, id) + } + } + return ids +} + +func releaseIDString(value interface{}) string { + switch id := value.(type) { + case string: + return strings.TrimSpace(id) + case float64: + if id == float64(int64(id)) { + return strconv.FormatInt(int64(id), 10) + } + return fmt.Sprintf("%v", id) + case int: + return strconv.Itoa(id) + default: + return "" + } +} + +func releaseString(values map[string]interface{}, key string) string { + if values == nil { + return "" + } + value, _ := values[key].(string) + return strings.TrimSpace(value) +} + +func firstReleaseValue(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index 8877476..aa31664 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -2,91 +2,67 @@ package release import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "reflect" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) -} - -func findShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - shortcuts := Shortcuts() - for _, s := range shortcuts { - if s.Name == name { - return s - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// --- list --- - func TestReleaseList(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/releases.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, []interface{}{ - map[string]interface{}{"tag_name": "v1.0"}, - map[string]interface{}{"tag_name": "v1.1"}, - }) - })) + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertReleaseRequest(t, r, "GET", "/owner/repo/releases.json") + writeReleaseJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{{"tag_name": "v1.0"}}}) + }) defer server.Close() - err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) - if err != nil { + if err := runReleaseShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}); err != nil { t.Fatalf("list failed: %v", err) } } -// --- create --- - -func TestReleaseCreate(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/releases.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"tag_name": "v2.0"}) - })) +func TestReleaseCreatePayload(t *testing.T) { + var payload map[string]interface{} + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + 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 := runShortcut(t, server, "create", map[string]string{ - "tag": "v2.0", - "name": "Version 2.0", + err := runReleaseShortcut(t, server, "create", map[string]string{ + "tag": "v1.0.0", + "name": "v1.0.0", + "body": "Release notes", + "target": "main", + "draft": "true", + "prerelease": "true", + "attachment-ids": "12,34,12", }) if err != nil { - t.Fatalf("create failed: %v", err) + t.Fatalf("create shortcut failed: %v", err) } + + assertReleaseEqual(t, payload["tag_name"], "v1.0.0") + assertReleaseEqual(t, payload["name"], "v1.0.0") + assertReleaseEqual(t, payload["body"], "Release notes") + assertReleaseEqual(t, payload["target_commitish"], "main") + assertReleaseEqual(t, payload["draft"], true) + assertReleaseEqual(t, payload["prerelease"], true) + assertReleaseStringSlice(t, payload["attachment_ids"], []string{"12", "34"}) } func TestReleaseCreateWithBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{"tag_name": "v2.1"}) - })) + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertReleaseRequest(t, r, "POST", "/owner/repo/releases.json") + writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v2.1"}) + }) defer server.Close() - err := runShortcut(t, server, "create", map[string]string{ + err := runReleaseShortcut(t, server, "create", map[string]string{ "tag": "v2.1", "name": "Version 2.1", "body": "Release notes here", @@ -98,126 +74,382 @@ func TestReleaseCreateWithBody(t *testing.T) { } } -// --- view --- - -func TestReleaseView(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/releases/v1.0.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"tag_name": "v1.0", "name": "Version 1.0"}) - })) +func TestReleaseEdit(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertReleaseRequest(t, r, "GET", "/owner/repo/releases/7/edit.json") + writeReleaseJSON(t, w, releaseEditFixture()) + }) defer server.Close() - err := runShortcut(t, server, "view", map[string]string{"id": "v1.0"}) - if err != nil { + if err := runReleaseShortcut(t, server, "edit", map[string]string{"id": "7"}); err != nil { + t.Fatalf("edit shortcut failed: %v", err) + } +} + +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"}) + }) + defer server.Close() + + if err := runReleaseShortcut(t, server, "view", map[string]string{"id": "v1.0"}); err != nil { t.Fatalf("view failed: %v", err) } } -// --- delete (normal path: delete succeeds) --- - -func TestReleaseDeleteSuccess(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "DELETE" { - writeJSON(w, map[string]interface{}{"message": "deleted"}) - return +func TestReleaseUpdatePreservesExistingFields(t *testing.T) { + var payload map[string]interface{} + server := newReleaseTestServer(t, 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()) + case r.Method == "PUT" && r.URL.Path == "/owner/repo/releases/7.json": + payload = decodeReleaseJSON(t, r) + writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - })) + }) defer server.Close() - err := runShortcut(t, server, "delete", map[string]string{"id": "1"}) + err := runReleaseShortcut(t, server, "update", map[string]string{ + "id": "7", + "body": "Updated notes", + "prerelease": "true", + }) if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + assertReleaseEqual(t, payload["tag_name"], "v1.0.0") + assertReleaseEqual(t, payload["name"], "Old release") + assertReleaseEqual(t, payload["body"], "Updated notes") + assertReleaseEqual(t, payload["target_commitish"], "master") + assertReleaseEqual(t, payload["draft"], false) + assertReleaseEqual(t, payload["prerelease"], true) + assertReleaseStringSlice(t, payload["attachment_ids"], []string{"12", "34"}) +} + +func TestReleaseUpdateOverridesAttachmentIDs(t *testing.T) { + var payload map[string]interface{} + server := newReleaseTestServer(t, 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()) + case r.Method == "PUT" && r.URL.Path == "/owner/repo/releases/7.json": + payload = decodeReleaseJSON(t, r) + writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "update", map[string]string{ + "id": "7", + "name": "New release", + "attachment-ids": "99,100", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + assertReleaseEqual(t, payload["name"], "New release") + assertReleaseStringSlice(t, payload["attachment_ids"], []string{"99", "100"}) +} + +func TestReleaseUpdateDryRunDoesNotWrite(t *testing.T) { + server := newReleaseTestServer(t, 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{ + "id": "7", + "body": "Preview notes", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("update dry-run failed: %v", err) + } +} + +func TestReleaseDeleteSuccess(t *testing.T) { + server := newReleaseTestServer(t, 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 { t.Fatalf("delete failed: %v", err) } } -// --- delete (API bug workaround: delete fails but release was actually deleted) --- +func TestReleaseDeleteDryRunDoesNotCallAPI(t *testing.T) { + server := newReleaseTestServer(t, 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{ + "id": "7", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("delete dry-run failed: %v", err) + } +} func TestReleaseDeleteBugWorkaround(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "DELETE": - // API bug: delete returns error even when successful w.WriteHeader(http.StatusInternalServerError) - writeJSON(w, map[string]interface{}{"status": float64(500), "message": "server error"}) + writeReleaseJSON(t, w, map[string]interface{}{"status": float64(500), "message": "server error"}) case "GET": - // Verify shows the release no longer exists w.WriteHeader(http.StatusNotFound) - writeJSON(w, map[string]interface{}{"status": float64(404), "message": "not found"}) + writeReleaseJSON(t, w, map[string]interface{}{"status": float64(404), "message": "not found"}) default: t.Fatalf("unexpected method: %s", r.Method) } - })) + }) defer server.Close() - err := runShortcut(t, server, "delete", map[string]string{"id": "1"}) - if err != nil { + if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err != nil { t.Fatalf("delete bug workaround failed: %v", err) } } -// --- delete (delete truly fails: release still exists) --- - func TestReleaseDeleteTrulyFails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "DELETE": w.WriteHeader(http.StatusInternalServerError) - writeJSON(w, map[string]interface{}{"status": float64(500), "message": "server error"}) + writeReleaseJSON(t, w, map[string]interface{}{"status": float64(500), "message": "server error"}) case "GET": - // Release still exists — delete truly failed - writeJSON(w, map[string]interface{}{"id": float64(1), "tag_name": "v1.0"}) + writeReleaseJSON(t, w, map[string]interface{}{"id": float64(1), "tag_name": "v1.0"}) default: t.Fatalf("unexpected method: %s", r.Method) } - })) + }) defer server.Close() - err := runShortcut(t, server, "delete", map[string]string{"id": "1"}) - if err == nil { + if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err == nil { t.Fatal("expected error when delete truly fails") } } -// --- HTTP error paths --- - func TestReleaseListHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + _, _ = w.Write([]byte("server error")) + }) defer server.Close() - err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) - if err == nil { + if err := runReleaseShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}); err == nil { t.Fatal("expected error for HTTP 500") } } func TestReleaseCreateHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + _, _ = w.Write([]byte("server error")) + }) defer server.Close() - err := runShortcut(t, server, "create", map[string]string{"tag": "v1.0", "name": "v1.0"}) - if err == nil { + if err := runReleaseShortcut(t, server, "create", map[string]string{"tag": "v1.0", "name": "v1.0"}); err == nil { t.Fatal("expected error for HTTP 500") } } func TestReleaseViewHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + _, _ = w.Write([]byte("server error")) + }) defer server.Close() - err := runShortcut(t, server, "view", map[string]string{"id": "v1.0"}) - if err == nil { + if err := runReleaseShortcut(t, server, "view", map[string]string{"id": "v1.0"}); err == nil { t.Fatal("expected error for HTTP 500") } } + +func TestReleaseRejectsInvalidCreateBool(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid bool should not call API, got %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", + "prerelease": "maybe", + }) + if err == nil { + t.Fatal("expected invalid prerelease value to return an error") + } +} + +func TestReleaseUpdateRejectsNoFields(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("empty update should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "update", map[string]string{"id": "7"}) + if err == nil { + t.Fatal("expected empty update to return an error") + } +} + +func TestReleaseUpdateRejectsInvalidBoolBeforeFetch(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid update bool should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "update", map[string]string{ + "id": "7", + "draft": "maybe", + }) + if err == nil { + t.Fatal("expected invalid draft value to return an error") + } +} + +func TestReleaseShortcutNames(t *testing.T) { + got := map[string]bool{} + for _, shortcut := range Shortcuts() { + got[shortcut.Name] = true + } + want := []string{"list", "create", "edit", "view", "update", "delete"} + for _, name := range want { + if !got[name] { + t.Fatalf("missing shortcut %q in %v", name, got) + } + } + if len(got) != len(want) { + t.Fatalf("shortcut count = %d, want %d: %v", len(got), len(want), got) + } +} + +func runReleaseShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findReleaseShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findReleaseShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newReleaseTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func releaseEditFixture() map[string]interface{} { + return map[string]interface{}{ + "id": 7, + "name": "Old release", + "body": "Old notes", + "tag_name": "v1.0.0", + "target_commitish": "master", + "draft": false, + "prerelease": false, + "attachments": []map[string]interface{}{ + {"id": 12, "title": "a.zip"}, + {"id": "34", "title": "b.zip"}, + }, + } +} + +func assertReleaseRequest(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) + } +} + +func decodeReleaseJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeReleaseJSON(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 assertReleaseEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} + +func assertReleaseStringSlice(t *testing.T, got interface{}, want []string) { + t.Helper() + values, ok := got.([]interface{}) + if !ok { + t.Fatalf("got %T, want []interface{}", got) + } + result := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + if !ok { + t.Fatalf("got value %v (%T), want string", value, value) + } + result = append(result, text) + } + if !reflect.DeepEqual(result, want) { + t.Fatalf("got %v, want %v", result, want) + } +} + +func ExampleShortcuts() { + for _, shortcut := range Shortcuts() { + fmt.Println(shortcut.Name) + } + // Output: + // list + // create + // edit + // view + // update + // delete +} diff --git a/skills/README.md b/skills/README.md index c7f2a23..af78bca 100644 --- a/skills/README.md +++ b/skills/README.md @@ -139,7 +139,7 @@ skills/ | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | -| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` | +| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` | ### 辅助 Skills diff --git a/skills/gitlink-release/SKILL.md b/skills/gitlink-release/SKILL.md index 4f12abd..1fbe3ae 100644 --- a/skills/gitlink-release/SKILL.md +++ b/skills/gitlink-release/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-release version: 1.0.0 -description: "发布管理:创建、查看、删除 Release。当用户需要操作 GitLink 版本发布时触发。" +description: "发布管理:创建、编辑、更新、查看、删除 Release。当用户需要操作 GitLink 版本发布时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -22,7 +22,9 @@ metadata: |----------|------| | `release +list` | 发布列表 | | `release +create` | 创建发布 | +| `release +edit` | 获取发布编辑数据 | | `release +view` | 发布详情 | +| `release +update` | 更新发布并保留未传字段 | | `release +delete` | 删除发布 | ## 使用示例 @@ -31,8 +33,8 @@ metadata: # 列出发布 gitlink-cli release +list --owner Gitlink --repo forgeplus -# 创建发布 -gitlink-cli release +create --tag v1.0.0 --name "v1.0.0 正式版" --body "## 更新内容\n- 新增搜索功能\n- 修复登录 Bug" --target master +# 创建发布,可附带草稿、预发布和附件 ID +gitlink-cli release +create --tag v1.0.0 --name "v1.0.0 正式版" --body "## 更新内容\n- 新增搜索功能\n- 修复登录 Bug" --target master --draft false --prerelease false --attachment-ids 12,34 # 查看发布详情(⚠️ 必须使用 version_id,不能用 tag_name) # 先用 release +list 获取 version_id @@ -40,7 +42,13 @@ gitlink-cli release +list --owner myuser --repo myrepo --format json # 从返回的 releases 数组中取 version_id 字段 gitlink-cli release +view --id -# 删除发布(使用 version_id) +# 获取编辑数据并更新发布(保留未传字段) +gitlink-cli release +edit --id +gitlink-cli release +update --id --body "更新后的发布说明" --dry-run +gitlink-cli release +update --id --body "更新后的发布说明" + +# 删除发布(使用 version_id),删除前先 dry-run +gitlink-cli release +delete --id --dry-run gitlink-cli release +delete --id ``` @@ -49,3 +57,13 @@ gitlink-cli release +delete --id - **`release +view` 必须使用 `version_id`**(从 `release +list` 返回结果中获取),使用 tag_name 会返回 HTML 页面而非 JSON - **`release +delete` 使用 `version_id`**,已验证可正常删除 - Release 列表中的 `id` 字段可能为 null,应使用 `version_id` 字段 +- **`release +update` 会先调用 `release +edit` 对应接口读取当前值**,然后保留未传字段,避免部分更新清空描述、标签、附件等字段 +- `release +update` 和 `release +delete` 支持 `--dry-run`,写入/删除前建议先预览请求 + +## References + +- [release +create](references/gitlink-release-create.md) +- [release +edit](references/gitlink-release-edit.md) +- [release +update](references/gitlink-release-update.md) +- [release +view](references/gitlink-release-view.md) +- [release +delete](references/gitlink-release-delete.md) diff --git a/skills/gitlink-release/references/gitlink-release-create.md b/skills/gitlink-release/references/gitlink-release-create.md index 2eaf1e8..d957c80 100644 --- a/skills/gitlink-release/references/gitlink-release-create.md +++ b/skills/gitlink-release/references/gitlink-release-create.md @@ -15,6 +15,9 @@ gitlink-cli release +create --tag v1.0.0 --name "v1.0.0" --body "Bug fixes and i # 创建预发布版本,指定目标分支 gitlink-cli release +create --tag v2.0.0-beta.1 --name "v2.0.0 Beta" --target develop --prerelease true + +# 创建草稿版本并关联已上传附件 +gitlink-cli release +create --tag v1.1.0 --name "v1.1.0 Draft" --draft true --attachment-ids 12,34 ``` ## 参数 @@ -24,8 +27,10 @@ gitlink-cli release +create --tag v2.0.0-beta.1 --name "v2.0.0 Beta" --target de | `--tag, -t` | 是 | Tag 名称 | | `--name, -n` | 是 | 发行版名称 | | `--body, -b` | 否 | 发布说明 | -| `--target` | 否 | 目标分支(默认 `main`) | +| `--target` | 否 | 目标分支(默认 `master`) | | `--prerelease` | 否 | 标记为预发布(`true`/`false`,默认 `false`) | +| `--draft` | 否 | 标记为草稿(`true`/`false`,默认 `false`) | +| `--attachment-ids` | 否 | 逗号分隔的附件 ID | | `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | | `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | diff --git a/skills/gitlink-release/references/gitlink-release-delete.md b/skills/gitlink-release/references/gitlink-release-delete.md index 9c8ddc7..90947f4 100644 --- a/skills/gitlink-release/references/gitlink-release-delete.md +++ b/skills/gitlink-release/references/gitlink-release-delete.md @@ -8,10 +8,11 @@ ```bash # 删除指定发行版(使用 version_id) +gitlink-cli release +delete --id 12345 --dry-run gitlink-cli release +delete --id 12345 # 指定仓库 -gitlink-cli release +delete --id 12345 --owner someone --repo myrepo +gitlink-cli release +delete --id 12345 --owner someone --repo myrepo --dry-run ``` ## 参数 @@ -19,6 +20,7 @@ gitlink-cli release +delete --id 12345 --owner someone --repo myrepo | 参数 | 必填 | 说明 | |------|------|------| | `--id, -i` | 是 | 发行版 ID(version_id,数字 ID) | +| `--dry-run` | 否 | 仅预览删除请求,不改变发布状态 | | `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | | `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | @@ -33,8 +35,9 @@ gitlink-cli release +delete --id 12345 --owner someone --repo myrepo 1. 确认用户确实希望删除该发行版(此操作不可逆)。 2. 如果用户只知道 tag name,先执行 `release +list` 获取 version_id。 -3. 执行 `release +delete --id `。 -4. 输出删除结果。 +3. 先执行 `release +delete --id --dry-run` 预览请求。 +4. 用户确认后执行 `release +delete --id `。 +5. 输出删除结果。 ## References - [gitlink-release](../SKILL.md) diff --git a/skills/gitlink-release/references/gitlink-release-edit.md b/skills/gitlink-release/references/gitlink-release-edit.md new file mode 100644 index 0000000..70bccfa --- /dev/null +++ b/skills/gitlink-release/references/gitlink-release-edit.md @@ -0,0 +1,27 @@ +# release +edit + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +获取发行版编辑接口返回的数据。该命令使用 `version_id`,通常先通过 `release +list` 获取。 + +## 命令 + +```bash +gitlink-cli release +edit --id 12345 +gitlink-cli release +edit --id 12345 --owner someone --repo myrepo --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--id, -i` | 是 | 发行版 ID(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-update.md b/skills/gitlink-release/references/gitlink-release-update.md new file mode 100644 index 0000000..c57115a --- /dev/null +++ b/skills/gitlink-release/references/gitlink-release-update.md @@ -0,0 +1,49 @@ +# release +update + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +更新发行版。该命令会先读取 `release +edit` 对应接口返回的当前值,再合并用户传入的字段,避免未传字段被清空。 + +## 命令 + +```bash +# 更新发布说明,先预览 +gitlink-cli release +update --id 12345 --body "Updated changelog" --dry-run + +# 确认后提交 +gitlink-cli release +update --id 12345 --body "Updated changelog" + +# 更新标题、tag、目标分支和附件 +gitlink-cli release +update --id 12345 \ + --name "v1.1.0" --tag v1.1.0 --target main --attachment-ids 12,34 + +# 切换草稿/预发布状态 +gitlink-cli release +update --id 12345 --draft false --prerelease true --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--id, -i` | 是 | 发行版 ID(version_id) | +| `--tag, -t` | 否 | Tag 名称 | +| `--name, -n` | 否 | 发行版名称 | +| `--body, -b` | 否 | 发布说明 | +| `--target` | 否 | 目标分支 | +| `--draft` | 否 | 是否为草稿(`true`/`false`) | +| `--prerelease` | 否 | 是否为预发布(`true`/`false`) | +| `--attachment-ids` | 否 | 逗号分隔的附件 ID | +| `--dry-run` | 否 | 仅预览更新请求,不写入 | + +## Workflow + +> [!CAUTION] +> This is a **Write Operation** -- confirm user intent. + +1. 先用 `release +list` 确认 `version_id`。 +2. 执行 `release +update ... --dry-run` 预览请求。 +3. 用户确认后移除 `--dry-run` 执行更新。 + +## References +- [gitlink-release](../SKILL.md) +- [gitlink-shared](../../gitlink-shared/SKILL.md)