Monorepo for Tangled tangled.org

appview:{db,models}: webhook tables and crud ops #1066

merged opened by anirudh.fi targeting master from icy/qlyxxp
Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:hwevmowznbiukdf6uk5dwrrq/sh.tangled.repo.pull/3menyebs7l722
+402
Diff #5
+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
···
··· 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
···
··· 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
sign up or login to add to the discussion
1 commit
expand
appview/{db,models}: webhook tables and crud ops
3/3 success
expand
expand 0 comments
pull request successfully merged
1 commit
expand
appview/{db,models}: webhook tables and crud ops
3/3 success
expand
expand 0 comments
1 commit
expand
appview/{db,models}: webhook tables and crud ops
3/3 success
expand
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...

1 commit
expand
appview/{db,models}: webhook tables and crud ops
3/3 success
expand
expand 1 comment
  • here: we should make the secret nullable in the db, since we no longer sign if secret is not supplied
1 commit
expand
appview:{db,models}: webhook tables and crud ops
3/3 success
expand
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 string with more concrete variants
1 commit
expand
appview:{db,models}: webhook tables and crud ops
3/3 success
expand
expand 0 comments