From d6d3cd3ae3234b8ea26990cfebad78e36368e0ea Mon Sep 17 00:00:00 2001 From: Justus <15849513+JustAmply@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:20:39 +0100 Subject: [PATCH] fix: parse work item relation references from Plane CE --- .github/workflows/test.yml | 5 ++ README.md | 7 ++ plane/models/work_items.py | 43 ++++++----- tests/test_work_item_relation_response.py | 93 +++++++++++++++++++++++ 4 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 tests/test_work_item_relation_response.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24be1ef..5099c24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,11 @@ jobs: - name: Unit tests (tests/v2, excluding integration) run: pytest tests/v2 --ignore=tests/v2/integration -q + - name: V1 relation response contract + run: | + ruff check plane/models/work_items.py tests/test_work_item_relation_response.py + pytest tests/test_work_item_relation_response.py -q + - name: Integration tests (tests/v2/integration) # No PLANE_* env vars are set in CI, so every test here must skip via the # repo's env-var skip gate rather than run against a live API. A run that diff --git a/README.md b/README.md index 536a156..269afeb 100644 --- a/README.md +++ b/README.md @@ -761,6 +761,13 @@ client.work_items.links.delete(workspace_slug, project_id, work_item_id, link_id # Relations relations = client.work_items.relations.list(workspace_slug, project_id, work_item_id) +# Community Edition returns references with both the target and its project. +# Bare-ID entries remain supported for compatibility with the existing SDK shape. +for target in relations.blocked_by: + if isinstance(target, str): + print(target) + else: + print(target.project_id, target.issue_id) relation = client.work_items.relations.create(workspace_slug, project_id, work_item_id, data) # Activities diff --git a/plane/models/work_items.py b/plane/models/work_items.py index 63c4288..2a8a2b2 100644 --- a/plane/models/work_items.py +++ b/plane/models/work_items.py @@ -493,42 +493,51 @@ class RemoveWorkItemRelation(BaseModel): ) +class WorkItemRelationReference(BaseModel): + """Related work item and its project, as returned by the v1 relations endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + project_id: str + issue_id: str + + class WorkItemRelationResponse(BaseModel): - """Response model for work item relations.""" + """Relations grouped by type, accepting references and the existing bare-ID shape.""" model_config = ConfigDict(extra="allow", populate_by_name=True) - blocking: list[str] = Field( + blocking: list[WorkItemRelationReference | str] = Field( ..., - description="List of issue IDs that are blocking this issue", + description="Work items blocked by this work item (references or bare IDs)", ) - blocked_by: list[str] = Field( + blocked_by: list[WorkItemRelationReference | str] = Field( ..., - description="List of issue IDs that this issue is blocked by", + description="Work items blocking this work item (references or bare IDs)", ) - duplicate: list[str] = Field( + duplicate: list[WorkItemRelationReference | str] = Field( ..., - description="List of issue IDs that are duplicates of this issue", + description="Duplicate work items (references or bare IDs)", ) - relates_to: list[str] = Field( + relates_to: list[WorkItemRelationReference | str] = Field( ..., - description="List of issue IDs that relate to this issue", + description="Related work items (references or bare IDs)", ) - start_after: list[str] = Field( + start_after: list[WorkItemRelationReference | str] = Field( ..., - description="List of issue IDs that start after this issue", + description="Work items in the start_after group (references or bare IDs)", ) - start_before: list[str] = Field( + start_before: list[WorkItemRelationReference | str] = Field( ..., - description="List of issue IDs that start before this issue", + description="Work items in the start_before group (references or bare IDs)", ) - finish_after: list[str] = Field( + finish_after: list[WorkItemRelationReference | str] = Field( ..., - description="List of issue IDs that finish after this issue", + description="Work items in the finish_after group (references or bare IDs)", ) - finish_before: list[str] = Field( + finish_before: list[WorkItemRelationReference | str] = Field( ..., - description="List of issue IDs that finish before this issue", + description="Work items in the finish_before group (references or bare IDs)", ) diff --git a/tests/test_work_item_relation_response.py b/tests/test_work_item_relation_response.py new file mode 100644 index 0000000..7f78e72 --- /dev/null +++ b/tests/test_work_item_relation_response.py @@ -0,0 +1,93 @@ +"""Offline contract tests for the v1 /relations/ response used by Plane CE.""" + +import pytest +import responses +from pydantic import ValidationError + +from plane.api.work_items.relations import WorkItemRelations +from plane.config import Configuration +from plane.errors.errors import HttpError +from plane.models.work_items import WorkItemRelationResponse + +GROUPS = ( + "blocking", + "blocked_by", + "duplicate", + "relates_to", + "start_after", + "start_before", + "finish_after", + "finish_before", +) +BASE = "https://plane.example.com" +URL = f"{BASE}/api/v1/workspaces/acme/projects/source-project/work-items/source-item/relations/" +TARGET = {"project_id": "other-project", "issue_id": "related-item"} + + +@pytest.fixture +def relations() -> WorkItemRelations: + return WorkItemRelations(Configuration(base_path=BASE, api_key="test-key")) + + +@pytest.mark.parametrize("group", GROUPS) +@responses.activate +def test_list_preserves_related_item_and_project(relations: WorkItemRelations, group: str) -> None: + # CE's IssueRelationListCreateAPIEndpoint returns references, not bare IDs. + payload = {key: [TARGET] if key == group else [] for key in GROUPS} + responses.get(URL, json=payload) + + result = relations.list("acme", "source-project", "source-item") + + target = getattr(result, group)[0] + assert target.issue_id == "related-item" + assert target.project_id == "other-project" + assert result.model_dump() == payload + assert len(responses.calls) == 1 + + +@responses.activate +def test_list_empty_groups(relations: WorkItemRelations) -> None: + payload = {key: [] for key in GROUPS} + responses.get(URL, json=payload) + + result = relations.list("acme", "source-project", "source-item") + + assert result.model_dump() == payload + + +def test_bare_ids_remain_supported() -> None: + payload = {key: ["related-item"] for key in GROUPS} + + result = WorkItemRelationResponse.model_validate(payload) + + assert result.model_dump() == payload + assert result.blocking == ["related-item"] + + +def test_reference_preserves_additional_response_fields() -> None: + payload = {key: [] for key in GROUPS} + payload["blocking"] = [{**TARGET, "identifier": "OTHER-42"}] + + result = WorkItemRelationResponse.model_validate(payload) + + assert result.model_dump() == payload + + +@pytest.mark.parametrize("target", [{"project_id": "p"}, {"issue_id": "i"}, 42, None]) +def test_malformed_reference_is_rejected(target: object) -> None: + payload = {key: [] for key in GROUPS} + payload["blocking"] = [target] + + with pytest.raises(ValidationError): + WorkItemRelationResponse.model_validate(payload) + + +@pytest.mark.parametrize("status", [401, 403, 404, 500]) +@responses.activate +def test_http_errors_are_not_empty_relations(relations: WorkItemRelations, status: int) -> None: + responses.get(URL, status=status, json={"error": "Request failed."}) + + with pytest.raises(HttpError) as error: + relations.list("acme", "source-project", "source-item") + + assert error.value.status_code == status