From 65f9f49e00bb4a045df2b85138aa8435d2c8ff8e Mon Sep 17 00:00:00 2001 From: chroe <454604461@qq.com> Date: Thu, 21 May 2026 17:45:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=87=8C=E7=A8=8B=E7=A2=91?= =?UTF-8?q?=E3=80=81Webhook=20=E5=92=8C=E6=A0=87=E7=AD=BE=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E5=91=BD=E4=BB=A4=EF=BC=8816=E4=B8=AA=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增三个快捷命令分组,覆盖之前只能通过原始 API 访问的 GitLink 平台功能: - milestone(里程碑):列表、查看、创建、更新、关闭、删除 - webhook(Webhook):列表、创建、查看、更新、删除、测试推送 - label(标签):列表、创建、更新、删除 每个模块均包含 httptest 单元测试,全部 31 个测试通过(新增 17 个 + 原有 14 个)。命令总数从 40+ 增长到 56+。 --- shortcuts/label/label.go | 133 +++++++++++++++++ shortcuts/label/label_test.go | 202 +++++++++++++++++++++++++ shortcuts/milestone/milestone.go | 192 ++++++++++++++++++++++++ shortcuts/milestone/milestone_test.go | 203 ++++++++++++++++++++++++++ shortcuts/register.go | 45 +++--- shortcuts/webhook/webhook.go | 196 +++++++++++++++++++++++++ shortcuts/webhook/webhook_test.go | 179 +++++++++++++++++++++++ 7 files changed, 1132 insertions(+), 18 deletions(-) create mode 100644 shortcuts/label/label.go create mode 100644 shortcuts/label/label_test.go create mode 100644 shortcuts/milestone/milestone.go create mode 100644 shortcuts/milestone/milestone_test.go create mode 100644 shortcuts/webhook/webhook.go create mode 100644 shortcuts/webhook/webhook_test.go diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go new file mode 100644 index 0000000..5954719 --- /dev/null +++ b/shortcuts/label/label.go @@ -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) + }, + }, + } +} diff --git a/shortcuts/label/label_test.go b/shortcuts/label/label_test.go new file mode 100644 index 0000000..44c8ff6 --- /dev/null +++ b/shortcuts/label/label_test.go @@ -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) + } +} diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go new file mode 100644 index 0000000..3e2845f --- /dev/null +++ b/shortcuts/milestone/milestone.go @@ -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) + }, + }, + } +} diff --git a/shortcuts/milestone/milestone_test.go b/shortcuts/milestone/milestone_test.go new file mode 100644 index 0000000..66c1f51 --- /dev/null +++ b/shortcuts/milestone/milestone_test.go @@ -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) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 210500e..eadaa81 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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 { diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go new file mode 100644 index 0000000..8f704c5 --- /dev/null +++ b/shortcuts/webhook/webhook.go @@ -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) + }, + }, + } +} diff --git a/shortcuts/webhook/webhook_test.go b/shortcuts/webhook/webhook_test.go new file mode 100644 index 0000000..45db5fd --- /dev/null +++ b/shortcuts/webhook/webhook_test.go @@ -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) + } +}