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
+398
Diff #2
+30
appview/db/db.go
··· 568 568 unique (from_at, to_at) 569 569 ); 570 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 not null, 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 + 571 599 create table if not exists migrations ( 572 600 id integer primary key autoincrement, 573 601 name text unique ··· 578 606 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 579 607 create index if not exists idx_references_from_at on reference_links(from_at); 580 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); 581 611 `) 582 612 if err != nil { 583 613 return nil, err
+294
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 active int 54 + 55 + err := rows.Scan( 56 + &wh.Id, 57 + &wh.RepoAt, 58 + &wh.Url, 59 + &wh.Secret, 60 + &active, 61 + &eventsStr, 62 + &createdAt, 63 + &updatedAt, 64 + ) 65 + if err != nil { 66 + return nil, fmt.Errorf("failed to scan webhook: %w", err) 67 + } 68 + 69 + wh.Active = active == 1 70 + if eventsStr != "" { 71 + wh.Events = strings.Split(eventsStr, ",") 72 + } 73 + 74 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 75 + wh.CreatedAt = t 76 + } 77 + if t, err := time.Parse(time.RFC3339, updatedAt); err == nil { 78 + wh.UpdatedAt = t 79 + } 80 + 81 + webhooks = append(webhooks, wh) 82 + } 83 + 84 + if err = rows.Err(); err != nil { 85 + return nil, fmt.Errorf("failed to iterate webhooks: %w", err) 86 + } 87 + 88 + return webhooks, nil 89 + } 90 + 91 + // GetWebhook returns a single webhook by ID 92 + func GetWebhook(e Execer, id int64) (*models.Webhook, error) { 93 + webhooks, err := GetWebhooks(e, orm.FilterEq("id", id)) 94 + if err != nil { 95 + return nil, err 96 + } 97 + 98 + if len(webhooks) == 0 { 99 + return nil, sql.ErrNoRows 100 + } 101 + 102 + if len(webhooks) != 1 { 103 + return nil, fmt.Errorf("expected 1 webhook, got %d", len(webhooks)) 104 + } 105 + 106 + return &webhooks[0], nil 107 + } 108 + 109 + // AddWebhook creates a new webhook 110 + func AddWebhook(e Execer, webhook *models.Webhook) error { 111 + eventsStr := strings.Join(webhook.Events, ",") 112 + active := 0 113 + if webhook.Active { 114 + active = 1 115 + } 116 + 117 + result, err := e.Exec(` 118 + insert into webhooks (repo_at, url, secret, active, events) 119 + values (?, ?, ?, ?, ?) 120 + `, webhook.RepoAt.String(), webhook.Url, webhook.Secret, active, eventsStr) 121 + 122 + if err != nil { 123 + return fmt.Errorf("failed to insert webhook: %w", err) 124 + } 125 + 126 + id, err := result.LastInsertId() 127 + if err != nil { 128 + return fmt.Errorf("failed to get webhook id: %w", err) 129 + } 130 + 131 + webhook.Id = id 132 + return nil 133 + } 134 + 135 + // UpdateWebhook updates an existing webhook 136 + func UpdateWebhook(e Execer, webhook *models.Webhook) error { 137 + eventsStr := strings.Join(webhook.Events, ",") 138 + active := 0 139 + if webhook.Active { 140 + active = 1 141 + } 142 + 143 + _, err := e.Exec(` 144 + update webhooks 145 + set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 146 + where id = ? 147 + `, webhook.Url, webhook.Secret, active, eventsStr, webhook.Id) 148 + 149 + if err != nil { 150 + return fmt.Errorf("failed to update webhook: %w", err) 151 + } 152 + 153 + return nil 154 + } 155 + 156 + // DeleteWebhook deletes a webhook 157 + func DeleteWebhook(e Execer, id int64) error { 158 + _, err := e.Exec(`delete from webhooks where id = ?`, id) 159 + if err != nil { 160 + return fmt.Errorf("failed to delete webhook: %w", err) 161 + } 162 + return nil 163 + } 164 + 165 + // AddWebhookDelivery records a webhook delivery attempt 166 + func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error { 167 + success := 0 168 + if delivery.Success { 169 + success = 1 170 + } 171 + 172 + result, err := e.Exec(` 173 + insert into webhook_deliveries ( 174 + webhook_id, 175 + event, 176 + delivery_id, 177 + url, 178 + request_body, 179 + response_code, 180 + response_body, 181 + success 182 + ) values (?, ?, ?, ?, ?, ?, ?, ?) 183 + `, 184 + delivery.WebhookId, 185 + delivery.Event, 186 + delivery.DeliveryId, 187 + delivery.Url, 188 + delivery.RequestBody, 189 + delivery.ResponseCode, 190 + delivery.ResponseBody, 191 + success, 192 + ) 193 + 194 + if err != nil { 195 + return fmt.Errorf("failed to insert webhook delivery: %w", err) 196 + } 197 + 198 + id, err := result.LastInsertId() 199 + if err != nil { 200 + return fmt.Errorf("failed to get delivery id: %w", err) 201 + } 202 + 203 + delivery.Id = id 204 + return nil 205 + } 206 + 207 + // GetWebhookDeliveries returns recent deliveries for a webhook 208 + func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) { 209 + if limit <= 0 { 210 + limit = 20 211 + } 212 + 213 + query := ` 214 + select 215 + id, 216 + webhook_id, 217 + event, 218 + delivery_id, 219 + url, 220 + request_body, 221 + response_code, 222 + response_body, 223 + success, 224 + created_at 225 + from webhook_deliveries 226 + where webhook_id = ? 227 + order by created_at desc 228 + limit ? 229 + ` 230 + 231 + rows, err := e.Query(query, webhookId, limit) 232 + if err != nil { 233 + return nil, fmt.Errorf("failed to query webhook deliveries: %w", err) 234 + } 235 + defer rows.Close() 236 + 237 + var deliveries []models.WebhookDelivery 238 + for rows.Next() { 239 + var d models.WebhookDelivery 240 + var createdAt string 241 + var success int 242 + var responseCode sql.NullInt64 243 + var responseBody sql.NullString 244 + 245 + err := rows.Scan( 246 + &d.Id, 247 + &d.WebhookId, 248 + &d.Event, 249 + &d.DeliveryId, 250 + &d.Url, 251 + &d.RequestBody, 252 + &responseCode, 253 + &responseBody, 254 + &success, 255 + &createdAt, 256 + ) 257 + if err != nil { 258 + return nil, fmt.Errorf("failed to scan delivery: %w", err) 259 + } 260 + 261 + d.Success = success == 1 262 + if responseCode.Valid { 263 + d.ResponseCode = int(responseCode.Int64) 264 + } 265 + if responseBody.Valid { 266 + d.ResponseBody = responseBody.String 267 + } 268 + 269 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 270 + d.CreatedAt = t 271 + } 272 + 273 + deliveries = append(deliveries, d) 274 + } 275 + 276 + if err = rows.Err(); err != nil { 277 + return nil, fmt.Errorf("failed to iterate deliveries: %w", err) 278 + } 279 + 280 + return deliveries, nil 281 + } 282 + 283 + // GetWebhooksForRepo is a convenience function to get all webhooks for a repository 284 + func GetWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) { 285 + return GetWebhooks(e, orm.FilterEq("repo_at", repoAt.String())) 286 + } 287 + 288 + // GetActiveWebhooksForRepo returns only active webhooks for a repository 289 + func GetActiveWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) { 290 + return GetWebhooks(e, 291 + orm.FilterEq("repo_at", repoAt.String()), 292 + orm.FilterEq("active", 1), 293 + ) 294 + }
+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