Skip to content

[SILO-1087] feat: Added IssueRelations external API#8763

Open
Saurabhkmr98 wants to merge 2 commits intopreviewfrom
feat-issue_relations_api
Open

[SILO-1087] feat: Added IssueRelations external API#8763
Saurabhkmr98 wants to merge 2 commits intopreviewfrom
feat-issue_relations_api

Conversation

@Saurabhkmr98
Copy link
Member

@Saurabhkmr98 Saurabhkmr98 commented Mar 16, 2026

Description

  • Introduces the work item relations API, including serializers, permissions, and OpenAPI documentation for list/create operations.
  • Added serializers for issue relation create and response payloads.
  • Implemented relation list/create API behavior with proper permissions and query handling.
  • Added work_item_relation_docs decorator and exported it for OpenAPI tagging and defaults.

URL - /api/v1/workspaces/{slug}/projects/{project_id}/work-items/{issue_id}/relations/

POST endpoint

Sample Payload

  {
    "relation_type": "blocking",
    "issues": [
      "550e8400-e29b-41d4-a716-446655440000",
      "550e8400-e29b-41d4-a716-446655440001"
    ]
  }

Sample Response

  [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Fix authentication bug",
      "sequence_id": 42,
      "project_id": "550e8400-e29b-41d4-a716-446655440001",
      "relation_type": "blocked_by",
      "state_id": "550e8400-e29b-41d4-a716-446655440002",
      "priority": "high",
      "type_id": "550e8400-e29b-41d4-a716-446655440003",
      "is_epic": false,
      "created_at": "2024-01-15T10:00:00Z",
      "updated_at": "2024-01-15T10:00:00Z",
      "created_by": "550e8400-e29b-41d4-a716-446655440004",
      "updated_by": "550e8400-e29b-41d4-a716-446655440004"
    }
  ]

GET endpoint

Sample response

{
    "blocking": [
      "550e8400-e29b-41d4-a716-446655440000",
      "550e8400-e29b-41d4-a716-446655440001"
    ],
    "blocked_by": ["550e8400-e29b-41d4-a716-446655440002"],
    "duplicate": [],
    "relates_to": ["550e8400-e29b-41d4-a716-446655440003"],
    "start_after": [],
    "start_before": ["550e8400-e29b-41d4-a716-446655440004"],
    "finish_after": [],
    "finish_before": []
  }

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Feature (non-breaking change which adds functionality)
  • Improvement (change that would cause existing functionality to not work as expected)
  • Code refactoring
  • Performance improvements
  • Documentation update

Screenshots and Media (if applicable)

Test Scenarios

References

Summary by CodeRabbit

  • New Features

    • Work item relationship management: create and list relationships between work items.
    • Support for multiple relation types (blocking, blocked_by, duplicate, relates_to, start/finish before/after).
    • Responses return relations grouped by type and support bulk creation.
  • Documentation

    • Added OpenAPI documentation for work item relations endpoints.

@makeplane
Copy link

makeplane bot commented Mar 16, 2026

Linked to Plane Work Item(s)

This comment was auto-generated by Plane

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

📝 Walkthrough

Walkthrough

Adds issue-relation support: new serializers, a GET/POST API endpoint to list and bulk-create typed relations, URL routing, and OpenAPI decorator for relation endpoints.

Changes

