Org-level webhooks: fan-out across all org repos #363

Closed
opened 2026-09-12 00:49:01 +00:00 by crueber · 3 comments
Owner

Survey: crueber/walhub#349 candidate 11.

Evidence

  • Webhooks are repo-scoped only: routes at /{o}/{r}/api/webhooks (see internal/notify/http.go + collab tests driving /acme/repo/api/webhooks); HookSpec/delivery keyed per repo. No org-level hook surface exists (grep org|Org in internal/notify finds only @org/team mention expansion, not org-scoped hooks).
  • Forgejo/GitHub both have org webhooks; org owners currently must register per-repo hooks to observe member/team/repo-create events.

Design

  • Org-scoped hooks (POST /api/v1/orgs/{org}/webhooks, owner-gated) fanning out on events across the org's repos (push, repo create, member/team/invite changes). Reuse the repo webhook delivery/keeper machinery; enumerate repos via the registry lister (human-rate fan-out, never hot path).

Acceptance criteria

  • Org owners can create/list/delete org webhooks; events across member repos deliver with HMAC keepers.
  • Org membership/team-change events included (not just repo events).
  • Caps/quotas mirror repo-hook limits; tests + docs.
Survey: crueber/walhub#349 candidate 11. ## Evidence - Webhooks are repo-scoped only: routes at /{o}/{r}/api/webhooks (see internal/notify/http.go + collab tests driving /acme/repo/api/webhooks); HookSpec/delivery keyed per repo. No org-level hook surface exists (grep org|Org in internal/notify finds only @org/team mention expansion, not org-scoped hooks). - Forgejo/GitHub both have org webhooks; org owners currently must register per-repo hooks to observe member/team/repo-create events. ## Design - Org-scoped hooks (POST /api/v1/orgs/{org}/webhooks, owner-gated) fanning out on events across the org's repos (push, repo create, member/team/invite changes). Reuse the repo webhook delivery/keeper machinery; enumerate repos via the registry lister (human-rate fan-out, never hot path). ## Acceptance criteria - [ ] Org owners can create/list/delete org webhooks; events across member repos deliver with HMAC keepers. - [ ] Org membership/team-change events included (not just repo events). - [ ] Caps/quotas mirror repo-hook limits; tests + docs.
crueber added this to the v1 milestone 2026-09-12 00:49:01 +00:00
Author
Owner

Fix PR: #372

Fix PR: https://git.packden.us/crueber/walhub/pulls/372
Author
Owner

Review of PR #372 (fix/issue-363, commit d125981) — org-level webhooks fan-out.

