Monorepo for Tangled
0

Configure Feed

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

appview: add pull request webhook events

This adds five new webhook event types alongside the existing `push` and
`repository:renamed` events:

- `pull_request:created`
- `pull_request:resubmitted`
- `pull_request:merged`
- `pull_request:closed`
- `pull_request:reopened`

Each event carries a payload with an `action` field, pull request metadata (number, title, state,
target branch, source branch/repo/sha), the repository object, and the sender. The patch itself is
not embedded; payloads include a `patch_url` pointing at the existing raw round-patch route instead.

Merge, close, and reopen reuse the existing `NewPullState` notifier call sites. `Resubmission`
previously emitted no notifier event at all, so this adds a `ResubmitPull` method to the `Notifier`
interface, fired from both the regular and stacked resubmit paths (stacked resubmits also now fire
`NewPull` for pulls newly added to a stack). Stack-pull abandonment intentionally emits no event.

The webhook notifier now takes the appview base URL so pull request `html_url`/`patch_url` point at the
appview, where pull routes live (`repository.html_url` keeps pointing at the knot, unchanged).

Refs #97, #94.

+660 -31
+38 -2
appview/models/webhook.go
··· 10 10 type WebhookEvent string 11 11 12 12 const ( 13 - WebhookEventPush WebhookEvent = "push" 14 - WebhookEventRepoRenamed WebhookEvent = "repository:renamed" 13 + WebhookEventPush WebhookEvent = "push" 14 + WebhookEventRepoRenamed WebhookEvent = "repository:renamed" 15 + WebhookEventPullRequestCreated WebhookEvent = "pull_request:created" 16 + WebhookEventPullRequestResubmitted WebhookEvent = "pull_request:resubmitted" 17 + WebhookEventPullRequestMerged WebhookEvent = "pull_request:merged" 18 + WebhookEventPullRequestClosed WebhookEvent = "pull_request:closed" 19 + WebhookEventPullRequestReopened WebhookEvent = "pull_request:reopened" 15 20 ) 16 21 17 22 type Webhook struct { ··· 81 86 Repository WebhookRepository `json:"repository"` 82 87 Sender WebhookUser `json:"sender"` 83 88 } 89 + 90 + // WebhookPullRequestPayload represents the payload for pull_request:* events 91 + type WebhookPullRequestPayload struct { 92 + Action string `json:"action"` 93 + PullRequest WebhookPullRequest `json:"pull_request"` 94 + Repository WebhookRepository `json:"repository"` 95 + Sender WebhookUser `json:"sender"` 96 + } 97 + 98 + // WebhookPullRequest represents pull request information in webhook payload 99 + type WebhookPullRequest struct { 100 + Number int `json:"number"` 101 + Title string `json:"title"` 102 + Body string `json:"body"` 103 + State string `json:"state"` 104 + TargetBranch string `json:"target_branch"` 105 + Source *WebhookPullRequestSource `json:"source,omitempty"` 106 + RoundNumber int `json:"round_number"` 107 + Owner WebhookUser `json:"owner"` 108 + HtmlUrl string `json:"html_url"` 109 + PatchUrl string `json:"patch_url"` 110 + CreatedAt string `json:"created_at"` 111 + } 112 + 113 + // WebhookPullRequestSource represents the source of a branch- or fork-based 114 + // pull request; absent for patch-based pull requests 115 + type WebhookPullRequestSource struct { 116 + Branch string `json:"branch"` 117 + Repo string `json:"repo,omitempty"` 118 + Sha string `json:"sha,omitempty"` 119 + }
+4
appview/notify/db/db.go
··· 474 474 ) 475 475 } 476 476 477 + func (n *databaseNotifier) ResubmitPull(ctx context.Context, pull *models.Pull) { 478 + // no-op for now 479 + } 480 + 477 481 func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 478 482 l := log.FromContext(ctx) 479 483
+5
appview/notify/logging/notifier.go
··· 95 95 l.inner.NewPull(ctx, pull) 96 96 } 97 97 98 + func (l *loggingNotifier) ResubmitPull(ctx context.Context, pull *models.Pull) { 99 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "ResubmitPull")) 100 + l.inner.ResubmitPull(ctx, pull) 101 + } 102 + 98 103 func (l *loggingNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 99 104 ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullState")) 100 105 l.inner.NewPullState(ctx, actor, pull)
+4
appview/notify/merged_notifier.go
··· 90 90 m.fanout(func(n Notifier) { n.NewPull(ctx, pull) }) 91 91 } 92 92 93 + func (m *mergedNotifier) ResubmitPull(ctx context.Context, pull *models.Pull) { 94 + m.fanout(func(n Notifier) { n.ResubmitPull(ctx, pull) }) 95 + } 96 + 93 97 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 94 98 m.fanout(func(n Notifier) { n.NewPullState(ctx, actor, pull) }) 95 99 }
+2
appview/notify/notifier.go
··· 26 26 DeleteFollow(ctx context.Context, follow *models.Follow) 27 27 28 28 NewPull(ctx context.Context, pull *models.Pull) 29 + ResubmitPull(ctx context.Context, pull *models.Pull) 29 30 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 30 31 31 32 NewIssueLabelOp(ctx context.Context, actor syntax.DID, issue *models.Issue, ops []models.LabelOp) ··· 72 73 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 73 74 74 75 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 76 + func (m *BaseNotifier) ResubmitPull(ctx context.Context, pull *models.Pull) {} 75 77 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 76 78 77 79 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+112 -6
appview/notify/webhook/notifier.go
··· 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/notify" 22 22 "tangled.org/core/log" 23 + "tangled.org/core/orm" 23 24 ) 24 25 25 26 type Notifier struct { 26 27 notify.BaseNotifier 27 - db *db.DB 28 - logger *slog.Logger 29 - client *http.Client 28 + db *db.DB 29 + baseUrl string 30 + logger *slog.Logger 31 + client *http.Client 30 32 } 31 33 32 - func NewNotifier(database *db.DB) *Notifier { 34 + func NewNotifier(database *db.DB, baseUrl string) *Notifier { 33 35 return &Notifier{ 34 - db: database, 35 - logger: log.New("webhook-notifier"), 36 + db: database, 37 + baseUrl: baseUrl, 38 + logger: log.New("webhook-notifier"), 36 39 client: &http.Client{ 37 40 Timeout: 30 * time.Second, 38 41 }, ··· 89 92 userAgent := "Tangled-Hook/rename" 90 93 for _, webhook := range webhooks { 91 94 go w.sendWebhook(ctx, webhook, string(models.WebhookEventRepoRenamed), payload.Repository.FullName, userAgent, payloadBytes) 95 + } 96 + } 97 + 98 + func (w *Notifier) NewPull(ctx context.Context, pull *models.Pull) { 99 + w.pullRequestEvent(ctx, models.WebhookEventPullRequestCreated, "created", pull.OwnerDid, pull) 100 + } 101 + 102 + func (w *Notifier) ResubmitPull(ctx context.Context, pull *models.Pull) { 103 + w.pullRequestEvent(ctx, models.WebhookEventPullRequestResubmitted, "resubmitted", pull.OwnerDid, pull) 104 + } 105 + 106 + func (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 116 + func 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 + 129 + func (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 + 163 + func 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}, 92 198 } 93 199 } 94 200
+333
appview/notify/webhook/notifier_test.go
··· 1 + package webhook 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "path/filepath" 8 + "testing" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + ) 15 + 16 + func TestPullStateEvent(t *testing.T) { 17 + tests := []struct { 18 + name string 19 + state models.PullState 20 + wantEvent models.WebhookEvent 21 + wantAction string 22 + wantOk bool 23 + }{ 24 + {"merged", models.PullMerged, models.WebhookEventPullRequestMerged, "merged", true}, 25 + {"closed", models.PullClosed, models.WebhookEventPullRequestClosed, "closed", true}, 26 + {"reopened", models.PullOpen, models.WebhookEventPullRequestReopened, "reopened", true}, 27 + {"abandoned", models.PullAbandoned, "", "", false}, 28 + } 29 + 30 + for _, tt := range tests { 31 + t.Run(tt.name, func(t *testing.T) { 32 + event, action, ok := pullStateEvent(tt.state) 33 + if event != tt.wantEvent || action != tt.wantAction || ok != tt.wantOk { 34 + t.Errorf("pullStateEvent(%v) = (%q, %q, %v), want (%q, %q, %v)", 35 + tt.state, event, action, ok, tt.wantEvent, tt.wantAction, tt.wantOk) 36 + } 37 + }) 38 + } 39 + } 40 + 41 + func TestBuildPullRequestPayload(t *testing.T) { 42 + const baseUrl = "https://tangled.org" 43 + 44 + targetDid := syntax.DID("did:plc:target") 45 + forkDid := syntax.DID("did:plc:fork") 46 + 47 + repo := &models.Repo{ 48 + Did: "did:plc:target", 49 + Name: "some-repo", 50 + Knot: "knot.example.com", 51 + Rkey: "some-repo", 52 + Created: time.Date(2025, 9, 15, 8, 57, 23, 0, time.UTC), 53 + } 54 + 55 + basePull := func() models.Pull { 56 + return models.Pull{ 57 + PullId: 4, 58 + RepoDid: targetDid, 59 + OwnerDid: "did:plc:author", 60 + Title: "add dark mode", 61 + Body: "implements dark mode", 62 + TargetBranch: "main", 63 + State: models.PullOpen, 64 + Created: time.Date(2025, 9, 16, 10, 0, 0, 0, time.UTC), 65 + Submissions: []*models.PullSubmission{ 66 + {RoundNumber: 0, SourceRev: "aaaa000"}, 67 + {RoundNumber: 1, SourceRev: "bbbb111"}, 68 + }, 69 + } 70 + } 71 + 72 + t.Run("patch based", func(t *testing.T) { 73 + pull := basePull() 74 + pull.PullSource = nil 75 + 76 + payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) 77 + 78 + if payload.Action != "created" { 79 + t.Errorf("action = %q, want %q", payload.Action, "created") 80 + } 81 + pr := payload.PullRequest 82 + if pr.Number != 4 { 83 + t.Errorf("number = %d, want 4", pr.Number) 84 + } 85 + if pr.State != "open" { 86 + t.Errorf("state = %q, want %q", pr.State, "open") 87 + } 88 + if pr.Source != nil { 89 + t.Errorf("source = %+v, want nil for patch-based pull", pr.Source) 90 + } 91 + if pr.RoundNumber != 1 { 92 + t.Errorf("round_number = %d, want 1", pr.RoundNumber) 93 + } 94 + wantHtmlUrl := "https://tangled.org/did:plc:target/some-repo/pulls/4" 95 + if pr.HtmlUrl != wantHtmlUrl { 96 + t.Errorf("html_url = %q, want %q", pr.HtmlUrl, wantHtmlUrl) 97 + } 98 + wantPatchUrl := wantHtmlUrl + "/round/1.patch" 99 + if pr.PatchUrl != wantPatchUrl { 100 + t.Errorf("patch_url = %q, want %q", pr.PatchUrl, wantPatchUrl) 101 + } 102 + if pr.Owner.Did != "did:plc:author" { 103 + t.Errorf("owner.did = %q, want %q", pr.Owner.Did, "did:plc:author") 104 + } 105 + if payload.Sender.Did != "did:plc:author" { 106 + t.Errorf("sender.did = %q, want %q", payload.Sender.Did, "did:plc:author") 107 + } 108 + if payload.Repository.FullName != "did:plc:target/some-repo" { 109 + t.Errorf("repository.full_name = %q, want %q", payload.Repository.FullName, "did:plc:target/some-repo") 110 + } 111 + }) 112 + 113 + t.Run("branch based", func(t *testing.T) { 114 + pull := basePull() 115 + pull.PullSource = &models.PullSource{ 116 + Branch: "dark-mode", 117 + RepoDid: &targetDid, 118 + } 119 + 120 + payload := buildPullRequestPayload("merged", repo, &pull, "did:plc:merger", baseUrl) 121 + 122 + pr := payload.PullRequest 123 + if pr.Source == nil { 124 + t.Fatal("source = nil, want non-nil for branch-based pull") 125 + } 126 + if pr.Source.Branch != "dark-mode" { 127 + t.Errorf("source.branch = %q, want %q", pr.Source.Branch, "dark-mode") 128 + } 129 + if pr.Source.Repo != "" { 130 + t.Errorf("source.repo = %q, want empty for branch-based pull", pr.Source.Repo) 131 + } 132 + if pr.Source.Sha != "bbbb111" { 133 + t.Errorf("source.sha = %q, want %q", pr.Source.Sha, "bbbb111") 134 + } 135 + if payload.Sender.Did != "did:plc:merger" { 136 + t.Errorf("sender.did = %q, want %q", payload.Sender.Did, "did:plc:merger") 137 + } 138 + }) 139 + 140 + t.Run("fork based", func(t *testing.T) { 141 + pull := basePull() 142 + pull.PullSource = &models.PullSource{ 143 + Branch: "dark-mode", 144 + RepoDid: &forkDid, 145 + } 146 + 147 + payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) 148 + 149 + pr := payload.PullRequest 150 + if pr.Source == nil { 151 + t.Fatal("source = nil, want non-nil for fork-based pull") 152 + } 153 + if pr.Source.Repo != "did:plc:fork" { 154 + t.Errorf("source.repo = %q, want %q", pr.Source.Repo, "did:plc:fork") 155 + } 156 + }) 157 + 158 + t.Run("no submissions", func(t *testing.T) { 159 + pull := basePull() 160 + pull.Submissions = nil 161 + 162 + payload := buildPullRequestPayload("created", repo, &pull, "did:plc:author", baseUrl) 163 + 164 + pr := payload.PullRequest 165 + if pr.RoundNumber != 0 { 166 + t.Errorf("round_number = %d, want 0", pr.RoundNumber) 167 + } 168 + if pr.PatchUrl != "" { 169 + t.Errorf("patch_url = %q, want empty when there are no submissions", pr.PatchUrl) 170 + } 171 + }) 172 + } 173 + 174 + type notifierTestEnv struct { 175 + notifier *Notifier 176 + webhook *models.Webhook 177 + db *db.DB 178 + received chan string 179 + } 180 + 181 + // newNotifierTestEnv sets up a real sqlite db with a repo and a webhook 182 + // subscribed to the given events, delivering to a local test server 183 + func newNotifierTestEnv(t *testing.T, events []string) *notifierTestEnv { 184 + t.Helper() 185 + 186 + d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "test.db")) 187 + if err != nil { 188 + t.Fatalf("Make: %v", err) 189 + } 190 + t.Cleanup(func() { d.Close() }) 191 + 192 + tx, err := d.Begin() 193 + if err != nil { 194 + t.Fatalf("Begin: %v", err) 195 + } 196 + if err := db.AddRepo(tx, &models.Repo{ 197 + Did: "did:plc:owner", 198 + Name: "some-repo", 199 + Knot: "knot.example.com", 200 + Rkey: "some-repo", 201 + RepoDid: "did:plc:repo1", 202 + }); err != nil { 203 + t.Fatalf("AddRepo: %v", err) 204 + } 205 + if err := tx.Commit(); err != nil { 206 + t.Fatalf("Commit: %v", err) 207 + } 208 + 209 + received := make(chan string, 1) 210 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 211 + received <- r.Header.Get("X-Tangled-Event") 212 + w.WriteHeader(http.StatusOK) 213 + })) 214 + t.Cleanup(srv.Close) 215 + 216 + webhook := &models.Webhook{ 217 + RepoDid: syntax.DID("did:plc:repo1"), 218 + Url: srv.URL, 219 + Active: true, 220 + Events: events, 221 + } 222 + if err := db.AddWebhook(d, webhook); err != nil { 223 + t.Fatalf("AddWebhook: %v", err) 224 + } 225 + 226 + return &notifierTestEnv{ 227 + notifier: NewNotifier(d, "https://tangled.org"), 228 + webhook: webhook, 229 + db: d, 230 + received: received, 231 + } 232 + } 233 + 234 + func (env *notifierTestEnv) awaitDelivery(t *testing.T, wantEvent string) { 235 + t.Helper() 236 + 237 + select { 238 + case event := <-env.received: 239 + if event != wantEvent { 240 + t.Errorf("X-Tangled-Event = %q, want %q", event, wantEvent) 241 + } 242 + case <-time.After(10 * time.Second): 243 + t.Fatalf("webhook %s not delivered", wantEvent) 244 + } 245 + 246 + // wait for the delivery record so the sender goroutine finishes 247 + // before the db is closed 248 + deadline := time.Now().Add(10 * time.Second) 249 + for { 250 + deliveries, err := db.GetWebhookDeliveries(env.db, env.webhook.Id, 10) 251 + if err == nil && len(deliveries) > 0 { 252 + if !deliveries[0].Success { 253 + t.Errorf("delivery recorded as failed, want success") 254 + } 255 + return 256 + } 257 + if time.Now().After(deadline) { 258 + t.Fatal("delivery record not written") 259 + } 260 + time.Sleep(10 * time.Millisecond) 261 + } 262 + } 263 + 264 + func testPull(state models.PullState) *models.Pull { 265 + return &models.Pull{ 266 + PullId: 1, 267 + RepoDid: syntax.DID("did:plc:repo1"), 268 + OwnerDid: "did:plc:author", 269 + Title: "hello", 270 + TargetBranch: "main", 271 + State: state, 272 + Created: time.Now(), 273 + } 274 + } 275 + 276 + // Pull request events fire from http handlers, whose request context is 277 + // canceled as soon as the handler returns. Deliveries run in background 278 + // goroutines and must not be cut short by that cancellation. 279 + func TestPullRequestEventDeliversAfterContextCancel(t *testing.T) { 280 + env := newNotifierTestEnv(t, []string{string(models.WebhookEventPullRequestCreated)}) 281 + 282 + ctx, cancel := context.WithCancel(context.Background()) 283 + cancel() 284 + 285 + env.notifier.NewPull(ctx, testPull(models.PullOpen)) 286 + env.awaitDelivery(t, "pull_request:created") 287 + } 288 + 289 + func TestNotifierDeliversPullRequestEvents(t *testing.T) { 290 + allEvents := []string{ 291 + string(models.WebhookEventPullRequestCreated), 292 + string(models.WebhookEventPullRequestResubmitted), 293 + string(models.WebhookEventPullRequestMerged), 294 + string(models.WebhookEventPullRequestClosed), 295 + string(models.WebhookEventPullRequestReopened), 296 + } 297 + actor := syntax.DID("did:plc:actor") 298 + 299 + tests := []struct { 300 + name string 301 + notify func(*Notifier, context.Context) 302 + wantEvent string 303 + }{ 304 + { 305 + "resubmitted", 306 + func(n *Notifier, ctx context.Context) { n.ResubmitPull(ctx, testPull(models.PullOpen)) }, 307 + "pull_request:resubmitted", 308 + }, 309 + { 310 + "merged", 311 + func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullMerged)) }, 312 + "pull_request:merged", 313 + }, 314 + { 315 + "closed", 316 + func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullClosed)) }, 317 + "pull_request:closed", 318 + }, 319 + { 320 + "reopened", 321 + func(n *Notifier, ctx context.Context) { n.NewPullState(ctx, actor, testPull(models.PullOpen)) }, 322 + "pull_request:reopened", 323 + }, 324 + } 325 + 326 + for _, tt := range tests { 327 + t.Run(tt.name, func(t *testing.T) { 328 + env := newNotifierTestEnv(t, allEvents) 329 + tt.notify(env.notifier, context.Background()) 330 + env.awaitDelivery(t, tt.wantEvent) 331 + }) 332 + } 333 + }
+52 -2
appview/pages/templates/repo/settings/hooks.html
··· 229 229 <input type="checkbox" name="event_repo_renamed" value="on" /> 230 230 <span class="text-sm">Repository renamed</span> 231 231 </div> 232 + <div class="flex items-center gap-2"> 233 + <input type="checkbox" name="event_pull_request_created" value="on" /> 234 + <span class="text-sm">Pull request opened</span> 235 + </div> 236 + <div class="flex items-center gap-2"> 237 + <input type="checkbox" name="event_pull_request_resubmitted" value="on" /> 238 + <span class="text-sm">Pull request resubmitted</span> 239 + </div> 240 + <div class="flex items-center gap-2"> 241 + <input type="checkbox" name="event_pull_request_merged" value="on" /> 242 + <span class="text-sm">Pull request merged</span> 243 + </div> 244 + <div class="flex items-center gap-2"> 245 + <input type="checkbox" name="event_pull_request_closed" value="on" /> 246 + <span class="text-sm">Pull request closed</span> 247 + </div> 248 + <div class="flex items-center gap-2"> 249 + <input type="checkbox" name="event_pull_request_reopened" value="on" /> 250 + <span class="text-sm">Pull request reopened</span> 251 + </div> 232 252 <p class="text-sm text-gray-500 dark:text-gray-400"> 233 - Additional event types (pull requests, issues) will be available in future updates. 253 + Additional event types (issues) will be available in future updates. 234 254 </p> 235 255 </div> 236 256 </div> ··· 299 319 <div class="flex flex-col gap-2 ml-4"> 300 320 {{ $hasPush := false }} 301 321 {{ $hasRepoRenamed := false }} 322 + {{ $hasPullCreated := false }} 323 + {{ $hasPullResubmitted := false }} 324 + {{ $hasPullMerged := false }} 325 + {{ $hasPullClosed := false }} 326 + {{ $hasPullReopened := false }} 302 327 {{ range $webhook.Events }} 303 328 {{ if eq . "push" }}{{ $hasPush = true }}{{ end }} 304 329 {{ if eq . "repository:renamed" }}{{ $hasRepoRenamed = true }}{{ end }} 330 + {{ if eq . "pull_request:created" }}{{ $hasPullCreated = true }}{{ end }} 331 + {{ if eq . "pull_request:resubmitted" }}{{ $hasPullResubmitted = true }}{{ end }} 332 + {{ if eq . "pull_request:merged" }}{{ $hasPullMerged = true }}{{ end }} 333 + {{ if eq . "pull_request:closed" }}{{ $hasPullClosed = true }}{{ end }} 334 + {{ if eq . "pull_request:reopened" }}{{ $hasPullReopened = true }}{{ end }} 305 335 {{ end }} 306 336 <div class="flex items-center gap-2"> 307 337 <input type="checkbox" name="event_push" value="on" {{ if $hasPush }}checked{{ end }} /> ··· 311 341 <input type="checkbox" name="event_repo_renamed" value="on" {{ if $hasRepoRenamed }}checked{{ end }} /> 312 342 <span class="text-sm">Repository renamed</span> 313 343 </div> 344 + <div class="flex items-center gap-2"> 345 + <input type="checkbox" name="event_pull_request_created" value="on" {{ if $hasPullCreated }}checked{{ end }} /> 346 + <span class="text-sm">Pull request opened</span> 347 + </div> 348 + <div class="flex items-center gap-2"> 349 + <input type="checkbox" name="event_pull_request_resubmitted" value="on" {{ if $hasPullResubmitted }}checked{{ end }} /> 350 + <span class="text-sm">Pull request resubmitted</span> 351 + </div> 352 + <div class="flex items-center gap-2"> 353 + <input type="checkbox" name="event_pull_request_merged" value="on" {{ if $hasPullMerged }}checked{{ end }} /> 354 + <span class="text-sm">Pull request merged</span> 355 + </div> 356 + <div class="flex items-center gap-2"> 357 + <input type="checkbox" name="event_pull_request_closed" value="on" {{ if $hasPullClosed }}checked{{ end }} /> 358 + <span class="text-sm">Pull request closed</span> 359 + </div> 360 + <div class="flex items-center gap-2"> 361 + <input type="checkbox" name="event_pull_request_reopened" value="on" {{ if $hasPullReopened }}checked{{ end }} /> 362 + <span class="text-sm">Pull request reopened</span> 363 + </div> 314 364 <p class="text-sm text-gray-500 dark:text-gray-400"> 315 - Additional event types (pull requests, issues) will be available in future updates. 365 + Additional event types (issues) will be available in future updates. 316 366 </p> 317 367 </div> 318 368 </div>
+30
appview/pulls/resubmit.go
··· 345 345 return 346 346 } 347 347 348 + pull.Submissions = append(pull.Submissions, &models.PullSubmission{ 349 + PullAt: pullAt, 350 + RoundNumber: newRoundNumber, 351 + Patch: newPatch, 352 + Combined: combinedPatch, 353 + SourceRev: newSourceRev, 354 + Created: time.Now(), 355 + }) 356 + s.notifier.ResubmitPull(r.Context(), pull) 357 + 348 358 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 349 359 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 350 360 } ··· 471 481 // pds updates to make 472 482 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 473 483 484 + // pulls to notify for after the transaction commits 485 + var resubmitted []*models.Pull 486 + 474 487 // deleted pulls are marked as deleted in the DB 475 488 for _, p := range deletions { 476 489 // do not do delete already merged PRs ··· 580 593 Value: knotcompat.Pull(&record), 581 594 }, 582 595 }) 596 + 597 + op.Submissions = append(op.Submissions, &models.PullSubmission{ 598 + PullAt: pullAt, 599 + RoundNumber: newRoundNumber, 600 + Patch: newPatch, 601 + Combined: combinedPatch, 602 + SourceRev: newSourceRev, 603 + Created: time.Now(), 604 + }) 605 + resubmitted = append(resubmitted, op) 583 606 } 584 607 585 608 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ ··· 597 620 l.Error("failed to commit resubmit transaction", "err", err) 598 621 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 599 622 return 623 + } 624 + 625 + for _, p := range additions { 626 + s.notifier.NewPull(r.Context(), p) 627 + } 628 + for _, p := range resubmitted { 629 + s.notifier.ResubmitPull(r.Context(), p) 600 630 } 601 631 602 632 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
+26 -16
appview/repo/webhooks.go
··· 12 12 "tangled.org/core/appview/pages" 13 13 ) 14 14 15 + // webhookEventFields maps event checkbox form fields to webhook events 16 + var webhookEventFields = []struct { 17 + field string 18 + event models.WebhookEvent 19 + }{ 20 + {"event_push", models.WebhookEventPush}, 21 + {"event_repo_renamed", models.WebhookEventRepoRenamed}, 22 + {"event_pull_request_created", models.WebhookEventPullRequestCreated}, 23 + {"event_pull_request_resubmitted", models.WebhookEventPullRequestResubmitted}, 24 + {"event_pull_request_merged", models.WebhookEventPullRequestMerged}, 25 + {"event_pull_request_closed", models.WebhookEventPullRequestClosed}, 26 + {"event_pull_request_reopened", models.WebhookEventPullRequestReopened}, 27 + } 28 + 29 + func webhookEventsFromForm(r *http.Request) []string { 30 + events := []string{} 31 + for _, ef := range webhookEventFields { 32 + if r.FormValue(ef.field) == "on" { 33 + events = append(events, string(ef.event)) 34 + } 35 + } 36 + return events 37 + } 38 + 15 39 // Webhooks displays the webhooks settings page 16 40 func (rp *Repo) Webhooks(w http.ResponseWriter, r *http.Request) { 17 41 l := rp.logger.With("handler", "Webhooks") ··· 79 103 80 104 active := r.FormValue("active") == "on" 81 105 82 - events := []string{} 83 - if r.FormValue("event_push") == "on" { 84 - events = append(events, string(models.WebhookEventPush)) 85 - } 86 - if r.FormValue("event_repo_renamed") == "on" { 87 - events = append(events, string(models.WebhookEventRepoRenamed)) 88 - } 89 - 106 + events := webhookEventsFromForm(r) 90 107 if len(events) == 0 { 91 108 rp.pages.Notice(w, "webhooks-error", "At least one event must be enabled") 92 109 return ··· 172 189 173 190 webhook.Active = r.FormValue("active") == "on" 174 191 175 - events := []string{} 176 - if r.FormValue("event_push") == "on" { 177 - events = append(events, string(models.WebhookEventPush)) 178 - } 179 - if r.FormValue("event_repo_renamed") == "on" { 180 - events = append(events, string(models.WebhookEventRepoRenamed)) 181 - } 182 - 192 + events := webhookEventsFromForm(r) 183 193 if len(events) > 0 { 184 194 webhook.Events = events 185 195 }
+1 -1
appview/state/state.go
··· 176 176 } 177 177 notifiers = append(notifiers, indexer) 178 178 179 - notifiers = append(notifiers, whnotify.NewNotifier(d)) 179 + notifiers = append(notifiers, whnotify.NewNotifier(d, config.Core.BaseUrl())) 180 180 181 181 notifier := notify.NewMergedNotifier(notifiers) 182 182 notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify"))
+53 -4
docs/DOCS.md
··· 1957 1957 1958 1958 ## Overview 1959 1959 1960 - Webhooks send HTTP POST requests to URLs you configure whenever specific events happen. Currently, Tangled supports push events, with more event types coming soon. 1960 + Webhooks send HTTP POST requests to URLs you configure whenever specific events happen. Currently, Tangled supports push, repository rename, and pull request events, with more event types coming soon. 1961 1961 1962 1962 ## Configuring webhooks 1963 1963 ··· 1969 1969 4. Configure your webhook: 1970 1970 - **Payload URL**: The endpoint that will receive the webhook POST requests 1971 1971 - **Secret**: An optional secret key for verifying webhook authenticity (leave blank to send unsigned webhooks) 1972 - - **Events**: Select which events trigger the webhook (currently only push events) 1972 + - **Events**: Select which events trigger the webhook 1973 1973 - **Active**: Toggle whether the webhook is enabled 1974 1974 1975 1975 ## Webhook payload ··· 2005 2005 } 2006 2006 ``` 2007 2007 2008 + ### Pull request 2009 + 2010 + Pull request events are sent as separate event types, so you can subscribe to 2011 + exactly the transitions you care about: 2012 + 2013 + - `pull_request:created` — a pull request was opened 2014 + - `pull_request:resubmitted` — a new round (revision) was pushed to a pull request 2015 + - `pull_request:merged` — a pull request was merged 2016 + - `pull_request:closed` — a pull request was closed 2017 + - `pull_request:reopened` — a closed pull request was reopened 2018 + 2019 + All pull request events share the same payload format: 2020 + 2021 + ```json 2022 + { 2023 + "action": "created", 2024 + "pull_request": { 2025 + "number": 4, 2026 + "title": "add dark mode", 2027 + "body": "implements dark mode as discussed in #2", 2028 + "state": "open", 2029 + "target_branch": "main", 2030 + "source": { 2031 + "branch": "dark-mode", 2032 + "sha": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5" 2033 + }, 2034 + "round_number": 0, 2035 + "owner": { 2036 + "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" 2037 + }, 2038 + "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo/pulls/4", 2039 + "patch_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo/pulls/4/round/0.patch", 2040 + "created_at": "2025-09-15T08:57:23Z" 2041 + }, 2042 + "repository": { ... }, 2043 + "sender": { 2044 + "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" 2045 + } 2046 + } 2047 + ``` 2048 + 2049 + Notes: 2050 + 2051 + - `action` mirrors the event type suffix (`created`, `resubmitted`, `merged`, `closed`, `reopened`). 2052 + - `repository` has the same format as in the push payload. 2053 + - The patch itself is not embedded in the payload (patches can be large); fetch it from `patch_url` instead. `round_number` identifies the latest round, and `patch_url` always points at that round's patch. 2054 + - `source` is only present for branch-based and fork-based pull requests; it is omitted for patch-based pulls. For fork-based pulls, `source.repo` contains the DID of the source repository. 2055 + - `sender` is the user who performed the action. 2056 + 2008 2057 ## HTTP headers 2009 2058 2010 2059 Each webhook request includes the following headers: 2011 2060 2012 2061 - `Content-Type: application/json` 2013 - - `User-Agent: Tangled-Hook/<short-sha>` — User agent with short SHA of the commit 2014 - - `X-Tangled-Event: push` — The event type 2062 + - `User-Agent: Tangled-Hook/<short-sha>` — User agent with short SHA of the commit (push events); `Tangled-Hook/pull_request` for pull request events 2063 + - `X-Tangled-Event: push` — The full event type (e.g. `push`, `pull_request:merged`) 2015 2064 - `X-Tangled-Hook-ID: <webhook-id>` — The webhook ID 2016 2065 - `X-Tangled-Delivery: <uuid>` — Unique delivery ID 2017 2066 - `X-Tangled-Signature-256: sha256=<hmac>` — HMAC-SHA256 signature (if secret configured)