Cohort / File(s) Summary
Serializers
apps/api/plane/api/serializers/__init__.py, apps/api/plane/api/serializers/issue.py
Introduced IssueRelationCreateSerializer, IssueRelationResponseSerializer, IssueRelationRemoveSerializer, IssueRelationSerializer, and RelatedIssueSerializer; exported the new serializers in package __init__.py.
API views
apps/api/plane/api/views/issue.py, apps/api/plane/api/views/__init__.py
Added IssueRelationListCreateAPIEndpoint with GET (aggregates relations by type using ArrayAgg/Coalesce) and POST (validates, computes actual relation, bulk-creates with ignore_conflicts, logs activity, re-fetches and serializes results). Exported the endpoint in views __init__.
Routing
apps/api/plane/api/urls/work_item.py
Added route workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/ mapped to the new endpoint allowing GET and POST.
OpenAPI docs
apps/api/plane/utils/openapi/__init__.py, apps/api/plane/utils/openapi/decorators.py
Added work_item_relation_docs decorator (default tag "Work Item Relations", required workspace/project params, standard error responses) and exported it in the openapi package.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Endpoint as IssueRelationListCreateAPIEndpoint
    participant DB as Database
    participant Serializer as Serializers

    rect rgba(100, 150, 200, 0.5)
    Note over Client,Endpoint: GET /relations/
    Client->>Endpoint: GET /relations/
    Endpoint->>DB: Query IssueRelation with ArrayAgg/Coalesce
    DB-->>Endpoint: Aggregated relation IDs by type
    Endpoint->>Serializer: IssueRelationResponseSerializer
    Serializer-->>Endpoint: Grouped relations payload
    Endpoint-->>Client: 200 OK with grouped relations
    end

    rect rgba(200, 150, 100, 0.5)
    Note over Client,Endpoint: POST /relations/
    Client->>Endpoint: POST /relations/ (relation_type, issue_ids)
    Endpoint->>Serializer: IssueRelationCreateSerializer (validate)
    Serializer-->>Endpoint: Validated data
    Endpoint->>DB: Compute actual_relation, bulk create IssueRelation (ignore_conflicts)
    DB-->>Endpoint: Created/ignored rows
    Endpoint->>DB: Re-fetch created relations with select_related
    DB-->>Endpoint: Enriched relation records
    Endpoint->>Serializer: RelatedIssueSerializer / IssueRelationSerializer
    Serializer-->>Endpoint: Serialized created relations
    Endpoint-->>Client: 201 Created with relation metadata
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 In burrows of code relations weave,
IDs hop, types group, and never leave,
A GET to gather, a POST to sow,
Connections sprout where data flows,
✨ Hop—our relations now nicely grow!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title '[SILO-1087] feat: Added IssueRelations external API' clearly and concisely describes the main feature being added - a new external API for issue relations, matching the changeset's primary objective.
Description check ✅ Passed The PR description is comprehensive and follows the template structure, including a detailed description of changes, type of change (Feature), API endpoints with sample payloads/responses, and references to the related work.
Docstring Coverage ✅ Passed Docstring coverage is 91.67% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat-issue_relations_api
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can use OpenGrep to find security vulnerabilities and bugs across 17+ programming languages.

OpenGrep is compatible with Semgrep configurations. Add an opengrep.yml or semgrep.yml configuration file to your project to enable OpenGrep analysis.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
apps/api/plane/api/views/issue.py (1)

2249-2252: Remove pagination params from relation-list docs (endpoint is not paginated).

get() returns a grouped object, not a paginated list, so cursor/per_page in docs is misleading.

Also applies to: 2283-2330

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/issue.py` around lines 2249 - 2252, Remove the
pagination parameters from the relation-list endpoint documentation because
get() returns a grouped object rather than a paginated list; specifically remove
CURSOR_PARAMETER and PER_PAGE_PARAMETER (and any mentions of
ORDER_BY_PARAMETER/CURSOR usage) from the parameter array where
ISSUE_ID_PARAMETER is used for the relation-list docs referenced around the
get() handler, and do the same cleanup for the second occurrence noted (the
block around the other relation-list docs). Ensure the docs only include
ISSUE_ID_PARAMETER and any relevant non-pagination params so the OpenAPI docs
reflect the non-paginated grouped response.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/plane/api/serializers/issue.py`:
- Around line 533-534: The serializer currently uses PrimaryKeyRelatedField for
the scalar UUID source "related_issue.project_id" (in the project_id field)
which expects a model instance; change the field to
serializers.UUIDField(source="related_issue.project_id", read_only=True) and do
the same for the other occurrence of project_id elsewhere in the file (the
duplicate at the later block around sequence_id), while leaving sequence_id as
serializers.IntegerField(source="related_issue.sequence_id", read_only=True);
ensure both project_id declarations reference the scalar UUID source and are
read_only UUIDField instances.

