···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 events578578+ 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 cascade582582+ );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 cascade597597+ );598598+571599 create table if not exists migrations (572600 id integer primary key autoincrement,573601 name text unique···606578 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);607579 create index if not exists idx_references_from_at on reference_links(from_at);608580 create index if not exists idx_references_to_at on reference_links(to_at);581581+ create index if not exists idx_webhooks_repo_at on webhooks(repo_at);582582+ create index if not exists idx_webhook_deliveries_webhook_id on webhook_deliveries(webhook_id);609583 `)610584 if err != nil {611585 return nil, err
+298
appview/db/webhooks.go
···11+package db22+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 repository1515+func GetWebhooks(e Execer, filters ...orm.Filter) ([]models.Webhook, error) {1616+ var conditions []string1717+ var args []any1818+ 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+ select3030+ id,3131+ repo_at,3232+ url,3333+ secret,3434+ active,3535+ events,3636+ created_at,3737+ updated_at3838+ from webhooks3939+ %s4040+ order by created_at desc4141+ `, 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.Webhook5050+ for rows.Next() {5151+ var wh models.Webhook5252+ var createdAt, updatedAt, eventsStr string5353+ var secret sql.NullString5454+ var active int5555+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.String7272+ }7373+ wh.Active = active == 17474+ if eventsStr != "" {7575+ wh.Events = strings.Split(eventsStr, ",")7676+ }7777+7878+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {7979+ wh.CreatedAt = t8080+ }8181+ if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {8282+ wh.UpdatedAt = t8383+ }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, nil9393+}9494+9595+// GetWebhook returns a single webhook by ID9696+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, err100100+ }101101+102102+ if len(webhooks) == 0 {103103+ return nil, sql.ErrNoRows104104+ }105105+106106+ if len(webhooks) != 1 {107107+ return nil, fmt.Errorf("expected 1 webhook, got %d", len(webhooks))108108+ }109109+110110+ return &webhooks[0], nil111111+}112112+113113+// AddWebhook creates a new webhook114114+func AddWebhook(e Execer, webhook *models.Webhook) error {115115+ eventsStr := strings.Join(webhook.Events, ",")116116+ active := 0117117+ if webhook.Active {118118+ active = 1119119+ }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 = id136136+ return nil137137+}138138+139139+// UpdateWebhook updates an existing webhook140140+func UpdateWebhook(e Execer, webhook *models.Webhook) error {141141+ eventsStr := strings.Join(webhook.Events, ",")142142+ active := 0143143+ if webhook.Active {144144+ active = 1145145+ }146146+147147+ _, err := e.Exec(`148148+ update webhooks149149+ 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 nil158158+}159159+160160+// DeleteWebhook deletes a webhook161161+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 nil167167+}168168+169169+// AddWebhookDelivery records a webhook delivery attempt170170+func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error {171171+ success := 0172172+ if delivery.Success {173173+ success = 1174174+ }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+ success186186+ ) 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 = id208208+ return nil209209+}210210+211211+// GetWebhookDeliveries returns recent deliveries for a webhook212212+func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) {213213+ if limit <= 0 {214214+ limit = 20215215+ }216216+217217+ query := `218218+ select219219+ id,220220+ webhook_id,221221+ event,222222+ delivery_id,223223+ url,224224+ request_body,225225+ response_code,226226+ response_body,227227+ success,228228+ created_at229229+ from webhook_deliveries230230+ where webhook_id = ?231231+ order by created_at desc232232+ 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.WebhookDelivery242242+ for rows.Next() {243243+ var d models.WebhookDelivery244244+ var createdAt string245245+ var success int246246+ var responseCode sql.NullInt64247247+ var responseBody sql.NullString248248+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 == 1266266+ if responseCode.Valid {267267+ d.ResponseCode = int(responseCode.Int64)268268+ }269269+ if responseBody.Valid {270270+ d.ResponseBody = responseBody.String271271+ }272272+273273+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {274274+ d.CreatedAt = t275275+ }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, nil285285+}286286+287287+// GetWebhooksForRepo is a convenience function to get all webhooks for a repository288288+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 repository293293+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 models22+33+import (44+ "slices"55+ "time"66+77+ "github.com/bluesky-social/indigo/atproto/syntax"88+)99+1010+type WebhookEvent string1111+1212+const (1313+ WebhookEventPush WebhookEvent = "push"1414+)1515+1616+type Webhook struct {1717+ Id int641818+ RepoAt syntax.ATURI1919+ Url string2020+ Secret string2121+ Active bool2222+ Events []string // comma-separated event types2323+ CreatedAt time.Time2424+ UpdatedAt time.Time2525+}2626+2727+// HasEvent checks if the webhook is subscribed to a specific event2828+func (w *Webhook) HasEvent(event WebhookEvent) bool {2929+ return slices.Contains(w.Events, string(event))3030+}3131+3232+type WebhookDelivery struct {3333+ Id int643434+ WebhookId int643535+ Event string3636+ DeliveryId string // UUID for tracking3737+ Url string3838+ RequestBody string3939+ ResponseCode int4040+ ResponseBody string4141+ Success bool4242+ CreatedAt time.Time4343+}4444+4545+// WebhookPayload represents the webhook payload structure4646+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 payload5555+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 payload7272+type WebhookUser struct {7373+ Did string `json:"did"`7474+}