fix(review): accumulate submit-receipt ids across a window, and test the producer

Two gaps in the review-channel bypass audit's receipt contract:

- The receipt was overwritten on every submit, but the audit window spans
  drift restarts (fetch-pr preserves auditSince), so two sanctioned
  submits could fall in one window — the single-id receipt then vouched
  only for the last, and cleanup flagged the earlier legitimate review as
  a bypass (a false positive for a write submit itself made). The receipt
  now accumulates ids (read prior, append, dedupe); cleanup reads the set
  and excludes all of them. Both sides migrate a legacy single `reviewId`.
- The producer half had no test: submit.test.ts's ghMock returns '', so
  JSON.parse(response) threw and the receipt write always hit its catch —
  the happy path never ran. Added producer tests (id/event/timestamp
  written, accumulation across two submits, legacy migration) driven from
  inside the fixture dir, plus a cleanup test that spares every id in a
  multi-id receipt.
This commit is contained in:
verify 2026-07-25 10:02:41 +08:00
parent aea1a44bd1
commit 87554647c4
4 changed files with 154 additions and 17 deletions

View file

@ -241,17 +241,19 @@ describe('findUnsanctionedReviews', () => {
],
'reviewer',
since,
null,
new Set(),
);
expect(got.map((r) => r.id)).toEqual([1]);
});
it('excludes exactly the receipt-vouched review id', () => {
it('excludes every receipt-vouched review id, not just the last', () => {
// Two sanctioned submits in one window (drift restart) — both ids are on
// the receipt, and NEITHER may be flagged.
const got = findUnsanctionedReviews(
[review({ id: 1 }), review({ id: 2 })],
[review({ id: 1 }), review({ id: 2 }), review({ id: 3 })],
'reviewer',
since,
2,
new Set([2, 3]),
);
expect(got.map((r) => r.id)).toEqual([1]);
});
@ -524,6 +526,42 @@ describe('runCleanup — bypass-write audit', () => {
expect(warnings.join('\n')).not.toContain('review 500');
});
it('spares every review in a multi-id receipt (two sanctioned submits in one window)', () => {
mocks.readFileSync.mockImplementation((path: string) => {
if (String(path).endsWith('submit-receipt.json')) {
return JSON.stringify({ reviewIds: [500, 502] });
}
return fetchReport;
});
mocks.ghApiAll.mockImplementation((path: string) =>
path.includes('/reviews')
? [
{
id: 500,
user: { login: 'reviewer' },
state: 'COMMENT',
submitted_at: '2026-07-24T09:00:00Z',
},
{
id: 502,
user: { login: 'reviewer' },
state: 'COMMENT',
submitted_at: '2026-07-24T09:05:00Z',
},
]
: [],
);
runCleanup('pr-123');
const warnings = mocks.writeStdoutLine.mock.calls
.map((c) => String(c[0]))
.filter((l) => l.startsWith('warning:'));
// Both are receipt-vouched → no bypass warning at all.
expect(warnings.join('\n')).not.toContain('review 500');
expect(warnings.join('\n')).not.toContain('review 502');
});
it('names each malformed-report shape and never reaches GitHub', () => {
const cases: Array<[string, string]> = [
['not json at all {', 'not valid JSON'],

View file

@ -157,14 +157,17 @@ export interface RawReview {
* review`, direct POSTs to `pulls/<n>/reviews`), and unlike issue comments
* a review CAN legitimately appear here the sanctioned submit posts one
* so sanctioned-vs-bypass is decided by id against the receipt submit wrote.
* No receipt vouches for nothing: with zero sanctioned writes recorded,
* every in-window review by the account is flagged (fail-safe).
* The receipt vouches for a SET of ids, not one: the window spans drift
* restarts, so two sanctioned submits can fall in it, and excluding only the
* last would flag the earlier legitimate review as a bypass. No receipt
* vouches for nothing: with zero sanctioned writes recorded, every in-window
* review by the account is flagged (fail-safe).
*/
export function findUnsanctionedReviews(
reviews: RawReview[],
reviewer: string,
sinceIso: string,
receiptReviewId: number | null,
receiptReviewIds: ReadonlySet<number>,
): RawReview[] {
const reviewerLc = reviewer.toLowerCase();
return reviews.filter(
@ -172,7 +175,7 @@ export function findUnsanctionedReviews(
(r.user?.login ?? '').toLowerCase() === reviewerLc &&
typeof r.submitted_at === 'string' &&
r.submitted_at >= sinceIso &&
r.id !== receiptReviewId,
!receiptReviewIds.has(r.id),
);
}
@ -243,15 +246,24 @@ function readAuditWindow(
}
}
/** The review id the sanctioned submit recorded, or null when none did. */
function readSubmitReceipt(target: string): number | null {
/**
* The set of review ids sanctioned submits recorded this session empty when
* none did. Reads the current `reviewIds: number[]` shape and migrates a
* legacy single `reviewId` a receipt from an older CLI carries.
*/
function readSubmitReceipt(target: string): Set<number> {
try {
const receipt = JSON.parse(
readFileSync(tmpFile(target, 'submit-receipt.json'), 'utf8'),
) as { reviewId?: unknown };
return typeof receipt.reviewId === 'number' ? receipt.reviewId : null;
) as { reviewIds?: unknown; reviewId?: unknown };
const ids = Array.isArray(receipt.reviewIds)
? receipt.reviewIds
: typeof receipt.reviewId === 'number'
? [receipt.reviewId]
: [];
return new Set(ids.filter((n): n is number => typeof n === 'number'));
} catch {
return null;
return new Set();
}
}

View file

@ -13,6 +13,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
utimesSync,
writeFileSync,
@ -704,3 +705,55 @@ describe('what the reviewer caught in this change', () => {
expect(out.cappedBy).toContain('uncoverable-chunk');
});
});
// The submit receipt is the WRITE half of cleanup's bypass-audit contract:
// cleanup reads the review ids it records to tell a sanctioned review from a
// bypass. Every other test here leaves ghMock returning '' (so JSON.parse of
// the response throws and the receipt block hits its catch), which means the
// happy path where a receipt is actually written was never exercised. These
// run the command from inside the fixture dir so the relative .qwen/tmp
// receipt lands there.
describe('submit receipt (producer half of the audit contract)', () => {
const receiptPath = () =>
join(dir, '.qwen', 'tmp', 'qwen-review-pr-6771-submit-receipt.json');
const authorizedPost = (over: Record<string, unknown> = {}) =>
args({ userAuthorized: true, ...over });
let savedCwd: string;
beforeEach(() => {
savedCwd = process.cwd();
process.chdir(dir);
});
afterEach(() => process.chdir(savedCwd));
it('writes the posted review id, event and a timestamp', () => {
ghMock.mockImplementationOnce(() => JSON.stringify({ id: 42 }));
runSubmit(authorizedPost());
const receipt = JSON.parse(readFileSync(receiptPath(), 'utf8'));
expect(receipt.reviewIds).toEqual([42]);
expect(receipt.event).toBe('COMMENT');
expect(typeof receipt.postedAt).toBe('string');
});
it('accumulates ids across two submits in the same window (drift restart)', () => {
ghMock.mockImplementationOnce(() => JSON.stringify({ id: 42 }));
runSubmit(authorizedPost());
ghMock.mockImplementationOnce(() => JSON.stringify({ id: 43 }));
runSubmit(authorizedPost());
const receipt = JSON.parse(readFileSync(receiptPath(), 'utf8'));
expect(receipt.reviewIds).toEqual([42, 43]);
});
it('migrates a legacy single-id receipt on the next submit', () => {
mkdirSync(join(dir, '.qwen', 'tmp'), { recursive: true });
writeFileSync(
receiptPath(),
JSON.stringify({ reviewId: 7, event: 'COMMENT', postedAt: 'x' }),
);
ghMock.mockImplementationOnce(() => JSON.stringify({ id: 8 }));
runSubmit(authorizedPost());
const receipt = JSON.parse(readFileSync(receiptPath(), 'utf8'));
expect(receipt.reviewIds).toEqual([7, 8]);
});
});

View file

@ -79,6 +79,30 @@ function defaultSkillArgsPath(): string {
/** The only events GitHub's Create Review API accepts. */
const EVENTS = new Set(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']);
/**
* Review ids a prior submit in this window already recorded. Accepts both the
* current `reviewIds: number[]` shape and the legacy single `reviewId` a
* receipt written by an older CLI carries. Best-effort: an absent or
* unreadable receipt is an empty list, never a throw the caller adds the
* current id regardless.
*/
function readReceiptIds(receiptPath: string): number[] {
try {
const r = JSON.parse(readFileSync(receiptPath, 'utf8')) as {
reviewIds?: unknown;
reviewId?: unknown;
};
const ids = Array.isArray(r.reviewIds)
? r.reviewIds
: typeof r.reviewId === 'number'
? [r.reviewId]
: [];
return ids.filter((n): n is number => typeof n === 'number');
} catch {
return [];
}
}
/**
* A line number GitHub will take: a positive whole number.
*
@ -528,19 +552,29 @@ export function runSubmit(args: SubmitArgs): void {
'--input',
'-',
);
// Receipt for cleanup's bypass audit: the ONE review this run was
// Receipt for cleanup's bypass audit: EVERY review this session was
// authorised to create, by id. The audit lists reviews by the reviewing
// account inside the window and flags any the receipt does not vouch for —
// without the id, a bypass posted through `gh pr review` (a review, not an
// issue comment) would be indistinguishable from the sanctioned one.
// Best-effort: a receipt failure must never fail a review that DID post.
//
// The receipt ACCUMULATES ids rather than overwriting: the audit window
// spans drift restarts (fetch-pr preserves `auditSince`), so two sanctioned
// submits can fall in one window. A single-id receipt vouched only for the
// last, and the earlier legitimate review was then flagged as a bypass —
// a false positive for a write submit itself made. So read the prior ids,
// add this one, dedupe, write back. Best-effort: a receipt failure must
// never fail a review that DID post.
try {
const reviewId = (JSON.parse(response) as { id?: number }).id;
if (typeof reviewId === 'number') {
const receiptPath = tmpFile(`pr-${args.pr}`, 'submit-receipt.json');
const priorIds = readReceiptIds(receiptPath);
const reviewIds = [...new Set([...priorIds, reviewId])];
mkdirSync(REVIEW_TMP_DIR, { recursive: true });
writeFileSync(
tmpFile(`pr-${args.pr}`, 'submit-receipt.json'),
`${JSON.stringify({ reviewId, event, postedAt: new Date().toISOString() })}\n`,
receiptPath,
`${JSON.stringify({ reviewIds, event, postedAt: new Date().toISOString() })}\n`,
'utf8',
);
}