diff --git a/api/tangled/repocreateWebhook.go b/api/tangled/repocreateWebhook.go deleted file mode 100644 index d38172d0..00000000 --- a/api/tangled/repocreateWebhook.go +++ /dev/null @@ -1,45 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: org.tangled.temp.repo.createWebhook - -import ( - "context" - - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - TempRepoCreateWebhookNSID = "org.tangled.temp.repo.createWebhook" -) - -// TempRepoCreateWebhook_Input is the input argument to a org.tangled.temp.repo.createWebhook call. -type TempRepoCreateWebhook_Input struct { - // active: Whether the webhook should be active immediately. Defaults to true. - Active *bool `json:"active,omitempty" cborgen:"active,omitempty"` - // events: Event types to subscribe to (e.g. 'push', 'repository:renamed'). - Events []string `json:"events" cborgen:"events"` - // repoDid: DID of the repository as minted by the knot. - RepoDid string `json:"repoDid" cborgen:"repoDid"` - // secret: Optional HMAC secret used to sign payloads. If omitted, payloads are not signed. - Secret *string `json:"secret,omitempty" cborgen:"secret,omitempty"` - // url: Endpoint URL that will receive webhook payloads. - Url string `json:"url" cborgen:"url"` -} - -// TempRepoCreateWebhook_Output is the output of a org.tangled.temp.repo.createWebhook call. -type TempRepoCreateWebhook_Output struct { - // id: ID of the newly created webhook. - Id int64 `json:"id" cborgen:"id"` -} - -// TempRepoCreateWebhook calls the XRPC method "org.tangled.temp.repo.createWebhook". -func TempRepoCreateWebhook(ctx context.Context, c util.LexClient, input *TempRepoCreateWebhook_Input) (*TempRepoCreateWebhook_Output, error) { - var out TempRepoCreateWebhook_Output - if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.createWebhook", nil, input, &out); err != nil { - return nil, err - } - - return &out, nil -} diff --git a/api/tangled/repodeleteWebhook.go b/api/tangled/repodeleteWebhook.go deleted file mode 100644 index 3f4bd6a5..00000000 --- a/api/tangled/repodeleteWebhook.go +++ /dev/null @@ -1,32 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: org.tangled.temp.repo.deleteWebhook - -import ( - "context" - - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - TempRepoDeleteWebhookNSID = "org.tangled.temp.repo.deleteWebhook" -) - -// TempRepoDeleteWebhook_Input is the input argument to a org.tangled.temp.repo.deleteWebhook call. -type TempRepoDeleteWebhook_Input struct { - // id: Webhook ID to delete. - Id int64 `json:"id" cborgen:"id"` - // repoDid: DID of the repository as minted by the knot. - RepoDid string `json:"repoDid" cborgen:"repoDid"` -} - -// TempRepoDeleteWebhook calls the XRPC method "org.tangled.temp.repo.deleteWebhook". -func TempRepoDeleteWebhook(ctx context.Context, c util.LexClient, input *TempRepoDeleteWebhook_Input) error { - if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.deleteWebhook", nil, input, nil); err != nil { - return err - } - - return nil -} diff --git a/api/tangled/repolistWebhookDeliveries.go b/api/tangled/repolistWebhookDeliveries.go deleted file mode 100644 index d46a5c12..00000000 --- a/api/tangled/repolistWebhookDeliveries.go +++ /dev/null @@ -1,55 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: org.tangled.temp.repo.listWebhookDeliveries - -import ( - "context" - - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - TempRepoListWebhookDeliveriesNSID = "org.tangled.temp.repo.listWebhookDeliveries" -) - -// TempRepoListWebhookDeliveries_Delivery is a "delivery" in the org.tangled.temp.repo.listWebhookDeliveries schema. -type TempRepoListWebhookDeliveries_Delivery struct { - CreatedAt string `json:"createdAt" cborgen:"createdAt"` - // deliveryId: UUID for tracking this delivery attempt. - DeliveryId string `json:"deliveryId" cborgen:"deliveryId"` - // event: Event type that triggered the delivery. - Event string `json:"event" cborgen:"event"` - Id int64 `json:"id" cborgen:"id"` - RequestBody *string `json:"requestBody,omitempty" cborgen:"requestBody,omitempty"` - ResponseBody *string `json:"responseBody,omitempty" cborgen:"responseBody,omitempty"` - ResponseCode *int64 `json:"responseCode,omitempty" cborgen:"responseCode,omitempty"` - Success bool `json:"success" cborgen:"success"` - Url string `json:"url" cborgen:"url"` -} - -// TempRepoListWebhookDeliveries_Output is the output of a org.tangled.temp.repo.listWebhookDeliveries call. -type TempRepoListWebhookDeliveries_Output struct { - Deliveries []*TempRepoListWebhookDeliveries_Delivery `json:"deliveries" cborgen:"deliveries"` -} - -// TempRepoListWebhookDeliveries calls the XRPC method "org.tangled.temp.repo.listWebhookDeliveries". -// -// id: Webhook ID. -// repoDid: DID of the repository as minted by the knot. -func TempRepoListWebhookDeliveries(ctx context.Context, c util.LexClient, id int64, limit int64, repoDid string) (*TempRepoListWebhookDeliveries_Output, error) { - var out TempRepoListWebhookDeliveries_Output - - params := map[string]interface{}{} - params["id"] = id - if limit != 0 { - params["limit"] = limit - } - params["repoDid"] = repoDid - if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.repo.listWebhookDeliveries", params, nil, &out); err != nil { - return nil, err - } - - return &out, nil -} diff --git a/api/tangled/repolistWebhooks.go b/api/tangled/repolistWebhooks.go deleted file mode 100644 index 719ca25e..00000000 --- a/api/tangled/repolistWebhooks.go +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: org.tangled.temp.repo.listWebhooks - -import ( - "context" - - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - TempRepoListWebhooksNSID = "org.tangled.temp.repo.listWebhooks" -) - -// TempRepoListWebhooks_Output is the output of a org.tangled.temp.repo.listWebhooks call. -type TempRepoListWebhooks_Output struct { - Webhooks []*TempRepoListWebhooks_Webhook `json:"webhooks" cborgen:"webhooks"` -} - -// TempRepoListWebhooks_Webhook is a "webhook" in the org.tangled.temp.repo.listWebhooks schema. -type TempRepoListWebhooks_Webhook struct { - // active: Whether the webhook is currently enabled. - Active bool `json:"active" cborgen:"active"` - CreatedAt string `json:"createdAt" cborgen:"createdAt"` - // events: Event types this webhook is subscribed to (e.g. 'push', 'repository:renamed'). - Events []string `json:"events" cborgen:"events"` - // id: Webhook identifier. - Id int64 `json:"id" cborgen:"id"` - UpdatedAt *string `json:"updatedAt,omitempty" cborgen:"updatedAt,omitempty"` - // url: Endpoint URL that receives webhook payloads. - Url string `json:"url" cborgen:"url"` -} - -// TempRepoListWebhooks calls the XRPC method "org.tangled.temp.repo.listWebhooks". -// -// repoDid: DID of the repository as minted by the knot. -func TempRepoListWebhooks(ctx context.Context, c util.LexClient, repoDid string) (*TempRepoListWebhooks_Output, error) { - var out TempRepoListWebhooks_Output - - params := map[string]interface{}{} - params["repoDid"] = repoDid - if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.repo.listWebhooks", params, nil, &out); err != nil { - return nil, err - } - - return &out, nil -} diff --git a/api/tangled/reporetryWebhookDelivery.go b/api/tangled/reporetryWebhookDelivery.go deleted file mode 100644 index d5e3f118..00000000 --- a/api/tangled/reporetryWebhookDelivery.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: org.tangled.temp.repo.retryWebhookDelivery - -import ( - "context" - - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - TempRepoRetryWebhookDeliveryNSID = "org.tangled.temp.repo.retryWebhookDelivery" -) - -// TempRepoRetryWebhookDelivery_Input is the input argument to a org.tangled.temp.repo.retryWebhookDelivery call. -type TempRepoRetryWebhookDelivery_Input struct { - // deliveryId: UUID of the delivery to retry. - DeliveryId string `json:"deliveryId" cborgen:"deliveryId"` - // repoDid: DID of the repository as minted by the knot. - RepoDid string `json:"repoDid" cborgen:"repoDid"` - // webhookId: Webhook ID that owns the delivery. - WebhookId int64 `json:"webhookId" cborgen:"webhookId"` -} - -// TempRepoRetryWebhookDelivery calls the XRPC method "org.tangled.temp.repo.retryWebhookDelivery". -func TempRepoRetryWebhookDelivery(ctx context.Context, c util.LexClient, input *TempRepoRetryWebhookDelivery_Input) error { - if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.retryWebhookDelivery", nil, input, nil); err != nil { - return err - } - - return nil -} diff --git a/api/tangled/repotoggleWebhook.go b/api/tangled/repotoggleWebhook.go deleted file mode 100644 index 69c2d292..00000000 --- a/api/tangled/repotoggleWebhook.go +++ /dev/null @@ -1,39 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: org.tangled.temp.repo.toggleWebhook - -import ( - "context" - - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - TempRepoToggleWebhookNSID = "org.tangled.temp.repo.toggleWebhook" -) - -// TempRepoToggleWebhook_Input is the input argument to a org.tangled.temp.repo.toggleWebhook call. -type TempRepoToggleWebhook_Input struct { - // id: Webhook ID to toggle. - Id int64 `json:"id" cborgen:"id"` - // repoDid: DID of the repository as minted by the knot. - RepoDid string `json:"repoDid" cborgen:"repoDid"` -} - -// TempRepoToggleWebhook_Output is the output of a org.tangled.temp.repo.toggleWebhook call. -type TempRepoToggleWebhook_Output struct { - // active: New active state of the webhook. - Active bool `json:"active" cborgen:"active"` -} - -// TempRepoToggleWebhook calls the XRPC method "org.tangled.temp.repo.toggleWebhook". -func TempRepoToggleWebhook(ctx context.Context, c util.LexClient, input *TempRepoToggleWebhook_Input) (*TempRepoToggleWebhook_Output, error) { - var out TempRepoToggleWebhook_Output - if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.toggleWebhook", nil, input, &out); err != nil { - return nil, err - } - - return &out, nil -} diff --git a/api/tangled/repoupdateWebhook.go b/api/tangled/repoupdateWebhook.go deleted file mode 100644 index 9d654c5b..00000000 --- a/api/tangled/repoupdateWebhook.go +++ /dev/null @@ -1,36 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: org.tangled.temp.repo.updateWebhook - -import ( - "context" - - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - TempRepoUpdateWebhookNSID = "org.tangled.temp.repo.updateWebhook" -) - -// TempRepoUpdateWebhook_Input is the input argument to a org.tangled.temp.repo.updateWebhook call. -type TempRepoUpdateWebhook_Input struct { - Active *bool `json:"active,omitempty" cborgen:"active,omitempty"` - Events []string `json:"events,omitempty" cborgen:"events,omitempty"` - // id: Webhook ID to update. - Id int64 `json:"id" cborgen:"id"` - // repoDid: DID of the repository as minted by the knot. - RepoDid string `json:"repoDid" cborgen:"repoDid"` - Secret *string `json:"secret,omitempty" cborgen:"secret,omitempty"` - Url *string `json:"url,omitempty" cborgen:"url,omitempty"` -} - -// TempRepoUpdateWebhook calls the XRPC method "org.tangled.temp.repo.updateWebhook". -func TempRepoUpdateWebhook(ctx context.Context, c util.LexClient, input *TempRepoUpdateWebhook_Input) error { - if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.updateWebhook", nil, input, nil); err != nil { - return err - } - - return nil -} diff --git a/appview/db/webhooks.go b/appview/db/webhooks.go deleted file mode 100644 index e7b6b121..00000000 --- a/appview/db/webhooks.go +++ /dev/null @@ -1,349 +0,0 @@ -package db - -import ( - "database/sql" - "fmt" - "strings" - "time" - - "tangled.org/core/appview/models" - "tangled.org/core/orm" -) - -// GetWebhooks returns all webhooks for a repository -func GetWebhooks(e Execer, filters ...orm.Filter) ([]models.Webhook, error) { - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(` - select - id, - repo_did, - url, - secret, - active, - events, - created_at, - updated_at - from webhooks - %s - order by created_at desc - `, whereClause) - - rows, err := e.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("failed to query webhooks: %w", err) - } - defer rows.Close() - - var webhooks []models.Webhook - for rows.Next() { - var wh models.Webhook - var createdAt, updatedAt, eventsStr string - var secret sql.NullString - var active int - - err := rows.Scan( - &wh.Id, - &wh.RepoDid, - &wh.Url, - &secret, - &active, - &eventsStr, - &createdAt, - &updatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan webhook: %w", err) - } - - if secret.Valid { - wh.Secret = secret.String - } - wh.Active = active == 1 - if eventsStr != "" { - wh.Events = strings.Split(eventsStr, ",") - } - - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { - wh.CreatedAt = t - } - if t, err := time.Parse(time.RFC3339, updatedAt); err == nil { - wh.UpdatedAt = t - } - - webhooks = append(webhooks, wh) - } - - if err = rows.Err(); err != nil { - return nil, fmt.Errorf("failed to iterate webhooks: %w", err) - } - - return webhooks, nil -} - -// GetWebhook returns a single webhook by ID -func GetWebhook(e Execer, id int64) (*models.Webhook, error) { - webhooks, err := GetWebhooks(e, orm.FilterEq("id", id)) - if err != nil { - return nil, err - } - - if len(webhooks) == 0 { - return nil, sql.ErrNoRows - } - - if len(webhooks) != 1 { - return nil, fmt.Errorf("expected 1 webhook, got %d", len(webhooks)) - } - - return &webhooks[0], nil -} - -// AddWebhook creates a new webhook -func AddWebhook(e Execer, webhook *models.Webhook) error { - eventsStr := strings.Join(webhook.Events, ",") - active := 0 - if webhook.Active { - active = 1 - } - - result, err := e.Exec(` - insert into webhooks (repo_did, url, secret, active, events) - values (?, ?, ?, ?, ?) - `, string(webhook.RepoDid), webhook.Url, webhook.Secret, active, eventsStr) - - if err != nil { - return fmt.Errorf("failed to insert webhook: %w", err) - } - - id, err := result.LastInsertId() - if err != nil { - return fmt.Errorf("failed to get webhook id: %w", err) - } - - webhook.Id = id - return nil -} - -// UpdateWebhook updates an existing webhook -func UpdateWebhook(e Execer, webhook *models.Webhook) error { - eventsStr := strings.Join(webhook.Events, ",") - active := 0 - if webhook.Active { - active = 1 - } - - _, err := e.Exec(` - update webhooks - set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - where id = ? - `, webhook.Url, webhook.Secret, active, eventsStr, webhook.Id) - - if err != nil { - return fmt.Errorf("failed to update webhook: %w", err) - } - - return nil -} - -// DeleteWebhook deletes a webhook -func DeleteWebhook(e Execer, id int64) error { - _, err := e.Exec(`delete from webhooks where id = ?`, id) - if err != nil { - return fmt.Errorf("failed to delete webhook: %w", err) - } - return nil -} - -// AddWebhookDelivery records a webhook delivery attempt -func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error { - success := 0 - if delivery.Success { - success = 1 - } - - result, err := e.Exec(` - insert into webhook_deliveries ( - webhook_id, - event, - delivery_id, - url, - request_body, - response_code, - response_body, - success - ) values (?, ?, ?, ?, ?, ?, ?, ?) - `, - delivery.WebhookId, - delivery.Event, - delivery.DeliveryId, - delivery.Url, - delivery.RequestBody, - delivery.ResponseCode, - delivery.ResponseBody, - success, - ) - - if err != nil { - return fmt.Errorf("failed to insert webhook delivery: %w", err) - } - - id, err := result.LastInsertId() - if err != nil { - return fmt.Errorf("failed to get delivery id: %w", err) - } - - delivery.Id = id - return nil -} - -// GetWebhookDeliveries returns recent deliveries for a webhook -func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) { - if limit <= 0 { - limit = 20 - } - - query := ` - select - id, - webhook_id, - event, - delivery_id, - url, - request_body, - response_code, - response_body, - success, - created_at - from webhook_deliveries - where webhook_id = ? - order by created_at desc - limit ? - ` - - rows, err := e.Query(query, webhookId, limit) - if err != nil { - return nil, fmt.Errorf("failed to query webhook deliveries: %w", err) - } - defer rows.Close() - - var deliveries []models.WebhookDelivery - for rows.Next() { - var d models.WebhookDelivery - var createdAt string - var success int - var responseCode sql.NullInt64 - var responseBody sql.NullString - - err := rows.Scan( - &d.Id, - &d.WebhookId, - &d.Event, - &d.DeliveryId, - &d.Url, - &d.RequestBody, - &responseCode, - &responseBody, - &success, - &createdAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan delivery: %w", err) - } - - d.Success = success == 1 - if responseCode.Valid { - d.ResponseCode = int(responseCode.Int64) - } - if responseBody.Valid { - d.ResponseBody = responseBody.String - } - - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { - d.CreatedAt = t - } - - deliveries = append(deliveries, d) - } - - if err = rows.Err(); err != nil { - return nil, fmt.Errorf("failed to iterate deliveries: %w", err) - } - - return deliveries, nil -} - -// GetWebhookDelivery returns a single delivery by its delivery_id (a uuid). -func GetWebhookDelivery(e Execer, deliveryId string) (*models.WebhookDelivery, error) { - var d models.WebhookDelivery - var createdAt string - var success int - var responseCode sql.NullInt64 - var responseBody sql.NullString - - err := e.QueryRow(` - select - id, - webhook_id, - event, - delivery_id, - url, - request_body, - response_code, - response_body, - success, - created_at - from webhook_deliveries - where delivery_id = ? - `, deliveryId).Scan( - &d.Id, - &d.WebhookId, - &d.Event, - &d.DeliveryId, - &d.Url, - &d.RequestBody, - &responseCode, - &responseBody, - &success, - &createdAt, - ) - if err != nil { - return nil, err - } - - d.Success = success == 1 - if responseCode.Valid { - d.ResponseCode = int(responseCode.Int64) - } - if responseBody.Valid { - d.ResponseBody = responseBody.String - } - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { - d.CreatedAt = t - } - - return &d, nil -} - -// GetWebhooksForRepo is a convenience function to get all webhooks for a repository -func GetWebhooksForRepo(e Execer, repoDid string) ([]models.Webhook, error) { - return GetWebhooks(e, orm.FilterEq("repo_did", repoDid)) -} - -// GetActiveWebhooksForRepo returns only active webhooks for a repository -func GetActiveWebhooksForRepo(e Execer, repoDid string) ([]models.Webhook, error) { - return GetWebhooks(e, - orm.FilterEq("repo_did", repoDid), - orm.FilterEq("active", 1), - ) -} diff --git a/appview/models/webhook.go b/appview/models/webhook.go index b3068d2f..b2c08306 100644 --- a/appview/models/webhook.go +++ b/appview/models/webhook.go @@ -1,12 +1,16 @@ package models import ( - "slices" "time" "github.com/bluesky-social/indigo/atproto/syntax" ) +// Webhook configuration and delivery types are view models: webhooks are now +// owned and delivered by the repo's spindle, and the appview only renders them +// (populated from spindle's XRPC responses). The wire payload structs live in +// spindle/models. + type WebhookEvent string const ( @@ -23,18 +27,12 @@ type Webhook struct { Id int64 RepoDid syntax.DID Url string - Secret string Active bool - Events []string // comma-separated event types + Events []string CreatedAt time.Time UpdatedAt time.Time } -// HasEvent checks if the webhook is subscribed to a specific event -func (w *Webhook) HasEvent(event WebhookEvent) bool { - return slices.Contains(w.Events, string(event)) -} - type WebhookDelivery struct { Id int64 WebhookId int64 @@ -47,73 +45,3 @@ type WebhookDelivery struct { Success bool CreatedAt time.Time } - -// WebhookPayload represents the webhook payload structure -type WebhookPayload struct { - Ref string `json:"ref"` - Before string `json:"before"` - After string `json:"after"` - Repository WebhookRepository `json:"repository"` - Pusher WebhookUser `json:"pusher"` -} - -// WebhookRepository represents repository information in webhook payload -type WebhookRepository struct { - Name string `json:"name"` - FullName string `json:"full_name"` - Description string `json:"description"` - Fork bool `json:"fork"` - HtmlUrl string `json:"html_url"` - CloneUrl string `json:"clone_url"` - SshUrl string `json:"ssh_url"` - Website string `json:"website,omitempty"` - StarsCount int `json:"stars_count,omitempty"` - OpenIssues int `json:"open_issues_count,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Owner WebhookUser `json:"owner"` -} - -// WebhookUser represents user information in webhook payload -type WebhookUser struct { - Did string `json:"did"` -} - -// WebhookRenamePayload represents the payload for a repository:renamed event -type WebhookRenamePayload struct { - OldName string `json:"old_name"` - NewName string `json:"new_name"` - Repository WebhookRepository `json:"repository"` - Sender WebhookUser `json:"sender"` -} - -// WebhookPullRequestPayload represents the payload for pull_request:* events -type WebhookPullRequestPayload struct { - Action string `json:"action"` - PullRequest WebhookPullRequest `json:"pull_request"` - Repository WebhookRepository `json:"repository"` - Sender WebhookUser `json:"sender"` -} - -// WebhookPullRequest represents pull request information in webhook payload -type WebhookPullRequest struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - State string `json:"state"` - TargetBranch string `json:"target_branch"` - Source *WebhookPullRequestSource `json:"source,omitempty"` - RoundNumber int `json:"round_number"` - Owner WebhookUser `json:"owner"` - HtmlUrl string `json:"html_url"` - PatchUrl string `json:"patch_url"` - CreatedAt string `json:"created_at"` -} - -// WebhookPullRequestSource represents the source of a branch- or fork-based -// pull request; absent for patch-based pull requests -type WebhookPullRequestSource struct { - Branch string `json:"branch"` - Repo string `json:"repo,omitempty"` - Sha string `json:"sha,omitempty"` -} diff --git a/appview/notify/webhook/notifier.go b/appview/notify/webhook/notifier.go deleted file mode 100644 index 21732a79..00000000 --- a/appview/notify/webhook/notifier.go +++ /dev/null @@ -1,371 +0,0 @@ -package webhook - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "time" - - "github.com/avast/retry-go/v4" - "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/google/uuid" - "tangled.org/core/appview/db" - "tangled.org/core/appview/models" - "tangled.org/core/appview/notify" - "tangled.org/core/hostutil" - "tangled.org/core/log" - "tangled.org/core/orm" -) - -type Notifier struct { - notify.BaseNotifier - db *db.DB - baseUrl string - logger *slog.Logger - client *http.Client -} - -func NewNotifier(database *db.DB, baseUrl string, dev bool) *Notifier { - return &Notifier{ - db: database, - baseUrl: baseUrl, - logger: log.New("webhook-notifier"), - // user-supplied webhook URLs are untrusted: block internal address - // ranges and don't follow redirects to guard against SSRF. - client: hostutil.SafeClient(dev, 30*time.Second), - } -} - -var _ notify.Notifier = &Notifier{} - -func (w *Notifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { - webhooks, err := w.activeWebhooksForEvent(repo.RepoDid, models.WebhookEventPush) - if err != nil { - w.logger.Error("failed to get webhooks for repo", "repo_did", repo.RepoDid, "err", err) - return - } - if len(webhooks) == 0 { - return - } - - payload := w.buildPushPayload(repo, ref, oldSha, newSha, committerDid) - payloadBytes, err := json.Marshal(payload) - if err != nil { - w.logger.Error("failed to marshal push payload", "repo_did", repo.RepoDid, "err", err) - return - } - - userAgent := "Tangled-Hook/" + newSha[:7] - for _, webhook := range webhooks { - go w.sendWebhook(ctx, webhook, string(models.WebhookEventPush), payload.Repository.FullName, userAgent, payloadBytes) - } -} - -func (w *Notifier) RenameRepo(ctx context.Context, actor syntax.DID, oldRepo, newRepo *models.Repo) { - webhooks, err := w.activeWebhooksForEvent(newRepo.RepoDid, models.WebhookEventRepoRenamed) - if err != nil { - w.logger.Error("failed to get webhooks for repo", "repo_did", newRepo.RepoDid, "err", err) - return - } - if len(webhooks) == 0 { - return - } - - payload := &models.WebhookRenamePayload{ - OldName: oldRepo.Name, - NewName: newRepo.Name, - Repository: buildWebhookRepository(newRepo), - Sender: models.WebhookUser{Did: actor.String()}, - } - payloadBytes, err := json.Marshal(payload) - if err != nil { - w.logger.Error("failed to marshal rename payload", "repo_did", newRepo.RepoDid, "err", err) - return - } - - userAgent := "Tangled-Hook/rename" - for _, webhook := range webhooks { - go w.sendWebhook(ctx, webhook, string(models.WebhookEventRepoRenamed), payload.Repository.FullName, userAgent, payloadBytes) - } -} - -func (w *Notifier) NewPull(ctx context.Context, pull *models.Pull) { - w.pullRequestEvent(ctx, models.WebhookEventPullRequestCreated, "created", pull.OwnerDid, pull) -} - -func (w *Notifier) ResubmitPull(ctx context.Context, pull *models.Pull) { - w.pullRequestEvent(ctx, models.WebhookEventPullRequestResubmitted, "resubmitted", pull.OwnerDid, pull) -} - -func (w *Notifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { - event, action, ok := pullStateEvent(pull.State) - if !ok { - return - } - w.pullRequestEvent(ctx, event, action, actor.String(), pull) -} - -// pullStateEvent maps a pull's state to the webhook event announcing the -// transition into that state -func pullStateEvent(state models.PullState) (models.WebhookEvent, string, bool) { - switch state { - case models.PullMerged: - return models.WebhookEventPullRequestMerged, "merged", true - case models.PullClosed: - return models.WebhookEventPullRequestClosed, "closed", true - case models.PullOpen: - return models.WebhookEventPullRequestReopened, "reopened", true - default: - return "", "", false - } -} - -func (w *Notifier) pullRequestEvent(ctx context.Context, event models.WebhookEvent, action, sender string, pull *models.Pull) { - // pull request events originate from http handlers, whose context is - // canceled as soon as the handler returns; detach so in-flight - // deliveries are not cut short - ctx = context.WithoutCancel(ctx) - - webhooks, err := w.activeWebhooksForEvent(string(pull.RepoDid), event) - if err != nil { - w.logger.Error("failed to get webhooks for repo", "repo_did", pull.RepoDid, "err", err) - return - } - if len(webhooks) == 0 { - return - } - - repo, err := db.GetRepo(w.db, orm.FilterEq("repo_did", string(pull.RepoDid))) - if err != nil { - w.logger.Error("failed to get repo", "repo_did", pull.RepoDid, "err", err) - return - } - - payload := buildPullRequestPayload(action, repo, pull, sender, w.baseUrl) - payloadBytes, err := json.Marshal(payload) - if err != nil { - w.logger.Error("failed to marshal pull request payload", "repo_did", pull.RepoDid, "err", err) - return - } - - userAgent := "Tangled-Hook/pull_request" - for _, webhook := range webhooks { - go w.sendWebhook(ctx, webhook, string(event), payload.Repository.FullName, userAgent, payloadBytes) - } -} - -func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull, sender, baseUrl string) *models.WebhookPullRequestPayload { - htmlUrl := fmt.Sprintf("%s/%s/%s/pulls/%d", baseUrl, repo.Did, repo.Slug(), pull.PullId) - - pullRequest := models.WebhookPullRequest{ - Number: pull.PullId, - Title: pull.Title, - Body: pull.Body, - State: pull.State.String(), - TargetBranch: pull.TargetBranch, - Owner: models.WebhookUser{Did: pull.OwnerDid}, - HtmlUrl: htmlUrl, - CreatedAt: pull.Created.Format(time.RFC3339), - } - if len(pull.Submissions) > 0 { - pullRequest.RoundNumber = pull.LastRoundNumber() - pullRequest.PatchUrl = fmt.Sprintf("%s/round/%d.patch", htmlUrl, pull.LastRoundNumber()) - } - if pull.PullSource != nil { - source := &models.WebhookPullRequestSource{ - Branch: pull.PullSource.Branch, - } - if len(pull.Submissions) > 0 { - source.Sha = pull.LatestSha() - } - if pull.IsForkBased() { - source.Repo = pull.PullSource.RepoDid.String() - } - pullRequest.Source = source - } - - return &models.WebhookPullRequestPayload{ - Action: action, - PullRequest: pullRequest, - Repository: buildWebhookRepository(repo), - Sender: models.WebhookUser{Did: sender}, - } -} - -// Redeliver re-sends a stored delivery via the live send-and-record path, -// signing with the webhook's current secret. -func (w *Notifier) Redeliver(ctx context.Context, webhook models.Webhook, prev models.WebhookDelivery) { - // recover the repo full name (X-Tangled-Repo header) from the stored payload - var meta struct { - Repository struct { - FullName string `json:"full_name"` - } `json:"repository"` - } - _ = json.Unmarshal([]byte(prev.RequestBody), &meta) - - w.sendWebhook(ctx, webhook, prev.Event, meta.Repository.FullName, "Tangled-Hook/retry", []byte(prev.RequestBody)) -} - -func (w *Notifier) activeWebhooksForEvent(repoDid string, event models.WebhookEvent) ([]models.Webhook, error) { - webhooks, err := db.GetActiveWebhooksForRepo(w.db, repoDid) - if err != nil { - return nil, err - } - var matching []models.Webhook - for _, webhook := range webhooks { - if webhook.HasEvent(event) { - matching = append(matching, webhook) - } - } - return matching, nil -} - -func buildWebhookRepository(repo *models.Repo) models.WebhookRepository { - repository := models.WebhookRepository{ - Name: repo.Name, - FullName: fmt.Sprintf("%s/%s", repo.Did, repo.Rkey), - Description: repo.Description, - Fork: repo.Source != "", - HtmlUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), - CloneUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), - SshUrl: fmt.Sprintf("ssh://git@%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), - CreatedAt: repo.Created.Format(time.RFC3339), - UpdatedAt: repo.Created.Format(time.RFC3339), - Owner: models.WebhookUser{ - Did: repo.Did, - }, - } - if repo.Website != "" { - repository.Website = repo.Website - } - if repo.RepoStats != nil { - repository.StarsCount = repo.RepoStats.StarCount - repository.OpenIssues = repo.RepoStats.IssueCount.Open - } - return repository -} - -func (w *Notifier) buildPushPayload(repo *models.Repo, ref, oldSha, newSha, committerDid string) *models.WebhookPayload { - pusher := committerDid - if committerDid == "" { - pusher = repo.Did - } - return &models.WebhookPayload{ - Ref: ref, - Before: oldSha, - After: newSha, - Repository: buildWebhookRepository(repo), - Pusher: models.WebhookUser{ - Did: pusher, - }, - } -} - -func (w *Notifier) sendWebhook(ctx context.Context, webhook models.Webhook, event, repoFullName, userAgent string, payloadBytes []byte) { - deliveryId := uuid.New().String() - - req, err := http.NewRequestWithContext(ctx, "POST", webhook.Url, bytes.NewReader(payloadBytes)) - if err != nil { - w.logger.Error("failed to create webhook request", "webhook_id", webhook.Id, "err", err) - return - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent) - req.Header.Set("X-Tangled-Event", event) - req.Header.Set("X-Tangled-Hook-ID", fmt.Sprintf("%d", webhook.Id)) - req.Header.Set("X-Tangled-Delivery", deliveryId) - req.Header.Set("X-Tangled-Repo", repoFullName) - - if webhook.Secret != "" { - signature := w.computeSignature(payloadBytes, webhook.Secret) - req.Header.Set("X-Tangled-Signature-256", "sha256="+signature) - } - - delivery := &models.WebhookDelivery{ - WebhookId: webhook.Id, - Event: event, - DeliveryId: deliveryId, - Url: webhook.Url, - RequestBody: string(payloadBytes), - } - - retryOpts := []retry.Option{ - retry.Attempts(3), - retry.Delay(1 * time.Second), - retry.MaxDelay(10 * time.Second), - retry.DelayType(retry.BackOffDelay), - retry.LastErrorOnly(true), - retry.OnRetry(func(n uint, err error) { - w.logger.Info("retrying webhook delivery", - "webhook_id", webhook.Id, - "attempt", n+1, - "err", err) - }), - retry.Context(ctx), - retry.RetryIf(func(err error) bool { - return err != nil - }), - } - - var resp *http.Response - err = retry.Do(func() error { - var err error - resp, err = w.client.Do(req) - if err != nil { - return err - } - if resp.StatusCode >= 500 { - defer resp.Body.Close() - return fmt.Errorf("server error: %d", resp.StatusCode) - } - return nil - }, retryOpts...) - - if err != nil { - w.logger.Error("webhook request failed after retries", "webhook_id", webhook.Id, "err", err) - delivery.Success = false - delivery.ResponseBody = err.Error() - } else { - defer resp.Body.Close() - - delivery.ResponseCode = resp.StatusCode - delivery.Success = resp.StatusCode >= 200 && resp.StatusCode < 300 - - bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024)) - if err != nil { - w.logger.Warn("failed to read webhook response body", "webhook_id", webhook.Id, "err", err) - } else { - delivery.ResponseBody = string(bodyBytes) - } - - if !delivery.Success { - w.logger.Warn("webhook delivery failed", - "webhook_id", webhook.Id, - "status", resp.StatusCode, - "url", webhook.Url) - } else { - w.logger.Info("webhook delivered successfully", - "webhook_id", webhook.Id, - "url", webhook.Url, - "delivery_id", deliveryId) - } - } - - if err := db.AddWebhookDelivery(w.db, delivery); err != nil { - w.logger.Error("failed to record webhook delivery", "webhook_id", webhook.Id, "err", err) - } -} - -func (w *Notifier) computeSignature(payload []byte, secret string) string { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(payload) - return hex.EncodeToString(mac.Sum(nil)) -} diff --git a/appview/notify/webhook/notifier_test.go b/appview/notify/webhook/notifier_test.go deleted file mode 100644 index aa788ec0..00000000 --- a/appview/notify/webhook/notifier_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package webhook - -import ( - "context" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "time" - - "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.org/core/appview/db" - "tangled.org/core/appview/models" -) - -func TestPullStateEvent(t *testing.T) { - tests := []struct { - name string - state models.PullState - wantEvent models.WebhookEvent - wantAction string - wantOk bool - }{ - {"merged", models.PullMerged, models.WebhookEventPullRequestMerged, "merged", true}, - {"closed", models.PullClosed, models.WebhookEventPullRequestClosed, "closed", true}, - {"reopened", models.PullOpen, models.WebhookEventPullRequestReopened, "reopened", true}, - {"abandoned", models.PullAbandoned, "", "", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - event, action, ok := pullStateEvent(tt.state) - if event != tt.wantEvent || action != tt.wantAction || ok != tt.wantOk { - t.Errorf("pullStateEvent(%v) = (%q, %q, %v), want (%q, %q, %v)", - tt.state, event, action, ok, tt.wantEvent, tt.wantAction, tt.wantOk) - } - }) - } -} - -func TestBuildPullRequestPayload(t *testing.T) { - const baseUrl = "https://tangled.org" - - targetDid := syntax.DID("did:plc:target") - forkDid := syntax.DID("did:plc:fork") - - repo := &models.Repo{ - Did: "did:plc:target", - Name: "some-repo", - Knot: "knot.example.com", - Rkey: "some-repo", - Created: time.Date(2025, 9, 15, 8, 57, 23, 0, time.UTC), - } - - basePull := func() models.Pull { - return models.Pull{ - PullId: 4, - RepoDid: targetDid, - OwnerDid: "did:plc:author", - Title: "add dark mode", - Body: "implements dark mode", - TargetBranch: "main", - State: models.PullOpen, - Created: time.Date(2025, 9, 16, 10, 0, 0, 0, time.UTC), - Submissions: []*models.PullSubmission{ - {RoundNumber: 0, SourceRev: "aaaa000"}, - {RoundNumber: 1, SourceRev: "bbbb111"}, - }, - } - } - - t.Run("patch based", func(t *testing.T) { - pull := basePull() - pull.PullSource = nil - - payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) - - if payload.Action != "created" { - t.Errorf("action = %q, want %q", payload.Action, "created") - } - pr := payload.PullRequest - if pr.Number != 4 { - t.Errorf("number = %d, want 4", pr.Number) - } - if pr.State != "open" { - t.Errorf("state = %q, want %q", pr.State, "open") - } - if pr.Source != nil { - t.Errorf("source = %+v, want nil for patch-based pull", pr.Source) - } - if pr.RoundNumber != 1 { - t.Errorf("round_number = %d, want 1", pr.RoundNumber) - } - wantHtmlUrl := "https://tangled.org/did:plc:target/some-repo/pulls/4" - if pr.HtmlUrl != wantHtmlUrl { - t.Errorf("html_url = %q, want %q", pr.HtmlUrl, wantHtmlUrl) - } - wantPatchUrl := wantHtmlUrl + "/round/1.patch" - if pr.PatchUrl != wantPatchUrl { - t.Errorf("patch_url = %q, want %q", pr.PatchUrl, wantPatchUrl) - } - if pr.Owner.Did != "did:plc:author" { - t.Errorf("owner.did = %q, want %q", pr.Owner.Did, "did:plc:author") - } - if payload.Sender.Did != "did:plc:author" { - t.Errorf("sender.did = %q, want %q", payload.Sender.Did, "did:plc:author") - } - if payload.Repository.FullName != "did:plc:target/some-repo" { - t.Errorf("repository.full_name = %q, want %q", payload.Repository.FullName, "did:plc:target/some-repo") - } - }) - - t.Run("branch based", func(t *testing.T) { - pull := basePull() - pull.PullSource = &models.PullSource{ - Branch: "dark-mode", - RepoDid: &targetDid, - } - - payload := buildPullRequestPayload("merged", repo, &pull, "did:plc:merger", baseUrl) - - pr := payload.PullRequest - if pr.Source == nil { - t.Fatal("source = nil, want non-nil for branch-based pull") - } - if pr.Source.Branch != "dark-mode" { - t.Errorf("source.branch = %q, want %q", pr.Source.Branch, "dark-mode") - } - if pr.Source.Repo != "" { - t.Errorf("source.repo = %q, want empty for branch-based pull", pr.Source.Repo) - } - if pr.Source.Sha != "bbbb111" { - t.Errorf("source.sha = %q, want %q", pr.Source.Sha, "bbbb111") - } - if payload.Sender.Did != "did:plc:merger" { - t.Errorf("sender.did = %q, want %q", payload.Sender.Did, "did:plc:merger") - } - }) - - t.Run("fork based", func(t *testing.T) { - pull := basePull() - pull.PullSource = &models.PullSource{ - Branch: "dark-mode", - RepoDid: &forkDid, - } - - payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) - - pr := payload.PullRequest - if pr.Source == nil { - t.Fatal("source = nil, want non-nil for fork-based pull") - } - if pr.Source.Repo != "did:plc:fork" { - t.Errorf("source.repo = %q, want %q", pr.Source.Repo, "did:plc:fork") - } - }) - - t.Run("no submissions", func(t *testing.T) { - pull := basePull() - pull.Submissions = nil - - payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) - - pr := payload.PullRequest - if pr.RoundNumber != 0 { - t.Errorf("round_number = %d, want 0", pr.RoundNumber) - } - if pr.PatchUrl != "" { - t.Errorf("patch_url = %q, want empty when there are no submissions", pr.PatchUrl) - } - }) -} - -type notifierTestEnv struct { - notifier *Notifier - webhook *models.Webhook - db *db.DB - received chan string -} - -// newNotifierTestEnv sets up a real sqlite db with a repo and a webhook -// subscribed to the given events, delivering to a local test server -func newNotifierTestEnv(t *testing.T, events []string) *notifierTestEnv { - t.Helper() - - d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("Make: %v", err) - } - t.Cleanup(func() { d.Close() }) - - tx, err := d.Begin() - if err != nil { - t.Fatalf("Begin: %v", err) - } - if err := db.AddRepo(tx, &models.Repo{ - Did: "did:plc:owner", - Name: "some-repo", - Knot: "knot.example.com", - Rkey: "some-repo", - RepoDid: "did:plc:repo1", - }); err != nil { - t.Fatalf("AddRepo: %v", err) - } - if err := tx.Commit(); err != nil { - t.Fatalf("Commit: %v", err) - } - - received := make(chan string, 1) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - received <- r.Header.Get("X-Tangled-Event") - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(srv.Close) - - webhook := &models.Webhook{ - RepoDid: syntax.DID("did:plc:repo1"), - Url: srv.URL, - Active: true, - Events: events, - } - if err := db.AddWebhook(d, webhook); err != nil { - t.Fatalf("AddWebhook: %v", err) - } - - return ¬ifierTestEnv{ - notifier: NewNotifier(d, "https://tangled.org", true), - webhook: webhook, - db: d, - received: received, - } -} - -func (env *notifierTestEnv) awaitDelivery(t *testing.T, wantEvent string) { - t.Helper() - - select { - case event := <-env.received: - if event != wantEvent { - t.Errorf("X-Tangled-Event = %q, want %q", event, wantEvent) - } - case <-time.After(10 * time.Second): - t.Fatalf("webhook %s not delivered", wantEvent) - } - - // wait for the delivery record so the sender goroutine finishes - // before the db is closed - deadline := time.Now().Add(10 * time.Second) - for { - deliveries, err := db.GetWebhookDeliveries(env.db, env.webhook.Id, 10) - if err == nil && len(deliveries) > 0 { - if !deliveries[0].Success { - t.Errorf("delivery recorded as failed, want success") - } - return - } - if time.Now().After(deadline) { - t.Fatal("delivery record not written") - } - time.Sleep(10 * time.Millisecond) - } -} - -func testPull(state models.PullState) *models.Pull { - return &models.Pull{ - PullId: 1, - RepoDid: syntax.DID("did:plc:repo1"), - OwnerDid: "did:plc:author", - Title: "hello", - TargetBranch: "main", - State: state, - Created: time.Now(), - } -} - -// Pull request events fire from http handlers, whose request context is -// canceled as soon as the handler returns. Deliveries run in background -// goroutines and must not be cut short by that cancellation. -func TestPullRequestEventDeliversAfterContextCancel(t *testing.T) { - env := newNotifierTestEnv(t, []string{string(models.WebhookEventPullRequestCreated)}) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - env.notifier.NewPull(ctx, testPull(models.PullOpen)) - env.awaitDelivery(t, "pull_request:created") -} - -func TestNotifierDeliversPullRequestEvents(t *testing.T) { - allEvents := []string{ - string(models.WebhookEventPullRequestCreated), - string(models.WebhookEventPullRequestResubmitted), - string(models.WebhookEventPullRequestMerged), - string(models.WebhookEventPullRequestClosed), - string(models.WebhookEventPullRequestReopened), - } - actor := syntax.DID("did:plc:actor") - - tests := []struct { - name string - notify func(*Notifier, context.Context) - wantEvent string - }{ - { - "resubmitted", - func(n *Notifier, ctx context.Context) { n.ResubmitPull(ctx, testPull(models.PullOpen)) }, - "pull_request:resubmitted", - }, - { - "merged", - func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullMerged)) }, - "pull_request:merged", - }, - { - "closed", - func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullClosed)) }, - "pull_request:closed", - }, - { - "reopened", - func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullOpen)) }, - "pull_request:reopened", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - env := newNotifierTestEnv(t, allEvents) - tt.notify(env.notifier, context.Background()) - env.awaitDelivery(t, tt.wantEvent) - }) - } -} diff --git a/appview/state/state.go b/appview/state/state.go index 9d4e041c..af48515e 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -30,7 +30,6 @@ import ( emaildispatch "tangled.org/core/appview/notify/email" lognotify "tangled.org/core/appview/notify/logging" phnotify "tangled.org/core/appview/notify/posthog" - whnotify "tangled.org/core/appview/notify/webhook" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" pipelinessh "tangled.org/core/appview/pipelines/ssh" @@ -178,7 +177,8 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { } notifiers = append(notifiers, indexer) - notifiers = append(notifiers, whnotify.NewNotifier(d, config.Core.BaseUrl(), config.Core.Dev)) + // webhook delivery now lives in spindle; the appview no longer fans out + // repo events to a webhook notifier. notifier := notify.NewMergedNotifier(notifiers) notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) diff --git a/lexicons/temp/repo/createWebhook.json b/lexicons/temp/repo/createWebhook.json deleted file mode 100644 index 57d28780..00000000 --- a/lexicons/temp/repo/createWebhook.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "lexicon": 1, - "id": "org.tangled.temp.repo.createWebhook", - "defs": { - "main": { - "type": "procedure", - "description": "Create a new webhook for a repository.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["repoDid", "url", "events"], - "properties": { - "repoDid": { - "type": "string", - "format": "did", - "description": "DID of the repository as minted by the knot." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Endpoint URL that will receive webhook payloads." - }, - "secret": { - "type": "string", - "description": "Optional HMAC secret used to sign payloads. If omitted, payloads are not signed." - }, - "active": { - "type": "boolean", - "description": "Whether the webhook should be active immediately. Defaults to true." - }, - "events": { - "type": "array", - "description": "Event types to subscribe to (e.g. 'push', 'repository:renamed').", - "items": { - "type": "string" - } - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["id"], - "properties": { - "id": { - "type": "integer", - "description": "ID of the newly created webhook." - } - } - } - }, - "errors": [ - { "name": "NoEventsSelected", "description": "At least one event type must be specified." } - ] - } - } -} diff --git a/lexicons/temp/repo/deleteWebhook.json b/lexicons/temp/repo/deleteWebhook.json deleted file mode 100644 index 7a530fd8..00000000 --- a/lexicons/temp/repo/deleteWebhook.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "lexicon": 1, - "id": "org.tangled.temp.repo.deleteWebhook", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a webhook from a repository.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["repoDid", "id"], - "properties": { - "repoDid": { - "type": "string", - "format": "did", - "description": "DID of the repository as minted by the knot." - }, - "id": { - "type": "integer", - "description": "Webhook ID to delete." - } - } - } - }, - "errors": [ - { "name": "WebhookNotFound" } - ] - } - } -} diff --git a/lexicons/temp/repo/listWebhookDeliveries.json b/lexicons/temp/repo/listWebhookDeliveries.json deleted file mode 100644 index 7ee73dbc..00000000 --- a/lexicons/temp/repo/listWebhookDeliveries.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "lexicon": 1, - "id": "org.tangled.temp.repo.listWebhookDeliveries", - "defs": { - "main": { - "type": "query", - "description": "List recent delivery attempts for a webhook.", - "parameters": { - "type": "params", - "required": ["repoDid", "id"], - "properties": { - "repoDid": { - "type": "string", - "format": "did", - "description": "DID of the repository as minted by the knot." - }, - "id": { - "type": "integer", - "description": "Webhook ID." - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100 - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["deliveries"], - "properties": { - "deliveries": { - "type": "array", - "items": { - "type": "ref", - "ref": "#delivery" - } - } - } - } - } - }, - "delivery": { - "type": "object", - "required": ["id", "deliveryId", "event", "url", "success", "createdAt"], - "properties": { - "id": { - "type": "integer" - }, - "deliveryId": { - "type": "string", - "description": "UUID for tracking this delivery attempt." - }, - "event": { - "type": "string", - "description": "Event type that triggered the delivery." - }, - "url": { - "type": "string", - "format": "uri" - }, - "requestBody": { - "type": "string" - }, - "responseCode": { - "type": "integer" - }, - "responseBody": { - "type": "string" - }, - "success": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } -} diff --git a/lexicons/temp/repo/listWebhooks.json b/lexicons/temp/repo/listWebhooks.json deleted file mode 100644 index 54351c43..00000000 --- a/lexicons/temp/repo/listWebhooks.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "lexicon": 1, - "id": "org.tangled.temp.repo.listWebhooks", - "defs": { - "main": { - "type": "query", - "description": "List webhooks configured for a repository.", - "parameters": { - "type": "params", - "required": ["repoDid"], - "properties": { - "repoDid": { - "type": "string", - "format": "did", - "description": "DID of the repository as minted by the knot." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["webhooks"], - "properties": { - "webhooks": { - "type": "array", - "items": { - "type": "ref", - "ref": "#webhook" - } - } - } - } - } - }, - "webhook": { - "type": "object", - "required": ["id", "url", "active", "events", "createdAt"], - "properties": { - "id": { - "type": "integer", - "description": "Webhook identifier." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Endpoint URL that receives webhook payloads." - }, - "active": { - "type": "boolean", - "description": "Whether the webhook is currently enabled." - }, - "events": { - "type": "array", - "description": "Event types this webhook is subscribed to (e.g. 'push', 'repository:renamed').", - "items": { - "type": "string" - } - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "updatedAt": { - "type": "string", - "format": "datetime" - } - } - } - } -} diff --git a/lexicons/temp/repo/retryWebhookDelivery.json b/lexicons/temp/repo/retryWebhookDelivery.json deleted file mode 100644 index 0fe909e3..00000000 --- a/lexicons/temp/repo/retryWebhookDelivery.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "lexicon": 1, - "id": "org.tangled.temp.repo.retryWebhookDelivery", - "defs": { - "main": { - "type": "procedure", - "description": "Re-send a specific webhook delivery.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["repoDid", "webhookId", "deliveryId"], - "properties": { - "repoDid": { - "type": "string", - "format": "did", - "description": "DID of the repository as minted by the knot." - }, - "webhookId": { - "type": "integer", - "description": "Webhook ID that owns the delivery." - }, - "deliveryId": { - "type": "string", - "description": "UUID of the delivery to retry." - } - } - } - }, - "errors": [ - { "name": "WebhookNotFound" }, - { "name": "DeliveryNotFound" } - ] - } - } -} diff --git a/lexicons/temp/repo/toggleWebhook.json b/lexicons/temp/repo/toggleWebhook.json deleted file mode 100644 index aa6cecfe..00000000 --- a/lexicons/temp/repo/toggleWebhook.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "lexicon": 1, - "id": "org.tangled.temp.repo.toggleWebhook", - "defs": { - "main": { - "type": "procedure", - "description": "Toggle the active state of a webhook.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["repoDid", "id"], - "properties": { - "repoDid": { - "type": "string", - "format": "did", - "description": "DID of the repository as minted by the knot." - }, - "id": { - "type": "integer", - "description": "Webhook ID to toggle." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["active"], - "properties": { - "active": { - "type": "boolean", - "description": "New active state of the webhook." - } - } - } - }, - "errors": [ - { "name": "WebhookNotFound" } - ] - } - } -} diff --git a/lexicons/temp/repo/updateWebhook.json b/lexicons/temp/repo/updateWebhook.json deleted file mode 100644 index fb046717..00000000 --- a/lexicons/temp/repo/updateWebhook.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "lexicon": 1, - "id": "org.tangled.temp.repo.updateWebhook", - "defs": { - "main": { - "type": "procedure", - "description": "Update an existing webhook. Only supplied fields are changed.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["repoDid", "id"], - "properties": { - "repoDid": { - "type": "string", - "format": "did", - "description": "DID of the repository as minted by the knot." - }, - "id": { - "type": "integer", - "description": "Webhook ID to update." - }, - "url": { - "type": "string", - "format": "uri" - }, - "secret": { - "type": "string" - }, - "active": { - "type": "boolean" - }, - "events": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "errors": [ - { "name": "WebhookNotFound" } - ] - } - } -}