diff --git a/plugins/engineering/.claude-plugin/plugin.json b/plugins/engineering/.claude-plugin/plugin.json index ed72ffa..9f068f3 100644 --- a/plugins/engineering/.claude-plugin/plugin.json +++ b/plugins/engineering/.claude-plugin/plugin.json @@ -1,10 +1,9 @@ { "name": "engineering", - "description": "Reusable engineering workflow skills for repository maintenance and delivery.", + "description": "Reusable engineering workflow skills for repository maintenance, delivery, and interactive pull request review.", "author": { "name": "AllAgents" }, - "version": "1.0.0", - "category": "development", + "version": "1.0.1", "homepage": "https://github.com/EntityProcess/allagents/tree/main/plugins/engineering" } diff --git a/plugins/engineering/skills/pr-interactive-review/SKILL.md b/plugins/engineering/skills/pr-interactive-review/SKILL.md new file mode 100644 index 0000000..2185e99 --- /dev/null +++ b/plugins/engineering/skills/pr-interactive-review/SKILL.md @@ -0,0 +1,129 @@ +--- +name: pr-interactive-review +description: Turn a structured GitHub pull request review into a local interactive site for business context, findings, and reviewer comments. Use when a reviewer needs to triage a PR interactively without posting to GitHub. +--- + +# Interactive Pull Request Review + +Use an existing review skill when one is available, and prefer its structured JSON output. This skill consumes structured findings and presents them locally; it does not select reviewer personas, assign severity, discover review scope, validate findings, or deduplicate findings. + +## Safety boundaries + +- Accept a GitHub PR number or `https://github.com///pull/` URL. +- Prefer the installed review skill's structured or agent output when it supports one. Do not parse markdown output. +- Store generated review data and comments outside the repository. The default workspace is `$XDG_STATE_HOME/allagents/pr-interactive-review/` (or `~/.local/state/allagents/pr-interactive-review/`). +- Never put tokens, cookies, GitHub authentication, comments, or generated review data in the repository. +- The server binds to `127.0.0.1` by default. Non-loopback binding requires the explicit `--expose` option and emits a warning because findings and comments become network-visible. +- Comments are local only. Do not post them to GitHub. This skill does not implement GitHub posting. + +## Obtain a structured review + +From the repository that owns the PR, use an existing review skill when one is available. Ask it for structured JSON and pass the PR target directly. If no review skill is installed, use the host's available code-review capability and produce the same structured review artifact without inventing findings. + +The review artifact must contain `status`, `verdict`, `intent`, `scope.head_sha`, and the validated `findings` array. Require `status: complete`; stop on `failed`, `degraded`, or `skipped`. Save the artifact outside the repository as `review.json` and consume that JSON directly. Do not scrape, transform, or infer findings from a human markdown report. + +### Concrete finding scenarios + +After the review completes, actively enrich every finding without modifying `review.json`. For each stable `#`, inspect only its structured `evidence` and `first_evidence` plus the exact cited path at the reviewed commit. Write a separate JSON sidecar beside the review artifact (or in the external review workspace) as `interactive-scenarios.json`, keyed by the stable finding IDs: + +```json +{ + "#1": { + "what_actually_happens": "When an operator saves a label containing markup, the page renders it and the browser executes it.", + "expected_suggested": "When that label is saved, the page displays the characters as text after encoding at the rendering boundary." + } +} +``` + +`what_actually_happens` must state a triggering setup/action and observable failure. `expected_suggested` must state the expected resulting behavior and correction. This sidecar is presentation context only: it must not change review scope, personas, severity, validation, deduplication, or required response. If direct evidence cannot support either statement, omit that field; the site labels the gap instead of inventing a scenario. `prepare` rejects malformed sidecars and IDs that are not review findings. + +Specifications may be private when the user authorizes access. Use the appropriate host tool to read or extract an authorized local file, document, or URL. Derive only concise labeled primer fields from that material, then pass the derived text with `--spec`; never put the original source content in this public repository. + +```text +Who configures: Release managers +Operational problem: Manual approval queues delay configuration changes +Intended outcome: Operators can complete the change without a queue handoff +Why it matters: Delays postpone customer-visible changes +Success criteria: A configured change completes and records its result +Scope: Configuration flow and audit record +Non-goals: Redesigning permission roles +``` + +`--requirements` remains a direct file-read option only for a repository-relative path. The helper rejects paths outside the repository and `.git`; use host extraction plus `--spec` for any external specification reference. + +## Create a reusable workspace + +Resolve this skill directory, then prepare the site from the structured `review.json`. + +```bash +SKILL_DIR="" +bun "$SKILL_DIR/scripts/review-site.ts" prepare \ + --review-json "/review.json" \ + --scenarios "/interactive-scenarios.json" \ + --pr 123 \ + --spec "Who configures: Release managers +Operational problem: Manual approval queues delay configuration changes +Intended outcome: Operators complete changes without a queue handoff +Why it matters: Delays postpone customer-visible changes +Success criteria: A configured change completes and records its result +Scope: Configuration flow and audit record +Non-goals: Redesigning permission roles" +``` + +For a repository-relative requirements reference, use: + +```bash +bun "$SKILL_DIR/scripts/review-site.ts" prepare \ + --review-json "/review.json" \ + --scenarios "/interactive-scenarios.json" \ + --pr https://github.com/example-org/sample-service/pull/123 \ + --requirements docs/requirements.md +``` + +`prepare` prints the per-repository, per-PR workspace path. It validates the review artifact, scenario sidecar, PR identifier, finding file paths, sizes, and requirements reference. It generates GitHub source links only when the runtime `origin` remote is GitHub. Links pin the reviewed commit and exact cited line range. Non-GitHub remotes receive no external link. + +For focused code context, `prepare` reads only the cited relative paths at the reviewed commit. Pass `--base-commit ` only when the review already supplied a verified exact base SHA; the helper does not rediscover PR scope. When no base or reviewed object is locally readable, the site labels that gap instead of substituting current-worktree content. + +## Host the site + +```bash +bun "$SKILL_DIR/scripts/review-site.ts" serve --workspace "" +``` + +Open the printed loopback URL. The page places **Business context** before findings, includes severity navigation and search, exact reviewed-commit links, required response, reviewers, confidence, structured evidence, available focused excerpts, and comments. + +LAN or public exposure is opt-in and must be deliberate: + +```bash +bun "$SKILL_DIR/scripts/review-site.ts" serve \ + --workspace "" \ + --host 0.0.0.0 \ + --expose +``` + +The warning is part of the command contract. Do not expose a review site that contains material the intended network audience may not read. + +## Comment handoff loop + +Reviewers can save general comments or comments attached to a finding. The browser sends only validated, bounded JSON to the local server. Comments are atomically written to `comments.json` in the external workspace; comment bodies are not logged. + +To respond as the assistant, first inspect only unanswered local comments: + +```bash +curl -sS "http://127.0.0.1:/api/comments?status=unanswered" +``` + +Use the returned comment `id`, formulate an evidence-based response from the review artifact and reviewed code, then save it locally: + +```bash +curl -sS -X POST "http://127.0.0.1:/api/comments//replies" \ + -H 'content-type: application/json' \ + --data '{"role":"assistant","author":"Assistant","body":"Verified response with the required next action."}' +``` + +The page renders replies with an Assistant label. Refresh unanswered comments until the queue is empty. Never treat this local operation as authority to post a GitHub comment; GitHub posting requires a separate explicit, user-confirmed feature. + +## Completion +1. Confirm the review artifact was consumed as JSON, not markdown. +2. Browser-check the local site: business context comes first; every finding shows `What actually happens` and `Expected / suggested` (or an explicit evidence gap); severity filters and search work; the responsive layout works; a local comment and assistant reply render; a GitHub remote produces a reviewed-commit line link. +3. State the workspace path and loopback URL. Do not include comment text, credentials, or source contents in the report. diff --git a/plugins/engineering/skills/pr-interactive-review/scripts/review-site.ts b/plugins/engineering/skills/pr-interactive-review/scripts/review-site.ts new file mode 100644 index 0000000..c1c4a4c --- /dev/null +++ b/plugins/engineering/skills/pr-interactive-review/scripts/review-site.ts @@ -0,0 +1,1248 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'; + +export const MAX_REQUEST_BYTES = 16 * 1024; +export const MAX_REQUIREMENTS_BYTES = 128 * 1024; +const MAX_COMMENT_LENGTH = 12 * 1024; +const MAX_EXCERPT_LINES = 120; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']); +const SEVERITIES = new Set(['P0', 'P1', 'P2', 'P3']); + +type JsonObject = Record; + +export interface ReviewFinding { + id: string; + title: string; + severity: 'P0' | 'P1' | 'P2' | 'P3'; + file: string; + line: number; + endLine: number; + confidence: number | string; + requiredResponse: string; + reviewers: string[]; + evidence: string[]; + firstEvidence: string | null; + sourceLink: string | null; + scenario: FindingScenario; + excerpts: { + before: CodeExcerpt | null; + after: CodeExcerpt | null; + }; +} + +export interface FindingScenario { + actualHappens: string | null; + expectedSuggested: string | null; + actualEvidenceGap: string | null; + expectedEvidenceGap: string | null; +} + +export interface CodeExcerpt { + startLine: number; + endLine: number; + content: string; +} + +export interface BusinessPrimer { + whoConfigures: PrimerField; + operationalProblem: PrimerField; + intendedOutcome: PrimerField; + businessImportance: PrimerField; + successCriteria: PrimerField; + scope: PrimerField; + nonGoals: PrimerField; + providedRequirements: string | null; +} + +export interface PrimerField { + value: string | null; + evidenceGap: string | null; +} + +export interface StoredReview { + version: 1; + repository: string; + githubRepository: string | null; + prNumber: number; + reviewedCommit: string; + title: string; + verdict: string; + intent: string; + primer: BusinessPrimer; + findings: ReviewFinding[]; + generatedAt: string; +} + +export interface CommentReply { + id: string; + author: string; + role: 'assistant'; + body: string; + createdAt: string; +} + +export interface ReviewComment { + id: string; + findingId: string | null; + author: string; + role: 'reviewer'; + body: string; + createdAt: string; + replies: CommentReply[]; +} + +interface CommentStore { + version: 1; + comments: ReviewComment[]; +} + +interface ReviewInput { + status: string; + verdict: string; + intent: string; + scope: { head_sha: string }; + findings: unknown[]; + title?: string; +} + +export interface PrepareOptions { + reviewJsonPath: string; + pr: string; + repoPath?: string; + dataDir?: string; + scenariosPath?: string; + specification?: string; + requirementsPath?: string; + baseCommit?: string; + now?: Date; +} + +export interface PreparedReview { + workspace: string; + review: StoredReview; +} + +function isRecord(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function boundedString( + value: unknown, + field: string, + maxLength: number, + required = true, +): string | null { + if (value === undefined || value === null) { + if (required) throw new Error(`${field} is required`); + return null; + } + if (typeof value !== 'string') throw new Error(`${field} must be a string`); + if (value.includes('\0')) + throw new Error(`${field} must not contain null characters`); + const normalized = value.trim(); + if (required && normalized.length === 0) + throw new Error(`${field} must not be empty`); + if (normalized.length > maxLength) + throw new Error(`${field} exceeds ${maxLength} characters`); + return normalized; +} + +function stringArray(value: unknown, field: string, maxEntries = 32): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > maxEntries) + throw new Error( + `${field} must be an array of at most ${maxEntries} strings`, + ); + return value.map( + (item, index) => boundedString(item, `${field}[${index}]`, 2000) as string, + ); +} + +function safeRelativePath(value: unknown, field: string): string { + const path = boundedString(value, field, 1000) as string; + if ( + path.includes('\0') || + isAbsolute(path) || + path.split(/[\\/]/).includes('..') + ) { + throw new Error(`${field} must be a relative repository path`); + } + return path.replace(/\\/g, '/'); +} + +function findingId(value: unknown): string { + if (typeof value !== 'number' && typeof value !== 'string') + throw new Error('finding.# is required'); + const number = Number(String(value).replace(/^#/, '')); + if (!Number.isInteger(number) || number < 1 || number > 1000000) + throw new Error('finding.# must be a positive integer'); + return `#${number}`; +} + +function lineNumber(value: unknown, field: string): number { + const number = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(number) || number < 1 || number > 10000000) + throw new Error(`${field} must be a valid line number`); + return number; +} + +function readReviewInput(value: unknown): ReviewInput { + if (!isRecord(value)) + throw new Error('Structured review artifact must be an object'); + const status = boundedString(value.status, 'status', 32) as string; + if (status !== 'complete') + throw new Error('Structured review artifact must have complete status'); + if (!isRecord(value.scope)) throw new Error('scope is required'); + const headSha = boundedString( + value.scope.head_sha, + 'scope.head_sha', + 64, + ) as string; + if (!/^[0-9a-f]{7,64}$/i.test(headSha)) + throw new Error('scope.head_sha must be a commit SHA'); + if (!Array.isArray(value.findings)) + throw new Error('findings must be an array'); + return { + status, + verdict: boundedString(value.verdict, 'verdict', 200) as string, + intent: boundedString(value.intent, 'intent', 8000) as string, + scope: { head_sha: headSha.toLowerCase() }, + findings: value.findings, + ...(typeof value.title === 'string' + ? { title: boundedString(value.title, 'title', 500) as string } + : {}), + }; +} + +function missingScenario(): FindingScenario { + return { + actualHappens: null, + expectedSuggested: null, + actualEvidenceGap: + 'Evidence gap: the scenario sidecar does not provide a specific triggering setup/action and observable failure.', + expectedEvidenceGap: + 'Evidence gap: the scenario sidecar does not provide a specific expected behavior and correction.', + }; +} + +function normalizeScenario(value: unknown, field: string): FindingScenario { + if (!isRecord(value)) throw new Error(`${field} must be an object`); + const actualHappens = boundedString( + value.what_actually_happens, + `${field}.what_actually_happens`, + 8000, + false, + ); + const expectedSuggested = boundedString( + value.expected_suggested, + `${field}.expected_suggested`, + 8000, + false, + ); + const missing = missingScenario(); + return { + actualHappens, + expectedSuggested, + actualEvidenceGap: actualHappens ? null : missing.actualEvidenceGap, + expectedEvidenceGap: expectedSuggested ? null : missing.expectedEvidenceGap, + }; +} + +function normalizeFinding(value: unknown): ReviewFinding { + if (!isRecord(value)) throw new Error('finding must be an object'); + const line = lineNumber(value.line, 'finding.line'); + const endLine = + value.end_line === undefined + ? line + : lineNumber(value.end_line, 'finding.end_line'); + if (endLine < line) + throw new Error('finding.end_line must not precede finding.line'); + const severity = boundedString( + value.severity, + 'finding.severity', + 2, + ) as ReviewFinding['severity']; + if (!SEVERITIES.has(severity)) + throw new Error('finding.severity must be P0, P1, P2, or P3'); + const owner = boundedString(value.owner, 'finding.owner', 120, false); + const suggestedFix = boundedString( + value.suggested_fix, + 'finding.suggested_fix', + 8000, + false, + ); + const whyItMatters = boundedString( + value.why_it_matters, + 'finding.why_it_matters', + 8000, + false, + ); + const autofixClass = boundedString( + value.autofix_class, + 'finding.autofix_class', + 120, + false, + ); + const requiredResponse = + suggestedFix ?? + whyItMatters ?? + owner ?? + autofixClass ?? + 'Review the evidence and choose the next action.'; + const confidence = value.confidence; + if ( + typeof confidence !== 'string' && + (typeof confidence !== 'number' || !Number.isFinite(confidence)) + ) { + throw new Error('finding.confidence must be a string or number'); + } + return { + id: findingId(value['#']), + title: boundedString(value.title, 'finding.title', 1000) as string, + severity, + file: safeRelativePath(value.file, 'finding.file'), + line, + endLine, + confidence, + requiredResponse, + reviewers: stringArray(value.reviewers, 'finding.reviewers'), + evidence: stringArray(value.evidence, 'finding.evidence', 64), + firstEvidence: boundedString( + value.first_evidence, + 'finding.first_evidence', + 8000, + false, + ), + sourceLink: null, + excerpts: { before: null, after: null }, + scenario: missingScenario(), + }; +} + +export function parsePrTarget(value: string): { + number: number; + urlRepository: string | null; +} { + const input = value.trim(); + if (/^\d+$/.test(input)) { + const number = Number(input); + if (Number.isSafeInteger(number) && number > 0) + return { number, urlRepository: null }; + } + let url: URL; + try { + url = new URL(input); + } catch { + throw new Error( + 'pr must be a positive number or a https://github.com/owner/repo/pull/number URL', + ); + } + const match = + url.hostname === 'github.com' + ? /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(url.pathname) + : null; + const number = Number(match?.[3]); + if (!match?.[1] || !match[2] || !Number.isSafeInteger(number) || number < 1) { + throw new Error( + 'pr must be a positive number or a https://github.com/owner/repo/pull/number URL', + ); + } + return { number, urlRepository: `${match[1]}/${match[2]}` }; +} + +export function githubRepositoryFromRemote( + remote: string | null, +): string | null { + if (!remote) return null; + const match = + /^(?:git@github\.com:|https:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec( + remote.trim(), + ); + return match?.[1] && match[2] ? `${match[1]}/${match[2]}` : null; +} + +export function buildGitHubLineLink( + repository: string | null, + commit: string, + file: string, + startLine: number, + endLine: number, +): string | null { + if (!repository || !/^[0-9a-f]{7,64}$/i.test(commit)) return null; + const normalizedPath = safeRelativePath(file, 'finding.file'); + if ( + !Number.isInteger(startLine) || + !Number.isInteger(endLine) || + startLine < 1 || + endLine < startLine + ) + return null; + const escapedPath = normalizedPath + .split('/') + .map(encodeURIComponent) + .join('/'); + return `https://github.com/${repository}/blob/${commit}/${escapedPath}#L${startLine}-L${endLine}`; +} + +function parsePrimerField(text: string, labels: string[]): string | null { + const expression = new RegExp( + `(?:^|\\n)\\s*(?:#{1,6}\\s*)?(?:${labels.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*:\\s*([^\\n]+)`, + 'i', + ); + const result = expression.exec(text); + return result?.[1]?.trim() || null; +} + +function primerValue(value: string | null, missing: string): PrimerField { + return value + ? { value, evidenceGap: null } + : { value: null, evidenceGap: missing }; +} + +export function buildBusinessPrimer( + specification: string | null, + intent: string, +): BusinessPrimer { + const text = specification?.trim() ?? ''; + const operationalGap = `The supplied requirements do not state the operational problem. Review intent: ${intent}`; + return { + whoConfigures: primerValue( + parsePrimerField(text, ['who configures', 'configured by', 'operator']), + 'The supplied requirements do not identify who configures this feature.', + ), + operationalProblem: primerValue( + parsePrimerField(text, ['operational problem', 'problem']), + operationalGap, + ), + intendedOutcome: primerValue( + parsePrimerField(text, ['intended outcome', 'before/after', 'outcome']), + 'The supplied requirements do not define the intended before-and-after outcome.', + ), + businessImportance: primerValue( + parsePrimerField(text, [ + 'why it matters', + 'business importance', + 'business value', + ]), + 'The supplied requirements do not state why this outcome matters to the business.', + ), + successCriteria: primerValue( + parsePrimerField(text, ['success criteria', 'acceptance criteria']), + 'The supplied requirements do not define measurable success criteria.', + ), + scope: primerValue( + parsePrimerField(text, ['scope', 'in scope']), + 'The supplied requirements do not define the intended scope.', + ), + nonGoals: primerValue( + parsePrimerField(text, ['non-goals', 'non goals', 'out of scope']), + 'The supplied requirements do not define non-goals.', + ), + providedRequirements: text || null, + }; +} + +function stateRoot(dataDir?: string): string { + if (dataDir) return resolve(dataDir); + const root = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'); + return join(root, 'allagents', 'pr-interactive-review'); +} + +function repositoryStorageKey( + remote: string | null, + githubRepository: string | null, +): string { + if (githubRepository) + return `github.com-${githubRepository.replace('/', '-')}`.toLowerCase(); + const digest = createHash('sha256') + .update(remote ?? 'no-origin-remote') + .digest('hex') + .slice(0, 16); + return `non-github-${digest}`; +} + +function workspaceFor( + dataDir: string, + repositoryKey: string, + prNumber: number, +): string { + if ( + !/^[a-z0-9.-]+$/i.test(repositoryKey) || + !Number.isInteger(prNumber) || + prNumber < 1 + ) + throw new Error('Invalid workspace identifier'); + return join(dataDir, repositoryKey, `pr-${prNumber}`); +} + +async function readBoundedFile( + path: string, + maximumBytes: number, +): Promise { + const handle = await open(path, 'r'); + try { + const stat = await handle.stat(); + if (stat.size > maximumBytes) + throw new Error(`${basename(path)} exceeds ${maximumBytes} bytes`); + return await handle.readFile({ encoding: 'utf8' }); + } finally { + await handle.close(); + } +} + +function requirementsFilePath(repoPath: string, value: string): string { + if (isAbsolute(value)) + throw new Error( + 'requirements must be a repository-relative public-safe reference', + ); + const resolved = resolve(repoPath, value); + const fromRepo = relative(repoPath, resolved); + if ( + fromRepo === '' || + fromRepo.startsWith(`..${sep}`) || + fromRepo === '..' || + isAbsolute(fromRepo) || + fromRepo.split(sep).includes('.git') + ) { + throw new Error( + 'requirements must stay inside the repository and outside .git', + ); + } + return resolved; +} + +function runGit(repoPath: string, args: string[]): string | null { + const result = Bun.spawnSync(['git', '-C', repoPath, ...args], { + stdout: 'pipe', + stderr: 'pipe', + }); + if (result.exitCode !== 0) return null; + return new TextDecoder().decode(result.stdout).trim(); +} + +function codeExcerpt( + repoPath: string, + commit: string, + file: string, + line: number, +): CodeExcerpt | null { + const source = runGit(repoPath, ['show', `${commit}:${file}`]); + if (source === null) return null; + const lines = source.split('\n'); + const startLine = Math.max(1, line - 3); + const endLine = Math.min(lines.length, startLine + MAX_EXCERPT_LINES - 1); + return { + startLine, + endLine, + content: lines.slice(startLine - 1, endLine).join('\n'), + }; +} + +function validBaseCommit(value: string | undefined): string | null { + return value && /^[0-9a-f]{7,64}$/i.test(value) ? value.toLowerCase() : null; +} + +async function writeJsonAtomically( + path: string, + value: unknown, +): Promise { + await mkdir(resolve(path, '..'), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporary, path); +} + +async function readScenarioSidecar( + path: string, + findingIds: Set, +): Promise> { + const value = JSON.parse( + await readBoundedFile(resolve(path), MAX_REQUIREMENTS_BYTES), + ) as unknown; + if (!isRecord(value) || Array.isArray(value)) + throw new Error( + 'Scenario sidecar must be an object keyed by stable finding ID', + ); + const entries = Object.entries(value); + if (entries.length > 1000) + throw new Error('Scenario sidecar exceeds 1000 findings'); + const scenarios = new Map(); + for (const [id, scenario] of entries) { + if (!/^#[1-9]\d*$/.test(id)) + throw new Error( + 'Scenario sidecar keys must be stable finding IDs such as #1', + ); + if (!findingIds.has(id)) + throw new Error(`Scenario sidecar references unknown finding ${id}`); + scenarios.set(id, normalizeScenario(scenario, `scenario ${id}`)); + } + return scenarios; +} + +export async function prepareReview( + options: PrepareOptions, +): Promise { + const target = parsePrTarget(options.pr); + const repoPath = resolve(options.repoPath ?? process.cwd()); + const remote = runGit(repoPath, ['config', '--get', 'remote.origin.url']); + const githubRepository = githubRepositoryFromRemote(remote); + if ( + target.urlRepository && + githubRepository && + target.urlRepository.toLowerCase() !== githubRepository.toLowerCase() + ) { + throw new Error( + 'PR URL repository does not match the runtime origin remote', + ); + } + const rawArtifact = JSON.parse( + await readBoundedFile( + resolve(options.reviewJsonPath), + MAX_REQUIREMENTS_BYTES, + ), + ) as unknown; + const artifact = readReviewInput(rawArtifact); + const requirementText = options.requirementsPath + ? await readBoundedFile( + requirementsFilePath(repoPath, options.requirementsPath), + MAX_REQUIREMENTS_BYTES, + ) + : null; + const specification = options.specification ?? requirementText; + if ( + specification && + Buffer.byteLength(specification, 'utf8') > MAX_REQUIREMENTS_BYTES + ) { + throw new Error(`specification exceeds ${MAX_REQUIREMENTS_BYTES} bytes`); + } + const baseCommit = validBaseCommit(options.baseCommit); + const rawFindings = artifact.findings.map(normalizeFinding); + const scenarios = options.scenariosPath + ? await readScenarioSidecar( + options.scenariosPath, + new Set(rawFindings.map((finding) => finding.id)), + ) + : new Map(); + const findings = rawFindings.map((finding) => ({ + ...finding, + scenario: scenarios.get(finding.id) ?? finding.scenario, + sourceLink: buildGitHubLineLink( + githubRepository, + artifact.scope.head_sha, + finding.file, + finding.line, + finding.endLine, + ), + excerpts: { + before: baseCommit + ? codeExcerpt(repoPath, baseCommit, finding.file, finding.line) + : null, + after: codeExcerpt( + repoPath, + artifact.scope.head_sha, + finding.file, + finding.line, + ), + }, + })); + const workspace = workspaceFor( + stateRoot(options.dataDir), + repositoryStorageKey(remote, githubRepository), + target.number, + ); + const review: StoredReview = { + version: 1, + repository: githubRepository ?? repositoryStorageKey(remote, null), + githubRepository, + prNumber: target.number, + reviewedCommit: artifact.scope.head_sha, + title: artifact.title ?? `Pull request #${target.number}`, + verdict: artifact.verdict, + intent: artifact.intent, + primer: buildBusinessPrimer(specification, artifact.intent), + findings, + generatedAt: (options.now ?? new Date()).toISOString(), + }; + await writeJsonAtomically(join(workspace, 'review.json'), review); + const commentsPath = join(workspace, 'comments.json'); + try { + await readFile(commentsPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + await writeJsonAtomically(commentsPath, { + version: 1, + comments: [], + } satisfies CommentStore); + } + return { workspace, review }; +} + +export async function loadStoredReview( + workspace: string, +): Promise { + return JSON.parse( + await readBoundedFile( + join(resolve(workspace), 'review.json'), + MAX_REQUIREMENTS_BYTES, + ), + ) as StoredReview; +} + +async function readCommentStore(workspace: string): Promise { + try { + const data = JSON.parse( + await readBoundedFile( + join(workspace, 'comments.json'), + MAX_REQUIREMENTS_BYTES, + ), + ) as unknown; + if (!isRecord(data) || data.version !== 1 || !Array.isArray(data.comments)) + throw new Error('Invalid comments store'); + return data as unknown as CommentStore; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return { version: 1, comments: [] }; + throw error; + } +} + +async function withLock( + path: string, + operation: () => Promise, +): Promise { + const lock = `${path}.lock`; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + await mkdir(lock, { mode: 0o700 }); + try { + return await operation(); + } finally { + await rm(lock, { recursive: true, force: true }); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + await Bun.sleep(10); + } + } + throw new Error('Comment store is busy; retry the request'); +} + +function commentBody(value: unknown): string { + return boundedString(value, 'body', MAX_COMMENT_LENGTH) as string; +} + +function commentAuthor(value: unknown): string { + const author = boundedString(value, 'author', 120, false); + return author ? author.replace(/[\r\n]/g, ' ') : 'Reviewer'; +} + +export async function readBoundedRequestBody( + request: Request, +): Promise { + if (!request.body) return ''; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_REQUEST_BYTES) { + try { + await reader.cancel(); + } catch { + // The request is already being terminated; preserve the size error. + } + throw new Error(`Request body exceeds ${MAX_REQUEST_BYTES} bytes`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); +} + +function parseJsonRequest(text: string): JsonObject { + if (Buffer.byteLength(text, 'utf8') > MAX_REQUEST_BYTES) + throw new Error(`Request body exceeds ${MAX_REQUEST_BYTES} bytes`); + try { + const value = JSON.parse(text) as unknown; + if (!isRecord(value)) throw new Error('Request body must be an object'); + return value; + } catch (error) { + if ( + error instanceof Error && + error.message === 'Request body must be an object' + ) + throw error; + throw new Error('Request body must be valid JSON'); + } +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }); +} + +function textResponse(value: string, status: number): Response { + return new Response(value, { + status, + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'no-store', + }, + }); +} + +function htmlEscape(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + character + ] as string, + ); +} + +export function renderReviewPage(review: StoredReview): string { + return ` + + + + + +${htmlEscape(review.title)} - Interactive review + + + +
Interactive PR review

