diff --git a/.claude/AI_GUIDE.md b/.claude/AI_GUIDE.md new file mode 100644 index 0000000..2d50425 --- /dev/null +++ b/.claude/AI_GUIDE.md @@ -0,0 +1,343 @@ +# AI Agent Session Guide for Plantas + Seiva + +Welcome! This project uses **Plantas** (session management) with **Seiva** (context management). + +## 🚨 MEMORIZE THESE COMMANDS - You will use them frequently! + +**Core Plantas Commands (EXACT SYNTAX - Commit to Memory):** + +```bash +# Check what sessions exist (ALWAYS run this first!) +plantas list + +# Start a new session (quoted description is REQUIRED!) +plantas start "" [branch] [areas] [--issue ] + +# End a session when done +plantas end + +# Check current session status +plantas status +``` + +**Command Syntax Rules:** +- Session description MUST be quoted: `"description"` βœ… not `description` ❌ +- Optional parameters in square brackets: `[branch]` `[areas]` +- Session ID: short, no spaces (e.g., `auth`, `fix-bug`, `A`, `B`) +- Use `--issue ` to associate with a GitHub issue + +**Real Examples to Remember:** +```bash +plantas start auth "Implement user authentication" feat/auth "src/auth/**" --issue 123 +plantas start bugfix "Fix navigation issues" fix/nav +plantas start A "Quick admin fixes" +plantas end auth +``` + +## Quick Start for AI Assistants + +1. **ALWAYS check active sessions first:** + ```bash + plantas list + ``` + See what sessions are active and their focus areas. Use `plantas list --active` for a cleaner view. + +2. **Read the context:** + ``` + Read .claude/context.md + Read .claude/sessions.json + ``` + This gives you project state, tech stack, and recent activity (~2k tokens vs 30-50k full scan). + + **Note:** context.md is auto-generated and gitignored β€” regenerated on each commit. + sessions.json IS committed to git and uses union merge strategy for cross-session coordination. + +3. **Use plantas commands for session management** - See memorized syntax above! + +## πŸͺ Automatic Context Updates (Post-Commit Hook) + +**context.md regenerates automatically after EVERY commit via post-commit hook.** + +**What happens when you commit:** +1. Git runs your commit +2. Post-commit hook calls `plantas context --quiet` +3. context.md is regenerated from repo state (tech stack, key files, work streams, sessions) + +**What this means for you as an AI Agent:** +- βœ… **You NEVER need to manually update context.md** β€” it's fully auto-generated +- βœ… context.md is gitignored β€” no commit amending, no merge conflicts +- βœ… Run `plantas context` manually if you need a fresh snapshot +- βœ… Run `plantas context --human` for a human-readable version + +## Session Management Commands + +**IMPORTANT**: Always use plantas commands for session operations. Never manually manipulate worktrees or sessions.json. + +**Keep `.plantas.config.json` committed.** It records which config dir this repo +uses (`.claude/` for `--seiva`, otherwise `.plantas/`). If it goes missing or +becomes malformed, plantas falls back to detecting the directory that holds +`sessions.json` and warns that it guessed; if both candidates hold one, it +refuses to guess and uses the default β€” which may not be the directory this repo +actually uses. Run `plantas init` to write the file back. + +### Starting a Session +When the user wants to start working on something new: +```bash +plantas start "" [optional-branch] [optional-areas] [--issue ] [--from ] + +# Examples: +plantas start auth "Implement user authentication" feat/auth "src/auth/**" --issue 45 +plantas start bugfix "Fix navigation issues" fix/nav +plantas start feature "Add new dashboard" +plantas start menu "Print-ready PDFs" feat/menu --from dev # cut from dev, not HEAD +``` + +**Where does the new branch come from?** By default, whatever `HEAD` the *main +checkout* happens to be sitting on. That is fine while `main` is the only +long-lived branch and wrong the moment a repo grows an integration branch. + +- `--from ` (alias `--base`) sets the branch point explicitly. +- A bare name resolves **remote-first** β€” `--from dev` uses `origin/dev`, not a + possibly-stale local `dev`. Use `refs/heads/dev` to force the local branch, + or `PLANTAS_NO_FETCH=1` to skip the fetch. Tags and SHAs work as given. +- The branch point is **always printed**, flag or not: + `βœ“ Branched from: origin/dev (4bad370 dev: integration work)`. Read it β€” a + wrong base is cheap to fix now and expensive to fix at merge time. +- It is recorded as `baseRef` in `sessions.json`, and `plantas merge` reuses it + when resolving what to merge. + +**Claude's persistent memory is shared with the main checkout.** Memories live at +`~/.claude/projects//memory`, keyed by absolute path, so each worktree +would otherwise get its own empty store and a memory written in one session would +be invisible everywhere else. `plantas start` symlinks the worktree's store to the +main checkout's, so `MEMORY.md` and every memory file are shared across all +sessions. Opt out with `"sessions": { "shareMemory": false }`. An existing real +memory directory is never replaced. + +**In a repo where PRs target an integration branch, pass `--from`.** Otherwise +the session silently branches from production and the mistake surfaces later as +a conflict or as missing code. + +### Ending a Session +When work is complete and the user wants to cleanup: +```bash +plantas end + +# Example: +plantas end auth + +# Skip confirmations (for scripts): +plantas end auth --force + +# Terminate processes still running out of the worktree (SIGTERM, then SIGKILL): +plantas end auth --kill-orphans + +# Also delete the leftovers it would otherwise preserve and list: +plantas end auth --purge +``` + +`plantas end` now does two things before and after removing the worktree: + +- **Orphaned processes.** A dev server started inside the worktree survives its + removal, keeps holding its port, and reports a cwd that no longer exists. The + next session then silently takes the *next* port, so nothing errors and the + orphans stack up. `end` scans for them and prints PID, command and listening + sockets. **Report-only by default** β€” pass `--kill-orphans` to terminate them. +- **Leftovers.** `git worktree remove` refuses to delete directories holding + ignored or untracked content, and a read-only build directory makes it fail + *after* de-registering the worktree β€” which is how empty husks survived while + `git worktree list` considered them gone. `end` sweeps empty dirs, ignored + build output, and tracked files it has verified byte-identical to the branch. + Anything else β€” untracked non-build files, modified tracked files, `.env`, + logs β€” is **preserved and listed**, and the output says `DIRECTORY REMAINS` + rather than claiming success. `--purge` deletes those too. + +`--force` skips confirmation prompts only. It deliberately implies neither +`--kill-orphans` nor `--purge`. + +**The session branch is deleted if it is fully merged** into the integration +branch β€” resolved the same way `plantas merge` resolves its target, so "merged" +means the same thing in both commands. A branch with unmerged commits is **kept** +and the reason is printed; so is one checked out in another worktree. +`--keep-branch` opts out entirely. Cleanup never destroys unmerged work. + +### Checking Sessions +Before starting work, always check what's active: +```bash +plantas list # See all active and recent sessions +plantas list --active # Only active sessions +plantas list --table # Compact table output +plantas list --json # Machine-readable JSON +plantas list --recent 5 # Active + last 5 closed +plantas status # Current session status +``` + +### What NOT to Do + +❌ **Never manually:** +- Run `git worktree add/remove` directly +- Edit sessions.json file +- Delete session directories manually +- Run `git merge` in session worktrees (use `plantas merge` instead) +- Run `git pull` in main when coordination files are dirty (use `plantas sync` instead) +- Commit with `--no-verify` in a worktree created by plantas ≀2.11.x (see the staging note below) + +### Staging in a session worktree + +**`git add -A` is safe.** The config dir in a worktree is a real directory, exactly +as git checked it out β€” there is no symlink standing at a tracked path, so there is +nothing dangerous to stage. `git commit --no-verify` is safe too. + +Only **gitignored** files are linked to the main checkout (`context.md`, +`settings.local.json`, `cross-session.log`). A symlink at an ignored path is +invisible to git. `sessions.json` is deliberately *not* linked β€” it is tracked, and +linking it would put a symlink back at a tracked path. plantas reads and writes the +main checkout's copy directly. + +> **Worktrees created by plantas ≀2.11.x** still have the whole config dir +> symlinked, and for those `git add -A` *is* destructive β€” it stages the symlink +> plus a deletion of every tracked file beneath it. A `pre-commit` guard catches it +> at commit time, but `--no-verify` skips the guard. In an old worktree, stage +> explicitly. `plantas doctor` reports which kind you have. + +βœ… **Always:** +- Use `plantas start` to begin new work +- Use `plantas end` to cleanup +- Use `plantas sync` to pull into main worktree +- Use `plantas merge` to merge branches in session worktrees +- Use `plantas list` to check status +- Let plantas handle all session lifecycle operations + +## Keeping Branches in Sync + +### In the main worktree: `plantas sync` + +After merging a session PR on GitHub, your main worktree often has an uncommitted +`sessions.json` (updated by hooks). A plain `git pull` will fail because git aborts +before the merge driver kicks in. + +(`context.md` is **gitignored** β€” it is a snapshot of local repo state that the +post-commit hook regenerates on every commit, so it never participates in a pull, +a merge, or a PR diff.) + +```bash +# From the main repo directory: +plantas sync +``` + +This auto-stashes coordination files β†’ pulls with `--no-rebase` (so union merge +applies) β†’ pops the stash. No extra commits, no manual stashing. + +**Options:** +- `-r, --remote ` β€” remote to pull from (default: `origin`) +- `-b, --branch ` β€” branch to pull (default: current branch) +- `-f, --force` β€” run even if you're in a worktree + +### In session worktrees: `plantas merge [branch]` + +Session worktrees have a symlinked `.claude/` (or `.plantas/`) directory. +A plain `git merge` fails because git can't stash paths beyond a symlink. + +```bash +# From a session worktree: +plantas merge # merges your integration branch (resolved, see below) +plantas merge origin/dev # explicit β€” always unambiguous +plantas merge feat/other # merge any branch +``` + +This temporarily removes the symlink β†’ restores real tracked files β†’ runs +`git merge` β†’ re-creates the symlink. If there are merge conflicts, the symlink +is still restored so you can resolve them normally. The swap is protected by +`try/finally` and SIGINT/SIGTERM handlers, so a Ctrl-C mid-merge still restores +the symlink. + +**Which branch does a bare `plantas merge` merge?** It is resolved in order, and +it refuses rather than guessing: + +1. the branch you passed explicitly; +2. the current branch's configured upstream; +3. `integrationBranch` in `.plantas.config.json` β€” defaults to the repo's + default branch, so single-branch repos need no configuration; +4. the ref this worktree was branched from (`baseRef` in `sessions.json`). + +If none of those resolve it errors and names the candidates. **In a repo where +`main` is production and another branch (e.g. `dev`) is the integration branch, +set `integrationBranch` and prefer passing the ref explicitly** β€” merging the +wrong branch is a valid git operation, so it cannot fail loudly. + +**IMPORTANT:** In session worktrees, always use `plantas merge` instead of +`git merge` to avoid the symlink stash failure. Never repair a worktree's config +dir by hand (`rm -rf .claude && ln -s …`) β€” run `plantas doctor --fix`, which +detects a config dir that should be a symlink but isn't and restores it, keeping +a backup of whatever it replaced. + +## Session Workflow + +**When user asks to start new work:** +1. Check what's already active: `plantas list` +2. Start the new session: `plantas start "" [branch] [areas]` +3. Read context.md (project understanding) +4. Work in the session worktree + +**During work:** +- Make changes within your focus areas +- Commit regularly to git +- context.md auto-updates via git hooks +- Use `plantas list` to see other active sessions +- Use `plantas merge` to pull in changes from main (not `git merge`) + +**When user asks to end session:** +1. Ensure work is committed +2. Run: `plantas end ` +3. Confirm when prompted (or use --force to skip) + +**After merging a session PR on GitHub:** +1. From the main repo: `plantas sync` to pull latest without coordination file conflicts + +## Token Optimization + +- βœ… Read context.md first (~2k tokens) +- βœ… Use grep/glob for targeted searches +- βœ… Read specific files only when needed +- ❌ Avoid full codebase scans + +## When Context Gets Full (Compression Time) + +When the conversation context approaches limits, run `plantas context` to get a fresh snapshot. +context.md is auto-generated β€” no need to manually maintain it. + +## Cross-Session Awareness (if installed) + +If this project was set up with `plantas init --seiva --cross-session`, parallel +Claude sessions across worktrees share a realtime awareness channel: + +- Each prompt you submit is logged to `.claude/cross-session.log`, and a digest of + the **other** sessions' last-hour activity is injected into your context + automatically β€” you start each turn already knowing what the others are doing. +- Use these slash commands to look across the whole worktree fleet: + - `/plantas:tail [N]` - recent cross-session log entries (default 20) + - `/plantas:status [recent_N]` - plantas sessions and their Claude session IDs + - `/plantas:search ` - grep transcripts across all worktrees + +This is the **conversation-layer** complement to the git-layer coordination +(committed `sessions.json`). See `.claude/CROSS_SESSION.md` for details. + +## Resources + +### Command Reference +- `plantas list` - List all active and recent sessions (`--table`, `--json`) +- `plantas start "" [branch] [areas]` - Start a new session (`--issue`, `--from `) +- `plantas end [--force]` - End a session and cleanup (`--kill-orphans`, `--purge`, `--keep-branch`) +- `plantas context` - Generate context.md (`--human`, `--refresh`) +- `plantas sync` - Pull latest, auto-stashing coordination files (main worktree) +- `plantas merge [branch]` - Merge a branch, handling symlinked config dirs (worktrees) +- `plantas rebuild [--dry-run]` - Rebuild sessions.json from git history +- `plantas status` - Show current session status +- `plantas doctor` - Check installation, dependencies, config-dir integrity and the commit guard +- `plantas doctor --fix` - Repair a worktree config dir that should be a symlink but isn't +- `plantas init --seiva` - Initialize in a new project +- `plantas init --seiva --cross-session` - ...plus realtime cross-session awareness +- `/plantas:tail` Β· `/plantas:status` Β· `/plantas:search` - Cross-session slash commands (if installed) + +--- +Made with 🌱 by Plantas + 🌊 Seiva diff --git a/.claude/CROSS_SESSION.md b/.claude/CROSS_SESSION.md new file mode 100644 index 0000000..2541e38 --- /dev/null +++ b/.claude/CROSS_SESSION.md @@ -0,0 +1,128 @@ +# Cross-Session Awareness (Conversation Layer) + +A realtime layer on top of plantas that lets parallel Claude Code sessions +across worktrees see each other's activity. Distinct from plantas's existing +v2.0 *git-layer* coordination (committed `sessions.json`, under a JSON-aware +merge driver): this layer is the *conversation-layer* equivalent β€” +what each Claude is currently prompting and touching, surfaced to the +others within the hour. + +Installed by `plantas init --seiva --cross-session`. + +--- + +## What it does + +1. Every prompt typed into any session appends a JSON line to + `.claude/cross-session.log`, and the same hook injects a digest of the + *other* sessions' last-hour activity into the prompt context. Each + Claude opens its turn already knowing what the others are working on. +2. At end of every turn, a Stop hook records which files this session + touched (deduped against the previous entry, `.claude*` paths filtered). +3. Three slash commands β€” `/plantas:search`, `/plantas:tail`, + `/plantas:status` β€” read across the whole worktree fleet beyond the + one-hour digest the hook produces. + +The whole thing rides on the fact that `plantas start` symlinks the +config dir across all worktrees to the main repo's, so a single shared +log file lives at `/.claude/cross-session.log` and every session +writes to the same file. + +--- + +## Components + +### Hooks β€” `.claude/hooks/` + +| File | Event | Job | +|---|---|---| +| `cross-session-prompt.sh` | `UserPromptSubmit` | Append this prompt to log; emit `additionalContext` JSON containing other sessions' last-hour digest. | +| `cross-session-stop.sh` | `Stop` | Append this turn's modified-file list to log (with dedup). | + +Wired in `.claude/settings.local.json` (gitignored β€” settings are +per-machine). The wired form is: + +```jsonc +{ + "hooks": { + "UserPromptSubmit": [{ "hooks": [{ "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/cross-session-prompt.sh\"" + }]}], + "Stop": [{ "hooks": [{ "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/cross-session-stop.sh\"" + }]}] + } +} +``` + +**Why `$CLAUDE_PROJECT_DIR` rather than a relative path.** Claude Code sets +this env var per session to the worktree root, and it's stable for the +life of the session. The Stop hook in particular fires *after* all tool +calls in a turn β€” if any Bash tool call shifted cwd (`cd /tmp && …`), +a relative `bash .claude/hooks/…` resolves against the wrong directory and +fails with `No such file or directory`. UserPromptSubmit fires before any +tool call, so it's less exposed, but anchoring both keeps the convention +uniform. + +### Wrapper scripts β€” `.claude/scripts/` + +| Script | Purpose | +|---|---| +| `sessions-search.sh ` | Greps Claude transcripts across all worktrees + plantas-tracked closed sessions. Builds a slug list from `git worktree list` + `plantas list --json --recent 999`, derives `~/.claude/projects//` paths, jq-greps user/assistant text. | +| `sessions-tail.sh [N]` | Pretty-prints the last N (default 20) entries of `cross-session.log`. | +| `sessions-status.sh [recent_N]` | Joins `plantas list --json` with each session's latest JSONL transcript path, so you can see which Claude UUID belongs to which plantas session. | + +### Slash commands β€” `.claude/commands/plantas/` + +Three Markdown files, each with frontmatter that scopes a `Bash(...)` +permission and a body that auto-executes the wrapper script via the +backtick `!` form: + +| Slash | Body | +|---|---| +| `/plantas:search ` | `` !`bash .claude/scripts/sessions-search.sh "$ARGUMENTS"` `` | +| `/plantas:tail [N]` | `` !`bash .claude/scripts/sessions-tail.sh "$ARGUMENTS"` `` | +| `/plantas:status [recent_N]` | `` !`bash .claude/scripts/sessions-status.sh "$ARGUMENTS"` `` | + +Output is injected directly into the conversation β€” no LLM round-trip. + +### Log β€” `.claude/cross-session.log` + +JSONL, gitignored, one line per event. Two record shapes: + +```json +{"ts":"2026-04-30T17:42:11Z","type":"prompt","session":"3d0217d5","branch":"feat/x","prompt":"…first 200 chars…"} +{"ts":"2026-04-30T17:42:48Z","type":"files","session":"3d0217d5","branch":"feat/x","files":"path/a.md,path/b.md"} +``` + +Session ID is the first 8 chars of the Claude session UUID. + +--- + +## Pitfalls worth knowing + +- **macOS bash 3.2 has no `declare -A`.** Use `sort -u` on collected strings + instead of associative-array dedup. +- **`set -euo pipefail` + `grep -v` returning no matches = exit 1 = killed + script.** Use `awk '$2 !~ /^pattern/'` instead of `grep -v '^pattern'` on + potentially-empty input. +- **First-run guards.** The Stop hook needs `[[ -f "$log_file" ]]` around + any jq dedup lookup, otherwise the very first invocation in a session + reads a missing file under strict mode and aborts. +- **`date -u -v-1H` is BSD; `date -u -d '1 hour ago'` is GNU.** Try BSD + first, fall back to GNU. +- **Symlinked `.claude/` under git.** Each worker worktree shows the + hooks/scripts/commands as deleted in `git status` (because `.claude` is + the symlink). Expected. Plantas's `plantas merge` exists specifically to + handle merges across this symlink. + +--- + +## How this complements `plantas` v2.0 + +| Layer | Mechanism | Surfaced when | +|---|---|---| +| **git-layer** *(v2.0)* | `sessions.json` committed under a JSON-aware merge driver | Across commits, branches, days | +| **conversation-layer** *(this)* | `cross-session.log` JSONL + UserPromptSubmit injection | Within the hour, between live sessions | + +Both are useful. Neither replaces the other. diff --git a/.claude/commands/plantas/search.md b/.claude/commands/plantas/search.md new file mode 100644 index 0000000..448687e --- /dev/null +++ b/.claude/commands/plantas/search.md @@ -0,0 +1,7 @@ +--- +description: Search Claude transcripts across this repo's worktrees +argument-hint: +allowed-tools: Bash(bash .claude/scripts/sessions-search.sh:*) +--- + +!`bash .claude/scripts/sessions-search.sh "$ARGUMENTS"` diff --git a/.claude/commands/plantas/status.md b/.claude/commands/plantas/status.md new file mode 100644 index 0000000..c6b5151 --- /dev/null +++ b/.claude/commands/plantas/status.md @@ -0,0 +1,7 @@ +--- +description: Show plantas sessions (active by default) with their Claude session IDs +argument-hint: [recent_count] +allowed-tools: Bash(bash .claude/scripts/sessions-status.sh:*) +--- + +!`bash .claude/scripts/sessions-status.sh "$ARGUMENTS"` diff --git a/.claude/commands/plantas/tail.md b/.claude/commands/plantas/tail.md new file mode 100644 index 0000000..c25bc85 --- /dev/null +++ b/.claude/commands/plantas/tail.md @@ -0,0 +1,7 @@ +--- +description: Show recent cross-session log entries (default last 20) +argument-hint: [count] +allowed-tools: Bash(bash .claude/scripts/sessions-tail.sh:*) +--- + +!`bash .claude/scripts/sessions-tail.sh "$ARGUMENTS"` diff --git a/.claude/context.md b/.claude/context.md deleted file mode 100644 index 1728485..0000000 --- a/.claude/context.md +++ /dev/null @@ -1,53 +0,0 @@ -# Project Context Snapshot -**Last Updated:** 2026-04-04 -**Project:** plantas-github-projects-mcp - -## Current State -Branch: main -Deployment: development - -## Uncommitted Work -**Status:** Clean working tree - -**Last Commit:** c2eb685 - chore: bump version to 1.4.4 (0 seconds ago) -## What's Being Built -- **Features in progress:** Add create_project_status_update and get_project_status_updates tools - - -## Tech Stack -- **Runtime:** Node.js -- **Build:** TypeScript - - -## Key Files to Know -- `package.json` - Core logic (modified 6x recently) -- `README.md` - Documentation (modified 6x recently) -- `src/index.ts` - Entry point (modified 5x recently) -- `INSTALL.md` - Core logic (modified 2x recently) - - -## Active Patterns -- Database schema evolving -- Documentation updates -- Session management via Plantas -- Context coordination via Seiva - - -## Next Likely Steps -1. [Next planned task] -2. Test changes -3. Deploy to staging - -## Recent Activity -- **2026-04-04**: a02ad2d - chore: bump version to 1.5.0 and update docs -- **2026-03-05**: c2eb685 - chore: bump version to 1.4.4 -- **2026-03-05**: 8eab1ed - refactor: extract updateItemStatus into testable module with tests -- **2026-03-05**: 4a6bb35 - fix: validate status param in update_item_status before use (issue #11) -- **2026-03-02**: e2ee232 - chore: bump version to 1.4.3 - -## Cost-Effective Context Strategy -**For AI assistants:** -- Read this file first (~2k tokens) -- Ask targeted questions before reading code -- Use grep/find for specific lookups -- Request full file reads only when necessary diff --git a/.claude/hooks/cross-session-prompt.sh b/.claude/hooks/cross-session-prompt.sh new file mode 100755 index 0000000..cc07d3a --- /dev/null +++ b/.claude/hooks/cross-session-prompt.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# UserPromptSubmit hook: record this session's prompt and inject a digest of +# other active Claude sessions' recent activity into the conversation. +set -euo pipefail + +input=$(cat) +session_id=$(jq -r '.session_id' <<<"$input") +cwd=$(jq -r '.cwd' <<<"$input") +prompt=$(jq -r '.prompt // ""' <<<"$input") + +log_file="$cwd/.claude/cross-session.log" +mkdir -p "$(dirname "$log_file")" + +ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +branch=$(git -C "$cwd" branch --show-current 2>/dev/null || echo "") +short="${session_id:0:8}" + +jq -nc --arg ts "$ts" --arg s "$short" --arg b "$branch" \ + --arg p "$(printf '%.200s' "$prompt")" \ + '{ts:$ts,type:"prompt",session:$s,branch:$b,prompt:$p}' >> "$log_file" + +cutoff=$(date -u -v-1H +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \ + || date -u -d '1 hour ago' +"%Y-%m-%dT%H:%M:%SZ") + +digest=$(jq -r --arg me "$short" --arg cut "$cutoff" ' + select(.session != $me and .ts > $cut) | + "[\(.ts[11:16]) \(.session) \(.branch)] \(.type): \(.prompt // .files)" +' "$log_file" 2>/dev/null | tail -10) + +if [[ -n "$digest" ]]; then + ctx=$'## Other active Claude sessions (last 1h)\n'"$digest" + jq -nc --arg ctx "$ctx" \ + '{hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:$ctx}}' +fi diff --git a/.claude/hooks/cross-session-stop.sh b/.claude/hooks/cross-session-stop.sh new file mode 100755 index 0000000..69adede --- /dev/null +++ b/.claude/hooks/cross-session-stop.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Stop hook: at end of each turn, record any files this session has touched. +set -euo pipefail + +input=$(cat) +session_id=$(jq -r '.session_id' <<<"$input") +cwd=$(jq -r '.cwd' <<<"$input") + +log_file="$cwd/.claude/cross-session.log" +[[ -d "$(dirname "$log_file")" ]] || exit 0 + +# Porcelain v1 lines are "XY PATH" (3-char status prefix). Strip it via substr, +# unwrap renames ("ORIG -> NEW" β†’ NEW), then drop .claude paths. awk (not +# `grep -v`) keeps this pipefail-safe on empty input, and substr preserves +# filenames containing spaces β€” `$2` would have truncated them. +files=$(git -C "$cwd" status --porcelain 2>/dev/null \ + | awk '{ sub(/^.../, ""); sub(/.* -> /, ""); if ($0 !~ /^\.claude/) print }' \ + | head -20 | paste -sd, -) +[[ -z "$files" ]] && exit 0 + +short="${session_id:0:8}" + +# Skip if the last 'files' entry for this session has the same list (dedupe noise). +last_files="" +if [[ -f "$log_file" ]]; then + last_files=$(jq -r --arg s "$short" ' + select(.session==$s and .type=="files") | .files + ' "$log_file" 2>/dev/null | tail -1) +fi +[[ "$files" == "$last_files" ]] && exit 0 + +ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +branch=$(git -C "$cwd" branch --show-current 2>/dev/null || echo "") + +jq -nc --arg ts "$ts" --arg s "$short" --arg b "$branch" --arg f "$files" \ + '{ts:$ts,type:"files",session:$s,branch:$b,files:$f}' >> "$log_file" diff --git a/.claude/scripts/sessions-search.sh b/.claude/scripts/sessions-search.sh new file mode 100755 index 0000000..ab37e86 --- /dev/null +++ b/.claude/scripts/sessions-search.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Search Claude Code transcripts across all worktrees of the current repo. +# Usage: sessions-search.sh +set -euo pipefail + +term="${1:?usage: $0 }" + +declare -a paths=() +# Active worktrees (always includes the main one) +while read -r wt; do + paths+=("$wt") +done < <(git worktree list --porcelain | awk '/^worktree /{print $2}') +# Closed plantas session worktree paths (their dirs may be gone, but transcripts persist) +while read -r wt; do + [[ -n "$wt" ]] && paths+=("$wt") +done < <(plantas list --json --recent 999 2>/dev/null \ + | jq -r '.closed[]?.worktree' 2>/dev/null) + +slugs=$(for wt in "${paths[@]}"; do echo "$wt" | sed 's|/|-|g'; done | sort -u) + +declare -a files=() +while read -r slug; do + [[ -z "$slug" ]] && continue + for f in "$HOME/.claude/projects/$slug/"*.jsonl; do + [[ -f "$f" ]] && files+=("$f") + done +done <<< "$slugs" + +if [[ ${#files[@]} -eq 0 ]]; then + echo "No Claude transcripts found for this repo's worktrees." + exit 0 +fi + +hits=0 +for f in "${files[@]}"; do + matches=$(jq -r --arg t "$term" ' + select(.type=="user" or .type=="assistant") | + ( .message.content as $c | + if ($c | type) == "string" then $c + elif ($c | type) == "array" then ($c | map(.text? // "") | join(" ")) + else "" end + ) as $text | + select($text | test($t; "i")) | + " \(.timestamp // "?") \(.type) \($text | gsub("\n"; " ") | .[0:180])" + ' "$f" 2>/dev/null || true) + + if [[ -n "$matches" ]]; then + slug=$(basename "$(dirname "$f")") + sess=$(basename "$f" .jsonl | cut -c1-8) + echo "── $slug / $sess ──" + echo "$matches" + echo + hits=$((hits + 1)) + fi +done + +if [[ $hits -eq 0 ]]; then + echo "No matches for: $term" +else + echo "$hits transcript(s) matched." +fi diff --git a/.claude/scripts/sessions-status.sh b/.claude/scripts/sessions-status.sh new file mode 100755 index 0000000..e858926 --- /dev/null +++ b/.claude/scripts/sessions-status.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Show plantas sessions (active + optionally recent closed) with their Claude session info. +# Usage: sessions-status.sh [recent_count] (default 0 = active only) +set -euo pipefail + +recent="${1:-0}" +[[ -z "$recent" ]] && recent=0 + +main_path=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}') + +if [[ "$recent" -gt 0 ]]; then + sessions=$(plantas list --json --recent "$recent" 2>/dev/null) +else + sessions=$(plantas list --json 2>/dev/null) +fi + +claude_info() { + local wt="$1" + local slug + slug=$(echo "$wt" | sed 's|/|-|g') + local proj_dir="$HOME/.claude/projects/$slug" + if [[ -d "$proj_dir" ]]; then + local latest + latest=$(ls -t "$proj_dir"/*.jsonl 2>/dev/null | head -1) + if [[ -n "$latest" ]]; then + local sess mtime + sess=$(basename "$latest" .jsonl | cut -c1-8) + mtime=$(date -r "$latest" "+%m-%d %H:%M" 2>/dev/null || echo "?") + echo -e "$sess\t$mtime" + return + fi + fi + echo -e "-\t-" +} + +{ + echo -e "ID\tSTATE\tBRANCH\tFOCUS\tCLAUDE\tLAST" + + if [[ -n "$main_path" ]]; then + branch=$(git -C "$main_path" branch --show-current 2>/dev/null || echo "?") + IFS=$'\t' read -r sess mtime < <(claude_info "$main_path") + echo -e "(main)\tactive\t$branch\t-\t$sess\t$mtime" + fi + + echo "$sessions" | jq -r '.active[]? | "\(.id)\tactive\t\(.branch)\t\(.focus)\t\(.worktree)"' \ + | while IFS=$'\t' read -r id state branch focus wt; do + IFS=$'\t' read -r sess mtime < <(claude_info "$wt") + echo -e "$id\t$state\t$branch\t$focus\t$sess\t$mtime" + done + + echo "$sessions" | jq -r '.closed[]? | "\(.id)\tclosed\t\(.branch)\t\(.focus)\t\(.worktree)"' \ + | while IFS=$'\t' read -r id state branch focus wt; do + IFS=$'\t' read -r sess mtime < <(claude_info "$wt") + echo -e "$id\t$state\t$branch\t$focus\t$sess\t$mtime" + done +} | column -t -s$'\t' diff --git a/.claude/scripts/sessions-tail.sh b/.claude/scripts/sessions-tail.sh new file mode 100755 index 0000000..059cdc8 --- /dev/null +++ b/.claude/scripts/sessions-tail.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Pretty-print the last N entries of the cross-session log. +# Usage: sessions-tail.sh [count] (default 20) +set -euo pipefail + +n="${1:-20}" +[[ -z "$n" ]] && n=20 + +# Anchor to the repo root so this works from any worktree subdirectory, not +# just where the slash command happened to be invoked. +root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +log_file="$root/.claude/cross-session.log" +[[ -f "$log_file" ]] || { echo "no cross-session.log yet"; exit 0; } + +tail -n "$n" "$log_file" | jq -r ' + "[\(.ts[11:16]) \(.session) \(.branch)] \(.type): \(.prompt // .files)" +' diff --git a/.gitattributes b/.gitattributes index 8b1ad01..8b0c4ef 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,10 +7,8 @@ # Plantas + Seiva: Union merge strategy for coordination files # These files accumulate changes from all sessions/branches -.claude/context.md merge=union -.claude/sessions.json merge=union -.plantas/context.md merge=union -.plantas/sessions.json merge=union +.claude/sessions.json merge=plantas-sessions +.plantas/sessions.json merge=plantas-sessions # Help resolve conflicts when config dir is a symlink (worktrees) .claude merge=keep-symlink diff --git a/.gitignore b/.gitignore index 18e7862..d1c534c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ dist/ github-mcp-server-fork/ .claude/settings.local.json -.claude/*.sh \ No newline at end of file +.claude/*.sh +.claude/context.md +.claude/cross-session.log diff --git a/.plantas.config.json b/.plantas.config.json new file mode 100644 index 0000000..0771f7a --- /dev/null +++ b/.plantas.config.json @@ -0,0 +1,12 @@ +{ + "configDir": ".claude", + "sessionsFile": ".claude/sessions.json", + "worktreePrefix": "session-", + "timestampFormat": "HH:mm", + "defaultAreas": "general", + "integrationBranch": null, + "hooks": { + "onStart": null, + "onEnd": null + } +} \ No newline at end of file diff --git a/README.md b/README.md index f49b834..61a6f5f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A Model Context Protocol (MCP) server for automating GitHub Projects v2 workflow - βœ… **Add to Projects**: Automatically add issues to project boards - βœ… **Iteration Fields**: Create weekly/sprint iteration fields - βœ… **Add/Update Iterations**: Add new iterations to existing fields or update existing ones +- βœ… **Safe Iteration Changes**: `dryRun` previews, disk-backed assignment snapshots, and per-iteration restore reporting - βœ… **Assign Iterations**: Distribute issues across sprints - βœ… **Sub-issue Management**: Add, remove, and reprioritize sub-issues - βœ… **Update Status**: Change project item status with human-readable values @@ -369,7 +370,11 @@ Identify the item either by number or by project item ID: Add a new iteration to an existing iteration field. -> **Assignments are preserved automatically.** GitHub has no per-iteration mutation β€” the whole configuration must be replaced, and that regenerates every iteration ID, detaching all item assignments. Both `add_iteration` and `update_iteration` snapshot every itemβ†’iteration value first and re-apply it by iteration title afterwards. The result includes `assignmentsRestored: { restored, failed }` so a partial restore is visible rather than silent. +> **Assignments are preserved automatically.** GitHub has no per-iteration mutation β€” the whole configuration must be replaced, and that regenerates every iteration ID, detaching all item assignments. Both `add_iteration` and `update_iteration` snapshot every itemβ†’iteration value first and re-apply it by iteration title afterwards. The result includes `assignmentsRestored: { restored, failed, byIteration }` so a partial restore is visible rather than silent, and `byIteration` answers *restored to where* without a separate read of the board. + +> **The snapshot is written to disk before the mutation.** Path is returned as `snapshotPath`, under `~/.config/github-projects-mcp/iteration-snapshots/`. The restore is one mutation per assigned item, so on a large board the window between "assignments detached" and "assignments restored" is minutes long; if the process dies inside it, the values are not recoverable from the API. Replay the file with [`restore_iteration_snapshot`](#restore_iteration_snapshot). If the snapshot cannot be written, the call fails **before** mutating anything. + +> **Iteration titles must be unique.** Restore is keyed by title β€” the only identifier that survives the mutation β€” so a duplicate would silently reassign items to the wrong iteration. A call that would leave two iterations sharing a title is refused, naming the offender. **Parameters:** - `projectId` (string, required): Project node ID @@ -377,6 +382,7 @@ Add a new iteration to an existing iteration field. - `title` (string, required): Iteration title (e.g., "Sprint 5") - `startDate` (string, required): Start date (YYYY-MM-DD) - `duration` (number, required): Duration in days (typically 7 or 14) +- `dryRun` (boolean, optional): Report what the call would do and mutate nothing **Example:** ```typescript @@ -393,7 +399,7 @@ Add a new iteration to an existing iteration field. ### `update_iteration` -Update an existing iteration's title, start date, or duration. Item assignments are preserved β€” see the note under [`add_iteration`](#add_iteration). Renaming is handled too: items on the renamed iteration are remapped to the new title rather than dropped. +Update an existing iteration's title, start date, or duration. Item assignments are preserved, the snapshot is written to disk first, and titles must stay unique β€” see the notes under [`add_iteration`](#add_iteration). Renaming is handled too: items on the renamed iteration are remapped to the new title rather than dropped, and `dryRun` reports the rename it detected so you can confirm the remap engaged. **Parameters:** - `projectId` (string, required): Project node ID @@ -402,6 +408,7 @@ Update an existing iteration's title, start date, or duration. Item assignments - `title` (string, optional): New title - `startDate` (string, optional): New start date (YYYY-MM-DD) - `duration` (number, optional): New duration in days +- `dryRun` (boolean, optional): Report what the call would do and mutate nothing **Example:** ```typescript @@ -416,6 +423,53 @@ Update an existing iteration's title, start date, or duration. Item assignments --- +### `dryRun` on iteration changes + +Both `add_iteration` and `update_iteration` accept `dryRun: true`. Nothing is mutated and no snapshot file is written; the two read queries run and the report comes back: + +```jsonc +{ + "dryRun": true, + "operation": "update_iteration", + "fieldId": "PVTIF_lAHOAwJiCM4BNC20zg8J544", + "fieldName": "Sprint", + // Current iterations, ids included. + "iterationsBefore": [ + { "id": "bab3ba50", "title": "Phase 7: A", "startDate": "2026-08-17", "duration": 14 } + ], + // As they would be. No ids β€” every one is regenerated by the mutation. + "iterationsAfter": [ + { "title": "Phase 7: B", "startDate": "2026-08-17", "duration": 14 } + ], + // Non-null when a title change is detected, so you can confirm the remap engaged. + "rename": { "from": "Phase 7: A", "to": "Phase 7: B", "assignmentsRemapped": 1 }, + // Keyed by the titles the restore will use, post-remap. + "assignments": { "total": 24, "byIteration": { "Phase 7: B": 1, "Phase 6": 23 } } +} +``` + +Diffing `assignments.byIteration` from the dry run against `assignmentsRestored.byIteration` from the real call is the strongest verification available without a transaction. + +--- + +### `restore_iteration_snapshot` + +Re-apply a snapshot written by `add_iteration` or `update_iteration`. Use it when a call reported entries under `assignmentsRestored.failed`, or when a call was interrupted before its restore finished. Idempotent β€” re-applying an assignment an item already has is a no-op. + +**Parameters:** +- `snapshotPath` (string, required): Path from the `snapshotPath` field of an `add_iteration` or `update_iteration` response + +**Example:** +```typescript +{ + "snapshotPath": "/Users/you/.config/github-projects-mcp/iteration-snapshots/PVT_kwHOAwJiCM4BNC20-2026-08-28T12-34-56-789Z.json" +} +``` + +Assignments are re-applied against the field's *current* iteration ids, read fresh β€” the ids in the snapshot are only there for a human reading the file. + +--- + ### `add_subissue` Add a sub-issue to a parent issue. diff --git a/package.json b/package.json index f5b203e..4a9e887 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@joaodotwork/plantas-github-projects-mcp", - "version": "1.6.1", + "version": "1.7.0", "description": "Plantas GitHub Projects v2 MCP server (unofficial) - automation for creating projects, milestones, issues, and iterations", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index 7d44a06..c1ea82c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { assignIssueToIteration, addIteration, updateIteration, + restoreIterationSnapshot, type IterationInput, type AssignIterationInput, type AddIterationInput, @@ -626,6 +627,11 @@ const tools: Tool[] = [ type: "number", description: "Duration in days (typically 7 or 14)", }, + dryRun: { + type: "boolean", + description: + "Report what the call would do and mutate nothing. Returns the iterations before and after, a per-iteration breakdown of the assignments that would be snapshotted and restored, and any detected rename. Recommended before a real call: the mutation regenerates every iteration ID and detaches every assignment.", + }, }, required: ["projectId", "fieldId", "title", "startDate", "duration"], }, @@ -661,17 +667,38 @@ const tools: Tool[] = [ type: "number", description: "New duration in days (optional)", }, + dryRun: { + type: "boolean", + description: + "Report what the call would do and mutate nothing. Returns the iterations before and after, a per-iteration breakdown of the assignments that would be snapshotted and restored, and any detected rename. Recommended before a real call: the mutation regenerates every iteration ID and detaches every assignment.", + }, }, required: ["projectId", "fieldId", "iterationId"], }, }, + { + name: "restore_iteration_snapshot", + description: + "Re-apply an iteration snapshot written by add_iteration or update_iteration. Use it when a call reported failed restores, or when a call was interrupted before its restore finished β€” the snapshot path is in the tool's response. Idempotent: assignments an item already has are re-applied harmlessly.", + inputSchema: { + type: "object", + properties: { + snapshotPath: { + type: "string", + description: + "Path to the snapshot JSON, as returned in the snapshotPath field of an add_iteration or update_iteration response (under ~/.config/github-projects-mcp/iteration-snapshots/)", + }, + }, + required: ["snapshotPath"], + }, + }, ]; // Server implementation const server = new Server( { name: "github-projects-mcp", - version: "1.5.0", + version: "1.7.0", }, { capabilities: { @@ -943,6 +970,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case "restore_iteration_snapshot": { + const { snapshotPath } = args as unknown as { snapshotPath: string }; + const result = await restoreIterationSnapshot( + githubGraphQL, + snapshotPath, + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + case "add_subissue": { const input = args as unknown as AddSubIssueInput; const result = await githubGraphQL( diff --git a/src/tools/iteration-snapshots.test.ts b/src/tools/iteration-snapshots.test.ts new file mode 100644 index 0000000..beb745f --- /dev/null +++ b/src/tools/iteration-snapshots.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import { + snapshotDir, + SNAPSHOT_VERSION, + writeIterationSnapshot, + readIterationSnapshot, +} from "./iteration-snapshots.js"; + +vi.mock("fs"); +vi.mock("os"); + +const mockFs = vi.mocked(fs); +const mockOs = vi.mocked(os); + +const PAYLOAD = { + operation: "update_iteration" as const, + projectId: "PVT_kwHOAwJiCM4BNC20", + fieldId: "PVTIF_lADO", + fieldName: "Sprint", + iterationsBefore: [ + { id: "iter-1", title: "Sprint 1", startDate: "2026-01-01", duration: 7 }, + ], + assignments: [ + { itemId: "item-1", label: "Issue #42", iterationTitle: "Sprint 1" }, + ], +}; + +const NOW = new Date("2026-08-28T12:34:56.789Z"); + +beforeEach(() => { + // reset, not clear: clearAllMocks keeps implementations, so a mock set to + // throw in one test leaks into every test after it. + vi.resetAllMocks(); + mockOs.homedir.mockReturnValue("/home/test"); +}); + +describe("writeIterationSnapshot", () => { + it("writes under the same config dir as the credential store", () => { + writeIterationSnapshot(PAYLOAD, NOW); + const [file] = mockFs.writeFileSync.mock.calls[0]; + expect(String(file)).toContain("github-projects-mcp"); + expect(String(file)).toContain("iteration-snapshots"); + }); + + it("creates the directory recursively before writing", () => { + writeIterationSnapshot(PAYLOAD, NOW); + expect(mockFs.mkdirSync).toHaveBeenCalledWith(snapshotDir(), { + recursive: true, + }); + // Order matters: a write into a missing directory throws. + expect(mockFs.mkdirSync.mock.invocationCallOrder[0]).toBeLessThan( + mockFs.writeFileSync.mock.invocationCallOrder[0], + ); + }); + + it("names the file by project and timestamp, with no filename-hostile characters", () => { + const file = writeIterationSnapshot(PAYLOAD, NOW); + const base = file.split("/").pop()!; + expect(base).toBe("PVT_kwHOAwJiCM4BNC20-2026-08-28T12-34-56-789Z.json"); + // Colons and dots break paths on some platforms and confuse shells everywhere. + expect(base.slice(0, -".json".length)).not.toMatch(/[:.]/); + }); + + it("sanitises a project id that is not a plain node id", () => { + const file = writeIterationSnapshot( + { ...PAYLOAD, projectId: "../../etc/passwd" }, + NOW, + ); + const base = file.split("/").pop()!; + expect(base).not.toContain("/"); + expect(base).not.toContain(".."); + expect(base.startsWith("______etc_passwd-")).toBe(true); + }); + + it("returns the path it wrote", () => { + const returned = writeIterationSnapshot(PAYLOAD, NOW); + expect(returned).toBe(String(mockFs.writeFileSync.mock.calls[0][0])); + }); + + it("stamps version and createdAt onto the payload", () => { + writeIterationSnapshot(PAYLOAD, NOW); + const written = JSON.parse( + String(mockFs.writeFileSync.mock.calls[0][1]), + ); + expect(written.version).toBe(SNAPSHOT_VERSION); + expect(written.createdAt).toBe("2026-08-28T12:34:56.789Z"); + }); + + it("round-trips the assignments and the pre-mutation iterations", () => { + writeIterationSnapshot(PAYLOAD, NOW); + const written = JSON.parse( + String(mockFs.writeFileSync.mock.calls[0][1]), + ); + expect(written.assignments).toEqual(PAYLOAD.assignments); + expect(written.iterationsBefore).toEqual(PAYLOAD.iterationsBefore); + expect(written.fieldId).toBe(PAYLOAD.fieldId); + expect(written.operation).toBe("update_iteration"); + }); + + it("throws an error naming the directory when the write fails", () => { + mockFs.writeFileSync.mockImplementation(() => { + throw new Error("EACCES: permission denied"); + }); + expect(() => writeIterationSnapshot(PAYLOAD, NOW)).toThrow( + /EACCES: permission denied/, + ); + expect(() => writeIterationSnapshot(PAYLOAD, NOW)).toThrow(snapshotDir()); + }); + + it("explains why the failure blocks the mutation", () => { + mockFs.mkdirSync.mockImplementation(() => { + throw new Error("EROFS"); + }); + expect(() => writeIterationSnapshot(PAYLOAD, NOW)).toThrow( + /Refusing to mutate/i, + ); + }); +}); + +describe("readIterationSnapshot", () => { + function onDisk(contents: unknown) { + mockFs.readFileSync.mockReturnValue( + typeof contents === "string" ? contents : JSON.stringify(contents), + ); + } + + const VALID = { + version: SNAPSHOT_VERSION, + createdAt: NOW.toISOString(), + ...PAYLOAD, + }; + + it("parses a snapshot written by writeIterationSnapshot", () => { + writeIterationSnapshot(PAYLOAD, NOW); + onDisk(String(mockFs.writeFileSync.mock.calls[0][1])); + + const snapshot = readIterationSnapshot("/snap/s.json"); + expect(snapshot.assignments).toEqual(PAYLOAD.assignments); + expect(snapshot.projectId).toBe(PAYLOAD.projectId); + }); + + it("names the file when it cannot be read", () => { + mockFs.readFileSync.mockImplementation(() => { + throw new Error("ENOENT: no such file"); + }); + expect(() => readIterationSnapshot("/snap/gone.json")).toThrow( + /\/snap\/gone\.json/, + ); + expect(() => readIterationSnapshot("/snap/gone.json")).toThrow(/ENOENT/); + }); + + it("rejects malformed JSON rather than returning a partial snapshot", () => { + onDisk("{ not json"); + expect(() => readIterationSnapshot("/snap/s.json")).toThrow( + /not valid JSON/, + ); + }); + + it("rejects an unknown snapshot version", () => { + onDisk({ ...VALID, version: 99 }); + expect(() => readIterationSnapshot("/snap/s.json")).toThrow( + /version 99, expected 1/, + ); + }); + + it("rejects a snapshot with no assignments array", () => { + onDisk({ ...VALID, assignments: undefined }); + expect(() => readIterationSnapshot("/snap/s.json")).toThrow( + /no assignments array/, + ); + }); + + it("rejects a snapshot that cannot target a restore", () => { + onDisk({ ...VALID, fieldId: "" }); + expect(() => readIterationSnapshot("/snap/s.json")).toThrow( + /missing projectId or fieldId/, + ); + }); + + it("accepts an empty assignment list β€” a board with nothing assigned is valid", () => { + onDisk({ ...VALID, assignments: [] }); + expect(readIterationSnapshot("/snap/s.json").assignments).toEqual([]); + }); +}); diff --git a/src/tools/iteration-snapshots.ts b/src/tools/iteration-snapshots.ts new file mode 100644 index 0000000..2ccd82f --- /dev/null +++ b/src/tools/iteration-snapshots.ts @@ -0,0 +1,132 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import type { IterationAssignment } from "./iterations.js"; + +/** + * Where iteration snapshots land. Mirrors the credential store's location + * (`src/auth/token-store.ts`) so the server keeps all its local state in one place. + * + * Resolved on call rather than at import: a module-level `os.homedir()` is fixed + * for the life of the process and unreachable from a test that mocks `os`. + */ +export function snapshotDir(): string { + return path.join( + os.homedir(), + ".config", + "github-projects-mcp", + "iteration-snapshots", + ); +} + +/** Bumped only if the on-disk shape changes incompatibly. */ +export const SNAPSHOT_VERSION = 1; + +export type IterationOperation = "add_iteration" | "update_iteration"; + +export interface IterationSnapshot { + version: number; + createdAt: string; + operation: IterationOperation; + projectId: string; + fieldId: string; + fieldName: string; + /** Iterations as they were *before* the mutation, ids included. */ + iterationsBefore: Array<{ + id: string; + title: string; + startDate: string; + duration: number; + }>; + /** + * Assignments to restore, keyed by iteration title. Already remapped for a + * rename, so this is directly replayable β€” no knowledge of the original call needed. + */ + assignments: IterationAssignment[]; +} + +/** ISO 8601 minus the characters that are awkward in a filename. */ +function fileTimestamp(now: Date): string { + return now.toISOString().replace(/[:.]/g, "-"); +} + +/** + * Persist a snapshot before the destructive mutation runs, and return its path. + * + * This is the recovery net for #32: `updateProjectV2Field` detaches every item's + * iteration value, and the restore that follows is one mutation per item. If the + * process dies inside that window the assignments are unrecoverable from the API β€” + * the old values are gone and the mutation already succeeded, so there is nothing + * to retry against. Writing them down first is the only thing that makes the window + * survivable. + * + * Failure to write is fatal on purpose. Proceeding without the file would enter + * exactly the unrecoverable window this exists to close, and the caller cannot tell + * the difference after the fact. + */ +export function writeIterationSnapshot( + snapshot: Omit, + now: Date = new Date(), +): string { + const payload: IterationSnapshot = { + version: SNAPSHOT_VERSION, + createdAt: now.toISOString(), + ...snapshot, + }; + + // projectId is a base64-ish node id (`PVT_kwHOAwJiCM4BNC20`); anything outside + // this class would be a surprise, so replace rather than trust it. + const safeProject = snapshot.projectId.replace(/[^A-Za-z0-9_-]/g, "_"); + const dir = snapshotDir(); + const file = path.join(dir, `${safeProject}-${fileTimestamp(now)}.json`); + + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(payload, null, 2), "utf-8"); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Refusing to mutate the iteration field: could not write the recovery ` + + `snapshot to ${dir} (${reason}). The mutation detaches every ` + + `item's iteration value, so without this file a mid-restore failure ` + + `would lose them permanently.`, + ); + } + + return file; +} + +/** Read a snapshot back for replay. Validates enough to fail clearly, not exhaustively. */ +export function readIterationSnapshot(file: string): IterationSnapshot { + let raw: string; + try { + raw = fs.readFileSync(file, "utf-8"); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot read iteration snapshot ${file}: ${reason}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Iteration snapshot ${file} is not valid JSON`); + } + + const snapshot = parsed as IterationSnapshot; + if (snapshot?.version !== SNAPSHOT_VERSION) { + throw new Error( + `Iteration snapshot ${file} has version ${snapshot?.version}, expected ${SNAPSHOT_VERSION}`, + ); + } + if (!Array.isArray(snapshot.assignments)) { + throw new Error(`Iteration snapshot ${file} has no assignments array`); + } + if (!snapshot.projectId || !snapshot.fieldId) { + throw new Error( + `Iteration snapshot ${file} is missing projectId or fieldId β€” cannot target a restore`, + ); + } + + return snapshot; +} diff --git a/src/tools/iterations.test.ts b/src/tools/iterations.test.ts index da9325a..73cb398 100644 --- a/src/tools/iterations.test.ts +++ b/src/tools/iterations.test.ts @@ -1,12 +1,34 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { createIterationField, assignIssueToIteration, addIteration, updateIteration, + restoreIterationSnapshot, + countByIteration, getProjectId, type GraphQLFn, } from "./iterations.js"; +import { + writeIterationSnapshot, + readIterationSnapshot, +} from "./iteration-snapshots.js"; + +// The real store writes under ~/.config; these tests only care that it is called +// with the right payload at the right point in the sequence. +vi.mock("./iteration-snapshots.js", () => ({ + writeIterationSnapshot: vi.fn(() => "/snap/fake-snapshot.json"), + readIterationSnapshot: vi.fn(), +})); + +const mockWrite = vi.mocked(writeIterationSnapshot); +const mockRead = vi.mocked(readIterationSnapshot); + +beforeEach(() => { + mockWrite.mockClear(); + mockWrite.mockReturnValue("/snap/fake-snapshot.json"); + mockRead.mockReset(); +}); // Helpers to capture what was sent to the GraphQL client function captureCall(mock: ReturnType, callIndex: number) { @@ -761,7 +783,11 @@ describe("addIteration", () => { duration: 7, }); - expect(result.assignmentsRestored).toEqual({ restored: 1, failed: [] }); + expect(result.assignmentsRestored).toEqual({ + restored: 1, + failed: [], + byIteration: { "Sprint 1": 1 }, + }); // The restore mutation runs after the field update and targets the NEW id. const { query, variables } = captureCall(vi.mocked(gql), MUTATION_CALL + 1); @@ -932,7 +958,11 @@ describe("updateIteration", () => { }); // Snapshot said "Sprint 1"; the iteration is now "Renamed". Restore must follow it. - expect(result.assignmentsRestored).toEqual({ restored: 1, failed: [] }); + expect(result.assignmentsRestored).toEqual({ + restored: 1, + failed: [], + byIteration: { Renamed: 1 }, + }); const { variables } = captureCall(vi.mocked(gql), MUTATION_CALL + 1); expect(variables.iterationId).toBe("NEW-ID"); }); @@ -983,3 +1013,507 @@ describe("updateIteration", () => { }); }); }); + +describe("countByIteration", () => { + it("tallies assignments by iteration title", () => { + expect( + countByIteration([ + { itemId: "a", label: "Issue #1", iterationTitle: "Sprint 1" }, + { itemId: "b", label: "Issue #2", iterationTitle: "Sprint 1" }, + { itemId: "c", label: "Issue #3", iterationTitle: "Sprint 2" }, + ]), + ).toEqual({ "Sprint 1": 2, "Sprint 2": 1 }); + }); + + it("returns an empty tally for an empty snapshot", () => { + expect(countByIteration([])).toEqual({}); + }); +}); + +describe("dryRun", () => { + const EXISTING = [ + { id: "iter-1", title: "Sprint 1", startDate: "2026-01-01", duration: 7 }, + { id: "iter-2", title: "Sprint 2", startDate: "2026-01-08", duration: 7 }, + ]; + + it("add_iteration reports before/after without mutating anything", async () => { + const gql = mockWithFieldConfig(EXISTING, {}, { + assignments: [ + { itemId: "item-1", number: 1, title: "Sprint 1" }, + { itemId: "item-2", number: 2, title: "Sprint 1" }, + { itemId: "item-3", number: 3, title: "Sprint 2" }, + ], + }); + + const report: any = await addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 3", + startDate: "2026-01-15", + duration: 7, + dryRun: true, + }); + + expect(report.dryRun).toBe(true); + expect(report.operation).toBe("add_iteration"); + expect(report.fieldName).toBe("Sprint"); + + // Only the two read queries ran β€” no updateProjectV2Field, no restores. + const queries = vi.mocked(gql).mock.calls.map((c) => c[0] as string); + expect(queries).toHaveLength(2); + expect(queries.some((q) => q.includes("updateProjectV2Field"))).toBe(false); + + // And nothing was written to disk: a dry run has no window to recover from. + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("reports the iterations as they are and as they would be", async () => { + const gql = mockWithFieldConfig(EXISTING, {}); + + const report: any = await addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 3", + startDate: "2026-01-15", + duration: 7, + dryRun: true, + }); + + // Before: ids included, since those are what the caller sees today. + expect(report.iterationsBefore.map((i: any) => [i.id, i.title])).toEqual([ + ["iter-1", "Sprint 1"], + ["iter-2", "Sprint 2"], + ]); + // After: no ids β€” every one is regenerated, so promising them would be a lie. + expect(report.iterationsAfter).toHaveLength(3); + expect(report.iterationsAfter[2]).toEqual({ + title: "Sprint 3", + startDate: "2026-01-15", + duration: 7, + }); + expect(report.iterationsAfter.every((i: any) => !("id" in i))).toBe(true); + }); + + it("breaks the assignment count down per iteration, not just a total", async () => { + const gql = mockWithFieldConfig(EXISTING, {}, { + assignments: [ + { itemId: "item-1", number: 1, title: "Sprint 1" }, + { itemId: "item-2", number: 2, title: "Sprint 1" }, + { itemId: "item-3", number: 3, title: "Sprint 2" }, + ], + }); + + const report: any = await addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 3", + startDate: "2026-01-15", + duration: 7, + dryRun: true, + }); + + expect(report.assignments).toEqual({ + total: 3, + byIteration: { "Sprint 1": 2, "Sprint 2": 1 }, + }); + }); + + it("add_iteration reports no rename", async () => { + const gql = mockWithFieldConfig(EXISTING, {}); + const report: any = await addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 3", + startDate: "2026-01-15", + duration: 7, + dryRun: true, + }); + expect(report.rename).toBeNull(); + }); + + it("surfaces a detected rename and how many assignments it remaps", async () => { + const gql = mockWithFieldConfig(EXISTING, {}, { + assignments: [ + { itemId: "item-1", number: 1, title: "Sprint 1" }, + { itemId: "item-2", number: 2, title: "Sprint 1" }, + { itemId: "item-3", number: 3, title: "Sprint 2" }, + ], + }); + + const report: any = await updateIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + iterationId: "iter-1", + title: "Phase 7: B", + dryRun: true, + }); + + expect(report.rename).toEqual({ + from: "Sprint 1", + to: "Phase 7: B", + assignmentsRemapped: 2, + }); + // Counts are keyed by the titles the restore will actually use. + expect(report.assignments.byIteration).toEqual({ + "Phase 7: B": 2, + "Sprint 2": 1, + }); + expect(vi.mocked(gql).mock.calls).toHaveLength(2); + }); + + it("reports null rename when the title is unchanged", async () => { + const gql = mockWithFieldConfig(EXISTING, {}); + const report: any = await updateIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + iterationId: "iter-1", + title: "Sprint 1", + duration: 14, + dryRun: true, + }); + expect(report.rename).toBeNull(); + }); +}); + +describe("duplicate iteration titles", () => { + const EXISTING = [ + { id: "iter-1", title: "Sprint 1", startDate: "2026-01-01", duration: 7 }, + { id: "iter-2", title: "Sprint 2", startDate: "2026-01-08", duration: 7 }, + ]; + + it("refuses to add an iteration whose title already exists", async () => { + const gql = mockWithFieldConfig(EXISTING, {}); + + await expect( + addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 2", + startDate: "2026-01-15", + duration: 7, + }), + ).rejects.toThrow(/titles must be unique/i); + + // Rejected on the config read alone β€” no snapshot query, no mutation. + expect(vi.mocked(gql).mock.calls).toHaveLength(1); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("refuses a rename that collides with a sibling iteration", async () => { + const gql = mockWithFieldConfig(EXISTING, {}); + + await expect( + updateIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + iterationId: "iter-1", + title: "Sprint 2", + }), + ).rejects.toThrow(/titles must be unique/i); + + expect(vi.mocked(gql).mock.calls).toHaveLength(1); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("names the offending title so the caller knows what to rename", async () => { + const gql = mockWithFieldConfig(EXISTING, {}); + await expect( + addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 2", + startDate: "2026-01-15", + duration: 7, + }), + ).rejects.toThrow(/'Sprint 2'/); + }); + + it("blocks a dry run too, rather than reporting a call that cannot succeed", async () => { + const gql = mockWithFieldConfig(EXISTING, {}); + await expect( + addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 2", + startDate: "2026-01-15", + duration: 7, + dryRun: true, + }), + ).rejects.toThrow(/titles must be unique/i); + }); +}); + +describe("snapshot persistence", () => { + const EXISTING = [ + { id: "iter-1", title: "Sprint 1", startDate: "2026-01-01", duration: 7 }, + ]; + + const MUTATION_RESULT = { + updateProjectV2Field: { + projectV2Field: { + id: "field-abc", + name: "Sprint", + configuration: { + iterations: [ + { id: "REGENERATED", title: "Sprint 1", startDate: "2026-01-01", duration: 7 }, + { id: "iter-new", title: "Sprint 2", startDate: "2026-01-08", duration: 7 }, + ], + completedIterations: [], + }, + }, + }, + }; + + it("writes the snapshot BEFORE the destructive mutation", async () => { + const gql = mockWithFieldConfig(EXISTING, MUTATION_RESULT, { + assignments: [{ itemId: "item-1", number: 42, title: "Sprint 1" }], + }); + + // Capture how far the GraphQL sequence had got when the file was written. + let gqlCallsAtWrite = -1; + mockWrite.mockImplementation(() => { + gqlCallsAtWrite = vi.mocked(gql).mock.calls.length; + return "/snap/fake-snapshot.json"; + }); + + await addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 2", + startDate: "2026-01-08", + duration: 7, + }); + + // 2 = config read + snapshot read. The mutation is call 3, so the file + // exists before anything is detached β€” the whole point of #32. + expect(gqlCallsAtWrite).toBe(2); + }); + + it("returns the snapshot path so a failed restore is recoverable", async () => { + const gql = mockWithFieldConfig(EXISTING, MUTATION_RESULT, { + assignments: [{ itemId: "item-1", number: 42, title: "Sprint 1" }], + }); + + const result: any = await addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 2", + startDate: "2026-01-08", + duration: 7, + }); + + expect(result.snapshotPath).toBe("/snap/fake-snapshot.json"); + }); + + it("records the pre-mutation iterations and assignments in the snapshot", async () => { + const gql = mockWithFieldConfig(EXISTING, MUTATION_RESULT, { + assignments: [{ itemId: "item-1", number: 42, title: "Sprint 1" }], + }); + + await addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 2", + startDate: "2026-01-08", + duration: 7, + }); + + const payload = mockWrite.mock.calls[0][0]; + expect(payload.operation).toBe("add_iteration"); + expect(payload.projectId).toBe("proj-xyz"); + expect(payload.fieldId).toBe("field-abc"); + expect(payload.fieldName).toBe("Sprint"); + // Ids kept: recovering by hand needs to know what was there. + expect(payload.iterationsBefore).toEqual(EXISTING); + expect(payload.assignments).toEqual([ + { itemId: "item-1", label: "Issue #42", iterationTitle: "Sprint 1" }, + ]); + }); + + it("stores the REMAPPED assignments for a rename, so the file replays standalone", async () => { + const renameResult = { + updateProjectV2Field: { + projectV2Field: { + id: "field-abc", + name: "Sprint", + configuration: { + iterations: [ + { id: "NEW-ID", title: "Renamed", startDate: "2026-01-01", duration: 7 }, + ], + completedIterations: [], + }, + }, + }, + }; + const gql = mockWithFieldConfig(EXISTING, renameResult, { + assignments: [{ itemId: "item-1", number: 42, title: "Sprint 1" }], + }); + + await updateIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + iterationId: "iter-1", + title: "Renamed", + }); + + const payload = mockWrite.mock.calls[0][0]; + expect(payload.operation).toBe("update_iteration"); + // "Sprint 1" would no longer resolve β€” the file holds the post-rename title. + expect(payload.assignments[0].iterationTitle).toBe("Renamed"); + }); + + it("does not mutate when the snapshot cannot be written", async () => { + const gql = mockWithFieldConfig(EXISTING, MUTATION_RESULT, { + assignments: [{ itemId: "item-1", number: 42, title: "Sprint 1" }], + }); + mockWrite.mockImplementation(() => { + throw new Error("EROFS: read-only file system"); + }); + + await expect( + addIteration(gql, { + projectId: "proj-xyz", + fieldId: "field-abc", + title: "Sprint 2", + startDate: "2026-01-08", + duration: 7, + }), + ).rejects.toThrow(/read-only file system/); + + // Failing closed: no updateProjectV2Field ran, so nothing was detached. + const queries = vi.mocked(gql).mock.calls.map((c) => c[0] as string); + expect(queries.some((q) => q.includes("updateProjectV2Field"))).toBe(false); + }); +}); + +describe("restoreIterationSnapshot", () => { + const SNAPSHOT = { + version: 1, + createdAt: "2026-08-28T12:00:00.000Z", + operation: "add_iteration" as const, + projectId: "proj-xyz", + fieldId: "field-abc", + fieldName: "Sprint", + iterationsBefore: [ + { id: "iter-old", title: "Sprint 1", startDate: "2026-01-01", duration: 7 }, + ], + assignments: [ + { itemId: "item-1", label: "Issue #42", iterationTitle: "Sprint 1" }, + { itemId: "item-2", label: "Issue #43", iterationTitle: "Sprint 2" }, + ], + }; + + /** Config read, then one restore mutation per assignment. */ + function mockForReplay() { + const mock = vi.fn().mockResolvedValueOnce({ + node: { + fields: { + nodes: [ + { + id: "field-abc", + name: "Sprint", + configuration: { + duration: 7, + startDay: 1, + iterations: [ + { id: "fresh-1", title: "Sprint 1", startDate: "2026-01-01", duration: 7 }, + { id: "fresh-2", title: "Sprint 2", startDate: "2026-01-08", duration: 7 }, + ], + completedIterations: [], + }, + }, + ], + }, + }, + }); + for (let i = 0; i < SNAPSHOT.assignments.length; i++) { + mock.mockResolvedValueOnce({ + updateProjectV2ItemFieldValue: { projectV2Item: { id: "item" } }, + }); + } + return mock as unknown as GraphQLFn; + } + + it("replays the snapshot against the field's current iteration ids", async () => { + mockRead.mockReturnValue(SNAPSHOT); + const gql = mockForReplay(); + + const result: any = await restoreIterationSnapshot(gql, "/snap/s.json"); + + expect(mockRead).toHaveBeenCalledWith("/snap/s.json"); + expect(result.assignmentsRestored.restored).toBe(2); + expect(result.assignmentsRestored.failed).toEqual([]); + + // The ids come from the fresh read, not from the (stale) snapshot. + const restores = vi.mocked(gql).mock.calls.slice(1); + expect(restores.map((c) => (c[1] as any).iterationId)).toEqual([ + "fresh-1", + "fresh-2", + ]); + }); + + it("reports where the assignments landed, per iteration", async () => { + mockRead.mockReturnValue(SNAPSHOT); + const result: any = await restoreIterationSnapshot(mockForReplay(), "/snap/s.json"); + + expect(result.assignmentsRestored.byIteration).toEqual({ + "Sprint 1": 1, + "Sprint 2": 1, + }); + // Snapshot total vs. restored total is the diff that verifies the replay. + expect(result.assignmentsInSnapshot).toBe(2); + }); + + it("carries the snapshot's provenance into the result", async () => { + mockRead.mockReturnValue(SNAPSHOT); + const result: any = await restoreIterationSnapshot(mockForReplay(), "/snap/s.json"); + + expect(result.snapshotPath).toBe("/snap/s.json"); + expect(result.operation).toBe("add_iteration"); + expect(result.createdAt).toBe("2026-08-28T12:00:00.000Z"); + expect(result.fieldName).toBe("Sprint"); + }); + + it("reports entries whose iteration no longer exists instead of failing the replay", async () => { + mockRead.mockReturnValue({ + ...SNAPSHOT, + assignments: [ + { itemId: "item-1", label: "Issue #42", iterationTitle: "Deleted Sprint" }, + ], + }); + const gql = vi.fn().mockResolvedValueOnce({ + node: { + fields: { + nodes: [ + { + id: "field-abc", + name: "Sprint", + configuration: { + duration: 7, + startDay: 1, + iterations: [ + { id: "fresh-1", title: "Sprint 1", startDate: "2026-01-01", duration: 7 }, + ], + completedIterations: [], + }, + }, + ], + }, + }, + }) as unknown as GraphQLFn; + + const result: any = await restoreIterationSnapshot(gql, "/snap/s.json"); + expect(result.assignmentsRestored.restored).toBe(0); + expect(result.assignmentsRestored.failed).toHaveLength(1); + expect(result.assignmentsRestored.failed[0].label).toBe("Issue #42"); + }); + + it("propagates a read failure rather than reporting a successful no-op", async () => { + mockRead.mockImplementation(() => { + throw new Error("Cannot read iteration snapshot /snap/gone.json: ENOENT"); + }); + await expect( + restoreIterationSnapshot(vi.fn() as unknown as GraphQLFn, "/snap/gone.json"), + ).rejects.toThrow(/ENOENT/); + }); +}); diff --git a/src/tools/iterations.ts b/src/tools/iterations.ts index dba3c21..86dac45 100644 --- a/src/tools/iterations.ts +++ b/src/tools/iterations.ts @@ -1,4 +1,9 @@ import { graphql } from "@octokit/graphql"; +import { + readIterationSnapshot, + writeIterationSnapshot, + type IterationOperation, +} from "./iteration-snapshots.js"; export type GraphQLFn = typeof graphql; @@ -288,6 +293,8 @@ export interface AddIterationInput { title: string; startDate: string; duration: number; + /** Report what the call would do, and mutate nothing. See `IterationDryRunReport`. */ + dryRun?: boolean; } export interface UpdateIterationInput { @@ -297,6 +304,8 @@ export interface UpdateIterationInput { title?: string; startDate?: string; duration?: number; + /** Report what the call would do, and mutate nothing. See `IterationDryRunReport`. */ + dryRun?: boolean; } interface IterationConfig { @@ -329,6 +338,67 @@ export interface IterationAssignment { iterationTitle: string; } +/** Assignment totals per iteration title β€” the shape both the dry run and the result report. */ +export type AssignmentsByIteration = Record; + +export interface IterationDryRunReport { + dryRun: true; + operation: IterationOperation; + fieldId: string; + fieldName: string; + /** Current iterations, ids included. */ + iterationsBefore: IterationConfig[]; + /** Iterations as they would be after the call. No ids β€” every one is regenerated. */ + iterationsAfter: Array<{ title: string; startDate: string; duration: number }>; + /** + * Non-null when a title change is detected, naming how many snapshot entries + * would be remapped onto the new title. The remap is otherwise invisible from + * outside, and it is the single step between a rename and orphaning exactly the + * iteration being renamed. + */ + rename: { from: string; to: string; assignmentsRemapped: number } | null; + assignments: { total: number; byIteration: AssignmentsByIteration }; +} + +/** Tally a snapshot by iteration title. Counts alone cannot answer "restored to where". */ +export function countByIteration( + snapshot: IterationAssignment[], +): AssignmentsByIteration { + const counts: AssignmentsByIteration = {}; + for (const entry of snapshot) { + counts[entry.iterationTitle] = (counts[entry.iterationTitle] ?? 0) + 1; + } + return counts; +} + +/** + * Reject a payload whose iterations do not have unique titles. + * + * Restore is keyed by title β€” the only identifier that survives + * `updateProjectV2Field`, which regenerates every id. Two iterations sharing a + * title collapse into one entry in that lookup, so every affected item silently + * lands on whichever id came last: the mutation succeeds, the restore reports no + * failures, and the items are simply on the wrong sprint. Cheaper to refuse. + */ +function assertUniqueTitles( + iterations: Array<{ title: string }>, +): void { + const seen = new Set(); + const duplicated = new Set(); + for (const it of iterations) { + if (seen.has(it.title)) duplicated.add(it.title); + seen.add(it.title); + } + if (duplicated.size === 0) return; + + const names = [...duplicated].map((t) => `'${t}'`).join(", "); + throw new Error( + `Iteration titles must be unique, but this call would leave duplicates: ${names}. ` + + `Assignments are restored by title, so duplicates would silently reassign items ` + + `to the wrong iteration. Rename one side first.`, + ); +} + async function getIterationFieldConfig( graphqlFn: GraphQLFn, projectId: string, @@ -467,9 +537,14 @@ export async function restoreIterationAssignments( fieldId: string, snapshot: IterationAssignment[], freshIterations: Array<{ id: string; title: string }>, -): Promise<{ restored: number; failed: IterationAssignment[] }> { +): Promise<{ + restored: number; + failed: IterationAssignment[]; + byIteration: AssignmentsByIteration; +}> { const idByTitle = new Map(freshIterations.map((i) => [i.title, i.id])); const failed: IterationAssignment[] = []; + const byIteration: AssignmentsByIteration = {}; let restored = 0; for (const entry of snapshot) { @@ -493,12 +568,14 @@ export async function restoreIterationAssignments( { projectId, itemId: entry.itemId, fieldId, iterationId }, ); restored++; + byIteration[entry.iterationTitle] = + (byIteration[entry.iterationTitle] ?? 0) + 1; } catch { failed.push(entry); } } - return { restored, failed }; + return { restored, failed, byIteration }; } export async function addIteration( @@ -525,6 +602,9 @@ export async function addIteration( }, ]; + // Fail before spending a round trip on the snapshot query. + assertUniqueTitles(allIterations); + // Capture assignments first β€” the mutation below detaches every one of them. const snapshot = await snapshotIterationAssignments( graphqlFn, @@ -532,6 +612,33 @@ export async function addIteration( config.name, ); + if (input.dryRun) { + const report: IterationDryRunReport = { + dryRun: true, + operation: "add_iteration", + fieldId: input.fieldId, + fieldName: config.name, + iterationsBefore: config.iterations, + iterationsAfter: allIterations, + rename: null, + assignments: { + total: snapshot.length, + byIteration: countByIteration(snapshot), + }, + }; + return report; + } + + // On disk before the destructive call, so the restore window is survivable. + const snapshotPath = writeIterationSnapshot({ + operation: "add_iteration", + projectId: input.projectId, + fieldId: input.fieldId, + fieldName: config.name, + iterationsBefore: config.iterations, + assignments: snapshot, + }); + const result = await graphqlFn( ` mutation($fieldId: ID!, $duration: Int!, $startDate: Date!, $iterations: ${ITERATIONS_ARG_TYPE}) { @@ -586,7 +693,7 @@ export async function addIteration( ], ); - return { ...field, assignmentsRestored: restoreReport }; + return { ...field, assignmentsRestored: restoreReport, snapshotPath }; } export async function updateIteration( @@ -621,6 +728,10 @@ export async function updateIteration( }; }); + // A rename that collides with a sibling would merge both iterations' entries onto + // one title, so this has to run against the post-rename list. + assertUniqueTitles(allIterations); + // Renaming an iteration breaks title-based restore, so remap the snapshot entries // that pointed at the old title before restoring. const snapshot = await snapshotIterationAssignments( @@ -637,6 +748,43 @@ export async function updateIteration( ) : snapshot; + if (input.dryRun) { + const report: IterationDryRunReport = { + dryRun: true, + operation: "update_iteration", + fieldId: input.fieldId, + fieldName: config.name, + iterationsBefore: config.iterations, + iterationsAfter: allIterations, + rename: renamedTo + ? { + from: target.title, + to: renamedTo, + assignmentsRemapped: snapshot.filter( + (e) => e.iterationTitle === target.title, + ).length, + } + : null, + // Post-remap, so the counts are keyed by the titles the restore will use. + assignments: { + total: adjustedSnapshot.length, + byIteration: countByIteration(adjustedSnapshot), + }, + }; + return report; + } + + // On disk before the destructive call, so the restore window is survivable. + const snapshotPath = writeIterationSnapshot({ + operation: "update_iteration", + projectId: input.projectId, + fieldId: input.fieldId, + fieldName: config.name, + iterationsBefore: config.iterations, + // Already remapped β€” the file is directly replayable without knowing the rename. + assignments: adjustedSnapshot, + }); + const result = await graphqlFn( ` mutation($fieldId: ID!, $duration: Int!, $startDate: Date!, $iterations: ${ITERATIONS_ARG_TYPE}) { @@ -691,7 +839,46 @@ export async function updateIteration( ], ); - return { ...field, assignmentsRestored: restoreReport }; + return { ...field, assignmentsRestored: restoreReport, snapshotPath }; +} + +/** + * Replay a snapshot file written by `add_iteration` or `update_iteration`. + * + * The recovery half of #32. The snapshot holds assignments keyed by iteration + * title, already remapped for any rename, so replay only needs the field's current + * iteration ids β€” which is exactly what a fresh config read provides. Idempotent: + * re-applying an assignment an item already has is a no-op server-side. + */ +export async function restoreIterationSnapshot( + graphqlFn: GraphQLFn, + snapshotPath: string, +) { + const snapshot = readIterationSnapshot(snapshotPath); + + const config = await getIterationFieldConfig( + graphqlFn, + snapshot.projectId, + snapshot.fieldId, + ); + + const restoreReport = await restoreIterationAssignments( + graphqlFn, + snapshot.projectId, + snapshot.fieldId, + snapshot.assignments, + config.iterations, + ); + + return { + snapshotPath, + operation: snapshot.operation, + createdAt: snapshot.createdAt, + fieldId: snapshot.fieldId, + fieldName: snapshot.fieldName, + assignmentsInSnapshot: snapshot.assignments.length, + assignmentsRestored: restoreReport, + }; } export async function assignIssueToIteration(