···568568 unique (from_at, to_at)
569569 );
570570571571+ create table if not exists webhooks (
572572+ id integer primary key autoincrement,
573573+ repo_at text not null,
574574+ url text not null,
575575+ secret text,
576576+ active integer not null default 1,
577577+ events text not null, -- comma-separated list of events
578578+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
579579+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
580580+581581+ foreign key (repo_at) references repos(at_uri) on delete cascade
582582+ );
583583+584584+ create table if not exists webhook_deliveries (
585585+ id integer primary key autoincrement,
586586+ webhook_id integer not null,
587587+ event text not null,
588588+ delivery_id text not null,
589589+ url text not null,
590590+ request_body text not null,
591591+ response_code integer,
592592+ response_body text,
593593+ success integer not null default 0,
594594+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
595595+596596+ foreign key (webhook_id) references webhooks(id) on delete cascade
597597+ );
598598+571599 create table if not exists migrations (
572600 id integer primary key autoincrement,
573601 name text unique
···578606 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
579607 create index if not exists idx_references_from_at on reference_links(from_at);
580608 create index if not exists idx_references_to_at on reference_links(to_at);
609609+ create index if not exists idx_webhooks_repo_at on webhooks(repo_at);
610610+ create index if not exists idx_webhook_deliveries_webhook_id on webhook_deliveries(webhook_id);
581611 `)
582612 if err != nil {
583613 return nil, err
+308
appview/db/webhooks.go
···11+package db
22+33+import (
44+ "database/sql"
55+ "fmt"
66+ "strings"
77+ "time"
88+99+ "github.com/bluesky-social/indigo/atproto/syntax"
1010+ "tangled.org/core/appview/models"
1111+ "tangled.org/core/orm"
1212+)
1313+1414+// GetWebhooks returns all webhooks for a repository
1515+func GetWebhooks(e Execer, filters ...orm.Filter) ([]models.Webhook, error) {
1616+ var conditions []string
1717+ var args []any
1818+ for _, filter := range filters {
1919+ conditions = append(conditions, filter.Condition())
2020+ args = append(args, filter.Arg()...)
2121+ }
2222+2323+ whereClause := ""
2424+ if conditions != nil {
2525+ whereClause = " where " + strings.Join(conditions, " and ")
2626+ }
2727+2828+ query := fmt.Sprintf(`
2929+ select
3030+ id,
3131+ repo_at,
3232+ url,
3333+ secret,
3434+ active,
3535+ events,
3636+ created_at,
3737+ updated_at
3838+ from webhooks
3939+ %s
4040+ order by created_at desc
4141+ `, whereClause)
4242+4343+ rows, err := e.Query(query, args...)
4444+ if err != nil {
4545+ return nil, fmt.Errorf("failed to query webhooks: %w", err)
4646+ }
4747+ defer rows.Close()
4848+4949+ var webhooks []models.Webhook
5050+ for rows.Next() {
5151+ var wh models.Webhook
5252+ var createdAt, updatedAt, eventsStr string
5353+ var secret sql.NullString
5454+ var active int
5555+5656+ err := rows.Scan(
5757+ &wh.Id,
5858+ &wh.RepoAt,
5959+ &wh.Url,
6060+ &secret,
6161+ &active,
6262+ &eventsStr,
6363+ &createdAt,
6464+ &updatedAt,
6565+ )
6666+ if err != nil {
6767+ return nil, fmt.Errorf("failed to scan webhook: %w", err)
6868+ }
6969+7070+ if secret.Valid {
7171+ wh.Secret = secret.String
7272+ }
7373+ wh.Active = active == 1
7474+ if eventsStr != "" {
7575+ wh.Events = strings.Split(eventsStr, ",")
7676+ }
7777+7878+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
7979+ wh.CreatedAt = t
8080+ }
8181+ if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
8282+ wh.UpdatedAt = t
8383+ }
8484+8585+ webhooks = append(webhooks, wh)
8686+ }
8787+8888+ if err = rows.Err(); err != nil {
8989+ return nil, fmt.Errorf("failed to iterate webhooks: %w", err)
9090+ }
9191+9292+ return webhooks, nil
9393+}
9494+9595+// GetWebhook returns a single webhook by ID
9696+func GetWebhook(e Execer, id int64) (*models.Webhook, error) {
9797+ webhooks, err := GetWebhooks(e, orm.FilterEq("id", id))
9898+ if err != nil {
9999+ return nil, err
100100+ }
101101+102102+ if len(webhooks) == 0 {
103103+ return nil, sql.ErrNoRows
104104+ }
105105+106106+ if len(webhooks) != 1 {
107107+ return nil, fmt.Errorf("expected 1 webhook, got %d", len(webhooks))
108108+ }
109109+110110+ return &webhooks[0], nil
111111+}
112112+113113+// AddWebhook creates a new webhook
114114+func AddWebhook(e Execer, webhook *models.Webhook) error {
115115+ eventsStr := strings.Join(webhook.Events, ",")
116116+ active := 0
117117+ if webhook.Active {
118118+ active = 1
119119+ }
120120+121121+ secret := sql.NullString{
122122+ String: webhook.Secret,
123123+ Valid: webhook.Secret != "",
124124+ }
125125+126126+ result, err := e.Exec(`
127127+ insert into webhooks (repo_at, url, secret, active, events)
128128+ values (?, ?, ?, ?, ?)
129129+ `, webhook.RepoAt.String(), webhook.Url, secret, active, eventsStr)
130130+131131+ if err != nil {
132132+ return fmt.Errorf("failed to insert webhook: %w", err)
133133+ }
134134+135135+ id, err := result.LastInsertId()
136136+ if err != nil {
137137+ return fmt.Errorf("failed to get webhook id: %w", err)
138138+ }
139139+140140+ webhook.Id = id
141141+ return nil
142142+}
143143+144144+// UpdateWebhook updates an existing webhook
145145+func UpdateWebhook(e Execer, webhook *models.Webhook) error {
146146+ eventsStr := strings.Join(webhook.Events, ",")
147147+ active := 0
148148+ if webhook.Active {
149149+ active = 1
150150+ }
151151+152152+ secret := sql.NullString{
153153+ String: webhook.Secret,
154154+ Valid: webhook.Secret != "",
155155+ }
156156+157157+ _, err := e.Exec(`
158158+ update webhooks
159159+ set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
160160+ where id = ?
161161+ `, webhook.Url, secret, active, eventsStr, webhook.Id)
162162+163163+ if err != nil {
164164+ return fmt.Errorf("failed to update webhook: %w", err)
165165+ }
166166+167167+ return nil
168168+}
169169+170170+// DeleteWebhook deletes a webhook
171171+func DeleteWebhook(e Execer, id int64) error {
172172+ _, err := e.Exec(`delete from webhooks where id = ?`, id)
173173+ if err != nil {
174174+ return fmt.Errorf("failed to delete webhook: %w", err)
175175+ }
176176+ return nil
177177+}
178178+179179+// AddWebhookDelivery records a webhook delivery attempt
180180+func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error {
181181+ success := 0
182182+ if delivery.Success {
183183+ success = 1
184184+ }
185185+186186+ result, err := e.Exec(`
187187+ insert into webhook_deliveries (
188188+ webhook_id,
189189+ event,
190190+ delivery_id,
191191+ url,
192192+ request_body,
193193+ response_code,
194194+ response_body,
195195+ success
196196+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
197197+ `,
198198+ delivery.WebhookId,
199199+ delivery.Event,
200200+ delivery.DeliveryId,
201201+ delivery.Url,
202202+ delivery.RequestBody,
203203+ delivery.ResponseCode,
204204+ delivery.ResponseBody,
205205+ success,
206206+ )
207207+208208+ if err != nil {
209209+ return fmt.Errorf("failed to insert webhook delivery: %w", err)
210210+ }
211211+212212+ id, err := result.LastInsertId()
213213+ if err != nil {
214214+ return fmt.Errorf("failed to get delivery id: %w", err)
215215+ }
216216+217217+ delivery.Id = id
218218+ return nil
219219+}
220220+221221+// GetWebhookDeliveries returns recent deliveries for a webhook
222222+func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) {
223223+ if limit <= 0 {
224224+ limit = 20
225225+ }
226226+227227+ query := `
228228+ select
229229+ id,
230230+ webhook_id,
231231+ event,
232232+ delivery_id,
233233+ url,
234234+ request_body,
235235+ response_code,
236236+ response_body,
237237+ success,
238238+ created_at
239239+ from webhook_deliveries
240240+ where webhook_id = ?
241241+ order by created_at desc
242242+ limit ?
243243+ `
244244+245245+ rows, err := e.Query(query, webhookId, limit)
246246+ if err != nil {
247247+ return nil, fmt.Errorf("failed to query webhook deliveries: %w", err)
248248+ }
249249+ defer rows.Close()
250250+251251+ var deliveries []models.WebhookDelivery
252252+ for rows.Next() {
253253+ var d models.WebhookDelivery
254254+ var createdAt string
255255+ var success int
256256+ var responseCode sql.NullInt64
257257+ var responseBody sql.NullString
258258+259259+ err := rows.Scan(
260260+ &d.Id,
261261+ &d.WebhookId,
262262+ &d.Event,
263263+ &d.DeliveryId,
264264+ &d.Url,
265265+ &d.RequestBody,
266266+ &responseCode,
267267+ &responseBody,
268268+ &success,
269269+ &createdAt,
270270+ )
271271+ if err != nil {
272272+ return nil, fmt.Errorf("failed to scan delivery: %w", err)
273273+ }
274274+275275+ d.Success = success == 1
276276+ if responseCode.Valid {
277277+ d.ResponseCode = int(responseCode.Int64)
278278+ }
279279+ if responseBody.Valid {
280280+ d.ResponseBody = responseBody.String
281281+ }
282282+283283+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
284284+ d.CreatedAt = t
285285+ }
286286+287287+ deliveries = append(deliveries, d)
288288+ }
289289+290290+ if err = rows.Err(); err != nil {
291291+ return nil, fmt.Errorf("failed to iterate deliveries: %w", err)
292292+ }
293293+294294+ return deliveries, nil
295295+}
296296+297297+// GetWebhooksForRepo is a convenience function to get all webhooks for a repository
298298+func GetWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
299299+ return GetWebhooks(e, orm.FilterEq("repo_at", repoAt.String()))
300300+}
301301+302302+// GetActiveWebhooksForRepo returns only active webhooks for a repository
303303+func GetActiveWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
304304+ return GetWebhooks(e,
305305+ orm.FilterEq("repo_at", repoAt.String()),
306306+ orm.FilterEq("active", 1),
307307+ )
308308+}
+74
appview/models/webhook.go
···11+package models
22+33+import (
44+ "slices"
55+ "time"
66+77+ "github.com/bluesky-social/indigo/atproto/syntax"
88+)
99+1010+type WebhookEvent string
1111+1212+const (
1313+ WebhookEventPush WebhookEvent = "push"
1414+)
1515+1616+type Webhook struct {
1717+ Id int64
1818+ RepoAt syntax.ATURI
1919+ Url string
2020+ Secret string
2121+ Active bool
2222+ Events []string // comma-separated event types
2323+ CreatedAt time.Time
2424+ UpdatedAt time.Time
2525+}
2626+2727+// HasEvent checks if the webhook is subscribed to a specific event
2828+func (w *Webhook) HasEvent(event WebhookEvent) bool {
2929+ return slices.Contains(w.Events, string(event))
3030+}
3131+3232+type WebhookDelivery struct {
3333+ Id int64
3434+ WebhookId int64
3535+ Event string
3636+ DeliveryId string // UUID for tracking
3737+ Url string
3838+ RequestBody string
3939+ ResponseCode int
4040+ ResponseBody string
4141+ Success bool
4242+ CreatedAt time.Time
4343+}
4444+4545+// WebhookPayload represents the webhook payload structure
4646+type WebhookPayload struct {
4747+ Ref string `json:"ref"`
4848+ Before string `json:"before"`
4949+ After string `json:"after"`
5050+ Repository WebhookRepository `json:"repository"`
5151+ Pusher WebhookUser `json:"pusher"`
5252+}
5353+5454+// WebhookRepository represents repository information in webhook payload
5555+type WebhookRepository struct {
5656+ Name string `json:"name"`
5757+ FullName string `json:"full_name"`
5858+ Description string `json:"description"`
5959+ Fork bool `json:"fork"`
6060+ HtmlUrl string `json:"html_url"`
6161+ CloneUrl string `json:"clone_url"`
6262+ SshUrl string `json:"ssh_url"`
6363+ Website string `json:"website,omitempty"`
6464+ StarsCount int `json:"stars_count,omitempty"`
6565+ OpenIssues int `json:"open_issues_count,omitempty"`
6666+ CreatedAt string `json:"created_at"`
6767+ UpdatedAt string `json:"updated_at"`
6868+ Owner WebhookUser `json:"owner"`
6969+}
7070+7171+// WebhookUser represents user information in webhook payload
7272+type WebhookUser struct {
7373+ Did string `json:"did"`
7474+}