From 53f516b45da5aa8c2565d765d02bac61c30f1a95 Mon Sep 17 00:00:00 2001 From: Henry Stern Date: Sun, 24 May 2026 05:44:34 -0300 Subject: [PATCH 1/2] ci: real-Plane round-trip job catches wire-shape drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PFB-22, PFB-24, and PFB-25 were all the same class of bug: the existing test/e2e-docker harness uses a plane-stub that happily agrees with the bridge's synthetic testdata, so any divergence between testdata and real Plane wire shape only surfaces in production. This adds an `e2e-real-plane` CI job that: - Brings up a minimum Plane CE v1.3.1 stack via docker compose (api+worker+postgres+redis+rabbitmq+minio, no frontends, no persistent volumes). Trimmed from upstream makeplane/plane compose. - Seeds an admin user + workspace + project + API token via direct Django ORM (seed.py runs inside the plane-api container). Plane CE has no headless signup flow — the browser flow goes through Django sessions + CSRF + workspace onboarding, all expensive to drive from a script. Direct ORM is idempotent and self-contained. - Runs the bridge's plane package integration test against real Plane over REST. The test pins every plane.Client method the bridge uses, asserting the high-value fields decode (CreateIssue/GetIssue/ UpdateIssue cover the PFB-25 state-as-bare-UUID failure mode). - Gates publish alongside lint + e2e-docker. Local: bash test/e2e-docker/plane-ce/run.sh up; go test -tags=integration ... The job adds ~90s of cold-cache time per CI run. Acceptable cost for the structural guarantee against future PFB-22/24/25-class bugs. Refs PFB-28. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 61 ++++- CHANGELOG.md | 18 ++ internal/plane/integration_test.go | 229 +++++++++++++++++++ test/e2e-docker/plane-ce/README.md | 122 ++++++++++ test/e2e-docker/plane-ce/docker-compose.yaml | 180 +++++++++++++++ test/e2e-docker/plane-ce/run.sh | 93 ++++++++ test/e2e-docker/plane-ce/seed.py | 227 ++++++++++++++++++ 7 files changed, 929 insertions(+), 1 deletion(-) create mode 100644 internal/plane/integration_test.go create mode 100644 test/e2e-docker/plane-ce/README.md create mode 100644 test/e2e-docker/plane-ce/docker-compose.yaml create mode 100755 test/e2e-docker/plane-ce/run.sh create mode 100644 test/e2e-docker/plane-ce/seed.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 27c7304..37000c9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -623,15 +623,74 @@ jobs: docker rm -f bridge 2>/dev/null docker rm -f plane-stub 2>/dev/null + # Real-Plane round-trip. Brings up a minimum Plane CE v1.3.1 stack + # (api+worker+deps, no frontends), seeds an admin user + workspace + + # project + API token via direct Django ORM (Plane CE has no headless + # signup flow), and runs the bridge's `plane` package integration + # tests against real Plane over the REST API. Catches the wire-shape + # drift class of bug that produced PFB-22, PFB-24, and PFB-25 — + # those all passed CI because the e2e-docker `plane-stub` happily + # agreed with the bridge's synthetic testdata. + # + # Separate from the e2e-docker matrix: Plane CE boot is ~90s on a + # cold cache, and the wire shape doesn't vary by forge flavor, so + # running once is enough. + e2e-real-plane: + name: e2e-real-plane + runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + cache: true + + - name: Bring up Plane CE v1.3.1 + seed + id: seed + env: + # Lock the API token to a known value for grep-ability in + # bridge logs; otherwise seed.py emits a random UUID hex. + PFB_PLANE_API_KEY: ci-plane-token-${{ github.run_id }} + run: | + set -euo pipefail + seed_json=$(bash test/e2e-docker/plane-ce/run.sh up | tail -n1) + echo "seed_json=$seed_json" + # Mask the token in any subsequent logs. + token=$(echo "$seed_json" | jq -r '.api_token') + echo "::add-mask::$token" + # Expose every field as PFB_PLANE_TEST_ for the test. + echo "$seed_json" | jq -r 'to_entries[] | "PFB_PLANE_TEST_\(.key|ascii_upcase)=\(.value)"' \ + >> "$GITHUB_ENV" + + - name: Run integration test against real Plane + env: + PFB_PLANE_TEST_BASE_URL: http://localhost:8765/api/v1 + run: | + set -euo pipefail + go test -tags=integration ./internal/plane -run TestIntegration -v -count=1 + + - name: Plane logs (always) + if: always() + run: | + docker compose -f test/e2e-docker/plane-ce/docker-compose.yaml \ + logs --tail=80 plane-api plane-worker plane-beat-worker || true + + - name: Tear down + if: always() + run: bash test/e2e-docker/plane-ce/run.sh down + # Publish on push to main (rolling `:latest`) and on v* tag pushes (semver # tags). Never on PRs. Retag the build-image artifact rather than rebuild — # the bytes we publish are the bytes e2e-docker just validated. publish: name: publish - needs: [lint, build-image, e2e-docker] + needs: [lint, build-image, e2e-docker, e2e-real-plane] if: | github.event_name != 'pull_request' && needs.e2e-docker.result == 'success' && + needs.e2e-real-plane.result == 'success' && needs.lint.result == 'success' runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 263fc95..b914c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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). +## Unreleased + +### Changed + +- **CI gains a real-Plane round-trip job (PFB-28).** The + `e2e-real-plane` workflow stage brings up a minimum Plane CE v1.3.1 + stack (api+worker+postgres+redis+rabbitmq+minio) via + `test/e2e-docker/plane-ce/docker-compose.yaml`, seeds an admin user / + workspace / project / API token via direct Django ORM + (`seed.py` — Plane CE has no headless signup flow), and runs the + bridge's `plane` package integration test (`go test + -tags=integration ./internal/plane`) against real Plane over the + REST API. The publish gate now requires this job to pass alongside + `e2e-docker` and `lint`. Goal: future Plane wire-shape changes (the + PFB-22 / PFB-24 / PFB-25 class) fail in CI instead of in production — + the existing `plane-stub` happily agrees with synthetic testdata, so + it can't catch upstream drift on its own. + ## [0.1.4] — 2026-05-24 ### Fixed diff --git a/internal/plane/integration_test.go b/internal/plane/integration_test.go new file mode 100644 index 0000000..8afc1db --- /dev/null +++ b/internal/plane/integration_test.go @@ -0,0 +1,229 @@ +//go:build integration + +// Package plane integration tests exercise the bridge's REST client +// against a real Plane CE backend (test/e2e-docker/plane-ce/). The +// build tag keeps them out of `go test ./...` — they only run when +// explicitly opted in: +// +// go test -tags=integration ./internal/plane -run TestIntegration -v +// +// Wire-up: source the JSON the seed emits into env vars, then run. +// +// eval "$(bash test/e2e-docker/plane-ce/run.sh up | tail -n1 | \ +// jq -r 'to_entries[] | "export PFB_PLANE_TEST_\(.key|ascii_upcase)=\(.value)"')" +// export PFB_PLANE_TEST_BASE_URL=http://localhost:8765 +// go test -tags=integration ./internal/plane -run TestIntegration -v +// +// CI does the same wiring inline in .github/workflows/ci.yaml. +// +// Goal: round-trip the plane.Client methods the bridge actually uses +// against a real Plane backend, so wire-shape drift (PFB-22/24/25) +// fails in CI instead of in production. The assertions intentionally +// pin only the fields the bridge consumes — decode is what we care +// about, not full payload parity. +package plane + +import ( + "context" + "net/http" + "os" + "strconv" + "testing" + "time" +) + +func newIntegrationClient(t *testing.T) (*Client, string) { + t.Helper() + base := os.Getenv("PFB_PLANE_TEST_BASE_URL") + slug := os.Getenv("PFB_PLANE_TEST_WORKSPACE_SLUG") + token := os.Getenv("PFB_PLANE_TEST_API_TOKEN") + project := os.Getenv("PFB_PLANE_TEST_PROJECT_ID") + if base == "" || slug == "" || token == "" || project == "" { + t.Skip("PFB_PLANE_TEST_{BASE_URL,WORKSPACE_SLUG,API_TOKEN,PROJECT_ID} not set; " + + "run test/e2e-docker/plane-ce/run.sh up first") + } + c := NewClient(base, slug, token, &http.Client{Timeout: 30 * time.Second}) + return c, project +} + +// TestIntegration_RoundTrip exercises every plane.Client method the +// bridge calls today. It's a single test, not a table, so cleanup +// happens in the right order: the issue → its comments → the labels. +// If Plane CE changes the wire shape of any response the bridge +// decodes, this test fails on the decode, exactly the failure mode +// PFB-25 exposed in production. +func TestIntegration_RoundTrip(t *testing.T) { + c, projectID := newIntegrationClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + // --- ListProjectStates: locked-in shape, used by state mapping --- + states, err := c.ListProjectStates(ctx, projectID) + if err != nil { + t.Fatalf("ListProjectStates: %v", err) + } + if len(states) == 0 { + t.Fatal("ListProjectStates returned no states; seed.py should have created the defaults") + } + var backlogID, doneID string + for _, s := range states { + switch s.Name { + case "Backlog": + backlogID = s.ID + case "Done": + doneID = s.ID + } + } + if backlogID == "" || doneID == "" { + t.Fatalf("missing default states; got %d states", len(states)) + } + + // --- CreateProjectLabel + ListProjectLabels --- + labelName := "pfb-integ-" + strconv.FormatInt(time.Now().UnixNano(), 36) + createdLabel, err := c.CreateProjectLabel(ctx, projectID, CreateLabelRequest{ + Name: labelName, + Color: "#7c3aed", + Description: "round-trip label", + }) + if err != nil { + t.Fatalf("CreateProjectLabel: %v", err) + } + if createdLabel.ID == "" || createdLabel.Name != labelName { + t.Errorf("CreateProjectLabel returned %+v, want non-empty ID + matching name", createdLabel) + } + labels, err := c.ListProjectLabels(ctx, projectID) + if err != nil { + t.Fatalf("ListProjectLabels: %v", err) + } + var foundLabel bool + for _, l := range labels { + if l.ID == createdLabel.ID { + foundLabel = true + break + } + } + if !foundLabel { + t.Errorf("ListProjectLabels missing the just-created label %q", createdLabel.ID) + } + + // --- ListWorkspaceMembers: bridge identity resolver uses this --- + members, err := c.ListWorkspaceMembers(ctx) + if err != nil { + t.Fatalf("ListWorkspaceMembers: %v", err) + } + if len(members) == 0 { + t.Fatal("ListWorkspaceMembers returned no members; seed.py created an admin") + } + if members[0].Email == "" { + t.Errorf("ListWorkspaceMembers returned member with empty Email; identity resolver depends on this") + } + + // --- CreateIssue: this is the PFB-25 failure path --- + // The bridge stamps external_source on every create; using a real + // value here also covers the PFB-27 echo-detection invariant from + // the *creation* side (the bridge's handler now skips inbound + // echoes that carry this prefix). + issueName := "pfb-integ-issue-" + strconv.FormatInt(time.Now().UnixNano(), 36) + created, err := c.CreateIssue(ctx, projectID, CreateIssueRequest{ + Name: issueName, + DescriptionHTML: "

