Skip to content

fix: switch tag removal to index-based JSON Patch ops#30

Merged
thewrz merged 5 commits into
mainfrom
fix/issue-3
Jul 19, 2026
Merged

fix: switch tag removal to index-based JSON Patch ops#30
thewrz merged 5 commits into
mainfrom
fix/issue-3

Conversation

@thewrz

@thewrz thewrz commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Why

AgilePlace's JSON Patch API for card tag removal was operating on tag values rather than array indices, and the write path carried a dead add kwarg that no caller used now that removal has its own function. This left the removal path fragile against duplicate tag values and carried unused API surface.

What

  • Dropped the add kwarg entirely from op_tag — removal now has its own dedicated function, so the combined kwarg was dead surface.
  • Tag removal now builds index-based JSON Patch remove ops instead of value-based ones, removing ALL occurrences of a duplicated tag value rather than only the first.
  • A tag miss now raises ValueError instead of silently no-oping: tracing the data flow confirms both r.ap_remove and stale are subsets of card_tags(card) for the same card snapshot, so a miss means a real bug (stale snapshot, race, etc.), not a legitimate idempotent replay.
  • ops_tag_remove is now called unconditionally (it no-ops safely on empty input), simplifying the call site.
  • Guarded get_card against non-dict JSON responses from AgilePlace.

Design decisions

Followed the pre-supplied design essentially as-is: dropped the add kwarg entirely (dead API surface once remove has its own function); fail-loud ValueError on a tag miss (traced data flow confirms both r.ap_remove and stale are subsets of card_tags(card) for the same card snapshot, so a miss signals a real bug, not idempotent replay); remove ALL occurrences of a duplicated tag value; unconditional call to ops_tag_remove since it no-ops on empty input.

One deviation found during implementation: tests/test_ghkit_labels.py had 3 pre-existing tests hardcoded to the old value-based op shape (path=='/tags', value=tag) — these needed fixing (not anticipated in the todos, which only mentioned test_agileplace.py). Added a _removed_tag_names test helper there to map index-based ops back to tag names for assertions; one of the three was a genuine behavioral check that failed until fixed, not merely cosmetic.

Testing

  • Unit tests pass
  • Integration tests pass
  • Manual verification: N/A (library-level change, covered by unit/integration suite)
  • CI green

🤖 Co-authored by Claude Sonnet 5. Closes #3

Summary by CodeRabbit

  • Bug Fixes

    • Improved tag synchronization to apply RFC-6902 index-based tag removals safely and in descending index order, including correct handling of duplicate tag values.
    • Added protection against refetching when tag lists have shifted and index-based removals are present.
    • Fail fast with clearer errors when the API returns unexpected card payload shapes.
  • Documentation

    • Updated API validation guidance for tag removal to reflect {op:"remove", path:"/tags/{i}"} semantics and ordering rules.
  • Tests

    • Added unit tests for tag patch operation shapes, removal ordering, missing-tag errors, and “tag snapshot” safety behavior.
    • Strengthened validation tests for multiple invalid API response formats and ensured sync operations don’t mutate inputs.

thewrz and others added 4 commits July 18, 2026 21:47
Replace the undocumented value-based tag removal
({"op":"remove","path":"/tags","value":tag}) with standard RFC-6902
index-based removal. No public LeanKit docs describe value-based
removal, and RFC 6902's remove op has no `value` member -- only an
index-based path. Indices are computed from the card's raw tags
array and removed in descending order within the same batched PATCH
so earlier removals never shift later ops' target indices.

op_tag is now add-only (its `add` kwarg is dropped, not kept as dead
API surface); a new ops_tag_remove builds the index-based remove ops
and raises loudly if a name isn't found on the card, since that can
only mean an upstream invariant broke. sync.py's tag-ops assembly now
accumulates removal names into one set and defers to ops_tag_remove
for the batch, instead of building remove ops per-name inline.

API-VALIDATION.md's tag-removal entry is corrected: the index-based
form is now documented as the implemented behavior, and the earlier
unsupported "LeanKit's documented value-based removal" citation is
removed. A live add-then-remove round-trip on a disposable card still
needs a human to run it -- not attempted here (no live AgilePlace
credentials available/authorized for this task).

