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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
67 changes: 67 additions & 0 deletions internal/plane/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<p>sample</p>",
"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()

Expand Down
84 changes: 84 additions & 0 deletions internal/plane/parse_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package plane

import (
"encoding/json"
"errors"
"net/http"
"os"
Expand Down Expand Up @@ -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/<slug>/projects/<pid>/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": "<p>sample</p>",
"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)
}
}
67 changes: 61 additions & 6 deletions internal/plane/types.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -56,31 +60,82 @@ 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"`
Color string `json:"color,omitempty"`
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
Expand Down
Loading