From 428882768ac4640dd31850ae0f9883cb9881ce36 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Tue, 7 Jul 2026 15:28:40 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(issue-tracker):=20=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EB=85=B8=EB=93=9C=20=EC=83=81=EC=84=B8=EC=97=90=20GitHub=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20PR=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포된 HTML 보드에서 티켓을 클릭하면 이슈번호·created·updated는 보이지만 연동된 PR은 안 보였다. 렌더러는 PR 행을 그릴 수 있었으나, 그 값은 티켓을 in-review로 옮길 때 이슈 본문에 수동으로 쓴 `pr:` 메타에서만 왔다. 그래서 PR 머지("Closes #N") 자동닫힘으로 done된 티켓은 pr 메타가 없어 PR이 안 떴고, 단일 pr 값은 PR 여러 개를 표현할 수도 없었다. 이제 GitHub에 이미 있는 연동 정보를 직접 읽는다: - _board.py snapshot: closedByPullRequestsReferences(이슈를 닫는 PR — 머지 자동닫힘 갭을 메움) + CROSS_REFERENCED_EVENT(이슈를 언급하는 PR)를 가져와 번호로 중복 제거한 per-ticket `prs` 리스트 생성. closes/ref 관계 중 강한 closes를 유지. - board-map.template.html 상세 패널: prs를 "PRs" 섹션으로 렌더 — PR당 한 줄, 링크 + 상태 칩(merged/open/closed/draft, GitHub 팔레트 색) + closing PR엔 "· closes" 표시. PR 여러 개 네이티브 지원. 기존 단일 pr: 메타는 GitHub이 연동을 보고하지 않을 때의 폴백으로 유지. 의존성 추가 없음, 렌더/배포 방식 불변(GitHub Pages·Cloudflare 두 템플릿 모두 동일한 board-map.sh --write). 검증: mock gh로 board-map.sh 실렌더 → #42가 [#58 merged/closes, #61 open/ref] payload 확인; 인라인 JS node --check 통과; DOM 심으로 prList/detailRow 렌더 결과(.prlist>.prline>a+.prchip) 확정. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- RELEASE-NOTES.md | 27 ++++++++++++++++++ package.json | 2 +- skills/issue-tracker/scripts/_board.py | 28 +++++++++++++++++++ skills/issue-tracker/scripts/board-map.sh | 3 +- .../scripts/board-map.template.html | 26 ++++++++++++++++- 8 files changed, 86 insertions(+), 6 deletions(-) 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..d4109cdcb2 100755 --- a/skills/issue-tracker/scripts/board-map.sh +++ b/skills/issue-tracker/scripts/board-map.sh @@ -364,7 +364,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); From f3ae7987224ee6e133b946c7793224487d714e28 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Tue, 7 Jul 2026 15:42:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(issue-tracker):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=20=EB=B0=98=EC=98=81=20=E2=80=94=20MD=20?= =?UTF-8?q?=ED=91=9C=EB=8F=84=20native=20PR=20=ED=91=9C=EC=8B=9C=20+=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20mock=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 리뷰(PR #4)가 찾은 두 가지를 수정: 1. board-map.sh: BOARD.md/stdout 폴백 표가 단일 pr: 메타만 써서, 머지 자동닫힘으로 done된 티켓은 MD 아티팩트에선 여전히 PR이 안 보이던 갭. HTML 패널과 동일하게 native prs 리스트를 우선 표시(폴백은 pr: 메타). 2. tests/issue-tracker/mock-gh/gh: snapshot 응답에 새 쿼리 필드 (closedByPullRequestsReferences, timelineItems)가 빠져 있어 _board.py가 무조건 인덱싱하면 KeyError로 기존 테스트가 크래시했다. mock이 쿼리 계약을 그대로 반환하도록 두 필드 추가(이슈 생성 시 closesPRs/xrefPRs 빈 리스트 시드). + test-board-scripts.sh: native 연동 PR(closes + xref)이 pr: 메타 없이도 MD 표와 HTML payload 양쪽에 뜨는지 검증하는 회귀 테스트 추가. 검증: test-board-scripts.sh 전체 통과(신규 PR 검증 포함); GraphQL 쿼리를 실제 GitHub(obra/superpowers)에 날려 필드명·응답 형태 라이브 확인. --- skills/issue-tracker/scripts/board-map.sh | 6 +++++- tests/issue-tracker/mock-gh/gh | 10 ++++++++++ tests/issue-tracker/test-board-scripts.sh | 9 +++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/skills/issue-tracker/scripts/board-map.sh b/skills/issue-tracker/scripts/board-map.sh index d4109cdcb2..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) 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..075509adcf 100755 --- a/tests/issue-tracker/test-board-scripts.sh +++ b/tests/issue-tracker/test-board-scripts.sh @@ -277,6 +277,15 @@ 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 linked PR number" +assert_contains "$(cat "$WORK/doperpowers/issue-tracker/BOARD.html")" '"rel": "closes"' "closing PR keeps the closes relation" + # ---- worktree friendliness (the v6 guard is gone) -------------------------------- echo "worktree:" out="$(cd "$TEST_ROOT/wt" && "$SCRIPTS_DIR/board-list.sh")" From 537771d8115890ab3e54b6f3ba6cbbb1c74f9b27 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Tue, 7 Jul 2026 15:46:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(issue-tracker):=20cross-ref=20PR?= =?UTF-8?q?=EC=9D=98=20num/rel=EB=8F=84=20HTML=20payload=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20(=EB=A6=AC=EB=B7=B0=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20=EA=B0=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/issue-tracker/test-board-scripts.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/issue-tracker/test-board-scripts.sh b/tests/issue-tracker/test-board-scripts.sh index 075509adcf..d62b09256f 100755 --- a/tests/issue-tracker/test-board-scripts.sh +++ b/tests/issue-tracker/test-board-scripts.sh @@ -283,8 +283,10 @@ python3 -c "import json;p='$MOCK_GH_STATE';s=json.load(open(p));i=s['issues']['9 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 linked PR number" +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:"