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:
1 parent
32f58712e5
commit
f815060584
16 files changed
+399
-134
No files matched your search
Executable
+80
@@ -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())
|
||||
Executable
+95
@@ -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())
|
||||
Reference in new issue
Block a user