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()