In `@apps/api/plane/api/views/issue.py`:
- Around line 2391-2418: The bulk_create call
(IssueRelation.objects.bulk_create) can insert relations with issue IDs that
don't belong to the same project/workspace and may raise IntegrityError; before
calling bulk_create, fetch and validate that the source issue_id and every ID in
serializer.validated_data["issues"] exist and belong to the same
Project/workspace (use Issue.objects.filter(pk__in=ids, project_id=project_id,
workspace_id=project.workspace_id) and compare counts or returned IDs), and
return a 400 Response if any IDs are missing/out-of-scope; only then proceed to
build the IssueRelation instances (respecting is_reverse and
get_actual_relation) and call bulk_create with ignore_conflicts.
- Around line 2403-2460: The bulk_create call uses ignore_conflicts=True which
silently skips existing issue-pair rows that have a different relation_type,
then still returns 201 and possibly an empty/partial result; fix by pre-checking
for conflicting existing relations before creating: query IssueRelation for the
same issue/related_issue pairs (use the same logic as refetch_filter but without
relation_type or with exclude(relation_type=actual_relation)) to find rows where
a pair exists with a different relation_type, and if any are found return a 409
response listing the conflicting pairs (or otherwise surface an error) instead
of proceeding to IssueRelation.objects.bulk_create with ignore_conflicts=True;
keep the rest of the flow (refetch_filter, refetched_relations, serializer
selection) unchanged but only execute them after the conflict check passes.

---

Nitpick comments:
In `@apps/api/plane/api/views/issue.py`:
- Around line 2249-2252: Remove the pagination parameters from the relation-list
endpoint documentation because get() returns a grouped object rather than a
paginated list; specifically remove CURSOR_PARAMETER and PER_PAGE_PARAMETER (and
any mentions of ORDER_BY_PARAMETER/CURSOR usage) from the parameter array where
ISSUE_ID_PARAMETER is used for the relation-list docs referenced around the
get() handler, and do the same cleanup for the second occurrence noted (the
block around the other relation-list docs). Ensure the docs only include
ISSUE_ID_PARAMETER and any relevant non-pagination params so the OpenAPI docs
reflect the non-paginated grouped response.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 496d6ae4-e178-48a4-be19-e5a7bd9bb0d2

📥 Commits

Reviewing files that changed from the base of the PR and between 588dc29 and 7fe16b0.

📒 Files selected for processing (7)
  • apps/api/plane/api/serializers/__init__.py
  • apps/api/plane/api/serializers/issue.py
  • apps/api/plane/api/urls/work_item.py
  • apps/api/plane/api/views/__init__.py
  • apps/api/plane/api/views/issue.py
  • apps/api/plane/utils/openapi/__init__.py
  • apps/api/plane/utils/openapi/decorators.py

Comment on lines +2403 to +2460
IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=(issue if is_reverse else issue_id),
related_issue_id=(issue_id if is_reverse else issue),
relation_type=actual_relation,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)

