Skip to content

Commit 0b1739f

Browse files
authored
Merge pull request #35 from markrai/feature/webhooks
Feature/webhooks (phase 1)
2 parents 959ef69 + 8923bcc commit 0b1739f

22 files changed

Lines changed: 1366 additions & 61 deletions

CHANGELOG.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
# Changelog
22

3-
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** unless noted below.
3+
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** unless noted below.
44
55

6+
## [3.10.0] - 2026-04-04
7+
8+
### Features
9+
10+
- **Event bus + SSE****`internal/eventbus`** fanout; **`PublishEvent`** on the server. Board refresh / members events go through the bus; **`sseBridge`** keeps the same SSE JSON as before.
11+
- **`todo.assigned`** — Published after commit from **`CreateTodo`** / **`UpdateTodo`** when assignee changes (non-anonymous temp boards). SSE uses reason **`todo_assigned`**; handlers skip duplicate **`todo_created`** / **`todo_updated`** refresh when **`AssignmentChanged`**.
12+
- **Webhooks (full mode)****`POST` / `GET` / `DELETE`** **`/api/webhooks`** (maintainer, session; **404** in anonymous mode). Migration **050**; optional HMAC **`X-Scrumboy-Signature`**; async queue + worker, retries, JSON envelope with event **`id`** (for idempotency). Dispatcher enqueues in a goroutine with a detached context so SSE is not blocked.
13+
14+
### Fixes
15+
16+
- **Shutdown** — HTTP **`Shutdown`** before cancelling the webhook worker.
17+
- **CreateTodo** — Same **`!isAnonymousBoard`** gate as **`UpdateTodo`** for assignment events.
18+
19+
### Other
20+
21+
- Tests: **`eventbus_regression_test.go`**. Docs: README webhooks section + TOC. Dep: **`github.com/google/uuid`**.
22+
23+
---
24+
625
## [3.9.4] - 2026-04-04
726

827
### Fixes

README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.9.4-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.10.0-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>
@@ -12,6 +12,34 @@
1212

1313
<img width="2975" height="1078" alt="image" src="internal/httpapi/web/github_preview.jpg" />
1414

