diff --git a/README.md b/README.md index df77751..6c0a22d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,21 @@ Requires Node 20+, `git`, `sqlite3`, and an authenticated [`gh`](https://cli.git CLI. `stacklore` reuses your existing `gh` credentials rather than asking for a second token. +### As a `gh` extension + +```bash +stacklore gh-install # then: gh stacklore save +``` + +This registers `gh stacklore` for your user. `gh extension install` is not used +because it requires the *repository* to be named `gh-stacklore`, and this one is +named `stacklore`; `gh-install` sidesteps that by writing the extension shim +itself, so it works for a global npm install or a source checkout alike. Re-run +it after upgrading Node or stacklore, and `stacklore gh-uninstall` to remove it. +A companion `gh-stacklore` repository, for the usual `gh extension install` +flow, is a planned follow-up; the `gh-stacklore` script at the root of this +repository is its entry point. + ## Quick start ```bash @@ -70,6 +85,8 @@ contents. | `stacklore show [--pr N]` | Print stored lore for a pull request | | `stacklore list` | List captured records for this repository | | `stacklore status` | Show config, adapter availability, and stack position | +| `stacklore gh-install` | Register the CLI as a `gh stacklore` extension | +| `stacklore gh-uninstall` | Remove the `gh stacklore` extension | | Flag | Description | |---|---| @@ -81,9 +98,12 @@ contents. ## How it works -1. **Resolve the stack.** GitHub exposes a `stack` object on the pull request - REST resource — stack number, position, size, and trunk. Standalone pull - requests are treated as a stack of one. +1. **Resolve the stack.** Stacks are read from GraphQL — `PullRequest.stackEntry` + yields the stack's number, size, trunk, and every member's position in one + round-trip. The REST pull request resource does *not* expose a `stack` field, + so GraphQL is the only source. When GitHub reports no native stack, the + topology is inferred by walking the chain of base branches (see below). + Standalone pull requests are treated as a stack of one. 2. **Capture per layer.** Each layer is a branch, so the adapter joins on `branch` to find the sessions recorded against it. 3. **Redact.** Every prompt passes through the redaction engine before it is @@ -103,6 +123,22 @@ which survives rebases, force-pushes, and squash merges: records///pr-.json ``` +### Native stacks and inferred chains + +GitHub only reports a native stack for pull requests created through its +stacking flow. A stack assembled by hand — each pull request targeting the one +below it — has a null `stackEntry`, so stacklore reconstructs the chain itself: +it walks base-to-head links from the trunk and stops at any branch point, since +a tree is not a stack. Inferred layers are marked `source: "inferred"` in the +record and labelled as such in `stacklore status`. + +### Idempotence + +`save` is safe to re-run. `capturedAt` moves on every capture, so records are +compared ignoring that field; an unchanged layer is not rewritten and the store +does not accumulate empty commits. The pull request comment is matched by a +hidden marker and updated in place rather than duplicated. + ## Redaction Secrets are removed before anything reaches the store: @@ -136,6 +172,10 @@ sensitive and keep it private. | Cursor | `cursor` | Planned | | Codex CLI | `codex` | Planned | +The Copilot adapter reads `~/.copilot/session-store.db`. Set +`STACKLORE_COPILOT_DB` to point it elsewhere, which is useful for testing and +for machines that keep Copilot state outside the default location. + ### Writing an adapter Capture is deliberately narrow — given a branch, return the exchanges and files @@ -176,10 +216,19 @@ const stack = await captureStack("acme/widgets", 42); console.log(renderStackComment(stack, { current: 42 })); ``` +Every module that shells out takes an optional `exec` runner, so `gh` and `git` +can be substituted in tests or wrapped by a host application: + +```ts +await captureStack("acme/widgets", 42, { exec: myRunner }); +``` + ## Status -Early. The record schema is versioned (`schemaVersion: 1`) and will migrate -rather than break. +Early, but the full path is exercised end-to-end against real GitHub resources, +and `gh`/`git` interactions are covered by unit tests with injected runners. The +record schema is versioned (`schemaVersion: 1`) and will migrate rather than +break. ## License diff --git a/gh-stacklore b/gh-stacklore new file mode 100755 index 0000000..b6e34a7 --- /dev/null +++ b/gh-stacklore @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# GitHub CLI extension entry point. +# +# gh requires an extension's repository to be named `gh-`, so this script +# exists for a `gh-stacklore` companion repository that mirrors this one. When +# stacklore is already installed, `stacklore gh-install` is the better route: it +# writes a shim with absolute paths and needs no checkout at all. +set -euo pipefail + +here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +# A source checkout that has been built runs directly from its own output, so a +# development version is never shadowed by a globally installed one. +if [[ -f "$here/dist/cli.js" ]]; then + exec node "$here/dist/cli.js" "$@" +fi + +if command -v stacklore >/dev/null 2>&1; then + exec stacklore "$@" +fi + +cat >&2 <<'MSG' +stacklore is not built or installed. + + From a checkout: npm install && npm run build + Or install it: npm install -g stacklore && stacklore gh-install +MSG +exit 1 diff --git a/package.json b/package.json index 381777d..da33983 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "test": "node --test --experimental-strip-types test/*.test.ts", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json", "prepublishOnly": "npm run build" }, "devDependencies": { diff --git a/src/adapters/copilot.ts b/src/adapters/copilot.ts index 8cb5d34..e9b1f8f 100644 --- a/src/adapters/copilot.ts +++ b/src/adapters/copilot.ts @@ -7,19 +7,26 @@ * commit-SHA join would not. */ -import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; -import type { Exchange } from "../types.js"; -import type { CaptureAdapter, CaptureQuery, CaptureResult } from "./types.js"; - -const execFileAsync = promisify(execFile); +import { runCommand, type CommandRunner } from "../exec.ts"; +import type { Exchange } from "../types.ts"; +import type { CaptureAdapter, CaptureQuery, CaptureResult } from "./types.ts"; const DEFAULT_DB = join(homedir(), ".copilot", "session-store.db"); +/** + * Location of the Copilot session store. + * + * Overridable so the adapter can be pointed at a fixture database in tests and + * end-to-end runs without touching the user's real session history. + */ +export function copilotDbPath(env: NodeJS.ProcessEnv = process.env): string { + return env.STACKLORE_COPILOT_DB || DEFAULT_DB; +} + interface SessionRow { id: string; branch: string; @@ -28,12 +35,12 @@ interface SessionRow { } /** Run a query against the SQLite store and return JSON rows. */ -async function query(dbPath: string, sql: string): Promise { - const { stdout } = await execFileAsync( - "sqlite3", - ["-readonly", "-json", dbPath, sql], - { maxBuffer: 64 * 1024 * 1024 }, - ); +async function query( + exec: CommandRunner, + dbPath: string, + sql: string, +): Promise { + const stdout = await exec("sqlite3", ["-readonly", "-json", dbPath, sql]); const trimmed = stdout.trim(); if (!trimmed) return []; return JSON.parse(trimmed) as T[]; @@ -47,15 +54,17 @@ function sqlString(value: string): string { export class CopilotAdapter implements CaptureAdapter { readonly name = "copilot"; readonly #dbPath: string; + readonly #exec: CommandRunner; - constructor(dbPath: string = DEFAULT_DB) { + constructor(dbPath: string = copilotDbPath(), exec: CommandRunner = runCommand) { this.#dbPath = dbPath; + this.#exec = exec; } async isAvailable(): Promise { if (!existsSync(this.#dbPath)) return false; try { - await execFileAsync("sqlite3", ["-version"]); + await this.#exec("sqlite3", ["-version"]); return true; } catch { return false; @@ -72,6 +81,7 @@ export class CopilotAdapter implements CaptureAdapter { } const sessions = await query( + this.#exec, this.#dbPath, `SELECT id, branch, repository, summary FROM sessions @@ -90,6 +100,7 @@ export class CopilotAdapter implements CaptureAdapter { assistant_response: string | null; timestamp: string | null; }>( + this.#exec, this.#dbPath, `SELECT turn_index, user_message, assistant_response, timestamp FROM turns @@ -100,6 +111,7 @@ export class CopilotAdapter implements CaptureAdapter { ); const files = await query<{ file_path: string }>( + this.#exec, this.#dbPath, `SELECT DISTINCT file_path FROM session_files @@ -139,6 +151,7 @@ export class CopilotAdapter implements CaptureAdapter { async linkedPullRequests(branch: string): Promise { if (!(await this.isAvailable())) return []; const rows = await query<{ ref_value: string }>( + this.#exec, this.#dbPath, `SELECT DISTINCT r.ref_value FROM session_refs r diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 345f5a7..01ef37d 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -2,11 +2,11 @@ export type { CaptureAdapter, CaptureQuery, CaptureResult, -} from "./types.js"; -export { CopilotAdapter } from "./copilot.js"; +} from "./types.ts"; +export { CopilotAdapter, copilotDbPath } from "./copilot.ts"; -import type { CaptureAdapter } from "./types.js"; -import { CopilotAdapter } from "./copilot.js"; +import type { CaptureAdapter } from "./types.ts"; +import { CopilotAdapter } from "./copilot.ts"; /** Adapters tried in order when none is explicitly selected. */ export function defaultAdapters(): CaptureAdapter[] { diff --git a/src/adapters/types.ts b/src/adapters/types.ts index 01bd98b..3936a76 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -7,7 +7,7 @@ * for a new tool. */ -import type { Exchange } from "../types.js"; +import type { Exchange } from "../types.ts"; /** Raw, un-redacted capture for a single branch. Redaction happens downstream. */ export interface CaptureResult { diff --git a/src/capture.ts b/src/capture.ts index cbac4a9..6d874cc 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -3,23 +3,31 @@ * redact, distill, and build a storable record. */ -import { resolveAdapter, type CaptureAdapter } from "./adapters/index.js"; -import { distillIntent } from "./distill.js"; -import { redact, redactAll } from "./redact.js"; -import { getStack, type PullRequestInfo } from "./stack.js"; +import { resolveAdapter, type CaptureAdapter } from "./adapters/index.ts"; +import { distillIntent } from "./distill.ts"; +import { runCommand, type CommandRunner } from "./exec.ts"; +import { redact, redactAll } from "./redact.ts"; +import { getStack, type PullRequestInfo } from "./stack.ts"; import { SCHEMA_VERSION, type Exchange, type LoreRecord, type LoreStack, type RedactionReport, -} from "./types.js"; +} from "./types.ts"; export interface CaptureOptions { repo: string; adapter?: CaptureAdapter | null; adapterName?: string; + /** + * Adapters to choose from when none is supplied. Injected by tests; defaults + * to every adapter built in. + */ + adapters?: CaptureAdapter[]; includeResponses?: boolean; + /** Subprocess runner. Injected by tests; defaults to a real `spawn`. */ + exec?: CommandRunner; } function mergeReports(reports: RedactionReport[]): RedactionReport { @@ -38,7 +46,8 @@ export async function captureLayer( options: CaptureOptions, ): Promise { const adapter = - options.adapter ?? (await resolveAdapter(undefined, options.adapterName)); + options.adapter ?? + (await resolveAdapter(options.adapters, options.adapterName)); if (!adapter) return null; const captures = await adapter.capture({ @@ -113,14 +122,19 @@ export async function captureStack( pr: number, options: Omit = {}, ): Promise { - const members = await getStack(repo, pr); + const members = await getStack(repo, pr, options.exec ?? runCommand); const adapter = - options.adapter ?? (await resolveAdapter(undefined, options.adapterName)); + options.adapter ?? + (await resolveAdapter(options.adapters, options.adapterName)); const layers: LoreRecord[] = []; - for (const member of members) { - const record = await captureLayer(member, { ...options, repo, adapter }); - if (record) layers.push(record); + // When no adapter is available there is nothing to capture, and probing once + // per layer would repeat the same failing detection for every pull request. + if (adapter) { + for (const member of members) { + const record = await captureLayer(member, { ...options, repo, adapter }); + if (record) layers.push(record); + } } const head = members.find((m) => m.number === pr) ?? members[0]; diff --git a/src/cli.ts b/src/cli.ts index 132f485..f491659 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,19 +6,25 @@ * installed as `gh-stacklore`. */ -import { captureStack } from "./capture.js"; -import { loadConfig, saveConfig, type StackloreConfig } from "./config.js"; -import { renderRecord, renderStackComment } from "./distill.js"; -import { resolveAdapter } from "./adapters/index.js"; -import { LoreStore } from "./store.js"; +import { captureStack } from "./capture.ts"; +import { loadConfig, saveConfig, type StackloreConfig } from "./config.ts"; +import { renderRecord, renderStackComment } from "./distill.ts"; +import { resolveAdapter } from "./adapters/index.ts"; +import { LoreStore } from "./store.ts"; +import { + EXTENSION_NAME, + extensionInstalled, + installExtension, + uninstallExtension, +} from "./extension.ts"; import { currentBranch, currentRepo, - getPullRequest, + getStack, pullRequestForBranch, upsertComment, -} from "./stack.js"; -import type { LoreStack } from "./types.js"; +} from "./stack.ts"; +import type { LoreStack } from "./types.ts"; const COMMENT_MARKER = ""; @@ -30,6 +36,8 @@ Usage stacklore show [--pr N] Print stored lore for a pull request stacklore list List captured records for this repo stacklore status Show configuration and adapter availability + stacklore gh-install Install as a 'gh stacklore' extension + stacklore gh-uninstall Remove the 'gh stacklore' extension Options --pr Target pull request. Defaults to the current branch's PR. @@ -119,8 +127,12 @@ async function cmdInit(args: Args): Promise { fail(`store must be in 'owner/repo' form, got '${store}'`); } - console.log(`Creating private store ${store}…`); - await LoreStore.create(store); + const created = await LoreStore.create(store); + console.log( + created + ? `Created private store ${store}` + : `Store ${store} already exists — reusing it`, + ); const config: StackloreConfig = { store, @@ -251,6 +263,9 @@ async function cmdStatus(): Promise { console.log(`config ${config ? "found" : "missing (.stacklore.json)"}`); console.log(`store ${config?.store ?? "—"}`); console.log(`adapter ${adapter ? `${adapter.name} (available)` : "none available"}`); + console.log( + `gh ext ${extensionInstalled() ? "installed" : "not installed (run 'stacklore gh-install')"}`, + ); try { console.log(`repo ${await currentRepo()}`); @@ -260,14 +275,19 @@ async function cmdStatus(): Promise { const repo = await currentRepo(); const pr = await pullRequestForBranch(repo, branch); if (pr) { - const info = await getPullRequest(repo, pr); - console.log( - `pr #${pr} ${ - info.stack.number !== null - ? `(stack ${info.stack.number}, layer ${info.stack.position}/${info.stack.size})` - : "(not stacked)" - }`, - ); + const layers = await getStack(repo, pr); + const self = layers.find((l) => l.number === pr); + const position = self?.stack.position; + const size = self?.stack.size; + const kind = + position && size + ? `(layer ${position}/${size}, ${ + self?.stack.source === "github" + ? `stack ${self.stack.number}` + : "inferred from base branches" + })` + : "(not stacked)"; + console.log(`pr #${pr} ${kind}`); } else { console.log("pr none open for this branch"); } @@ -277,7 +297,26 @@ async function cmdStatus(): Promise { } } +async function cmdGhInstall(): Promise { + const { path, changed } = await installExtension(); + console.log( + changed + ? `Installed ${EXTENSION_NAME} at ${path}` + : `${EXTENSION_NAME} is already up to date at ${path}`, + ); + console.log("Run 'gh stacklore status' to confirm."); +} + +async function cmdGhUninstall(): Promise { + console.log( + (await uninstallExtension()) + ? `Removed ${EXTENSION_NAME}.` + : `${EXTENSION_NAME} is not installed.`, + ); +} + async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); switch (args.command) { @@ -296,6 +335,12 @@ async function main(): Promise { case "status": await cmdStatus(); break; + case "gh-install": + await cmdGhInstall(); + break; + case "gh-uninstall": + await cmdGhUninstall(); + break; case "help": case "--help": case "-h": diff --git a/src/distill.ts b/src/distill.ts index d16bf18..849a705 100644 --- a/src/distill.ts +++ b/src/distill.ts @@ -6,7 +6,7 @@ * change exists and rendered as a compact, reviewer-facing summary. */ -import type { Exchange, LoreRecord, LoreStack } from "./types.js"; +import type { Exchange, LoreRecord, LoreStack } from "./types.ts"; /** Conversational filler that carries no intent. */ const LOW_SIGNAL = diff --git a/src/exec.ts b/src/exec.ts new file mode 100644 index 0000000..ad6687a --- /dev/null +++ b/src/exec.ts @@ -0,0 +1,90 @@ +/** + * Subprocess seam. + * + * Every outbound call to `gh`, `git` and `sqlite3` goes through a + * `CommandRunner`. Modules accept one by injection so tests can substitute a + * fake without touching the network or the filesystem, and so the default + * remains a plain `spawn` in production. + * + * `spawn` rather than `execFile` because `gh api --input -` needs a body on + * stdin: command-line argument length is bounded, pull request comment bodies + * are not. + */ + +import { spawn } from "node:child_process"; + +export interface RunOptions { + /** Working directory for the child process. */ + cwd?: string; + /** Written to the child's stdin, which is then closed. */ + stdin?: string; +} + +/** Runs a command and resolves with its stdout. Rejects on a non-zero exit. */ +export interface CommandRunner { + (file: string, args: string[], options?: RunOptions): Promise; +} + +export class CommandError extends Error { + readonly file: string; + readonly args: string[]; + readonly code: number | null; + readonly stderr: string; + + constructor( + file: string, + args: string[], + code: number | null, + stderr: string, + ) { + super(`${file} ${args.join(" ")} failed: ${stderr.trim() || `exit ${code}`}`); + this.name = "CommandError"; + this.file = file; + this.args = args; + this.code = code; + this.stderr = stderr; + } +} + +/** Default runner: spawn the binary directly, never via a shell. */ +export const runCommand: CommandRunner = (file, args, options = {}) => + new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options.cwd, + stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], + }); + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + + child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk)); + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString("utf8")); + return; + } + reject( + new CommandError( + file, + args, + code, + Buffer.concat(stderr).toString("utf8"), + ), + ); + }); + + if (options.stdin !== undefined) { + child.stdin?.on("error", reject); + child.stdin?.end(options.stdin); + } + }); + +/** Extract a human-readable reason from an arbitrary thrown value. */ +export function reasonFor(error: unknown): string { + if (error instanceof CommandError) return error.stderr.trim() || error.message; + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..8a1b8c8 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,100 @@ +/** + * GitHub CLI extension packaging. + * + * `gh extension install` requires the *repository* name to start with `gh-`, + * which this package's does not. Rather than rename the package, stacklore + * installs itself: it writes a small shim into gh's extension directory that + * execs this CLI. That works for every install route — global npm, npx, or a + * source checkout — because the shim records absolute paths at install time. + */ + +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Extension name, and therefore the `gh ` subcommand. */ +export const EXTENSION_NAME = "gh-stacklore"; + +/** + * gh stores extensions under its data directory, honouring `XDG_DATA_HOME` + * on Linux and macOS alike. + */ +export function extensionDir(env: NodeJS.ProcessEnv = process.env): string { + const base = env["XDG_DATA_HOME"] || join(homedir(), ".local", "share"); + return join(base, "gh", "extensions", EXTENSION_NAME); +} + +/** Absolute path to this CLI's entry point, as shipped. */ +export function cliPath(): string { + return fileURLToPath(new URL("cli.js", import.meta.url)); +} + +/** + * The shim is bash rather than a symlink to `cli.js` because gh executes the + * file directly: a `.js` file has no interpreter of its own, and relying on its + * shebang would break whenever a different node is first on `PATH`. Paths are + * JSON-quoted so spaces in a home directory cannot split the command. + */ +export function shimScript(node: string, cli: string): string { + return `#!/usr/bin/env bash +# Generated by 'stacklore gh-install'. Re-run that command to update paths. +exec ${JSON.stringify(node)} ${JSON.stringify(cli)} "$@" +`; +} + +export interface ExtensionOptions { + env?: NodeJS.ProcessEnv; + /** Node binary the shim should exec. Defaults to the running one. */ + node?: string; + /** CLI entry point the shim should run. Defaults to this package's. */ + cli?: string; +} + +export interface InstallResult { + path: string; + /** False when an identical shim was already in place. */ + changed: boolean; +} + +/** Install, or refresh, the `gh stacklore` extension for the current user. */ +export async function installExtension( + options: ExtensionOptions = {}, +): Promise { + const dir = extensionDir(options.env ?? process.env); + const target = join(dir, EXTENSION_NAME); + const script = shimScript( + options.node ?? process.execPath, + options.cli ?? cliPath(), + ); + + // Rewriting an identical shim would be harmless, but reporting it as a change + // misleads anyone checking whether their install is current. + const existing = existsSync(target) + ? await readFile(target, "utf8").catch(() => null) + : null; + if (existing === script) return { path: target, changed: false }; + + await mkdir(dir, { recursive: true }); + await writeFile(target, script, "utf8"); + await chmod(target, 0o755); + return { path: target, changed: true }; +} + +/** Remove the extension. Returns false when it was not installed. */ +export async function uninstallExtension( + options: ExtensionOptions = {}, +): Promise { + const dir = extensionDir(options.env ?? process.env); + if (!existsSync(dir)) return false; + await rm(dir, { recursive: true, force: true }); + return true; +} + +/** Whether the extension is currently installed, for `status`. */ +export function extensionInstalled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return existsSync(join(extensionDir(env), EXTENSION_NAME)); +} diff --git a/src/index.ts b/src/index.ts index c62c587..1c730a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,17 @@ * pull request, in a private store. */ -export * from "./types.js"; -export * from "./redact.js"; -export * from "./distill.js"; -export * from "./config.js"; -export { LoreStore, StoreError, type StoreOptions } from "./store.js"; +export * from "./types.ts"; +export * from "./redact.ts"; +export * from "./distill.ts"; +export * from "./config.ts"; +export { + CommandError, + runCommand, + type CommandRunner, + type RunOptions, +} from "./exec.ts"; +export { LoreStore, StoreError, type StoreOptions } from "./store.ts"; export { GitHubError, currentBranch, @@ -17,7 +23,7 @@ export { pullRequestForBranch, upsertComment, type PullRequestInfo, -} from "./stack.js"; +} from "./stack.ts"; export { CopilotAdapter, defaultAdapters, @@ -25,5 +31,15 @@ export { type CaptureAdapter, type CaptureQuery, type CaptureResult, -} from "./adapters/index.js"; -export { captureLayer, captureStack, type CaptureOptions } from "./capture.js"; +} from "./adapters/index.ts"; +export { captureLayer, captureStack, type CaptureOptions } from "./capture.ts"; +export { + EXTENSION_NAME, + extensionDir, + extensionInstalled, + installExtension, + shimScript, + uninstallExtension, + type ExtensionOptions, + type InstallResult, +} from "./extension.ts"; diff --git a/src/redact.ts b/src/redact.ts index 67c387a..300209c 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -7,7 +7,7 @@ * anything is persisted. */ -import type { RedactionReport } from "./types.js"; +import type { RedactionReport } from "./types.ts"; export interface RedactionRule { name: string; diff --git a/src/stack.ts b/src/stack.ts index 9e015f0..28dfb7a 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -1,18 +1,26 @@ /** * GitHub stack topology. * - * Stacked pull requests expose a `stack` object on the pull request REST - * resource when the pull request belongs to a stack. All access goes through - * the authenticated `gh` CLI so stacklore inherits existing credentials rather - * than asking for a second token. + * GitHub's native stack metadata is exposed on the GraphQL `PullRequest.stackEntry` + * field — it is **not** present on the REST pull request resource, so topology + * is read over GraphQL. Pull requests that were chained by hand rather than + * through GitHub's stacking flow have no native stack, so the chain of base + * branches is used to reconstruct the topology instead. + * + * All access goes through the authenticated `gh` CLI so stacklore inherits + * existing credentials rather than asking for a second token. */ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -import type { StackPosition } from "./types.js"; +import { + reasonFor, + runCommand, + type CommandRunner, + type RunOptions, +} from "./exec.ts"; +import type { StackPosition } from "./types.ts"; -const execFileAsync = promisify(execFile); +/** Upper bound on stack size, guarding chain walks against pathological input. */ +const MAX_STACK = 50; export interface PullRequestInfo { number: number; @@ -27,44 +35,118 @@ interface RestPullRequest { number: number; title: string; state: string; - head: { ref: string }; + head: { ref: string; repo?: { full_name?: string } | null }; base: { ref: string }; - stack?: { - number?: number; - position?: number; - size?: number; - base?: string; - base_branch?: string; +} + +interface GqlPullRequest { + number: number; + title: string; + headRefName: string; + baseRefName: string; + state: string; + stackEntry?: { + position?: number | null; + stack?: { + number?: number | null; + size?: number | null; + baseRefName?: string | null; + entries?: { + nodes?: ({ + position?: number | null; + pullRequest?: GqlPullRequest | null; + } | null)[]; + } | null; + } | null; } | null; } export class GitHubError extends Error {} -async function gh(args: string[]): Promise { +const PR_FIELDS = "number title headRefName baseRefName state"; + +const PR_QUERY = `query($owner:String!,$name:String!,$number:Int!){ + repository(owner:$owner,name:$name){ + pullRequest(number:$number){ + ${PR_FIELDS} + stackEntry{ + position + stack{ + number size baseRefName + entries(first:${MAX_STACK}){ nodes{ position pullRequest{ ${PR_FIELDS} } } } + } + } + } + } +}`; + +async function gh( + exec: CommandRunner, + args: string[], + options?: RunOptions, +): Promise { try { - const { stdout } = await execFileAsync("gh", args, { - maxBuffer: 32 * 1024 * 1024, - }); - return stdout; + return await exec("gh", args, options); } catch (error) { - const message = - error instanceof Error && "stderr" in error - ? String((error as { stderr?: unknown }).stderr ?? error.message) - : String(error); - throw new GitHubError(`gh ${args.join(" ")} failed: ${message.trim()}`); + throw new GitHubError(`gh ${args.join(" ")} failed: ${reasonFor(error)}`); } } -function toStackPosition(pr: RestPullRequest): StackPosition { - const s = pr.stack; - if (!s) { - return { number: null, position: null, size: null, base: null }; +/** + * Parse `gh api --paginate --jq '.[]'` output. + * + * Without `--jq`, `--paginate` emits one complete JSON array per page and + * concatenates them, which is not valid JSON. Projecting with `.[]` yields one + * object per line instead, which stays parseable across any number of pages. + */ +function parseJsonLines(raw: string): T[] { + const items: T[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + items.push(JSON.parse(trimmed) as T); } + return items; +} + +const NO_STACK: StackPosition = { + number: null, + position: null, + size: null, + base: null, +}; + +function gqlToInfo(pr: GqlPullRequest): PullRequestInfo { + const entry = pr.stackEntry; + const stack = entry?.stack; return { - number: s.number ?? null, - position: s.position ?? null, - size: s.size ?? null, - base: s.base ?? s.base_branch ?? null, + number: pr.number, + title: pr.title, + branch: pr.headRefName, + baseBranch: pr.baseRefName, + // GraphQL reports OPEN/CLOSED/MERGED; REST reports open/closed. Normalise + // to the REST casing so the two sources stay interchangeable. + state: pr.state.toLowerCase(), + stack: stack + ? { + number: stack.number ?? null, + position: entry?.position ?? null, + size: stack.size ?? null, + base: stack.baseRefName ?? null, + source: "github", + } + : { ...NO_STACK }, + }; +} + +function restToInfo(pr: RestPullRequest): PullRequestInfo { + return { + number: pr.number, + title: pr.title, + branch: pr.head.ref, + baseBranch: pr.base.ref, + state: pr.state, + stack: { ...NO_STACK }, }; } @@ -72,59 +154,182 @@ function toStackPosition(pr: RestPullRequest): StackPosition { export async function getPullRequest( repo: string, pr: number, + exec: CommandRunner = runCommand, ): Promise { - const raw = await gh(["api", `repos/${repo}/pulls/${pr}`]); - const data = JSON.parse(raw) as RestPullRequest; - return { - number: data.number, - title: data.title, - branch: data.head.ref, - baseBranch: data.base.ref, - state: data.state, - stack: toStackPosition(data), + const [owner, name] = repo.split("/"); + if (!owner || !name) { + throw new GitHubError(`expected 'owner/repo', got '${repo}'`); + } + const raw = await gh(exec, [ + "api", + "graphql", + "-f", + `query=${PR_QUERY}`, + "-F", + `owner=${owner}`, + "-F", + `name=${name}`, + "-F", + `number=${pr}`, + ]); + const data = JSON.parse(raw) as { + data?: { repository?: { pullRequest?: GqlPullRequest | null } | null }; }; + const node = data.data?.repository?.pullRequest; + if (!node) throw new GitHubError(`pull request ${repo}#${pr} not found`); + return gqlToInfo(node); +} + +/** Every open pull request in the repository. */ +async function listOpen( + repo: string, + exec: CommandRunner, +): Promise { + const raw = await gh(exec, [ + "api", + "--paginate", + "--jq", + ".[]", + `repos/${repo}/pulls?state=open&per_page=100`, + ]); + return parseJsonLines(raw).map(restToInfo); +} + +/** + * Reconstruct a stack from the chain of base branches. + * + * A stack is linear: each layer's base branch is the layer below it's head + * branch. Walking that relation down to the trunk and back up recovers the same + * ordering GitHub would report, for stacks that were chained by hand. + * + * Ascent stops at a branch point: when two open pull requests target the same + * head, the topology is a tree rather than a stack and guessing a path would + * misreport it. + */ +function inferChain( + head: PullRequestInfo, + open: PullRequestInfo[], +): PullRequestInfo[] { + const byHead = new Map(); + const childrenOf = new Map(); + for (const pr of open) { + byHead.set(pr.branch, pr); + const siblings = childrenOf.get(pr.baseBranch) ?? []; + siblings.push(pr); + childrenOf.set(pr.baseBranch, siblings); + } + + const seen = new Set([head.number]); + const chain = [head]; + + let cursor = head; + while (chain.length < MAX_STACK) { + const parent = byHead.get(cursor.baseBranch); + if (!parent || seen.has(parent.number)) break; + seen.add(parent.number); + chain.unshift(parent); + cursor = parent; + } + + cursor = head; + while (chain.length < MAX_STACK) { + const children = childrenOf.get(cursor.branch) ?? []; + const child = children.length === 1 ? children[0] : undefined; + if (!child || seen.has(child.number)) break; + seen.add(child.number); + chain.push(child); + cursor = child; + } + + return chain; } /** * Return every pull request in the same stack as `pr`, ordered bottom-to-top. * - * Falls back to a single-element list when the pull request is not stacked, so + * Falls back to a single-element list when the pull request is standalone, so * callers can treat standalone pull requests as a stack of one. */ export async function getStack( repo: string, pr: number, + exec: CommandRunner = runCommand, ): Promise { - const head = await getPullRequest(repo, pr); - if (head.stack.number === null) return [head]; - - const raw = await gh([ + const [owner, name] = repo.split("/"); + if (!owner || !name) { + throw new GitHubError(`expected 'owner/repo', got '${repo}'`); + } + const raw = await gh(exec, [ "api", - "--paginate", - `repos/${repo}/pulls?state=open&per_page=100`, + "graphql", + "-f", + `query=${PR_QUERY}`, + "-F", + `owner=${owner}`, + "-F", + `name=${name}`, + "-F", + `number=${pr}`, ]); - const all = JSON.parse(raw) as RestPullRequest[]; - - const members = all - .filter((p) => p.stack?.number === head.stack.number) - .map((p) => ({ - number: p.number, - title: p.title, - branch: p.head.ref, - baseBranch: p.base.ref, - state: p.state, - stack: toStackPosition(p), - })); - - if (members.length === 0) return [head]; - - members.sort((a, b) => (a.stack.position ?? 0) - (b.stack.position ?? 0)); - return members; + const node = ( + JSON.parse(raw) as { + data?: { repository?: { pullRequest?: GqlPullRequest | null } | null }; + } + ).data?.repository?.pullRequest; + if (!node) throw new GitHubError(`pull request ${repo}#${pr} not found`); + + const head = gqlToInfo(node); + + // A GitHub-native stack carries every member with it, already positioned. + const entries = node.stackEntry?.stack?.entries?.nodes ?? []; + const members: PullRequestInfo[] = []; + for (const entry of entries) { + const member = entry?.pullRequest; + if (!member) continue; + members.push({ + ...gqlToInfo(member), + stack: { + number: node.stackEntry?.stack?.number ?? null, + position: entry.position ?? null, + size: node.stackEntry?.stack?.size ?? null, + base: node.stackEntry?.stack?.baseRefName ?? null, + source: "github", + }, + }); + } + + if (members.length > 0) { + members.sort((a, b) => { + const byPosition = + (a.stack.position ?? Number.MAX_SAFE_INTEGER) - + (b.stack.position ?? Number.MAX_SAFE_INTEGER); + return byPosition !== 0 ? byPosition : a.number - b.number; + }); + return members; + } + + // No native stack. Reconstruct from base branches. + const chain = inferChain(head, await listOpen(repo, exec)); + if (chain.length <= 1) return [head]; + + const bottom = chain[0]; + return chain.map((member, index) => ({ + ...member, + stack: { + number: null, + position: index + 1, + size: chain.length, + base: bottom?.baseBranch ?? null, + source: "inferred", + }, + })); } /** Resolve `owner/repo` for the current working directory. */ -export async function currentRepo(): Promise { - const raw = await gh([ +export async function currentRepo( + exec: CommandRunner = runCommand, +): Promise { + const raw = await gh(exec, [ "repo", "view", "--json", @@ -136,23 +341,27 @@ export async function currentRepo(): Promise { } /** Current git branch, used to infer which layer the user is on. */ -export async function currentBranch(): Promise { - const { stdout } = await execFileAsync("git", ["branch", "--show-current"]); - return stdout.trim(); +export async function currentBranch( + exec: CommandRunner = runCommand, +): Promise { + return (await exec("git", ["branch", "--show-current"])).trim(); } -/** Find the open pull request whose head is `branch`, if any. */ +/** + * Find the open pull request whose head is `branch`, if any. + * + * The `head` query filter is deliberately not used: it requires the head + * *owner*, which differs from the base repository owner for fork-based pull + * requests. Listing and matching on `head.ref` is correct in both cases and + * sidesteps encoding branch names that contain a slash. + */ export async function pullRequestForBranch( repo: string, branch: string, + exec: CommandRunner = runCommand, ): Promise { - const owner = repo.split("/")[0]; - const raw = await gh([ - "api", - `repos/${repo}/pulls?state=open&head=${owner}:${branch}`, - ]); - const list = JSON.parse(raw) as RestPullRequest[]; - return list[0]?.number ?? null; + const open = await listOpen(repo, exec); + return open.find((p) => p.branch === branch)?.number ?? null; } /** Post or update a single stacklore comment on a pull request. */ @@ -161,31 +370,25 @@ export async function upsertComment( pr: number, body: string, marker: string, + exec: CommandRunner = runCommand, ): Promise { - const raw = await gh(["api", `repos/${repo}/issues/${pr}/comments`]); - const comments = JSON.parse(raw) as { id: number; body: string }[]; + const raw = await gh(exec, [ + "api", + "--paginate", + "--jq", + ".[]", + `repos/${repo}/issues/${pr}/comments?per_page=100`, + ]); + const comments = parseJsonLines<{ id: number; body?: string }>(raw); const existing = comments.find((c) => c.body?.includes(marker)); - const payload = `${marker}\n${body}`; - - if (existing) { - await gh([ - "api", - "--method", - "PATCH", - `repos/${repo}/issues/comments/${existing.id}`, - "-f", - `body=${payload}`, - ]); - return; - } + // Sent on stdin rather than as `-f body=…`: comment bodies are unbounded + // markdown and command-line arguments are not. + const payload = JSON.stringify({ body: `${marker}\n${body}` }); - await gh([ - "api", - "--method", - "POST", - `repos/${repo}/issues/${pr}/comments`, - "-f", - `body=${payload}`, - ]); + const endpoint = existing + ? [`repos/${repo}/issues/comments/${existing.id}`, "--method", "PATCH"] + : [`repos/${repo}/issues/${pr}/comments`, "--method", "POST"]; + + await gh(exec, ["api", ...endpoint, "--input", "-"], { stdin: payload }); } diff --git a/src/store.ts b/src/store.ts index e55c429..c5da43f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -7,57 +7,106 @@ * separate private repository cloned into a local cache. */ -import { execFile } from "node:child_process"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { promisify } from "node:util"; -import { type LoreRecord, recordPath } from "./types.js"; - -const execFileAsync = promisify(execFile); +import { reasonFor, runCommand, type CommandRunner } from "./exec.ts"; +import { type LoreRecord, recordPath } from "./types.ts"; export interface StoreOptions { /** `owner/repo` of the private store repository. */ repo: string; /** Local cache directory. Defaults to `~/.stacklore/store`. */ cacheDir?: string; + /** Subprocess runner. Injected by tests; defaults to a real `spawn`. */ + exec?: CommandRunner; } export class StoreError extends Error {} +/** Equality ignoring `capturedAt`, which changes on every capture run. */ +function sameRecord(a: LoreRecord, b: LoreRecord): boolean { + return canonical(a) === canonical(b); +} + +/** + * Key-order-independent serialization with `capturedAt` removed. Stored records + * are reparsed from disk, so their key order is not guaranteed to match a + * freshly built record's. + */ +function canonical(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonical).join(",")}]`; + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([key]) => key !== "capturedAt") + .sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** + * Identity used only when the machine has no git identity configured. Applied + * per-command so it never writes to the user's git config. + */ +const FALLBACK_IDENTITY = [ + "-c", + "user.name=stacklore", + "-c", + "user.email=stacklore@users.noreply.github.com", +]; + export class LoreStore { readonly repo: string; readonly dir: string; + readonly #exec: CommandRunner; + #identity: string[] | null = null; constructor(options: StoreOptions) { this.repo = options.repo; this.dir = options.cacheDir ?? join(homedir(), ".stacklore", "store", options.repo); + this.#exec = options.exec ?? runCommand; } async #git(args: string[]): Promise { try { - const { stdout } = await execFileAsync("git", ["-C", this.dir, ...args], { - maxBuffer: 32 * 1024 * 1024, - }); - return stdout; + return await this.#exec("git", ["-C", this.dir, ...args]); } catch (error) { - const message = - error instanceof Error && "stderr" in error - ? String((error as { stderr?: unknown }).stderr ?? error.message) - : String(error); - throw new StoreError(`git ${args.join(" ")} failed: ${message.trim()}`); + throw new StoreError(`git ${args.join(" ")} failed: ${reasonFor(error)}`); } } + /** + * `-c user.email=…` overrides configured values, so the fallback identity is + * only ever supplied when the user genuinely has none. Resolved once. + */ + async #identityArgs(): Promise { + if (this.#identity !== null) return this.#identity; + try { + const email = await this.#exec("git", [ + "-C", + this.dir, + "config", + "user.email", + ]); + this.#identity = email.trim() ? [] : FALLBACK_IDENTITY; + } catch { + this.#identity = FALLBACK_IDENTITY; + } + return this.#identity; + } + /** Clone the store if absent, otherwise fast-forward it. */ async sync(): Promise { if (!existsSync(join(this.dir, ".git"))) { await mkdir(dirname(this.dir), { recursive: true }); try { - await execFileAsync("gh", [ + await this.#exec("gh", [ "repo", "clone", this.repo, @@ -66,43 +115,72 @@ export class LoreStore { "--quiet", ]); } catch (error) { - const message = - error instanceof Error && "stderr" in error - ? String((error as { stderr?: unknown }).stderr ?? error.message) - : String(error); throw new StoreError( - `Could not clone store '${this.repo}'. Run 'stacklore init' first. ${message.trim()}`, + `Could not clone store '${this.repo}'. Run 'stacklore init' first. ${reasonFor(error)}`, ); } return; } + await this.#git(["fetch", "--quiet", "origin"]); - await this.#git(["pull", "--quiet", "--ff-only"]).catch(() => { - // A diverged store is recoverable by the user; surface on write instead. - }); + + // A store whose only commits are local, or whose remote is still empty, has + // nothing to fast-forward onto; neither is an error. A genuinely diverged + // store is recoverable by the user and surfaces on push instead. + await this.#git(["pull", "--quiet", "--ff-only"]).catch(() => {}); } - /** Create the private store repository if it does not already exist. */ - static async create(repo: string): Promise { + /** + * Create the private store repository if it does not already exist. + * + * `--add-readme` is not optional: a repository with no commits has an unborn + * default branch, so the subsequent clone has no HEAD and the first push + * would publish whatever `init.defaultBranch` happens to be locally rather + * than the repository's own default. + */ + static async create( + repo: string, + exec: CommandRunner = runCommand, + ): Promise { try { - await execFileAsync("gh", ["repo", "view", repo]); - return; + await exec("gh", ["repo", "view", repo]); + return false; } catch { // Not found, continue to create. } - await execFileAsync("gh", [ - "repo", - "create", - repo, - "--private", - "--description", - "Private stacklore store: prompts and intent behind stacked pull requests.", - ]); + try { + await exec("gh", [ + "repo", + "create", + repo, + "--private", + "--add-readme", + "--description", + "Private stacklore store: prompts and intent behind stacked pull requests.", + ]); + } catch (error) { + throw new StoreError( + `Could not create store '${repo}': ${reasonFor(error)}`, + ); + } + return true; } + /** + * Write a record, unless an identical one is already stored. + * + * `capturedAt` moves on every run, so a naive write would dirty the tree and + * push a commit each time `save` is invoked even when nothing about the pull + * request changed. Comparing everything *except* the timestamp keeps `save` + * idempotent and the store's history meaningful. + */ async write(record: LoreRecord): Promise { const relative = recordPath(record.repo, record.pr); const absolute = join(this.dir, relative); + + const existing = await this.read(record.repo, record.pr); + if (existing && sameRecord(existing, record)) return relative; + await mkdir(dirname(absolute), { recursive: true }); await writeFile(absolute, `${JSON.stringify(record, null, 2)}\n`, "utf8"); return relative; @@ -133,9 +211,12 @@ export class LoreStore { async commit(message: string): Promise { const status = await this.#git(["status", "--porcelain"]); if (!status.trim()) return false; + const identity = await this.#identityArgs(); await this.#git(["add", "-A"]); - await this.#git(["commit", "--quiet", "-m", message]); - await this.#git(["push", "--quiet", "origin", "HEAD"]); + await this.#git([...identity, "commit", "--quiet", "-m", message]); + // `-u` so a freshly cloned store tracks its remote branch from the first + // push onwards, rather than relying on push.default to guess. + await this.#git(["push", "--quiet", "-u", "origin", "HEAD"]); return true; } diff --git a/src/types.ts b/src/types.ts index 4bc2fd3..812962b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,7 @@ export const SCHEMA_VERSION = 1 as const; /** A pull request's position within a GitHub stack. */ export interface StackPosition { - /** Stack identifier, when the PR belongs to a stack. */ + /** Stack identifier, when the PR belongs to a GitHub-native stack. */ number: number | null; /** 1-based position from the bottom of the stack. */ position: number | null; @@ -19,6 +19,13 @@ export interface StackPosition { size: number | null; /** The stack's trunk branch, e.g. `main`. */ base: string | null; + /** + * How the topology was determined. `github` means GitHub reported a native + * stack; `inferred` means it was reconstructed from the chain of base + * branches. Absent on standalone pull requests and on records written before + * this field existed. + */ + source?: "github" | "inferred"; } /** A single captured prompt/response exchange. */ diff --git a/test/capture.test.ts b/test/capture.test.ts new file mode 100644 index 0000000..5d23560 --- /dev/null +++ b/test/capture.test.ts @@ -0,0 +1,403 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { runner } from "./helpers.ts"; +import { captureLayer, captureStack } from "../src/capture.ts"; +import type { + CaptureAdapter, + CaptureQuery, + CaptureResult, +} from "../src/adapters/index.ts"; +import type { PullRequestInfo } from "../src/stack.ts"; +import { SCHEMA_VERSION } from "../src/types.ts"; + +/** + * Adapter double. Captures are keyed by branch so a stack can be described in + * one literal, and every query is recorded to assert on probing behaviour. + */ +function adapterFor( + byBranch: Record, + options: { available?: boolean; name?: string } = {}, +): { adapter: CaptureAdapter; queries: CaptureQuery[]; probes: number } { + const queries: CaptureQuery[] = []; + const state = { probes: 0 }; + const adapter: CaptureAdapter = { + name: options.name ?? "fake", + async isAvailable() { + state.probes += 1; + return options.available ?? true; + }, + async capture(query) { + queries.push(query); + return byBranch[query.branch] ?? []; + }, + }; + return { + adapter, + queries, + get probes() { + return state.probes; + }, + }; +} + +function capture( + branch: string, + exchanges: { turn: number; at: string; prompt: string; response?: string }[], + extra: Partial = {}, +): CaptureResult { + return { + tool: "fake-tool", + adapter: "fake", + sessionId: `session-${branch}`, + branch, + summary: null, + exchanges, + files: [], + ...extra, + }; +} + +function pr( + number: number, + branch: string, + stack: Partial = {}, +): PullRequestInfo { + return { + number, + title: `PR ${number}`, + branch, + baseBranch: "main", + state: "open", + stack: { number: null, position: null, size: null, base: null, ...stack }, + }; +} + +function restPr(number: number, branch: string, base: string) { + return JSON.stringify({ + number, + title: `PR ${number}`, + state: "open", + head: { ref: branch }, + base: { ref: base }, + }); +} + +function noStack(number: number, branch: string, base: string) { + return JSON.stringify({ + data: { + repository: { + pullRequest: { + number, + title: `PR ${number}`, + headRefName: branch, + baseRefName: base, + state: "OPEN", + stackEntry: null, + }, + }, + }, + }); +} + +test("a branch with no recorded session yields no record at all", async () => { + // An empty record would publish a pull request comment promising context + // that does not exist, which is worse than staying silent. + const { adapter } = adapterFor({}); + + const record = await captureLayer(pr(1, "layer-1"), { + repo: "acme/widgets", + adapter, + }); + + assert.equal(record, null); +}); + +test("a session with no exchanges still yields no record", async () => { + const { adapter } = adapterFor({ "layer-1": [] }); + + assert.equal( + await captureLayer(pr(1, "layer-1"), { repo: "acme/widgets", adapter }), + null, + ); +}); + +test("an unavailable adapter yields no record rather than throwing", async () => { + const probe = adapterFor({}, { available: false }); + + const record = await captureLayer(pr(1, "layer-1"), { + repo: "acme/widgets", + adapters: [probe.adapter], + }); + + assert.equal(record, null); + assert.equal(probe.probes, 1); +}); + +test("secrets are redacted downstream of the adapter", async () => { + // Adapters deliberately return raw data; redaction is the pipeline's job so + // no adapter can forget to do it. + const { adapter } = adapterFor({ + "layer-1": [ + capture("layer-1", [ + { + turn: 0, + at: "2024-01-01T00:00:00.000Z", + prompt: "use ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 to push", + }, + ]), + ], + }); + + const record = await captureLayer(pr(1, "layer-1"), { + repo: "acme/widgets", + adapter, + }); + + assert.ok(!record?.exchanges[0]?.prompt.includes("ghp_ABCDEFGHIJ")); + assert.ok((record?.redactions.count ?? 0) > 0); + assert.ok(record?.redactions.rules.length); +}); + +test("responses are redacted too, when captured", async () => { + const { adapter } = adapterFor({ + "layer-1": [ + capture("layer-1", [ + { + turn: 0, + at: "2024-01-01T00:00:00.000Z", + prompt: "deploy it", + response: "I used ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + }, + ]), + ], + }); + + const record = await captureLayer(pr(1, "layer-1"), { + repo: "acme/widgets", + adapter, + includeResponses: true, + }); + + assert.ok(!record?.exchanges[0]?.response?.includes("ghp_ABCDEFGHIJ")); +}); + +test("file paths are deduped, sorted and redacted", async () => { + const { adapter } = adapterFor({ + "layer-1": [ + capture( + "layer-1", + [{ turn: 0, at: "2024-01-01T00:00:00.000Z", prompt: "work" }], + { files: ["src/b.ts", "src/a.ts", "src/b.ts"] }, + ), + capture( + "layer-1", + [{ turn: 1, at: "2024-01-01T01:00:00.000Z", prompt: "more" }], + { sessionId: "second", files: ["src/a.ts", "src/c.ts"] }, + ), + ], + }); + + const record = await captureLayer(pr(1, "layer-1"), { + repo: "acme/widgets", + adapter, + }); + + assert.deepEqual(record?.files, ["src/a.ts", "src/b.ts", "src/c.ts"]); +}); + +test("exchanges from several sessions merge in chronological order", async () => { + // One branch is often worked across several sessions; the transcript has to + // read in the order the work actually happened. + const { adapter } = adapterFor({ + "layer-1": [ + capture("layer-1", [ + { turn: 2, at: "2024-01-02T00:00:00.000Z", prompt: "third" }, + { turn: 0, at: "2024-01-01T00:00:00.000Z", prompt: "first" }, + ]), + capture( + "layer-1", + [{ turn: 1, at: "2024-01-01T12:00:00.000Z", prompt: "second" }], + { sessionId: "other" }, + ), + ], + }); + + const record = await captureLayer(pr(1, "layer-1"), { + repo: "acme/widgets", + adapter, + }); + + assert.deepEqual( + record?.exchanges.map((exchange) => exchange.prompt), + ["first", "second", "third"], + ); +}); + +test("records are keyed by pull request, not by commit", async () => { + const { adapter } = adapterFor({ + "layer-1": [ + capture("layer-1", [ + { turn: 0, at: "2024-01-01T00:00:00.000Z", prompt: "add the widget" }, + ]), + ], + }); + + const record = await captureLayer( + pr(42, "layer-1", { number: 7, position: 1, size: 3, base: "main" }), + { repo: "acme/widgets", adapter }, + ); + + assert.equal(record?.pr, 42); + assert.equal(record?.repo, "acme/widgets"); + assert.equal(record?.schemaVersion, SCHEMA_VERSION); + assert.equal(record?.stack.position, 1); + assert.ok(!Object.keys(record ?? {}).some((key) => /sha|commit/i.test(key))); +}); + +test("intent is distilled from the prompts", async () => { + const { adapter } = adapterFor({ + "layer-1": [ + capture("layer-1", [ + { turn: 0, at: "2024-01-01T00:00:00.000Z", prompt: "thanks!" }, + { + turn: 1, + at: "2024-01-01T01:00:00.000Z", + prompt: "Extract the rate limiter so retries are testable", + }, + ]), + ], + }); + + const record = await captureLayer(pr(1, "layer-1"), { + repo: "acme/widgets", + adapter, + }); + + assert.match(record?.intent ?? "", /rate limiter/i); +}); + +test("the adapter is told which repository it is capturing for", async () => { + // Branch names collide across repositories, so the repo has to be passed + // through for the adapter to disambiguate. + const { adapter, queries } = adapterFor({}); + + await captureLayer(pr(1, "layer-1"), { repo: "acme/widgets", adapter }); + + assert.equal(queries[0]?.repo, "acme/widgets"); + assert.equal(queries[0]?.branch, "layer-1"); + assert.equal(queries[0]?.includeResponses, false); +}); + +test("a standalone pull request is captured as a stack of one", async () => { + const { exec } = runner([ + ["api graphql", noStack(9, "solo", "main")], + ["repos/acme/widgets/pulls", restPr(9, "solo", "main")], + ]); + const { adapter } = adapterFor({ + solo: [ + capture("solo", [ + { turn: 0, at: "2024-01-01T00:00:00.000Z", prompt: "fix the typo" }, + ]), + ], + }); + + const stack = await captureStack("acme/widgets", 9, { adapter, exec }); + + assert.equal(stack.layers.length, 1); + assert.equal(stack.layers[0]?.pr, 9); + assert.equal(stack.stackNumber, null); + assert.equal(stack.base, "main"); +}); + +test("stack layers are captured bottom-to-top", async () => { + const { exec } = runner([ + ["api graphql", noStack(2, "layer-2", "layer-1")], + [ + "repos/acme/widgets/pulls", + [ + restPr(1, "layer-1", "main"), + restPr(2, "layer-2", "layer-1"), + restPr(3, "layer-3", "layer-2"), + ].join("\n"), + ], + ]); + const { adapter } = adapterFor({ + "layer-1": [ + capture("layer-1", [ + { turn: 0, at: "2024-01-01T00:00:00.000Z", prompt: "bottom" }, + ]), + ], + "layer-2": [ + capture("layer-2", [ + { turn: 0, at: "2024-01-02T00:00:00.000Z", prompt: "middle" }, + ]), + ], + "layer-3": [ + capture("layer-3", [ + { turn: 0, at: "2024-01-03T00:00:00.000Z", prompt: "top" }, + ]), + ], + }); + + const stack = await captureStack("acme/widgets", 2, { adapter, exec }); + + assert.deepEqual( + stack.layers.map((layer) => layer.pr), + [1, 2, 3], + ); + assert.deepEqual( + stack.layers.map((layer) => layer.stack.position), + [1, 2, 3], + ); +}); + +test("layers without sessions are skipped, not emitted empty", async () => { + const { exec } = runner([ + ["api graphql", noStack(2, "layer-2", "layer-1")], + [ + "repos/acme/widgets/pulls", + [restPr(1, "layer-1", "main"), restPr(2, "layer-2", "layer-1")].join("\n"), + ], + ]); + const { adapter } = adapterFor({ + "layer-2": [ + capture("layer-2", [ + { turn: 0, at: "2024-01-02T00:00:00.000Z", prompt: "only this one" }, + ]), + ], + }); + + const stack = await captureStack("acme/widgets", 2, { adapter, exec }); + + assert.deepEqual( + stack.layers.map((layer) => layer.pr), + [2], + ); +}); + +test("no adapter means no capture, and no repeated probing per layer", async () => { + // `??` does not short-circuit on an explicit null, so a missing adapter used + // to be re-detected once for every layer in the stack. + const { exec } = runner([ + ["api graphql", noStack(2, "layer-2", "layer-1")], + [ + "repos/acme/widgets/pulls", + [ + restPr(1, "layer-1", "main"), + restPr(2, "layer-2", "layer-1"), + restPr(3, "layer-3", "layer-2"), + ].join("\n"), + ], + ]); + const probe = adapterFor({}, { available: false }); + + const stack = await captureStack("acme/widgets", 2, { + adapters: [probe.adapter], + exec, + }); + + assert.deepEqual(stack.layers, []); + // Three layers, but detection must happen exactly once. + assert.equal(probe.probes, 1); +}); diff --git a/test/extension.test.ts b/test/extension.test.ts new file mode 100644 index 0000000..fea11b1 --- /dev/null +++ b/test/extension.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtemp, readFile, stat, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + EXTENSION_NAME, + extensionDir, + extensionInstalled, + installExtension, + shimScript, + uninstallExtension, +} from "../src/extension.ts"; + +async function fakeHome(): Promise { + return { XDG_DATA_HOME: await mkdtemp(join(tmpdir(), "stacklore-gh-")) }; +} + +test("the extension lives where gh looks for it", async () => { + const env = { XDG_DATA_HOME: "/data" }; + + assert.equal(extensionDir(env), "/data/gh/extensions/gh-stacklore"); + // gh only recognises extensions whose directory and executable start with + // `gh-`; the subcommand is whatever follows. + assert.ok(EXTENSION_NAME.startsWith("gh-")); +}); + +test("an empty XDG_DATA_HOME falls back to the default location", () => { + assert.match( + extensionDir({ XDG_DATA_HOME: "" }), + /\.local[/\\]share[/\\]gh[/\\]extensions[/\\]gh-stacklore$/, + ); +}); + +test("install writes an executable shim", async () => { + const env = await fakeHome(); + + const result = await installExtension({ env, node: "/n/node", cli: "/c.js" }); + + assert.equal(result.changed, true); + const mode = (await stat(result.path)).mode & 0o777; + // gh execs the file directly, so it has to carry the executable bit. + assert.equal(mode & 0o111, 0o111); + const body = await readFile(result.path, "utf8"); + assert.match(body, /^#!\/usr\/bin\/env bash/); + assert.ok(body.includes('"/n/node" "/c.js" "$@"')); +}); + +test("reinstalling an unchanged shim reports no change", async () => { + const env = await fakeHome(); + const options = { env, node: "/n/node", cli: "/c.js" }; + + await installExtension(options); + const second = await installExtension(options); + + assert.equal(second.changed, false); +}); + +test("a moved node or cli path refreshes the shim", async () => { + const env = await fakeHome(); + await installExtension({ env, node: "/old/node", cli: "/c.js" }); + + const result = await installExtension({ env, node: "/new/node", cli: "/c.js" }); + + assert.equal(result.changed, true); + assert.ok((await readFile(result.path, "utf8")).includes("/new/node")); +}); + +test("paths containing spaces stay a single argument", async () => { + // Homebrew-free macOS installs land under '/Users//Library/Application + // Support/...', so unquoted paths would split mid-command. + const script = shimScript("/usr/local/n o d e", "/My Files/cli.js"); + + assert.ok(script.includes('"/usr/local/n o d e" "/My Files/cli.js" "$@"')); +}); + +test("arguments are forwarded verbatim to the cli", () => { + // `"$@"` rather than `$*`, so 'gh stacklore save --pr 3' keeps its words. + assert.ok(shimScript("/node", "/cli.js").trimEnd().endsWith('"$@"')); +}); + +test("install reports the extension as present, uninstall removes it", async () => { + const env = await fakeHome(); + assert.equal(extensionInstalled(env), false); + + await installExtension({ env, node: "/n", cli: "/c.js" }); + assert.equal(extensionInstalled(env), true); + + assert.equal(await uninstallExtension({ env }), true); + assert.equal(extensionInstalled(env), false); + assert.equal(existsSync(extensionDir(env)), false); +}); + +test("uninstalling when nothing is installed is not an error", async () => { + const env = await fakeHome(); + + assert.equal(await uninstallExtension({ env }), false); +}); + +test("install creates gh's extension directory if it is missing", async () => { + const env = await fakeHome(); + + const result = await installExtension({ env, node: "/n", cli: "/c.js" }); + + assert.equal(result.path, join(extensionDir(env), EXTENSION_NAME)); + assert.ok(existsSync(result.path)); +}); + +test("an unreadable existing shim is replaced rather than compared", async () => { + const env = await fakeHome(); + const dir = extensionDir(env); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, EXTENSION_NAME), "stale", "utf8"); + + const result = await installExtension({ env, node: "/n", cli: "/c.js" }); + + assert.equal(result.changed, true); + assert.ok((await readFile(result.path, "utf8")).includes('"/n" "/c.js"')); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..bf4074c --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,46 @@ +/** + * Shared test doubles. + * + * Every module that touches the outside world takes a `CommandRunner`, so the + * whole `gh`/`git` surface can be faked without a network or a real clone. + */ + +import type { CommandRunner } from "../src/exec.ts"; + +export interface Call { + file: string; + args: string[]; + stdin?: string; +} + +/** + * Fake subprocess runner. Responses are matched as substrings of the joined + * argument list, so a test only has to describe the calls it cares about. An + * unrouted command throws rather than silently returning empty output, which + * is what catches a test that forgot to inject this runner. + */ +export function runner( + routes: [match: string, response: string | (() => string)][], +): { exec: CommandRunner; calls: Call[] } { + const calls: Call[] = []; + const exec: CommandRunner = async (file, args, options) => { + calls.push({ + file, + args, + ...(options?.stdin ? { stdin: options.stdin } : {}), + }); + const key = `${file} ${args.join(" ")}`; + for (const [match, response] of routes) { + if (key.includes(match)) { + return typeof response === "function" ? response() : response; + } + } + throw new Error(`unrouted command: ${key}`); + }; + return { exec, calls }; +} + +/** Joined argument lists, for asserting on what actually ran. */ +export function commandLines(calls: Call[]): string[] { + return calls.map((call) => `${call.file} ${call.args.join(" ")}`); +} diff --git a/test/stack.test.ts b/test/stack.test.ts new file mode 100644 index 0000000..ba7b112 --- /dev/null +++ b/test/stack.test.ts @@ -0,0 +1,331 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { CommandRunner } from "../src/exec.ts"; +import { runner } from "./helpers.ts"; +import { + GitHubError, + currentBranch, + currentRepo, + getPullRequest, + getStack, + pullRequestForBranch, + upsertComment, +} from "../src/stack.ts"; + +function gqlPr( + number: number, + branch: string, + base: string, + extra: Record = {}, +) { + return { + number, + title: `PR ${number}`, + headRefName: branch, + baseRefName: base, + state: "OPEN", + ...extra, + }; +} + +function graphql(pullRequest: unknown): string { + return JSON.stringify({ data: { repository: { pullRequest } } }); +} + +function restPr(number: number, branch: string, base: string) { + return JSON.stringify({ + number, + title: `PR ${number}`, + state: "open", + head: { ref: branch }, + base: { ref: base }, + }); +} + +test("stack topology is read over GraphQL, which is the only API that exposes it", async () => { + const { exec, calls } = runner([ + ["api graphql", graphql(gqlPr(7, "feature", "main", { stackEntry: null }))], + ]); + + const pr = await getPullRequest("acme/widgets", 7, exec); + + assert.equal(pr.number, 7); + assert.equal(pr.branch, "feature"); + // GraphQL reports OPEN; the REST casing is the contract. + assert.equal(pr.state, "open"); + assert.deepEqual(calls[0]?.args.slice(0, 2), ["api", "graphql"]); +}); + +test("a pull request with no stack reports an empty position", async () => { + const { exec } = runner([ + ["api graphql", graphql(gqlPr(7, "feature", "main", { stackEntry: null }))], + ]); + + const pr = await getPullRequest("acme/widgets", 7, exec); + + assert.deepEqual(pr.stack, { + number: null, + position: null, + size: null, + base: null, + }); +}); + +test("a standalone pull request is treated as a stack of one", async () => { + const { exec } = runner([ + ["api graphql", graphql(gqlPr(7, "feature", "main", { stackEntry: null }))], + // The only other open pull request is unrelated: different base, and it is + // not a child of `feature`. + ["repos/acme/widgets/pulls", [restPr(7, "feature", "main"), restPr(9, "other", "main")].join("\n")], + ]); + + const stack = await getStack("acme/widgets", 7, exec); + + assert.equal(stack.length, 1); + assert.equal(stack[0]?.number, 7); + assert.equal(stack[0]?.stack.position, null); + assert.equal(stack[0]?.stack.size, null); +}); + +test("a GitHub-native stack is ordered bottom-to-top by position", async () => { + const entries = [ + { position: 3, pullRequest: gqlPr(43, "layer-3", "layer-2") }, + { position: 1, pullRequest: gqlPr(41, "layer-1", "main") }, + { position: 2, pullRequest: gqlPr(42, "layer-2", "layer-1") }, + ]; + const { exec } = runner([ + [ + "api graphql", + graphql( + gqlPr(42, "layer-2", "layer-1", { + stackEntry: { + position: 2, + stack: { + number: 5, + size: 3, + baseRefName: "main", + entries: { nodes: entries }, + }, + }, + }), + ), + ], + ]); + + const stack = await getStack("acme/widgets", 42, exec); + + assert.deepEqual( + stack.map((p) => p.number), + [41, 42, 43], + ); + assert.deepEqual( + stack.map((p) => p.stack.position), + [1, 2, 3], + ); + assert.equal(stack[0]?.stack.number, 5); + assert.equal(stack[0]?.stack.base, "main"); + assert.equal(stack[0]?.stack.source, "github"); +}); + +test("a hand-chained stack is inferred from base branches, bottom-to-top", async () => { + const { exec } = runner([ + ["api graphql", graphql(gqlPr(42, "layer-2", "layer-1", { stackEntry: null }))], + [ + "repos/acme/widgets/pulls", + [ + restPr(43, "layer-3", "layer-2"), + restPr(41, "layer-1", "main"), + restPr(42, "layer-2", "layer-1"), + ].join("\n"), + ], + ]); + + const stack = await getStack("acme/widgets", 42, exec); + + assert.deepEqual( + stack.map((p) => p.number), + [41, 42, 43], + ); + assert.deepEqual( + stack.map((p) => p.stack.position), + [1, 2, 3], + ); + assert.equal(stack[0]?.stack.size, 3); + assert.equal(stack[0]?.stack.base, "main"); + assert.equal(stack[0]?.stack.source, "inferred"); + // There is no GitHub stack id to report for an inferred chain. + assert.equal(stack[0]?.stack.number, null); +}); + +test("inference stops at a branch point rather than guessing a path", async () => { + const { exec } = runner([ + ["api graphql", graphql(gqlPr(41, "layer-1", "main", { stackEntry: null }))], + [ + "repos/acme/widgets/pulls", + [ + restPr(41, "layer-1", "main"), + restPr(42, "fork-a", "layer-1"), + restPr(43, "fork-b", "layer-1"), + ].join("\n"), + ], + ]); + + const stack = await getStack("acme/widgets", 41, exec); + + assert.equal(stack.length, 1); + assert.equal(stack[0]?.number, 41); +}); + +test("inference terminates on a cycle", async () => { + const { exec } = runner([ + ["api graphql", graphql(gqlPr(1, "a", "b", { stackEntry: null }))], + [ + "repos/acme/widgets/pulls", + [restPr(1, "a", "b"), restPr(2, "b", "a")].join("\n"), + ], + ]); + + const stack = await getStack("acme/widgets", 1, exec); + + assert.ok(stack.length <= 2); +}); + +test("paginated responses parse as JSON lines, not concatenated arrays", async () => { + // `gh api --paginate` emits one array per page; `--jq '.[]'` flattens that to + // one object per line. Parsing the raw concatenation would throw here. + const pages = [ + restPr(1, "a", "main"), + restPr(2, "b", "main"), + "", + restPr(3, "target", "main"), + ].join("\n"); + const { exec, calls } = runner([["repos/acme/widgets/pulls", pages]]); + + assert.equal(await pullRequestForBranch("acme/widgets", "target", exec), 3); + assert.ok(calls[0]?.args.includes("--paginate")); + assert.ok(calls[0]?.args.includes("--jq")); +}); + +test("branch lookup matches head refs directly, so forks and slashes work", async () => { + const { exec, calls } = runner([ + [ + "repos/acme/widgets/pulls", + [restPr(1, "main", "main"), restPr(8, "user/fix/thing", "main")].join("\n"), + ], + ]); + + assert.equal( + await pullRequestForBranch("acme/widgets", "user/fix/thing", exec), + 8, + ); + // A `head=owner:branch` filter would be wrong for fork-based pull requests. + assert.ok(!calls[0]?.args.join(" ").includes("head=")); +}); + +test("an unknown branch yields null rather than throwing", async () => { + const { exec } = runner([ + ["repos/acme/widgets/pulls", restPr(1, "main", "main")], + ]); + assert.equal(await pullRequestForBranch("acme/widgets", "absent", exec), null); +}); + +test("a malformed repository is rejected before any network call", async () => { + const { exec, calls } = runner([]); + await assert.rejects( + () => getPullRequest("not-a-repo", 1, exec), + (error: unknown) => error instanceof GitHubError, + ); + assert.equal(calls.length, 0); +}); + +test("a missing pull request is reported, not silently empty", async () => { + const { exec } = runner([["api graphql", graphql(null)]]); + await assert.rejects( + () => getPullRequest("acme/widgets", 404, exec), + /not found/, + ); +}); + +test("a new comment is created with the marker, via stdin", async () => { + const { exec, calls } = runner([ + ["issues/5/comments?per_page", ""], + ["--method POST", "{}"], + ]); + + await upsertComment("acme/widgets", 5, "line one\nline two", "", exec); + + const post = calls.find((c) => c.args.includes("POST")); + assert.ok(post, "expected a POST"); + // The body travels on stdin: comment bodies are unbounded, argv is not. + assert.ok(post?.args.includes("--input")); + assert.deepEqual(JSON.parse(post?.stdin ?? "{}"), { + body: "\nline one\nline two", + }); +}); + +test("an existing stacklore comment is updated rather than duplicated", async () => { + const { exec, calls } = runner([ + [ + "issues/5/comments?per_page", + [ + JSON.stringify({ id: 1, body: "unrelated chatter" }), + JSON.stringify({ id: 2, body: "\nold lore" }), + ].join("\n"), + ], + ["--method PATCH", "{}"], + ]); + + await upsertComment("acme/widgets", 5, "new lore", "", exec); + + const patch = calls.find((c) => c.args.includes("PATCH")); + assert.ok(patch, "expected a PATCH"); + assert.ok(patch?.args.some((a) => a.includes("issues/comments/2"))); + assert.equal( + calls.filter((c) => c.args.includes("POST")).length, + 0, + "must not also post a duplicate", + ); +}); + +test("comment bodies survive markdown that would break shell quoting", async () => { + const body = [ + "| a | b |", + "|---|---|", + "| `x=1` | $(whoami) |", + '"quoted" and \\backslash\\ and 🪶', + ].join("\n"); + const { exec, calls } = runner([ + ["issues/5/comments?per_page", ""], + ["--method POST", "{}"], + ]); + + await upsertComment("acme/widgets", 5, body, "", exec); + + const post = calls.find((c) => c.args.includes("POST")); + assert.equal( + (JSON.parse(post?.stdin ?? "{}") as { body: string }).body, + `\n${body}`, + ); +}); + +test("repository and branch are read from the working directory", async () => { + const { exec } = runner([ + ["repo view", "acme/widgets\n"], + ["branch --show-current", "feature\n"], + ]); + + assert.equal(await currentRepo(exec), "acme/widgets"); + assert.equal(await currentBranch(exec), "feature"); +}); + +test("a failing gh invocation surfaces as GitHubError", async () => { + const exec: CommandRunner = async () => { + throw new Error("gh: not authenticated"); + }; + await assert.rejects( + () => getPullRequest("acme/widgets", 1, exec), + (error: unknown) => + error instanceof GitHubError && /not authenticated/.test(error.message), + ); +}); diff --git a/test/store.test.ts b/test/store.test.ts new file mode 100644 index 0000000..a35e52b --- /dev/null +++ b/test/store.test.ts @@ -0,0 +1,376 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { commandLines, runner } from "./helpers.ts"; +import { LoreStore, StoreError } from "../src/store.ts"; +import { SCHEMA_VERSION, type LoreRecord } from "../src/types.ts"; + +async function scratch(): Promise { + return mkdtemp(join(tmpdir(), "stacklore-store-")); +} + +/** A cache directory that already looks like a clone. */ +async function cloned(): Promise { + const dir = await scratch(); + await mkdir(join(dir, ".git"), { recursive: true }); + return dir; +} + +function record(pr: number, overrides: Partial = {}): LoreRecord { + return { + schemaVersion: SCHEMA_VERSION, + repo: "acme/widgets", + pr, + branch: `layer-${pr}`, + title: `Layer ${pr}`, + stack: { number: 7, position: pr, size: 3, base: "main" }, + capturedAt: "2024-01-01T00:00:00.000Z", + source: { tool: "copilot", adapter: "copilot", sessionId: "s1" }, + intent: `why layer ${pr} exists`, + exchanges: [{ turn: 0, at: "2024-01-01T00:00:00.000Z", prompt: "do it" }], + files: ["src/a.ts"], + redactions: { count: 0, rules: [] }, + ...overrides, + }; +} + +test("sync clones the store when the cache is empty", async () => { + const dir = join(await scratch(), "nested", "acme", "lore"); + const { exec, calls } = runner([["repo clone", ""]]); + + await new LoreStore({ repo: "acme/lore", cacheDir: dir, exec }).sync(); + + assert.deepEqual(calls[0]?.args, [ + "repo", + "clone", + "acme/lore", + dir, + "--", + "--quiet", + ]); + // The parent of a nested cache path must be created before gh can clone. + assert.ok(existsSync(join(dir, ".."))); +}); + +test("sync fast-forwards an existing clone instead of recloning", async () => { + const dir = await cloned(); + const { exec, calls } = runner([ + ["fetch", ""], + ["pull", ""], + ]); + + await new LoreStore({ repo: "acme/lore", cacheDir: dir, exec }).sync(); + + const lines = commandLines(calls); + assert.ok(!lines.some((line) => line.includes("repo clone"))); + assert.ok(lines.some((line) => line.includes("fetch --quiet origin"))); + assert.ok(lines.some((line) => line.includes("pull --quiet --ff-only"))); +}); + +test("sync tolerates a store with nothing to fast-forward onto", async () => { + // A freshly created store whose remote branch does not exist yet makes + // `pull --ff-only` fail; that is not an error worth aborting a capture for. + const dir = await cloned(); + const { exec } = runner([ + ["fetch", ""], + [ + "pull", + () => { + throw new Error("no such ref"); + }, + ], + ]); + + await new LoreStore({ repo: "acme/lore", cacheDir: dir, exec }).sync(); +}); + +test("a failed clone points the user at init", async () => { + const dir = join(await scratch(), "missing"); + const { exec } = runner([ + [ + "repo clone", + () => { + throw new Error("Could not resolve to a Repository"); + }, + ], + ]); + + await assert.rejects( + new LoreStore({ repo: "acme/lore", cacheDir: dir, exec }).sync(), + (error: unknown) => + error instanceof StoreError && /stacklore init/.test(error.message), + ); +}); + +test("create is a no-op when the store repository already exists", async () => { + const { exec, calls } = runner([["repo view", "acme/lore"]]); + + const created = await LoreStore.create("acme/lore", exec); + + assert.equal(created, false); + assert.equal(calls.length, 1); + assert.ok(!commandLines(calls).some((line) => line.includes("repo create"))); +}); + +test("create makes a private repository with an initial commit", async () => { + const { exec, calls } = runner([ + [ + "repo view", + () => { + throw new Error("Not Found"); + }, + ], + ["repo create", ""], + ]); + + const created = await LoreStore.create("acme/lore", exec); + + assert.equal(created, true); + const args = calls[1]?.args ?? []; + assert.ok(args.includes("--private")); + // Without an initial commit the default branch is unborn, so the first push + // would publish the local init.defaultBranch instead of the repo's own. + assert.ok(args.includes("--add-readme")); +}); + +test("a failed create surfaces as StoreError", async () => { + const { exec } = runner([ + [ + "repo view", + () => { + throw new Error("Not Found"); + }, + ], + [ + "repo create", + () => { + throw new Error("name already exists on this account"); + }, + ], + ]); + + await assert.rejects( + LoreStore.create("acme/lore", exec), + (error: unknown) => error instanceof StoreError, + ); +}); + +test("records round-trip through a pull-request-keyed path", async () => { + const dir = await cloned(); + const { exec } = runner([]); + const store = new LoreStore({ repo: "acme/lore", cacheDir: dir, exec }); + + const relative = await store.write(record(12)); + + assert.equal(relative, "records/acme/widgets/pr-12.json"); + const read = await store.read("acme/widgets", 12); + assert.equal(read?.intent, "why layer 12 exists"); +}); + +test("reading an absent record yields null", async () => { + const dir = await cloned(); + const store = new LoreStore({ + repo: "acme/lore", + cacheDir: dir, + exec: runner([]).exec, + }); + + assert.equal(await store.read("acme/widgets", 404), null); +}); + +test("a record identical but for capturedAt is not rewritten", async () => { + // capturedAt moves every run, so rewriting on timestamp alone would push an + // empty commit on every save. + const dir = await cloned(); + const store = new LoreStore({ + repo: "acme/lore", + cacheDir: dir, + exec: runner([]).exec, + }); + + await store.write(record(3)); + await store.write(record(3, { capturedAt: "2025-06-01T12:00:00.000Z" })); + + const read = await store.read("acme/widgets", 3); + assert.equal(read?.capturedAt, "2024-01-01T00:00:00.000Z"); +}); + +test("a record that changed in substance is rewritten", async () => { + const dir = await cloned(); + const store = new LoreStore({ + repo: "acme/lore", + cacheDir: dir, + exec: runner([]).exec, + }); + + await store.write(record(3)); + await store.write( + record(3, { capturedAt: "2025-06-01T12:00:00.000Z", intent: "new intent" }), + ); + + const read = await store.read("acme/widgets", 3); + assert.equal(read?.intent, "new intent"); + assert.equal(read?.capturedAt, "2025-06-01T12:00:00.000Z"); +}); + +test("key order on disk does not defeat the identical-record check", async () => { + // Stored records are reparsed from disk, so their key order need not match a + // freshly built record's; comparison has to be order-independent. + const dir = await cloned(); + const store = new LoreStore({ + repo: "acme/lore", + cacheDir: dir, + exec: runner([]).exec, + }); + const original = record(5); + const reordered = Object.fromEntries( + Object.entries(original).reverse(), + ) as unknown as LoreRecord; + + await store.write(reordered); + const before = await readFile( + join(dir, "records/acme/widgets/pr-5.json"), + "utf8", + ); + await store.write({ ...original, capturedAt: "2030-01-01T00:00:00.000Z" }); + + assert.equal( + await readFile(join(dir, "records/acme/widgets/pr-5.json"), "utf8"), + before, + ); +}); + +test("list is sorted ascending by pull request number", async () => { + const dir = await cloned(); + const store = new LoreStore({ + repo: "acme/lore", + cacheDir: dir, + exec: runner([]).exec, + }); + for (const pr of [30, 4, 11]) await store.write(record(pr)); + await writeFile(join(dir, "records/acme/widgets/notes.txt"), "ignore", "utf8"); + + const listed = await store.list("acme/widgets"); + + assert.deepEqual( + listed.map((item) => item.pr), + [4, 11, 30], + ); +}); + +test("listing an unknown repository yields an empty list", async () => { + const dir = await cloned(); + const store = new LoreStore({ + repo: "acme/lore", + cacheDir: dir, + exec: runner([]).exec, + }); + + assert.deepEqual(await store.list("acme/nothing"), []); +}); + +test("commit is a no-op on a clean tree", async () => { + const dir = await cloned(); + const { exec, calls } = runner([["status --porcelain", "\n"]]); + + const committed = await new LoreStore({ + repo: "acme/lore", + cacheDir: dir, + exec, + }).commit("nothing to do"); + + assert.equal(committed, false); + assert.equal(calls.length, 1); +}); + +test("commit stages, commits and pushes with upstream tracking", async () => { + const dir = await cloned(); + const { exec, calls } = runner([ + ["status --porcelain", "A records/acme/widgets/pr-1.json\n"], + ["config user.email", "dev@example.com\n"], + ["add -A", ""], + ["commit", ""], + ["push", ""], + ]); + + const committed = await new LoreStore({ + repo: "acme/lore", + cacheDir: dir, + exec, + }).commit("stacklore: capture"); + + assert.equal(committed, true); + const lines = commandLines(calls); + assert.ok(lines.some((line) => line.includes("add -A"))); + // `-u` so a freshly cloned store tracks its branch from the first push. + assert.ok(lines.some((line) => line.includes("push --quiet -u origin HEAD"))); +}); + +test("a configured git identity is never overridden", async () => { + const dir = await cloned(); + const { exec, calls } = runner([ + ["status --porcelain", " M a\n"], + ["config user.email", "dev@example.com\n"], + ["add -A", ""], + ["commit", ""], + ["push", ""], + ]); + + await new LoreStore({ repo: "acme/lore", cacheDir: dir, exec }).commit("x"); + + const commit = calls.find((call) => call.args.includes("commit")); + assert.ok(!commit?.args.some((arg) => arg.startsWith("user.email="))); +}); + +test("a fallback identity is supplied only when none is configured", async () => { + const dir = await cloned(); + const { exec, calls } = runner([ + ["status --porcelain", " M a\n"], + ["config user.email", ""], + ["add -A", ""], + ["commit", ""], + ["push", ""], + ]); + + await new LoreStore({ repo: "acme/lore", cacheDir: dir, exec }).commit("x"); + + const commit = calls.find((call) => call.args.includes("commit")); + assert.ok( + commit?.args.includes("user.email=stacklore@users.noreply.github.com"), + ); +}); + +test("a git failure surfaces as StoreError with the command that failed", async () => { + const dir = await cloned(); + const { exec } = runner([ + [ + "status --porcelain", + () => { + throw new Error("not a git repository"); + }, + ], + ]); + + await assert.rejects( + new LoreStore({ repo: "acme/lore", cacheDir: dir, exec }).commit("x"), + (error: unknown) => + error instanceof StoreError && /status --porcelain/.test(error.message), + ); +}); + +test("record urls point into the private store, not the source repo", async () => { + const store = new LoreStore({ + repo: "acme/lore", + cacheDir: "/tmp/unused", + exec: runner([]).exec, + }); + + assert.equal( + store.recordUrl("acme/widgets", 12), + "https://github.com/acme/lore/blob/HEAD/records/acme/widgets/pr-12.json", + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 3dd05c9..41d1a46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,13 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noUncheckedIndexedAccess": true, - "noImplicitOverride": true + "noImplicitOverride": true, + + // Source imports carry `.ts` extensions so Node's type stripping can + // resolve them directly when tests run against `src/`; the compiler + // rewrites them to `.js` on emit, so the published package is unaffected. + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test"] diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..8d35e95 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + // Tests are excluded from the build but still need type checking; they are + // the main consumer of the public API and catch drift in its shape. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +}