1package cmd
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7
8 "github.com/caarlos0/tablewriter"
9 "github.com/charmbracelet/soft-serve/pkg/backend"
10 "github.com/charmbracelet/soft-serve/pkg/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}