Skip to content
Draft
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
29 changes: 28 additions & 1 deletion runner/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { isDeepStrictEqual } from 'node:util';
import { Store, type ReviewState, type SnippetReference } from './store.ts';
import type { PlanIdentity } from '../core/identity.ts';
import { readHistory } from '../git/history.ts';
import { execFileSync } from 'node:child_process';
import { isolatedGitEnvironment } from '../scripts/git-environment.ts';
import type { BaseEntry, PlanContext } from '../core/plan.ts';
import { linkHistory } from '../core/linking.ts';
import { applyChoices, approvalStates, approveItem, choiceKeys } from '../core/approvals.ts';
import type { GhMergeConfig } from '../github/merge.ts';
Expand Down Expand Up @@ -87,7 +90,25 @@ export class ReviewService {
const token = createHash('sha256').update(JSON.stringify({ expected, saved, plan, segments })).digest('hex');
return { repository: basename(repository), demo: this.config.demo ?? false, plan, snapshot, expected, token, items, segments, notes, approved: items.filter(item => item.state === 'approved').length };
}
act(input: unknown) {
/** The trusted plan context for import and Apply: base entries from the snapshot's base tree, and the configured path identity. */
planContext(): PlanContext {
const { identity, repository, pathIdentity } = this.config, plan = this.store.getPlan(identity), snapshot = this.store.getSnapshot(identity);
const pathKey = (path: string) => {
if (!pathIdentity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.');
const normalized = pathIdentity.unicodeNormalization === 'NFC' ? path.normalize('NFC') : path;
return pathIdentity.caseSensitive ? normalized : normalized.toLowerCase();
};
const listing = execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'ls-tree', '-rz', snapshot.base], { cwd: repository, env: isolatedGitEnvironment(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const baseEntries: BaseEntry[] = listing.split('\0').filter(Boolean).map(record => {
const split = record.indexOf('\t'), [mode, , oid] = record.slice(0, split).split(' '), path = record.slice(split + 1);
if (mode === '160000') return { path, kind: 'gitlink' };
if (mode === '120000') return { path, kind: 'symlink', target: execFileSync('git', ['cat-file', 'blob', oid!], { cwd: repository, env: isolatedGitEnvironment(), encoding: 'utf8' }) };
return { path, kind: 'file' };
});
return { identity, issue: plan.issue, baseEntries, pathKey, allowedCommands: [] };
}
/** With an actionId (inside Store.userAction), feedback-producing actions record their event in the same transaction. */
act(input: unknown, actionId?: string) {
if (!input || typeof input !== 'object') throw new Error('Invalid review command.');
const command = input as Record<string, unknown>;
const view = this.load();
Expand All @@ -105,6 +126,11 @@ export class ReviewService {
const item = command.action === 'assign' && typeof command.item === 'string' ? command.item : null;
const storedKey = choiceKeys(view.segments, identity)[view.segments.indexOf(segment)]!;
this.store.saveReview(identity, view.expected, [], [{ key: storedKey, action: command.action, item }]);
// Choice keys embed segment content and are unbounded; the event's source is a stable fixed-size fingerprint of the key.
const sourceRef = `choice:${createHash('sha256').update(storedKey).digest('hex')}`;
if (actionId) this.store.recordFeedback(identity, actionId, command.action === 'assign'
? { kind: 'segment-assign', item, sourceRef, supersedeLatest: true }
: { kind: 'segment-accept', sourceRef, supersedeLatest: true });
} else if (command.action === 'note' && typeof command.item === 'string' && typeof command.text === 'string' && (command.kind === 'question' || command.kind === 'change')) {
let reference: SnippetReference | undefined;
if (command.reference !== undefined) {
Expand All @@ -121,6 +147,7 @@ export class ReviewService {
reference = { key: segment.key, path: segment.operation === '-' ? segment.oldPath ?? segment.path : segment.path, side: segment.operation === '+' ? 'new' : 'old', start, end, text, head: view.snapshot.head, base: view.snapshot.base };
}
createdNoteId = this.store.addReviewNote(identity, view.expected, command.item, command.kind, command.text, reference).id;
if (actionId && command.kind === 'change') this.store.recordFeedback(identity, actionId, { kind: 'change-request', item: command.item, text: command.text.trim(), sourceRef: createdNoteId });
} else throw new Error('Unknown review command.');
return { ...this.load(), createdNoteId };
}
Expand Down
5 changes: 4 additions & 1 deletion runner/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,13 +760,16 @@ export class Store {
}
}
/** Append one feedback event. Call inside userAction so the event and its action share one transaction. */
recordFeedback(identity: PlanIdentity, actionId: string, event: { kind: Exclude<FeedbackKind, 'task-closed'>; item?: string | null; text?: string | null; sourceRef: string; supersedes?: string | null }): FeedbackEvent {
recordFeedback(identity: PlanIdentity, actionId: string, event: { kind: Exclude<FeedbackKind, 'task-closed'>; item?: string | null; text?: string | null; sourceRef: string; supersedes?: string | null; supersedeLatest?: boolean }): FeedbackEvent {
assertUuidV4(actionId, 'Action ID');
if (this.#depth === 0) throw new Error('Feedback events are written inside their user action.');
if (!FEEDBACK_KINDS.includes(event.kind) || event.kind === ('task-closed' as FeedbackKind)) throw new GuardRefusal('Invalid feedback kind.');
if (event.text != null && (typeof event.text !== 'string' || event.text.length > 4000)) throw new GuardRefusal('Feedback text is limited to 4000 characters.');
if (typeof event.sourceRef !== 'string' || !event.sourceRef || event.sourceRef.length > 200) throw new GuardRefusal('Invalid feedback source.');
const key = identityKey(identity), plan = this.#current(key);
// A changed segment choice links to the latest earlier event for the same choice key.
if (event.supersedeLatest && event.supersedes == null)
event = { ...event, supersedes: (this.#get("SELECT id FROM feedback_events WHERE plan_key=? AND source_ref=? AND kind IN ('segment-accept','segment-assign') ORDER BY rowid DESC LIMIT 1", key, event.sourceRef)?.id as string | undefined) ?? null };
if (event.supersedes != null && !this.#get('SELECT 1 FROM feedback_events WHERE plan_key=? AND id=? AND source_ref=?', key, event.supersedes, event.sourceRef))
throw new GuardRefusal('A superseded event must belong to the same source.');
const id = randomUUID(), createdAt = new Date().toISOString();
Expand Down
3 changes: 2 additions & 1 deletion test/browser/review.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { test, expect } from '@playwright/test';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
Expand Down Expand Up @@ -138,7 +139,7 @@ test('shows an honest history error and keeps markup in notes as text',async({pa
test('can assign a large foreign change without sending its content back in the command',async({request})=>{
const repository=app.service.config.repository;writeFileSync(join(repository,'debug.log'),'x'.repeat(20000)+'\n');execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','Large foreign change'],{cwd:repository,stdio:'pipe'});
const base=app.url.split('#')[0]!,headers={'x-codeboost-token':app.token};const view=await(await request.get(base+'api/review',{headers})).json();const segment=view.segments.find((s:{content:string;row:string})=>s.row==='Unplanned'&&s.content.length>19000);
const response=await request.post(base+'api/action',{headers:{...headers,'Content-Type':'application/json'},data:{action:'assign',item:'P1',key:segment.key,token:view.token}});
const response=await request.post(base+'api/action',{headers:{...headers,'Content-Type':'application/json'},data:{action:'assign',item:'P1',key:segment.key,token:view.token,actionId:randomUUID()}});
expect(response.status()).toBe(200);
});
test('shows bounded raster previews and byte sizes for file-change cards',async({page})=>{
Expand Down
Loading
Loading