feat(kvb-git-close-issues): close the issues a pull request fixes

issues.py reads gh pr view --json from stdin and lists every issue the pull request references: closing keywords and mentions in the body and the commits (own repo, owner/repo#n and issue URLs), a trailing number in the branch name and the issues GitHub already links. The skill verifies each one against the diff and its test or CI evidence, asks the operator, then closes a fix that reached the default branch with a comment naming the pull request and commit, or adds Closes lines to the body of a pull request that is still open or merged elsewhere. tests/test_close_issues.py covers every reference kind. Operator order 2026-11-03; two descriptions trimmed for the listing budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-03 16:51:45 +01:00
1 parent 9917cccd6e
commit c2ab3f39cf
6 files changed
+204 -6

No files matched your search

+3
View File
@@ -49,6 +49,7 @@ flowchart LR
git --> git_commit_next[kvb-git-commit-next]
git --> git_commit_no_verify[kvb-git-commit-no-verify]
git --> git_merge_fix[kvb-git-merge-fix]
git --> git_close_issues[kvb-git-close-issues]
git --> git_branch_gc[kvb-git-branch-gc]
git --> git_changelog[kvb-git-changelog]
check --> check_legal[kvb-check-legal]
@@ -126,6 +127,7 @@ flowchart LR
commit_next[kvb-git-commit-next]
commit_no_verify[kvb-git-commit-no-verify]
merge_fix[kvb-git-merge-fix]
close_issues[kvb-git-close-issues]
branch_gc[kvb-git-branch-gc]
changelog[kvb-git-changelog]
end
@@ -182,6 +184,7 @@ flowchart LR
commit_next --> commit_no_verify
commit_next --> explain
commit_next --> dialectic
close_issues --> confidence
commit_no_verify --> comments_clean
commit_no_verify --> advocate
advocate --> dialectic
+2 -3
View File
@@ -2,9 +2,8 @@
name: kvb-code-no-defaults
description: >
Forbid default values for parameters, settings and environment lookups;
demand explicit values and fail loudly. Apply AUTOMATICALLY on every task
that writes, edits, generates, refactors or reviews code, config, templates,
schemas or infrastructure.
demand explicit values. Apply AUTOMATICALLY whenever code, config,
templates or infrastructure are written, edited or reviewed.
---
A default value is a guess, written at the point where the caller's intent is
+50
View File
@@ -0,0 +1,50 @@
---
name: kvb-git-close-issues
description: >
Close the issues a pull request fixes, each verified and confirmed.
Trigger on /kvb-git-close-issues [pr].
---
Closing an issue is outward-facing: nothing is closed or edited before the
operator confirms the list, and every closed issue names the pull request
and commit that fixed it.
## Step 1: candidates
The pull request named after the command, else the one of the current
branch (`gh pr view --json number`). `${CLAUDE_SKILL_DIR}/scripts/issues.py`
lists every issue it references, from closing keywords and mentions in the
body and the commits, a trailing number in the branch name, and the issues
GitHub already links:
```bash
gh pr view <pr> --json body,headRefName,commits,closingIssuesReferences | issues.py
```
## Step 2: verify
Per candidate, `gh issue view <n> --json state,title,body,comments` (with
`--repo` for a foreign one); a closed issue is skipped. Judge it `fixed`,
`partly` or `unrelated` against the pull request's diff, with evidence: the
change that addresses each point of the issue and the test or CI run that
proves it. Only `fixed` goes on; `partly` gets a progress comment instead.
## Step 3: confirm
`AskUserQuestion` with `multiSelect: true`, one option per `fixed` issue,
the evidence in its description, nothing preselected.
## Step 4: close
Where the fix lives decides how:
- **Merged into the default branch**: `gh issue close <n> --reason completed
--comment "Fixed by #<pr> (<sha>)."`.
- **Open, or merged into another branch**: add one `Closes #<n>` line per
issue to the pull request body (`gh pr edit <pr> --body-file <file>`,
keeping the existing body), so the merge into the default branch closes
them; never close them early.
## Output
| Issue | Verdict | Evidence | Action |
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""List the issues a pull request references, from `gh pr view --json` on stdin.
Args:
stdin: JSON with body, headRefName, commits and closingIssuesReferences,
as `gh pr view <pr> --json body,headRefName,commits,closingIssuesReferences`
prints it.
--json: print the candidates as JSON instead of tab-separated lines.
Exit codes:
0 done, 2 usage error.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
KEYWORD = r"(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)"
REF = r"(?:(?P<repo>[\w.-]+/[\w.-]+)#|https://github\.com/(?P<url>[\w.-]+/[\w.-]+)/issues/|#)(?P<num>\d+)"
PATTERN = re.compile(rf"(?P<kw>\b{KEYWORD}\b:?\s+)?{REF}", re.IGNORECASE)
BRANCH = re.compile(r"[/-](\d+)$")
def fail(message: str) -> None:
print(f"issues: {message}", file=sys.stderr)
sys.exit(2)
def scan(text: str, source: str, found: dict) -> None:
for match in PATTERN.finditer(text or ""):
repo = match.group("repo") or match.group("url") or ""
entry = found.setdefault(
(repo, int(match.group("num"))), {"closing": False, "sources": []}
)
entry["closing"] = entry["closing"] or bool(match.group("kw"))
if source not in entry["sources"]:
entry["sources"].append(source)
def candidates(pr: dict) -> list[dict]:
found: dict = {}
scan(pr.get("body", ""), "body", found)
for commit in pr.get("commits", []):
text = f"{commit.get('messageHeadline', '')}\n{commit.get('messageBody', '')}"
scan(text, f"commit {commit.get('oid', '')[:10]}", found)
branch = BRANCH.search(pr.get("headRefName", ""))
if branch:
found.setdefault(("", int(branch.group(1))), {"closing": False, "sources": []})[
"sources"
].append("branch")
for linked in pr.get("closingIssuesReferences", []):
url = re.search(r"github\.com/([\w.-]+/[\w.-]+)/issues/", linked.get("url", ""))
key = (url.group(1) if url else "", int(linked["number"]))
entry = found.setdefault(key, {"closing": False, "sources": []})
entry["closing"] = True
entry["sources"].append("linked")
return [
{"repo": repo, "issue": num, **entry}
for (repo, num), entry in sorted(found.items())
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
try:
pr = json.load(sys.stdin)
except ValueError:
fail("stdin is not the JSON of gh pr view --json")
if not isinstance(pr, dict):
fail("stdin is not a pull request object")
rows = candidates(pr)
if args.json:
print(json.dumps(rows, indent=1))
return 0
for row in rows:
kind = "closing" if row["closing"] else "mention"
print(f"{row['repo'] or '-'}\t{row['issue']}\t{kind}\t{', '.join(row['sources'])}")
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -3
View File
@@ -1,9 +1,9 @@
---
name: kvb-org-perform
description: >
Pick `model` and `effort` per subagent from a fixed table: cheaper only at
90 % confidence that the result equals the default's; log spawns and
quality. Apply AUTOMATICALLY before every Agent tool call.
Pick `model` and `effort` per subagent from a fixed table, cheaper only at
90 % confidence of an equal result. Apply AUTOMATICALLY before every Agent
tool call.
---
A subagent runs on the main model and effort unless the spawn names others.
+59
View File
@@ -0,0 +1,59 @@
"""Exercise issues.py: closing keywords, mentions, commits, branch number and linked issues."""
from __future__ import annotations
import json
import subprocess
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "kvb-git-close-issues" / "scripts" / "issues.py"
PR = {
"body": "Fixes #12 and touches #13.\nCloses: other/repo#7\nSee https://github.com/a/b/issues/9",
"headRefName": "fix/sys-ctl-hlth-runtime/vulnerability-engines/1324",
"commits": [
{"oid": "abcdef1234567", "messageHeadline": "fix: guard", "messageBody": "Resolved #13"},
{"oid": "1111111111111", "messageHeadline": "chore: tidy", "messageBody": ""},
],
"closingIssuesReferences": [
{"number": 21, "url": "https://github.com/owner/repo/issues/21"}
],
}
def _run(payload: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), "--json"],
input=payload,
check=False,
capture_output=True,
text=True,
)
class TestIssues(unittest.TestCase):
def test_every_reference_is_found_with_its_kind_and_source(self):
result = _run(json.dumps(PR))
self.assertEqual(result.returncode, 0, result.stderr)
rows = {(r["repo"], r["issue"]): r for r in json.loads(result.stdout)}
self.assertEqual(
{key: (row["closing"], row["sources"]) for key, row in rows.items()},
{
("", 12): (True, ["body"]),
("", 13): (True, ["body", "commit abcdef1234"]),
("other/repo", 7): (True, ["body"]),
("a/b", 9): (False, ["body"]),
("", 1324): (False, ["branch"]),
("owner/repo", 21): (True, ["linked"]),
},
)
def test_invalid_input_exits_2(self):
self.assertEqual(_run("not json").returncode, 2)
self.assertEqual(_run("[]").returncode, 2)
if __name__ == "__main__":
unittest.main()