Signed-off-by: Anirudh Oppiliappan anirudh@tangled.org
+30
appview/db/db.go
+30
appview/db/db.go
···
568
568
unique (from_at, to_at)
569
569
);
570
570
571
+
create table if not exists webhooks (
572
+
id integer primary key autoincrement,
573
+
repo_at text not null,
574
+
url text not null,
575
+
secret text,
576
+
active integer not null default 1,
577
+
events text not null, -- comma-separated list of events
578
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
579
+
updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
580
+
581
+
foreign key (repo_at) references repos(at_uri) on delete cascade
582
+
);
583
+
584
+
create table if not exists webhook_deliveries (
585
+
id integer primary key autoincrement,
586
+
webhook_id integer not null,
587
+
event text not null,
588
+
delivery_id text not null,
589
+
url text not null,
590
+
request_body text not null,
591
+
response_code integer,
592
+
response_body text,
593
+
success integer not null default 0,
594
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
595
+
596
+
foreign key (webhook_id) references webhooks(id) on delete cascade
597
+
);
598
+
571
599
create table if not exists migrations (
572
600
id integer primary key autoincrement,
573
601
name text unique
···
578
606
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
579
607
create index if not exists idx_references_from_at on reference_links(from_at);
580
608
create index if not exists idx_references_to_at on reference_links(to_at);
609
+
create index if not exists idx_webhooks_repo_at on webhooks(repo_at);
610
+
create index if not exists idx_webhook_deliveries_webhook_id on webhook_deliveries(webhook_id);
581
611
`)
582
612
if err != nil {
583
613
return nil, err
+308
appview/db/webhooks.go
+308
appview/db/webhooks.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"strings"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
12
+
)
13
+
14
+
// GetWebhooks returns all webhooks for a repository
15
+
func GetWebhooks(e Execer, filters ...orm.Filter) ([]models.Webhook, error) {
16
+
var conditions []string
17
+
var args []any
18
+
for _, filter := range filters {
19
+
conditions = append(conditions, filter.Condition())
20
+
args = append(args, filter.Arg()...)
21
+
}
22
+
23
+
whereClause := ""
24
+
if conditions != nil {
25
+
whereClause = " where " + strings.Join(conditions, " and ")
26
+
}
27
+
28
+
query := fmt.Sprintf(`
29
+
select
30
+
id,
31
+
repo_at,
32
+
url,
33
+
secret,
34
+
active,
35
+
events,
36
+
created_at,
37
+
updated_at
38
+
from webhooks
39
+
%s
40
+
order by created_at desc
41
+
`, whereClause)
42
+
43
+
rows, err := e.Query(query, args...)
44
+
if err != nil {
45
+
return nil, fmt.Errorf("failed to query webhooks: %w", err)
46
+
}
47
+
defer rows.Close()
48
+
49
+
var webhooks []models.Webhook
50
+
for rows.Next() {
51
+
var wh models.Webhook
52
+
var createdAt, updatedAt, eventsStr string
53
+
var secret sql.NullString
54
+
var active int
55
+
56
+
err := rows.Scan(
57
+
&wh.Id,
58
+
&wh.RepoAt,
59
+
&wh.Url,
60
+
&secret,
61
+
&active,
62
+
&eventsStr,
63
+
&createdAt,
64
+
&updatedAt,
65
+
)
66
+
if err != nil {
67
+
return nil, fmt.Errorf("failed to scan webhook: %w", err)
68
+
}
69
+
70
+
if secret.Valid {
71
+
wh.Secret = secret.String
72
+
}
73
+
wh.Active = active == 1
74
+
if eventsStr != "" {
75
+
wh.Events = strings.Split(eventsStr, ",")
76
+
}
77
+
78
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
79
+
wh.CreatedAt = t
80
+
}
81
+
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
82
+
wh.UpdatedAt = t
83
+
}
84
+
85
+
webhooks = append(webhooks, wh)
86
+
}
87
+
88
+
if err = rows.Err(); err != nil {
89
+
return nil, fmt.Errorf("failed to iterate webhooks: %w", err)
90
+
}
91
+
92
+
return webhooks, nil
93
+
}
94
+
95
+
// GetWebhook returns a single webhook by ID
96
+
func GetWebhook(e Execer, id int64) (*models.Webhook, error) {
97
+
webhooks, err := GetWebhooks(e, orm.FilterEq("id", id))
98
+
if err != nil {
99
+
return nil, err
100
+
}
101
+
102
+
if len(webhooks) == 0 {
103
+
return nil, sql.ErrNoRows
104
+
}
105
+
106
+
if len(webhooks) != 1 {
107
+
return nil, fmt.Errorf("expected 1 webhook, got %d", len(webhooks))
108
+
}
109
+
110
+
return &webhooks[0], nil
111
+
}
112
+
113
+
// AddWebhook creates a new webhook
114
+
func AddWebhook(e Execer, webhook *models.Webhook) error {
115
+
eventsStr := strings.Join(webhook.Events, ",")
116
+
active := 0
117
+
if webhook.Active {
118
+
active = 1
119
+
}
120
+
121
+
secret := sql.NullString{
122
+
String: webhook.Secret,
123
+
Valid: webhook.Secret != "",
124
+
}
125
+
126
+
result, err := e.Exec(`
127
+
insert into webhooks (repo_at, url, secret, active, events)
128
+
values (?, ?, ?, ?, ?)
129
+
`, webhook.RepoAt.String(), webhook.Url, secret, active, eventsStr)
130
+
131
+
if err != nil {
132
+
return fmt.Errorf("failed to insert webhook: %w", err)
133
+
}
134
+
135
+
id, err := result.LastInsertId()
136
+
if err != nil {
137
+
return fmt.Errorf("failed to get webhook id: %w", err)
138
+
}
139
+
140
+
webhook.Id = id
141
+
return nil
142
+
}
143
+
144
+
// UpdateWebhook updates an existing webhook
145
+
func UpdateWebhook(e Execer, webhook *models.Webhook) error {
146
+
eventsStr := strings.Join(webhook.Events, ",")
147
+
active := 0
148
+
if webhook.Active {
149
+
active = 1
150
+
}
151
+
152
+
secret := sql.NullString{
153
+
String: webhook.Secret,
154
+
Valid: webhook.Secret != "",
155
+
}
156
+
157
+
_, err := e.Exec(`
158
+
update webhooks
159
+
set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
160
+
where id = ?
161
+
`, webhook.Url, secret, active, eventsStr, webhook.Id)
162
+
163
+
if err != nil {
164
+
return fmt.Errorf("failed to update webhook: %w", err)
165
+
}
166
+
167
+
return nil
168
+
}
169
+
170
+
// DeleteWebhook deletes a webhook
171
+
func DeleteWebhook(e Execer, id int64) error {
172
+
_, err := e.Exec(`delete from webhooks where id = ?`, id)
173
+
if err != nil {
174
+
return fmt.Errorf("failed to delete webhook: %w", err)
175
+
}
176
+
return nil
177
+
}
178
+
179
+
// AddWebhookDelivery records a webhook delivery attempt
180
+
func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error {
181
+
success := 0
182
+
if delivery.Success {
183
+
success = 1
184
+
}
185
+
186
+
result, err := e.Exec(`
187
+
insert into webhook_deliveries (
188
+
webhook_id,
189
+
event,
190
+
delivery_id,
191
+
url,
192
+
request_body,
193
+
response_code,
194
+
response_body,
195
+
success
196
+
) values (?, ?, ?, ?, ?, ?, ?, ?)
197
+
`,
198
+
delivery.WebhookId,
199
+
delivery.Event,
200
+
delivery.DeliveryId,
201
+
delivery.Url,
202
+
delivery.RequestBody,
203
+
delivery.ResponseCode,
204
+
delivery.ResponseBody,
205
+
success,
206
+
)
207
+
208
+
if err != nil {
209
+
return fmt.Errorf("failed to insert webhook delivery: %w", err)
210
+
}
211
+
212
+
id, err := result.LastInsertId()
213
+
if err != nil {
214
+
return fmt.Errorf("failed to get delivery id: %w", err)
215
+
}
216
+
217
+
delivery.Id = id
218
+
return nil
219
+
}
220
+
221
+
// GetWebhookDeliveries returns recent deliveries for a webhook
222
+
func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) {
223
+
if limit <= 0 {
224
+
limit = 20
225
+
}
226
+
227
+
query := `
228
+
select
229
+
id,
230
+
webhook_id,
231
+
event,
232
+
delivery_id,
233
+
url,
234
+
request_body,
235
+
response_code,
236
+
response_body,
237
+
success,
238
+
created_at
239
+
from webhook_deliveries
240
+
where webhook_id = ?
241
+
order by created_at desc
242
+
limit ?
243
+
`
244
+
245
+
rows, err := e.Query(query, webhookId, limit)
246
+
if err != nil {
247
+
return nil, fmt.Errorf("failed to query webhook deliveries: %w", err)
248
+
}
249
+
defer rows.Close()
250
+
251
+
var deliveries []models.WebhookDelivery
252
+
for rows.Next() {
253
+
var d models.WebhookDelivery
254
+
var createdAt string
255
+
var success int
256
+
var responseCode sql.NullInt64
257
+
var responseBody sql.NullString
258
+
259
+
err := rows.Scan(
260
+
&d.Id,
261
+
&d.WebhookId,
262
+
&d.Event,
263
+
&d.DeliveryId,
264
+
&d.Url,
265
+
&d.RequestBody,
266
+
&responseCode,
267
+
&responseBody,
268
+
&success,
269
+
&createdAt,
270
+
)
271
+
if err != nil {
272
+
return nil, fmt.Errorf("failed to scan delivery: %w", err)
273
+
}
274
+
275
+
d.Success = success == 1
276
+
if responseCode.Valid {
277
+
d.ResponseCode = int(responseCode.Int64)
278
+
}
279
+
if responseBody.Valid {
280
+
d.ResponseBody = responseBody.String
281
+
}
282
+
283
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
284
+
d.CreatedAt = t
285
+
}
286
+
287
+
deliveries = append(deliveries, d)
288
+
}
289
+
290
+
if err = rows.Err(); err != nil {
291
+
return nil, fmt.Errorf("failed to iterate deliveries: %w", err)
292
+
}
293
+
294
+
return deliveries, nil
295
+
}
296
+
297
+
// GetWebhooksForRepo is a convenience function to get all webhooks for a repository
298
+
func GetWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
299
+
return GetWebhooks(e, orm.FilterEq("repo_at", repoAt.String()))
300
+
}
301
+
302
+
// GetActiveWebhooksForRepo returns only active webhooks for a repository
303
+
func GetActiveWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
304
+
return GetWebhooks(e,
305
+
orm.FilterEq("repo_at", repoAt.String()),
306
+
orm.FilterEq("active", 1),
307
+
)
308
+
}
+74
appview/models/webhook.go
+74
appview/models/webhook.go
···
1
+
package models
2
+
3
+
import (
4
+
"slices"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
)
9
+
10
+
type WebhookEvent string
11
+
12
+
const (
13
+
WebhookEventPush WebhookEvent = "push"
14
+
)
15
+
16
+
type Webhook struct {
17
+
Id int64
18
+
RepoAt syntax.ATURI
19
+
Url string
20
+
Secret string
21
+
Active bool
22
+
Events []string // comma-separated event types
23
+
CreatedAt time.Time
24
+
UpdatedAt time.Time
25
+
}
26
+
27
+
// HasEvent checks if the webhook is subscribed to a specific event
28
+
func (w *Webhook) HasEvent(event WebhookEvent) bool {
29
+
return slices.Contains(w.Events, string(event))
30
+
}
31
+
32
+
type WebhookDelivery struct {
33
+
Id int64
34
+
WebhookId int64
35
+
Event string
36
+
DeliveryId string // UUID for tracking
37
+
Url string
38
+
RequestBody string
39
+
ResponseCode int
40
+
ResponseBody string
41
+
Success bool
42
+
CreatedAt time.Time
43
+
}
44
+
45
+
// WebhookPayload represents the webhook payload structure
46
+
type WebhookPayload struct {
47
+
Ref string `json:"ref"`
48
+
Before string `json:"before"`
49
+
After string `json:"after"`
50
+
Repository WebhookRepository `json:"repository"`
51
+
Pusher WebhookUser `json:"pusher"`
52
+
}
53
+
54
+
// WebhookRepository represents repository information in webhook payload
55
+
type WebhookRepository struct {
56
+
Name string `json:"name"`
57
+
FullName string `json:"full_name"`
58
+
Description string `json:"description"`
59
+
Fork bool `json:"fork"`
60
+
HtmlUrl string `json:"html_url"`
61
+
CloneUrl string `json:"clone_url"`
62
+
SshUrl string `json:"ssh_url"`
63
+
Website string `json:"website,omitempty"`
64
+
StarsCount int `json:"stars_count,omitempty"`
65
+
OpenIssues int `json:"open_issues_count,omitempty"`
66
+
CreatedAt string `json:"created_at"`
67
+
UpdatedAt string `json:"updated_at"`
68
+
Owner WebhookUser `json:"owner"`
69
+
}
70
+
71
+
// WebhookUser represents user information in webhook payload
72
+
type WebhookUser struct {
73
+
Did string `json:"did"`
74
+
}
History
6 rounds
5 comments
anirudh.fi
submitted
#5
1 commit
expand
collapse
appview/{db,models}: webhook tables and crud ops
Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>
3/3 success
expand
collapse
expand 0 comments
pull request successfully merged
anirudh.fi
submitted
#4
1 commit
expand
collapse
appview/{db,models}: webhook tables and crud ops
Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>
3/3 success
expand
collapse
expand 0 comments
anirudh.fi
submitted
#3
1 commit
expand
collapse
appview/{db,models}: webhook tables and crud ops
Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>
3/3 success
expand
collapse
expand 3 comments
the db code needs to be updated accordingly to handle null strings, by reading into a sql.Null[string] and checking for s.Valid.
Oh, right...
anirudh.fi
submitted
#2
1 commit
expand
collapse
appview/{db,models}: webhook tables and crud ops
Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>
3/3 success
expand
collapse
anirudh.fi
submitted
#1
1 commit
expand
collapse
appview:{db,models}: webhook tables and crud ops
Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>
3/3 success
expand
collapse
expand 1 comment
- we dont need this index
- would be nice to make [this] more strongly typed, we could have an enum for this, like
type WebhookEvent stringwith more concrete variants
anirudh.fi
submitted
#0
1 commit
expand
collapse
appview:{db,models}: webhook tables and crud ops
Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>
@oppi.li, done.