From f81506058441be17523839f3361bd8ca73f09a6c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 3 Nov 2026 21:00:53 +0100 Subject: [PATCH] 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-- 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 --- README.md | 18 ++- skills/kvb-check-security/SKILL.md | 6 +- skills/kvb-cleanup/SKILL.md | 6 +- skills/kvb-code-scope-guard/SKILL.md | 6 +- skills/kvb-create-plan/SKILL.md | 6 +- skills/kvb-git-changelog/SKILL.md | 7 +- skills/kvb-git-close-issues/SKILL.md | 50 ------- skills/kvb-git-issue-close/SKILL.md | 61 +++++++++ skills/kvb-git-issue-close/scripts/done.py | 80 ++++++++++++ .../scripts/issues.py | 0 skills/kvb-git-issue-solve/SKILL.md | 35 +++++ skills/kvb-git-pr-create/SKILL.md | 35 +++++ skills/kvb-git-pr-label/SKILL.md | 34 +++++ skills/kvb-org-split/SKILL.md | 4 +- tests/test_close_issues.py | 63 --------- tests/test_issue_close.py | 122 ++++++++++++++++++ 16 files changed, 399 insertions(+), 134 deletions(-) delete mode 100644 skills/kvb-git-close-issues/SKILL.md create mode 100644 skills/kvb-git-issue-close/SKILL.md create mode 100755 skills/kvb-git-issue-close/scripts/done.py rename skills/{kvb-git-close-issues => kvb-git-issue-close}/scripts/issues.py (100%) create mode 100644 skills/kvb-git-issue-solve/SKILL.md create mode 100644 skills/kvb-git-pr-create/SKILL.md create mode 100644 skills/kvb-git-pr-label/SKILL.md delete mode 100644 tests/test_close_issues.py create mode 100644 tests/test_issue_close.py diff --git a/README.md b/README.md index 76d723b..cd94016 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,10 @@ 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_issue_close[kvb-git-issue-close] + git --> git_issue_solve[kvb-git-issue-solve] + git --> git_pr_create[kvb-git-pr-create] + git --> git_pr_label[kvb-git-pr-label] git --> git_update_branches[kvb-git-update-branches] git --> git_branch_gc[kvb-git-branch-gc] git --> git_changelog[kvb-git-changelog] @@ -128,7 +131,10 @@ 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] + issue_close[kvb-git-issue-close] + issue_solve[kvb-git-issue-solve] + pr_create[kvb-git-pr-create] + pr_label[kvb-git-pr-label] update_branches[kvb-git-update-branches] branch_gc[kvb-git-branch-gc] changelog[kvb-git-changelog] @@ -186,7 +192,13 @@ flowchart LR commit_next --> commit_no_verify commit_next --> explain commit_next --> dialectic - close_issues --> confidence + issue_close --> confidence + issue_solve --> issue_close + issue_solve --> funnel + issue_solve --> pr_create + pr_create --> issue_close + pr_create --> pr_label + pr_create -. push .-> operator update_branches --> active_listening update_branches -. conflicts .-> merge_fix update_branches -. pushes .-> operator diff --git a/skills/kvb-check-security/SKILL.md b/skills/kvb-check-security/SKILL.md index a9cb536..e4beb26 100644 --- a/skills/kvb-check-security/SKILL.md +++ b/skills/kvb-check-security/SKILL.md @@ -1,9 +1,9 @@ --- name: kvb-check-security description: > - Run the MEDUSA scanner (medusa-security) on a repository, working tree or - secret stores. Use for security scans, vetting a repo, leaked secrets in - chat or shell history, or /kvb-check-security. Defensive only. + Run the MEDUSA scanner on a repository, working tree or secret stores: + security scans, vetting a repo, leaked secrets in chat or shell history, or + /kvb-check-security. Defensive only. --- MEDUSA is a defensive AI-first security scanner (40,000+ patterns: CVEs, prompt injection, MCP tool poisoning, leaked secrets). diff --git a/skills/kvb-cleanup/SKILL.md b/skills/kvb-cleanup/SKILL.md index e927a88..492cc9c 100644 --- a/skills/kvb-cleanup/SKILL.md +++ b/skills/kvb-cleanup/SKILL.md @@ -1,9 +1,9 @@ --- name: kvb-cleanup description: > - Free disk space agents filled: ask peers, inventory /tmp, Docker and agent - dirs, and delete only what the operator picks. Trigger on /kvb-cleanup, "aufräumen", "bereinigen", "platte voll", "no - space left on device". + Free disk space agents filled in /tmp, Docker and agent dirs; delete only + what the operator picks. Trigger on /kvb-cleanup, "aufräumen", "bereinigen", + "platte voll", "no space left on device". --- Nothing is deleted before the operator has picked it. Every candidate carries diff --git a/skills/kvb-code-scope-guard/SKILL.md b/skills/kvb-code-scope-guard/SKILL.md index d3be253..8160abd 100644 --- a/skills/kvb-code-scope-guard/SKILL.md +++ b/skills/kvb-code-scope-guard/SKILL.md @@ -1,9 +1,9 @@ --- name: kvb-code-scope-guard description: > - Deliver exactly what was asked and nothing more. Trigger on every task that - creates files, commands or commits, on autonomous runs and hand-overs, and - when asked why something unrequested exists. + Deliver exactly what was asked. Trigger on every task that creates files, + commands or commits, on autonomous runs and hand-overs, and when asked why + something unrequested exists. --- The requested scope is the deliverable. A problem you noticed behind the diff --git a/skills/kvb-create-plan/SKILL.md b/skills/kvb-create-plan/SKILL.md index 446b1fe..4d77e02 100644 --- a/skills/kvb-create-plan/SKILL.md +++ b/skills/kvb-create-plan/SKILL.md @@ -1,9 +1,9 @@ --- name: kvb-create-plan description: > - Turn a request into an objective-oriented plan and hand it to the kvb-org-robot - skill. Trigger on /kvb-create-plan or when the operator asks for a plan, breakdown or - strategy before work starts. + Turn a request into an objective-oriented plan for the kvb-org-robot skill. + Trigger on /kvb-create-plan or a request for a plan, breakdown or strategy + before work starts. --- Produce a plan whose every item is an outcome with a check, then execute it diff --git a/skills/kvb-git-changelog/SKILL.md b/skills/kvb-git-changelog/SKILL.md index 3b7b783..154d60d 100644 --- a/skills/kvb-git-changelog/SKILL.md +++ b/skills/kvb-git-changelog/SKILL.md @@ -1,10 +1,9 @@ --- name: kvb-git-changelog description: > - Write the release message for `pkgmgr release`; - prints only, never edits, tags or releases. Trigger on - /kvb-git-changelog, "release message", "changelog entry", before cutting a release, - or on pkgmgr's markdown-lint rejection. + Write the release message for `pkgmgr release`; prints only. Trigger on + /kvb-git-changelog, "release message", "changelog entry", before cutting a + release, or on pkgmgr's markdown-lint rejection. --- The message is one text that pkgmgr writes to four places: the CHANGELOG.md diff --git a/skills/kvb-git-close-issues/SKILL.md b/skills/kvb-git-close-issues/SKILL.md deleted file mode 100644 index 5e8ad90..0000000 --- a/skills/kvb-git-close-issues/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -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 number,body,headRefName,commits,closingIssuesReferences | issues.py --repo -``` - -## 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-issue-close/SKILL.md b/skills/kvb-git-issue-close/SKILL.md new file mode 100644 index 0000000..7930e23 --- /dev/null +++ b/skills/kvb-git-issue-close/SKILL.md @@ -0,0 +1,61 @@ +--- +name: kvb-git-issue-close +description: > + Close finished issues, of one pull request or the whole repository, each + verified and confirmed. Trigger on /kvb-git-issue-close [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 or +commit that fixed it. + +## Step 1: candidates + +- **A pull request named** (or the current branch's, `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 number,body,headRefName,commits,closingIssuesReferences | issues.py --repo + ``` + +- **No pull request**: every open issue of the repository. After `git fetch`, + `${CLAUDE_SKILL_DIR}/scripts/done.py` lists the default-branch commits that + reference each one: + + ```bash + gh issue list --state open --limit 1000 --json number | done.py --repo --git-dir . --ref origin/ + ``` + + An open issue no commit references can still be fixed: search the default + branch by its description with the kvb-git-merge-fix skill's `find_fix.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 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: + +- **On the default branch**: `gh issue close --reason completed + --comment "Fixed by # ()."`, or the commit alone when no pull + request carried it. +- **In an open pull request, 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-issue-close/scripts/done.py b/skills/kvb-git-issue-close/scripts/done.py new file mode 100755 index 0000000..9c4a754 --- /dev/null +++ b/skills/kvb-git-issue-close/scripts/done.py @@ -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()) diff --git a/skills/kvb-git-close-issues/scripts/issues.py b/skills/kvb-git-issue-close/scripts/issues.py similarity index 100% rename from skills/kvb-git-close-issues/scripts/issues.py rename to skills/kvb-git-issue-close/scripts/issues.py diff --git a/skills/kvb-git-issue-solve/SKILL.md b/skills/kvb-git-issue-solve/SKILL.md new file mode 100644 index 0000000..71024fa --- /dev/null +++ b/skills/kvb-git-issue-solve/SKILL.md @@ -0,0 +1,35 @@ +--- +name: kvb-git-issue-solve +description: > + Solve open issues, each in its own worktree, and open one pull request per + issue. Trigger on /kvb-git-issue-solve [issue...]. +--- + +Every issue gets its own sidetree, its own evidence and its own pull +request, so a red one blocks nothing else. + +## Procedure + +1. **Candidates**: the issues named, else `gh issue list --state open`. Drop + what the `kvb-git-issue-close` skill finds already fixed, and what a peer + already works on: `council.py list` and the `kvb-org-organize` Peer + protocol; when a peer solves it, only comment on the issue who solves it + where. +2. **Rank**: issues about a failing default branch first, then the rank of + the `kvb-org-wsjf` skill; the operator's order wins. +3. **Solve** each with the `kvb-org-split` skill: claim `work/issue-`, + enter it in the `kvb-org-rasci` register (objective: the issue fixed; key + results: a test that failed before and passes after, the pull request + open), branch `fix//` from the default branch in its own + worktree, evidence, self-assessment, independent review. Commits go to + that branch only, never to the default branch; parallel work follows the + resource chair's grants. +4. **Open the pull request** with the `kvb-git-pr-create` skill, closing + exactly its issue. Below the split bar the proposal becomes a draft pull + request with the residuals and the evidence that would close them. +5. **Report** per issue, then release its claim and tear its sidetree down + as `kvb-org-split` describes; the branch stays until the merge. + +## Output + +| Issue | Branch | Confidence | Break risk | PR | State | diff --git a/skills/kvb-git-pr-create/SKILL.md b/skills/kvb-git-pr-create/SKILL.md new file mode 100644 index 0000000..3384392 --- /dev/null +++ b/skills/kvb-git-pr-create/SKILL.md @@ -0,0 +1,35 @@ +--- +name: kvb-git-pr-create +description: > + Open a pull request for the current branch: push handed over, Closes lines, + labels. Trigger on /kvb-git-pr-create. +--- + +Opening a pull request is outward-facing and needs the branch on the remote, +which only the operator pushes. Nothing is created before the operator +confirms it. + +## Procedure + +1. **Check**: the branch is committed and clean, branched from the default + branch (`gh repo view --json defaultBranchRef`), and has no open pull + request yet (`gh pr list --head `). +2. **Assess** the branch with the `kvb-check-confidence` skill: fix + confidence, break risk, residuals. +3. **Push**: hand the operator `gipsn` in the worktree through the + `kvb-org-operator` skill, and continue once `git ls-remote --heads origin + ` shows the branch's head commit. +4. **Body**, written to a scratch file: what changed and why, the test + evidence, the confidence block, and one `Closes #` line per issue the + branch fixes, each verified as the `kvb-git-issue-close` skill does. +5. **Confirm** with one `AskUserQuestion`: title (Conventional Commits), + base, state and labels. The state follows the bar of the `kvb-org-split` + skill: ready at or above it, a draft with the residuals in the body below + it. +6. **Create**: `gh pr create --base --head --title + "" --body-file <file>`, plus `--draft` below the bar. +7. **Label** with the `kvb-git-pr-label` skill. + +## Output + +| PR | Base | State | Labels | Closes | diff --git a/skills/kvb-git-pr-label/SKILL.md b/skills/kvb-git-pr-label/SKILL.md new file mode 100644 index 0000000..22e99fa --- /dev/null +++ b/skills/kvb-git-pr-label/SKILL.md @@ -0,0 +1,34 @@ +--- +name: kvb-git-pr-label +description: > + Label pull requests `hotfix` and `90/5` from evidence. Trigger on + /kvb-git-pr-label [pr]; kvb-git-pr-create runs it. +--- + +Two labels, each set only with its evidence and removed when it no longer +holds: + +- **`hotfix`**: the pull request targets the default branch and fixes a + failure on it, a red default-branch run or an issue that reproduces there; + name the run or issue. +- **`90/5`**: the pull request meets the bar of the `kvb-org-split` skill (fix + confidence of at least 90 %, break risk under 5 %), from the + `kvb-check-confidence` numbers in its body or freshly assessed. + +## Procedure + +1. Target: the pull request named, else the current branch's (`gh pr view + --json number,baseRefName,headRefName,isDraft,labels,body`); the default + branch from `gh repo view --json defaultBranchRef`. +2. Judge both labels with their evidence. +3. A missing label is created once: `gh label create hotfix --color B60205 + --description "Fixes a failure on the default branch"`, `gh label create + 90/5 --color 0E8A16 --description "Fix confidence >= 90 %, break risk < 5 %"`. +4. Standalone, confirm the changes with one `AskUserQuestion`; from + `kvb-git-pr-create`, its confirmation covers them. +5. `gh pr edit <pr> --add-label <l>` or `--remove-label <l>`. A draft that + gains `90/5` also gets `gh pr ready <pr>`. + +## Output + +| PR | Label | Change | Evidence | diff --git a/skills/kvb-org-split/SKILL.md b/skills/kvb-org-split/SKILL.md index 48033d2..fdb2a08 100644 --- a/skills/kvb-org-split/SKILL.md +++ b/skills/kvb-org-split/SKILL.md @@ -2,8 +2,8 @@ name: kvb-org-split description: > Keep agent work in its own worktree until 90% fix confidence and under 5% - break risk; below that it becomes a proposal. Trigger when delegating to or - parallelizing agents, or on /kvb-org-split. + break risk, else a proposal. Trigger when delegating or parallelizing, or on + /kvb-org-split. --- # Funnel diff --git a/tests/test_close_issues.py b/tests/test_close_issues.py deleted file mode 100644 index af6708e..0000000 --- a/tests/test_close_issues.py +++ /dev/null @@ -1,63 +0,0 @@ -"""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 = { - "number": 40, - "body": ( - "Fixes #12 and touches #13.\nCloses: other/repo#7\nSee https://github.com/a/b/issues/9\n" - "Rows WordPress#0 and docs#1 are not issues; this is #40." - ), - "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), "--repo", "Owner/repo", "--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"]), - ("", 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() diff --git a/tests/test_issue_close.py b/tests/test_issue_close.py new file mode 100644 index 0000000..77b6ea4 --- /dev/null +++ b/tests/test_issue_close.py @@ -0,0 +1,122 @@ +"""Exercise issues.py (one pull request's issues) and done.py (open issues the default branch references).""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "skills" / "kvb-git-issue-close" / "scripts" / "issues.py" +DONE = SCRIPT.with_name("done.py") +PR = { + "number": 40, + "body": ( + "Fixes #12 and touches #13.\nCloses: other/repo#7\nSee https://github.com/a/b/issues/9\n" + "Rows WordPress#0 and docs#1 are not issues; this is #40." + ), + "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), "--repo", "Owner/repo", "--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"]), + ("", 21): (True, ["linked"]), + }, + ) + + def test_invalid_input_exits_2(self): + self.assertEqual(_run("not json").returncode, 2) + self.assertEqual(_run("[]").returncode, 2) + + +class TestDone(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.repo = Path(tmp.name) + self.env = { + "PATH": "/usr/bin:/bin", + "HOME": tmp.name, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.org", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.org", + } + self._git("init", "-q", "-b", "main") + for message in ( + "fix: guard\n\nFixes #3", + "docs: mention #4 and other/repo#6", + "fix: port\n\nCloses Owner/repo#5", + "chore: tidy #3", + ): + self._git("commit", "-q", "--allow-empty", "-m", message) + self.shas = self._git("log", "--format=%H", "main").split() + + def _git(self, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=self.repo, env=self.env, capture_output=True, text=True, check=True + ).stdout + + def _run(self, payload: str, ref: str = "main") -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(DONE), "--repo", "owner/Repo", "--git-dir", str(self.repo), "--ref", ref, "--json"], + input=payload, + capture_output=True, + text=True, + check=False, + ) + + def test_open_issues_with_their_commits(self): + result = self._run(json.dumps([{"number": n} for n in (3, 4, 5, 6, 7)])) + self.assertEqual(result.returncode, 0, result.stderr) + newest, closes5, mentions4, fixes3 = (sha[:10] for sha in self.shas) + self.assertEqual( + json.loads(result.stdout), + [ + {"issue": 3, "closing": True, "commits": [newest, fixes3]}, + {"issue": 4, "closing": False, "commits": [mentions4]}, + {"issue": 5, "closing": True, "commits": [closes5]}, + ], + ) + + def test_bad_input_or_ref_exits_2(self): + self.assertEqual(self._run("not json").returncode, 2) + self.assertEqual(self._run('[{"id": 1}]').returncode, 2) + self.assertEqual(self._run("[]", ref="nope").returncode, 2) + + +if __name__ == "__main__": + unittest.main()