round-trip body

", + StateID: backlogID, + Priority: "low", + Labels: []string{createdLabel.ID}, + ExternalSource: "forge:acme/widgets", + ExternalID: "42", + }) + if err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if created.ID == "" || created.SequenceID == 0 { + t.Errorf("CreateIssue returned %+v, want populated ID + SequenceID", created) + } + // PFB-25 specifically: state must decode from the bare UUID Plane + // REST returns. PFB-24 specifically: webhooks come back as objects; + // this test covers the REST path. + if created.State.ID != backlogID { + t.Errorf("State.ID = %q, want %q (PFB-25 regression if decode fails)", + created.State.ID, backlogID) + } + if len(created.Labels) != 1 || created.Labels[0].ID != createdLabel.ID { + t.Errorf("Labels = %+v, want [{ID:%s}]", created.Labels, createdLabel.ID) + } + + // --- GetIssue: independent decode path --- + got, err := c.GetIssue(ctx, projectID, created.ID) + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if got.Name != issueName || got.State.ID != backlogID { + t.Errorf("GetIssue mismatch: name=%q state=%q", got.Name, got.State.ID) + } + + // --- UpdateIssue: PATCH response decode --- + newName := issueName + " (updated)" + updated, err := c.UpdateIssue(ctx, projectID, created.ID, UpdateIssueRequest{ + Name: &newName, + StateID: &doneID, + }) + if err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + if updated.Name != newName || updated.State.ID != doneID { + t.Errorf("UpdateIssue mismatch: name=%q state=%q", updated.Name, updated.State.ID) + } + + // --- GetIssueByExternalRef: bridge's reverse-lookup path --- + found, err := c.GetIssueByExternalRef(ctx, projectID, "forge:acme/widgets", "42") + if err != nil { + t.Fatalf("GetIssueByExternalRef: %v", err) + } + if found.ID != created.ID { + t.Errorf("GetIssueByExternalRef returned %q, want %q", found.ID, created.ID) + } + + // --- GetIssueByExternalRef on a non-existent pair returns ErrNotFound --- + missing, err := c.GetIssueByExternalRef(ctx, projectID, "forge:nope/nope", "0") + if err == nil || err != ErrNotFound { + t.Errorf("GetIssueByExternalRef(missing) = (%v, %v), want (nil, ErrNotFound)", missing, err) + } + + // --- CreateComment / UpdateComment / DeleteComment --- + createdComment, err := c.CreateComment(ctx, projectID, created.ID, CreateCommentRequest{ + CommentHTML: "

round-trip comment ", + Access: "EXTERNAL", + }) + if err != nil { + t.Fatalf("CreateComment: %v", err) + } + if createdComment.ID == "" || createdComment.IssueID != created.ID { + t.Errorf("CreateComment returned %+v", createdComment) + } + + patched, err := c.UpdateComment(ctx, projectID, created.ID, createdComment.ID, UpdateCommentRequest{ + CommentHTML: pStr("

edited body