Scope checked: notify/orghooks.go (new, 1046 lines), notify/http.go handleOrg + ExposedTemplates, notify/notify.go (OrgOwner seam + wakeOrgCh + org watermarks), notify/tasks.go (minute org sweep + retention floor), notify/webhooks.go (dedup to shared helpers), identity/identity.go (OrgEvent seam) + orgs.go/invites.go/http_invites.go (emissions + DeleteOrgInvite), cmd/walhub/collab.go wiring, web/sdk/src/orgs.js + sdk-notifications.test.js, docs/features/06 §§1.5/5.4/6/7/9 + 14 amendment. No browser exercised (API/service change; tests + reasoning suffice — stated explicitly per brief).

  1. THE DEVIATION — push + repo-create excluded (06 §5.4 + PR 'Out of scope'). RULING: JUSTIFIED, with one required follow-up. Pushes never enter the collab-events logs the org pass reads; pulling them in would mean a new WAL/git-hot-path source (law 4/6 cost, Seam 4 bridge is the git-push surface), i.e. a scope expansion, not a fan-out. Repo-create has no single author/timestamp seam (create-twin vs PUT lane), so 'first activity is the signal' is honest. The doc is explicit (member-repo half = collab activity only, NOT pushes; no synthetic create record; decisions entry 2026-09-12). REQUIRED: the issue criterion needs an amendment comment (Design line lists push + repo create; acceptance bullet 1 'events across member repos' reads as including push). Treat this comment as that amendment proposal: #363 delivers membership/team/invite + member-repo collab-activity events with HMAC; git-push delivery stays on the events-bridge TOML sinks and repo-create surfaces via first activity. If the author agrees, keep this note as the amended criterion — no code block on this point.

  2. Observer wiring — PASS. Post-commit only, nil-safe via emitOrgEvent (identity/identity.go). Covered: Set/RemoveMember, Create/DeleteTeam, Set/RemoveTeamMember, CreateOrgInvite, AcceptInvite (org kind: member_added/role_changed via SetMember + own invite_accepted), CancelInvite (org-kind only; repo cancels stay silent — correct), new DeleteOrgInvite owner-cancel (invites.go) wired into http_invites.go owner-cancel path (same 404/400 mapping as the inlined code it replaced + inbox drop + emit). CAS-retry flags (added/roleChanged/removed) re-derived per attempt with exactly-one-emission comment — correct. No other membership-mutation path found (org create/delete/rename are not member/team/invite transitions and correctly emit nothing).

  3. Delivery — PASS. postEvent reused verbatim (keepers/HMAC/10s lane — same wire bytes; org actions carry Kind 'org', Repo=org). Per-scope cursors (org-log cursor + per-(hook,repo) cursors) CAS-advance monotonically per scope; lost CAS redelivers, never skips (at-least-once preserved). No cross-hook contamination (keys are per-hook; finishOrgPass watermarks are per-org/per-(org,repo) gate hints only, delivery cursors stay in bucket). Retention floor folds orgRepoMinCursor in (tasks.go) — sound; fresh hook (cursor 0) holds floor only until first pass. Minute sweep bounded: hookless org = one hooks LIST, early return; active org = LIST + org-state GET + member collab_state probes only when due; hookMu held for maps only, never across I/O. Dedicated wakeOrgCh (notify.go) — no §7 bulk/control sharing (no shared client/semaphore with repo lane; FanoutParallel=8 semaphore is per-pass, pre-acquired per #153).

  4. Gates — PASS. Owner-only CRUD via requireOrgOwner → CheckOrgOwner (server-authoritative identity seam; nil checker fails closed; anon 401 / non-owner 403 / unknown id 404). Both lanes served (api + api-browser twins) + ExposedTemplates/discovery entries. Caps mirror repo limits exactly: same maxHooks() (default 20, 409 past it), 256/scope/pass, FanoutParallel 8, last-25 deliveries ring. Secrets write-only (secret_set).

  5. No-UI — ACCEPTABLE. Issue never demands a UI tab; API+SDK is the v1 contract and 06 out-of-scope explicitly defers the org-settings hooks tab. SDK surface complete (list/create/get/update/remove/ping/deliveries) + unit tests.

  6. Tests/docs — PASS. notify 95.6% / identity 96.4% (≥95 gate), -race clean, gofmt clean, vet clean, go build clean, cmd/walhub suite green, node SDK surface tests 3/3. Table coverage: CRUD/gating/validation/caps/fan-out+HMAC/filter/failure-pending/sweep-gate/retention-floor + e2e collab chain (author-attested; cmd/walhub suite green here). 06 §§1.5/5.4/6/7/9 accurate; 14 amendment follows frozen-overwritable rule 2 (same-revision adoption, key families enumerated, orgevents Create-only correctly excluded from overwritable set).

  7. Deps/behavior — PASS. No go.mod/npm changes. Repo-hook behavior: webhooks.go helpers deduplicated to shared readCursorAt/advanceCursorAt/recordDeliveryAt/readDeliveriesAt — byte-identical logic, existing notify suite green proves no behavior change.

Verified in scratch worktree /tmp/pr372 (removed afterward); main worktree left clean/read-only (fetch only). No live instance/docker touched.

RECOMMENDATION: ready to merge once the criterion-amendment note above is acknowledged (this comment serves as the amendment; no code change required).

Review of PR #372 (fix/issue-363, commit d125981) — org-level webhooks fan-out. Scope checked: notify/orghooks.go (new, 1046 lines), notify/http.go handleOrg + ExposedTemplates, notify/notify.go (OrgOwner seam + wakeOrgCh + org watermarks), notify/tasks.go (minute org sweep + retention floor), notify/webhooks.go (dedup to shared helpers), identity/identity.go (OrgEvent seam) + orgs.go/invites.go/http_invites.go (emissions + DeleteOrgInvite), cmd/walhub/collab.go wiring, web/sdk/src/orgs.js + sdk-notifications.test.js, docs/features/06 §§1.5/5.4/6/7/9 + 14 amendment. No browser exercised (API/service change; tests + reasoning suffice — stated explicitly per brief). 1. THE DEVIATION — push + repo-create excluded (06 §5.4 + PR 'Out of scope'). RULING: JUSTIFIED, with one required follow-up. Pushes never enter the collab-events logs the org pass reads; pulling them in would mean a new WAL/git-hot-path source (law 4/6 cost, Seam 4 bridge is the git-push surface), i.e. a scope expansion, not a fan-out. Repo-create has no single author/timestamp seam (create-twin vs PUT lane), so 'first activity is the signal' is honest. The doc is explicit (member-repo half = collab activity only, NOT pushes; no synthetic create record; decisions entry 2026-09-12). REQUIRED: the issue criterion needs an amendment comment (Design line lists push + repo create; acceptance bullet 1 'events across member repos' reads as including push). Treat this comment as that amendment proposal: #363 delivers membership/team/invite + member-repo collab-activity events with HMAC; git-push delivery stays on the events-bridge TOML sinks and repo-create surfaces via first activity. If the author agrees, keep this note as the amended criterion — no code block on this point. 2. Observer wiring — PASS. Post-commit only, nil-safe via emitOrgEvent (identity/identity.go). Covered: Set/RemoveMember, Create/DeleteTeam, Set/RemoveTeamMember, CreateOrgInvite, AcceptInvite (org kind: member_added/role_changed via SetMember + own invite_accepted), CancelInvite (org-kind only; repo cancels stay silent — correct), new DeleteOrgInvite owner-cancel (invites.go) wired into http_invites.go owner-cancel path (same 404/400 mapping as the inlined code it replaced + inbox drop + emit). CAS-retry flags (added/roleChanged/removed) re-derived per attempt with exactly-one-emission comment — correct. No other membership-mutation path found (org create/delete/rename are not member/team/invite transitions and correctly emit nothing). 3. Delivery — PASS. postEvent reused verbatim (keepers/HMAC/10s lane — same wire bytes; org actions carry Kind 'org', Repo=org). Per-scope cursors (org-log cursor + per-(hook,repo) cursors) CAS-advance monotonically per scope; lost CAS redelivers, never skips (at-least-once preserved). No cross-hook contamination (keys are per-hook; finishOrgPass watermarks are per-org/per-(org,repo) gate hints only, delivery cursors stay in bucket). Retention floor folds orgRepoMinCursor in (tasks.go) — sound; fresh hook (cursor 0) holds floor only until first pass. Minute sweep bounded: hookless org = one hooks LIST, early return; active org = LIST + org-state GET + member collab_state probes only when due; hookMu held for maps only, never across I/O. Dedicated wakeOrgCh (notify.go) — no §7 bulk/control sharing (no shared client/semaphore with repo lane; FanoutParallel=8 semaphore is per-pass, pre-acquired per #153). 4. Gates — PASS. Owner-only CRUD via requireOrgOwner → CheckOrgOwner (server-authoritative identity seam; nil checker fails closed; anon 401 / non-owner 403 / unknown id 404). Both lanes served (api + api-browser twins) + ExposedTemplates/discovery entries. Caps mirror repo limits exactly: same maxHooks() (default 20, 409 past it), 256/scope/pass, FanoutParallel 8, last-25 deliveries ring. Secrets write-only (secret_set). 5. No-UI — ACCEPTABLE. Issue never demands a UI tab; API+SDK is the v1 contract and 06 out-of-scope explicitly defers the org-settings hooks tab. SDK surface complete (list/create/get/update/remove/ping/deliveries) + unit tests. 6. Tests/docs — PASS. notify 95.6% / identity 96.4% (≥95 gate), -race clean, gofmt clean, vet clean, go build clean, cmd/walhub suite green, node SDK surface tests 3/3. Table coverage: CRUD/gating/validation/caps/fan-out+HMAC/filter/failure-pending/sweep-gate/retention-floor + e2e collab chain (author-attested; cmd/walhub suite green here). 06 §§1.5/5.4/6/7/9 accurate; 14 amendment follows frozen-overwritable rule 2 (same-revision adoption, key families enumerated, orgevents Create-only correctly excluded from overwritable set). 7. Deps/behavior — PASS. No go.mod/npm changes. Repo-hook behavior: webhooks.go helpers deduplicated to shared readCursorAt/advanceCursorAt/recordDeliveryAt/readDeliveriesAt — byte-identical logic, existing notify suite green proves no behavior change. Verified in scratch worktree /tmp/pr372 (removed afterward); main worktree left clean/read-only (fetch only). No live instance/docker touched. RECOMMENDATION: ready to merge once the criterion-amendment note above is acknowledged (this comment serves as the amendment; no code change required).
Author
Owner

Fixed by PR #372 (review clean — deviation ruled justified with amendment note; observer, delivery, gates, caps verified), merged. Closing.

Fixed by PR #372 (review clean — deviation ruled justified with amendment note; observer, delivery, gates, caps verified), merged. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#363
No description provided.