From c2ab3f39cf36977bf083c234e86ae43234e62501 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 3 Nov 2026 16:48:19 +0100 Subject: [PATCH] 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 --- README.md | 3 + skills/kvb-code-no-defaults/SKILL.md | 5 +- skills/kvb-git-close-issues/SKILL.md | 50 +++++++++++ skills/kvb-git-close-issues/scripts/issues.py | 87 +++++++++++++++++++ skills/kvb-org-perform/SKILL.md | 6 +- tests/test_close_issues.py | 59 +++++++++++++ 6 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 skills/kvb-git-close-issues/SKILL.md create mode 100755 skills/kvb-git-close-issues/scripts/issues.py create mode 100644 tests/test_close_issues.py diff --git a/README.md b/README.md index 83f2eb0..a3b9dcf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills/kvb-code-no-defaults/SKILL.md b/skills/kvb-code-no-defaults/SKILL.md index f4f2c41..58c5633 100644 --- a/skills/kvb-code-no-defaults/SKILL.md +++ b/skills/kvb-code-no-defaults/SKILL.md @@ -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 diff --git a/skills/kvb-git-close-issues/SKILL.md b/skills/kvb-git-close-issues/SKILL.md new file mode 100644 index 0000000..09c181d --- /dev/null +++ b/skills/kvb-git-close-issues/SKILL.md @@ -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 --json body,headRefName,commits,closingIssuesReferences | issues.py +``` + +## Step 2: verify + +Per candidate, `gh issue view --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 --reason completed + --comment "Fixed by # ()."`. +- **Open, or merged into another branch**: add one `Closes #` line per + issue to the pull request body (`gh pr edit --body-file `, + keeping the existing body), so the merge into the default branch closes + them; never close them early. + +## Output + +| Issue | Verdict | Evidence | Action | diff --git a/skills/kvb-git-close-issues/scripts/issues.py b/skills/kvb-git-close-issues/scripts/issues.py new file mode 100755 index 0000000..c102118 --- /dev/null +++ b/skills/kvb-git-close-issues/scripts/issues.py @@ -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 --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[\w.-]+/[\w.-]+)#|https://github\.com/(?P[\w.-]+/[\w.-]+)/issues/|#)(?P\d+)" +PATTERN = re.compile(rf"(?P\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()) diff --git a/skills/kvb-org-perform/SKILL.md b/skills/kvb-org-perform/SKILL.md index c0eff50..ab79f75 100644 --- a/skills/kvb-org-perform/SKILL.md +++ b/skills/kvb-org-perform/SKILL.md @@ -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. diff --git a/tests/test_close_issues.py b/tests/test_close_issues.py new file mode 100644 index 0000000..a1de964 --- /dev/null +++ b/tests/test_close_issues.py @@ -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()