Parent directory

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}