新增里程碑、Webhook 和标签快捷命令(16个命令)

新增三个快捷命令分组,覆盖之前只能通过原始 API 访问的 GitLink 平台功能:

- milestone(里程碑):列表、查看、创建、更新、关闭、删除
- webhook(Webhook):列表、创建、查看、更新、删除、测试推送
- label(标签):列表、创建、更新、删除

每个模块均包含 httptest 单元测试,全部 31 个测试通过(新增 17 个 + 原有 14 个)。命令总数从 40+ 增长到 56+。
This commit is contained in:
chroe 2026-05-21 17:45:10 +08:00
parent fde322669a
commit 65f9f49e00
7 changed files with 1132 additions and 18 deletions

133
shortcuts/label/label.go Normal file
View File

@ -0,0 +1,133 @@
package label
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List issue labels",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{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"))
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create an issue label",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Label name", Required: true},
{Name: "color", Short: "c", Usage: "Label color (hex, e.g. #FF0000)", Default: "#F17013"},
{Name: "description", Short: "d", Usage: "Label description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
body := map[string]interface{}{
"name": name,
"color": ctx.Arg("color"),
}
if desc := ctx.Arg("description"); desc != "" {
body["description"] = desc
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issue_tags", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an issue label",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "color", Short: "c", Usage: "New color (hex, e.g. #FF0000)"},
{Name: "description", Short: "d", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
name := ctx.Arg("name")
color := ctx.Arg("color")
desc := ctx.Arg("description")
if name == "" && color == "" && desc == "" {
return fmt.Errorf("at least one of --name, --color, or --description is required")
}
body := map[string]interface{}{}
if name != "" {
body["name"] = name
}
if color != "" {
body["color"] = color
}
if desc != "" {
body["description"] = desc
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an issue label",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label 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("DELETE", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,202 @@
package label
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestLabelList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_tags.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 2,
"issue_tags": []map[string]interface{}{
{"id": 1, "name": "bug", "color": "#FF0000"},
{"id": 2, "name": "feature", "color": "#00FF00"},
},
})
}))
defer server.Close()
err := runLabelShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestLabelCreate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issue_tags.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runLabelShortcut(t, server, "create", map[string]string{
"name": "enhancement",
"color": "#0000FF",
"description": "New feature",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "enhancement")
assertEqual(t, payload["color"], "#0000FF")
assertEqual(t, payload["description"], "New feature")
}
func TestLabelCreateWithDefaultColor(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issue_tags.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runLabelShortcut(t, server, "create", map[string]string{
"name": "bug",
"color": "#F17013",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["color"], "#F17013")
}
func TestLabelUpdate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issue_tags/3.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runLabelShortcut(t, server, "update", map[string]string{
"id": "3",
"name": "critical",
"color": "#FF0000",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "critical")
assertEqual(t, payload["color"], "#FF0000")
}
func TestLabelUpdateRequiresAtLeastOneField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made when no fields are provided")
}))
defer server.Close()
err := runLabelShortcut(t, server, "update", map[string]string{
"id": "3",
})
if err == nil {
t.Fatal("expected error when no update fields provided, got nil")
}
}
func TestLabelDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issue_tags/3.json" {
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runLabelShortcut(t, server, "delete", map[string]string{
"id": "3",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func runLabelShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findLabelShortcut(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 findLabelShortcut(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 decodeJSON(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 writeJSON(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 assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

View File

@ -0,0 +1,192 @@
package milestone
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List milestones",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: opening, closed, all", Default: "opening"},
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "sort", Usage: "Sort by: created_on, updated_on, effective_date, issues_count, percent", Default: "created_on"},
{Name: "direction", Short: "d", Usage: "Sort direction: asc, desc", Default: "desc"},
{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"))
if s := ctx.Arg("state"); s != "" && s != "all" {
q.Set("category", s)
}
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k)
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort_by", s)
}
if d := ctx.Arg("direction"); d != "" {
q.Set("sort_direction", d)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/milestones", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View milestone details",
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, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
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: "Milestone description"},
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
body := map[string]interface{}{
"name": name,
}
if desc := ctx.Arg("description"); desc != "" {
body["description"] = desc
}
if due := ctx.Arg("due"); due != "" {
body["effective_date"] = due
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", 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, err := ctx.RequireArg("id")
if err != nil {
return err
}
name := ctx.Arg("name")
desc := ctx.Arg("description")
due := ctx.Arg("due")
if name == "" && desc == "" && due == "" {
return fmt.Errorf("at least one of --name, --description, or --due is required")
}
body := map[string]interface{}{}
if name != "" {
body["name"] = name
}
if desc != "" {
body["description"] = desc
}
if due != "" {
body["effective_date"] = due
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "close",
Description: "Close 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, err := ctx.RequireArg("id")
if err != nil {
return err
}
body := map[string]interface{}{
"status": "closed",
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, 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, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,203 @@
package milestone
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestMilestoneList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/milestones.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("category") != "opening" {
t.Fatalf("expected category=opening, got %s", r.URL.Query().Get("category"))
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"milestones": []map[string]interface{}{
{"id": 1, "name": "v1.0", "status": "open"},
},
})
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "list", map[string]string{
"state": "opening",
})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestMilestoneCreate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/milestones.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "create", map[string]string{
"name": "v2.0",
"description": "Next release",
"due": "2026-06-01",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "v2.0")
assertEqual(t, payload["description"], "Next release")
assertEqual(t, payload["effective_date"], "2026-06-01")
}
func TestMilestoneUpdate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/milestones/5.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "update", map[string]string{
"id": "5",
"name": "v3.0",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "v3.0")
}
func TestMilestoneClose(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/milestones/5/update_status.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "操作成功",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "close", map[string]string{
"id": "5",
})
if err != nil {
t.Fatalf("close shortcut failed: %v", err)
}
assertEqual(t, payload["status"], "closed")
}
func TestMilestoneDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/milestones/5.json" {
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "delete", map[string]string{
"id": "5",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestMilestoneUpdateRequiresAtLeastOneField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made when no fields are provided")
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "update", map[string]string{
"id": "5",
})
if err == nil {
t.Fatal("expected error when no update fields provided, got nil")
}
}
func runMilestoneShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findMilestoneShortcut(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 findMilestoneShortcut(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 decodeJSON(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 writeJSON(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 assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

View File

@ -7,38 +7,47 @@ 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/label"
"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/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
)
// 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(),
"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(),
"webhook": webhook.Shortcuts(),
"label": label.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",
"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",
"webhook": "Webhook operations",
"label": "Issue label operations",
}
for name, shortcuts := range groups {

View File

@ -0,0 +1,196 @@
package webhook
import (
"fmt"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List webhooks",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/webhooks", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a webhook",
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Webhook URL", Required: true},
{Name: "events", Short: "e", Usage: "Comma-separated events (push,create,delete,issues_only,pull_request_only)", Default: "push"},
{Name: "content-type", Usage: "Content type: json, form", Default: "json"},
{Name: "secret", Short: "s", Usage: "Webhook secret"},
{Name: "branch-filter", Short: "b", Usage: "Branch filter glob pattern", Default: "*"},
{Name: "active", Usage: "Enable webhook (true/false)", Default: "true"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
webhookURL, err := ctx.RequireArg("url")
if err != nil {
return err
}
eventsStr := ctx.Arg("events")
events := []string{}
for _, e := range strings.Split(eventsStr, ",") {
e = strings.TrimSpace(e)
if e != "" {
events = append(events, e)
}
}
body := map[string]interface{}{
"url": webhookURL,
"type": "gitea",
"active": ctx.Arg("active") == "true",
"content_type": ctx.Arg("content-type"),
"http_method": "POST",
"branch_filter": ctx.Arg("branch-filter"),
"events": events,
}
if secret := ctx.Arg("secret"); secret != "" {
body["secret"] = secret
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/webhooks", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View webhook details",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook 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/webhooks/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "url", Short: "u", Usage: "New webhook URL"},
{Name: "events", Short: "e", Usage: "Comma-separated events"},
{Name: "content-type", Usage: "Content type: json, form"},
{Name: "secret", Short: "s", Usage: "New webhook secret"},
{Name: "branch-filter", Short: "b", Usage: "Branch filter glob pattern"},
{Name: "active", Usage: "Enable webhook (true/false)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body := map[string]interface{}{}
if u := ctx.Arg("url"); u != "" {
body["url"] = u
}
if eventsStr := ctx.Arg("events"); eventsStr != "" {
events := []string{}
for _, e := range strings.Split(eventsStr, ",") {
e = strings.TrimSpace(e)
if e != "" {
events = append(events, e)
}
}
body["events"] = events
}
if ct := ctx.Arg("content-type"); ct != "" {
body["content_type"] = ct
}
if secret := ctx.Arg("secret"); secret != "" {
body["secret"] = secret
}
if bf := ctx.Arg("branch-filter"); bf != "" {
body["branch_filter"] = bf
}
if active := ctx.Arg("active"); active != "" {
body["active"] = active == "true"
}
if len(body) == 0 {
return fmt.Errorf("at least one update field is required")
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook 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("DELETE", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "test",
Description: "Test a webhook delivery",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook 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("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1RepoPath(ctx), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,179 @@
package webhook
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestWebhookList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/webhooks.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"webhooks": []map[string]interface{}{
{"id": 1, "url": "https://example.com/hook", "is_active": true},
},
})
}))
defer server.Close()
err := runWebhookShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestWebhookCreate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": 42,
"url": "https://example.com/hook",
"active": true,
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWebhookShortcut(t, server, "create", map[string]string{
"url": "https://example.com/hook",
"events": "push,create",
"secret": "mysecret",
"active": "true",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["url"], "https://example.com/hook")
assertEqual(t, payload["active"], true)
assertEqual(t, payload["type"], "gitea")
assertEqual(t, payload["secret"], "mysecret")
events, ok := payload["events"].([]interface{})
if !ok {
t.Fatalf("events should be a slice, got %T", payload["events"])
}
if len(events) != 2 {
t.Fatalf("expected 2 events, got %d", len(events))
}
}
func TestWebhookDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/webhooks/42.json" {
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWebhookShortcut(t, server, "delete", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestWebhookTest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks/42/tests.json" {
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWebhookShortcut(t, server, "test", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("test shortcut failed: %v", err)
}
}
func TestWebhookUpdateRequiresAtLeastOneField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made when no fields are provided")
}))
defer server.Close()
err := runWebhookShortcut(t, server, "update", map[string]string{
"id": "42",
})
if err == nil {
t.Fatal("expected error when no update fields provided, got nil")
}
}
func runWebhookShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWebhookShortcut(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 findWebhookShortcut(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 decodeJSON(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 writeJSON(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 assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}