Skip to content

REVIEW: Support merging tags through the API - #1

Closed
sethfitz wants to merge 2 commits into
masterfrom
review/api-tag-merge
Closed

REVIEW: Support merging tags through the API#1
sethfitz wants to merge 2 commits into
masterfrom
review/api-tag-merge

Conversation

@sethfitz

Copy link
Copy Markdown
Owner

The proposed upstream PR body is committed as .pr-review/BODY.md — read it in Files
changed
and comment on it line by line, same as the code. It lives only on this review
branch; api-tag-merge (what would be submitted) does not contain it.

Branches: api-tag-merge = the change. review/api-tag-merge = that plus the body file.
Nothing has been pushed to sissbruecker/linkding, and no upstream PR exists.

The call that's yours: send it at all?

The honest alternative is "use the web UI for whole-tag merges and keep the pruner for
everything else." What the API buys is one atomic call in /triage-bookmarks instead of N
per-bookmark PATCHes plus a GC pass — and it removes a class of orphan rather than cleaning
up after it. What it costs is a second PR to the same maintainer in a week, on a smaller need
than tag delete had. I'd send it: the parity argument is the same one sissbruecker#1411 made and he took
it without pushback, and the diff stays small.

Decisions I made, and what I'd change them to if you disagree

POST /api/tags/<id>/merge/ rather than POST /api/tags/merge/. Reasoning is in the
body. The deciding factor was not aesthetics: routing the target through get_object() means
the cross-user 404 comes from the same mechanism as retrieve and delete, so there's no new
ownership check to get wrong. A collection-level endpoint would have to hand-roll it.

Merge tags by ID, not name. The form matches names case-insensitively because it's fed by
autocomplete; an API caller has IDs from GET /api/tags/. If you'd rather it took names —
our own tooling thinks in names — say so and I'll add name support; it's a small change, but
it reopens "what does a name that doesn't resolve mean," which IDs sidestep.

204 No Content. Matches archive/unarchive and tag delete. I flagged the alternative
(return a count of retagged bookmarks) in the body as an offer rather than building it, so
the maintainer can pick.

Scope: 6 files, not sissbruecker#1411's 3

sissbruecker#1411 only had to add a mixin. Merge has a transaction to share, so the block moved from
bookmarks/views/tags.py to bookmarks.services.tags.merge_tags() unchanged — same
queries in the same order — and the view now calls it. That's the refactor you'd have asked
for anyway; the alternative is two copies that drift. services/tags.py already existed and
already held the other tag-write helper, so nothing new was invented to host it.

Verification

Ran the target's gates against its own pinned deps (uv sync first), not ours.

Gate Baseline (upstream/master) With the change
make test (pytest -n auto) 1300 passed, 0 failed 1307 passed, 0 failed
make lint (ruff check) clean clean
ruff format --check 224 formatted 224 formatted

uv sync bumped four unpinned-transitive packages and dirtied uv.lock; reverted, so it's
not in the diff. I deliberately did not run the djlint/prettier halves of make format
they touch templates and frontend, neither of which this change goes near, and running them
would add unrelated churn.

Which of the 7 new tests actually prove something. All 7 fail without the change, but
only because the route doesn't exist (NoReverseMatch) — that's a weak signal, so I mutated
the implementation to check they discriminate:

  • Dropped .exclude(tags=target_tag) from the service → only
    test_merge_tags_keeps_single_relationship_when_bookmark_has_target_tag caught it
    (IntegrityError on the unique through-row). It also caught the web view's
    test_merge_tags_complex, which is the evidence the extraction is behaviour-preserving.
  • Swapped self.get_queryset() for Tag.objects in the merge action → only
    test_can_not_merge_tag_of_other_user caught it (204 instead of 400 — i.e. it merged
    another user's tag).

The other five are boundary/regression guards, not gates.

What I left out

No changelog entry (upstream writes those at release from PR titles). No merge in the
web-UI-facing code beyond the service extraction. No name-based lookup, no response payload,
no bulk/multi-target form.

The tags API supported list, retrieve, create, and delete, but not the
tag merge that the web UI has had since v1.43.0, so a scripted
consolidation could not reach it.

This adds merge as a detail action on the target tag. The merge
transaction moves from the tags view to bookmarks.services.tags
unchanged, so both callers share it.
@sethfitz

Copy link
Copy Markdown
Owner Author

Reviewed with no changes; submitted upstream as sissbruecker#1446. Closing this review branch.

@sethfitz sethfitz closed this Aug 14, 2026
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.

1 participant