diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e64c6..93a4df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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.3] — 2026-05-24 + +### Fixed + +- **Plane REST `POST /issues/` response decode regression from v0.1.2.** + v0.1.2 modelled `WorkItem.state` / `labels` / `assignees` as object + refs to match the webhook wire shape (PFB-24), but Plane CE v1.3.1's + REST surface returns the same fields as bare UUID strings (state) and + arrays of bare UUID strings (labels, assignees). Every forge→plane + create call decoded the POST response with + `json: cannot unmarshal string into Go struct field WorkItem.state of + type plane.StateRef`, leaving the bridge with no record of the + just-created Plane work item and causing a duplicate Forgejo issue + when Plane's webhook for the new work item came back. Added custom + `UnmarshalJSON` on `StateRef` / `LabelRef` / `AssigneeRef` accepting + both the bare-UUID and object forms; webhook decode is unchanged. + Regression tests pin both shapes against verbatim captures from + plane.stern.ca so the next contract drift fails in CI. See PFB-25. + ## [0.1.2] — 2026-05-24 ### Fixed @@ -59,6 +78,7 @@ 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.3]: https://github.com/hstern/plane-forge-bridge/compare/v0.1.2...v0.1.3 [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/internal/plane/client_test.go b/internal/plane/client_test.go index 3a2b1d5..6f0a93e 100644 --- a/internal/plane/client_test.go +++ b/internal/plane/client_test.go @@ -133,6 +133,73 @@ func TestCreateIssue_APIError(t *testing.T) { } } +// TestCreateIssue_RealPlaneRESTShape_PFB25 pins end-to-end decoding of +// the verbatim REST POST response Plane CE v1.3.1 returns, including +// the bare-UUID state / labels / assignees that PFB-25 regressed on. +// The fixture is a real capture from plane.stern.ca (see PFB-25 body). +func TestCreateIssue_RealPlaneRESTShape_PFB25(t *testing.T) { + t.Parallel() + + const realRESTResponse = `{ + "id": "2d048fe5-c172-42f2-ab92-d5d26f4d7e96", + "type_id": null, + "created_at": "2026-05-24T03:44:46.591330Z", + "updated_at": "2026-05-24T03:44:46.591386Z", + "deleted_at": null, + "point": null, + "name": "pfb-25 capture sample", + "description_html": "

sample

