feat: add label/review/milestone/team shortcuts, merge master updates

- issue: add label add/remove/list subcommands, add reopen command
- pr: add approve/request-changes/reviews subcommands
- org: merge update/delete from master, add invite/remove-member
- repo: add update command
- release: add update command, clean up comments
- search: add issues search
- register: add milestone, team, webhook modules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
zkevin 2026-06-03 23:37:40 +08:00
parent 466a7ab181
commit 36b8a912a5
11 changed files with 608 additions and 25 deletions

View File

@ -27,6 +27,9 @@ func Shortcuts() []*common.Shortcut {
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchCreateShortcut(),
newLabelAddShortcut(),
newLabelRemoveShortcut(),
newLabelListShortcut(),
{
Name: "list",
Description: "List issues",
@ -154,6 +157,37 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "reopen",
Description: "Reopen a closed issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": 1, // 1 = open
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an issue",

77
shortcuts/issue/label.go Normal file
View File

@ -0,0 +1,77 @@
package issue
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newLabelAddShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-add",
Description: "Add labels to an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "labels", Short: "l", Usage: "Comma-separated label names or IDs", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, _ := ctx.RequireArg("number")
labelsStr, _ := ctx.RequireArg("labels")
body := map[string]interface{}{
"labels": labelsStr,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), body)
if err != nil {
return err
}
return ctx.Output(env)
},
}
}
func newLabelRemoveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-remove",
Description: "Remove a label from an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "label", Short: "l", Usage: "Label ID to remove", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, _ := ctx.RequireArg("number")
label, _ := ctx.RequireArg("label")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/labels/%s", v1RepoPath(ctx), number, label), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
}
}
func newLabelListShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-list",
Description: "List labels on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, _ := ctx.RequireArg("number")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
}
}

View File

@ -0,0 +1,115 @@
package milestone
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List milestones",
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a milestone",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
{Name: "description", Short: "d", Usage: "Description"},
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
body := map[string]interface{}{
"title": name,
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if due := ctx.Arg("due"); due != "" {
body["due_date"] = due
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a milestone",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "description", Short: "d", Usage: "New description"},
{Name: "due", Usage: "New due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["title"] = n
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if due := ctx.Arg("due"); due != "" {
body["due_date"] = due
}
if len(body) == 0 {
return fmt.Errorf("at least one of --name, --description, --due is required")
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a milestone",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -91,6 +91,48 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an organization",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "description", Short: "d", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id")
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if len(body) == 0 {
return fmt.Errorf("at least one of --name, --description is required")
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/organizations/%s", id), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an organization",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "invite",
Description: "Invite a member to a project",

View File

@ -10,6 +10,9 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newApproveShortcut(),
newRequestChangesShortcut(),
newReviewsShortcut(),
{
Name: "list",
Description: "List pull requests",

83
shortcuts/pr/review.go Normal file
View File

@ -0,0 +1,83 @@
package pr
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newApproveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "approve",
Description: "Approve a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment (optional)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
body := map[string]interface{}{
"state": "approved",
}
if b := ctx.Arg("body"); b != "" {
body["body"] = b
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), body)
if err != nil {
return err
}
return ctx.Output(env)
},
}
}
func newRequestChangesShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "request-changes",
Description: "Request changes on a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment explaining what needs to change", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
body, _ := ctx.RequireArg("body")
payload := map[string]interface{}{
"state": "changes_requested",
"body": body,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
}
}
func newReviewsShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "reviews",
Description: "List reviews for a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
}
}

View File

@ -7,41 +7,50 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/team"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki" // 新增wiki管理
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
)
// RegisterAll mounts all shortcut groups onto the root command.
func RegisterAll(root *cobra.Command) {
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"wiki": wiki.Shortcuts(), // 新增wiki
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"milestone": milestone.Shortcuts(),
"team": team.Shortcuts(),
"wiki": wiki.Shortcuts(),
"webhook": webhook.Shortcuts(),
}
descriptions := map[string]string{
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"wiki": "Wiki operations", // 新增wiki
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"milestone": "Milestone operations",
"team": "Team operations",
"wiki": "Wiki operations",
"webhook": "Webhook operations",
}
for name, shortcuts := range groups {

View File

@ -109,16 +109,12 @@ func Shortcuts() []*common.Shortcut {
id, _ := ctx.RequireArg("id")
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), 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)
if viewErr != nil {
// Release no longer exists — delete actually succeeded
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
}, nil))
}
// Release still exists — delete truly failed
return delErr
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
@ -126,5 +122,39 @@ func Shortcuts() []*common.Shortcut {
}, nil))
},
},
{
Name: "update",
Description: "Update a release",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
{Name: "name", Short: "n", Usage: "New release name"},
{Name: "body", Short: "b", Usage: "New release notes"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
}
if b := ctx.Arg("body"); b != "" {
body["body"] = b
}
if p := ctx.Arg("prerelease"); p != "" {
body["prerelease"] = p == "true"
}
if len(body) == 0 {
return fmt.Errorf("at least one of --name, --body, --prerelease is required")
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -142,6 +142,34 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update repository settings",
Flags: []common.Flag{
{Name: "description", Short: "d", Usage: "New description"},
{Name: "private", Usage: "Set private (true/false)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
body := map[string]interface{}{}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if p := ctx.Arg("private"); p != "" {
body["private"] = p == "true"
}
if len(body) == 0 {
return fmt.Errorf("at least one of --description, --private is required")
}
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "members",
Description: "List repository members (collaborators)",

View File

@ -1,6 +1,7 @@
package search
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -50,5 +51,29 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "issues",
Description: "Search issues",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
keyword, _ := ctx.RequireArg("keyword")
q := url.Values{}
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/issues", ctx.RepoPath()), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

137
shortcuts/team/team.go Normal file
View File

@ -0,0 +1,137 @@
package team
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List teams in an organization",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", org), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a team in an organization",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "name", Short: "n", Usage: "Team name", Required: true},
{Name: "description", Short: "d", Usage: "Team description"},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
name, _ := ctx.RequireArg("name")
body := map[string]interface{}{
"name": name,
}
if desc := ctx.Arg("description"); desc != "" {
body["description"] = desc
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", org), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a team",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s", org, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "members",
Description: "List members of a team",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
id, _ := ctx.RequireArg("id")
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams/%s/members", org, id), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "member-add",
Description: "Add a user to a team",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
{Name: "user", Short: "u", Usage: "User login or ID to add", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
team, _ := ctx.RequireArg("team")
user, _ := ctx.RequireArg("user")
body := map[string]interface{}{
"user_id": user,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/members", org, team), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "member-remove",
Description: "Remove a user from a team",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
{Name: "user", Short: "u", Usage: "User login or ID to remove", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
team, _ := ctx.RequireArg("team")
user, _ := ctx.RequireArg("user")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/members/%s", org, team, user), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}