Skip to content

feat(iterations): dryRun, disk-backed snapshots, per-iteration restore counts - #34

Merged
joaodotwork merged 2 commits into
mainfrom
feat/iteration-safety
Aug 28, 2026
Merged

joaodotwork merged 2 commits into
mainfrom
feat/iteration-safety

Conversation

@joaodotwork

Copy link
Copy Markdown
Owner

Closes #32, closes #33.

Both issues are about the same mechanism. updateProjectV2Field is the only route to add or edit a single iteration, and it replaces the whole configuration — regenerating every iteration id and detaching every item's Sprint value. Both tools already snapshot the assignments and restore them afterwards; this makes that mechanism survivable, inspectable, and verifiable.

#32 — the snapshot lived only in memory

The restore is one mutation per assigned item, so on a 150-assignment board the window between every value detached and every value restored is minutes long. A death inside it lost every assignment with nothing to retry against, since the destructive mutation had already succeeded.

  • The snapshot is written to ~/.config/github-projects-mcp/iteration-snapshots/<projectId>-<timestamp>.json before the mutation, and its path returned as snapshotPath. Location follows src/auth/token-store.ts rather than the ~/.plantas/ the issue suggested, so all local state stays in one place.
  • New restore_iteration_snapshot tool replays a snapshot against the field's current iteration ids. Idempotent.
  • For a rename, the file holds the already-remapped titles, so it replays standalone without knowing what the original call did.
  • A write failure aborts the call before it mutates. Proceeding would enter exactly the unrecoverable window the file exists to close, and the caller cannot tell the difference afterwards.

#33 — nothing could be inspected before running

dryRun: true on both tools returns:

{
  "dryRun": true,
  "operation": "update_iteration",
  "iterationsBefore": [ /* current, ids included */ ],
  "iterationsAfter":  [ /* no ids — every one is regenerated */ ],
  "rename": { "from": "Phase 7: A", "to": "Phase 7: B", "assignmentsRemapped": 1 },
  "assignments": { "total": 24, "byIteration": { "Phase 7: B": 1, "Phase 6": 23 } }
}

Everything it reports is already fetched before the mutation, so a dry run is an early return rather than extra queries — two read calls, no mutation, no snapshot file. The rename field makes the remap at iterations.ts:631 visible from outside for the first time.

assignmentsRestored now carries byIteration alongside restored/failed, so the dry run's breakdown and the real result can be diffed instead of re-reading the whole board.

Also: duplicate titles silently misassigned items

Not in either issue; found while reading the restore path and commented on #33.

Restore is keyed by title — the only identifier surviving the mutation — and the lookup is a Map, so two iterations sharing a title collapsed into one entry and every affected item landed on whichever id came last. The call reported success with failed: []; the items were simply on the wrong sprint. Reachable by add_iteration with an existing title, or by update_iteration renaming onto a sibling.

Both tools now refuse a payload that would leave duplicate titles, naming the offender, on the config read alone — before the snapshot query or the mutation.

Important

This is a behaviour change. A board that already holds duplicate iteration titles will now get a rejected call where it previously got a silently-wrong one. Judged worth it — there is no correct outcome available in that state — but it is the one thing here that is not purely additive. Called out since the repo has no CHANGELOG.

Version

1.6.1 → 1.7.0. Also fixes the MCP server's advertised version, which was pinned at 1.5.0 in src/index.ts while package.json had moved to 1.6.1.

Tests

102 passing (was 64). New coverage: dry-run shapes and that they issue no mutation; per-iteration counts; rename detection and remap counts; duplicate-title rejection at the right point in the sequence; that the snapshot is written before the mutation (asserted on call ordering); that a failed write blocks the mutation; snapshot replay against fresh ids; and the store's own filename sanitising, versioning, and validation.

iterations.test.ts mocks the snapshot store — the first run of these tests wrote six real files into ~/.config before that was added.

🤖 Generated with Claude Code

joaodotwork and others added 2 commits August 28, 2026 16:33
The .claude/ scaffold here dated from 7 Apr and predated several cli
releases. Re-ran `plantas init --force --seiva --cross-session` on
2.12.0, which:

- migrated sessions.json to the JSON-aware plantas-sessions merge
  driver (plain union concatenates single-line JSON into invalid
  two-line JSON on a concurrent merge)
- dropped the context.md merge=union rule, which asserted the file was
  tracked when it is gitignored
- installed the cross-session hooks and /plantas:{tail,status,search}
  commands, added in cli 2.8.0 (2026-05-27)

context.md is untracked here too. The .gitignore entry alone did
nothing while the path stayed tracked, so every commit re-dirtied it
and it showed up as PR churn. File stays on disk; the post-commit hook
regenerates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e counts

Closes #32, closes #33.

The iteration tools have no per-iteration mutation available to them:
`updateProjectV2Field` replaces the whole configuration, regenerating
every iteration id and detaching every item's Sprint value, so both
tools snapshot the assignments and restore them afterwards. Three gaps
in that mechanism:

**The snapshot was memory-only (#32).** The restore is one mutation per
assigned item, so on a 150-assignment board the window between "every
value detached" and "every value restored" is minutes long. A death
inside it — dropped connection, rate limit, a stopped MCP client — lost
every assignment with nothing to retry against, since the destructive
mutation had already succeeded. The snapshot is now written to
~/.config/github-projects-mcp/iteration-snapshots/ before the mutation
and its path returned as `snapshotPath`; `restore_iteration_snapshot`
replays it against the field's current ids. A write failure aborts the
call before it mutates, because proceeding would enter exactly the
unrecoverable window the file exists to close.

**Nothing could be inspected before running (#33).** Both tools take
`dryRun`, returning the iterations before and after, the assignments
that would be snapshotted broken down per iteration, and any detected
rename with the number of entries it would remap — that last one makes
the rename remap visible from outside for the first time. Everything it
reports is already fetched before the mutation, so a dry run is an
early return, not extra queries.

**`assignmentsRestored` reported counts without destinations (#33).**
It now carries `byIteration` alongside `restored`/`failed`, so a caller
can diff the dry run's breakdown against the result's instead of
re-reading the whole board to verify.

**Duplicate titles silently misassigned items.** Restore is keyed by
title, the only identifier surviving the mutation, and the lookup is a
Map — two iterations sharing a title collapsed to one entry and every
affected item landed on whichever id came last, with the call reporting
success and no failures. Both tools now refuse a payload that would
leave duplicate titles, naming the offender. This turns a silently
wrong outcome into a rejected call, so it is a behaviour change for any
board that already holds duplicates.

Also fixes the MCP server's advertised version, which was pinned at
1.5.0 while package.json had moved to 1.6.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joaodotwork
joaodotwork merged commit 8969201 into main Aug 28, 2026
3 checks passed
@joaodotwork
joaodotwork deleted the feat/iteration-safety branch August 28, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant