diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4e256f8120..4c7d98ed40 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "doperpowers", "description": "Fork of Superpowers (obra/superpowers): a full software-development methodology for coding agents — brainstorm, plan, subagent-driven TDD, review — plus fork-specific skills like orchestrating-daemons.", - "version": "7.3.0", + "version": "7.4.0", "source": "./", "author": { "name": "SSFSKIM", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5f26b7938f..02a992e140 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "doperpowers", "description": "Fork of Superpowers (obra/superpowers): a full software-development methodology for coding agents — brainstorm, plan, subagent-driven TDD, review — plus fork-specific skills like orchestrating-daemons. Derived from Jesse Vincent's MIT-licensed Superpowers.", - "version": "7.3.0", + "version": "7.4.0", "author": { "name": "SSFSKIM", "email": "supremekim17@gmail.com" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0f34a6b11c..d1e7c775ae 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.3.0", + "version": "7.4.0", "description": "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.", "author": { "name": "Jesse Vincent", diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 9a405e2533..5806d64076 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,32 @@ # Doperpowers Release Notes +## v7.4.0 (2026-07-07) + +### Issue Tracker — the board's node detail now shows GitHub-linked PRs + +Clicking a ticket on the hosted board showed its issue number, created, and +updated dates, but never the pull requests linked to it. The renderer *could* +show a PR — but only from a `pr:` line manually written into the issue body when +a ticket was moved to `in-review`. Tickets that reached **done** the normal way, +via a merged `Closes #N` pull request, never got that line written, so their PR +was invisible. And a single `pr:` value could never represent more than one PR. + +This release reads PR links straight from GitHub, where they already exist: + +- **`_board.py` snapshot** now fetches `closedByPullRequestsReferences` (the PRs + that close the issue — this is what fills the merge-autoclose gap) and + `CROSS_REFERENCED_EVENT` timeline items (PRs that merely mention the issue). + They are deduped by number into a new per-ticket `prs` list; a PR that both + closes and references keeps the stronger "closes" relation. +- **`board-map.template.html` detail panel** renders that list as a **PRs** + section — one line per PR, linkified, with a state chip (merged / open / + closed / draft) coloured to GitHub's own palette and a "· closes" marker on + closing PRs. Multiple linked PRs are handled natively. The old single `pr:` + meta stays as a fallback when GitHub reports no linkage. + +No new dependencies and no change to how the board is rendered or deployed — the +same `board-map.sh --write` on both the GitHub Pages and Cloudflare templates. + ## v7.3.0 (2026-07-07) ### Issue Tracker — a private hosted board via Cloudflare Pages + Access diff --git a/package.json b/package.json index eff3bb9ecf..3755977f80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.3.0", + "version": "7.4.0", "description": "Doperpowers skills and runtime bootstrap for coding agents", "type": "module", "main": ".opencode/plugins/doperpowers.js", diff --git a/skills/issue-tracker/scripts/_board.py b/skills/issue-tracker/scripts/_board.py index ff53465281..7292086bf0 100644 --- a/skills/issue-tracker/scripts/_board.py +++ b/skills/issue-tracker/scripts/_board.py @@ -142,6 +142,16 @@ def _nums(val): assignees(first:10) { nodes { login } } parent { number } blockedBy(first:50) { nodes { number } } + closedByPullRequestsReferences(first:20, includeClosedPrs:true) { + nodes { number url state isDraft } + } + timelineItems(itemTypes:[CROSS_REFERENCED_EVENT], first:40) { + nodes { + ... on CrossReferencedEvent { + source { __typename ... on PullRequest { number url state isDraft } } + } + } + } } } } @@ -181,6 +191,23 @@ def snapshot(refresh=False): status = [l[len(STATUS_PREFIX):] for l in labels if l.startswith(STATUS_PREFIX)] meta = parse_meta(it["body"]) spawned = _nums(meta.get("spawned-by")) + # Native GitHub PR linkage — closesByPR fills the merge-autoclose gap + # (a "Closes #N" merge never writes a pr: meta), cross-refs catch PRs + # that merely mention the issue. Keyed by number → deduped; a PR that + # both closes and cross-refs keeps the stronger "closes" relation. + prs = {} + for pr in it["closedByPullRequestsReferences"]["nodes"]: + if pr: + prs[pr["number"]] = {"num": pr["number"], "url": pr["url"], + "state": pr["state"], + "draft": pr.get("isDraft", False), "rel": "closes"} + for tl in it["timelineItems"]["nodes"]: + src = (tl or {}).get("source") or {} + if src.get("__typename") == "PullRequest": + prs.setdefault(src["number"], {"num": src["number"], "url": src["url"], + "state": src["state"], + "draft": src.get("isDraft", False), "rel": "ref"}) + pr_list = sorted(prs.values(), key=lambda p: p["num"]) tickets[str(it["number"])] = { "id": it["id"], "title": it["title"], @@ -194,6 +221,7 @@ def snapshot(refresh=False): "relates_to": _nums(meta.get("relates-to")), "branch": meta.get("branch"), "pr": meta.get("pr"), + "prs": pr_list, "labels": [l for l in labels if not l.startswith(STATUS_PREFIX) and l not in ("bug", "enhancement")], "assignees": [a["login"] for a in it["assignees"]["nodes"]], diff --git a/skills/issue-tracker/scripts/board-map.sh b/skills/issue-tracker/scripts/board-map.sh index 392fe629f8..64a82eead0 100755 --- a/skills/issue-tracker/scripts/board-map.sh +++ b/skills/issue-tracker/scripts/board-map.sh @@ -64,7 +64,11 @@ md = ["# Issue Board", "", for tid in order: n = tickets[tid] title = " ".join(str(n["title"]).split()).replace("|", "\\|") - md.append("| #%s | %s | %s | %s |" % (tid, state_label(tid, n), title, n.get("pr") or "")) + # Prefer the native GitHub-linked PRs (same source the HTML panel uses); fall + # back to the manual pr: meta so a hand-entered PR URL still shows. + prs = n.get("prs") or [] + pr_cell = " ".join("#%s" % p["num"] for p in prs) or (n.get("pr") or "") + md.append("| #%s | %s | %s | %s |" % (tid, state_label(tid, n), title, pr_cell)) table = "\n".join(md) print(table) @@ -364,7 +368,8 @@ for t in order: "blocked_by": [did(b) for b in n.get("blocked_by", [])], "spawned_by": did(n["spawned_by"]) if n.get("spawned_by") else None, "relates_to": [did(r) for r in n.get("relates_to", []) or []], - "branch": n.get("branch"), "pr": n.get("pr"), "md": n.get("url"), + "branch": n.get("branch"), "pr": n.get("pr"), "prs": n.get("prs") or [], + "md": n.get("url"), "created": n.get("created"), "updated": n.get("updated"), "x": x, "y": y, }) diff --git a/skills/issue-tracker/scripts/board-map.template.html b/skills/issue-tracker/scripts/board-map.template.html index db13098f81..b863565255 100644 --- a/skills/issue-tracker/scripts/board-map.template.html +++ b/skills/issue-tracker/scripts/board-map.template.html @@ -102,6 +102,12 @@ border-top: 1px solid #141a24; font-size: 12px; } .drow span { color: #556170; } .drow b { font-weight: 500; word-break: break-word; color: #aeb9c6; } .drow a { color: #93c5fd; text-decoration: none; } .drow a:hover { text-decoration: underline; } + .prlist { display: flex; flex-direction: column; gap: 3px; } + .prline { display: flex; align-items: center; gap: .4rem; } + .prchip { font-size: 9px; text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; + padding: .02rem .3rem; border-radius: .25rem; border: 1px solid currentColor; opacity: .9; } + .prchip.merged { color: #a371f7; } .prchip.open { color: #3fb950; } + .prchip.closed { color: #f85149; } .prchip.draft { color: #7d8896; } /* ---- kanban view: state columns sharing the card palette ---- */ #kb { position: absolute; inset: 0; overflow: auto; padding: .8rem; display: flex; gap: .7rem; align-items: flex-start; } @@ -432,6 +438,23 @@ var a = el("a", url); a.href = url; a.target = "_blank"; a.rel = "noopener"; return a; } + // Native GitHub-linked PRs (closes + cross-ref), one line each with a state chip. + function prList(prs) { + var wrap = el("div", null, "prlist"); + prs.forEach(function (p) { + var line = el("div", null, "prline"); + var a = link(p.url); + if (a) { a.textContent = "#" + p.num; line.appendChild(a); } + else line.appendChild(el("span", "#" + p.num)); + var st = p.state === "MERGED" ? "merged" + : p.state === "CLOSED" ? "closed" + : p.draft ? "draft" : "open"; + var chip = el("span", st + (p.rel === "closes" ? " · closes" : ""), "prchip " + st); + line.appendChild(chip); + wrap.appendChild(line); + }); + return wrap; + } function showDetail(n) { var d = document.getElementById("detail"); d.textContent = ""; @@ -454,7 +477,8 @@ add("spawned by", n.spawned_by); add("relates", (n.relates_to || []).join(", ")); add("branch", n.branch); - if (n.pr) add("PR", link(n.pr) || n.pr); + if (n.prs && n.prs.length) rows.appendChild(detailRow("PRs", prList(n.prs))); + else if (n.pr) add("PR", link(n.pr) || n.pr); if (n.md) add("issue", link(n.md) || n.md); add("created", n.created); add("updated", n.updated); diff --git a/tests/issue-tracker/mock-gh/gh b/tests/issue-tracker/mock-gh/gh index 60951efdc6..2e4ffc126e 100755 --- a/tests/issue-tracker/mock-gh/gh +++ b/tests/issue-tracker/mock-gh/gh @@ -83,6 +83,7 @@ def main(): "title": opt(argv, "--title") or "", "body": body, "state": "OPEN", "stateReason": None, "labels": labels, "assignees": [], "parent": None, "blockedBy": [], + "closesPRs": [], "xrefPRs": [], "comments": [], "createdAt": "2026-07-06T00:00:00Z", "updatedAt": "2026-07-06T00:00:00Z", "url": "https://github.com/%s/issues/%s" % (os.environ.get("BOARD_REPO", "test/repo"), num), @@ -173,6 +174,15 @@ def main(): "assignees": {"nodes": [{"login": a} for a in it["assignees"]]}, "parent": {"number": it["parent"]} if it.get("parent") else None, "blockedBy": {"nodes": [{"number": b} for b in it["blockedBy"]]}, + "closedByPullRequestsReferences": {"nodes": [ + {"number": p["number"], "url": p["url"], + "state": p.get("state", "MERGED"), "isDraft": p.get("isDraft", False)} + for p in it.get("closesPRs", [])]}, + "timelineItems": {"nodes": [ + {"source": {"__typename": "PullRequest", "number": p["number"], + "url": p["url"], "state": p.get("state", "OPEN"), + "isDraft": p.get("isDraft", False)}} + for p in it.get("xrefPRs", [])]}, }) print(json.dumps({"data": {"repository": {"issues": { "pageInfo": {"hasNextPage": False, "endCursor": None}, diff --git a/tests/issue-tracker/test-board-scripts.sh b/tests/issue-tracker/test-board-scripts.sh index 472a60aa93..d62b09256f 100755 --- a/tests/issue-tracker/test-board-scripts.sh +++ b/tests/issue-tracker/test-board-scripts.sh @@ -277,6 +277,17 @@ assert_file_exists "$WORK/doperpowers/issue-tracker/BOARD.md" "BOARD.md rendered assert_equals "$(cat "$WORK/doperpowers/issue-tracker/.gitignore")" "*" "render dir is gitignored" assert_contains "$(cat "$WORK/doperpowers/issue-tracker/BOARD.html")" '"id": "#9"' "html payload uses display ids" +# native GitHub-linked PRs (closes + cross-ref) surface without any pr: meta — +# the merge-autoclose gap the manual meta could not cover. +python3 -c "import json;p='$MOCK_GH_STATE';s=json.load(open(p));i=s['issues']['9'];i['closesPRs']=[{'number':58,'url':'https://github.com/test/repo/pull/58','state':'MERGED'}];i['xrefPRs']=[{'number':61,'url':'https://github.com/test/repo/pull/61','state':'OPEN'}];json.dump(s,open(p,'w'))" +out="$(run board-map.sh)" +assert_contains "$out" "#58 #61" "md table shows native linked PRs (closes + xref)" +run board-map.sh --write >/dev/null 2>&1 +assert_contains "$(cat "$WORK/doperpowers/issue-tracker/BOARD.html")" '"num": 58' "html payload carries closing PR number" +assert_contains "$(cat "$WORK/doperpowers/issue-tracker/BOARD.html")" '"rel": "closes"' "closing PR keeps the closes relation" +assert_contains "$(cat "$WORK/doperpowers/issue-tracker/BOARD.html")" '"num": 61' "html payload carries cross-ref PR number" +assert_contains "$(cat "$WORK/doperpowers/issue-tracker/BOARD.html")" '"rel": "ref"' "cross-ref PR keeps the ref relation" + # ---- worktree friendliness (the v6 guard is gone) -------------------------------- echo "worktree:" out="$(cd "$TEST_ROOT/wt" && "$SCRIPTS_DIR/board-list.sh")"