REVIEW: Support merging tags through the API - #1
Closed
sethfitz wants to merge 2 commits into
Closed
Conversation
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.
Owner
Author
|
Reviewed with no changes; submitted upstream as sissbruecker#1446. Closing this review branch. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The proposed upstream PR body is committed as
.pr-review/BODY.md— read it in Fileschanged 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-bookmarksinstead of Nper-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 thanPOST /api/tags/merge/. Reasoning is in thebody. The deciding factor was not aesthetics: routing the target through
get_object()meansthe 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. Matchesarchive/unarchiveand 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.pytobookmarks.services.tags.merge_tags()unchanged — samequeries 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.pyalready existed andalready 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 syncfirst), not ours.upstream/master)make test(pytest -n auto)make lint(ruff check)ruff format --checkuv syncbumped four unpinned-transitive packages and dirtieduv.lock; reverted, so it'snot in the diff. I deliberately did not run the
djlint/prettierhalves ofmake 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 mutatedthe implementation to check they discriminate:
.exclude(tags=target_tag)from the service → onlytest_merge_tags_keeps_single_relationship_when_bookmark_has_target_tagcaught it(
IntegrityErroron the unique through-row). It also caught the web view'stest_merge_tags_complex, which is the evidence the extraction is behaviour-preserving.self.get_queryset()forTag.objectsin the merge action → onlytest_can_not_merge_tag_of_other_usercaught it (204 instead of 400 — i.e. it mergedanother 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
mergein theweb-UI-facing code beyond the service extraction. No name-based lookup, no response payload,
no bulk/multi-target form.