Skip to content

Commit 64610d6

Browse files
authored
fix: require structured kapi-agent revision comments (#125)
Require current-head structured revision evidence for stale or non-approving kapi-agent reviews, with tests and documentation for the PR re-review workflow.
1 parent df7f830 commit 64610d6

4 files changed

Lines changed: 105 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Issue #37 is intentionally split into reviewable child slices: executable CLI se
103103

104104
### Review CLI Harness
105105

106-
`kapi-review github-pr` emits non-posting JSON for kapi-agent review/check automation and enforces local size, verification, stale-review revision explanation, structured approval-summary, and no-blocking-issues gates. GitHub merge enforcement for formal kapi-agent approval lives in `.github/workflows/kapi-agent-formal-approval-gate.yml`; require `require formal kapi-agent approval` plus `kapi-agent/review` in branch protection/rulesets.
106+
`kapi-review github-pr` emits non-posting JSON for kapi-agent review/check automation and enforces local size, verification, stale-review revision explanation, structured approval-summary, and no-blocking-issues gates. GitHub merge enforcement for formal kapi-agent approval lives in `.github/workflows/kapi-agent-formal-approval-gate.yml`; require `require formal kapi-agent approval` plus `kapi-agent/review` in branch protection/rulesets. Re-review requests after stale/non-approving kapi-agent reviews must put `@kapi-agent review`, the current head SHA, `What changed`, `Why this closes the prior feedback`, and `Verification` in the same author comment; see `docs/kapi-agent-approval-gate.md`.
107107

108108
### Agent Tools
109109

docs/kapi-agent-approval-gate.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,37 @@ Branch protection or rulesets for `dev` should require both:
4242

4343
This makes the PR unmergeable in GitHub until the formal current-head review and bot check are both present. Local CLI/report logic may still explain the state, but it is not the enforcement boundary.
4444

45+
## Revision comment rules
46+
47+
After any stale or non-approving `kapi-agent` review, the author/supervisor must request re-review with a single same-comment trigger and revision explanation. Do not post `@kapi-agent review` separately from the explanation.
48+
49+
Required template:
50+
51+
```md
52+
@kapi-agent review
53+
54+
Revision explanation for current head `<HEAD_SHA>`:
55+
56+
What changed:
57+
- <actual code/docs/test change>
58+
59+
Why this closes the prior feedback:
60+
- Prior blocking issue: <quote or summarize the blocker>
61+
- Resolution: <how this head addresses it>
62+
63+
Verification:
64+
- `<command>` passed.
65+
```
66+
67+
Rules:
68+
69+
1. The comment must include `@kapi-agent review` and the `Revision explanation for current head` line in the same body.
70+
2. The head SHA in the comment must match the current PR head.
71+
3. `What changed`, `Why this closes the prior feedback`, and `Verification` are required headings and each must contain at least one bullet.
72+
4. New pushes require a fresh revision comment for the new head; old explanations become stale.
73+
5. The author comment must not mimic the bot review format (`## kapi-agent review` or `**Verdict:** APPROVE`). Verdict headers are reserved for formal kapi-agent review bodies.
74+
6. Verification must report executed evidence. Speculative wording such as `should pass` or `expected to work` does not satisfy the gate.
75+
4576
## Incident reference
4677

4778
PR #110 was merged after an approval-shaped issue comment but before a formal kapi-agent review. The subsequent formal review requested changes. The corrected rule is: **comment verdicts are never merge approval**.

src/cli/kapi-review-cli.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ interface ParsedArgs { repo: string; pr: number; headSha: string; changedLines:
1515

1616
export function buildKapiReview(input: KapiReviewInput): KapiReviewResult {
1717
const previousReviewOnOlderHead = Boolean(input.priorReviewState && (!input.priorHeadSha || input.priorHeadSha !== input.headSha));
18+
const priorReviewIsNonApproving = input.priorReviewState === "REQUEST_CHANGES" || input.priorReviewState === "COMMENT";
19+
const revisionExplanationRequired = previousReviewOnOlderHead || priorReviewIsNonApproving;
1820
const supersedesPreviousApproval = input.priorReviewState === "APPROVE" && previousReviewOnOlderHead;
1921
const approvalBody = /^## kapi-agent review\s*\n\s*\n\*\*Verdict:\*\*\s*APPROVE(?:\s*\n)/i.test(input.reviewBody.trimStart());
2022
const commentBody = /^## kapi-agent review\s*\n\s*\n\*\*Verdict:\*\*\s*COMMENT(?:\s*\n)/i.test(input.reviewBody.trimStart());
2123
const gates = {
2224
size: input.changedLines < KAPI_REVIEW_MAX_CHANGED_LINES ? "pass" as const : "fail" as const,
2325
verify: input.verification,
24-
revision_explanation: previousReviewOnOlderHead && !hasRevisionExplanation(input.revisionExplanation ?? "") ? "fail" as const : "pass" as const,
26+
revision_explanation: revisionExplanationRequired && !hasRevisionExplanation(input.revisionExplanation ?? "", input.headSha) ? "fail" as const : "pass" as const,
2527
final_approval_summary: !approvalBody || hasFinalApprovalSummary(input.reviewBody) ? "pass" as const : "fail" as const,
2628
};
2729
const gatesPass = Object.values(gates).every((status) => status === "pass");
@@ -77,7 +79,30 @@ async function persistAttempt(root: string, input: ParsedArgs, result: KapiRevie
7779
]);
7880
}
7981

80-
function hasRevisionExplanation(text: string): boolean { return /what changed|changes?|changed|fix(?:ed)?|address(?:ed)?/i.test(text) && /why|because|prior|blocker|risk|reason/i.test(text) && /verification|verified|tests?|checks?|pass(?:ed)?/i.test(text); }
82+
function hasRevisionExplanation(text: string, headSha: string): boolean {
83+
const body = text.trim();
84+
if (!body) return false;
85+
if (!/@kapi-agent\s+review/i.test(body)) return false;
86+
if (new RegExp(`Revision explanation for current head\\s+\\\`${escapeRegExp(headSha)}\\\`\\s*:`, "i").test(body) === false) return false;
87+
if (!/^What changed:\s*$/im.test(body) || !sectionHasBullet(body, "What changed")) return false;
88+
if (!/^Why this closes the prior feedback:\s*$/im.test(body) || !sectionHasBullet(body, "Why this closes the prior feedback")) return false;
89+
if (!/^Verification:\s*$/im.test(body) || !sectionHasBullet(body, "Verification")) return false;
90+
if (/^##\s+kapi-agent review\s*$/im.test(body) || /^\*\*Verdict:\*\*\s*(?:APPROVE|REQUEST_CHANGES|COMMENT)\s*$/im.test(body)) return false;
91+
if (/\b(?:should|expected to|probably|hopefully)\s+(?:pass|work|be green)\b/i.test(body)) return false;
92+
return true;
93+
}
94+
function sectionHasBullet(text: string, heading: string): boolean {
95+
const lines = text.split(/\r?\n/);
96+
const start = lines.findIndex((line) => line.trim().toLowerCase() === `${heading.toLowerCase()}:`);
97+
if (start === -1) return false;
98+
const sectionLines = [];
99+
for (const line of lines.slice(start + 1)) {
100+
if (/^\S.*:\s*$/.test(line)) break;
101+
sectionLines.push(line);
102+
}
103+
return sectionLines.some((line) => /^\s*-\s+\S/.test(line));
104+
}
105+
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
81106
function hasFinalApprovalSummary(text: string): boolean { const body = text.trimStart(); const final = body.match(/^## kapi-agent review\s*\n\s*\n\*\*Verdict:\*\*\s*APPROVE\s*\n\s*\n## Final approval summary\s*\n([\s\S]*?)\n### Blocking issues\s*\n([\s\S]*?)(?=\n### |\n## |$)/i); const summary = final?.[1] ?? "", issues = final?.[2]?.trim(); return Boolean(final) && /^### Review journey\s*$/im.test(summary) && !/^### Blocking issues\s*$/im.test(summary) && /^### What changed\s*$/im.test(summary) && /^### Why this is correct\s*$/im.test(summary) && /^### Evidence\s*$/im.test(summary) && /^### Remaining risks and approval rationale\s*$/im.test(summary) && /semantic scope/i.test(summary) && issues === "- none"; }
82107
async function nextAttemptNumber(base: string): Promise<number> { try { const dirs = await readdir(path.join(base, "attempts")); return Math.max(0, ...dirs.map((name) => Number(name)).filter(Number.isInteger)) + 1; } catch { return 1; } }
83108
function formatResult(result: KapiReviewResult): string { return `kapi-review ${result.context.repo}#${result.context.pr}: ${result.verdict}\ncheck: ${result.check_conclusion}\ngates: ${Object.entries(result.gates).map(([key, value]) => `${key}=${value}`).join(", ")}\n`; }

test/kapi-review-cli.test.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ import { test } from "node:test";
66
import { buildKapiReview, runKapiReviewCli } from "../src/cli/kapi-review-cli.js";
77
const approvingBody = "## kapi-agent review\n\n**Verdict:** APPROVE\n\n## Final approval summary\n\n### Review journey\n- Reviewed the semantic scope and prior feedback.\n\n### What changed\n- Harness gates are covered.\n\n### Why this is correct\n- The JSON result follows policy.\n\n### Evidence\n- tests pass\n\n### Remaining risks and approval rationale\n- none\n\n### Blocking issues\n- none\n";
88
const headSha = "0123456789abcdef0123456789abcdef01234567", priorHeadSha = "fedcba9876543210fedcba9876543210fedcba98";
9+
const validRevisionExplanation = `@kapi-agent review
10+
11+
Revision explanation for current head \`${headSha}\`:
12+
13+
What changed:
14+
- Tightened the revision gate.
15+
16+
Why this closes the prior feedback:
17+
- Prior review asked for current-head comment evidence.
18+
19+
Verification:
20+
- Tests passed.
21+
`;
922

1023
async function withBodyFile<T>(fn: (workspace: string, bodyFile: string) => Promise<T>): Promise<T> {
1124
const workspace = await mkdtemp(path.join(os.tmpdir(), "kapi-review-cli-"));
@@ -34,12 +47,39 @@ test("kapi-review maps gates to review verdict and check conclusion", () => {
3447
const comment = buildKapiReview({ repo: "devkade/kapi", pr: 35, headSha, changedLines: 42, verification: "pass", reviewBody: "## kapi-agent review\n\n**Verdict:** COMMENT\n\n### Notes\n- non-blocking\n" }); assert.equal(comment.verdict, "COMMENT"); assert.equal(comment.check_conclusion, "neutral");
3548
});
3649

37-
test("kapi-review refresh-check requires revision explanation after any stale review", () => {
50+
test("kapi-review refresh-check requires same-comment current-head revision explanation", () => {
3851
for (const prior of ["APPROVE", "COMMENT"] as const) {
39-
const result = buildKapiReview({ repo: "devkade/kapi", pr: 35, headSha, changedLines: 41, verification: "pass", reviewBody: approvingBody, priorReviewState: prior, priorHeadSha, refreshCheck: true });
40-
assert.equal(result.verdict, "REQUEST_CHANGES");
41-
assert.equal(result.check_conclusion, "failure");
42-
assert.equal(result.gates.revision_explanation, "fail");
52+
const missing = buildKapiReview({ repo: "devkade/kapi", pr: 35, headSha, changedLines: 41, verification: "pass", reviewBody: approvingBody, priorReviewState: prior, priorHeadSha, refreshCheck: true });
53+
assert.equal(missing.verdict, "REQUEST_CHANGES");
54+
assert.equal(missing.check_conclusion, "failure");
55+
assert.equal(missing.gates.revision_explanation, "fail");
56+
57+
const valid = buildKapiReview({ repo: "devkade/kapi", pr: 35, headSha, changedLines: 41, verification: "pass", reviewBody: approvingBody, priorReviewState: prior, priorHeadSha, refreshCheck: true, revisionExplanation: validRevisionExplanation });
58+
assert.equal(valid.verdict, "APPROVE");
59+
assert.equal(valid.gates.revision_explanation, "pass");
60+
}
61+
62+
for (const prior of ["REQUEST_CHANGES", "COMMENT"] as const) {
63+
const missing = buildKapiReview({ repo: "devkade/kapi", pr: 35, headSha, changedLines: 41, verification: "pass", reviewBody: approvingBody, priorReviewState: prior, priorHeadSha: headSha, refreshCheck: true });
64+
assert.equal(missing.verdict, "REQUEST_CHANGES");
65+
assert.equal(missing.check_conclusion, "failure");
66+
assert.equal(missing.gates.revision_explanation, "fail");
67+
68+
const valid = buildKapiReview({ repo: "devkade/kapi", pr: 35, headSha, changedLines: 41, verification: "pass", reviewBody: approvingBody, priorReviewState: prior, priorHeadSha: headSha, refreshCheck: true, revisionExplanation: validRevisionExplanation });
69+
assert.equal(valid.verdict, "APPROVE");
70+
assert.equal(valid.gates.revision_explanation, "pass");
71+
}
72+
73+
for (const revisionExplanation of [
74+
validRevisionExplanation.replace("@kapi-agent review\n\n", ""),
75+
validRevisionExplanation.replace(headSha, priorHeadSha),
76+
validRevisionExplanation.replace("Why this closes the prior feedback:", "Why:"),
77+
validRevisionExplanation.replace("- Tests passed.", "Tests should pass."),
78+
`@kapi-agent review\n\n## kapi-agent review\n\n**Verdict:** APPROVE\n\nRevision explanation for current head \`${headSha}\`:\n\nWhat changed:\n- x\n\nWhy this closes the prior feedback:\n- y\n\nVerification:\n- tests passed\n`,
79+
]) {
80+
const invalid = buildKapiReview({ repo: "devkade/kapi", pr: 35, headSha, changedLines: 41, verification: "pass", reviewBody: approvingBody, priorReviewState: "REQUEST_CHANGES", priorHeadSha: headSha, refreshCheck: true, revisionExplanation });
81+
assert.equal(invalid.verdict, "REQUEST_CHANGES");
82+
assert.equal(invalid.gates.revision_explanation, "fail");
4383
}
4484
});
4585

@@ -65,7 +105,7 @@ test("kapi-review JSON CLI enforces revision explanation and preserves attempts"
65105
await withBodyFile(async (workspace, bodyFile) => {
66106
const revisionFile = path.join(workspace, "revision.md");
67107
const artifactRoot = path.join(workspace, "artifacts");
68-
await writeFile(revisionFile, "Changes: tightened gates.\nWhy: addresses the prior review risk.\nTests passed.\n", "utf8");
108+
await writeFile(revisionFile, validRevisionExplanation, "utf8");
69109
const args = ["github-pr", "--repo", "devkade/kapi", "--pr", "35", "--head-sha", headSha, "--changed-lines", "41", "--verification", "pass", "--body-file", bodyFile, "--prior-review-state", "REQUEST_CHANGES", "--revision-explanation-file", revisionFile, "--artifact-root", artifactRoot, "--json"];
70110
const first = await runKapiReviewCli(args);
71111
const second = await runKapiReviewCli(args);

0 commit comments

Comments
 (0)