15+
## Table of contents
16+
17+
- [Quick Start](#quick-start)
18+
- [Run with Docker](#run-with-docker)
19+
- [Run from source](#run-from-source)
20+
- [Optional Configuration](#optional-configuration)
21+
- [Environment variables](#environment-variables)
22+
- [Encryption key (optional)](#encryption-key-optional)
23+
- [OIDC / SSO login (optional)](#oidc--sso-login-optional)
24+
- [TLS / HTTPS (optional)](#tls--https-optional)
25+
- [Frontend build note](#frontend-build-note)
26+
- [Why Scrumboy?](#why-scrumboy)
27+
- [Who is this for?](#who-is-this-for)
28+
- [Modes](#modes)
29+
- [Features](#features)
30+
- [Integrations & API Access](#integrations--api-access)
31+
- [MCP (JSON-RPC) for AI agents](#mcp-json-rpc-for-ai-agents)
32+
- [Webhooks (outbound HTTP)](#webhooks-outbound-http)
33+
- [Config](#config)
34+
- [Roles](#roles)
35+
- [System roles (instance-wide)](#system-roles-instance-wide)
36+
- [Project roles (per project)](#project-roles-per-project)
37+
- [Export scope](#export-scope)
38+
- [Import modes](#import-modes)
39+
- [Code layout (reference)](#code-layout-reference)
40+
- [Documentation](#documentation)
41+
- [License and Contributions](#license-and-contributions)
42+
1543
## Quick Start
1644

1745
Runs in seconds. No setup required.
@@ -117,6 +145,8 @@ Simplicity of a light Kanban, with the power of structured systems: Roles, sprin
117145

118146
- Realtime SSE enabled boards for instant multi-user actions.
119147

148+
- **Webhooks (API-only, full mode):** Register URLs per project so Scrumboy can POST JSON when subscribed domain events fire (e.g. `todo.assigned`). For your own automations—not in-app or browser notifications. See [Integrations](#integrations--api-access).
149+
120150
- Customizable Tags: Users can inherit and customize tag colors.
121151

122152
- Advanced filtering: Search todos based on text or tags.
@@ -218,6 +248,26 @@ This enables:
218248
- AI agents and MCP clients (use **`POST /mcp/rpc`** for JSON-RPC; **`POST /mcp`** remains available for the legacy `{ "tool", "input" }` envelope)
219249
- Scripting/integrations without login flows
220250

251+
### Webhooks (outbound HTTP)
252+
253+
Scrumboy can **POST JSON to URLs you register** when certain events occur. This is for **server-side integrations** (your script, gateway, queue worker, etc.). It does **not** add notifications inside the Scrumboy UI; live boards still update via **SSE** as before.
254+
255+
- **Availability:** **Full mode only** (endpoints are disabled in anonymous mode).
256+
- **Who can configure:** Project **maintainers**, via the HTTP API only—there is **no settings screen** for webhooks yet.
257+
- **API:** `POST /api/webhooks` (create), `GET /api/webhooks` (list yours), `DELETE /api/webhooks/{id}` — same session cookie / CSRF header rules as other mutating `/api/*` calls.
258+
- **Events:** Subscribe to specific types (e.g. `todo.assigned`) or `*` for all delivered types. The set may grow over time; unused types in your list are harmless.
259+
- **Security:** Optional per-webhook **secret**; when set, requests include an `X-Scrumboy-Signature` header (`sha256=` HMAC of the raw JSON body).
260+
- **Semantics:** Best-effort delivery with retries on failure; not a durable external queue—design for idempotent receivers using the event `id` in the JSON body.
261+
262+
Example create (replace cookie / project id / URL):
263+
264+
```bash
265+
curl -b cookies.txt -X POST http://localhost:8080/api/webhooks \
266+
-H "Content-Type: application/json" \
267+
-H "X-Scrumboy: 1" \
268+
-d '{"projectId":1,"url":"https://example.com/scrumboy-hook","events":["todo.assigned"],"secret":"optional-shared-secret"}'
269+
```
270+
221271

222272
# Config
223273

cmd/scrumboy/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ func main() {
9494
EncryptionKey: encKey,
9595
OIDCService: oidcSvc,
9696
})
97+
st.SetTodoAssignedPublisher(srv.PublishTodoAssigned)
9798

9899
httpServer := &http.Server{
99100
Addr: cfg.BindAddr,
@@ -177,9 +178,12 @@ func main() {
177178

178179
<-stop
179180

181+
// Drain in-flight HTTP requests first so any final todo.assigned events
182+
// are published and enqueued before the webhook worker is stopped.
180183
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
181184
defer shutdownCancel()
182185
if err := httpServer.Shutdown(shutdownCtx); err != nil {
183186
logger.Printf("shutdown: %v", err)
184187
}
188+
srv.Close()
185189
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ toolchain go1.25.5
77
require (
88
github.com/coreos/go-oidc/v3 v3.11.0
99
github.com/go-jose/go-jose/v4 v4.0.2
10+
github.com/google/uuid v1.6.0
1011
github.com/pquerna/otp v1.5.0
1112
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
1213
golang.org/x/crypto v0.25.0
@@ -17,7 +18,6 @@ require (
1718
require (
1819
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
1920
github.com/dustin/go-humanize v1.0.1 // indirect
20-
github.com/google/uuid v1.6.0 // indirect
2121
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
2222
github.com/mattn/go-isatty v0.0.20 // indirect
2323
github.com/ncruces/go-strftime v0.1.9 // indirect

internal/eventbus/event.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package eventbus
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"time"
7+
)
8+
9+
// Event is the canonical domain event passed through the bus.
10+
type Event struct {
11+
ID string `json:"id"`
12+
Type string `json:"type"`
13+
Time time.Time `json:"timestamp"`
14+
ProjectID int64 `json:"projectId"`
15+
Payload json.RawMessage `json:"payload,omitempty"`
16+
}
17+
18+
// Consumer receives events from the bus.
19+
type Consumer interface {
20+
OnEvent(ctx context.Context, e Event)
21+
}
22+
23+
// Publisher sends events into the bus.
24+
type Publisher interface {
25+
Publish(ctx context.Context, e Event) error
26+
}

internal/eventbus/fanout.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package eventbus
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/google/uuid"
8+
)
9+
10+
// Fanout distributes each published event to all registered consumers in order.
11+
type Fanout struct {
12+
consumers []Consumer
13+
}
14+
15+
func NewFanout(consumers ...Consumer) *Fanout {
16+
return &Fanout{consumers: consumers}
17+
}
18+
19+
func (f *Fanout) Publish(ctx context.Context, e Event) error {
20+
if e.ID == "" {
21+
e.ID = uuid.NewString()
22+
}
23+
if e.Time.IsZero() {
24+
e.Time = time.Now().UTC()
25+
}
26+
for _, c := range f.consumers {
27+
c.OnEvent(ctx, e)
28+
}
29+
return nil
30+
}

0 commit comments

Comments
 (0)