diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6d91ddd..ed8a2cf 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "super-board", - "version": "3.0.4", + "version": "3.0.5", "description": "Autonomous GitHub Project board runner. Primary skills: super-board, super-build, super-qa, super-review, super-collect. Secondary skills: visual, git-sync, ui-refine-loop. Installed as a plugin it ships the skills only; run /super-board onboard to add the guard hooks, .claude/bin scripts and workflows (get.sh / install.sh install all of it in one go).", "author": { "name": "Eric Tech" diff --git a/README.md b/README.md index 320f4d9..ccd95a2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

8 skills (5 you type, 3 the board runs) ยท 9 commands ยท 6 guard hooks

-Version +Version Host License

diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 035fa49..52fdf39 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,22 @@ # Release notes +## v3.0.5 โ€” 2026-10-03 + +Migrating a board no longer leaves its cards with no status. + +- ๐Ÿ› **Every option rewrite is undone.** Rewriting the Status options gives every option a + new id and clears the Status of every card. `board-migrate --prune-empty` rewrote them + without restoring, so a live board's cards all fell to "No status". The restore now runs + after any rewrite, whatever triggered it. +- ๐Ÿ’พ **Snapshot on disk first.** Before the first rewrite, every card's status is saved to + `.claude/super-board/backup/board--.json` (path in `status_backup`), so a run + that dies mid-way can still be put back. +- ๐Ÿ”Ž **Snapshot that cannot be empty by accident.** When `gh project item-list` surfaces no + `status` key on any card, statuses are read over GraphQL instead; item reads now go past + 500 cards. +- ๐Ÿงช **Regressions.** Offline scenarios cover prune-empty on a board in use, the GraphQL + fallback, and a dry run that writes nothing. + ## v3.0.4 โ€” 2026-10-03 The skill map reads from left to right, with nested skill families and clearer drill-down diff --git a/VERSION b/VERSION index b0f2dcb..eca690e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.4 +3.0.5 diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index 880d70b..a4afc9b 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -33,6 +33,9 @@ creates the three labels, maps old type labels, labels every card `qa` on a board that was "qa-only" (--qa-all), moves Skipped cards to Done, removes the Skipped option, and puts back any card status the option rewrite cleared. Cards are never lost. +Before the first option rewrite it saves every card's status to +.claude/super-board/backup/board--.json, so a run that dies mid-way can still +be put back by hand. Exit: 0 ok ยท 1 check found red items ยท 2 a gh call failed ยท 64 usage ยท 66 pack not found. Stdlib only. @@ -537,10 +540,44 @@ def item_labels(it): return {(x.get("name") if isinstance(x, dict) else str(x)).lower() for x in raw} -def board_migrate(owner, number, repo, qa_all=False, dry=False, prune_empty=False): - """Bring one GitHub Project to the v3 shape. Never removes a card.""" +ITEM_STATUS_Q = ("query($id:ID!,$after:String){node(id:$id){... on ProjectV2{items(first:100,after:$after){" + "pageInfo{hasNextPage endCursor} nodes{id fieldValueByName(name:\"Status\"){" + "... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}}") + + +def board_items(owner, number, pid): + """Every card and its Status, as {item_id: status-or-None} beside the raw items. + + `gh project item-list` names the Status column by its lowercased field name + (`status`) and leaves the key out on a card with no status. When no card carries + the key at all, it is read again over GraphQL (fieldValueByName "Status") rather + than trusted: a snapshot of all-None would make the restore a silent no-op.""" + items = gh_json("project", "item-list", str(number), "--owner", owner, "--format", "json", + "--limit", "5000").get("items", []) + if items and not any("status" in it for it in items): + statuses, after = {}, None + while True: + page = gql(ITEM_STATUS_Q, {"id": pid, "after": after})["data"]["node"]["items"] + for n in page["nodes"]: + statuses[n["id"]] = (n.get("fieldValueByName") or {}).get("name") + if not page["pageInfo"]["hasNextPage"]: + break + after = page["pageInfo"]["endCursor"] + for it in items: + if statuses.get(it["id"]): + it["status"] = statuses[it["id"]] + return items, {it["id"]: it.get("status") for it in items} + + +def board_migrate(owner, number, repo, qa_all=False, dry=False, prune_empty=False, root=None): + """Bring one GitHub Project to the v3 shape. Never removes a card. + + updateProjectV2Field with singleSelectOptions replaces the whole option list: every + option gets a new id and GitHub clears the Status of EVERY card on the board. So every + rewrite is preceded by a snapshot on disk and followed by a restore, whatever path + triggered it (added columns, --prune-empty, Skipped removal).""" res = {"added_columns": [], "labels_created": [], "labels_mapped": 0, "qa_labelled": 0, - "skipped_moved": 0, "skipped_removed": False, "restored": 0} + "skipped_moved": 0, "skipped_removed": False, "restored": 0, "status_backup": None} proj = gh_json("project", "view", str(number), "--owner", owner, "--format", "json") pid, res["url"] = proj.get("id"), proj.get("url") field = status_field(owner, number) @@ -548,8 +585,7 @@ def board_migrate(owner, number, repo, qa_all=False, dry=False, prune_empty=Fals raise RuntimeError("project has no Status field") fid = field["id"] opts = gql(OPTIONS_Q, {"id": fid})["data"]["node"]["options"] - items = gh_json("project", "item-list", str(number), "--owner", owner, "--format", "json", "--limit", "500").get("items", []) - snapshot = {it["id"]: it.get("status") for it in items} + items, snapshot = board_items(owner, number, pid) names_low = {o["name"].lower(): o for o in opts} in_use = {it.get("status") for it in items} @@ -572,9 +608,23 @@ def option_inputs(keep_skipped): return out_ has_skipped = "skipped" in names_low + rewrote = False + + def rewrite(keep_skipped): + nonlocal rewrote + if not rewrote: + # On disk before the first rewrite: a crash after it still leaves a way back. + bdir = os.path.join(root or os.getcwd(), ".claude", "super-board", "backup") + os.makedirs(bdir, exist_ok=True) + path = os.path.join(bdir, f"board-{number}-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json") + write_json(path, snapshot) + res["status_backup"] = path + rewrote = True + got = gql(UPDATE_M, {"id": fid, "opts": option_inputs(keep_skipped)}) + return got["data"]["updateProjectV2Field"]["projectV2Field"]["options"] + if (res["added_columns"] or prune_empty) and not dry: - opts_now = gql(UPDATE_M, {"id": fid, "opts": option_inputs(keep_skipped=True)}) - opts_now = opts_now["data"]["updateProjectV2Field"]["projectV2Field"]["options"] + opts_now = rewrite(keep_skipped=True) else: opts_now = opts ids = {o["name"].lower(): o["id"] for o in opts_now} @@ -615,18 +665,17 @@ def set_status(item_id, name): snapshot[it["id"]] = "Done" res["skipped_moved"] += 1 if not dry: - opts_now = gql(UPDATE_M, {"id": fid, "opts": option_inputs(keep_skipped=False)}) - opts_now = opts_now["data"]["updateProjectV2Field"]["projectV2Field"]["options"] + opts_now = rewrite(keep_skipped=False) ids = {o["name"].lower(): o["id"] for o in opts_now} res["skipped_removed"] = True - # Rewriting options can clear a card's status. Put every one back. - if not dry and (res["added_columns"] or has_skipped): - now = gh_json("project", "item-list", str(number), "--owner", owner, "--format", "json", "--limit", "500").get("items", []) - for it in now: - want = snapshot.get(it["id"]) - if want and it.get("status") != want and want.lower() in ids: - set_status(it["id"], want) + # Rewriting options clears card statuses. After ANY rewrite, put every one back. + if rewrote and not dry: + _, now = board_items(owner, number, pid) + for item_id, have in now.items(): + want = snapshot.get(item_id) + if want and have != want and want.lower() in ids: + set_status(item_id, want) res["restored"] += 1 return res @@ -690,7 +739,7 @@ def main(argv): if not (owner and number and repo): return 64 qa_all = "--qa-all" in rest or bool(cfg.get("_qa_all")) - res = board_migrate(owner, int(number), repo, qa_all, dry, "--prune-empty" in rest) + res = board_migrate(owner, int(number), repo, qa_all, dry, "--prune-empty" in rest, root) if cfg_path and cfg.pop("_qa_all", None) is not None and not dry: write_json(cfg_path, cfg) return out(res) diff --git a/skills/super-board/VERSION b/skills/super-board/VERSION index b0f2dcb..eca690e 100644 --- a/skills/super-board/VERSION +++ b/skills/super-board/VERSION @@ -1 +1 @@ -3.0.4 +3.0.5 diff --git a/tests/test-setup.sh b/tests/test-setup.sh index 84df003..4428815 100644 --- a/tests/test-setup.sh +++ b/tests/test-setup.sh @@ -116,7 +116,12 @@ elif a[:2] == ["project", "field-list"]: elif a[:2] == ["project", "view"]: print(json.dumps({"id": "P" + a[2], "url": "https://github.com/users/eric/projects/" + a[2]})) elif a[:2] == ["project", "item-list"]: - print(json.dumps({"items": st["items"]})) + items = st["items"] + if st.get("hide_status"): # a gh that does not surface the Status column + items = [{k: v for k, v in it.items() if k != "status"} for it in items] + else: # like gh: no key on a card with no status + items = [{k: v for k, v in it.items() if not (k == "status" and v is None)} for it in items] + print(json.dumps({"items": items})) elif a[:2] == ["project", "item-edit"]: item, opt = a[a.index("--id") + 1], a[a.index("--single-select-option-id") + 1] name = next(o["name"] for o in st["options"]["2"] if o["id"] == opt) @@ -125,7 +130,12 @@ elif a[:2] == ["project", "item-edit"]: save() elif a[:2] == ["api", "graphql"]: body = json.loads(sys.stdin.read()) - if body["query"].startswith("query"): + if "fieldValueByName" in body["query"]: + nodes = [{"id": it["id"], "fieldValueByName": {"name": it["status"]} if it.get("status") else None} + for it in st["items"]] + print(json.dumps({"data": {"node": {"items": {"pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": nodes}}}})) + elif body["query"].startswith("query"): print(json.dumps({"data": {"node": {"options": st["options"]["2"]}}})) else: st["gen"] = st.get("gen", 0) + 1 @@ -135,7 +145,7 @@ elif a[:2] == ["api", "graphql"]: names = {o["name"] for o in new} # Like the real API can: rewriting options clears statuses (here: every card in "Ready"). for it in st["items"]: - if it.get("status") == "Ready" or it.get("status") not in names: it["status"] = None + if st.get("wipe_all") or it.get("status") == "Ready" or it.get("status") not in names: it["status"] = None save() print(json.dumps({"data": {"updateProjectV2Field": {"projectV2Field": {"options": [{"id": o["id"], "name": o["name"]} for o in new]}}}})) elif a[:2] == ["label", "list"]: @@ -185,7 +195,7 @@ JSON : > "$GH_LOG" CFG="$WORK/books.json" echo '{"project":{"owner":"eric","number":2},"repo":{"remote":"https://github.com/eric/books.git"},"_qa_all":true}' > "$CFG" -OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --config "$CFG") +OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --config "$CFG" --root "$WORK") echo "$OUT" | q '.added_columns == ["Backlog","Building"] and .labels_created == ["qa","feature"]' || fail "columns/labels wrong: $OUT" echo "$OUT" | q '.labels_mapped == 1 and .qa_labelled == 1 and .skipped_moved == 1 and .skipped_removed == true' \ || fail "mapping / qa-all / Skipped counts wrong: $OUT" @@ -196,6 +206,8 @@ q '[.options["2"][].name] == ["Backlog","Ready","Building","QA","Review","Blocke q '[.items[] | {(.id): .status}] | add == {"I1":"Done","I2":"Ready","I3":"Ready","I4":"Review"}' "$GH_STATE" \ || fail "every card must keep its column (Skipped โ†’ Done): $(jq -c .items "$GH_STATE")" echo "$OUT" | q '.restored >= 2' || fail "cleared statuses must be restored: $OUT" +q '. == {"I1":"Skipped","I2":"Ready","I3":"Ready","I4":"Review"}' "$(echo "$OUT" | jq -r .status_backup)" \ + || fail "the pre-rewrite snapshot is saved to disk: $OUT" q 'has("_qa_all") | not' "$CFG" || fail "the qa-all marker is dropped once applied" # 9 โ€” a brand-new board: --prune-empty swaps GitHub's default Todo / In Progress for the seven. @@ -204,9 +216,57 @@ cat > "$GH_STATE" <<'JSON' "options":{"2":[{"id":"t","name":"Todo","color":"GRAY","description":""},{"id":"p","name":"In Progress","color":"YELLOW","description":""}, {"id":"d","name":"Done","color":"GREEN","description":""}]},"items":[]} JSON -OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty) +OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty --root "$WORK") q '[.options["2"][].name] == ["Backlog","Ready","Building","QA","Review","Blocked","Done"]' "$GH_STATE" \ || fail "a new board should end with exactly the seven: $(jq -c '.options["2"]' "$GH_STATE")" echo "$OUT" | q '.labels_created == []' || fail "existing labels are not created again: $OUT" -echo "PASS: test-setup.sh (9 scenarios)" +# 10 โ€” --prune-empty on a live board that already has the seven: the option rewrite wipes +# EVERY card's status (as GitHub does), and the restore must still run โ€” it used to +# run only for added columns or Skipped, so 114 cards fell to "No status". +cat > "$GH_STATE" <<'JSON' +{"projects":[],"labels":["qa","bug","feature"],"wipe_all":true, + "options":{"2":[{"id":"k","name":"Backlog","color":"GRAY","description":""},{"id":"r","name":"Ready","color":"BLUE","description":""}, + {"id":"u","name":"Building","color":"YELLOW","description":""},{"id":"q","name":"QA","color":"ORANGE","description":""}, + {"id":"v","name":"Review","color":"PURPLE","description":""},{"id":"b","name":"Blocked","color":"RED","description":""}, + {"id":"d","name":"Done","color":"GREEN","description":""},{"id":"t","name":"Todo","color":"GRAY","description":""}]}, + "items":[{"id":"I1","status":"Done","content":{"type":"Issue","number":1}}, + {"id":"I2","status":"Building","content":{"type":"Issue","number":2}}, + {"id":"I3","status":"Review","content":{"type":"Issue","number":3}}, + {"id":"I4","status":null,"content":{"type":"Issue","number":4}}]} +JSON +: > "$GH_LOG" +OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty --root "$WORK") +q '[.options["2"][].name] == ["Backlog","Ready","Building","QA","Review","Blocked","Done"]' "$GH_STATE" \ + || fail "the unused Todo should be pruned: $(jq -c '.options["2"]' "$GH_STATE")" +q '[.items[] | {(.id): .status}] | add == {"I1":"Done","I2":"Building","I3":"Review","I4":null}' "$GH_STATE" \ + || fail "prune-empty must put every wiped status back: $(jq -c .items "$GH_STATE")" +echo "$OUT" | q '.restored == 3' || fail "three cards should be restored: $OUT" +BK=$(echo "$OUT" | jq -r .status_backup) +case "$BK" in "$WORK/.claude/super-board/backup/board-2-"*.json) ;; *) fail "backup path wrong: $BK" ;; esac +q '. == {"I1":"Done","I2":"Building","I3":"Review","I4":null}' "$BK" || fail "backup must hold the pre-rewrite statuses: $(cat "$BK")" + +# 11 โ€” a gh that does not surface `status`: the snapshot falls back to GraphQL +# fieldValueByName("Status"), so the restore still has something to restore. +jq '.items = [{"id":"I1","status":"Done"},{"id":"I2","status":"Building"}] | .hide_status = true + | .options["2"] += [{"id":"t","name":"Todo","color":"GRAY","description":""}]' "$GH_STATE" > "$WORK/s" && mv "$WORK/s" "$GH_STATE" +OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty --root "$WORK") +q '[.items[] | {(.id): .status}] | add == {"I1":"Done","I2":"Building"}' "$GH_STATE" \ + || fail "statuses read over GraphQL must be restored: $(jq -c .items "$GH_STATE") $OUT" +echo "$OUT" | q '.restored == 2' || fail "two cards restored via the GraphQL snapshot: $OUT" + +# 12 โ€” --dry-run writes nothing: no option rewrite, no card move, no label, no backup file. +jq '.items = [{"id":"I1","status":"Skipped","content":{"type":"Issue","number":1}}, + {"id":"I2","status":"Ready","labels":["build"],"content":{"type":"Issue","number":2}}] + | .hide_status = false | .labels = [] | del(.gen) + | .options["2"] = [{"id":"r","name":"Ready","color":"BLUE","description":""},{"id":"s","name":"Skipped","color":"GRAY","description":""}]' \ + "$GH_STATE" > "$WORK/s" && mv "$WORK/s" "$GH_STATE" +cp "$GH_STATE" "$WORK/before.json"; : > "$GH_LOG"; rm -rf "$WORK/dry"; mkdir -p "$WORK/dry" +OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty --qa-all --dry-run --root "$WORK/dry") +cmp -s "$GH_STATE" "$WORK/before.json" || fail "dry-run changed board state: $(cat "$GH_STATE")" +! grep -Eq '^(project item-edit|label create|issue edit)' "$GH_LOG" || fail "dry-run made a write: $(cat "$GH_LOG")" +[ "$(grep -c '^api graphql' "$GH_LOG")" = 1 ] || fail "dry-run should only read options over GraphQL: $(cat "$GH_LOG")" +[ ! -e "$WORK/dry/.claude" ] || fail "dry-run must not write a backup file" +echo "$OUT" | q '.status_backup == null and .restored == 0 and .added_columns != []' || fail "dry-run report wrong: $OUT" + +echo "PASS: test-setup.sh (12 scenarios)"