This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / notify / webhook / notifier.go
11 kB 356 lines
1package webhook 2 3import ( 4 "bytes" 5 "context" 6 "crypto/hmac" 7 "crypto/sha256" 8 "encoding/hex" 9 "encoding/json" 10 "fmt" 11 "io" 12 "log/slog" 13 "net/http" 14 "time" 15 16 "github.com/avast/retry-go/v4" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 "github.com/google/uuid" 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/notify" 22 "tangled.org/core/log" 23 "tangled.org/core/orm" 24) 25 26type Notifier struct { 27 notify.BaseNotifier 28 db *db.DB 29 baseUrl string 30 logger *slog.Logger 31 client *http.Client 32} 33 34func NewNotifier(database *db.DB, baseUrl string) *Notifier { 35 return &Notifier{ 36 db: database, 37 baseUrl: baseUrl, 38 logger: log.New("webhook-notifier"), 39 client: &http.Client{ 40 Timeout: 30 * time.Second, 41 }, 42 } 43} 44 45var _ notify.Notifier = &Notifier{} 46 47func (w *Notifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { 48 webhooks, err := w.activeWebhooksForEvent(repo.RepoDid, models.WebhookEventPush) 49 if err != nil { 50 w.logger.Error("failed to get webhooks for repo", "repo_did", repo.RepoDid, "err", err) 51 return 52 } 53 if len(webhooks) == 0 { 54 return 55 } 56 57 payload := w.buildPushPayload(repo, ref, oldSha, newSha, committerDid) 58 payloadBytes, err := json.Marshal(payload) 59 if err != nil { 60 w.logger.Error("failed to marshal push payload", "repo_did", repo.RepoDid, "err", err) 61 return 62 } 63 64 userAgent := "Tangled-Hook/" + newSha[:7] 65 for _, webhook := range webhooks { 66 go w.sendWebhook(ctx, webhook, string(models.WebhookEventPush), payload.Repository.FullName, userAgent, payloadBytes) 67 } 68} 69 70func (w *Notifier) RenameRepo(ctx context.Context, actor syntax.DID, oldRepo, newRepo *models.Repo) { 71 webhooks, err := w.activeWebhooksForEvent(newRepo.RepoDid, models.WebhookEventRepoRenamed) 72 if err != nil { 73 w.logger.Error("failed to get webhooks for repo", "repo_did", newRepo.RepoDid, "err", err) 74 return 75 } 76 if len(webhooks) == 0 { 77 return 78 } 79 80 payload := &models.WebhookRenamePayload{ 81 OldName: oldRepo.Name, 82 NewName: newRepo.Name, 83 Repository: buildWebhookRepository(newRepo), 84 Sender: models.WebhookUser{Did: actor.String()}, 85 } 86 payloadBytes, err := json.Marshal(payload) 87 if err != nil { 88 w.logger.Error("failed to marshal rename payload", "repo_did", newRepo.RepoDid, "err", err) 89 return 90 } 91 92 userAgent := "Tangled-Hook/rename" 93 for _, webhook := range webhooks { 94 go w.sendWebhook(ctx, webhook, string(models.WebhookEventRepoRenamed), payload.Repository.FullName, userAgent, payloadBytes) 95 } 96} 97 98func (w *Notifier) NewPull(ctx context.Context, pull *models.Pull) { 99 w.pullRequestEvent(ctx, models.WebhookEventPullRequestCreated, "created", pull.OwnerDid, pull) 100} 101 102func (w *Notifier) ResubmitPull(ctx context.Context, pull *models.Pull) { 103 w.pullRequestEvent(ctx, models.WebhookEventPullRequestResubmitted, "resubmitted", pull.OwnerDid, pull) 104} 105 106func (w *Notifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 107 event, action, ok := pullStateEvent(pull.State) 108 if !ok { 109 return 110 } 111 w.pullRequestEvent(ctx, event, action, actor.String(), pull) 112} 113 114// pullStateEvent maps a pull's state to the webhook event announcing the 115// transition into that state 116func pullStateEvent(state models.PullState) (models.WebhookEvent, string, bool) { 117 switch state { 118 case models.PullMerged: 119 return models.WebhookEventPullRequestMerged, "merged", true 120 case models.PullClosed: 121 return models.WebhookEventPullRequestClosed, "closed", true 122 case models.PullOpen: 123 return models.WebhookEventPullRequestReopened, "reopened", true 124 default: 125 return "", "", false 126 } 127} 128 129func (w *Notifier) pullRequestEvent(ctx context.Context, event models.WebhookEvent, action, sender string, pull *models.Pull) { 130 // pull request events originate from http handlers, whose context is 131 // canceled as soon as the handler returns; detach so in-flight 132 // deliveries are not cut short 133 ctx = context.WithoutCancel(ctx) 134 135 webhooks, err := w.activeWebhooksForEvent(string(pull.RepoDid), event) 136 if err != nil { 137 w.logger.Error("failed to get webhooks for repo", "repo_did", pull.RepoDid, "err", err) 138 return 139 } 140 if len(webhooks) == 0 { 141 return 142 } 143 144 repo, err := db.GetRepo(w.db, orm.FilterEq("repo_did", string(pull.RepoDid))) 145 if err != nil { 146 w.logger.Error("failed to get repo", "repo_did", pull.RepoDid, "err", err) 147 return 148 } 149 150 payload := buildPullRequestPayload(action, repo, pull, sender, w.baseUrl) 151 payloadBytes, err := json.Marshal(payload) 152 if err != nil { 153 w.logger.Error("failed to marshal pull request payload", "repo_did", pull.RepoDid, "err", err) 154 return 155 } 156 157 userAgent := "Tangled-Hook/pull_request" 158 for _, webhook := range webhooks { 159 go w.sendWebhook(ctx, webhook, string(event), payload.Repository.FullName, userAgent, payloadBytes) 160 } 161} 162 163func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull, sender, baseUrl string) *models.WebhookPullRequestPayload { 164 htmlUrl := fmt.Sprintf("%s/%s/%s/pulls/%d", baseUrl, repo.Did, repo.Slug(), pull.PullId) 165 166 pullRequest := models.WebhookPullRequest{ 167 Number: pull.PullId, 168 Title: pull.Title, 169 Body: pull.Body, 170 State: pull.State.String(), 171 TargetBranch: pull.TargetBranch, 172 Owner: models.WebhookUser{Did: pull.OwnerDid}, 173 HtmlUrl: htmlUrl, 174 CreatedAt: pull.Created.Format(time.RFC3339), 175 } 176 if len(pull.Submissions) > 0 { 177 pullRequest.RoundNumber = pull.LastRoundNumber() 178 pullRequest.PatchUrl = fmt.Sprintf("%s/round/%d.patch", htmlUrl, pull.LastRoundNumber()) 179 } 180 if pull.PullSource != nil { 181 source := &models.WebhookPullRequestSource{ 182 Branch: pull.PullSource.Branch, 183 } 184 if len(pull.Submissions) > 0 { 185 source.Sha = pull.LatestSha() 186 } 187 if pull.IsForkBased() { 188 source.Repo = pull.PullSource.RepoDid.String() 189 } 190 pullRequest.Source = source 191 } 192 193 return &models.WebhookPullRequestPayload{ 194 Action: action, 195 PullRequest: pullRequest, 196 Repository: buildWebhookRepository(repo), 197 Sender: models.WebhookUser{Did: sender}, 198 } 199} 200 201func (w *Notifier) activeWebhooksForEvent(repoDid string, event models.WebhookEvent) ([]models.Webhook, error) { 202 webhooks, err := db.GetActiveWebhooksForRepo(w.db, repoDid) 203 if err != nil { 204 return nil, err 205 } 206 var matching []models.Webhook 207 for _, webhook := range webhooks { 208 if webhook.HasEvent(event) { 209 matching = append(matching, webhook) 210 } 211 } 212 return matching, nil 213} 214 215func buildWebhookRepository(repo *models.Repo) models.WebhookRepository { 216 repository := models.WebhookRepository{ 217 Name: repo.Name, 218 FullName: fmt.Sprintf("%s/%s", repo.Did, repo.Rkey), 219 Description: repo.Description, 220 Fork: repo.Source != "", 221 HtmlUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), 222 CloneUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), 223 SshUrl: fmt.Sprintf("ssh://git@%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), 224 CreatedAt: repo.Created.Format(time.RFC3339), 225 UpdatedAt: repo.Created.Format(time.RFC3339), 226 Owner: models.WebhookUser{ 227 Did: repo.Did, 228 }, 229 } 230 if repo.Website != "" { 231 repository.Website = repo.Website 232 } 233 if repo.RepoStats != nil { 234 repository.StarsCount = repo.RepoStats.StarCount 235 repository.OpenIssues = repo.RepoStats.IssueCount.Open 236 } 237 return repository 238} 239 240func (w *Notifier) buildPushPayload(repo *models.Repo, ref, oldSha, newSha, committerDid string) *models.WebhookPayload { 241 pusher := committerDid 242 if committerDid == "" { 243 pusher = repo.Did 244 } 245 return &models.WebhookPayload{ 246 Ref: ref, 247 Before: oldSha, 248 After: newSha, 249 Repository: buildWebhookRepository(repo), 250 Pusher: models.WebhookUser{ 251 Did: pusher, 252 }, 253 } 254} 255 256func (w *Notifier) sendWebhook(ctx context.Context, webhook models.Webhook, event, repoFullName, userAgent string, payloadBytes []byte) { 257 deliveryId := uuid.New().String() 258 259 req, err := http.NewRequestWithContext(ctx, "POST", webhook.Url, bytes.NewReader(payloadBytes)) 260 if err != nil { 261 w.logger.Error("failed to create webhook request", "webhook_id", webhook.Id, "err", err) 262 return 263 } 264 265 req.Header.Set("Content-Type", "application/json") 266 req.Header.Set("User-Agent", userAgent) 267 req.Header.Set("X-Tangled-Event", event) 268 req.Header.Set("X-Tangled-Hook-ID", fmt.Sprintf("%d", webhook.Id)) 269 req.Header.Set("X-Tangled-Delivery", deliveryId) 270 req.Header.Set("X-Tangled-Repo", repoFullName) 271 272 if webhook.Secret != "" { 273 signature := w.computeSignature(payloadBytes, webhook.Secret) 274 req.Header.Set("X-Tangled-Signature-256", "sha256="+signature) 275 } 276 277 delivery := &models.WebhookDelivery{ 278 WebhookId: webhook.Id, 279 Event: event, 280 DeliveryId: deliveryId, 281 Url: webhook.Url, 282 RequestBody: string(payloadBytes), 283 } 284 285 retryOpts := []retry.Option{ 286 retry.Attempts(3), 287 retry.Delay(1 * time.Second), 288 retry.MaxDelay(10 * time.Second), 289 retry.DelayType(retry.BackOffDelay), 290 retry.LastErrorOnly(true), 291 retry.OnRetry(func(n uint, err error) { 292 w.logger.Info("retrying webhook delivery", 293 "webhook_id", webhook.Id, 294 "attempt", n+1, 295 "err", err) 296 }), 297 retry.Context(ctx), 298 retry.RetryIf(func(err error) bool { 299 return err != nil 300 }), 301 } 302 303 var resp *http.Response 304 err = retry.Do(func() error { 305 var err error 306 resp, err = w.client.Do(req) 307 if err != nil { 308 return err 309 } 310 if resp.StatusCode >= 500 { 311 defer resp.Body.Close() 312 return fmt.Errorf("server error: %d", resp.StatusCode) 313 } 314 return nil 315 }, retryOpts...) 316 317 if err != nil { 318 w.logger.Error("webhook request failed after retries", "webhook_id", webhook.Id, "err", err) 319 delivery.Success = false 320 delivery.ResponseBody = err.Error() 321 } else { 322 defer resp.Body.Close() 323 324 delivery.ResponseCode = resp.StatusCode 325 delivery.Success = resp.StatusCode >= 200 && resp.StatusCode < 300 326 327 bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024)) 328 if err != nil { 329 w.logger.Warn("failed to read webhook response body", "webhook_id", webhook.Id, "err", err) 330 } else { 331 delivery.ResponseBody = string(bodyBytes) 332 } 333 334 if !delivery.Success { 335 w.logger.Warn("webhook delivery failed", 336 "webhook_id", webhook.Id, 337 "status", resp.StatusCode, 338 "url", webhook.Url) 339 } else { 340 w.logger.Info("webhook delivered successfully", 341 "webhook_id", webhook.Id, 342 "url", webhook.Url, 343 "delivery_id", deliveryId) 344 } 345 } 346 347 if err := db.AddWebhookDelivery(w.db, delivery); err != nil { 348 w.logger.Error("failed to record webhook delivery", "webhook_id", webhook.Id, "err", err) 349 } 350} 351 352func (w *Notifier) computeSignature(payload []byte, secret string) string { 353 mac := hmac.New(sha256.New, []byte(secret)) 354 mac.Write(payload) 355 return hex.EncodeToString(mac.Sum(nil)) 356}