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