Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 66 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 <id>` 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 <id> --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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

189 changes: 189 additions & 0 deletions src/cluster-diff.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
)[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<string>()
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),
}
}
Loading
Loading