Skip to content

[bug]: Issue link metadata is always overwritten on update — crawl_work_item_link_title runs unconditionally in partial_update #9674

Description

@0x-Yang

Is there an existing issue for this?

  • I have searched the existing issues

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.pypartial_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.

  1. Add a link to any work item and let the crawler fill metadata.
  2. Clear it: PATCH /api/workspaces/{slug}/projects/{project}/issues/{issue}/issue-links/{link}/
    with {"metadata": {}} → 200, and the response body shows metadata: {}.
  3. Read the link back immediately → metadata is empty.
  4. 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:

  1. PATCH the link with only {"title": "<any display name>"}, leaving url unchanged.
  2. 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

Metadata

Metadata

Assignees

Labels

planesync issues to Plane🐛bugSomething isn't working

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions