Skip to content
Open
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
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 26 additions & 17 deletions plane/models/work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
)


Expand Down
93 changes: 93 additions & 0 deletions tests/test_work_item_relation_response.py
Original file line number Diff line number Diff line change
@@ -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