issue_activity.delay(
type="issue_relation.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)

# Re-fetch with select_related to avoid N+1 queries in serializers.
# bulk_create with ignore_conflicts=True may not return PKs,
# so query by the issue/related_issue pairs and relation type.
if is_reverse:
refetch_filter = Q(
issue_id__in=issues,
related_issue_id=issue_id,
relation_type=actual_relation,
)
else:
refetch_filter = Q(
issue_id=issue_id,
related_issue_id__in=issues,
relation_type=actual_relation,
)

refetched_relations = IssueRelation.objects.filter(
refetch_filter,
workspace__slug=slug,
).select_related(
"issue__state",
"related_issue__state",
)

serializer_class = RelatedIssueSerializer if is_reverse else IssueRelationSerializer
return Response(
serializer_class(refetched_relations, many=True).data,
status=status.HTTP_201_CREATED,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ignore_conflicts=True can hide relation-type conflicts and return misleading 201s.

Because uniqueness is on issue-pair (not relation_type), a pre-existing pair with a different type is skipped silently. Current flow still reports success and may return an empty/partial created list.

🔧 Suggested conflict handling
         actual_relation = get_actual_relation(relation_type)
         is_reverse = relation_type in ["blocking", "start_after", "finish_after"]

+        candidate_pairs = [
+            (issue if is_reverse else issue_id, issue_id if is_reverse else issue)
+            for issue in issues
+        ]
+        existing = {
+            (str(r.issue_id), str(r.related_issue_id)): r.relation_type
+            for r in IssueRelation.objects.filter(
+                workspace_id=project.workspace_id,
+                issue_id__in=[p[0] for p in candidate_pairs],
+                related_issue_id__in=[p[1] for p in candidate_pairs],
+            )
+        }
+        conflicts = [
+            {"issue_id": str(i), "related_issue_id": str(r), "existing_relation_type": existing[(str(i), str(r))]}
+            for (i, r) in candidate_pairs
+            if (str(i), str(r)) in existing and existing[(str(i), str(r))] != actual_relation
+        ]
+        if conflicts:
+            return Response(
+                {"error": "Relation already exists with a different type", "conflicts": conflicts},
+                status=status.HTTP_409_CONFLICT,
+            )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/issue.py` around lines 2403 - 2460, The bulk_create
call uses ignore_conflicts=True which silently skips existing issue-pair rows
that have a different relation_type, then still returns 201 and possibly an
empty/partial result; fix by pre-checking for conflicting existing relations
before creating: query IssueRelation for the same issue/related_issue pairs (use
the same logic as refetch_filter but without relation_type or with
exclude(relation_type=actual_relation)) to find rows where a pair exists with a
different relation_type, and if any are found return a 409 response listing the
conflicting pairs (or otherwise surface an error) instead of proceeding to
IssueRelation.objects.bulk_create with ignore_conflicts=True; keep the rest of
the flow (refetch_filter, refetched_relations, serializer selection) unchanged
but only execute them after the conflict check passes.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
apps/api/plane/api/serializers/issue.py (1)

624-626: ⚠️ Potential issue | 🔴 Critical

Use UUIDField for project_id on the reverse serializer.

Line 625 points PrimaryKeyRelatedField at issue.project_id, which is already a UUID scalar. DRF relation fields expect a related object and will try to serialize .pk, so reverse-relation responses can fail here. Switch this to serializers.UUIDField(source="issue.project_id", read_only=True).

💡 Proposed fix
-    project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
+    project_id = serializers.UUIDField(source="issue.project_id", read_only=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/serializers/issue.py` around lines 624 - 626, Change the
serializer field for project_id to use serializers.UUIDField instead of
serializers.PrimaryKeyRelatedField: update the field declaration (currently
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id",
read_only=True)) to project_id =
serializers.UUIDField(source="issue.project_id", read_only=True) so the reverse
serializer emits the UUID scalar from issue.project_id rather than treating it
as a related-object field; leave id and sequence_id declarations unchanged.
🧹 Nitpick comments (1)
apps/api/plane/api/views/issue.py (1)

2442-2448: issue__type is still missing from the refetch query.

Reverse responses serialize issue.type.id and issue.type.is_epic, so this query still does per-row lookups even though the comment says N+1s are being avoided. Add issue__type to select_related() here.

♻️ Proposed fix
         refetched_relations = IssueRelation.objects.filter(
             refetch_filter,
             workspace__slug=slug,
         ).select_related(
+            "issue__type",
             "issue__state",
             "related_issue__state",
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/issue.py` around lines 2442 - 2448, The refetch
query on IssueRelation (refetched_relations =
IssueRelation.objects.filter(...).select_related(...)) omits the related issue
type so reverse responses still trigger per-row lookups; update the
select_related call on IssueRelation to include "issue__type" (alongside the
existing "issue__state" and "related_issue__state") so that issue.type.id and
issue.type.is_epic are fetched in the same query.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/plane/api/views/issue.py`:
- Around line 2394-2412: The bulk_create block can create duplicate logical
relations for symmetric types like "duplicate" and "relates_to" because you only
flip asymmetric types via is_reverse; update the logic around IssueRelation bulk
creation so symmetric relations are normalized or pre-checked: detect when
relation_type is symmetric (e.g., "duplicate", "relates_to"), then for each
candidate pair normalize the order (canonicalize by id or tuple sort) or query
existing IssueRelation rows for either (issue, issue_id) or (issue_id, issue)
and filter out those already present before calling
IssueRelation.objects.bulk_create; keep references to get_actual_relation,
relation_type, is_reverse, issues, and the IssueRelation bulk_create call to
locate and modify the code.
- Around line 2351-2354: The POST 201 OpenApiResponse currently claims
IssueRelationSerializer[] but actually returns IssueRelationSerializer[] or
RelatedIssueSerializer[] depending on the relation_type; update the OpenAPI
response to document both shapes explicitly (or normalize the response to a
single serializer) — e.g., replace the single IssueRelationSerializer(many=True)
with a polymorphic/OneOf response that includes
IssueRelationSerializer(many=True) and RelatedIssueSerializer(many=True) (using
your OpenAPI helper / drf-spectacular OneOf construct), and apply the same
change for the other POST response instance referenced (the one around lines
2450-2452); ensure the relation_type parameter is noted in the operation
description so consumers know which variant will be returned.
- Around line 2297-2300: Ensure the code validates that the requested issue_id
belongs to the route's (slug, project_id) and returns a 404 if not, then
restrict the IssueRelation query to that project: first fetch or get Issue (or
Issue.objects.filter(pk=issue_id, workspace__slug=slug, project__id=project_id))
and raise Http404 if absent, and then build issue_relation_qs using
IssueRelation.objects.filter((Q(issue_id=issue_id) |
Q(related_issue_id=issue_id)), workspace__slug=slug, project__id=project_id) so
relations are limited to the same project; update any variables (e.g.,
issue_relation_qs, issue_id checks) accordingly.

---

Duplicate comments:
In `@apps/api/plane/api/serializers/issue.py`:
- Around line 624-626: Change the serializer field for project_id to use
serializers.UUIDField instead of serializers.PrimaryKeyRelatedField: update the
field declaration (currently project_id =
serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True))
to project_id = serializers.UUIDField(source="issue.project_id", read_only=True)
so the reverse serializer emits the UUID scalar from issue.project_id rather
than treating it as a related-object field; leave id and sequence_id
declarations unchanged.

---

Nitpick comments:
In `@apps/api/plane/api/views/issue.py`:
- Around line 2442-2448: The refetch query on IssueRelation (refetched_relations
= IssueRelation.objects.filter(...).select_related(...)) omits the related issue
type so reverse responses still trigger per-row lookups; update the
select_related call on IssueRelation to include "issue__type" (alongside the
existing "issue__state" and "related_issue__state") so that issue.type.id and
issue.type.is_epic are fetched in the same query.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e6f7dbc2-e245-47be-9c51-7baf4f2c0c63

📥 Commits

Reviewing files that changed from the base of the PR and between 7fe16b0 and 1931ea0.

📒 Files selected for processing (2)
  • apps/api/plane/api/serializers/issue.py
  • apps/api/plane/api/views/issue.py

Comment on lines +2297 to +2300
issue_relation_qs = IssueRelation.objects.filter(
Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
workspace__slug=slug,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Scope the relation lookup to the route's project.

Line 2297 only filters by workspace. Because access is granted at project scope, a member of project A can query relations for an arbitrary issue UUID from project B in the same workspace and get a 200. This method also never returns the documented 404 for a missing or out-of-scope issue. Validate that issue_id belongs to (slug, project_id) first, and keep the relation query scoped to that project.

🛡️ Suggested guardrail
+        if not Issue.issue_objects.filter(
+            id=issue_id,
+            project_id=project_id,
+            workspace__slug=slug,
+        ).exists():
+            return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND)
+
         issue_relation_qs = IssueRelation.objects.filter(
             Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
             workspace__slug=slug,
+            project_id=project_id,
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/issue.py` around lines 2297 - 2300, Ensure the code
validates that the requested issue_id belongs to the route's (slug, project_id)
and returns a 404 if not, then restrict the IssueRelation query to that project:
first fetch or get Issue (or Issue.objects.filter(pk=issue_id,
workspace__slug=slug, project__id=project_id)) and raise Http404 if absent, and
then build issue_relation_qs using
IssueRelation.objects.filter((Q(issue_id=issue_id) |
Q(related_issue_id=issue_id)), workspace__slug=slug, project__id=project_id) so
relations are limited to the same project; update any variables (e.g.,
issue_relation_qs, issue_id checks) accordingly.

Comment on lines +2351 to +2354
201: OpenApiResponse(
description="Work item relations created successfully",
response=IssueRelationSerializer(many=True),
examples=[
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The POST response shape depends on relation_type.

The schema advertises IssueRelationSerializer[], but reverse relation types return RelatedIssueSerializer[] instead. That makes the external API polymorphic without documenting it, and the field set changes based on request input. Align the two serializers or document both response shapes explicitly.

Also applies to: 2450-2452

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/issue.py` around lines 2351 - 2354, The POST 201
OpenApiResponse currently claims IssueRelationSerializer[] but actually returns
IssueRelationSerializer[] or RelatedIssueSerializer[] depending on the
relation_type; update the OpenAPI response to document both shapes explicitly
(or normalize the response to a single serializer) — e.g., replace the single
IssueRelationSerializer(many=True) with a polymorphic/OneOf response that
includes IssueRelationSerializer(many=True) and
RelatedIssueSerializer(many=True) (using your OpenAPI helper / drf-spectacular
OneOf construct), and apply the same change for the other POST response instance
referenced (the one around lines 2450-2452); ensure the relation_type parameter
is noted in the operation description so consumers know which variant will be
returned.

Comment on lines +2394 to +2412
actual_relation = get_actual_relation(relation_type)
is_reverse = relation_type in ["blocking", "start_after", "finish_after"]

IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=(issue if is_reverse else issue_id),
related_issue_id=(issue_id if is_reverse else issue),
relation_type=actual_relation,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent opposite-direction duplicates for symmetric relations.

Line 2395 only flips the asymmetric aliases. For duplicate and relates_to, the stored pair keeps caller order, so A -> B and B -> A bypass the directional unique constraint and create two logical copies of the same relation. Normalize symmetric pairs or pre-check both permutations before bulk_create.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/issue.py` around lines 2394 - 2412, The bulk_create
block can create duplicate logical relations for symmetric types like
"duplicate" and "relates_to" because you only flip asymmetric types via
is_reverse; update the logic around IssueRelation bulk creation so symmetric
relations are normalized or pre-checked: detect when relation_type is symmetric
(e.g., "duplicate", "relates_to"), then for each candidate pair normalize the
order (canonicalize by id or tuple sort) or query existing IssueRelation rows
for either (issue, issue_id) or (issue_id, issue) and filter out those already
present before calling IssueRelation.objects.bulk_create; keep references to
get_actual_relation, relation_type, is_reverse, issues, and the IssueRelation
bulk_create call to locate and modify the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants