webhooks.go

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