Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,12 @@ jobs:
"id": "33333333-3333-3333-3333-333333333333",
"name": "e2e: hello from plane",
"description_html": "<p>Issue created on Plane that the bridge should mirror to forge.</p>",
"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",
Expand Down
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/),
Expand Down Expand Up @@ -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)
Expand All @@ -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
```

Expand All @@ -82,23 +84,23 @@ 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)

**One-way (forge → plane)**

- 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**

Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions internal/plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/plane/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}))
Expand Down
70 changes: 70 additions & 0 deletions internal/plane/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<p>captured from postgres webhook_logs.request_body</p>",
"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)
}
}
11 changes: 8 additions & 3 deletions internal/plane/testdata/work_item_created.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
"id": "33333333-3333-3333-3333-333333333333",
"name": "Investigate flaky CI runner",
"description_html": "<p>Race detector trips intermittently on the forge package.</p>",
"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",
Expand Down
15 changes: 10 additions & 5 deletions internal/plane/testdata/work_item_updated.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@
"id": "33333333-3333-3333-3333-333333333333",
"name": "Investigate flaky CI runner (in progress)",
"description_html": "<p>Race detector trips intermittently. Bisecting.</p>",
"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",
Expand Down
56 changes: 43 additions & 13 deletions internal/plane/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion internal/sync/planeworkitem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading