Merge pull request 'feat(release): add edit and update shortcuts' (#69) from wangyue111/gitlink-cli:feat/release-update-shortcuts into master

This commit is contained in:
wbtiger 2026-06-09 01:01:55 +08:00
commit 21966bf1ea
11 changed files with 812 additions and 143 deletions

View File

@ -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 <version_id>
# Get edit data and update while preserving unspecified fields
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
# Preview release deletion before executing it
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --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.) |

View File

@ -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 <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 "更新后的内容" --dry-run
# 删除前先预览请求
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --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` | 流水线工作流操作(运行、日志、启停、删除等) |

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
# 删除发布(使用 version_id
# 获取编辑数据并更新发布(保留未传字段)
gitlink-cli release +edit --id <version_id>
gitlink-cli release +update --id <version_id> --body "更新后的发布说明" --dry-run
gitlink-cli release +update --id <version_id> --body "更新后的发布说明"
# 删除发布(使用 version_id删除前先 dry-run
gitlink-cli release +delete --id <version_id> --dry-run
gitlink-cli release +delete --id <version_id>
```
@ -49,3 +57,13 @@ gitlink-cli release +delete --id <version_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)

View File

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

View File

@ -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` | 是 | 发行版 IDversion_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 <version_id>`
4. 输出删除结果。
3. 先执行 `release +delete --id <version_id> --dry-run` 预览请求。
4. 用户确认后执行 `release +delete --id <version_id>`
5. 输出删除结果。
## References
- [gitlink-release](../SKILL.md)

View File

@ -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` | 是 | 发行版 IDversion_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)

View File

@ -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` | 是 | 发行版 IDversion_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)