webhooks.go

  1package cmd
  2
  3import (
  4	"fmt"
  5	"strconv"
  6	"strings"
  7
  8	"github.com/charmbracelet/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: checkIfAdmin,
 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: checkIfAdmin,
 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: checkIfAdmin,
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: checkIfAdmin,
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: checkIfAdmin,
278		RunE: func(cmd *cobra.Command, args []string) error {
279			ctx := cmd.Context()
280			be := backend.FromContext(ctx)
281			id, err := strconv.ParseInt(args[1], 10, 64)
282			if err != nil {
283				return fmt.Errorf("invalid webhook ID: %w", err)
284			}
285
286			dels, err := be.ListWebhookDeliveries(ctx, id)
287			if err != nil {
288				return err
289			}
290
291			table := table.New().Headers("Status", "ID", "Event", "Created At")
292			for _, d := range dels {
293				status := "❌"
294				if d.ResponseStatus >= 200 && d.ResponseStatus < 300 {
295					status = "✅"
296				}
297				table = table.Row(
298					status,
299					d.ID.String(),
300					d.Event.String(),
301					humanize.Time(d.CreatedAt),
302				)
303			}
304			cmd.Println(table)
305			return nil
306		},
307	}
308
309	return cmd
310}
311
312func webhookDeliveriesRedeliverCommand() *cobra.Command {
313	cmd := &cobra.Command{
314		Use:               "redeliver REPOSITORY WEBHOOK_ID DELIVERY_ID",
315		Short:             "Redeliver a webhook delivery",
316		PersistentPreRunE: checkIfAdmin,
317		RunE: func(cmd *cobra.Command, args []string) error {
318			ctx := cmd.Context()
319			be := backend.FromContext(ctx)
320			repo, err := be.Repository(ctx, args[0])
321			if err != nil {
322				return err
323			}
324
325			id, err := strconv.ParseInt(args[1], 10, 64)
326			if err != nil {
327				return fmt.Errorf("invalid webhook ID: %w", err)
328			}
329
330			delID, err := uuid.Parse(args[2])
331			if err != nil {
332				return fmt.Errorf("invalid delivery ID: %w", err)
333			}
334
335			return be.RedeliverWebhookDelivery(ctx, repo, id, delID)
336		},
337	}
338
339	return cmd
340}
341
342func webhookDeliveriesGetCommand() *cobra.Command {
343	cmd := &cobra.Command{
344		Use:               "get REPOSITORY WEBHOOK_ID DELIVERY_ID",
345		Short:             "Get a webhook delivery",
346		PersistentPreRunE: checkIfAdmin,
347		RunE: func(cmd *cobra.Command, args []string) error {
348			ctx := cmd.Context()
349			be := backend.FromContext(ctx)
350			id, err := strconv.ParseInt(args[1], 10, 64)
351			if err != nil {
352				return fmt.Errorf("invalid webhook ID: %w", err)
353			}
354
355			delID, err := uuid.Parse(args[2])
356			if err != nil {
357				return fmt.Errorf("invalid delivery ID: %w", err)
358			}
359
360			del, err := be.WebhookDelivery(ctx, id, delID)
361			if err != nil {
362				return err
363			}
364
365			out := cmd.OutOrStdout()
366			fmt.Fprintf(out, "ID: %s\n", del.ID)                             //nolint:errcheck
367			fmt.Fprintf(out, "Event: %s\n", del.Event)                       //nolint:errcheck
368			fmt.Fprintf(out, "Request URL: %s\n", del.RequestURL)            //nolint:errcheck
369			fmt.Fprintf(out, "Request Method: %s\n", del.RequestMethod)      //nolint:errcheck
370			fmt.Fprintf(out, "Request Error: %s\n", del.RequestError.String) //nolint:errcheck
371			fmt.Fprintf(out, "Request Headers:\n")                           //nolint:errcheck
372			reqHeaders := strings.Split(del.RequestHeaders, "\n")
373			for _, h := range reqHeaders {
374				fmt.Fprintf(out, "  %s\n", h) //nolint:errcheck
375			}
376
377			fmt.Fprintf(out, "Request Body:\n") //nolint:errcheck
378			reqBody := strings.Split(del.RequestBody, "\n")
379			for _, b := range reqBody {
380				fmt.Fprintf(out, "  %s\n", b) //nolint:errcheck
381			}
382
383			fmt.Fprintf(out, "Response Status: %d\n", del.ResponseStatus) //nolint:errcheck
384			fmt.Fprintf(out, "Response Headers:\n")                       //nolint:errcheck
385			resHeaders := strings.Split(del.ResponseHeaders, "\n")
386			for _, h := range resHeaders {
387				fmt.Fprintf(out, "  %s\n", h) //nolint:errcheck
388			}
389
390			fmt.Fprintf(out, "Response Body:\n") //nolint:errcheck
391			resBody := strings.Split(del.ResponseBody, "\n")
392			for _, b := range resBody {
393				fmt.Fprintf(out, "  %s\n", b) //nolint:errcheck
394			}
395
396			return nil
397		},
398	}
399
400	return cmd
401}