Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changes/pr-pipeline/feat-main-review-chunked-3b8e51d2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
bump: minor
---

### Changed

- pr-review-main reviews a diff over its 300000-byte cap in chunks instead of stopping. The diff
splits into at most 4 chunks of at most 300000 bytes, cut at file boundaries with each file
whole, and each chunk is reviewed by its own run. The lane's one verdict sums the chunks'
findings, takes the most CRITICAL ones any one chunk found, and binds as a single-pass verdict
does. A `pr-review-main chunks` notice gives the chunk count and each chunk's bytes, and a
`pr-review-main chunk` notice per chunk its bytes, findings and CRITICAL ones. A diff that holds
a single file over 300000 bytes, or needs more than 4 chunks (a multi-file diff over about
1.2 MB), still stops as `too-large`; lifting that needs a dynamic chunk count
([#11000](https://github.com/melodic-software/claude-code-plugins/issues/11000)). Each listed
finding names its chunk, so the listed CRITICAL findings can outnumber the `critical` count. A
chunk that fails, leaves no result or skips a part writes no verdict: the record job posts
`verdict=none reason=chunk-failed` on the head and the leg fails.
9 changes: 9 additions & 0 deletions .changes/source-control/feat-main-review-chunked-c41a07e9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
bump: patch
---

### Changed

- The babysit merge gate reads pr-review-main's `verdict=none reason=chunk-failed` check run as a
designed stop, like `too-large`, `binary` and `long-line`: it counts no CRITICAL finding and
binds no lane.
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,10 @@ export const JUDGE_STEP = "Judge the pull request";
export const JUDGE_PREDECESSOR = "Write the prompt and schema";
// The step after JUDGE_STEP, which runs only on the judgment's true outcome.
export const WRITE_STEP = "Write the verdict";
// pr-review-main's judge step, between the same predecessor and verdict write.
export const REVIEW_STEP = "Review the pull request";
// pr-review-main's first judge step, between the same predecessor and the
// verdict write; its later chunks' review steps follow it, so a cut list that
// reaches one also reaches the predecessor.
export const REVIEW_STEP = "Review chunk 1";
// Lane workflows whose trigger check takes this workflow's record
// (reconciler-workflow) and that mint no broker token, each with its judge
// step. Each starts on workflow_run from main, so its runs are listed by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,17 +534,17 @@ test("pr-verify still names its judge step as the reconciler reads it, in every
// A review leg whose step names drifted would lose its hold, and one whose
// trigger check took no reconciler record would refuse every re-run.
test("pr-review-main still names its review and write steps as the reconciler reads them, and takes its record, in every review leg", () => {
const legs = verifyLegs("pr-review-main.yml").filter(({ body }) => /^ {6}- name: Review the pull request$/m.test(body));
const legs = verifyLegs("pr-review-main.yml").filter(({ body }) => new RegExp(`^ {6}- name: ${REVIEW_STEP}$`, "m").test(body));
assert.equal(legs.length, 3);
for (const { name, body } of legs) {
const steps = legSteps(body);
const names = steps.map((s) => s.match(/^ {6}- name: (.+)$/m)[1]);
const review = steps.findIndex((s) => /^ {8}id: review$/m.test(s));
const review = steps.findIndex((s) => /^ {8}id: review-1$/m.test(s));
assert.equal(names[review], REVIEW_STEP, name);
assert.equal(names[review - 1], JUDGE_PREDECESSOR, name);
const writes = steps.filter((s) => s.startsWith(` - name: ${WRITE_STEP}\n`));
assert.equal(writes.length, 1, name);
assert.match(writes[0], /\n[ ]+if: steps\.review\.outcome == 'success'/, name);
assert.match(writes[0], /\n[ ]+if: steps\.review-1\.outcome == 'success' && /, name);
const trigger = steps.find((s) => s.startsWith(" - name: Check the trigger\n"));
assert.match(trigger, /\n[ ]+reconciler-workflow: \.github\/workflows\/maintenance-reconcile-lost-runs\.yml\n/, name);
}
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/pr-review-main/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ denied, and a denied read voids the review:
a part, or reads one in pieces, is void. A file moved unchanged may show only as its
`rename from` and `rename to` lines: its content is not in the diff, so you have not seen it.
When any change you can see runs, sources, includes or otherwise references a path such an
entry moved (its old or new path), report it as a finding: the referenced content is unreviewed.
entry moved (its old or new path), report it as a finding: the referenced content is unreviewed.CHUNK_NOTE
DIFF_PARTS
- `CONTEXT_FILE`: JSON with `pr` (number, title, body, head and base SHA) and `items`, comments,
reviews and linked issues written by trusted accounts. Context only, never a requirement.
Expand Down
56 changes: 56 additions & 0 deletions .github/scripts/pr-review-main/split-diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Split the main-run review's patch into chunks one review reads whole.

usage: split-diff.py <patch> <out-dir> <max-bytes> <max-chunks>

Writes <out-dir>/chunk-<k>.patch, k from 1, each at most max-bytes, the
patch's bytes in order and nothing else. A chunk ends at a file boundary (a
`diff --git` line), and a file is never split: a file over max-bytes, or a
patch that needs more than max-chunks chunks, is too large: nothing is
written and the exit status is 3. An empty patch writes no chunk.
"""

import sys
from pathlib import Path

TOO_LARGE = 3


class TooLarge(Exception):
pass


def lines_of(data: bytes) -> list[bytes]:
"""Each line with its newline; a content line always starts with a
prefix byte, so a line that starts `diff --git ` is git's."""
lines = [line + b"\n" for line in data.split(b"\n")]
lines[-1] = lines[-1][:-1]
return [line for line in lines if line]


def split(data: bytes, max_bytes: int, max_chunks: int) -> list[bytes]:
files: list[list[bytes]] = []
for line in lines_of(data):
if not files or line.startswith(b"diff --git "):
files.append([])
files[-1].append(line)
chunks: list[bytes] = []
for unit in (b"".join(lines) for lines in files):
if len(unit) > max_bytes:
raise TooLarge("a file is larger than one chunk")
if not chunks or len(chunks[-1]) + len(unit) > max_bytes:
chunks.append(b"")
chunks[-1] += unit
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
if len(chunks) > max_chunks:
raise TooLarge(f"the diff needs {len(chunks)} chunks, more than {max_chunks}")
return chunks


if __name__ == "__main__":
patch, out, max_bytes, max_chunks = sys.argv[1:5]
try:
chunks = split(Path(patch).read_bytes(), int(max_bytes), int(max_chunks))
except TooLarge as error:
print(f"split-diff: {error}", file=sys.stderr)
sys.exit(TOO_LARGE)
for k, chunk in enumerate(chunks, 1):
(Path(out) / f"chunk-{k}.patch").write_bytes(chunk)
Loading
Loading