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