Signed-off-by: Anirudh Oppiliappan anirudh@tangled.org
+30
appview/db/db.go
+30
appview/db/db.go
···
568
unique (from_at, to_at)
569
);
570
571
create table if not exists migrations (
572
id integer primary key autoincrement,
573
name text unique
···
578
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
579
create index if not exists idx_references_from_at on reference_links(from_at);
580
create index if not exists idx_references_to_at on reference_links(to_at);
581
`)
582
if err != nil {
583
return nil, err
···
568
unique (from_at, to_at)
569
);
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
+
599
create table if not exists migrations (
600
id integer primary key autoincrement,
601
name text unique
···
606
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
607
create index if not exists idx_references_from_at on reference_links(from_at);
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);
611
`)
612
if err != nil {
613
return nil, err
+298
appview/db/webhooks.go
+298
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
+
result, err := e.Exec(`
122
+
insert into webhooks (repo_at, url, secret, active, events)
123
+
values (?, ?, ?, ?, ?)
124
+
`, webhook.RepoAt.String(), webhook.Url, webhook.Secret, active, eventsStr)
125
+
126
+
if err != nil {
127
+
return fmt.Errorf("failed to insert webhook: %w", err)
128
+
}
129
+
130
+
id, err := result.LastInsertId()
131
+
if err != nil {
132
+
return fmt.Errorf("failed to get webhook id: %w", err)
133
+
}
134
+
135
+
webhook.Id = id
136
+
return nil
137
+
}
138
+
139
+
// UpdateWebhook updates an existing webhook
140
+
func UpdateWebhook(e Execer, webhook *models.Webhook) error {
141
+
eventsStr := strings.Join(webhook.Events, ",")
142
+
active := 0
143
+
if webhook.Active {
144
+
active = 1
145
+
}
146
+
147
+
_, err := e.Exec(`
148
+
update webhooks
149
+
set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
150
+
where id = ?
151
+
`, webhook.Url, webhook.Secret, active, eventsStr, webhook.Id)
152
+
153
+
if err != nil {
154
+
return fmt.Errorf("failed to update webhook: %w", err)
155
+
}
156
+
157
+
return nil
158
+
}
159
+
160
+
// DeleteWebhook deletes a webhook
161
+
func DeleteWebhook(e Execer, id int64) error {
162
+
_, err := e.Exec(`delete from webhooks where id = ?`, id)
163
+
if err != nil {
164
+
return fmt.Errorf("failed to delete webhook: %w", err)
165
+
}
166
+
return nil
167
+
}
168
+
169
+
// AddWebhookDelivery records a webhook delivery attempt
170
+
func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error {
171
+
success := 0
172
+
if delivery.Success {
173
+
success = 1
174
+
}
175
+
176
+
result, err := e.Exec(`
177
+
insert into webhook_deliveries (
178
+
webhook_id,
179
+
event,
180
+
delivery_id,
181
+
url,
182
+
request_body,
183
+
response_code,
184
+
response_body,
185
+
success
186
+
) values (?, ?, ?, ?, ?, ?, ?, ?)
187
+
`,
188
+
delivery.WebhookId,
189
+
delivery.Event,
190
+
delivery.DeliveryId,
191
+
delivery.Url,
192
+
delivery.RequestBody,
193
+
delivery.ResponseCode,
194
+
delivery.ResponseBody,
195
+
success,
196
+
)
197
+
198
+
if err != nil {
199
+
return fmt.Errorf("failed to insert webhook delivery: %w", err)
200
+
}
201
+
202
+
id, err := result.LastInsertId()
203
+
if err != nil {
204
+
return fmt.Errorf("failed to get delivery id: %w", err)
205
+
}
206
+
207
+
delivery.Id = id
208
+
return nil
209
+
}
210
+
211
+
// GetWebhookDeliveries returns recent deliveries for a webhook
212
+
func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) {
213
+
if limit <= 0 {
214
+
limit = 20
215
+
}
216
+
217
+
query := `
218
+
select
219
+
id,
220
+
webhook_id,
221
+
event,
222
+
delivery_id,
223
+
url,
224
+
request_body,
225
+
response_code,
226
+
response_body,
227
+
success,
228
+
created_at
229
+
from webhook_deliveries
230
+
where webhook_id = ?
231
+
order by created_at desc
232
+
limit ?
233
+
`
234
+
235
+
rows, err := e.Query(query, webhookId, limit)
236
+
if err != nil {
237
+
return nil, fmt.Errorf("failed to query webhook deliveries: %w", err)
238
+
}
239
+
defer rows.Close()
240
+
241
+
var deliveries []models.WebhookDelivery
242
+
for rows.Next() {
243
+
var d models.WebhookDelivery
244
+
var createdAt string
245
+
var success int
246
+
var responseCode sql.NullInt64
247
+
var responseBody sql.NullString
248
+
249
+
err := rows.Scan(
250
+
&d.Id,
251
+
&d.WebhookId,
252
+
&d.Event,
253
+
&d.DeliveryId,
254
+
&d.Url,
255
+
&d.RequestBody,
256
+
&responseCode,
257
+
&responseBody,
258
+
&success,
259
+
&createdAt,
260
+
)
261
+
if err != nil {
262
+
return nil, fmt.Errorf("failed to scan delivery: %w", err)
263
+
}
264
+
265
+
d.Success = success == 1
266
+
if responseCode.Valid {
267
+
d.ResponseCode = int(responseCode.Int64)
268
+
}
269
+
if responseBody.Valid {
270
+
d.ResponseBody = responseBody.String
271
+
}
272
+
273
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
274
+
d.CreatedAt = t
275
+
}
276
+
277
+
deliveries = append(deliveries, d)
278
+
}
279
+
280
+
if err = rows.Err(); err != nil {
281
+
return nil, fmt.Errorf("failed to iterate deliveries: %w", err)
282
+
}
283
+
284
+
return deliveries, nil
285
+
}
286
+
287
+
// GetWebhooksForRepo is a convenience function to get all webhooks for a repository
288
+
func GetWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
289
+
return GetWebhooks(e, orm.FilterEq("repo_at", repoAt.String()))
290
+
}
291
+
292
+
// GetActiveWebhooksForRepo returns only active webhooks for a repository
293
+
func GetActiveWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
294
+
return GetWebhooks(e,
295
+
orm.FilterEq("repo_at", repoAt.String()),
296
+
orm.FilterEq("active", 1),
297
+
)
298
+
}
+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.