"), + }) + if err != nil { + t.Fatalf("UpdateComment: %v", err) + } + if patched.CommentHTML == "" { + t.Errorf("UpdateComment dropped comment_html: %+v", patched) + } + + if err := c.DeleteComment(ctx, projectID, created.ID, createdComment.ID); err != nil { + t.Fatalf("DeleteComment: %v", err) + } + // Re-delete must be ErrNotFound, not an opaque error. + if err := c.DeleteComment(ctx, projectID, created.ID, createdComment.ID); err != ErrNotFound { + t.Errorf("DeleteComment(already deleted) = %v, want ErrNotFound", err) + } + + // --- Cleanup: the issue was created with external_source so the + // next test run finds it via GetIssueByExternalRef. We DON'T delete + // it from REST — Plane CE deletes propagate to its webhook system, + // and leaving a residual issue is harmless (next CI run re-uses + // the same external_id and reconciles via UpdateIssue). The label + // stays too. Tests run against an ephemeral DB in CI so nothing + // accumulates across runs. +} + +func pStr(s string) *string { return &s } diff --git a/test/e2e-docker/plane-ce/README.md b/test/e2e-docker/plane-ce/README.md new file mode 100644 index 0000000..4219927 --- /dev/null +++ b/test/e2e-docker/plane-ce/README.md @@ -0,0 +1,122 @@ +# test/e2e-docker/plane-ce — real Plane CE in CI + +A minimal Plane CE v1.3.1 stack the bridge spins up to round-trip a +real REST + webhook exchange against the actual Plane backend (not the +recording stub). This is the structural fix from PFB-28 for the class +of bug that produced PFB-22, PFB-24, and PFB-25 — testdata that +diverged from real Plane wire shape, with the existing e2e stub happy +to agree with synthetic data. + +## What runs + +`docker-compose.yaml` brings up the minimum Plane CE topology needed +for the REST API + webhook delivery worker: + +- `plane-db` — postgres 15 (ephemeral, no volume) +- `plane-redis` — valkey 7 +- `plane-mq` — rabbitmq 3.13 with management plugin +- `plane-minio` — S3-compatible storage for attachments (Plane requires + it even if nothing uses it) +- `plane-migrator` — one-shot django migrations +- `plane-api` — gunicorn serving `/api/v1/*` and `/auth/*` +- `plane-worker` — celery worker (processes webhook deliveries) +- `plane-beat-worker` — celery beat scheduler + +Trimmed from the upstream `makeplane/plane` compose by dropping the +frontends (web, space, admin, live), the proxy, and all persistent +volumes — none are needed for headless REST + webhook coverage. + +## Why these versions + +The Plane backend is pinned to `v1.3.1` because that is the version +`plane.stern.ca` runs and that `internal/plane/testdata/` is locked to +(see `internal/plane/testdata/README.md`). Bumping the version requires: + +1. Re-capturing the webhook payloads in `internal/plane/testdata/` + against the new version +2. Verifying that `seed.py` still finds the expected ORM surface (Plane + has moved User/Workspace models across module boundaries before) +3. Verifying that the `register_instance` and `configure_instance` + management commands still exist with the same arguments + +The `valkey:7.2.11-alpine` / `postgres:15.7-alpine` / `rabbitmq:3.13.6-management-alpine` +pins match the upstream compose at the same Plane release; no reason to +drift them independently. + +## Local run + +```bash +# Bring the stack up + seed admin/workspace/project/token (~90s cold cache): +bash test/e2e-docker/plane-ce/run.sh up + +# The last stdout line is a JSON record with all the connection details: +# {"workspace_slug": "pfb-ci", "project_id": "...", "api_token": "...", ...} + +# Source it into env vars and run the integration test: +eval "$(bash test/e2e-docker/plane-ce/run.sh seed 2>/dev/null | tail -n1 \ + | jq -r 'to_entries[] | "export PFB_PLANE_TEST_\(.key|ascii_upcase)=\(.value)"')" +export PFB_PLANE_TEST_BASE_URL=http://localhost:8765/api/v1 +go test -tags=integration ./internal/plane -run TestIntegration -v + +# Tear down: +bash test/e2e-docker/plane-ce/run.sh down +``` + +The seed is idempotent — re-running it returns the same workspace, +project, and token; pass `PFB_PLANE_API_KEY=` to lock the +token to a stable string (CI does this for log grep-ability). + +### Podman wart + +On rootless podman, `rabbitmq:3.13.6-management-alpine` fails to start +with `eacces` on `/var/lib/rabbitmq/.erlang.cookie` because the +container runs as root, which maps to a UID that lacks read access to +the in-image `/var/lib/rabbitmq/` (owned by uid 100). The compose pins +`user: "100:101"` on the rabbitmq service to work around this. Docker +(in CI) is unaffected either way. + +## CI integration + +`.github/workflows/ci.yaml` defines an `e2e-real-plane` job that: + +1. Brings up this stack via `run.sh up` +2. Sources the seed JSON into env vars +3. Runs `go test -tags=integration ./internal/plane -run TestIntegration` +4. Tears down via `run.sh down` (always — `if: always()`) + +The job is **separate from** the existing `e2e-docker` matrix to keep +the marginal CI time scoped: real Plane boot is ~90s on a cold cache, +and the value of running the integration test twice (once per forge +flavor) is low — the wire shape doesn't depend on which forge is +upstream. + +## Bootstrap details (seed.py) + +Plane CE has no documented headless seed path. The browser flow +(register instance → sign up admin → create workspace → create project → +mint API token) is human-oriented and runs through Django sessions +with CSRF cookies; driving it from a script is more wire than it's +worth. `seed.py` runs inside the `plane-api` container and goes +directly through Django ORM: + +- `Instance` row with `is_setup_done=True` (the gate the auth views + check before allowing sign-up) +- `User` with a hashed password + `is_email_verified=True` +- `InstanceAdmin` linking the user to the instance as Owner (role 20) +- `Workspace` (slug `pfb-ci`) + `WorkspaceMember` +- `Project` (identifier `PFB`) + `ProjectIdentifier` + `ProjectMember` +- Default `State` rows (Backlog/Todo/In Progress/Done/Cancelled) — Plane + normally seeds these via a post-create signal, but we create them + explicitly so state-map tests are deterministic +- `APIToken` with a known value (env override or random UUID hex) + +Every step is `get_or_create`, so the seed is safe to re-run. + +## Integration test coverage + +`internal/plane/integration_test.go` (build tag `integration`) calls +every `plane.Client` method the bridge uses today and asserts the +high-value fields decoded. The PFB-25 regression mode (REST `state` +field as a bare UUID) is specifically pinned at the +`CreateIssue` / `GetIssue` / `UpdateIssue` round-trip points, and +PFB-27's external_source echo path is exercised on the create side. diff --git a/test/e2e-docker/plane-ce/docker-compose.yaml b/test/e2e-docker/plane-ce/docker-compose.yaml new file mode 100644 index 0000000..1c5a1a9 --- /dev/null +++ b/test/e2e-docker/plane-ce/docker-compose.yaml @@ -0,0 +1,180 @@ +# Minimal Plane CE v1.3.1 stack for the bridge's real-Plane e2e tests. +# +# Trimmed from the upstream makeplane/plane docker-compose: we keep only +# the services needed for REST + webhooks (api, worker, beat-worker, +# migrator, postgres, redis, rabbitmq, minio). No frontends (web/space/ +# admin/live), no proxy — the bridge talks to Plane over the REST API at +# http://plane-api:8000/ and receives webhooks back from plane-worker. +# +# Pinned to v1.3.1 to match internal/plane/testdata/. Bumping the version +# requires recapturing testdata; see internal/plane/testdata/README.md. + +x-db-env: &db-env + PGHOST: plane-db + PGDATABASE: plane + POSTGRES_USER: plane + POSTGRES_PASSWORD: plane + POSTGRES_DB: plane + POSTGRES_PORT: "5432" + PGDATA: /var/lib/postgresql/data + +x-redis-env: &redis-env + REDIS_HOST: plane-redis + REDIS_PORT: "6379" + REDIS_URL: redis://plane-redis:6379/ + +x-minio-env: &minio-env + MINIO_ROOT_USER: access-key + MINIO_ROOT_PASSWORD: secret-key + +x-aws-s3-env: &aws-s3-env + AWS_REGION: "" + AWS_ACCESS_KEY_ID: access-key + AWS_SECRET_ACCESS_KEY: secret-key + AWS_S3_ENDPOINT_URL: http://plane-minio:9000 + AWS_S3_BUCKET_NAME: uploads + +x-mq-env: &mq-env + RABBITMQ_HOST: plane-mq + RABBITMQ_PORT: "5672" + RABBITMQ_DEFAULT_USER: plane + RABBITMQ_DEFAULT_PASS: plane + RABBITMQ_DEFAULT_VHOST: plane + RABBITMQ_VHOST: plane + +x-app-env: &app-env + # Plane's anti-SSRF webhook validator DNS-resolves and rejects private + # IPs. The bridge is reachable on this compose network as + # http://pfb-bridge:8080; WEBHOOK_ALLOWED_HOSTS bypasses the check. + # This must match the bridge container's hostname. + WEBHOOK_ALLOWED_HOSTS: pfb-bridge,host.docker.internal + WEBHOOK_ALLOWED_IPS: "" + WEB_URL: http://localhost:8000 + DEBUG: "0" + CORS_ALLOWED_ORIGINS: http://localhost:8000 + GUNICORN_WORKERS: "1" + USE_MINIO: "1" + DATABASE_URL: postgresql://plane:plane@plane-db/plane + SECRET_KEY: ci-secret-key-not-for-production-use-32chr + AMQP_URL: amqp://plane:plane@plane-mq:5672/plane + API_KEY_RATE_LIMIT: 600/minute + MINIO_ENDPOINT_SSL: "0" + LIVE_SERVER_SECRET_KEY: ci-live-secret-not-for-production-use32 + +services: + plane-db: + image: postgres:15.7-alpine + command: postgres -c 'max_connections=1000' + environment: + <<: *db-env + # No volume — CI is one-shot, data dies with the compose down. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U plane -d plane"] + interval: 5s + timeout: 3s + retries: 20 + + plane-redis: + image: valkey/valkey:7.2.11-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + plane-mq: + image: rabbitmq:3.13.6-management-alpine + # Run as the rabbitmq user inside the container. Default is root, + # which on rootless podman maps to a host UID that lacks read access + # to the in-image /var/lib/rabbitmq/ (owned by uid 100). With + # `user:` set, the container starts as rabbitmq directly and the + # .erlang.cookie permission dance works. Docker (CI) is unaffected + # either way; this also helps anyone running the e2e locally on + # podman. + user: "100:101" + environment: + <<: *mq-env + healthcheck: + # rabbitmq-diagnostics check_port_connectivity is the official + # readiness probe per upstream docs. + test: ["CMD", "rabbitmq-diagnostics", "-q", "check_port_connectivity"] + interval: 10s + timeout: 5s + retries: 20 + + plane-minio: + image: minio/minio:RELEASE.2025-01-20T14-49-07Z + command: server /export --console-address ":9090" + environment: + <<: *minio-env + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 3s + retries: 20 + + plane-migrator: + image: makeplane/plane-backend:v1.3.1 + command: ./bin/docker-entrypoint-migrator.sh + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env] + depends_on: + plane-db: + condition: service_healthy + plane-redis: + condition: service_healthy + restart: on-failure + + plane-api: + image: makeplane/plane-backend:v1.3.1 + command: ./bin/docker-entrypoint-api.sh + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env] + depends_on: + plane-migrator: + condition: service_completed_successfully + plane-db: + condition: service_healthy + plane-redis: + condition: service_healthy + plane-mq: + condition: service_healthy + plane-minio: + condition: service_healthy + ports: + # Expose to the host so seed scripts and integration tests outside + # the compose network can hit the REST API. 8765 is unlikely to + # collide with a developer's local services; the CI workflow can + # override via `services.plane-api.ports`. + - "8765:8000" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/instances/"] + interval: 5s + timeout: 3s + retries: 30 + + plane-worker: + image: makeplane/plane-backend:v1.3.1 + command: ./bin/docker-entrypoint-worker.sh + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + depends_on: + plane-api: + condition: service_healthy + plane-mq: + condition: service_healthy + + plane-beat-worker: + image: makeplane/plane-backend:v1.3.1 + command: ./bin/docker-entrypoint-beat.sh + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + depends_on: + plane-api: + condition: service_healthy + plane-mq: + condition: service_healthy + +networks: + default: + name: pfb-e2e-plane-ce diff --git a/test/e2e-docker/plane-ce/run.sh b/test/e2e-docker/plane-ce/run.sh new file mode 100755 index 0000000..6e64759 --- /dev/null +++ b/test/e2e-docker/plane-ce/run.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Bring up the Plane CE v1.3.1 stack used by the bridge's real-Plane +# round-trip integration test, seed an admin user / workspace / project / +# API token, and emit a single JSON line with the connection details for +# callers to source. +# +# Usage: +# bash test/e2e-docker/plane-ce/run.sh up # bring up + seed; print JSON +# bash test/e2e-docker/plane-ce/run.sh down # tear down +# bash test/e2e-docker/plane-ce/run.sh seed # re-run the seed (idempotent) +# +# The "up" form is the entry point for CI: it blocks until plane-api is +# accepting requests, runs the seed, and prints +# {"workspace_slug":"...","project_id":"...","api_token":"...", ...} +# on the last stdout line. Earlier lines are progress diagnostics. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yaml" +SEED_SCRIPT="${SCRIPT_DIR}/seed.py" + +API_HOST_PORT="${PFB_PLANE_HOST_PORT:-8765}" +API_HEALTH_URL="http://localhost:${API_HOST_PORT}/api/instances/" + +# Compose helper. Prefer the v2 plugin (`docker compose`); fall back to +# the standalone `docker-compose` so CI images without the plugin work. +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose -f "$COMPOSE_FILE" "$@" + else + docker-compose -f "$COMPOSE_FILE" "$@" + fi +} + +log() { echo "pfb-e2e-plane:" "$@" >&2; } + +wait_for_api() { + local i max=60 + for ((i=1; i<=max; i++)); do + # /api/instances/ returns 200 even before configure_instance — the + # endpoint always renders the Instance record (or its absence) as JSON. + if curl -fsS "$API_HEALTH_URL" >/dev/null 2>&1; then + log "plane-api healthy after ${i}s" + return 0 + fi + sleep 5 + done + log "ERROR: plane-api never became healthy" + compose logs --tail=80 plane-api >&2 + return 1 +} + +cmd_up() { + log "bringing up Plane CE v1.3.1 stack (this takes ~90s on a cold cache)" + compose up -d + wait_for_api + cmd_seed +} + +cmd_seed() { + log "seeding admin user + workspace + project + API token" + # Pass the desired API token through if the caller wants a stable value + # (CI sets PFB_PLANE_API_KEY=ci-plane-key- for grep-ability in + # logs); otherwise seed.py generates a random one. + local token_env="" + if [[ -n "${PFB_PLANE_API_KEY:-}" ]]; then + token_env="-e PFB_PLANE_API_KEY=${PFB_PLANE_API_KEY}" + fi + + # Copy the seed script into the container, then run it with the plane + # codebase on PYTHONPATH. We don't bind-mount because compose-managed + # containers in CI may not have host paths accessible. + docker cp "$SEED_SCRIPT" plane-ce-plane-api-1:/tmp/seed.py + # shellcheck disable=SC2086 + compose exec -T $token_env -e PYTHONPATH=/code plane-api \ + python /tmp/seed.py +} + +cmd_down() { + log "tearing down Plane CE stack" + compose down -v --remove-orphans +} + +case "${1:-up}" in + up) cmd_up ;; + seed) cmd_seed ;; + down) cmd_down ;; + *) + echo "usage: $0 {up|seed|down}" >&2 + exit 64 + ;; +esac diff --git a/test/e2e-docker/plane-ce/seed.py b/test/e2e-docker/plane-ce/seed.py new file mode 100644 index 0000000..f43d880 --- /dev/null +++ b/test/e2e-docker/plane-ce/seed.py @@ -0,0 +1,227 @@ +"""Bootstrap a Plane CE v1.3.1 instance for the bridge's e2e test. + +Runs inside the plane-api container via: + docker compose exec plane-api python /etc/pfb-seed/seed.py + +Creates: + - the singleton Instance row + InstanceConfiguration entries + - an admin user (uid=pfb-admin, email=pfb-admin@pfb.test) with a known password + - a Workspace ("pfb-ci", slug=pfb-ci) + - a Project ("PFB", identifier="PFB") + - an APIToken with a known value (PFB_PLANE_API_KEY env var, falls back to a generated one) + +Bypasses the /auth/sign-up/ HTML flow (session cookies, CSRF, redirects) and +the UI workspace onboarding flow — both are expensive to drive from a script +and exist for human operators, not bridge bootstrap. Inserting directly +through Django ORM keeps the seed self-contained and idempotent: re-running +the seed is a no-op once everything exists. + +Emits the API token to stdout (last line); the surrounding shell script +captures it for the bridge config + integration test env. +""" +import os +import sys +import uuid + +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") +django.setup() + +from django.contrib.auth.hashers import make_password +from django.utils import timezone + +from plane.db.models import ( + APIToken, + Project, + ProjectIdentifier, + ProjectMember, + User, + Workspace, + WorkspaceMember, +) +from plane.license.models import Instance, InstanceAdmin, InstanceEdition + + +ADMIN_EMAIL = "pfb-admin@pfb.test" +ADMIN_PASSWORD = "pfb-admin-pass-ci-only" +ADMIN_USERNAME = "pfb-admin" +WORKSPACE_NAME = "pfb-ci" +WORKSPACE_SLUG = "pfb-ci" +PROJECT_NAME = "PFB Bridge E2E" +PROJECT_IDENTIFIER = "PFB" +TOKEN_LABEL = "pfb-bridge-e2e" + + +def ensure_instance() -> Instance: + instance = Instance.objects.first() + if instance is None: + instance = Instance.objects.create( + instance_name="Plane CE - pfb e2e", + instance_id=uuid.uuid4().hex[:24], + current_version=os.environ.get("APP_VERSION", "v1.3.1"), + latest_version=os.environ.get("APP_VERSION", "v1.3.1"), + last_checked_at=timezone.now(), + is_test=True, + edition=InstanceEdition.PLANE_COMMUNITY.value, + ) + if not instance.is_setup_done: + instance.is_setup_done = True + instance.save(update_fields=["is_setup_done"]) + return instance + + +def ensure_admin_user() -> User: + user, created = User.objects.get_or_create( + email=ADMIN_EMAIL, + defaults={ + "username": ADMIN_USERNAME, + "password": make_password(ADMIN_PASSWORD), + "is_active": True, + "is_email_verified": True, + "first_name": "PFB", + "last_name": "Admin", + "display_name": ADMIN_USERNAME, + }, + ) + if created: + # Newly created users land without the password hash bit being + # marked "set" — fix that up so downstream auth flows work. + user.set_password(ADMIN_PASSWORD) + user.is_password_autoset = False + user.save() + return user + + +def ensure_instance_admin(instance: Instance, user: User) -> None: + InstanceAdmin.objects.get_or_create( + instance=instance, + user=user, + defaults={"role": 20}, # ROLE = Owner; see plane.license.models + ) + + +def ensure_workspace(owner: User) -> Workspace: + workspace, created = Workspace.objects.get_or_create( + slug=WORKSPACE_SLUG, + defaults={ + "name": WORKSPACE_NAME, + "owner": owner, + "organization_size": "Just myself", + }, + ) + WorkspaceMember.objects.get_or_create( + workspace=workspace, + member=owner, + defaults={"role": 20}, # Admin + ) + return workspace + + +def ensure_project(workspace: Workspace, owner: User) -> Project: + project, created = Project.objects.get_or_create( + workspace=workspace, + identifier=PROJECT_IDENTIFIER, + defaults={ + "name": PROJECT_NAME, + "created_by": owner, + "updated_by": owner, + "network": 2, # workspace-public + }, + ) + ProjectIdentifier.objects.get_or_create( + workspace=workspace, + name=PROJECT_IDENTIFIER, + defaults={"project": project}, + ) + ProjectMember.objects.get_or_create( + workspace=workspace, + project=project, + member=owner, + defaults={"role": 20, "is_active": True}, + ) + return project + + +def ensure_default_states(project: Project, owner: User) -> None: + """Plane normally seeds default states (Backlog, Todo, In Progress, + Done, Cancelled) on workspace/project creation via a signal. We + create them explicitly so the bridge's state-map lookups have + something to find.""" + from plane.db.models import State + + defaults = [ + ("Backlog", "backlog", "#5e6ad2"), + ("Todo", "unstarted", "#3f76ff"), + ("In Progress", "started", "#f59e0b"), + ("Done", "completed", "#22c55e"), + ("Cancelled", "cancelled", "#ef4444"), + ] + for name, group, color in defaults: + State.objects.get_or_create( + workspace=project.workspace, + project=project, + name=name, + defaults={ + "group": group, + "color": color, + "default": name == "Backlog", + "created_by": owner, + "updated_by": owner, + }, + ) + + +def ensure_api_token(user: User, workspace: Workspace) -> APIToken: + desired_token = os.environ.get("PFB_PLANE_API_KEY", "").strip() + existing = APIToken.objects.filter( + user=user, workspace=workspace, label=TOKEN_LABEL + ).first() + if existing: + if desired_token and existing.token != desired_token: + existing.token = desired_token + existing.save(update_fields=["token"]) + return existing + return APIToken.objects.create( + user=user, + workspace=workspace, + label=TOKEN_LABEL, + description="bridge e2e token (CI only)", + token=desired_token or uuid.uuid4().hex, + user_type=0, + is_active=True, + is_service=False, + allowed_rate_limit="600/minute", + ) + + +def main() -> None: + instance = ensure_instance() + user = ensure_admin_user() + ensure_instance_admin(instance, user) + workspace = ensure_workspace(user) + project = ensure_project(workspace, user) + ensure_default_states(project, user) + token = ensure_api_token(user, workspace) + + sys.stderr.write( + f"seed: instance={instance.instance_id} user={user.email} " + f"workspace_slug={workspace.slug} workspace_id={workspace.id} " + f"project_id={project.id} project_identifier={project.identifier}\n" + ) + # stdout: a single JSON line with everything the caller needs to wire + # the bridge config. Captured by seed.sh. + import json + print(json.dumps({ + "workspace_slug": workspace.slug, + "workspace_id": str(workspace.id), + "project_id": str(project.id), + "project_identifier": project.identifier, + "admin_user_id": str(user.id), + "admin_email": user.email, + "api_token": token.token, + })) + + +if __name__ == "__main__": + main() From eb68d6073d2280d912053cc2bec786ca7ed6ff05 Mon Sep 17 00:00:00 2001 From: Henry Stern Date: Sun, 24 May 2026 06:28:46 -0300 Subject: [PATCH 2/2] ci(pfb-28): e2e-real-plane exercises the built bridge image, not the library Restructures the job per review: drop the library integration test (redundant with `go test ./...` in lint), and instead pull the bridge image produced by build-image, run it as a sibling container on the plane-ce compose network, POST a synthetic Forgejo issues.opened webhook at it, and assert via REST that real Plane has the work item (looked up by external_source/external_id). The publish gate now exercises the actual artifact that's about to ship. Healthcheck fixes for the plane-api service from the first failed CI run: - curl is not in the makeplane/plane-backend image; switch to wget --spider (the image has wget). - localhost inside the container resolves to ::1 first on some rootless podman setups, but gunicorn binds 0.0.0.0:8000 (IPv4 only), so the healthcheck got ECONNREFUSED. Use 127.0.0.1 explicitly. - Add start_period: 60s so the cold-cache gunicorn boot + static collectstatic + bucket creation don't trip the healthcheck before the API is ready to serve. README documents the new flow + the healthcheck rationale + the rootless-podman file-mount quirk that affects local dev (but not CI). Refs PFB-28. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 176 ++++++++++++-- CHANGELOG.md | 12 +- internal/plane/integration_test.go | 229 ------------------- test/e2e-docker/plane-ce/README.md | 86 ++++--- test/e2e-docker/plane-ce/docker-compose.yaml | 15 +- 5 files changed, 227 insertions(+), 291 deletions(-) delete mode 100644 internal/plane/integration_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 37000c9..7c33c63 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -626,60 +626,194 @@ jobs: # Real-Plane round-trip. Brings up a minimum Plane CE v1.3.1 stack # (api+worker+deps, no frontends), seeds an admin user + workspace + # project + API token via direct Django ORM (Plane CE has no headless - # signup flow), and runs the bridge's `plane` package integration - # tests against real Plane over the REST API. Catches the wire-shape - # drift class of bug that produced PFB-22, PFB-24, and PFB-25 — - # those all passed CI because the e2e-docker `plane-stub` happily - # agreed with the bridge's synthetic testdata. + # signup flow), then runs the **bridge image built by build-image** + # against that real Plane and POSTs a synthetic forge issue webhook + # at it. Asserts the bridge translated the webhook into a real work + # item in real Plane (looked up via external_source/_id, the same + # round-trip the production deploy relies on). + # + # Catches the wire-shape drift class of bug that produced PFB-22, + # PFB-24, and PFB-25 — those all passed CI because the e2e-docker + # plane-stub happily agreed with the bridge's synthetic testdata. # # Separate from the e2e-docker matrix: Plane CE boot is ~90s on a # cold cache, and the wire shape doesn't vary by forge flavor, so # running once is enough. e2e-real-plane: name: e2e-real-plane + needs: build-image runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }} timeout-minutes: 20 + env: + PFB_FORGE_WEBHOOK_SECRET: ci-forge-secret-${{ github.run_id }} + PFB_PLANE_API_KEY: ci-plane-token-${{ github.run_id }} + PFB_PLANE_WEBHOOK_SECRET: ci-plane-secret-${{ github.run_id }} steps: - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - name: Log in to ghcr.io + uses: docker/login-action@v3 with: - go-version: '1.26' - cache: true + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Bring up Plane CE v1.3.1 + seed id: seed - env: - # Lock the API token to a known value for grep-ability in - # bridge logs; otherwise seed.py emits a random UUID hex. - PFB_PLANE_API_KEY: ci-plane-token-${{ github.run_id }} run: | set -euo pipefail seed_json=$(bash test/e2e-docker/plane-ce/run.sh up | tail -n1) echo "seed_json=$seed_json" - # Mask the token in any subsequent logs. token=$(echo "$seed_json" | jq -r '.api_token') + project_id=$(echo "$seed_json" | jq -r '.project_id') + workspace_slug=$(echo "$seed_json" | jq -r '.workspace_slug') echo "::add-mask::$token" - # Expose every field as PFB_PLANE_TEST_ for the test. - echo "$seed_json" | jq -r 'to_entries[] | "PFB_PLANE_TEST_\(.key|ascii_upcase)=\(.value)"' \ - >> "$GITHUB_ENV" + # Override the run-id-derived API key with the actual seeded token + # (the seed honours the env hint but we re-export to be sure). + echo "PFB_PLANE_API_KEY=$token" >> "$GITHUB_ENV" + echo "PFB_PLANE_WORKSPACE_SLUG=$workspace_slug" >> "$GITHUB_ENV" + echo "PFB_PLANE_PROJECT_ID=$project_id" >> "$GITHUB_ENV" + + - name: Pull bridge image (with retry against ghcr transient denies) + env: + IMAGE: ${{ needs.build-image.outputs.image }} + run: | + set -euo pipefail + # ghcr.io intermittently returns "denied: denied" on freshly-pushed + # multi-arch manifests for a few seconds after the push. Re-login + # and retry up to 6 times with backoff before giving up. + for i in 1 2 3 4 5 6; do + if docker pull "$IMAGE"; then + echo "pulled after $i attempt(s)"; exit 0 + fi + echo "pull failed; re-authenticating and retrying in $((i*5))s" + echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io \ + -u '${{ github.actor }}' --password-stdin + sleep $((i*5)) + done + echo "ERROR: docker pull never succeeded"; exit 1 + + - name: Write bridge config pointing at real Plane + run: | + set -euo pipefail + # The bridge joins the pfb-e2e-plane-ce network (the one the + # compose creates) so it can address plane-api by service name. + # /api/v1 prefix is part of base_url (matches config.example.yaml). + cat > /tmp/pfb-config.yaml < plane direction the test + # exercises (the bridge only writes to plane). Point at a stub + # URL so config validation passes. + base_url: http://forge.invalid + token_env: PFB_FORGE_TOKEN + webhook_secret_env: PFB_FORGE_WEBHOOK_SECRET + plane: + base_url: http://plane-api:8000/api/v1 + workspace_slug: ${PFB_PLANE_WORKSPACE_SLUG} + api_key_env: PFB_PLANE_API_KEY + webhook_secret_env: PFB_PLANE_WEBHOOK_SECRET + bridge_bot: + forge_username: pfb-bot + plane_member_id: 00000000-0000-0000-0000-000000000000 + links: + - forge_repo: acme/widget + plane_project_id: ${PFB_PLANE_PROJECT_ID} + project_identifier: PFB + state_map: + open: Backlog + closed: Done + pr_state_map: + opened: "In Progress" + merged: "Done" + closed: "Cancelled" + users: {} + YAML - - name: Run integration test against real Plane + - name: Launch bridge on the plane-ce network env: - PFB_PLANE_TEST_BASE_URL: http://localhost:8765/api/v1 + IMAGE: ${{ needs.build-image.outputs.image }} + run: | + set -euo pipefail + docker run -d --name pfb-bridge \ + --network pfb-e2e-plane-ce \ + -p 8080:8080 \ + -v /tmp/pfb-config.yaml:/etc/pfb/config.yaml:ro \ + -e PFB_FORGE_TOKEN=placeholder \ + -e PFB_FORGE_WEBHOOK_SECRET="$PFB_FORGE_WEBHOOK_SECRET" \ + -e PFB_PLANE_API_KEY="$PFB_PLANE_API_KEY" \ + -e PFB_PLANE_WEBHOOK_SECRET="$PFB_PLANE_WEBHOOK_SECRET" \ + "$IMAGE" \ + --config /etc/pfb/config.yaml + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then + echo "bridge healthy after ${i}s"; break + fi + sleep 1 + done + curl -fsS http://127.0.0.1:8080/healthz + + - name: POST synthetic forge issue webhook run: | set -euo pipefail - go test -tags=integration ./internal/plane -run TestIntegration -v -count=1 + body=$(cat internal/forge/testdata/issues_opened.json) + sig=$(printf '%s' "$body" | openssl dgst -sha256 \ + -hmac "$PFB_FORGE_WEBHOOK_SECRET" -hex | awk '{print $NF}') + status=$(curl -sS -o /tmp/resp -w '%{http_code}' \ + -X POST http://127.0.0.1:8080/forge/webhook \ + -H "X-Gitea-Signature: $sig" \ + -H "X-Gitea-Event: issues" \ + -H "X-Gitea-Delivery: e2e-real-plane-${{ github.run_id }}" \ + -H 'Content-Type: application/json' \ + --data-binary "$body") + echo "status=$status body=$(cat /tmp/resp)" + [ "$status" = "202" ] || { echo "ERROR: bridge returned $status"; exit 1; } + + - name: Assert real Plane has the mirrored work item + run: | + set -euo pipefail + # The bridge stamps external_source="forge:acme/widget" and + # external_id="42" on the create. Plane's per-project issues + # endpoint short-circuits to a single get when both are set, + # returning 200 with the bare WorkItem on a hit or 404 on a + # miss. Translator + decode + POST happens asynchronously + # relative to the 202 ack, so poll briefly. + for i in $(seq 1 30); do + http=$(curl -sS -o /tmp/wi.json -w '%{http_code}' \ + -H "X-API-Key: $PFB_PLANE_API_KEY" \ + "http://localhost:8765/api/v1/workspaces/$PFB_PLANE_WORKSPACE_SLUG/projects/$PFB_PLANE_PROJECT_ID/issues/?external_source=forge:acme/widget&external_id=42") + if [ "$http" = "200" ]; then + echo "work item found after ${i}s" + jq '{id, name, sequence_id, external_source, external_id, state}' /tmp/wi.json + # PFB-25 regression check: state must decode, not break the + # bridge. If decode broke, the bridge's POST log would show + # the unmarshal error and no work item would land. + state_id=$(jq -r '.state // empty' /tmp/wi.json) + [ -n "$state_id" ] || { echo "ERROR: state empty in response"; exit 1; } + exit 0 + fi + sleep 2 + done + echo "ERROR: work item never appeared in real Plane" + docker logs pfb-bridge --tail=80 || true + exit 1 + + - name: Bridge logs (always) + if: always() + run: docker logs pfb-bridge --tail=120 || true - name: Plane logs (always) if: always() run: | docker compose -f test/e2e-docker/plane-ce/docker-compose.yaml \ - logs --tail=80 plane-api plane-worker plane-beat-worker || true + logs --tail=80 plane-api plane-worker || true - name: Tear down if: always() - run: bash test/e2e-docker/plane-ce/run.sh down + run: | + docker rm -f pfb-bridge 2>/dev/null || true + bash test/e2e-docker/plane-ce/run.sh down # Publish on push to main (rolling `:latest`) and on v* tag pushes (semver # tags). Never on PRs. Retag the build-image artifact rather than rebuild — diff --git a/CHANGELOG.md b/CHANGELOG.md index b914c28..4b93dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). stack (api+worker+postgres+redis+rabbitmq+minio) via `test/e2e-docker/plane-ce/docker-compose.yaml`, seeds an admin user / workspace / project / API token via direct Django ORM - (`seed.py` — Plane CE has no headless signup flow), and runs the - bridge's `plane` package integration test (`go test - -tags=integration ./internal/plane`) against real Plane over the - REST API. The publish gate now requires this job to pass alongside - `e2e-docker` and `lint`. Goal: future Plane wire-shape changes (the + (`seed.py` — Plane CE has no headless signup flow), then runs the + **bridge image built by `build-image`** against that real Plane and + POSTs a synthetic Forgejo `issues.opened` webhook. Asserts the + resulting work item in real Plane via REST (`external_source` + + `external_id` round-trip) — exactly the path PFB-25 broke in + production. The publish gate now requires this job alongside `lint` + and `e2e-docker`. Goal: future Plane wire-shape changes (the PFB-22 / PFB-24 / PFB-25 class) fail in CI instead of in production — the existing `plane-stub` happily agrees with synthetic testdata, so it can't catch upstream drift on its own. diff --git a/internal/plane/integration_test.go b/internal/plane/integration_test.go deleted file mode 100644 index 8afc1db..0000000 --- a/internal/plane/integration_test.go +++ /dev/null @@ -1,229 +0,0 @@ -//go:build integration - -// Package plane integration tests exercise the bridge's REST client -// against a real Plane CE backend (test/e2e-docker/plane-ce/). The -// build tag keeps them out of `go test ./...` — they only run when -// explicitly opted in: -// -// go test -tags=integration ./internal/plane -run TestIntegration -v -// -// Wire-up: source the JSON the seed emits into env vars, then run. -// -// eval "$(bash test/e2e-docker/plane-ce/run.sh up | tail -n1 | \ -// jq -r 'to_entries[] | "export PFB_PLANE_TEST_\(.key|ascii_upcase)=\(.value)"')" -// export PFB_PLANE_TEST_BASE_URL=http://localhost:8765 -// go test -tags=integration ./internal/plane -run TestIntegration -v -// -// CI does the same wiring inline in .github/workflows/ci.yaml. -// -// Goal: round-trip the plane.Client methods the bridge actually uses -// against a real Plane backend, so wire-shape drift (PFB-22/24/25) -// fails in CI instead of in production. The assertions intentionally -// pin only the fields the bridge consumes — decode is what we care -// about, not full payload parity. -package plane - -import ( - "context" - "net/http" - "os" - "strconv" - "testing" - "time" -) - -func newIntegrationClient(t *testing.T) (*Client, string) { - t.Helper() - base := os.Getenv("PFB_PLANE_TEST_BASE_URL") - slug := os.Getenv("PFB_PLANE_TEST_WORKSPACE_SLUG") - token := os.Getenv("PFB_PLANE_TEST_API_TOKEN") - project := os.Getenv("PFB_PLANE_TEST_PROJECT_ID") - if base == "" || slug == "" || token == "" || project == "" { - t.Skip("PFB_PLANE_TEST_{BASE_URL,WORKSPACE_SLUG,API_TOKEN,PROJECT_ID} not set; " + - "run test/e2e-docker/plane-ce/run.sh up first") - } - c := NewClient(base, slug, token, &http.Client{Timeout: 30 * time.Second}) - return c, project -} - -// TestIntegration_RoundTrip exercises every plane.Client method the -// bridge calls today. It's a single test, not a table, so cleanup -// happens in the right order: the issue → its comments → the labels. -// If Plane CE changes the wire shape of any response the bridge -// decodes, this test fails on the decode, exactly the failure mode -// PFB-25 exposed in production. -func TestIntegration_RoundTrip(t *testing.T) { - c, projectID := newIntegrationClient(t) - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - - // --- ListProjectStates: locked-in shape, used by state mapping --- - states, err := c.ListProjectStates(ctx, projectID) - if err != nil { - t.Fatalf("ListProjectStates: %v", err) - } - if len(states) == 0 { - t.Fatal("ListProjectStates returned no states; seed.py should have created the defaults") - } - var backlogID, doneID string - for _, s := range states { - switch s.Name { - case "Backlog": - backlogID = s.ID - case "Done": - doneID = s.ID - } - } - if backlogID == "" || doneID == "" { - t.Fatalf("missing default states; got %d states", len(states)) - } - - // --- CreateProjectLabel + ListProjectLabels --- - labelName := "pfb-integ-" + strconv.FormatInt(time.Now().UnixNano(), 36) - createdLabel, err := c.CreateProjectLabel(ctx, projectID, CreateLabelRequest{ - Name: labelName, - Color: "#7c3aed", - Description: "round-trip label", - }) - if err != nil { - t.Fatalf("CreateProjectLabel: %v", err) - } - if createdLabel.ID == "" || createdLabel.Name != labelName { - t.Errorf("CreateProjectLabel returned %+v, want non-empty ID + matching name", createdLabel) - } - labels, err := c.ListProjectLabels(ctx, projectID) - if err != nil { - t.Fatalf("ListProjectLabels: %v", err) - } - var foundLabel bool - for _, l := range labels { - if l.ID == createdLabel.ID { - foundLabel = true - break - } - } - if !foundLabel { - t.Errorf("ListProjectLabels missing the just-created label %q", createdLabel.ID) - } - - // --- ListWorkspaceMembers: bridge identity resolver uses this --- - members, err := c.ListWorkspaceMembers(ctx) - if err != nil { - t.Fatalf("ListWorkspaceMembers: %v", err) - } - if len(members) == 0 { - t.Fatal("ListWorkspaceMembers returned no members; seed.py created an admin") - } - if members[0].Email == "" { - t.Errorf("ListWorkspaceMembers returned member with empty Email; identity resolver depends on this") - } - - // --- CreateIssue: this is the PFB-25 failure path --- - // The bridge stamps external_source on every create; using a real - // value here also covers the PFB-27 echo-detection invariant from - // the *creation* side (the bridge's handler now skips inbound - // echoes that carry this prefix). - issueName := "pfb-integ-issue-" + strconv.FormatInt(time.Now().UnixNano(), 36) - created, err := c.CreateIssue(ctx, projectID, CreateIssueRequest{ - Name: issueName, - DescriptionHTML: "

round-trip body

", - StateID: backlogID, - Priority: "low", - Labels: []string{createdLabel.ID}, - ExternalSource: "forge:acme/widgets", - ExternalID: "42", - }) - if err != nil { - t.Fatalf("CreateIssue: %v", err) - } - if created.ID == "" || created.SequenceID == 0 { - t.Errorf("CreateIssue returned %+v, want populated ID + SequenceID", created) - } - // PFB-25 specifically: state must decode from the bare UUID Plane - // REST returns. PFB-24 specifically: webhooks come back as objects; - // this test covers the REST path. - if created.State.ID != backlogID { - t.Errorf("State.ID = %q, want %q (PFB-25 regression if decode fails)", - created.State.ID, backlogID) - } - if len(created.Labels) != 1 || created.Labels[0].ID != createdLabel.ID { - t.Errorf("Labels = %+v, want [{ID:%s}]", created.Labels, createdLabel.ID) - } - - // --- GetIssue: independent decode path --- - got, err := c.GetIssue(ctx, projectID, created.ID) - if err != nil { - t.Fatalf("GetIssue: %v", err) - } - if got.Name != issueName || got.State.ID != backlogID { - t.Errorf("GetIssue mismatch: name=%q state=%q", got.Name, got.State.ID) - } - - // --- UpdateIssue: PATCH response decode --- - newName := issueName + " (updated)" - updated, err := c.UpdateIssue(ctx, projectID, created.ID, UpdateIssueRequest{ - Name: &newName, - StateID: &doneID, - }) - if err != nil { - t.Fatalf("UpdateIssue: %v", err) - } - if updated.Name != newName || updated.State.ID != doneID { - t.Errorf("UpdateIssue mismatch: name=%q state=%q", updated.Name, updated.State.ID) - } - - // --- GetIssueByExternalRef: bridge's reverse-lookup path --- - found, err := c.GetIssueByExternalRef(ctx, projectID, "forge:acme/widgets", "42") - if err != nil { - t.Fatalf("GetIssueByExternalRef: %v", err) - } - if found.ID != created.ID { - t.Errorf("GetIssueByExternalRef returned %q, want %q", found.ID, created.ID) - } - - // --- GetIssueByExternalRef on a non-existent pair returns ErrNotFound --- - missing, err := c.GetIssueByExternalRef(ctx, projectID, "forge:nope/nope", "0") - if err == nil || err != ErrNotFound { - t.Errorf("GetIssueByExternalRef(missing) = (%v, %v), want (nil, ErrNotFound)", missing, err) - } - - // --- CreateComment / UpdateComment / DeleteComment --- - createdComment, err := c.CreateComment(ctx, projectID, created.ID, CreateCommentRequest{ - CommentHTML: "

