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}