diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3d72c43..27c7304 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -530,7 +530,12 @@ jobs: "id": "33333333-3333-3333-3333-333333333333", "name": "e2e: hello from plane", "description_html": "

Issue created on Plane that the bridge should mirror to forge.

", - "state": "11111111-0000-0000-0000-000000000002", + "state": { + "id": "11111111-0000-0000-0000-000000000002", + "name": "In Progress", + "color": "#0066ff", + "group": "started" + }, "project": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "workspace": "00000000-0000-0000-0000-000000000aaa", "created_by": "44444444-4444-4444-4444-444444444444", diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f1e64c6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to plane-forge-bridge are documented here. The format +follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.2] — 2026-05-24 + +### Fixed + +- **Plane → forge: `WorkItem.state`, `labels`, `assignees` are objects, + not bare UUIDs.** The bridge's `WorkItem` struct modelled them as + `string` / `[]string`; real Plane (CE v1.3.1+) serializes them as + nested objects (`{id, name, color, group}` for `state`, etc.). Every + real Plane webhook 400'd on payload decode after the v0.1.1 + action-tense fix. Added `StateRef`, `LabelRef`, `AssigneeRef` types + and pointed the fields at them; the bridge's sync layer only reads + `Labels[i].ID` today, so the user-visible behaviour change is exactly: + decode no longer fails. Regression test pins decoding of a captured + real Plane payload so the next struct-shape drift fails in CI instead + of in production. + +## [0.1.1] — 2026-05-24 + +### Fixed + +- **Plane → forge: real Plane sends past-tense action verbs (`created` / + `updated` / `deleted`).** v0.1.0's parser only recognised present-tense + (`create` / `update` / `delete`), so every real Plane webhook hit + `ErrUnsupportedEvent` → 204 No Content + log at DEBUG. plane → forge + was broken on every v0.1.0 deployment; the silent 204 made Plane think + the delivery succeeded and the DEBUG log was suppressed at the default + `log_level=info`, so operators saw nothing. Parser now accepts both + spellings. Testdata fixtures swapped to past-tense (the wire reality). + `/plane/webhook` `ErrUnsupportedEvent` logs promoted from DEBUG to INFO + so a future contract drift can't be silent again — `/forge/webhook` + stays at DEBUG since forges legitimately fan out events the operator + didn't subscribe to. + ([#10](https://github.com/hstern/plane-forge-bridge/issues/10)) + +## [0.1.0] — 2026-05-23 + +First release. Bidirectional webhook bridge between Plane and any +Gitea-API-compatible forge (Forgejo, Gitea): + +- Issues create/update/close/reopen ↔ work items +- Comments create both ways (edit/delete deferred) +- Labels both ways with auto-create +- State mapping per-link +- PR/branch refs (`[PROJ-123]` or `proj-123-foo`) → work-item state + automation +- Plane → forge issue creation closes the loop (work_item.created → forge + issue; updates and deletes deferred) +- Identity: config map → email match → bridge bot, with auto-fallback + through `forge.SearchUsers` to defeat Gitea/Forgejo's webhook noreply + placeholder + +Loop-break: HTML-comment marker + in-memory LRU. Single static binary, +distroless runtime, stateless. CI matrix tests against real Forgejo 15 +and Gitea 1.22 service containers end-to-end on every PR. + +[0.1.2]: https://github.com/hstern/plane-forge-bridge/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/hstern/plane-forge-bridge/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/hstern/plane-forge-bridge/releases/tag/v0.1.0 diff --git a/README.md b/README.md index ea89bc7..58c96de 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # plane-forge-bridge -**Status: v0.1.0** — first release published. Multi-arch image (linux/amd64 + linux/arm64) at -`ghcr.io/hstern/plane-forge-bridge:0.1.0` (also `:0.1`, `:0`, `:latest`). +**Status: v0.1.2** — multi-arch image (linux/amd64 + linux/arm64) at +`ghcr.io/hstern/plane-forge-bridge:0.1.2` (also `:0.1`, `:0`, `:latest`). +Patch release fixing two real-deployment bugs in the plane → forge path +that v0.1.0 shipped silently broken — see CHANGELOG. Bidirectional webhook bridge between [Plane](https://plane.so) and any Gitea-API-compatible git forge ([Forgejo](https://forgejo.org/), @@ -46,7 +48,7 @@ See [docs/design.md](docs/design.md) for the full design + open questions. 1. **Pull the image** ```sh - docker pull ghcr.io/hstern/plane-forge-bridge:0.1.0 + docker pull ghcr.io/hstern/plane-forge-bridge:0.1.2 ``` 2. **Write a `config.yaml`** — copy [`config.example.yaml`](config.example.yaml) @@ -64,7 +66,7 @@ See [docs/design.md](docs/design.md) for the full design + open questions. -e PFB_FORGE_WEBHOOK_SECRET=... \ -e PFB_PLANE_API_KEY=... \ -e PFB_PLANE_WEBHOOK_SECRET=... \ - ghcr.io/hstern/plane-forge-bridge:0.1.0 \ + ghcr.io/hstern/plane-forge-bridge:0.1.2 \ --config /etc/pfb/config.yaml ``` @@ -82,12 +84,12 @@ See [docs/design.md](docs/design.md) for the full design + open questions. each accepted webhook is one info-level line with `kind`, `delivery_id`, and the resulting outbound action. -## What ships in v0.1.0 +## What ships in v0.1 **Bidirectional** - Issues create/update/close/reopen ↔ work items (title, description, state, assignee, labels) -- Comments create both ways (edit/delete deferred — see [PFB-12](https://github.com/hstern/plane-forge-bridge/issues)) +- Comments create both ways (edit/delete deferred) - Labels both ways with auto-create on either side - State mapping per-link (forge `open`/`closed` ↔ configurable plane states) @@ -95,10 +97,10 @@ See [docs/design.md](docs/design.md) for the full design + open questions. - PR open/reopen/close/merge → state automation on the linked work item via `[PROJ-123]` bracket refs in title/body or `proj-123-foo` head-branch - names (review-state semantics deferred — see PFB-14) + names (review-state semantics deferred) - Plane → forge issue creation closes the loop (work_item.created → forge issue created with both loop-break and plane-ref markers; plane updates + - deletes deferred — see PFB-13) + deletes deferred) **Identity** @@ -107,13 +109,11 @@ See [docs/design.md](docs/design.md) for the full design + open questions. placeholder by re-resolving through the user-search API - Configurable bridge bot as the final fallback -**Out of v0.1.0** (tracked as PFB-12 through PFB-19): comment edit/delete -identity persistence, plane work_item update/delete reverse lookup, -PR-review-state automation, plane→forge label sync wire-up, custom OAuth -flow (only needed for multi-tenant heterogeneous-email deployments), label -cache invalidation on rename, PR state no-op suppression, plane→forge body -author preface. Each issue has a "Make:" section pointing at the exact -files and methods to touch. +**Out of v0.1**: comment edit/delete identity persistence, plane +work_item update/delete reverse lookup, PR-review-state automation, +plane→forge label sync wire-up, custom OAuth flow (only needed for +multi-tenant heterogeneous-email deployments), label cache invalidation +on rename, PR state no-op suppression, plane→forge body author preface. ## Build / test diff --git a/internal/plane/README.md b/internal/plane/README.md index 176b01d..35a1940 100644 --- a/internal/plane/README.md +++ b/internal/plane/README.md @@ -187,6 +187,17 @@ These are the contract details we relied on; they came from reading the upstream source at `makeplane/plane` rather than the docs site, which under-specifies the list endpoint: +0. **`WorkItem.state`, `labels`, and `assignees` are nested objects on + the wire, NOT bare UUIDs.** Real Plane CE v1.3.1 serializes them as + `{id, name, color, group}` (state) / `{id, name, color}` (label) / + `{id, display_name}` (assignee). The bridge models them with the + `StateRef` / `LabelRef` / `AssigneeRef` types in `types.go`. Bridge + code that wants just the UUID reads `.State.ID` or iterates + `.Labels[i].ID`. The v0.1.0 release shipped these as `string` / + `[]string`, which made every real Plane webhook 400 on payload + decode — fixed in v0.1.2 with regression test + `TestParse_RealPlaneWorkItemPayload_PFB24`. + 1. **Filter parameter names.** Plane's work-item list endpoint accepts `external_id` and `external_source` as query parameters. When both are present the view short-circuits to `Issue.objects.get(...)` and diff --git a/internal/plane/client_test.go b/internal/plane/client_test.go index f0328b7..3a2b1d5 100644 --- a/internal/plane/client_test.go +++ b/internal/plane/client_test.go @@ -66,7 +66,7 @@ func TestCreateIssue_HappyPath(t *testing.T) { _ = json.NewEncoder(w).Encode(WorkItem{ ID: "issue-uuid", Name: gotBody.Name, - State: gotBody.StateID, + State: StateRef{ID: gotBody.StateID}, Priority: gotBody.Priority, }) })) diff --git a/internal/plane/parse_test.go b/internal/plane/parse_test.go index a719055..b468fe9 100644 --- a/internal/plane/parse_test.go +++ b/internal/plane/parse_test.go @@ -334,3 +334,73 @@ func TestParse_RealPlaneActionVerbsAccepted(t *testing.T) { }) } } + +// TestParse_RealPlaneWorkItemPayload_PFB24 pins decoding of a real +// Plane CE v1.3.1 work_item.created payload captured from +// postgres webhook_logs.request_body. It exercises the object-form +// state/labels (PFB-24) alongside the past-tense action (PFB-22) — +// either regressing alone produces a 400-malformed-payload response +// in the bridge handler. +func TestParse_RealPlaneWorkItemPayload_PFB24(t *testing.T) { + t.Parallel() + + const realPlanePayload = `{ + "event": "issue", + "action": "created", + "webhook_id": "3eda452e-0000-0000-0000-000000000001", + "workspace_id": "00000000-0000-0000-0000-000000000aaa", + "data": { + "id": "12345678-1234-1234-1234-123456789abc", + "name": "Real Plane payload", + "description_html": "

