[SILO-1087] feat: Added IssueRelations external API#8763
[SILO-1087] feat: Added IssueRelations external API#8763Saurabhkmr98 wants to merge 2 commits intopreviewfrom
Conversation
|
Linked to Plane Work Item(s) This comment was auto-generated by Plane |
📝 WalkthroughWalkthroughAdds 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
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. Comment Tip CodeRabbit can use OpenGrep to find security vulnerabilities and bugs across 17+ programming languages.OpenGrep is compatible with Semgrep configurations. Add an |
There was a problem hiding this comment.
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, socursor/per_pagein 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
📒 Files selected for processing (7)
apps/api/plane/api/serializers/__init__.pyapps/api/plane/api/serializers/issue.pyapps/api/plane/api/urls/work_item.pyapps/api/plane/api/views/__init__.pyapps/api/plane/api/views/issue.pyapps/api/plane/utils/openapi/__init__.pyapps/api/plane/utils/openapi/decorators.py
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
apps/api/plane/api/serializers/issue.py (1)
624-626:⚠️ Potential issue | 🔴 CriticalUse
UUIDFieldforproject_idon the reverse serializer.Line 625 points
PrimaryKeyRelatedFieldatissue.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 toserializers.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__typeis still missing from the refetch query.Reverse responses serialize
issue.type.idandissue.type.is_epic, so this query still does per-row lookups even though the comment says N+1s are being avoided. Addissue__typetoselect_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
📒 Files selected for processing (2)
apps/api/plane/api/serializers/issue.pyapps/api/plane/api/views/issue.py
| issue_relation_qs = IssueRelation.objects.filter( | ||
| Q(issue_id=issue_id) | Q(related_issue_id=issue_id), | ||
| workspace__slug=slug, | ||
| ) |
There was a problem hiding this comment.
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.
| 201: OpenApiResponse( | ||
| description="Work item relations created successfully", | ||
| response=IssueRelationSerializer(many=True), | ||
| examples=[ |
There was a problem hiding this comment.
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.
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
Description
URL -
/api/v1/workspaces/{slug}/projects/{project_id}/work-items/{issue_id}/relations/POST endpoint
Sample Payload
Sample Response
GET endpoint
Sample response
Type of Change
Screenshots and Media (if applicable)
Test Scenarios
References
Summary by CodeRabbit
New Features
Documentation