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:
1 parent
9917cccd6e
commit
c2ab3f39cf
6 files changed
+204
-6
No files matched your search
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user