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:
commit
21966bf1ea
15
README.md
15
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 <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.) |
|
||||
|
|
|
|||
|
|
@ -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` | 流水线工作流操作(运行、日志、启停、删除等) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 <version_id>`。
|
||||
4. 输出删除结果。
|
||||
3. 先执行 `release +delete --id <version_id> --dry-run` 预览请求。
|
||||
4. 用户确认后执行 `release +delete --id <version_id>`。
|
||||
5. 输出删除结果。
|
||||
|
||||
## References
|
||||
- [gitlink-release](../SKILL.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)
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue