webhooks.go
10958 bytes
1package cmd
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7
8 "charm.land/lipgloss/v2/table"
9 "github.com/charmbracelet/soft-serve/pkg/backend"
10 "github.com/charmbracelet/soft-serve/pkg/utils"
11 "github.com/charmbracelet/soft-serve/pkg/webhook"
12 "github.com/dustin/go-humanize"
13 "github.com/google/uuid"
14 "github.com/spf13/cobra"
15)
16
17func webhookCommand() *cobra.Command {
18 cmd := &cobra.Command{
19 Use: "webhook",
20 Aliases: []string{"webhooks"},
21 Short: "Manage repository webhooks",
22 }
23
24 cmd.AddCommand(
25 webhookListCommand(),
26 webhookCreateCommand(),
27 webhookDeleteCommand(),
28 webhookUpdateCommand(),
29 webhookDeliveriesCommand(),
30 )
31
32 return cmd
33}
34
35var webhookEvents []string
36
37func init() {
38 events := webhook.Events()
39 webhookEvents = make([]string, len(events))
40 for i, e := range events {
41 webhookEvents[i] = e.String()
42 }
43}
44
45func webhookListCommand() *cobra.Command {
46 cmd := &cobra.Command{
47 Use: "list REPOSITORY",
48 Short: "List repository webhooks",
49 Args: cobra.ExactArgs(1),
50 PersistentPreRunE: checkIfRepoAdmin,
51 RunE: func(cmd *cobra.Command, args []string) error {
52 ctx := cmd.Context()
53 be := backend.FromContext(ctx)
54 repo, err := be.Repository(ctx, args[0])
55 if err != nil {
56 return err
57 }
58
59 webhooks, err := be.ListWebhooks(ctx, repo)
60 if err != nil {
61 return err
62 }
63
64 table := table.New().Headers("ID", "URL", "Events", "Active", "Created At", "Updated At")
65 for _, h := range webhooks {
66 events := make([]string, len(h.Events))
67 for i, e := range h.Events {
68 events[i] = e.String()
69 }
70
71 table = table.Row(
72 strconv.FormatInt(h.ID, 10),
73 utils.Sanitize(h.URL),
74 strings.Join(events, ","),
75 strconv.FormatBool(h.Active),
76 humanize.Time(h.CreatedAt),
77 humanize.Time(h.UpdatedAt),
78 )
79 }
80 cmd.Println(table)
81 return nil
82 },
83 }
84
85 return cmd
86}
87
88func webhookCreateCommand() *cobra.Command {
89 var events []string
90 var secret string
91 var active bool
92 var contentType string
93 cmd := &cobra.Command{
94 Use: "create REPOSITORY URL",
95 Short: "Create a repository webhook",
96 Args: cobra.ExactArgs(2),
97 PersistentPreRunE: checkIfRepoAdmin,
98 RunE: func(cmd *cobra.Command, args []string) error {
99 ctx := cmd.Context()
100 be := backend.FromContext(ctx)
101 repo, err := be.Repository(ctx, args[0])
102 if err != nil {
103 return err
104 }
105
106 var evs []webhook.Event
107 for _, e := range events {
108 ev, err := webhook.ParseEvent(e)
109 if err != nil {
110 return fmt.Errorf("invalid event: %w", err)
111 }
112
113 evs = append(evs, ev)
114 }
115
116 var ct webhook.ContentType
117 switch strings.ToLower(strings.TrimSpace(contentType)) {
118 case "json":
119 ct = webhook.ContentTypeJSON
120 case "form":
121 ct = webhook.ContentTypeForm
122 default:
123 return webhook.ErrInvalidContentType
124 }
125
126 url := utils.Sanitize(args[1])
127 return be.CreateWebhook(ctx, repo, strings.TrimSpace(url), ct, secret, evs, active)
128 },
129 }
130
131 cmd.Flags().StringSliceVarP(&events, "events", "e", nil, fmt.Sprintf("events to trigger the webhook, available events are (%s)", strings.Join(webhookEvents, ", ")))
132 cmd.Flags().StringVarP(&secret, "secret", "s", "", "secret to sign the webhook payload")
133 cmd.Flags().BoolVarP(&active, "active", "a", true, "whether the webhook is active")
134 cmd.Flags().StringVarP(&contentType, "content-type", "c", "json", "content type of the webhook payload, can be either `json` or `form`")
135
136 return cmd
137}
138
139func webhookDeleteCommand() *cobra.Command {
140 cmd := &cobra.Command{
141 Use: "delete REPOSITORY WEBHOOK_ID",
142 Short: "Delete a repository webhook",
143 Args: cobra.ExactArgs(2),
144 PersistentPreRunE: checkIfRepoAdmin,
145 RunE: func(cmd *cobra.Command, args []string) error {
146 ctx := cmd.Context()
147 be := backend.FromContext(ctx)
148 repo, err := be.Repository(ctx, args[0])
149 if err != nil {
150 return err
151 }
152
153 id, err := strconv.ParseInt(args[1], 10, 64)
154 if err != nil {
155 return fmt.Errorf("invalid webhook ID: %w", err)
156 }
157
158 return be.DeleteWebhook(ctx, repo, id)
159 },
160 }
161
162 return cmd
163}
164
165func webhookUpdateCommand() *cobra.Command {
166 var events []string
167 var secret string
168 var active string
169 var contentType string
170 var url string
171 cmd := &cobra.Command{
172 Use: "update REPOSITORY WEBHOOK_ID",
173 Short: "Update a repository webhook",
174 Args: cobra.ExactArgs(2),
175 PersistentPreRunE: checkIfRepoAdmin,
176 RunE: func(cmd *cobra.Command, args []string) error {
177 ctx := cmd.Context()
178 be := backend.FromContext(ctx)
179 repo, err := be.Repository(ctx, args[0])
180 if err != nil {
181 return err
182 }
183
184 id, err := strconv.ParseInt(args[1], 10, 64)
185 if err != nil {
186 return fmt.Errorf("invalid webhook ID: %w", err)
187 }
188
189 wh, err := be.Webhook(ctx, repo, id)
190 if err != nil {
191 return err
192 }
193
194 newURL := wh.URL
195 if url != "" {
196 newURL = url
197 }
198
199 newSecret := wh.Secret
200 if secret != "" {
201 newSecret = secret
202 }
203
204 newActive := wh.Active
205 if active != "" {
206 active, err := strconv.ParseBool(active)
207 if err != nil {
208 return fmt.Errorf("invalid active value: %w", err)
209 }
210
211 newActive = active
212 }
213
214 newContentType := wh.ContentType
215 if contentType != "" {
216 var ct webhook.ContentType
217 switch strings.ToLower(strings.TrimSpace(contentType)) {
218 case "json":
219 ct = webhook.ContentTypeJSON
220 case "form":
221 ct = webhook.ContentTypeForm
222 default:
223 return webhook.ErrInvalidContentType
224 }
225 newContentType = ct
226 }
227
228 newEvents := wh.Events
229 if len(events) > 0 {
230 var evs []webhook.Event
231 for _, e := range events {
232 ev, err := webhook.ParseEvent(e)
233 if err != nil {
234 return fmt.Errorf("invalid event: %w", err)
235 }
236
237 evs = append(evs, ev)
238 }
239
240 newEvents = evs
241 }
242
243 return be.UpdateWebhook(ctx, repo, id, newURL, newContentType, newSecret, newEvents, newActive)
244 },
245 }
246
247 cmd.Flags().StringSliceVarP(&events, "events", "e", nil, fmt.Sprintf("events to trigger the webhook, available events are (%s)", strings.Join(webhookEvents, ", ")))
248 cmd.Flags().StringVarP(&secret, "secret", "s", "", "secret to sign the webhook payload")
249 cmd.Flags().StringVarP(&active, "active", "a", "", "whether the webhook is active")
250 cmd.Flags().StringVarP(&contentType, "content-type", "c", "", "content type of the webhook payload, can be either `json` or `form`")
251 cmd.Flags().StringVarP(&url, "url", "u", "", "webhook URL")
252
253 return cmd
254}
255
256func webhookDeliveriesCommand() *cobra.Command {
257 cmd := &cobra.Command{
258 Use: "deliveries",
259 Short: "Manage webhook deliveries",
260 Aliases: []string{"delivery", "deliver"},
261 }
262
263 cmd.AddCommand(
264 webhookDeliveriesListCommand(),
265 webhookDeliveriesRedeliverCommand(),
266 webhookDeliveriesGetCommand(),
267 )
268
269 return cmd
270}
271
272func webhookDeliveriesListCommand() *cobra.Command {
273 cmd := &cobra.Command{
274 Use: "list REPOSITORY WEBHOOK_ID",
275 Short: "List webhook deliveries",
276 Args: cobra.ExactArgs(2),
277 PersistentPreRunE: checkIfRepoAdmin,
278 RunE: func(cmd *cobra.Command, args []string) error {
279 ctx := cmd.Context()
280 be := backend.FromContext(ctx)
281 repo, err := be.Repository(ctx, args[0])
282 if err != nil {
283 return err
284 }
285
286 id, err := strconv.ParseInt(args[1], 10, 64)
287 if err != nil {
288 return fmt.Errorf("invalid webhook ID: %w", err)
289 }
290
291 dels, err := be.ListWebhookDeliveries(ctx, repo, id)
292 if err != nil {
293 return err
294 }
295
296 table := table.New().Headers("Status", "ID", "Event", "Created At")
297 for _, d := range dels {
298 status := "❌"
299 if d.ResponseStatus >= 200 && d.ResponseStatus < 300 {
300 status = "✅"
301 }
302 table = table.Row(
303 status,
304 d.ID.String(),
305 d.Event.String(),
306 humanize.Time(d.CreatedAt),
307 )
308 }
309 cmd.Println(table)
310 return nil
311 },
312 }
313
314 return cmd
315}
316
317func webhookDeliveriesRedeliverCommand() *cobra.Command {
318 cmd := &cobra.Command{
319 Use: "redeliver REPOSITORY WEBHOOK_ID DELIVERY_ID",
320 Short: "Redeliver a webhook delivery",
321 Args: cobra.ExactArgs(3),
322 PersistentPreRunE: checkIfRepoAdmin,
323 RunE: func(cmd *cobra.Command, args []string) error {
324 ctx := cmd.Context()
325 be := backend.FromContext(ctx)
326 repo, err := be.Repository(ctx, args[0])
327 if err != nil {
328 return err
329 }
330
331 id, err := strconv.ParseInt(args[1], 10, 64)
332 if err != nil {
333 return fmt.Errorf("invalid webhook ID: %w", err)
334 }
335
336 delID, err := uuid.Parse(args[2])
337 if err != nil {
338 return fmt.Errorf("invalid delivery ID: %w", err)
339 }
340
341 return be.RedeliverWebhookDelivery(ctx, repo, id, delID)
342 },
343 }
344
345 return cmd
346}
347
348func webhookDeliveriesGetCommand() *cobra.Command {
349 cmd := &cobra.Command{
350 Use: "get REPOSITORY WEBHOOK_ID DELIVERY_ID",
351 Short: "Get a webhook delivery",
352 Args: cobra.ExactArgs(3),
353 PersistentPreRunE: checkIfRepoAdmin,
354 RunE: func(cmd *cobra.Command, args []string) error {
355 ctx := cmd.Context()
356 be := backend.FromContext(ctx)
357 repo, err := be.Repository(ctx, args[0])
358 if err != nil {
359 return err
360 }
361
362 id, err := strconv.ParseInt(args[1], 10, 64)
363 if err != nil {
364 return fmt.Errorf("invalid webhook ID: %w", err)
365 }
366
367 delID, err := uuid.Parse(args[2])
368 if err != nil {
369 return fmt.Errorf("invalid delivery ID: %w", err)
370 }
371
372 del, err := be.WebhookDelivery(ctx, repo, id, delID)
373 if err != nil {
374 return err
375 }
376
377 out := cmd.OutOrStdout()
378 fmt.Fprintf(out, "ID: %s\n", del.ID) //nolint:errcheck
379 fmt.Fprintf(out, "Event: %s\n", del.Event) //nolint:errcheck
380 fmt.Fprintf(out, "Request URL: %s\n", del.RequestURL) //nolint:errcheck
381 fmt.Fprintf(out, "Request Method: %s\n", del.RequestMethod) //nolint:errcheck
382 fmt.Fprintf(out, "Request Error: %s\n", del.RequestError.String) //nolint:errcheck
383 fmt.Fprintf(out, "Request Headers:\n") //nolint:errcheck
384 reqHeaders := strings.Split(del.RequestHeaders, "\n")
385 for _, h := range reqHeaders {
386 fmt.Fprintf(out, " %s\n", h) //nolint:errcheck
387 }
388
389 fmt.Fprintf(out, "Request Body:\n") //nolint:errcheck
390 reqBody := strings.Split(del.RequestBody, "\n")
391 for _, b := range reqBody {
392 fmt.Fprintf(out, " %s\n", b) //nolint:errcheck
393 }
394
395 fmt.Fprintf(out, "Response Status: %d\n", del.ResponseStatus) //nolint:errcheck
396 fmt.Fprintf(out, "Response Headers:\n") //nolint:errcheck
397 resHeaders := strings.Split(del.ResponseHeaders, "\n")
398 for _, h := range resHeaders {
399 fmt.Fprintf(out, " %s\n", h) //nolint:errcheck
400 }
401
402 fmt.Fprintf(out, "Response Body:\n") //nolint:errcheck
403 resBody := strings.Split(del.ResponseBody, "\n")
404 for _, b := range resBody {
405 fmt.Fprintf(out, " %s\n", b) //nolint:errcheck
406 }
407
408 return nil
409 },
410 }
411
412 return cmd
413}