captured from postgres webhook_logs.request_body

", + "state": { + "id": "e931d389-7080-4612-9f6a-05b535ac3afa", + "name": "Backlog", + "color": "#60646C", + "group": "backlog" + }, + "priority": "none", + "assignees": [], + "labels": [ + { + "id": "abcdef00-0000-0000-0000-000000000001", + "name": "claude:DOCS-25", + "color": "#FAB287" + } + ], + "project": "77777777-7777-7777-7777-777777777777", + "workspace": "00000000-0000-0000-0000-000000000aaa", + "created_by": "88888888-8888-8888-8888-888888888888", + "sequence_id": 33 + } + }` + + h := http.Header{} + h.Set(HeaderEvent, planeEventIssue) + h.Set(HeaderDelivery, "pfb24-regression") + ev, err := Parse(h, []byte(realPlanePayload)) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if ev.Kind != EventWorkItemCreated { + t.Errorf("Kind = %q, want %q", ev.Kind, EventWorkItemCreated) + } + if ev.WorkItem == nil { + t.Fatal("WorkItem nil") + } + if got := ev.WorkItem.State.ID; got != "e931d389-7080-4612-9f6a-05b535ac3afa" { + t.Errorf("State.ID = %q", got) + } + if got := ev.WorkItem.State.Name; got != "Backlog" { + t.Errorf("State.Name = %q", got) + } + if got := len(ev.WorkItem.Labels); got != 1 { + t.Fatalf("Labels len = %d, want 1", got) + } + if got := ev.WorkItem.Labels[0].Name; got != "claude:DOCS-25" { + t.Errorf("Labels[0].Name = %q", got) + } + if got := len(ev.WorkItem.Assignees); got != 0 { + t.Errorf("Assignees len = %d, want 0", got) + } +} diff --git a/internal/plane/testdata/work_item_created.json b/internal/plane/testdata/work_item_created.json index aaaba7e..b1447e8 100644 --- a/internal/plane/testdata/work_item_created.json +++ b/internal/plane/testdata/work_item_created.json @@ -7,13 +7,18 @@ "id": "33333333-3333-3333-3333-333333333333", "name": "Investigate flaky CI runner", "description_html": "

