···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
+298
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+ result, err := e.Exec(`
122122+ insert into webhooks (repo_at, url, secret, active, events)
123123+ values (?, ?, ?, ?, ?)
124124+ `, webhook.RepoAt.String(), webhook.Url, webhook.Secret, active, eventsStr)
125125+126126+ if err != nil {
127127+ return fmt.Errorf("failed to insert webhook: %w", err)
128128+ }
129129+130130+ id, err := result.LastInsertId()
131131+ if err != nil {
132132+ return fmt.Errorf("failed to get webhook id: %w", err)
133133+ }
134134+135135+ webhook.Id = id
136136+ return nil
137137+}
138138+139139+// UpdateWebhook updates an existing webhook
140140+func UpdateWebhook(e Execer, webhook *models.Webhook) error {
141141+ eventsStr := strings.Join(webhook.Events, ",")
142142+ active := 0
143143+ if webhook.Active {
144144+ active = 1
145145+ }
146146+147147+ _, err := e.Exec(`
148148+ update webhooks
149149+ set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
150150+ where id = ?
151151+ `, webhook.Url, webhook.Secret, active, eventsStr, webhook.Id)
152152+153153+ if err != nil {
154154+ return fmt.Errorf("failed to update webhook: %w", err)
155155+ }
156156+157157+ return nil
158158+}
159159+160160+// DeleteWebhook deletes a webhook
161161+func DeleteWebhook(e Execer, id int64) error {
162162+ _, err := e.Exec(`delete from webhooks where id = ?`, id)
163163+ if err != nil {
164164+ return fmt.Errorf("failed to delete webhook: %w", err)
165165+ }
166166+ return nil
167167+}
168168+169169+// AddWebhookDelivery records a webhook delivery attempt
170170+func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error {
171171+ success := 0
172172+ if delivery.Success {
173173+ success = 1
174174+ }
175175+176176+ result, err := e.Exec(`
177177+ insert into webhook_deliveries (
178178+ webhook_id,
179179+ event,
180180+ delivery_id,
181181+ url,
182182+ request_body,
183183+ response_code,
184184+ response_body,
185185+ success
186186+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
187187+ `,
188188+ delivery.WebhookId,
189189+ delivery.Event,
190190+ delivery.DeliveryId,
191191+ delivery.Url,
192192+ delivery.RequestBody,
193193+ delivery.ResponseCode,
194194+ delivery.ResponseBody,
195195+ success,
196196+ )
197197+198198+ if err != nil {
199199+ return fmt.Errorf("failed to insert webhook delivery: %w", err)
200200+ }
201201+202202+ id, err := result.LastInsertId()
203203+ if err != nil {
204204+ return fmt.Errorf("failed to get delivery id: %w", err)
205205+ }
206206+207207+ delivery.Id = id
208208+ return nil
209209+}
210210+211211+// GetWebhookDeliveries returns recent deliveries for a webhook
212212+func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) {
213213+ if limit <= 0 {
214214+ limit = 20
215215+ }
216216+217217+ query := `
218218+ select
219219+ id,
220220+ webhook_id,
221221+ event,
222222+ delivery_id,
223223+ url,
224224+ request_body,
225225+ response_code,
226226+ response_body,
227227+ success,
228228+ created_at
229229+ from webhook_deliveries
230230+ where webhook_id = ?
231231+ order by created_at desc
232232+ limit ?
233233+ `
234234+235235+ rows, err := e.Query(query, webhookId, limit)
236236+ if err != nil {
237237+ return nil, fmt.Errorf("failed to query webhook deliveries: %w", err)
238238+ }
239239+ defer rows.Close()
240240+241241+ var deliveries []models.WebhookDelivery
242242+ for rows.Next() {
243243+ var d models.WebhookDelivery
244244+ var createdAt string
245245+ var success int
246246+ var responseCode sql.NullInt64
247247+ var responseBody sql.NullString
248248+249249+ err := rows.Scan(
250250+ &d.Id,
251251+ &d.WebhookId,
252252+ &d.Event,
253253+ &d.DeliveryId,
254254+ &d.Url,
255255+ &d.RequestBody,
256256+ &responseCode,
257257+ &responseBody,
258258+ &success,
259259+ &createdAt,
260260+ )
261261+ if err != nil {
262262+ return nil, fmt.Errorf("failed to scan delivery: %w", err)
263263+ }
264264+265265+ d.Success = success == 1
266266+ if responseCode.Valid {
267267+ d.ResponseCode = int(responseCode.Int64)
268268+ }
269269+ if responseBody.Valid {
270270+ d.ResponseBody = responseBody.String
271271+ }
272272+273273+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
274274+ d.CreatedAt = t
275275+ }
276276+277277+ deliveries = append(deliveries, d)
278278+ }
279279+280280+ if err = rows.Err(); err != nil {
281281+ return nil, fmt.Errorf("failed to iterate deliveries: %w", err)
282282+ }
283283+284284+ return deliveries, nil
285285+}
286286+287287+// GetWebhooksForRepo is a convenience function to get all webhooks for a repository
288288+func GetWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
289289+ return GetWebhooks(e, orm.FilterEq("repo_at", repoAt.String()))
290290+}
291291+292292+// GetActiveWebhooksForRepo returns only active webhooks for a repository
293293+func GetActiveWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) {
294294+ return GetWebhooks(e,
295295+ orm.FilterEq("repo_at", repoAt.String()),
296296+ orm.FilterEq("active", 1),
297297+ )
298298+}
+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+}