Loading structured review...

+
+

Business context

Context precedes architecture and findings. Missing evidence is explicit.

+

Findings

+
+

General comments

+
+ + +`; +} + +function validateWriteRequest(request: Request, url: URL): Response | null { + const contentType = request.headers.get('content-type'); + if ( + contentType?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json' + ) { + return textResponse('Content-Type must be application/json', 415); + } + const origin = request.headers.get('origin'); + if (origin && origin !== url.origin) { + return textResponse('Cross-origin comment writes are not allowed', 403); + } + return null; +} + +export function createReviewServer( + workspace: string, + host = '127.0.0.1', + port = 0, +) { + const resolvedWorkspace = resolve(workspace); + let reviewPromise: Promise | null = null; + const review = async (): Promise => { + reviewPromise ??= loadStoredReview(resolvedWorkspace); + return reviewPromise; + }; + return Bun.serve({ + hostname: host, + port, + async fetch(request) { + const url = new URL(request.url); + try { + if (request.method === 'GET' && url.pathname === '/') + return new Response(renderReviewPage(await review()), { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'content-security-policy': + "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'self'", + }, + }); + if (request.method === 'GET' && url.pathname === '/api/review') + return json(await review()); + if (request.method === 'GET' && url.pathname === '/api/comments') { + const comments = (await readCommentStore(resolvedWorkspace)).comments; + const unanswered = url.searchParams.get('status') === 'unanswered'; + return json({ + comments: unanswered + ? comments.filter( + (comment) => + !comment.replies.some( + (reply) => reply.role === 'assistant', + ), + ) + : comments, + }); + } + if (request.method === 'POST' && url.pathname === '/api/comments') { + const writeError = validateWriteRequest(request, url); + if (writeError) return writeError; + const contentLength = Number( + request.headers.get('content-length') ?? '0', + ); + if (contentLength > MAX_REQUEST_BYTES) + return textResponse('Request body is too large', 413); + const body = parseJsonRequest(await readBoundedRequestBody(request)); + const finding = + body.findingId === null || body.findingId === undefined + ? null + : boundedString(body.findingId, 'findingId', 16); + const storedReview = await review(); + if ( + finding !== null && + !storedReview.findings.some((item) => item.id === finding) + ) + return textResponse('Unknown findingId', 400); + const comment = await withLock( + join(resolvedWorkspace, 'comments.json'), + async () => { + const store = await readCommentStore(resolvedWorkspace); + const next: ReviewComment = { + id: randomUUID(), + findingId: finding, + author: commentAuthor(body.author), + role: 'reviewer', + body: commentBody(body.body), + createdAt: new Date().toISOString(), + replies: [], + }; + store.comments.push(next); + await writeJsonAtomically( + join(resolvedWorkspace, 'comments.json'), + store, + ); + return next; + }, + ); + return json({ comment }, 201); + } + const replyMatch = /^\/api\/comments\/([0-9a-f-]{36})\/replies$/.exec( + url.pathname, + ); + if (request.method === 'POST' && replyMatch?.[1]) { + const writeError = validateWriteRequest(request, url); + if (writeError) return writeError; + const contentLength = Number( + request.headers.get('content-length') ?? '0', + ); + if (contentLength > MAX_REQUEST_BYTES) + return textResponse('Request body is too large', 413); + const body = parseJsonRequest(await readBoundedRequestBody(request)); + if (body.role !== 'assistant') + return textResponse('Replies must declare assistant role', 400); + const comment = await withLock( + join(resolvedWorkspace, 'comments.json'), + async () => { + const store = await readCommentStore(resolvedWorkspace); + const parent = store.comments.find( + (item) => item.id === replyMatch[1], + ); + if (!parent) throw new Error('Comment not found'); + const reply: CommentReply = { + id: randomUUID(), + author: commentAuthor(body.author), + role: 'assistant', + body: commentBody(body.body), + createdAt: new Date().toISOString(), + }; + parent.replies.push(reply); + await writeJsonAtomically( + join(resolvedWorkspace, 'comments.json'), + store, + ); + return parent; + }, + ); + return json({ comment }, 201); + } + return textResponse('Not found', 404); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Request failed'; + const status = + message === 'Comment not found' + ? 404 + : message.includes('busy') + ? 503 + : 400; + return textResponse(message, status); + } + }, + }); +} + +function readArguments(argumentsList: string[]): Map { + const values = new Map(); + for (let index = 0; index < argumentsList.length; index += 1) { + const item = argumentsList[index]; + if (!item?.startsWith('--')) + throw new Error(`Unexpected argument: ${item}`); + const key = item.slice(2); + const value = argumentsList[index + 1]; + if (value && !value.startsWith('--')) { + values.set(key, value); + index += 1; + } else { + values.set(key, true); + } + } + return values; +} + +function option( + values: Map, + key: string, + required = false, +): string | undefined { + const value = values.get(key); + if (value === true) throw new Error(`--${key} requires a value`); + if (required && value === undefined) throw new Error(`--${key} is required`); + return value; +} + +function rejectUnknownOptions( + values: Map, + allowed: string[], +): void { + for (const key of values.keys()) + if (!allowed.includes(key)) throw new Error(`Unknown option: --${key}`); +} + +export async function main( + argumentsList = process.argv.slice(2), +): Promise { + const [command, ...rest] = argumentsList; + const values = readArguments(rest); + if (command === 'prepare') { + rejectUnknownOptions(values, [ + 'review-json', + 'pr', + 'repo', + 'data-dir', + 'scenarios', + 'spec', + 'requirements', + 'base-commit', + ]); + const prepared = await prepareReview({ + reviewJsonPath: option(values, 'review-json', true) as string, + pr: option(values, 'pr', true) as string, + repoPath: option(values, 'repo'), + dataDir: option(values, 'data-dir'), + scenariosPath: option(values, 'scenarios'), + specification: option(values, 'spec'), + requirementsPath: option(values, 'requirements'), + baseCommit: option(values, 'base-commit'), + }); + process.stdout.write( + `workspace: ${prepared.workspace}\nreviewed commit: ${prepared.review.reviewedCommit}\n`, + ); + return; + } + if (command === 'serve') { + rejectUnknownOptions(values, ['workspace', 'host', 'port', 'expose']); + const workspace = option(values, 'workspace', true) as string; + const host = option(values, 'host') ?? '127.0.0.1'; + const exposed = values.get('expose') === true; + if (!LOOPBACK_HOSTS.has(host) && !exposed) + throw new Error( + 'Refusing non-loopback binding without explicit --expose. Local review comments and findings may be visible on the network.', + ); + if (!LOOPBACK_HOSTS.has(host)) + process.stderr.write( + 'WARNING: review site is exposed beyond loopback; anyone who can reach this host can read review data and submit local comments.\n', + ); + const portText = option(values, 'port'); + const port = portText === undefined ? 0 : Number(portText); + if (!Number.isInteger(port) || port < 0 || port > 65535) + throw new Error('--port must be an integer from 0 through 65535'); + const server = createReviewServer(workspace, host, port); + process.stdout.write( + `Interactive review: http://${host}:${server.port}\nworkspace: ${resolve(workspace)}\n`, + ); + return; + } + throw new Error( + 'Usage: review-site.ts prepare --review-json --scenarios --pr [--spec | --requirements ] [--base-commit ]\n review-site.ts serve --workspace [--host 127.0.0.1] [--port 0] [--expose]', + ); +} + +if (import.meta.main) { + main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.message : 'Interactive review failed'}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/plugins/engineering/skills/pr-interactive-review/scripts/tsconfig.json b/plugins/engineering/skills/pr-interactive-review/scripts/tsconfig.json new file mode 100644 index 0000000..1f00e5c --- /dev/null +++ b/plugins/engineering/skills/pr-interactive-review/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": ["bun-types", "node"] + }, + "include": ["review-site.ts"] +} diff --git a/tests/unit/plugins/pr-interactive-review.test.ts b/tests/unit/plugins/pr-interactive-review.test.ts new file mode 100644 index 0000000..4a08f0a --- /dev/null +++ b/tests/unit/plugins/pr-interactive-review.test.ts @@ -0,0 +1,428 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + MAX_REQUEST_BYTES, + type StoredReview, + buildBusinessPrimer, + buildGitHubLineLink, + createReviewServer, + prepareReview, + readBoundedRequestBody, + renderReviewPage, +} from '../../../plugins/engineering/skills/pr-interactive-review/scripts/review-site.ts'; + +const temporaryPaths: string[] = []; + +async function temporaryDirectory(): Promise { + const path = await mkdtemp(join(tmpdir(), 'allagents-pr-review-')); + temporaryPaths.push(path); + return path; +} + +async function fixture(withScenario = true) { + const root = await temporaryDirectory(); + const repository = join(root, 'repository'); + const state = join(root, 'state'); + await mkdir(repository); + const init = Bun.spawnSync(['git', '-C', repository, 'init', '--quiet']); + expect(init.exitCode).toBe(0); + const remote = Bun.spawnSync([ + 'git', + '-C', + repository, + 'remote', + 'add', + 'origin', + 'https://github.com/example-org/sample-service.git', + ]); + expect(remote.exitCode).toBe(0); + await writeFile( + join(repository, 'requirements.md'), + 'Who configures: Operations managers\nOperational problem: Manual approvals delay routine changes\nIntended outcome: Operators complete changes in one workflow\nWhy it matters: Delays postpone service changes\nSuccess criteria: A change completes and records its result\nScope: Change configuration\nNon-goals: Redesigning access roles\n', + ); + const artifact = join(root, 'review.json'); + await writeFile( + artifact, + JSON.stringify({ + status: 'complete', + verdict: 'Ready with fixes', + title: 'Example change', + intent: 'Review a configuration workflow.', + scope: { head_sha: '0123456789abcdef0123456789abcdef01234567' }, + findings: [ + { + '#': 1, + title: 'Escape untrusted label', + severity: 'P1', + file: 'src/config.ts', + line: 18, + end_line: 20, + confidence: 90, + owner: 'downstream-resolver', + suggested_fix: 'Escape the label before rendering it.', + first_evidence: 'The value enters the template without encoding.', + evidence: ['The changed template uses the raw value.'], + reviewers: ['security', 'correctness'], + }, + ], + }), + ); + const scenarios = join(root, 'interactive-scenarios.json'); + if (withScenario) { + await writeFile( + scenarios, + JSON.stringify({ + '#1': { + what_actually_happens: + 'When an operator submits a label containing markup, the page renders the markup and the browser executes it.', + expected_suggested: + 'When an operator submits that label, the page displays the characters as text after encoding the value at the rendering boundary.', + }, + }), + ); + } + const prepared = await prepareReview({ + reviewJsonPath: artifact, + pr: '123', + repoPath: repository, + dataDir: state, + requirementsPath: 'requirements.md', + ...(withScenario ? { scenariosPath: scenarios } : {}), + now: new Date('2026-01-02T03:04:05.000Z'), + }); + return { ...prepared, artifact, repository, scenarios, state }; +} + +beforeEach(() => { + temporaryPaths.length = 0; +}); + +afterEach(async () => { + await Promise.all( + temporaryPaths.map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe('pr-interactive-review', () => { + it('renders an evidence-based business primer and labels missing evidence', () => { + const primer = buildBusinessPrimer( + 'Who configures: Operations managers\nSuccess criteria: A change completes', + 'Review the changed flow.', + ); + expect(primer.whoConfigures.value).toBe('Operations managers'); + expect(primer.successCriteria.value).toBe('A change completes'); + expect(primer.nonGoals.value).toBeNull(); + expect(primer.nonGoals.evidenceGap).toContain('non-goals'); + expect(primer.operationalProblem.evidenceGap).toContain( + 'Review intent: Review the changed flow.', + ); + }); + + it('pins GitHub links to the reviewed commit and exact line range', () => { + expect( + buildGitHubLineLink( + 'example-org/sample-service', + '0123456789abcdef0123456789abcdef01234567', + 'src/a file.ts', + 18, + 20, + ), + ).toBe( + 'https://github.com/example-org/sample-service/blob/0123456789abcdef0123456789abcdef01234567/src/a%20file.ts#L18-L20', + ); + expect( + buildGitHubLineLink( + null, + '0123456789abcdef0123456789abcdef01234567', + 'src/a.ts', + 1, + 1, + ), + ).toBeNull(); + expect(() => + buildGitHubLineLink( + 'example-org/sample-service', + '0123456789abcdef0123456789abcdef01234567', + '../secrets', + 1, + 1, + ), + ).toThrow('relative repository path'); + }); + + it('cancels an oversized chunked request body before accepting it', async () => { + let cancelled = false; + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(MAX_REQUEST_BYTES + 1)); + }, + cancel() { + cancelled = true; + }, + }); + const request = new Request('http://127.0.0.1/api/comments', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: stream, + }); + expect(request.headers.get('content-length')).toBeNull(); + await expect(readBoundedRequestBody(request)).rejects.toThrow( + `Request body exceeds ${MAX_REQUEST_BYTES} bytes`, + ); + expect(cancelled).toBe(true); + }); + + it('labels missing concrete scenario evidence instead of inventing a claim', async () => { + const prepared = await fixture(false); + const scenario = prepared.review.findings[0]?.scenario; + expect(scenario?.actualHappens).toBeNull(); + expect(scenario?.expectedSuggested).toBeNull(); + expect(scenario?.actualEvidenceGap).toContain('triggering setup/action'); + expect(scenario?.expectedEvidenceGap).toContain( + 'expected behavior and correction', + ); + }); + + it('merges scenario sidecars by stable finding ID and rejects invalid entries', async () => { + const prepared = await fixture(); + await writeFile( + prepared.scenarios, + JSON.stringify({ + '#99': { + what_actually_happens: 'A triggering action fails.', + expected_suggested: 'The action succeeds.', + }, + }), + ); + await expect( + prepareReview({ + reviewJsonPath: prepared.artifact, + pr: '123', + repoPath: prepared.repository, + dataDir: prepared.state, + scenariosPath: prepared.scenarios, + }), + ).rejects.toThrow('unknown finding #99'); + await writeFile( + prepared.scenarios, + JSON.stringify({ '#1': { what_actually_happens: 7 } }), + ); + await expect( + prepareReview({ + reviewJsonPath: prepared.artifact, + pr: '123', + repoPath: prepared.repository, + dataDir: prepared.state, + scenariosPath: prepared.scenarios, + }), + ).rejects.toThrow('what_actually_happens must be a string'); + }); + it('partitions review workspaces outside the repository and creates atomically readable stores', async () => { + const prepared = await fixture(); + expect(prepared.workspace).toContain( + 'state/github.com-example-org-sample-service/pr-123', + ); + expect(prepared.review.primer.scope.value).toBe('Change configuration'); + expect(prepared.review.findings[0]?.sourceLink).toContain( + '/blob/0123456789abcdef0123456789abcdef01234567/src/config.ts#L18-L20', + ); + expect(prepared.review.findings[0]?.scenario).toEqual({ + actualHappens: + 'When an operator submits a label containing markup, the page renders the markup and the browser executes it.', + expectedSuggested: + 'When an operator submits that label, the page displays the characters as text after encoding the value at the rendering boundary.', + actualEvidenceGap: null, + expectedEvidenceGap: null, + }); + expect( + JSON.parse( + await readFile(join(prepared.workspace, 'comments.json'), 'utf8'), + ), + ).toEqual({ version: 1, comments: [] }); + expect( + (await readdir(prepared.workspace)).some((name) => name.endsWith('.tmp')), + ).toBe(false); + }); + + it('escapes untrusted page values and keeps business context before findings', async () => { + const prepared = await fixture(); + const malicious: StoredReview = { + ...prepared.review, + title: '', + }; + const page = renderReviewPage(malicious); + expect(page).not.toContain(''); + expect(page.indexOf('id="business-context"')).toBeLessThan( + page.indexOf('id="findings"'), + ); + expect(page).not.toContain('Assistant reply'); + }); + + it('contains long reviewed lines within finding cards and scrollable code blocks', async () => { + const prepared = await fixture(); + const longSourceLine = 'x'.repeat(4096); + const review: StoredReview = { + ...prepared.review, + findings: prepared.review.findings.map((finding) => ({ + ...finding, + excerpts: { + before: null, + after: { startLine: 1, endLine: 1, content: longSourceLine }, + }, + })), + }; + const page = renderReviewPage(review); + expect(review.findings[0]?.excerpts.after?.content).toHaveLength(4096); + expect(page).toContain('#findings { display: grid; min-width: 0;'); + expect(page).toContain('.finding { min-width: 0;'); + expect(page).toContain( + '.finding-top, .finding-top > *, .excerpt-grid, .excerpt-grid > * { min-width: 0; }', + ); + expect(page).toContain( + 'pre { max-width: 100%; min-width: 0; overflow-x: auto; white-space: pre;', + ); + }); + + it('validates routes, atomically saves comments, and renders assistant replies', async () => { + const prepared = await fixture(); + const server = createReviewServer(prepared.workspace, '127.0.0.1', 0); + try { + const base = `http://127.0.0.1:${server.port}`; + const page = await fetch(`${base}/`); + expect(page.headers.get('content-security-policy')).toContain( + "default-src 'self'", + ); + expect(page.status).toBe(200); + expect((await page.text()).indexOf('Business context')).toBeGreaterThan( + -1, + ); + expect( + ( + await fetch(`${base}/api/comments`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: '{"body":"not JSON content"}', + }) + ).status, + ).toBe(415); + expect( + ( + await fetch(`${base}/api/comments`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'https://untrusted.example', + }, + body: JSON.stringify({ body: 'cross-origin write' }), + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(`${base}/api/comments`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ findingId: '#99', body: 'bad' }), + }) + ).status, + ).toBe(400); + const create = await fetch(`${base}/api/comments`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + findingId: '#1', + author: 'Reviewer', + body: '', + }), + }); + expect(create.status).toBe(201); + const created = (await create.json()) as { comment: { id: string } }; + const parallelWrites = await Promise.all( + Array.from({ length: 8 }, (_, index) => + fetch(`${base}/api/comments`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ body: `comment ${index}` }), + }), + ), + ); + expect( + ( + await fetch(`${base}/api/comments/${created.comment.id}/replies`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'https://untrusted.example', + }, + body: JSON.stringify({ + role: 'assistant', + body: 'cross-origin reply', + }), + }) + ).status, + ).toBe(403); + expect(parallelWrites.every((response) => response.status === 201)).toBe( + true, + ); + expect( + (await fetch(`${base}/api/comments?status=unanswered`)).status, + ).toBe(200); + const reply = await fetch( + `${base}/api/comments/${created.comment.id}/replies`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + role: 'assistant', + author: 'Assistant', + body: 'Use the documented escape helper.', + }), + }, + ); + expect(reply.status).toBe(201); + const all = (await (await fetch(`${base}/api/comments`)).json()) as { + comments: Array<{ + body: string; + replies: Array<{ role: string; body: string }>; + }>; + }; + expect(all.comments).toHaveLength(9); + expect(all.comments[0]).toEqual( + expect.objectContaining({ + body: '', + replies: [ + expect.objectContaining({ + role: 'assistant', + body: 'Use the documented escape helper.', + }), + ], + }), + ); + const unanswered = (await ( + await fetch(`${base}/api/comments?status=unanswered`) + ).json()) as { comments: unknown[] }; + expect(unanswered.comments).toHaveLength(8); + expect( + JSON.parse( + await readFile(join(prepared.workspace, 'comments.json'), 'utf8'), + ).comments, + ).toHaveLength(9); + expect( + (await readdir(prepared.workspace)).some((name) => + name.endsWith('.tmp'), + ), + ).toBe(false); + } finally { + server.stop(true); + } + }); +});