feat(kvb-git): add pr-create, pr-label and issue-solve; rename close-issues to issue-close

kvb-git-pr-create opens a pull request for the current branch: it assesses the branch, hands the push to the operator (gipsn) and waits until git ls-remote shows the head, writes a body with one Closes line per verified issue, confirms once, and opens it ready at the kvb-org-split bar or as a draft below it, then runs kvb-git-pr-label. kvb-git-pr-label sets hotfix on a pull request that targets the default branch and fixes a failure there, and 90/5 once it meets the split bar, each with its evidence, creating the labels once and marking a draft ready when it gains 90/5. kvb-git-issue-solve solves open issues one sidetree each through kvb-org-split, skipping fixed ones and those a peer already works on, and opens one pull request per issue.

kvb-git-close-issues becomes kvb-git-issue-close, following the kvb-git-<object>-<verb> scheme: with a pull request it works as before; without one it covers every open issue, and the new done.py lists the default-branch commits that reference each (one git log, foreign repositories excluded). tests/test_close_issues.py moves to tests/test_issue_close.py and gains TestDone on a temporary repository. Six skill descriptions are shortened, keeping their triggers, so the listing stays within its 1500-word budget (1517 to 1493).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-04 17:07:22 +01:00
1 parent 32f58712e5
commit f815060584
16 files changed
+399 -134

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""List the default-branch commits that reference each open issue.
Args:
stdin: JSON list of open issues, as `gh issue list --state open --json
number` prints it.
--repo: owner/name of the repository; its own references count, foreign
ones do not.
--git-dir: path of a local clone, fetched.
--ref: the default branch to scan, e.g. origin/main.
--json: print the result as JSON instead of tab-separated lines.
Output:
one line per open issue that a commit references: issue, closing or
mention, the short shas.
Exit codes:
0 done, 2 usage error.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from issues import PATTERN, fail
def references(log: str, own: str, open_issues: set[int]) -> dict[int, dict]:
found: dict[int, dict] = {}
for record in filter(None, log.split("\x1e")):
sha, _, message = record.strip("\n").partition("\x1f")
for match in PATTERN.finditer(message):
repo = match.group("repo") or match.group("url") or ""
number = int(match.group("num"))
if number not in open_issues or (repo and repo.lower() != own.lower()):
continue
entry = found.setdefault(number, {"closing": False, "commits": []})
entry["closing"] = entry["closing"] or bool(match.group("kw"))
if sha[:10] not in entry["commits"]:
entry["commits"].append(sha[:10])
return found
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--repo", required=True)
parser.add_argument("--git-dir", required=True)
parser.add_argument("--ref", required=True)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
try:
issues = json.load(sys.stdin)
open_issues = {int(issue["number"]) for issue in issues}
except (ValueError, TypeError, KeyError):
fail("stdin is not the JSON of gh issue list --json number")
log = subprocess.run(
["git", "log", "--format=%H%x1f%B%x1e", args.ref],
cwd=args.git_dir,
capture_output=True,
text=True,
check=False,
)
if log.returncode != 0:
fail(log.stderr.strip() or f"git log {args.ref} failed")
found = references(log.stdout, args.repo, open_issues)
rows = [{"issue": number, **found[number]} for number in sorted(found)]
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['issue']}\t{kind}\t{', '.join(row['commits'])}")
return 0
if __name__ == "__main__":
sys.exit(main())
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""List the issues a pull request references, from `gh pr view --json` on stdin.
Args:
stdin: JSON with number, body, headRefName, commits and
closingIssuesReferences, as `gh pr view <pr> --json
number,body,headRefName,commits,closingIssuesReferences` prints it.
--repo: owner/name of the pull request's repository; its own references
are reported without a repository.
--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/|(?<![\w/])#)(?P<num>[1-9]\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, own: str) -> 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")
merged: dict = {}
for (repo, num), entry in found.items():
key = ("" if repo.lower() == own.lower() else repo, num)
if key == ("", pr.get("number")):
continue
target = merged.setdefault(key, {"closing": False, "sources": []})
target["closing"] = target["closing"] or entry["closing"]
target["sources"] += [s for s in entry["sources"] if s not in target["sources"]]
return [{"repo": repo, "issue": num, **entry} for (repo, num), entry in sorted(merged.items())]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--repo", required=True)
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, args.repo)
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())