Is there an existing issue for this?
Current behavior
What happens
PATCHing an issue link's metadata succeeds (200, and the response echoes what was sent), but the
value is replaced a second or two later. The background crawler runs on every update — even when
the url did not change — and assigns the whole metadata field from what it scraped.
PATCH .../issues/{issue}/issue-links/{link}/ {"metadata": {}}
-> 200, response metadata = {}
-> re-read immediately: metadata.title = None
-> re-read after ~2s: metadata.title = "…" (back to the crawled value)
Expected: a metadata value the caller sent explicitly is kept, and a link whose url did not
change is not re-crawled.
Actual: metadata is always replaced by the crawl result. There is no way to clear or override
it, and no setting to opt out.
Cause
apps/api/plane/app/views/issue/link.py — partial_update:
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url"))
The task is dispatched with no condition. And in
apps/api/plane/bgtasks/work_item_link_task.py the task assigns the field wholesale:
issue_link.metadata = meta_data
issue_link.save()
So any caller-supplied metadata is discarded on the next update, whatever field was actually
being edited.
Suggested fix
partial_update already loads issue_link before saving, so the previous URL is available:
def partial_update(self, request, slug, project_id, issue_id, pk):
issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
requested_data = json.dumps(request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder)
+ previous_url = issue_link.url
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
- crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url"))
+ if serializer.data.get("url") != previous_url:
+ crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url"))
create should keep crawling unconditionally — that is the case where there is nothing to preserve.
Why it matters
- A successful write silently does not stick. The API returns 200 with the new value, so a
client has no way to tell that it will be reverted. We only found it by re-reading twice.
- No opt-out exists. We checked the environment-variable reference end to end; the only
link-related variable is IFRAMELY_REPLICAS, which is a replica count, and that service is not
part of the community docker-compose.yaml at all. There is no conditional in the code either.
- Unnecessary outbound requests. Editing a link's display title re-fetches a URL that did not
change — confirmed by watching updated_at change a second time after a title-only PATCH. Every
such fetch is an outbound request from the worker and, however well guarded by validate_url_ip,
an SSRF surface that did not need to be touched.
- The crawled title is often not useful. For links pointing at Plane's own work items, the
crawler (BeautifulSoup, no JS) only sees the SPA shell, so every such link ends up labelled
Plane | Simple, extensible, open-source project management tool. The caller cannot replace it
with something meaningful.
The title field is not affected — a caller-set display title survives. It is only metadata.
Checked against v1.4.1; the same code path is unchanged in v1.4.2.
Steps to reproduce
Reproducible through the API alone — no UI steps needed.
- Add a link to any work item and let the crawler fill
metadata.
- Clear it:
PATCH /api/workspaces/{slug}/projects/{project}/issues/{issue}/issue-links/{link}/
with {"metadata": {}} → 200, and the response body shows metadata: {}.
- Read the link back immediately →
metadata is empty.
- Read it again after ~2-3 seconds → the crawled value is back.
The same happens when the edited field has nothing to do with the URL:
PATCH the link with only {"title": "<any display name>"}, leaving url unchanged.
- Watch
updated_at: it changes once for the PATCH, then a second time about a second later —
that is the crawl task saving the row again.
A note for step 6, because it cost us a wrong conclusion first: the crawl can finish in well
under a second, so a single "immediately after" read may already be the post-crawl state and make
it look like nothing happened. Poll updated_at once a second rather than sampling twice. We
validated the method with a positive control — the metadata PATCH in steps 2-4, where the crawl
is known to run, does produce the second updated_at change.
Environment
Production
Browser
Other
Variant
Self-hosted
Version
v1.4.1
Is there an existing issue for this?
Current behavior
What happens
PATCHing an issue link'smetadatasucceeds (200, and the response echoes what was sent), but thevalue is replaced a second or two later. The background crawler runs on every update — even when
the
urldid not change — and assigns the wholemetadatafield from what it scraped.Expected: a
metadatavalue the caller sent explicitly is kept, and a link whoseurldid notchange is not re-crawled.
Actual:
metadatais always replaced by the crawl result. There is no way to clear or overrideit, and no setting to opt out.
Cause
apps/api/plane/app/views/issue/link.py—partial_update:The task is dispatched with no condition. And in
apps/api/plane/bgtasks/work_item_link_task.pythe task assigns the field wholesale:So any caller-supplied
metadatais discarded on the next update, whatever field was actuallybeing edited.
Suggested fix
partial_updatealready loadsissue_linkbefore saving, so the previous URL is available:def partial_update(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) + previous_url = issue_link.url serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() - crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url")) + if serializer.data.get("url") != previous_url: + crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url"))createshould keep crawling unconditionally — that is the case where there is nothing to preserve.Why it matters
client has no way to tell that it will be reverted. We only found it by re-reading twice.
link-related variable is
IFRAMELY_REPLICAS, which is a replica count, and that service is notpart of the community
docker-compose.yamlat all. There is no conditional in the code either.change — confirmed by watching
updated_atchange a second time after a title-only PATCH. Everysuch fetch is an outbound request from the worker and, however well guarded by
validate_url_ip,an SSRF surface that did not need to be touched.
crawler (BeautifulSoup, no JS) only sees the SPA shell, so every such link ends up labelled
Plane | Simple, extensible, open-source project management tool.The caller cannot replace itwith something meaningful.
The
titlefield is not affected — a caller-set display title survives. It is onlymetadata.Checked against v1.4.1; the same code path is unchanged in v1.4.2.
Steps to reproduce
Reproducible through the API alone — no UI steps needed.
metadata.PATCH /api/workspaces/{slug}/projects/{project}/issues/{issue}/issue-links/{link}/with
{"metadata": {}}→ 200, and the response body showsmetadata: {}.metadatais empty.The same happens when the edited field has nothing to do with the URL:
PATCHthe link with only{"title": "<any display name>"}, leavingurlunchanged.updated_at: it changes once for the PATCH, then a second time about a second later —that is the crawl task saving the row again.
Environment
Production
Browser
Other
Variant
Self-hosted
Version
v1.4.1