", + "description_binary": null, + "priority": "none", + "start_date": null, + "target_date": null, + "sequence_id": 35, + "sort_order": 255535, + "completed_at": null, + "archived_at": null, + "is_draft": false, + "external_source": null, + "external_id": null, + "created_by": "00a3fa45-f4f8-4a19-b7cf-f86a648f717d", + "updated_by": null, + "project": "ede7e196-e408-47b6-883b-d2188f101dd0", + "workspace": "f0ebd07e-6540-4876-8b07-cdc15659e2b1", + "parent": null, + "state": "e931d389-7080-4612-9f6a-05b535ac3afa", + "estimate_point": null, + "type": null, + "assignees": ["00a3fa45-f4f8-4a19-b7cf-f86a648f717d"], + "labels": ["0798d982-9eef-4993-9d4b-b196d0d4ba3e"] + }` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, realRESTResponse) + })) + t.Cleanup(srv.Close) + + c := newTestClient(t, srv) + got, err := c.CreateIssue(context.Background(), "proj-1", CreateIssueRequest{ + Name: "pfb-25 capture sample", + }) + if err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if got.State.ID != "e931d389-7080-4612-9f6a-05b535ac3afa" { + t.Errorf("State.ID = %q", got.State.ID) + } + if len(got.Labels) != 1 || got.Labels[0].ID != "0798d982-9eef-4993-9d4b-b196d0d4ba3e" { + t.Errorf("Labels = %+v", got.Labels) + } + if len(got.Assignees) != 1 || got.Assignees[0].ID != "00a3fa45-f4f8-4a19-b7cf-f86a648f717d" { + t.Errorf("Assignees = %+v", got.Assignees) + } + if got.SequenceID != 35 { + t.Errorf("SequenceID = %d", got.SequenceID) + } +} + func TestUpdateIssue_HappyPath(t *testing.T) { t.Parallel() diff --git a/internal/plane/parse_test.go b/internal/plane/parse_test.go index b468fe9..039fed0 100644 --- a/internal/plane/parse_test.go +++ b/internal/plane/parse_test.go @@ -1,6 +1,7 @@ package plane import ( + "encoding/json" "errors" "net/http" "os" @@ -404,3 +405,86 @@ func TestParse_RealPlaneWorkItemPayload_PFB24(t *testing.T) { t.Errorf("Assignees len = %d, want 0", got) } } + +// TestWorkItem_UnmarshalJSON_RESTShape_PFB25 pins decoding of a real +// Plane CE v1.3.1 REST POST /issues/ response captured against +// plane.stern.ca. The REST surface returns state as a bare UUID +// string and labels/assignees as arrays of bare UUID strings — the +// opposite shape from webhooks. v0.1.2 (the PFB-24 fix) hard-coded +// the object form on WorkItem, regressing every create call. See +// PFB-25. +// +// This test fails if anyone reverts the dual-shape UnmarshalJSON. +func TestWorkItem_UnmarshalJSON_RESTShape_PFB25(t *testing.T) { + t.Parallel() + + // Verbatim shape Plane CE v1.3.1 returns from + // POST /workspaces//projects//issues/. Captured against + // plane.stern.ca on 2026-05-24 (probe in PFB-25 body). + const restResponse = `{ + "id": "2d048fe5-c172-42f2-ab92-d5d26f4d7e96", + "name": "pfb-25 capture sample", + "description_html": "

sample

", + "priority": "none", + "sequence_id": 35, + "state": "e931d389-7080-4612-9f6a-05b535ac3afa", + "assignees": ["00a3fa45-f4f8-4a19-b7cf-f86a648f717d"], + "labels": ["0798d982-9eef-4993-9d4b-b196d0d4ba3e"], + "project": "ede7e196-e408-47b6-883b-d2188f101dd0", + "workspace": "f0ebd07e-6540-4876-8b07-cdc15659e2b1", + "created_by": "00a3fa45-f4f8-4a19-b7cf-f86a648f717d" + }` + + var wi WorkItem + if err := json.Unmarshal([]byte(restResponse), &wi); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if wi.State.ID != "e931d389-7080-4612-9f6a-05b535ac3afa" { + t.Errorf("State.ID = %q, want e931d389-...", wi.State.ID) + } + if wi.State.Name != "" { + t.Errorf("State.Name = %q, want empty (REST omits the name)", wi.State.Name) + } + if len(wi.Labels) != 1 || wi.Labels[0].ID != "0798d982-9eef-4993-9d4b-b196d0d4ba3e" { + t.Errorf("Labels = %+v", wi.Labels) + } + if wi.Labels[0].Name != "" { + t.Errorf("Labels[0].Name = %q, want empty (REST omits the name)", wi.Labels[0].Name) + } + if len(wi.Assignees) != 1 || wi.Assignees[0].ID != "00a3fa45-f4f8-4a19-b7cf-f86a648f717d" { + t.Errorf("Assignees = %+v", wi.Assignees) + } + if wi.SequenceID != 35 { + t.Errorf("SequenceID = %d", wi.SequenceID) + } +} + +// TestWorkItem_UnmarshalJSON_MixedShapesNullAndEmpty covers the +// corner cases: null values, empty arrays, and unknown keys in the +// object form. These are all observed in real Plane responses (REST +// returns null for unset state on certain endpoints; webhooks ship +// extra description_* fields). +func TestWorkItem_UnmarshalJSON_MixedShapesNullAndEmpty(t *testing.T) { + t.Parallel() + + const payload = `{ + "id": "abc", + "name": "n", + "state": null, + "labels": [], + "assignees": null + }` + var wi WorkItem + if err := json.Unmarshal([]byte(payload), &wi); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if wi.State.ID != "" { + t.Errorf("State.ID = %q, want empty for null state", wi.State.ID) + } + if len(wi.Labels) != 0 { + t.Errorf("Labels = %+v, want empty", wi.Labels) + } + if len(wi.Assignees) != 0 { + t.Errorf("Assignees = %+v, want empty", wi.Assignees) + } +} diff --git a/internal/plane/types.go b/internal/plane/types.go index 6c818c9..dd5644a 100644 --- a/internal/plane/types.go +++ b/internal/plane/types.go @@ -1,6 +1,10 @@ package plane -import "errors" +import ( + "bytes" + "encoding/json" + "errors" +) // EventKind is a normalized identifier for the events this package // understands. It is composed from the Plane "event" + "action" fields in @@ -56,9 +60,15 @@ type Actor struct { } // 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. +// +// Plane CE v1.3.1 serializes the same WorkItem.state field in two different +// shapes depending on the path: +// - webhook deliveries send the full object (id, name, color, group) +// - REST POST/GET/PATCH /issues/ responses send a bare UUID string +// +// UnmarshalJSON accepts both so a single WorkItem type can decode either +// path. PFB-24 fixed the webhook (object) decode; PFB-25 added the REST +// (string) decode after a regression on the create-response path. type StateRef struct { ID string `json:"id"` Name string `json:"name,omitempty"` @@ -66,21 +76,66 @@ type StateRef struct { Group string `json:"group,omitempty"` } +// UnmarshalJSON accepts either a bare UUID string (REST) or the +// {id,name,color,group} object (webhook). +func (s *StateRef) UnmarshalJSON(data []byte) error { + return unmarshalRefBothShapes(data, &s.ID, (*stateRefAlias)(s)) +} + // LabelRef is the nested label object Plane serializes inside a WorkItem. -// Same shape rationale as StateRef. +// Same two-shape rationale as StateRef — REST returns a bare UUID string, +// webhook returns {id, name, color}. UnmarshalJSON handles both. type LabelRef struct { ID string `json:"id"` Name string `json:"name,omitempty"` Color string `json:"color,omitempty"` } +// UnmarshalJSON accepts either a bare UUID string (REST) or the +// {id,name,color} object (webhook). +func (l *LabelRef) UnmarshalJSON(data []byte) error { + return unmarshalRefBothShapes(data, &l.ID, (*labelRefAlias)(l)) +} + // AssigneeRef is the nested member object Plane serializes inside a -// WorkItem's assignees array. Same shape rationale as StateRef. +// WorkItem's assignees array. Same two-shape rationale as StateRef — REST +// returns a bare UUID string, webhook returns {id, display_name}. +// UnmarshalJSON handles both. type AssigneeRef struct { ID string `json:"id"` DisplayName string `json:"display_name,omitempty"` } +// UnmarshalJSON accepts either a bare UUID string (REST) or the +// {id,display_name} object (webhook). +func (a *AssigneeRef) UnmarshalJSON(data []byte) error { + return unmarshalRefBothShapes(data, &a.ID, (*assigneeRefAlias)(a)) +} + +// Aliases break the UnmarshalJSON recursion when we want the default +// struct decode for the object form. +type ( + stateRefAlias StateRef + labelRefAlias LabelRef + assigneeRefAlias AssigneeRef +) + +// unmarshalRefBothShapes decodes a WorkItem ref field that may arrive as +// either a bare UUID string (REST) or an object (webhook). On a string the +// UUID is written into idOut; on an object the full struct is decoded via +// objOut (which must be an alias pointer so the default struct decoder +// runs, not the caller's custom UnmarshalJSON). null and empty payloads +// leave the target untouched. +func unmarshalRefBothShapes(data []byte, idOut *string, objOut any) error { + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + return nil + } + if data[0] == '"' { + return json.Unmarshal(data, idOut) + } + return json.Unmarshal(data, objOut) +} + // 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