Race detector trips intermittently on the forge package.

", - "state": "44444444-4444-4444-4444-444444444444", + "state": { + "id": "44444444-4444-4444-4444-444444444444", + "name": "Backlog", + "color": "#60646C", + "group": "backlog" + }, "priority": "high", "assignees": [ - "55555555-5555-5555-5555-555555555555" + { "id": "55555555-5555-5555-5555-555555555555", "display_name": "Alice" } ], "labels": [ - "66666666-6666-6666-6666-666666666666" + { "id": "66666666-6666-6666-6666-666666666666", "name": "bug", "color": "#ff0000" } ], "project": "77777777-7777-7777-7777-777777777777", "workspace": "22222222-2222-2222-2222-222222222222", diff --git a/internal/plane/testdata/work_item_updated.json b/internal/plane/testdata/work_item_updated.json index 381bce2..d420d3b 100644 --- a/internal/plane/testdata/work_item_updated.json +++ b/internal/plane/testdata/work_item_updated.json @@ -7,15 +7,20 @@ "id": "33333333-3333-3333-3333-333333333333", "name": "Investigate flaky CI runner (in progress)", "description_html": "

Race detector trips intermittently. Bisecting.

", - "state": "99999999-9999-9999-9999-999999999999", + "state": { + "id": "99999999-9999-9999-9999-999999999999", + "name": "In Progress", + "color": "#0066ff", + "group": "started" + }, "priority": "urgent", "assignees": [ - "55555555-5555-5555-5555-555555555555", - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + { "id": "55555555-5555-5555-5555-555555555555", "display_name": "Alice" }, + { "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "display_name": "Bob" } ], "labels": [ - "66666666-6666-6666-6666-666666666666", - "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + { "id": "66666666-6666-6666-6666-666666666666", "name": "bug", "color": "#ff0000" }, + { "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "name": "regression", "color": "#aa00aa" } ], "project": "77777777-7777-7777-7777-777777777777", "workspace": "22222222-2222-2222-2222-222222222222", diff --git a/internal/plane/types.go b/internal/plane/types.go index 23a208a..6c818c9 100644 --- a/internal/plane/types.go +++ b/internal/plane/types.go @@ -55,24 +55,54 @@ type Actor struct { DisplayName string `json:"display_name"` } +// StateRef is the nested state object Plane serializes inside a WorkItem. +// Real Plane (verified against CE v1.3.1) sends the full state object, not +// a bare UUID — the bridge originally modelled this as a string and 400'd +// on every real delivery. See PFB-24. +type StateRef struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Color string `json:"color,omitempty"` + Group string `json:"group,omitempty"` +} + +// LabelRef is the nested label object Plane serializes inside a WorkItem. +// Same shape rationale as StateRef. +type LabelRef struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Color string `json:"color,omitempty"` +} + +// AssigneeRef is the nested member object Plane serializes inside a +// WorkItem's assignees array. Same shape rationale as StateRef. +type AssigneeRef struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` +} + // WorkItem is the minimal subset of Plane's IssueExpandSerializer output we // need to translate to a forge issue. Plane calls these "work items" in its // UI and "issues" in its database / API; we use the UI name in our public // surface. +// +// State, Labels, and Assignees are serialized as nested objects by real +// Plane (not as the bare UUIDs the original modelling assumed). Callers +// that want just the UUID read .State.ID, iterate .Labels[i].ID, etc. type WorkItem struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description_html"` - State string `json:"state"` - Priority string `json:"priority"` - Assignees []string `json:"assignees"` - Labels []string `json:"labels"` - Project string `json:"project"` - Workspace string `json:"workspace"` - CreatedBy string `json:"created_by"` - SequenceID int `json:"sequence_id"` - ExternalSource string `json:"external_source,omitempty"` - ExternalID string `json:"external_id,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description_html"` + State StateRef `json:"state"` + Priority string `json:"priority"` + Assignees []AssigneeRef `json:"assignees"` + Labels []LabelRef `json:"labels"` + Project string `json:"project"` + Workspace string `json:"workspace"` + CreatedBy string `json:"created_by"` + SequenceID int `json:"sequence_id"` + ExternalSource string `json:"external_source,omitempty"` + ExternalID string `json:"external_id,omitempty"` } // Comment is the minimal subset of Plane's IssueCommentSerializer output diff --git a/internal/sync/planeworkitem.go b/internal/sync/planeworkitem.go index 4480e97..acc514d 100644 --- a/internal/sync/planeworkitem.go +++ b/internal/sync/planeworkitem.go @@ -175,7 +175,17 @@ func (e *Engine) handlePlaneWorkItemCreated(ctx context.Context, evt *plane.Even // label IDs. Two cache hops: plane UUID → name via the plane label // cache, then name → forge integer ID (with auto-create) via the // forge label cache. - names, err := e.resolvePlaneLabelNames(ctx, link.PlaneProjectID, evt.WorkItem.Labels) + // + // As of PFB-24 the webhook payload includes the label name inline + // (LabelRef has Name + Color alongside ID), so the UUID→name lookup + // could be skipped here. We keep it for now so the resolver remains + // the single source of truth for label-name resolution (in case + // upstream ever drops .Name from a future event type). + labelUUIDs := make([]string, len(evt.WorkItem.Labels)) + for i, l := range evt.WorkItem.Labels { + labelUUIDs[i] = l.ID + } + names, err := e.resolvePlaneLabelNames(ctx, link.PlaneProjectID, labelUUIDs) if err != nil { return nil, fmt.Errorf("sync: resolve plane label names: %w", err) } diff --git a/internal/sync/planeworkitem_test.go b/internal/sync/planeworkitem_test.go index 80a8dc4..6f87acb 100644 --- a/internal/sync/planeworkitem_test.go +++ b/internal/sync/planeworkitem_test.go @@ -56,7 +56,10 @@ func mkPlaneWorkItemEvent(kind plane.EventKind) *plane.Event { Description: "When the clock strikes 13:37, files vanish.", Project: testProjectID, Workspace: "ws-001", - Labels: []string{"label-uuid-bug", "label-uuid-help"}, + Labels: []plane.LabelRef{ + {ID: "label-uuid-bug", Name: "bug"}, + {ID: "label-uuid-help", Name: "help wanted"}, + }, }, } }