round-trip comment ", - Access: "EXTERNAL", - }) - if err != nil { - t.Fatalf("CreateComment: %v", err) - } - if createdComment.ID == "" || createdComment.IssueID != created.ID { - t.Errorf("CreateComment returned %+v", createdComment) - } - - patched, err := c.UpdateComment(ctx, projectID, created.ID, createdComment.ID, UpdateCommentRequest{ - CommentHTML: pStr("

edited body

"), - }) - if err != nil { - t.Fatalf("UpdateComment: %v", err) - } - if patched.CommentHTML == "" { - t.Errorf("UpdateComment dropped comment_html: %+v", patched) - } - - if err := c.DeleteComment(ctx, projectID, created.ID, createdComment.ID); err != nil { - t.Fatalf("DeleteComment: %v", err) - } - // Re-delete must be ErrNotFound, not an opaque error. - if err := c.DeleteComment(ctx, projectID, created.ID, createdComment.ID); err != ErrNotFound { - t.Errorf("DeleteComment(already deleted) = %v, want ErrNotFound", err) - } - - // --- Cleanup: the issue was created with external_source so the - // next test run finds it via GetIssueByExternalRef. We DON'T delete - // it from REST — Plane CE deletes propagate to its webhook system, - // and leaving a residual issue is harmless (next CI run re-uses - // the same external_id and reconciles via UpdateIssue). The label - // stays too. Tests run against an ephemeral DB in CI so nothing - // accumulates across runs. -} - -func pStr(s string) *string { return &s } diff --git a/test/e2e-docker/plane-ce/README.md b/test/e2e-docker/plane-ce/README.md index 4219927..83ca061 100644 --- a/test/e2e-docker/plane-ce/README.md +++ b/test/e2e-docker/plane-ce/README.md @@ -1,11 +1,11 @@ # test/e2e-docker/plane-ce — real Plane CE in CI A minimal Plane CE v1.3.1 stack the bridge spins up to round-trip a -real REST + webhook exchange against the actual Plane backend (not the -recording stub). This is the structural fix from PFB-28 for the class -of bug that produced PFB-22, PFB-24, and PFB-25 — testdata that -diverged from real Plane wire shape, with the existing e2e stub happy -to agree with synthetic data. +real forge webhook → translator → POST `/issues/` → assertion exchange +against the actual Plane backend (not the recording stub). This is the +structural fix from PFB-28 for the class of bug that produced PFB-22, +PFB-24, and PFB-25 — testdata that diverged from real Plane wire +shape, with the existing e2e stub happy to agree with synthetic data. ## What runs @@ -26,6 +26,30 @@ Trimmed from the upstream `makeplane/plane` compose by dropping the frontends (web, space, admin, live), the proxy, and all persistent volumes — none are needed for headless REST + webhook coverage. +## What CI does + +The `e2e-real-plane` job in `.github/workflows/ci.yaml`: + +1. Brings up this stack via `run.sh up` (~90s on a cold cache) +2. Seeds an admin user + workspace + project + API token via direct + Django ORM (see `seed.py`) +3. Pulls **the bridge image built by `build-image`** (not the source — + this is the artifact that's about to be published) +4. Runs that image as a sibling container on the `pfb-e2e-plane-ce` + network, configured to talk to `plane-api:8000/api/v1` +5. POSTs a synthetic Forgejo `issues.opened` webhook + (`internal/forge/testdata/issues_opened.json`) at the bridge +6. Polls real Plane via REST for the work item the bridge should + have created, looking it up by `external_source=forge:acme/widget` + + `external_id=42`. A 200 with a populated `state` field proves + the full round-trip (forge webhook → bridge decode → translator → + bridge POST → real Plane decode → REST GET round-trip) — exactly + the path PFB-25 broke in production. +7. Tears down on success or failure (`if: always()`). + +The `publish` job depends on `e2e-real-plane` succeeding, alongside +`lint` and `e2e-docker`. + ## Why these versions The Plane backend is pinned to `v1.3.1` because that is the version @@ -48,15 +72,13 @@ drift them independently. ```bash # Bring the stack up + seed admin/workspace/project/token (~90s cold cache): bash test/e2e-docker/plane-ce/run.sh up +# Last stdout line is JSON: +# {"workspace_slug":"pfb-ci","project_id":"...","api_token":"...", ...} -# The last stdout line is a JSON record with all the connection details: -# {"workspace_slug": "pfb-ci", "project_id": "...", "api_token": "...", ...} - -# Source it into env vars and run the integration test: -eval "$(bash test/e2e-docker/plane-ce/run.sh seed 2>/dev/null | tail -n1 \ - | jq -r 'to_entries[] | "export PFB_PLANE_TEST_\(.key|ascii_upcase)=\(.value)"')" -export PFB_PLANE_TEST_BASE_URL=http://localhost:8765/api/v1 -go test -tags=integration ./internal/plane -run TestIntegration -v +# Inspect the seeded REST surface: +TOKEN= +curl -H "X-API-Key: $TOKEN" \ + http://localhost:8765/api/v1/workspaces/pfb-ci/projects/ | jq # Tear down: bash test/e2e-docker/plane-ce/run.sh down @@ -66,6 +88,12 @@ The seed is idempotent — re-running it returns the same workspace, project, and token; pass `PFB_PLANE_API_KEY=` to lock the token to a stable string (CI does this for log grep-ability). +To exercise the full bridge round-trip locally, follow the same +sequence the CI job does (compose up + seed → run bridge image with +config pointing at `plane-api:8000` on the compose network → POST +signed webhook → assert via REST). See the `e2e-real-plane` block in +`.github/workflows/ci.yaml` for the exact commands. + ### Podman wart On rootless podman, `rabbitmq:3.13.6-management-alpine` fails to start @@ -75,20 +103,19 @@ the in-image `/var/lib/rabbitmq/` (owned by uid 100). The compose pins `user: "100:101"` on the rabbitmq service to work around this. Docker (in CI) is unaffected either way. -## CI integration +Separately, rootless podman occasionally interprets `-v file:file` +bind mounts as directories (the destination doesn't exist yet, so +podman creates a directory at that path before the source is mounted). +The Linux Docker daemon in GitHub Actions doesn't have this quirk; +the e2e-real-plane CI job uses the same `-v config.yaml:...` pattern +the existing e2e-docker job uses without issue. -`.github/workflows/ci.yaml` defines an `e2e-real-plane` job that: +## Why the healthcheck uses 127.0.0.1, not localhost -1. Brings up this stack via `run.sh up` -2. Sources the seed JSON into env vars -3. Runs `go test -tags=integration ./internal/plane -run TestIntegration` -4. Tears down via `run.sh down` (always — `if: always()`) - -The job is **separate from** the existing `e2e-docker` matrix to keep -the marginal CI time scoped: real Plane boot is ~90s on a cold cache, -and the value of running the integration test twice (once per forge -flavor) is low — the wire shape doesn't depend on which forge is -upstream. +`plane-api` gunicorn binds `0.0.0.0:8000` (IPv4 only). On some rootless +podman setups, `localhost` inside the container resolves to `::1` +first, and the wget healthcheck gets ECONNREFUSED. `127.0.0.1` is +unambiguous and works across Docker and Podman. ## Bootstrap details (seed.py) @@ -111,12 +138,3 @@ directly through Django ORM: - `APIToken` with a known value (env override or random UUID hex) Every step is `get_or_create`, so the seed is safe to re-run. - -## Integration test coverage - -`internal/plane/integration_test.go` (build tag `integration`) calls -every `plane.Client` method the bridge uses today and asserts the -high-value fields decoded. The PFB-25 regression mode (REST `state` -field as a bare UUID) is specifically pinned at the -`CreateIssue` / `GetIssue` / `UpdateIssue` round-trip points, and -PFB-27's external_source echo path is exercised on the create side. diff --git a/test/e2e-docker/plane-ce/docker-compose.yaml b/test/e2e-docker/plane-ce/docker-compose.yaml index 1c5a1a9..0a3ef0f 100644 --- a/test/e2e-docker/plane-ce/docker-compose.yaml +++ b/test/e2e-docker/plane-ce/docker-compose.yaml @@ -148,10 +148,21 @@ services: # override via `services.plane-api.ports`. - "8765:8000" healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/instances/"] + # plane-backend image has wget but no curl. /api/instances/ returns + # 200 even before register_instance — Plane always renders the + # Instance row (or its absence) as JSON. + # + # Use 127.0.0.1, not localhost: gunicorn binds 0.0.0.0:8000 (IPv4 + # only) and on some rootless podman setups `localhost` inside the + # container resolves to ::1, which fails ECONNREFUSED. 127.0.0.1 + # is unambiguous across docker/podman/CI. + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8000/api/instances/"] interval: 5s - timeout: 3s + timeout: 5s retries: 30 + # Cold-cache gunicorn boot is ~30s; static collectstatic + + # bucket creation adds more. Don't fail-healthy before ~60s. + start_period: 60s plane-worker: image: makeplane/plane-backend:v1.3.1