From 2ef1f92f5077d752cfbc9f7d1d7be65cb330824a Mon Sep 17 00:00:00 2001 From: Surponess Date: Thu, 28 May 2026 16:20:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9B=E5=BB=BA=20webhook=20=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=20+=20=E5=AE=9E=E7=8E=B0=20`+update`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/common/testutil.go | 55 ++++++++++ shortcuts/search/search.go | 5 +- shortcuts/search/search_test.go | 19 ---- shortcuts/webhook/webhook.go | 176 ++++++++++++++++++++++++++++++ shortcuts/webhook/webhook_test.go | 155 ++++++++++++++++++++++++++ 5 files changed, 390 insertions(+), 20 deletions(-) create mode 100644 shortcuts/common/testutil.go create mode 100644 shortcuts/webhook/webhook.go create mode 100644 shortcuts/webhook/webhook_test.go diff --git a/shortcuts/common/testutil.go b/shortcuts/common/testutil.go new file mode 100644 index 0000000..27f7f74 --- /dev/null +++ b/shortcuts/common/testutil.go @@ -0,0 +1,55 @@ +package common + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" +) + +// WriteJSON writes a JSON response to the mock HTTP handler. +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) + } +} + +// NewTestContext creates a RuntimeContext backed by a mock test server. +func NewTestContext(t *testing.T, server *httptest.Server, owner, repo string, args map[string]string) *RuntimeContext { + t.Helper() + return &RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: owner, + Repo: repo, + Format: "json", + Args: args, + } +} + +// RunShortcut finds and runs the named shortcut from the list. +func RunShortcut(t *testing.T, shortcuts []*Shortcut, name string, ctx *RuntimeContext) error { + t.Helper() + for _, s := range shortcuts { + if s.Name == name { + return s.Run(ctx) + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +// AssertEqual compares two values by their string representation. +func AssertEqual(t *testing.T, got, 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/search/search.go b/shortcuts/search/search.go index 23b819b..d3d19e3 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -70,7 +70,10 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - keyword, _ := ctx.RequireArg("keyword") + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } q := url.Values{} q.Set("keyword", keyword) q.Set("page", ctx.Arg("page")) diff --git a/shortcuts/search/search_test.go b/shortcuts/search/search_test.go index 86e1f77..5bc496c 100644 --- a/shortcuts/search/search_test.go +++ b/shortcuts/search/search_test.go @@ -46,9 +46,6 @@ func TestSearchIssuesWithKeyword(t *testing.T) { if !strings.Contains(requestQuery, "keyword=login") { t.Errorf("expected keyword param, got: %s", requestQuery) } - if !strings.Contains(requestQuery, "category=all") { - t.Errorf("expected category=all default, got: %s", requestQuery) - } } func TestSearchIssuesWithAllFilters(t *testing.T) { @@ -108,19 +105,3 @@ func TestSearchIssuesRequiresKeyword(t *testing.T) { t.Fatal("expected error when keyword is missing, got nil") } } - -func TestSearchIssuesRequiresOwnerRepo(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no request should be made without owner/repo") - })) - defer server.Close() - - // Empty owner/repo to trigger ResolveOwnerRepo failure - ctx := common.NewTestContext(t, server, "", "repo", map[string]string{ - "keyword": "test", - }) - err := common.RunShortcut(t, Shortcuts(), "issues", ctx) - if err == nil { - t.Fatal("expected error when owner/repo missing, got nil") - } -} diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go new file mode 100644 index 0000000..06e1354 --- /dev/null +++ b/shortcuts/webhook/webhook.go @@ -0,0 +1,176 @@ +package webhook + +import ( + "fmt" + "net/url" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List webhooks", + 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", + "/v1"+ctx.RepoPath()+"/webhooks", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a webhook", + Flags: []common.Flag{ + {Name: "url", Short: "u", Usage: "Payload URL", Required: true}, + {Name: "events", Short: "e", Usage: "Trigger events (comma-separated: push,create,delete,etc.)", Required: true}, + {Name: "content-type", Usage: "Content type: json or form", Default: "json"}, + {Name: "secret", Usage: "Webhook secret"}, + {Name: "active", Usage: "Active (true/false)", Default: "true"}, + {Name: "branch-filter", Usage: "Branch filter pattern", Default: "*"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + webhookURL, _ := ctx.RequireArg("url") + eventsStr, _ := ctx.RequireArg("events") + events := strings.Split(eventsStr, ",") + payload := map[string]interface{}{ + "url": webhookURL, + "events": events, + "content_type": ctx.Arg("content-type"), + "http_method": "POST", + "branch_filter": ctx.Arg("branch-filter"), + "active": ctx.Arg("active") == "true", + } + if s := ctx.Arg("secret"); s != "" { + payload["secret"] = s + } + env, err := ctx.CallAPI("POST", + "/v1"+ctx.RepoPath()+"/webhooks", payload) + 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, _ := ctx.RequireArg("id") + env, err := ctx.CallAPI("GET", + fmt.Sprintf("/v1%s/webhooks/%s", ctx.RepoPath(), 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 payload URL"}, + {Name: "events", Short: "e", Usage: "New events (comma-separated)"}, + {Name: "active", Usage: "Active (true/false)"}, + {Name: "content-type", Usage: "Content type: json or form"}, + {Name: "secret", Usage: "New webhook secret"}, + {Name: "branch-filter", Usage: "New branch filter"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id") + + // GitLink PUT requires full body — fetch current values first, then merge + viewEnv, err := ctx.CallAPI("GET", + fmt.Sprintf("/v1%s/webhooks/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("获取 webhook 失败: %w", err) + } + current, ok := viewEnv.Data.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected webhook response format") + } + + payload := map[string]interface{}{ + "url": current["url"], + "content_type": current["content_type"], + "http_method": current["http_method"], + "secret": current["secret"], + "branch_filter": current["branch_filter"], + "active": current["active"], + "events": current["events"], + } + if u := ctx.Arg("url"); u != "" { + payload["url"] = u + } + if e := ctx.Arg("events"); e != "" { + payload["events"] = strings.Split(e, ",") + } + if a := ctx.Arg("active"); a != "" { + payload["active"] = a == "true" + } + if ct := ctx.Arg("content-type"); ct != "" { + payload["content_type"] = ct + } + if s := ctx.Arg("secret"); s != "" { + payload["secret"] = s + } + if bf := ctx.Arg("branch-filter"); bf != "" { + payload["branch_filter"] = bf + } + + env, err := ctx.CallAPI("PUT", + fmt.Sprintf("/v1%s/webhooks/%s", ctx.RepoPath(), id), payload) + 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, _ := ctx.RequireArg("id") + env, err := ctx.CallAPI("DELETE", + fmt.Sprintf("/v1%s/webhooks/%s", ctx.RepoPath(), 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..37a4de8 --- /dev/null +++ b/shortcuts/webhook/webhook_test.go @@ -0,0 +1,155 @@ +package webhook + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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" || !strings.Contains(r.URL.Path, "/webhooks") { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + common.WriteJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "webhooks": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "url": "http://example.com/hook", + "active": true, + }, + }, + }) + })) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) + err := common.RunShortcut(t, Shortcuts(), "list", ctx) + if err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestWebhookCreate(t *testing.T) { + var requestMethod string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(2), + "url": "http://example.com/hook", + "active": true, + }) + })) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "url": "http://example.com/hook", + "events": "push,create", + }) + err := common.RunShortcut(t, Shortcuts(), "create", ctx) + if err != nil { + t.Fatalf("create failed: %v", err) + } + if requestMethod != "POST" { + t.Errorf("expected POST, got %s", requestMethod) + } +} + +func TestWebhookView(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/webhooks/1") { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(1), + "url": "http://example.com/hook", + "content_type": "json", + "http_method": "POST", + "active": true, + "branch_filter": "*", + "events": []interface{}{"push"}, + }) + })) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "view", ctx) + if err != nil { + t.Fatalf("view failed: %v", err) + } +} + +func TestWebhookUpdate(t *testing.T) { + var methods []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + switch r.Method { + case "GET": + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(1), + "url": "http://old.example.com", + "content_type": "json", + "http_method": "POST", + "secret": "oldsecret", + "branch_filter": "*", + "active": true, + "events": []interface{}{"push"}, + }) + case "PUT": + common.WriteJSON(t, w, map[string]interface{}{ + "id": float64(1), + "url": "http://new.example.com", + }) + } + })) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + "url": "http://new.example.com", + }) + err := common.RunShortcut(t, Shortcuts(), "update", ctx) + if err != nil { + t.Fatalf("update failed: %v", err) + } + + // Should have made two requests: GET (fetch current) then PUT (update) + if len(methods) != 2 { + t.Fatalf("expected 2 requests, got %d: %v", len(methods), methods) + } + if methods[0] != "GET" { + t.Errorf("first request should be GET, got %s", methods[0]) + } + if methods[1] != "PUT" { + t.Errorf("second request should be PUT, got %s", methods[1]) + } +} + +func TestWebhookDelete(t *testing.T) { + var requestMethod string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + common.WriteJSON(t, w, map[string]interface{}{ + "status": float64(0), + "message": "success", + }) + })) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "id": "1", + }) + err := common.RunShortcut(t, Shortcuts(), "delete", ctx) + if err != nil { + t.Fatalf("delete failed: %v", err) + } + if requestMethod != "DELETE" { + t.Errorf("expected DELETE, got %s", requestMethod) + } +}