Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<p>8 skills (5 you type, 3 the board runs) · 9 commands · 6 guard hooks</p>

<p>
<img alt="Version" src="https://img.shields.io/badge/version-3.0.4-1f883d?style=flat-square">
<img alt="Version" src="https://img.shields.io/badge/version-3.0.5-1f883d?style=flat-square">
<img alt="Host" src="https://img.shields.io/badge/Claude%20Code-skills-d97757?style=flat-square">
<img alt="License" src="https://img.shields.io/badge/license-MIT-0969da?style=flat-square">
</p>
Expand Down
17 changes: 17 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -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-<number>-<ts>.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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.4
3.0.5
83 changes: 66 additions & 17 deletions scripts/super-board-setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<number>-<ts>.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.
Expand Down Expand Up @@ -537,19 +540,52 @@ 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)
if not field:
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}
Expand All @@ -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}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion skills/super-board/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.4
3.0.5
72 changes: 66 additions & 6 deletions tests/test-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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"]:
Expand Down Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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)"
Loading