diff --git a/README.md b/README.md index 1a6a944..db8b456 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # Assert Local -Assert Local allows you to use the full functionality of [Assert](assert.dev)'s -world-class review experience using your local credentials. It does not require -installing the Assert GitHub app. +Review pull requests in [Assert](https://assert.dev), explore logical groups of +changes, and share focused review links. No Assert GitHub app installation is +required. [Sign in or create an Assert account](https://app.assert.dev) ## Requirements -- Node.js 20.17+, 22.13+, or 23.5+ (matching the prompt library's requirements) -- [GitHub CLI](https://cli.github.com/) -- An Assert account linked to the same GitHub user as `gh` +- Node.js 20.17+, 22.13+, or 23.5+ +- [GitHub CLI](https://cli.github.com/), signed in to your GitHub account +- An Assert account linked to that same GitHub account ## Usage @@ -27,40 +27,71 @@ npx assert-local review https://github.com/owner/repo/pull/123 Repositories open their inbox; pull request URLs open that review directly. -The command confirms the active GitHub and Assert identities, resolves the -current repository or lets you search for another one, and opens a loopback -web server. The server proxies Assert's production web assets and API without -exposing the GitHub or Assert token to browser JavaScript. +The command confirms your account, lets you choose a repository, and opens the +review in your browser. It remembers your account confirmation and most recent +repository, and keeps the repository inbox up to date while running. -While the server is running, it refreshes the repository-scoped inbox every -two minutes and asks Assert to prepare necessary review assets. +Use `--no-open` to print the review URL without opening a browser. +Press `o` to reopen the page or Ctrl+C to stop. -Use `--no-open` to print the local URL without opening a browser. -While the server is running, press `o` in its terminal to reopen the page. +## Clusters for agents -Assert Local remembers confirmed GitHub/Assert identity matches and the most -recent repository in `~/.assert-local/preferences.json`. It never stores GitHub -or Assert credentials there. Repository search filters repositories already -available to your GitHub account locally; exact lookups and broader public -searches each make a single explicit GitHub request. - -## Development +Discover logical review areas, inspect their changes, and get links to share +with reviewers. These commands print their results without prompting or opening +a browser. ```sh -pnpm install -pnpm typecheck -pnpm lint -pnpm test -pnpm build +npx assert-local clusters +npx assert-local clusters --repo example/project --pr 123 +npx assert-local clusters https://github.com/example/project/pull/123 +npx assert-local cluster error-handling --diff +npx assert-local cluster error-handling --repo example/project --pr 123 --json ``` -`src/commands/review.ts` owns the interactive setup and terminal lifecycle. -`src/assert-api.ts` owns session exchange, refresh, and authenticated API requests; -the proxy and background polling share this client. `src/local-server.ts` serves -the browser and injects runtime configuration, while `src/poll.ts` runs cancellable, -non-overlapping background work. UI assets remain hosted by Assert. +`clusters` shows the first level of the review tree, with titles, descriptions, +explicit IDs, file counts, and review links. Use `--depth 2` to expand another +level or `--depth all` to show the complete tree. File paths are omitted from the +list to keep large reviews readable. + +`cluster ` shows a review summary with line-change totals, the five largest +changed files, its parent, and immediate subclusters. Use `--files` to list every +changed file. If line-change stats are unavailable, the summary still includes +the cluster description, file count, and review link. + +`cluster --diff` prints only the diff on stdout; errors and wait-status +messages go to stderr. With `--json`, the diff remains a field in the JSON result. + +Without a target, commands use the current repository and current branch's PR. +A different repository requires a PR number. Cluster IDs are exact +IDs from the latest `clusters` result; they may change after new commits or +regeneration. Each result includes base/head SHAs, the PR review version, and a +version-pinned Assert link. Add `--diff` to show only the selected cluster's +changes, including its subclusters, for that same review version. +Diffs are review excerpts with original line coordinates, not necessarily +standalone patches to apply. Binary and rename metadata are preserved. Very large +comparisons may be unavailable; the command reports an error in that case. + +Commands wait up to 60 seconds for usable results. Partial results can be used +while smaller subclusters are still generating. + +```sh +npx assert-local clusters --timeout 2m --wait-until ready +npx assert-local clusters --no-wait --json +``` -Sessions refresh shortly before expiry or once after a 401. Refresh must preserve -the GitHub identity, Assert identity, and workspace selected at startup. Tokens -stay in process memory and are never forwarded to the web-asset origin or exposed -in the browser's runtime configuration. +`--timeout` sets how long to wait for clusters and accepts seconds (the default +unit), `ms`, or `m`. Signing in, finding the PR, and loading its diff may take +additional time. `--no-wait` returns the current status without waiting for +generation to complete. + +Exit codes: **0** for a successful result (including a pending `--no-wait` +response), **1** for an error, **2** when the wait timeout expires. A timeout +returns the last known status and available results, a retry command, and the +review URL when known. Cluster generation continues after the CLI stops waiting. +If the first request times out, status is explicitly `unknown`. + +`--json` emits one JSON value on stdout, including structured errors. List output +contains the full cluster tree; detail output contains the selected `cluster`, +`clusterUrl`, line-change `stats` when available, and optional `diff`. Text +limits such as `--depth` and the five-file preview do not truncate JSON output. +No credentials are printed or saved. diff --git a/package.json b/package.json index 7a5cea0..48737e7 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "commander": "^14.0.0", "execa": "^9.6.0", "fastify": "^5.6.0", - "open": "^10.2.0" + "open": "^10.2.0", + "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc39ca9..6b8dc11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: open: specifier: ^10.2.0 version: 10.2.0 + zod: + specifier: ^4.6.5 + version: 4.6.5 devDependencies: '@types/node': specifier: ^24.0.0 @@ -1399,6 +1402,9 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + snapshots: '@esbuild/aix-ppc64@0.27.7': @@ -2526,3 +2532,5 @@ snapshots: is-wsl: 3.1.1 yoctocolors@2.2.0: {} + + zod@4.6.5: {} diff --git a/src/cluster-diff.ts b/src/cluster-diff.ts new file mode 100644 index 0000000..82396f4 --- /dev/null +++ b/src/cluster-diff.ts @@ -0,0 +1,189 @@ +import type { ClusterLeaf } from './clusters.js' + +// Git quotes unusual paths using C escapes, including octal UTF-8 bytes. +function gitPath(value: string) { + if (!value.startsWith('"')) return value + const bytes: number[] = [] + for (let i = 1; i < value.length - 1; i++) { + if (value.charAt(i) !== '\\') { + bytes.push(...Buffer.from(value.charAt(i))) + continue + } + const octal = /^[0-7]{1,3}/.exec(value.slice(i + 1))?.[0] + if (octal != null) { + bytes.push(Number.parseInt(octal, 8)) + i += octal.length + } else { + i++ + bytes.push( + ...Buffer.from( + ( + { + t: '\t', + n: '\n', + r: '\r', + b: '\b', + f: '\f', + v: '\v', + a: '\x07', + } as Record + )[value.charAt(i)] ?? value.charAt(i), + ), + ) + } + } + return Buffer.from(bytes).toString('utf8') +} + +function fileName(lines: string[]) { + const newPath = lines.find((line) => line.startsWith('+++ ')) + const oldPath = lines.find((line) => line.startsWith('--- ')) + const patchPath = newPath === '+++ /dev/null' ? oldPath : newPath + if (patchPath != null) + return gitPath(patchPath.slice(4).replace(/\t.*$/, '')).slice(2) + const rename = lines.find((line) => line.startsWith('rename to ')) + if (rename != null) return gitPath(rename.slice(10)) + // Binary/mode-only patches need their diff header, which has no ---/+++ lines. + const header = + /^diff --git ("(?:[^"\\]|\\.)*"|a\/.*?) ("(?:[^"\\]|\\.)*"|b\/.*)$/.exec( + lines[0] ?? '', + ) + return header == null ? undefined : gitPath(header[2]!).slice(2) +} + +/** Produce review-only unified diff excerpts, preserving original line coordinates. */ +export function sliceClusterDiff(diff: string, leaves: ClusterLeaf[]) { + const sections = diff + .split(/(?=^diff --git )/m) + .filter((section) => section.startsWith('diff --git ')) + const output: string[] = [] + const found = new Set() + for (const section of sections) { + const lines = section.replace(/\n$/, '').split('\n') + const firstHunk = lines.findIndex((line) => line.startsWith('@@ ')) + const header = firstHunk === -1 ? lines : lines.slice(0, firstHunk) + const name = fileName(header) + const ranges = leaves.filter((leaf) => leaf.fileName === name) + if (ranges.length === 0) continue + ranges.forEach((range) => found.add(range.fileName)) + if (ranges.some((range) => 'fullFile' in range)) { + output.push(section.replace(/\n$/, '')) + continue + } + const excerpts: string[] = [] + let oldLine = 0 + let newLine = 0 + let block: string[] = [] + let oldStart = 0 + let newStart = 0 + let oldCount = 0 + let newCount = 0 + const flush = () => { + if (block.some((line) => line.startsWith('+') || line.startsWith('-'))) { + excerpts.push( + `@@ -${oldCount === 0 ? oldStart - 1 : oldStart},${oldCount} +${newCount === 0 ? newStart - 1 : newStart},${newCount} @@\n${block.join('\n')}`, + ) + } + block = [] + oldCount = 0 + newCount = 0 + } + for (const line of lines.slice(firstHunk < 0 ? lines.length : firstHunk)) { + const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line) + if (hunk != null) { + flush() + oldLine = Number(hunk[1]) + (hunk[2] === '0' ? 1 : 0) + newLine = Number(hunk[3]) + (hunk[4] === '0' ? 1 : 0) + continue + } + if (line.startsWith('\\')) { + if (block.length > 0) block.push(line) + continue + } + const prefix = line[0] + if (prefix !== '+' && prefix !== '-' && prefix !== ' ') continue + const selected = ranges.some((range) => { + if ('fullFile' in range) return true + const oldMatch = + oldLine >= range.oldStart && oldLine < range.oldStart + range.oldLines + const newMatch = + newLine >= range.newStart && newLine < range.newStart + range.newLines + return prefix === '-' + ? oldMatch + : prefix === '+' + ? newMatch + : oldMatch && newMatch + }) + if (selected) { + if (block.length === 0) { + oldStart = oldLine + newStart = newLine + } + block.push(line) + if (prefix !== '+') oldCount++ + if (prefix !== '-') newCount++ + } else flush() + if (prefix !== '+') oldLine++ + if (prefix !== '-') newLine++ + } + flush() + if (excerpts.length > 0) output.push([...header, ...excerpts].join('\n')) + } + const missing = [...new Set(leaves.map((leaf) => leaf.fileName))].filter( + (name) => !found.has(name), + ) + if (missing.length > 0) + throw new Error( + `The GitHub diff is incomplete for this cluster. Missing files: ${missing.join(', ')}`, + ) + return output.join('\n') + (output.length > 0 ? '\n' : '') +} + +export interface ClusterDiffStats { + additions: number + deletions: number + files: { + fileName: string + additions: number + deletions: number + binary: boolean + renamed: boolean + }[] +} + +/** Count only actual changed lines in the already-sliced cluster diff. */ +export function clusterDiffStats(diff: string): ClusterDiffStats { + const files: ClusterDiffStats['files'] = [] + for (const section of diff.split(/(?=^diff --git )/m)) { + if (!section.startsWith('diff --git ')) continue + const lines = section.split('\n') + const firstHunk = lines.findIndex((line) => line.startsWith('@@ ')) + const header = firstHunk < 0 ? lines : lines.slice(0, firstHunk) + const name = fileName(header) + if (name == null) + throw new Error('Could not identify a file in the cluster diff.') + let additions = 0 + let deletions = 0 + if (firstHunk >= 0) { + for (const line of lines.slice(firstHunk)) { + if (line.startsWith('+')) additions++ + if (line.startsWith('-')) deletions++ + } + } + files.push({ + fileName: name, + additions, + deletions, + binary: header.some( + (line) => + line.startsWith('Binary files ') || line === 'GIT binary patch', + ), + renamed: header.some((line) => line.startsWith('rename to ')), + }) + } + return { + files, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + } +} diff --git a/src/cluster-format.ts b/src/cluster-format.ts new file mode 100644 index 0000000..8b8f2ac --- /dev/null +++ b/src/cluster-format.ts @@ -0,0 +1,130 @@ +import { clusterLeaves, clusterUrl, type ClusterGroup } from './clusters.js' +import type { ClusterDiffStats } from './cluster-diff.js' + +function groups(tree: ClusterGroup) { + return tree.children.filter((child) => child.kind === 'group') +} + +function fileCount(tree: ClusterGroup) { + return new Set(clusterLeaves(tree).map((leaf) => leaf.fileName)).size +} + +function count(value: number, noun: string) { + return `${value} ${noun}${value === 1 ? '' : 's'}` +} + +function scope(tree: ClusterGroup) { + const children = groups(tree) + return `${count(fileCount(tree), 'file')}${children.length === 0 ? '' : ` · ${count(children.length, 'subcluster')}`}` +} + +export function formatClusterList( + tree: ClusterGroup, + reviewUrl: string, + maxDepth: number, + depth = 0, +): string { + const prefix = ' '.repeat(depth) + return [ + `${prefix}${tree.title}${tree.pendingSubclustering ? ' (subdividing)' : ''}`, + `${prefix} ID: ${tree.id}`, + ...tree.description + .split('\n') + .filter(Boolean) + .map((line) => `${prefix} ${line}`), + `${prefix} Scope: ${scope(tree)}`, + `${prefix} Review: ${clusterUrl(reviewUrl, tree.id)}`, + ...(depth >= maxDepth + ? [] + : groups(tree).map( + (child) => + `\n${formatClusterList(child, reviewUrl, maxDepth, depth + 1)}`, + )), + ].join('\n') +} + +export function findParentCluster( + tree: ClusterGroup, + id: string, +): ClusterGroup | undefined { + for (const child of groups(tree)) { + if (child.id === id) return tree + const parent = findParentCluster(child, id) + if (parent != null) return parent + } + return undefined +} + +export function formatClusterDetail( + tree: ClusterGroup, + reviewUrl: string, + options: { + stats?: ClusterDiffStats + statsError?: string + parent?: ClusterGroup + allFiles?: boolean + }, +) { + const files = + options.stats?.files ?? + [...new Set(clusterLeaves(tree).map((leaf) => leaf.fileName))].map( + (fileName) => ({ + fileName, + additions: undefined, + deletions: undefined, + binary: false, + renamed: false, + }), + ) + const sorted = [...files].sort((a, b) => { + const weight = (file: typeof a) => + (file.additions ?? 0) + (file.deletions ?? 0) + return weight(b) - weight(a) || a.fileName.localeCompare(b.fileName) + }) + const limit = options.allFiles ? sorted.length : 5 + const children = groups(tree) + return [ + tree.title + (tree.pendingSubclustering ? ' (subdividing)' : ''), + `ID: ${tree.id}`, + ...(options.parent == null + ? [] + : [`Parent: ${options.parent.title}`, `Parent ID: ${options.parent.id}`]), + '', + tree.description, + '', + `Changes: ${count(files.length, 'file')}${options.stats == null ? ' · line stats unavailable' : ` · +${options.stats.additions} −${options.stats.deletions}`}`, + ...(options.statsError == null + ? [] + : [`Stats unavailable: ${options.statsError}`]), + `Review: ${clusterUrl(reviewUrl, tree.id)}`, + ...(files.length === 0 + ? [] + : [ + '', + options.stats == null ? 'Files:' : 'Files (largest changes first):', + ...sorted.slice(0, limit).map((file) => { + const stats = + file.additions != null + ? file.binary + ? 'binary' + : `+${file.additions} −${file.deletions}` + : '' + return ` ${file.fileName}${stats ? ` ${stats}` : ''}${file.renamed ? ' (renamed)' : ''}` + }), + ]), + ...(files.length > limit + ? [ + ` … ${count(files.length - limit, 'more file')}; use --files to show all.`, + ] + : []), + ...(children.length === 0 + ? [] + : [ + '', + 'Subclusters:', + children + .map((child) => formatClusterList(child, reviewUrl, 1, 1)) + .join('\n\n'), + ]), + ].join('\n') +} diff --git a/src/cluster-poll.ts b/src/cluster-poll.ts new file mode 100644 index 0000000..54ccb44 --- /dev/null +++ b/src/cluster-poll.ts @@ -0,0 +1,52 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { findCluster, type ClustersResponse } from './clusters.js' + +export async function pollClusters( + fetchStatus: (signal: AbortSignal) => Promise, + options: { + timeoutMs: number + wait: boolean + waitUntil: 'usable' | 'ready' + clusterId?: string + }, +) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), options.timeoutMs) + let last: ClustersResponse | undefined + try { + for (;;) { + last = await new Promise((resolve, reject) => { + const aborted = () => reject(new Error('Clustering wait timed out')) + controller.signal.addEventListener('abort', aborted, { once: true }) + if (controller.signal.aborted) { + aborted() + return + } + fetchStatus(controller.signal) + .then(resolve, reject) + .finally(() => + controller.signal.removeEventListener('abort', aborted), + ) + }) + const usable = + last.status !== 'pending' && + (options.clusterId == null || + findCluster(last.tree, options.clusterId) != null) + if ( + last.status === 'ready' || + (usable && options.waitUntil === 'usable') || + !options.wait + ) { + return { result: last, timedOut: false } + } + await delay(last.retryAfterSeconds * 1000, undefined, { + signal: controller.signal, + }) + } + } catch (error) { + if (!controller.signal.aborted) throw error + return { result: last, timedOut: true } + } finally { + clearTimeout(timer) + } +} diff --git a/src/clusters.ts b/src/clusters.ts new file mode 100644 index 0000000..c9ccf25 --- /dev/null +++ b/src/clusters.ts @@ -0,0 +1,72 @@ +import { z } from 'zod' + +export const ClusterLeaf = z.union([ + z.object({ + kind: z.literal('leaf'), + fileName: z.string(), + fullFile: z.literal(true), + }), + z.object({ + kind: z.literal('leaf'), + fileName: z.string(), + oldStart: z.number(), + oldLines: z.number(), + newStart: z.number(), + newLines: z.number(), + }), +]) +export type ClusterLeaf = z.infer +export const ClusterGroup = z.object({ + kind: z.literal('group'), + id: z.string(), + title: z.string(), + description: z.string(), + pendingSubclustering: z.boolean(), + get children(): z.ZodArray< + z.ZodUnion<[typeof ClusterLeaf, typeof ClusterGroup]> + > { + return z.array(z.union([ClusterLeaf, ClusterGroup])) + }, +}) +export type ClusterGroup = z.infer +const Snapshot = z.object({ + repository: z.string().regex(/^[\w.-]+\/[\w.-]+$/), + pullNumber: z.number().int().positive(), + version: z.number().int().positive(), + baseSha: z.string().regex(/^[a-f\d]{40}$/i), + headSha: z.string().regex(/^[a-f\d]{40}$/i), + reviewUrl: z.string().url(), + retryAfterSeconds: z.number().positive().max(60), +}) +export const ClustersResponse = z.discriminatedUnion('status', [ + Snapshot.extend({ status: z.literal('pending') }), + Snapshot.extend({ status: z.literal('partial'), tree: ClusterGroup }), + Snapshot.extend({ status: z.literal('ready'), tree: ClusterGroup }), +]) +export type ClustersResponse = z.infer + +export function findCluster( + tree: ClusterGroup, + id: string, +): ClusterGroup | undefined { + if (tree.id === id) return tree + for (const child of tree.children) { + if (child.kind === 'group') { + const match = findCluster(child, id) + if (match != null) return match + } + } + return undefined +} + +export function clusterLeaves(tree: ClusterGroup): ClusterLeaf[] { + return tree.children.flatMap((node) => + node.kind === 'leaf' ? [node] : clusterLeaves(node), + ) +} + +export function clusterUrl(reviewUrl: string, id: string) { + const url = new URL(reviewUrl) + url.searchParams.set('cluster', id) + return url.toString() +} diff --git a/src/commands/clusters.ts b/src/commands/clusters.ts new file mode 100644 index 0000000..e0279a3 --- /dev/null +++ b/src/commands/clusters.ts @@ -0,0 +1,287 @@ +import { execa } from 'execa' +import { createAssertClient, exchangeGithubToken } from '../assert-api.js' +import { getConfiguration } from '../config.js' +import { getCurrentRepository, getGhToken } from '../gh.js' +import { parseReviewTarget } from '../review-target.js' +import { + ClustersResponse, + clusterLeaves, + clusterUrl, + findCluster, +} from '../clusters.js' +import { + sliceClusterDiff, + clusterDiffStats, + type ClusterDiffStats, +} from '../cluster-diff.js' +import { + findParentCluster, + formatClusterDetail, + formatClusterList, +} from '../cluster-format.js' +import { pollClusters } from '../cluster-poll.js' + +class ClusterCommandError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message) + } +} + +export interface ClusterOptions { + repo?: string + pr?: string + json?: boolean + timeout: string + wait: boolean + waitUntil: string + diff?: boolean + depth?: string + files?: boolean +} + +export function parseTimeout(value: string) { + const match = /^(\d+(?:\.\d+)?)(ms|s|m)?$/.exec(value) + const ms = + match == null + ? NaN + : Number(match[1]) * + (match[2] === 'ms' ? 1 : match[2] === 'm' ? 60_000 : 1000) + if (!Number.isFinite(ms) || ms < 1 || ms > 2_147_483_647) + throw new Error( + 'Use a positive timeout such as 30s, 2m, or 500ms (maximum 24 days).', + ) + return ms +} + +async function resolveTarget( + target: string | undefined, + options: ClusterOptions, +) { + const parsed = target == null ? undefined : parseReviewTarget(target) + if (parsed != null && (options.repo != null || options.pr != null)) + throw new Error('Use either a PR URL or --repo/--pr, not both.') + const repository = + parsed?.repository ?? + (options.repo == null + ? (await getCurrentRepository())?.nameWithOwner + : parseReviewTarget(options.repo).repository) + if (repository == null) + throw new Error( + 'No current GitHub repository. Provide --repo owner/repo --pr number or a PR URL.', + ) + let pullNumber = parsed?.pullNumber + if (options.pr != null) { + if ( + !/^[1-9]\d*$/.test(options.pr) || + !Number.isSafeInteger(Number(options.pr)) + ) + throw new Error('--pr must be a positive pull request number.') + pullNumber = Number(options.pr) + } + if (pullNumber == null) { + const current = await getCurrentRepository() + if (current?.nameWithOwner.toLowerCase() !== repository.toLowerCase()) + throw new Error( + 'Provide --pr when selecting a repository other than the current repository.', + ) + const result = await execa('gh', ['pr', 'view', '--json', 'number'], { + reject: false, + timeout: 15_000, + }) + if (result.exitCode !== 0) + throw new Error( + 'No pull request found for the current branch. Provide --pr number or a PR URL.', + ) + const value = JSON.parse(result.stdout) as { number?: unknown } + if ( + typeof value.number !== 'number' || + !Number.isSafeInteger(value.number) || + value.number < 1 + ) + throw new Error('gh returned an invalid pull request number.') + pullNumber = value.number + } + return { repository, pullNumber } +} + +export async function clustersCommand( + options: ClusterOptions, + target?: string, + clusterId?: string, +) { + let client: ReturnType | undefined + try { + const timeoutMs = parseTimeout(options.timeout) + const depth = + options.depth === 'all' ? Infinity : Number(options.depth ?? '1') + if (options.depth !== 'all' && (!Number.isSafeInteger(depth) || depth < 0)) + throw new Error('--depth must be a non-negative integer or all.') + if (options.waitUntil !== 'usable' && options.waitUntil !== 'ready') + throw new Error('--wait-until must be usable or ready.') + const { repository, pullNumber } = await resolveTarget(target, options) + const configuration = getConfiguration() + const exchange = async (signal?: AbortSignal) => + exchangeGithubToken(configuration, await getGhToken(signal), signal) + client = createAssertClient(configuration, await exchange(), exchange) + const path = `/api/v1/pulls/github/${repository}/${pullNumber}/clusters` + const retryCommand = `assert-local ${clusterId == null ? 'clusters' : `cluster '${clusterId.replaceAll("'", "'\\''")}'`} --repo ${repository} --pr ${pullNumber}` + const { result, timedOut } = await pollClusters( + async (signal) => { + const response = await client!.request(path, { signal }) + if (!response.ok) { + const body = (await response.json().catch(() => undefined)) as + { code?: string; message?: string } | undefined + const hint = + response.status === 401 + ? ' Check your sign-in and repository access with `gh auth status`.' + : response.status === 403 + ? ' Check your GitHub repository access and Assert account.' + : '' + throw new ClusterCommandError( + body?.code ?? `HTTP_${response.status}`, + `${body?.code ?? `HTTP_${response.status}`}: ${body?.message ?? 'Could not retrieve clusters.'}${hint}`, + ) + } + const parsed = ClustersResponse.safeParse(await response.json()) + if (!parsed.success) + throw new Error( + 'Assert returned an unexpected clustering response. Update assert-local or try again later.', + ) + return parsed.data + }, + { + timeoutMs, + wait: options.wait, + waitUntil: options.waitUntil, + clusterId, + }, + ) + const selected = + result != null && result.status !== 'pending' && clusterId != null + ? findCluster(result.tree, clusterId) + : undefined + if (clusterId != null && selected == null && result?.status === 'ready') + throw new ClusterCommandError( + 'CLUSTER_NOT_FOUND', + `Cluster ${JSON.stringify(clusterId)} was not found in the latest clustering. Run \`assert-local clusters --repo ${repository} --pr ${pullNumber}\` to list current IDs.`, + ) + let diff: string | undefined + let stats: ClusterDiffStats | undefined + let statsError: string | undefined + if (selected != null && result != null) { + try { + const comparison = await execa( + 'gh', + [ + 'api', + `repos/${repository}/compare/${result.baseSha}...${result.headSha}`, + '-H', + 'Accept: application/vnd.github.diff', + ], + { timeout: 60_000, maxBuffer: 50 * 1024 * 1024 }, + ) + const sliced = sliceClusterDiff( + comparison.stdout, + clusterLeaves(selected), + ) + stats = clusterDiffStats(sliced) + if (options.diff) diff = sliced + } catch (error) { + if (options.diff) throw error + statsError = error instanceof Error ? error.message : String(error) + } + } + const parent = + selected != null && result != null && result.status !== 'pending' + ? findParentCluster(result.tree, selected.id) + : undefined + const message = timedOut + ? result == null + ? 'Timed out before receiving clustering status. Generation status is unknown.' + : `Still generating clusters (${result.status}); the CLI stopped waiting. Server-side generation continues.` + : result?.status === 'pending' + ? 'Clusters are still generating.' + : clusterId != null && selected == null + ? 'The requested cluster is not available yet; subclustering is still running.' + : undefined + const output = { + ...(result == null + ? { repository, pullNumber, status: 'unknown' } + : selected == null + ? result + : { ...result, tree: undefined }), + timedOut, + message, + retryCommand, + ...(selected == null + ? {} + : { + cluster: selected, + clusterUrl: clusterUrl(result!.reviewUrl, selected.id), + }), + ...(stats == null ? {} : { stats }), + ...(statsError == null ? {} : { statsError }), + ...(parent == null + ? {} + : { parent: { id: parent.id, title: parent.title } }), + ...(diff == null ? {} : { diff }), + } + if (options.json) console.log(JSON.stringify(output, null, 2)) + else if (options.diff) { + if (diff != null) process.stdout.write(diff) + if (message != null) console.error(message) + if (timedOut || selected == null) console.error(`Retry: ${retryCommand}`) + } else { + if (message != null) console.log(message) + if (result != null) { + console.log( + `${repository}#${pullNumber} · ${result.status} · Version ${result.version}\n`, + ) + if (selected != null) { + console.log( + formatClusterDetail(selected, result.reviewUrl, { + stats, + statsError, + parent, + allFiles: options.files, + }), + ) + } else if (result.status !== 'pending' && clusterId == null) { + console.log(formatClusterList(result.tree, result.reviewUrl, depth)) + if (depth !== Infinity) + console.log( + `\nInspect a cluster with: assert-local cluster --repo ${repository} --pr ${pullNumber}\nExpand the tree with: assert-local clusters --repo ${repository} --pr ${pullNumber} --depth all`, + ) + } else console.log(`Review: ${result.reviewUrl}`) + } + if ( + timedOut || + result?.status === 'pending' || + (clusterId != null && selected == null) + ) + console.log(`Retry: ${retryCommand}`) + } + if (timedOut) process.exitCode = 2 + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (options.json) + console.log( + JSON.stringify({ + error: { + code: + error instanceof ClusterCommandError + ? error.code + : 'COMMAND_FAILED', + message, + }, + }), + ) + else console.error(message) + process.exitCode = 1 + } finally { + client?.close() + } +} diff --git a/src/index.ts b/src/index.ts index aff290c..ad19ad1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { Command } from 'commander' import { createRequire } from 'node:module' +import { clustersCommand } from './commands/clusters.js' import { reviewCommand } from './commands/review.js' const { version } = createRequire(import.meta.url)('../package.json') as { @@ -8,7 +9,7 @@ const { version } = createRequire(import.meta.url)('../package.json') as { const program = new Command() .name('assert-local') - .description('Run Assert locally with your GitHub CLI credentials') + .description('Review pull requests and explore their changes with Assert') .addHelpText( 'after', "\nMove fast, don't break things\nGet started: assert-local review", @@ -24,6 +25,50 @@ program reviewCommand(options, target), ) +for (const name of ['clusters', 'cluster']) { + const command = program + .command(name) + .description( + name === 'clusters' + ? 'List logical review clusters for a pull request' + : 'Inspect a cluster in the latest pull request clustering', + ) + .option( + '--repo ', + 'GitHub repository (defaults to current repository)', + ) + .option( + '--pr ', + 'pull request number (defaults to current branch PR)', + ) + .option('--json', 'print structured output') + .option( + '--timeout ', + 'maximum time waiting for clustering', + '60s', + ) + .option('--no-wait', 'request status once') + .option( + '--wait-until ', + 'wait for usable or ready clusters', + 'usable', + ) + if (name === 'clusters') + command + .argument('[pr-url]', 'GitHub pull request URL') + .option('--depth ', 'levels of subclusters to show, or all', '1') + .action((target, options) => clustersCommand(options, target)) + else + command + .argument('', 'exact cluster ID from clusters output') + .option( + '--diff', + 'output only the diff for this cluster and its subclusters', + ) + .option('--files', 'list every changed file in the cluster summary') + .action((id, options) => clustersCommand(options, undefined, id)) +} + program.parseAsync().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)) process.exitCode = 1 diff --git a/src/tests/cluster-diff.test.ts b/src/tests/cluster-diff.test.ts new file mode 100644 index 0000000..6471f0d --- /dev/null +++ b/src/tests/cluster-diff.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { clusterDiffStats, sliceClusterDiff } from '../cluster-diff.js' +import type { ClusterLeaf } from '../clusters.js' + +const range = ( + oldStart: number, + oldLines: number, + newStart: number, + newLines: number, +): ClusterLeaf => ({ + kind: 'leaf', + fileName: 'src/example.ts', + oldStart, + oldLines, + newStart, + newLines, +}) +const diff = `diff --git a/src/example.ts b/src/example.ts +index 1111111..2222222 100644 +--- a/src/example.ts ++++ b/src/example.ts +@@ -1,5 +1,5 @@ + first +-old one ++new one + middle +-old two ++new two + last +` + +describe('cluster diff excerpts', () => { + it('slices inside a shared hunk without including neighboring changes', () => { + const sliced = sliceClusterDiff(diff, [range(2, 1, 2, 1)]) + expect(sliced).toContain('@@ -2,1 +2,1 @@\n-old one\n+new one') + expect(sliced).not.toContain('new two') + expect(sliced).not.toContain('old two') + }) + it('unions parent ranges and does not duplicate overlapping children', () => { + const sliced = sliceClusterDiff(diff, [ + range(2, 1, 2, 1), + range(2, 1, 2, 1), + range(4, 1, 4, 1), + ]) + expect(sliced.match(/\+new one/g)).toHaveLength(1) + expect(sliced).toContain('@@ -4,1 +4,1 @@\n-old two\n+new two') + }) + it('preserves zero-line coordinates for added files and no-newline markers', () => { + const added = `diff --git a/src/example.ts b/src/example.ts +new file mode 100644 +--- /dev/null ++++ b/src/example.ts +@@ -0,0 +1,1 @@ ++hello +\\ No newline at end of file +` + expect(sliceClusterDiff(added, [range(0, 0, 1, 1)])).toBe(added) + }) + it('preserves deletion coordinates', () => { + const removed = `diff --git a/src/example.ts b/src/example.ts +--- a/src/example.ts ++++ /dev/null +@@ -1,1 +0,0 @@ +-hello +` + expect(sliceClusterDiff(removed, [range(1, 1, 0, 0)])).toBe(removed) + }) + it('includes full-file rename and binary metadata', () => { + const patch = `diff --git a/old.png b/new.png +similarity index 100% +rename from old.png +rename to new.png +` + expect( + sliceClusterDiff(patch, [ + { kind: 'leaf', fileName: 'new.png', fullFile: true }, + ]), + ).toBe(patch) + const binary = + 'diff --git a/image.png b/image.png\nBinary files a/image.png and b/image.png differ\n' + expect( + sliceClusterDiff(binary, [ + { kind: 'leaf', fileName: 'image.png', fullFile: true }, + ]), + ).toBe(binary) + }) + it('decodes quoted UTF-8 file names', () => { + const patch = + 'diff --git "a/caf\\303\\251.png" "b/caf\\303\\251.png"\nBinary files differ\n' + expect( + sliceClusterDiff(patch, [ + { kind: 'leaf', fileName: 'café.png', fullFile: true }, + ]), + ).toBe(patch) + }) + it('fails explicitly when GitHub omitted a requested file', () => { + expect(() => sliceClusterDiff('', [range(2, 1, 2, 1)])).toThrow( + 'incomplete', + ) + }) +}) + +it('uses the destination path when a renamed file’s old path is reused', () => { + const patch = `diff --git a/old.ts b/new.ts +similarity index 100% +rename from old.ts +rename to new.ts +diff --git a/old.ts b/old.ts +new file mode 100644 +--- /dev/null ++++ b/old.ts +@@ -0,0 +1,1 @@ ++replacement +` + const sliced = sliceClusterDiff(patch, [ + { kind: 'leaf', fileName: 'old.ts', fullFile: true }, + ]) + expect(sliced).toContain('+replacement') + expect(sliced).not.toContain('rename to new.ts') +}) + +it('counts selected changes once without counting context or patch headers', () => { + const sliced = sliceClusterDiff(diff, [range(2, 1, 2, 1), range(2, 1, 2, 1)]) + expect(clusterDiffStats(sliced)).toEqual({ + additions: 1, + deletions: 1, + files: [ + { + fileName: 'src/example.ts', + additions: 1, + deletions: 1, + binary: false, + renamed: false, + }, + ], + }) +}) + +it('counts content resembling file headers as changed lines', () => { + const patch = `diff --git a/example.txt b/example.txt +--- a/example.txt ++++ b/example.txt +@@ -1,2 +1,3 @@ +--- old content ++++ new content ++another line + unchanged +` + expect(clusterDiffStats(patch)).toMatchObject({ additions: 2, deletions: 1 }) +}) + +it('reports binary and rename-only files without inventing line counts', () => { + const patch = `diff --git a/old.png b/new.png +similarity index 100% +rename from old.png +rename to new.png +diff --git a/image.png b/image.png +Binary files a/image.png and b/image.png differ +` + expect(clusterDiffStats(patch)).toEqual({ + additions: 0, + deletions: 0, + files: [ + { + fileName: 'new.png', + additions: 0, + deletions: 0, + binary: false, + renamed: true, + }, + { + fileName: 'image.png', + additions: 0, + deletions: 0, + binary: true, + renamed: false, + }, + ], + }) +}) diff --git a/src/tests/cluster-poll.test.ts b/src/tests/cluster-poll.test.ts new file mode 100644 index 0000000..c8336ca --- /dev/null +++ b/src/tests/cluster-poll.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pollClusters } from '../cluster-poll.js' +import { + clusterLeaves, + clusterUrl, + findCluster, + type ClustersResponse, + type ClusterGroup, +} from '../clusters.js' +import { parseTimeout } from '../commands/clusters.js' + +const snapshot = { + repository: 'example/project', + pullNumber: 12, + version: 1, + baseSha: 'a'.repeat(40), + headSha: 'b'.repeat(40), + reviewUrl: 'https://app.assert.dev/review/github/example/project/12?v=1', + retryAfterSeconds: 0.01, +} +const tree: ClusterGroup = { + kind: 'group', + id: 'root', + title: 'Change', + description: '', + pendingSubclustering: true, + children: [], +} +const pending: ClustersResponse = { ...snapshot, status: 'pending' } +const partial: ClustersResponse = { ...snapshot, status: 'partial', tree } +const ready: ClustersResponse = { ...snapshot, status: 'ready', tree } +const options = { timeoutMs: 500, wait: true, waitUntil: 'usable' as const } + +afterEach(() => vi.restoreAllMocks()) +describe('cluster polling', () => { + it('returns partial results as soon as usable', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(pending) + .mockResolvedValue(partial) + expect(await pollClusters(fetch, options)).toEqual({ + result: partial, + timedOut: false, + }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + it('can wait until recursive clustering completes', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(partial) + .mockResolvedValue(ready) + expect( + (await pollClusters(fetch, { ...options, waitUntil: 'ready' })).result + ?.status, + ).toBe('ready') + }) + it('keeps waiting for a requested child missing from partial results', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(partial) + .mockResolvedValue(ready) + await pollClusters(fetch, { ...options, clusterId: 'later-child' }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + it('returns the last status on timeout', async () => { + expect( + await pollClusters(async () => pending, { ...options, timeoutMs: 20 }), + ).toEqual({ result: pending, timedOut: true }) + }) + it('bounds a stalled request and reports unknown status', async () => { + const fetch = (signal: AbortSignal) => + new Promise((_, reject) => + signal.addEventListener('abort', () => reject(new Error('aborted'))), + ) + expect(await pollClusters(fetch, { ...options, timeoutMs: 10 })).toEqual({ + result: undefined, + timedOut: true, + }) + }) + it('does not retry authentication/access errors', async () => { + const fetch = vi.fn().mockRejectedValue(new Error('ACCESS_DENIED')) + await expect(pollClusters(fetch, options)).rejects.toThrow('ACCESS_DENIED') + expect(fetch).toHaveBeenCalledTimes(1) + }) + it('no-wait makes one request', async () => { + const fetch = vi.fn().mockResolvedValue(pending) + expect( + (await pollClusters(fetch, { ...options, wait: false })).timedOut, + ).toBe(false) + expect(fetch).toHaveBeenCalledTimes(1) + }) +}) + +it('finds descendants and constructs encoded version-pinned links', () => { + const child = { + ...tree, + id: 'area:child', + children: [ + { kind: 'leaf' as const, fileName: 'file.ts', fullFile: true as const }, + ], + } + const root = { ...tree, children: [child] } + expect(findCluster(root, child.id)).toBe(child) + expect(clusterLeaves(root)).toEqual(child.children) + const url = new URL(clusterUrl(snapshot.reviewUrl, child.id)) + expect(url.searchParams.get('v')).toBe('1') + expect(url.searchParams.get('cluster')).toBe(child.id) +}) + +it('parses durations and rejects invalid or overflowing timers', () => { + expect(parseTimeout('2m')).toBe(120000) + expect(parseTimeout('500ms')).toBe(500) + expect(parseTimeout('30')).toBe(30000) + for (const value of ['0', '-1s', 'never', '999999999999m']) + expect(() => parseTimeout(value)).toThrow() +})