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
58 changes: 57 additions & 1 deletion __tests__/unit/github/ledger.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ReviewLedger } from '../../../src/github/ledger';
import {
ReviewLedger,
commandLedgerWatermark,
} from '../../../src/github/ledger';
import { GitHubClient } from '../../../src/github/client';

function makeClient(
Expand All @@ -23,6 +26,59 @@ function makeClient(
}

describe('ReviewLedger', () => {
it('derives a stable watermark from accepted command comment ids', () => {
expect(
commandLedgerWatermark({
version: 1,
repo: 'test-owner/test-repo',
pr: 123,
entries: [
{
action: 'skip',
fingerprint: 'abc123',
severity: 'major',
actor: 'maintainer',
actorRole: 'maintain',
parentCommentId: 99,
commandCommentId: 101,
createdAt: '2026-05-01T00:00:00.000Z',
},
{
action: 'unskip',
fingerprint: 'abc123',
severity: 'major',
actor: 'maintainer',
actorRole: 'maintain',
parentCommentId: 99,
commandCommentId: 105,
createdAt: '2026-05-01T00:01:00.000Z',
},
],
})
).toBe(105);
});

it('uses the parent id for legacy signed entries without a command id', () => {
expect(
commandLedgerWatermark({
version: 1,
repo: 'test-owner/test-repo',
pr: 123,
entries: [
{
action: 'skip',
fingerprint: 'abc123',
severity: 'major',
actor: 'maintainer',
actorRole: 'maintain',
parentCommentId: 99,
createdAt: '2026-05-01T00:00:00.000Z',
},
],
})
).toBe(99);
});

it('creates a signed ledger comment and can load it back', async () => {
const { client, octokit } = makeClient([]);
const ledger = new ReviewLedger(client, 'test-secret');
Expand Down
69 changes: 41 additions & 28 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32637,6 +32637,18 @@ var import_crypto4 = require("crypto");
var LEDGER_MARKER = "reviewrouter-ledger:v1";
var LEDGER_RE = /<!--\s*reviewrouter-ledger:v1\s+payload=([A-Za-z0-9_-]+)\s+signature=([a-f0-9]{64})\s*-->/;
var MAX_LEDGER_ENTRIES = 200;
function commandLedgerWatermark(payload) {
return Math.max(
0,
...payload.entries.map((entry) => {
const value = entry.commandCommentId ?? entry.parentCommentId;
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error("review_router_command_ledger_watermark_invalid");
}
return value;
})
);
}
var ReviewLedger = class {
constructor(client, secret, dryRun = false) {
this.client = client;
Expand Down Expand Up @@ -71093,19 +71105,24 @@ var GitHubReviewRevisionGuard = class {
}
};
var FreshGitHubLifecycleInventory = class {
loader;
constructor(client) {
constructor(client, ledger) {
this.ledger = ledger;
this.loader = new ReviewThreadInventoryLoader(client);
}
loader;
async loadCurrent(query) {
return mapFreshInventory(
await this.loader.load(query.scope.pullRequestNumber),
query.scope.reviewedHeadSha
);
const [raw, ledger] = await Promise.all([
this.loader.load(query.scope.pullRequestNumber),
this.ledger.load(query.scope.pullRequestNumber)
]);
return mapFreshInventory(raw, query.scope.reviewedHeadSha, ledger);
}
async loadForPrompt(pullRequestNumber, expectedHeadSha) {
const raw = await this.loader.load(pullRequestNumber);
const inventory = mapFreshInventory(raw, expectedHeadSha);
const [raw, ledger] = await Promise.all([
this.loader.load(pullRequestNumber),
this.ledger.load(pullRequestNumber)
]);
const inventory = mapFreshInventory(raw, expectedHeadSha, ledger);
return Object.freeze({
inventory,
promptTargets: Object.freeze([
Expand All @@ -71115,7 +71132,7 @@ var FreshGitHubLifecycleInventory = class {
});
}
};
function mapFreshInventory(raw, expectedHeadSha) {
function mapFreshInventory(raw, expectedHeadSha, ledger) {
if (raw.failed) {
throw new Error("review_action_v2_lifecycle_inventory_unavailable");
}
Expand All @@ -71138,13 +71155,10 @@ function mapFreshInventory(raw, expectedHeadSha) {
if (new Set(rawTargets.map(({ target }) => target.targetId)).size !== rawTargets.length) {
throw new Error("review_action_v2_lifecycle_inventory_duplicate_target");
}
const missingDatabaseId = rawTargets.some(
({ target }) => target.parentCommentDatabaseId === void 0
);
const warnings = [
...raw.warnings,
...missingDatabaseId ? ["review thread comment watermark is incomplete"] : []
].sort();
if (!ledger.valid) {
throw new Error("review_action_v2_command_ledger_unavailable");
}
const warnings = [...raw.warnings].sort();
const targets = rawTargets.map(({ target, manual }) => ({
targetId: target.targetId,
threadId: target.threadId,
Expand All @@ -71169,16 +71183,12 @@ function mapFreshInventory(raw, expectedHeadSha) {
}
} : {}
}));
const commandLedgerWatermark = String(
Math.max(
0,
...rawTargets.map(({ target }) => target.parentCommentDatabaseId ?? 0)
)
);
const commandWatermark = commandLedgerWatermark(ledger.payload);
const commandLedgerWatermarkValue = String(commandWatermark);
const lifecycleStateHash = sha2568(
canonicalJson9({
commandLedgerWatermark,
complete: !missingDatabaseId,
commandLedgerWatermark: commandLedgerWatermarkValue,
complete: true,
loadedForHeadSha,
targets,
warnings
Expand All @@ -71188,8 +71198,8 @@ function mapFreshInventory(raw, expectedHeadSha) {
inventoryVersion: "review_lifecycle_inventory.v1",
loadedForHeadSha,
lifecycleStateHash,
commandLedgerWatermark,
complete: !missingDatabaseId,
commandLedgerWatermark: commandLedgerWatermarkValue,
complete: true,
warnings: Object.freeze(warnings),
targets: Object.freeze(targets)
});
Expand Down Expand Up @@ -71303,7 +71313,7 @@ var BuildCurrentReviewProjection = class {
occurrences = applyPlacementDecisions(occurrences, presentation.placements);
const coverageOnly = coverage.state === "partial" /* Partial */;
const allClear = !coverageOnly && canClaimAllClear(coverage, inventory, occurrences, gate);
const lifecycleFacts = coverageOnly ? [] : buildLifecycleFacts(
const lifecycleFacts = buildLifecycleFacts(
inventory,
lifecycleDecisions,
command.priorLineageHints,
Expand Down Expand Up @@ -73591,7 +73601,10 @@ var ProductionT0ReviewRunner = class {
if (pr2.baseSha.toLowerCase() !== authorization.facts.baseSha || pr2.headSha.toLowerCase() !== authorization.facts.headSha) {
return { outcome: "superseded" /* Superseded */ };
}
const lifecycleInventory = new FreshGitHubLifecycleInventory(github);
const lifecycleInventory = new FreshGitHubLifecycleInventory(
github,
new ReviewLedger(github, process.env.REVIEW_ROUTER_LEDGER_KEY)
);
const initialLifecycle = await lifecycleInventory.loadForPrompt(
pr2.number,
authorization.facts.headSha
Expand Down
4 changes: 2 additions & 2 deletions dist/index.js.map

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/github/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ export interface ActiveLedgerSkip extends LedgerEntry {
action: 'skip';
}

export function commandLedgerWatermark(payload: ReviewLedgerPayload): number {
return Math.max(
0,
...payload.entries.map((entry) => {
const value = entry.commandCommentId ?? entry.parentCommentId;
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error('review_router_command_ledger_watermark_invalid');
}
return value;
})
);
}

export class ReviewLedger {
constructor(
private readonly client: GitHubClient,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { createHash } from 'crypto';
import { GitHubClient } from '../../github/client';
import {
commandLedgerWatermark,
type LoadedLedger,
type ReviewLedger,
} from '../../github/ledger';
import {
ReviewThreadInventoryLoader,
type ReviewThreadInventory,
Expand Down Expand Up @@ -91,17 +96,21 @@ export class GitHubReviewRevisionGuard implements ReviewRevisionGuardPort {
export class FreshGitHubLifecycleInventory implements CurrentLifecycleInventoryPort {
private readonly loader: ReviewThreadInventoryLoader;

constructor(client: GitHubClient) {
constructor(
client: GitHubClient,
private readonly ledger: ReviewLedger
) {
this.loader = new ReviewThreadInventoryLoader(client);
}

async loadCurrent(query: {
readonly scope: ReviewProjectionScope;
}): Promise<CurrentLifecycleInventory> {
return mapFreshInventory(
await this.loader.load(query.scope.pullRequestNumber),
query.scope.reviewedHeadSha
);
const [raw, ledger] = await Promise.all([
this.loader.load(query.scope.pullRequestNumber),
this.ledger.load(query.scope.pullRequestNumber),
]);
return mapFreshInventory(raw, query.scope.reviewedHeadSha, ledger);
}

async loadForPrompt(
Expand All @@ -111,8 +120,11 @@ export class FreshGitHubLifecycleInventory implements CurrentLifecycleInventoryP
readonly inventory: CurrentLifecycleInventory;
readonly promptTargets: readonly LifecycleTarget[];
}> {
const raw = await this.loader.load(pullRequestNumber);
const inventory = mapFreshInventory(raw, expectedHeadSha);
const [raw, ledger] = await Promise.all([
this.loader.load(pullRequestNumber),
this.ledger.load(pullRequestNumber),
]);
const inventory = mapFreshInventory(raw, expectedHeadSha, ledger);
return Object.freeze({
inventory,
promptTargets: Object.freeze([
Expand All @@ -125,7 +137,8 @@ export class FreshGitHubLifecycleInventory implements CurrentLifecycleInventoryP

function mapFreshInventory(
raw: ReviewThreadInventory,
expectedHeadSha: string
expectedHeadSha: string,
ledger: LoadedLedger
): CurrentLifecycleInventory {
if (raw.failed) {
throw new Error('review_action_v2_lifecycle_inventory_unavailable');
Expand Down Expand Up @@ -154,15 +167,10 @@ function mapFreshInventory(
throw new Error('review_action_v2_lifecycle_inventory_duplicate_target');
}

const missingDatabaseId = rawTargets.some(
({ target }) => target.parentCommentDatabaseId === undefined
);
const warnings = [
...raw.warnings,
...(missingDatabaseId
? ['review thread comment watermark is incomplete']
: []),
].sort();
if (!ledger.valid) {
throw new Error('review_action_v2_command_ledger_unavailable');
}
const warnings = [...raw.warnings].sort();
const targets = rawTargets.map(({ target, manual }) => ({
targetId: target.targetId,
threadId: target.threadId,
Expand Down Expand Up @@ -197,16 +205,12 @@ function mapFreshInventory(
}
: {}),
}));
const commandLedgerWatermark = String(
Math.max(
0,
...rawTargets.map(({ target }) => target.parentCommentDatabaseId ?? 0)
)
);
const commandWatermark = commandLedgerWatermark(ledger.payload);
const commandLedgerWatermarkValue = String(commandWatermark);
const lifecycleStateHash = sha256(
canonicalJson({
commandLedgerWatermark,
complete: !missingDatabaseId,
commandLedgerWatermark: commandLedgerWatermarkValue,
complete: true,
loadedForHeadSha,
targets,
warnings,
Expand All @@ -216,8 +220,8 @@ function mapFreshInventory(
inventoryVersion: 'review_lifecycle_inventory.v1',
loadedForHeadSha,
lifecycleStateHash,
commandLedgerWatermark,
complete: !missingDatabaseId,
commandLedgerWatermark: commandLedgerWatermarkValue,
complete: true,
warnings: Object.freeze(warnings),
targets: Object.freeze(targets),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ReviewActionV2Client } from '../../control-plane/review-action-v2-clien
import { BatchOrchestrator } from '../../core/batch-orchestrator';
import { prioritizeFilesByRisk } from '../../review-execution/domain/file-risk-priority';
import { GitHubClient } from '../../github/client';
import { ReviewLedger } from '../../github/ledger';
import { PullRequestLoader } from '../../github/pr-loader';
import { CodexProvider } from '../../providers/codex';
import { recoverDiffForFiles } from '../../utils/diff';
Expand Down Expand Up @@ -104,7 +105,10 @@ export class ProductionT0ReviewRunner implements CodexOAuthV2ReviewRunnerPort {
) {
return { outcome: CodexOAuthV2ReviewOutcome.Superseded };
}
const lifecycleInventory = new FreshGitHubLifecycleInventory(github);
const lifecycleInventory = new FreshGitHubLifecycleInventory(
github,
new ReviewLedger(github, process.env.REVIEW_ROUTER_LEDGER_KEY)
);
const initialLifecycle = await lifecycleInventory.loadForPrompt(
pr.number,
authorization.facts.headSha
Expand Down
Loading