Closes #3

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds three tests targeting sync_metadata's tag-ops assembly block
(issue #3): the card input is never mutated (both an add and a remove
op fire in the same pass without writing back into card), the
tags_to_remove accumulator is a fresh local set on every call (a
removal computed for one issue's card cannot leak into the next
call's queued ops), and a pass needing both a tag add and a tag remove
combines them into exactly one queue() call so they land in a single
versioned PATCH per card instead of being fragmented across two calls.

Each test was confirmed to fail for the right reason against a
deliberately reintroduced regression (in-place tags mutation; a split
queue() call) before being left green against the current
implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a regression test asserting op_tag("foo", add=False) raises
TypeError. Without it, reintroducing a def op_tag(tag, add=True) with
a value-based remove branch would default every existing call site
(sync.py's two callers and the existing append-op test) to add=True
and pass silently, resurrecting the undocumented value-based
{"op":"remove","path":"/tags","value":tag} shape issue #3 removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
get_card() only checked for None before calling data.get("card", data),
so any non-dict, non-null AgilePlace GET response (a bare list, string,
number, or bool) crashed with an opaque AttributeError instead of the
intended SystemExit. Since the single-card GET's exact shape is
unconfirmed (VALIDATE LIVE), this was a plausible live failure mode
reached from every PATCH via _card_with_version's refetch.

Validate both the top-level response and the unwrapped card value are
dicts, failing loud with the card id and observed type otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4357792f-0b17-4615-8f8c-1564cb641f60

📥 Commits

Reviewing files that changed from the base of the PR and between 6cb3233 and a61cd0a.

📒 Files selected for processing (5)
  • API-VALIDATION.md
  • agileplace.py
  • sync.py
  • tests/test_agileplace.py
  • tests/test_ghkit_labels.py

📝 Walkthrough

Walkthrough

Changes

The patch validates AgilePlace card responses, switches tag removals to descending RFC-6902 index operations, protects against stale tag snapshots, batches sync updates, and expands documentation and tests.

Tag removal and validation

Layer / File(s) Summary
Strict card response validation
agileplace.py, tests/test_agileplace.py
get_card rejects null and non-dict response shapes, with expanded failure tests.
Index-based tag patch builders
agileplace.py, tests/test_agileplace.py, API-VALIDATION.md
Tag additions use /tags/-; removals use descending /tags/{i} operations without value, with stale-snapshot protection, tests, and documentation.
Batched sync tag operations
sync.py, tests/test_ghkit_labels.py
sync_metadata combines additions and removals into one patch while preserving input immutability and per-call state isolation.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant sync_metadata
  participant agileplace_ops
  participant AgilePlace
  sync_metadata->>agileplace_ops: Build tag additions and removals
  agileplace_ops->>agileplace_ops: Resolve current tag indices descending
  sync_metadata->>AgilePlace: Queue one combined tag patch
Loading

Possibly related issues

  • Issue 21 — Updates the same API validation documentation area for inaccurate AgilePlace tag-removal behavior.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also hardens get_card response validation, which is outside issue #3’s tag-removal scope. Move the get_card validation changes into a separate PR, or link a new issue if they are intended to be part of this work.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: switching tag removal to index-based JSON Patch ops.
Linked Issues check ✅ Passed Core issue #3 requirements are met: tag removal now uses descending index-based removes, add behavior is preserved, and docs/tests were updated.
Docstring Coverage ✅ Passed Docstring coverage is 86.05% which is sufficient. The required threshold is 80.00%.

Comment @coderabbitai help to get the list of available commands.

@thewrz

thewrz commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

Index-based tag removes (ops_tag_remove) encode positions in a specific
tags snapshot. patch_card's version-refetch path (_card_with_version)
fills in a fresh resource version for a version-less card but keeps the
original tags snapshot, so pairing stale /tags/{i} ops with a version the
server may have bumped since would pass optimistic concurrency yet delete
the WRONG tag if the tags array shifted meanwhile.

Guard the refetch path: when the batch carries index-based tag removes and
the refetched tags differ from the snapshot, refuse the PATCH with a WARN
(fail closed, matching the module's ambiguity philosophy) rather than risk
a silent mis-deletion. Append-only batches and value-carrying ops are
position-independent and unaffected.

Found by Codex (GPT-5.5) adversarial review of PR #30.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thewrz

thewrz commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Codex (GPT-5.5) adversarial review — additional eyes

Ran Codex as a second reviewer alongside CodeRabbit. One confirmed [P1] finding, now fixed:

[P1] Stale tag indices paired with a fresh resource version (sync.py:204 → root cause in agileplace.py _card_with_version) — FIXED in a61cd0a.
When a card arrives without a resource version, patch_card refetches it once to obtain a fresh version (_card_with_version) but keeps the card's original tags snapshot. This PR's switch to index-based tag removal (ops_tag_remove/tags/{i}) newly couples op correctness to that snapshot: if the card's tags shifted between the initial read and the refetch, the fresh version passes optimistic concurrency yet the stale /tags/{i} op deletes the wrong tag. (Under the old value-based removal this path was harmless.)

Fix: guard the refetch path in _card_with_version — when the batch contains index-based tag-remove ops and the refetched tags differ from the snapshot, refuse the PATCH with a WARN (fail closed, matching the module's stated ambiguity philosophy) instead of risking a silent mis-deletion. Append-only (/tags/-) and value-carrying ops are position-independent and unaffected. Added 4 regression tests (test_agileplace.py); full suite 194 passing.

The fix lives entirely in agileplace.py + its tests — no change to sync.py, so the sibling branches (fix/issue-5, fix/issue-14) are untouched.

🤖 Codex finding triaged and fixed by Claude Opus 4.8.

@thewrz

thewrz commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@thewrz
thewrz marked this pull request as ready for review July 19, 2026 06:39
@thewrz
thewrz merged commit 420872b into main Jul 19, 2026
1 check passed
thewrz added a commit that referenced this pull request Jul 19, 2026
…cts)

Both this branch and main (via merged PRs #30/#31) extended the same
test_sync_main.py helpers and appended new test sections. Resolution keeps
this branch's superset _mock_io/_run_main_once signatures (open_pr_return,
lanes_return, card, existing_cards with _UNSET sentinels), main's
underscore-bound unused unpacks (RUF059), and both branches' test sections.
create_card mock returns {} per main's convention so freshly created cards
are not re-processed by the step-2 lane pass in these wiring tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Switch tag removal to index-based JSON Patch ops (value-based remove is undocumented and contradicted)

1 participant