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