diff --git a/CHANGELOG.md b/CHANGELOG.md index 7673b3e..570182c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 0.3.1 - Unreleased +- Added `clawpatch ci` to initialize, map, review, write a report, and append a GitHub Actions step summary in one CI-friendly command. +- Added `clawpatch open-pr --patch ` to turn an applied patch attempt into an explicit GitHub pull request. +- Added review prompt provenance and budget accounting for included files, omitted files, prompt bytes, and approximate tokens. +- Hardened review ingestion so provider findings must cite included files with valid line ranges and matching evidence quotes. +- Fixed `clawpatch open-pr` so repositories without default-branch metadata use a dedicated patch branch and let GitHub choose the PR base. +- Fixed `clawpatch open-pr` retries to push the recorded patch commit instead of any later local branch tip. +- Fixed first-time `clawpatch open-pr` branch creation to start from the recorded patch base. +- Fixed command execution so providers that exit before reading stdin do not surface benign `EPIPE` errors. +- Fixed `clawpatch ci --since` empty-review output so it reports `reviewed: 0`. - Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi. - Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi. - Fixed Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911. diff --git a/README.md b/README.md index 6da095a..5ee40b4 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,13 @@ clawpatch init clawpatch map clawpatch review --limit 3 --jobs 3 clawpatch review --mode deslopify --limit 3 +clawpatch ci --since origin/main --output clawpatch-report.md clawpatch report clawpatch next clawpatch show --finding clawpatch triage --finding --status false-positive --note "covered by tests" clawpatch fix --finding +clawpatch open-pr --patch --draft clawpatch revalidate --finding clawpatch revalidate --all --status open ``` @@ -122,11 +124,13 @@ Supported provider names today: - `clawpatch status`: show project, dirty state, feature/finding counts - `clawpatch review`: review pending or selected features - `clawpatch review --mode deslopify`: review only for locally provable slop cleanup +- `clawpatch ci`: initialize if needed, map, review, write a report, and append a GitHub step summary - `clawpatch report`: print or write a Markdown findings report - `clawpatch next`: print the next actionable finding - `clawpatch show --finding `: inspect one finding with evidence and suggested validation - `clawpatch triage --finding --status `: mark a finding with optional history note - `clawpatch fix --finding `: run the explicit patch loop for one finding +- `clawpatch open-pr --patch `: commit an applied patch attempt and open a GitHub PR - `clawpatch revalidate --finding `: re-check one finding - `clawpatch revalidate --all`: re-check open findings with report-style filters - `clawpatch doctor`: check provider availability @@ -180,7 +184,8 @@ to features so runs can resume and be audited. - Review does not edit files. - Fix is explicit and selected by finding ID. - Fix refuses a dirty source worktree by default. -- Clawpatch never commits, pushes, opens PRs, or lands changes today. +- Clawpatch commits, pushes, and opens PRs only from explicit patch commands such as `open-pr`. +- Clawpatch does not land changes today. - Provider output is parsed through strict schemas. - Symlinked directories and generated build output are skipped during mapping. diff --git a/docs/code-review.md b/docs/code-review.md index c5c3c70..e1ce7f0 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -23,10 +23,15 @@ Current behavior: - reviews with a bounded worker pool; default `--jobs` is `10` - emits progress to stderr unless `--quiet` is set - builds bounded prompt context from owned files, context files, and tests +- includes a prompt context manifest with included files, omitted files, byte + counts, and truncation status - calls the configured provider - requires strict JSON output +- rejects findings whose evidence cites files outside the prompt context, stale + line ranges, or quotes that do not match current file contents - writes findings under `.clawpatch/findings/` - appends analysis history to the feature record +- records prompt byte and approximate token counts in feature analysis history - releases the feature lock ## Flags @@ -47,6 +52,19 @@ If no features are touched by the diff, `review` exits cleanly with no findings. The same flag is available on `revalidate`; revalidation scopes open findings to features whose owned files changed. +### CI command + +Use `clawpatch ci` when a GitHub Actions job should run the whole read-only +review loop: + +```bash +clawpatch ci --since origin/main --limit 20 --jobs 4 --output clawpatch-report.md +``` + +The command initializes `.clawpatch/` if needed, maps features, reviews the +selected feature set, writes a Markdown report when `--output` is provided, and +appends a compact summary to `GITHUB_STEP_SUMMARY` when that file is available. + Progress uses stderr so `--json` stdout remains machine-readable. The worker pool is per-process, and lock files under `.clawpatch/locks/` prevent overlapping review processes from claiming the same feature. Interrupted runs diff --git a/docs/patching.md b/docs/patching.md index c3ebd2b..ea19abc 100644 --- a/docs/patching.md +++ b/docs/patching.md @@ -34,10 +34,23 @@ Status updates: The CLI does not currently mark a finding `fixed` from the patch pass alone. Use `clawpatch revalidate --finding ` for a second pass. +## Opening a PR + +After reviewing the applied worktree changes, create a GitHub PR explicitly: + +```bash +clawpatch open-pr --patch --draft +``` + +`open-pr` requires an applied or validated patch attempt with recorded changed +files. It refuses failed validation unless `--force` is passed, commits only the +recorded patch files, pushes the branch, and calls the GitHub CLI. Use +`--dry-run` to preview the branch, title, body, and commands without touching +git. + Not implemented yet: - fixing by severity or category - batching multiple findings - auto-commit -- PR creation - rollback snapshots diff --git a/docs/spec.md b/docs/spec.md index e139ee1..85d14ff 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -330,10 +330,8 @@ Behavior: - Requires existing patch attempt with changed files. - Requires clean validation state unless `--force`. - Creates branch/commit/PR only after explicit command. -- Uses repo-native commit helper if configured. -- PR body includes findings, tests, revalidation, and links to state report. - -Post-v0. +- Uses the GitHub CLI to create the PR after committing the recorded patch files. +- PR body includes linked findings, changed files, validation output, and the patch plan. ### `clawpatch land` diff --git a/src/app.ts b/src/app.ts index 95e7b1a..05be074 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,5 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { appendFile, lstat, readFile, realpath, writeFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; import { hostname } from "node:os"; import { changedPathsBetweenSnapshots, @@ -9,7 +9,7 @@ import { import { loadConfig, resolveStateDir, GlobalOptions } from "./config.js"; import { detectProject } from "./detect.js"; import { ClawpatchError, assertDefined } from "./errors.js"; -import { runCommand } from "./exec.js"; +import { runCommand, runCommandArgs } from "./exec.js"; import { appendFindingHistory, findingFromOutput, @@ -23,8 +23,8 @@ import { mapWithSource } from "./agent-mapper.js"; import { mapFeatures } from "./mapper.js"; import { emitProgress } from "./progress.js"; import { providerByName } from "./provider.js"; -import { buildFixPrompt, buildReviewPrompt, buildRevalidatePrompt } from "./prompt.js"; -import type { ReviewMode } from "./prompt.js"; +import { buildFixPrompt, buildReviewPromptBundle, buildRevalidatePrompt } from "./prompt.js"; +import type { ReviewMode, ReviewPromptManifest } from "./prompt.js"; import { evidenceLabel, findingSummaries, @@ -32,6 +32,7 @@ import { renderFindingDetail, renderReport, } from "./reporting.js"; +import { validateReviewOutput } from "./review-validation.js"; import { filterFeaturesByChangedFiles, filterFeaturesByProject, @@ -232,6 +233,39 @@ export async function statusCommand(context: AppContext): Promise { }; } +export async function ciCommand( + context: AppContext, + flags: Record, +): Promise { + const initialized = await ensureInitialized(context); + const mapFlags = providerFlagSubset(flags); + const reviewFlags = reviewFlagSubset(flags); + const mapped = await mapCommand(context, mapFlags); + const reviewed = await reviewCommand(context, reviewFlags); + const reportFlags = reportFlagSubset(flags); + const report = (await reportCommand(context, reportFlags)) as { + findings?: number; + output?: string | null; + markdown?: string; + }; + const reviewFindings = numberField(reviewed, "findings") ?? 0; + const summary = renderCiSummary({ initialized, mapped, reviewed, reviewFindings, report }); + const githubStepSummary = process.env["GITHUB_STEP_SUMMARY"]; + if (githubStepSummary !== undefined && githubStepSummary.length > 0) { + await appendFile(githubStepSummary, summary, "utf8"); + } + return { + initialized, + mapped: numberField(mapped, "features"), + reviewed: numberField(reviewed, "reviewed") ?? 0, + findings: reviewFindings, + reportFindings: report.findings ?? 0, + report: report.output ?? null, + githubStepSummary: githubStepSummary ?? null, + next: stringField(reviewed, "next") ?? "clawpatch status", + }; +} + export async function reviewCommand( context: AppContext, flags: Record, @@ -633,7 +667,7 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId }, ); locked = lockedFeature; - const prompt = await buildReviewPrompt( + const reviewPrompt = await buildReviewPromptBundle( loaded.root, loaded.project, lockedFeature, @@ -641,11 +675,28 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId mode, customPrompt, ); - const output = await provider.review(loaded.root, prompt, providerOptions(config)); - const modeFindings = reviewFindingsForMode(output.findings, mode); - const records = modeFindings - .slice(0, config.review.maxFindingsPerFeature) - .map((finding) => findingFromOutput(finding, lockedFeature.featureId, currentRunId)); + const providerOutput = await provider.review( + loaded.root, + reviewPrompt.prompt, + providerOptions(config), + ); + const reviewOutput = { + ...providerOutput, + findings: reviewFindingsForMode(providerOutput.findings, mode).slice( + 0, + config.review.maxFindingsPerFeature, + ), + }; + const output = await validateReviewOutput( + loaded.root, + lockedFeature, + config, + reviewPrompt.manifest, + reviewOutput, + ); + const records = output.findings.map((finding) => + findingFromOutput(finding, lockedFeature.featureId, currentRunId), + ); const findingIds: string[] = []; for (const finding of records) { const existingFinding = await readFinding(loaded.paths, finding.findingId); @@ -665,7 +716,7 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId { runId: currentRunId, kind: "review", - summary: `${records.length} finding(s)`, + summary: reviewAnalysisSummary(records.length, reviewPrompt.manifest), provider: provider.name, model: config.provider.model, reasoningEffort: config.provider.reasoningEffort, @@ -984,6 +1035,174 @@ export async function fixCommand( }; } +export async function openPrCommand( + context: AppContext, + flags: Record, +): Promise { + const loaded = await loadProjectState(context); + const patchId = assertDefined(stringFlag(flags, "patch"), "missing --patch"); + const patches = await readPatchAttempts(loaded.paths); + const patch = assertDefined( + patches.find((candidate) => candidate.patchAttemptId === patchId), + `patch attempt not found: ${patchId}`, + ); + const force = flags["force"] === true; + validatePrPatch(patch, force); + const git = await discoverGit(loaded.root); + if (git.root === null) { + throw new ClawpatchError("open-pr requires a git repository", 2, "not-git-repository"); + } + const base = stringFlag(flags, "base") ?? git.defaultBranch; + const branch = prBranchName(patch, stringFlag(flags, "branch"), git.currentBranch, base); + if ( + flags["dryRun"] !== true && + patch.git.prUrl !== null && + patch.git.commitSha !== null && + patch.git.branchName !== null + ) { + return { + patchAttempt: patch.patchAttemptId, + branch: patch.git.branchName, + base, + commit: patch.git.commitSha, + pr: patch.git.prUrl, + next: patch.git.prUrl, + }; + } + const findings = await readFindings(loaded.paths); + const linkedFindings = findings.filter((finding) => patch.findingIds.includes(finding.findingId)); + const title = prTitle(stringFlag(flags, "title"), linkedFindings, patch); + const body = renderPatchPrBody(patch, linkedFindings); + const gitFiles = await gitRelativePatchFiles(git.root, loaded.root, patch.filesChanged); + const draft = flags["draft"] === true; + const dryRunStagePlan = + flags["dryRun"] === true && patch.git.commitSha === null + ? await patchStagePlan( + git.root, + await assertPatchWorktree(patch, git.root, loaded.paths.stateDir, gitFiles, force), + ) + : null; + const branchExists = + flags["dryRun"] === true && patch.git.commitSha === null + ? await localBranchExists(git.root, branch) + : false; + const commands = plannedPrCommands( + patch, + branch, + base, + title, + gitFiles, + draft, + branchExists, + dryRunStagePlan, + ); + if (flags["dryRun"] === true) { + return { + dryRun: true, + patchAttempt: patch.patchAttemptId, + branch, + base, + title, + body, + commands, + commandsPreview: commands.join("\n"), + }; + } + + const patchWorktree = await assertPatchWorktree( + patch, + git.root, + loaded.paths.stateDir, + gitFiles, + force, + ); + let commitSha = patch.git.commitSha; + const hadRecordedCommit = commitSha !== null; + if (commitSha === null) { + const patchBaseSha = assertDefined(patch.git.baseSha, "missing patch base"); + const targetBranchExists = await localBranchExists(git.root, branch); + if (targetBranchExists) { + await assertRefAtPatchBase(git.root, branch, patch); + } + if (git.currentBranch !== branch) { + const switchArgs = targetBranchExists + ? ["switch", branch] + : ["switch", "-c", branch, patchBaseSha]; + await checkedRun("git switch", runCommandArgs("git", switchArgs, git.root)); + } + await assertRefAtPatchBase(git.root, "HEAD", patch); + const stagePlan = await patchStagePlan(git.root, patchWorktree); + if (stagePlan.addFiles.length > 0) { + await checkedRun( + "git add", + runCommandArgs("git", ["add", "--", ...stagePlan.addFiles.map(gitPathspec)], git.root), + ); + } + if (stagePlan.updateFiles.length > 0) { + await checkedRun( + "git add -u", + runCommandArgs( + "git", + ["add", "-u", "--", ...stagePlan.updateFiles.map(gitPathspec)], + git.root, + ), + ); + } + await checkedRun( + "git commit", + runCommandArgs( + "git", + ["commit", "-m", title, "--", ...stagePlan.commitFiles.map(gitPathspec)], + git.root, + ), + ); + const commit = await checkedRun( + "git rev-parse", + runCommandArgs("git", ["rev-parse", "HEAD"], git.root), + ); + commitSha = commit.stdout.trim(); + await writePatchPrGitState(loaded.paths, patch, { + commitSha, + branchName: branch, + prUrl: patch.git.prUrl, + }); + } + commitSha = assertDefined(commitSha, "missing patch commit"); + const pushArgs = hadRecordedCommit + ? ["push", "origin", `${commitSha}:refs/heads/${branch}`] + : ["push", "-u", "origin", branch]; + await checkedRun("git push", runCommandArgs("git", pushArgs, git.root)); + const ghArgs = prCreateArgs(base, branch, title, draft); + const gh = await checkedRun("gh pr create", runCommandArgs(githubCli(), ghArgs, git.root, body)); + const prUrl = firstUrl(gh.stdout) ?? gh.stdout.trim(); + await writePatchPrGitState(loaded.paths, patch, { commitSha, branchName: branch, prUrl }); + return { + patchAttempt: patch.patchAttemptId, + branch, + base, + commit: commitSha, + pr: prUrl, + next: prUrl.length > 0 ? prUrl : "inspect GitHub CLI output", + }; +} + +async function writePatchPrGitState( + paths: ReturnType, + patch: PatchAttempt, + git: { commitSha: string; branchName: string; prUrl: string | null }, +): Promise { + await writePatchAttempt(paths, { + ...patch, + git: { + ...patch.git, + commitSha: git.commitSha, + branchName: git.branchName, + prUrl: git.prUrl, + }, + updatedAt: nowIso(), + }); +} + export async function doctorCommand( context: AppContext, flags: Record = {}, @@ -1049,6 +1268,17 @@ async function loadProjectState(context: AppContext) { return { root: context.root, config, paths, project }; } +async function ensureInitialized(context: AppContext): Promise { + const config = await loadConfig(context.root, context.options); + const paths = statePaths(resolveStateDir(context.root, config)); + if ((await readProject(paths)) !== null) { + await ensureStateDirs(paths); + return false; + } + await initCommand(context, {}); + return true; +} + function applyProviderFlags( config: Awaited>, flags: Record, @@ -1068,6 +1298,508 @@ function applyProviderFlags( }; } +function providerFlagSubset( + flags: Record, +): Record { + const subset: Record = {}; + for (const flag of ["provider", "model", "reasoningEffort"] as const) { + const value = stringFlag(flags, flag); + if (value !== undefined) { + subset[flag] = value; + } + } + if (flags["skipGitRepoCheck"] === true) { + subset["skipGitRepoCheck"] = true; + } + return subset; +} + +function reviewFlagSubset( + flags: Record, +): Record { + const subset = providerFlagSubset(flags); + for (const flag of ["since", "limit", "jobs"] as const) { + const value = stringFlag(flags, flag); + if (value !== undefined) { + subset[flag] = value; + } + } + return subset; +} + +function reportFlagSubset(flags: Record): Record { + const output = stringFlag(flags, "output"); + return output === undefined ? {} : { output }; +} + +function renderCiSummary(input: { + initialized: boolean; + mapped: unknown; + reviewed: unknown; + reviewFindings: number; + report: { findings?: number; output?: string | null }; +}): string { + const lines = [ + "## Clawpatch review", + "", + `- initialized: ${input.initialized ? "yes" : "no"}`, + `- mapped features: ${numberField(input.mapped, "features") ?? "unknown"}`, + `- reviewed features: ${numberField(input.reviewed, "reviewed") ?? 0}`, + `- findings: ${input.reviewFindings}`, + ]; + if (input.report.findings !== undefined && input.report.findings !== input.reviewFindings) { + lines.push(`- report findings: ${input.report.findings}`); + } + if (input.report.output !== undefined && input.report.output !== null) { + lines.push(`- report: ${input.report.output}`); + } + const next = stringField(input.reviewed, "next"); + if (next !== undefined) { + lines.push(`- next: \`${next}\``); + } + lines.push(""); + return `${lines.join("\n")}\n`; +} + +function numberField(value: unknown, field: string): number | null { + if (typeof value !== "object" || value === null) { + return null; + } + const candidate = (value as Record)[field]; + return typeof candidate === "number" ? candidate : null; +} + +function stringField(value: unknown, field: string): string | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const candidate = (value as Record)[field]; + return typeof candidate === "string" ? candidate : undefined; +} + +function reviewAnalysisSummary(findings: number, manifest: ReviewPromptManifest): string { + return [ + `${findings} finding(s)`, + `prompt=${manifest.promptBytes} bytes`, + `approxTokens=${manifest.approximateTokens}`, + `includedFiles=${manifest.includedFiles.length}`, + `omittedFiles=${manifest.omittedFiles.length}`, + ].join("; "); +} + +function validatePrPatch(patch: PatchAttempt, force: boolean): void { + if (patch.filesChanged.length === 0) { + throw new ClawpatchError( + `patch has no changed files: ${patch.patchAttemptId}`, + 2, + "invalid-input", + ); + } + if (!["applied", "validated"].includes(patch.status) && !force) { + throw new ClawpatchError( + `patch is not ready for PR: ${patch.patchAttemptId} (${patch.status})`, + 2, + "invalid-input", + ); + } + const failed = patch.testResults.filter((result) => result.exitCode !== 0); + if (failed.length > 0 && !force) { + throw new ClawpatchError( + `patch validation failed; use --force to open a PR anyway: ${failed[0]?.command ?? "unknown"}`, + 6, + "validation-failed", + ); + } +} + +function prBranchName( + patch: PatchAttempt, + explicit: string | undefined, + currentBranch: string | null, + base: string | null, +): string { + if (explicit !== undefined) { + return explicit; + } + if (base === null) { + return patch.git.branchName?.startsWith("clawpatch/") === true + ? patch.git.branchName + : `clawpatch/${patch.patchAttemptId}`; + } + if ( + patch.git.branchName !== null && + patch.git.branchName !== base && + patch.git.branchName !== "main" && + patch.git.branchName !== "master" + ) { + return patch.git.branchName; + } + if ( + base !== null && + currentBranch !== null && + currentBranch !== base && + currentBranch !== "main" && + currentBranch !== "master" + ) { + return currentBranch; + } + return `clawpatch/${patch.patchAttemptId}`; +} + +function prTitle( + explicit: string | undefined, + findings: FindingRecord[], + patch: PatchAttempt, +): string { + if (explicit !== undefined) { + return explicit; + } + const title = findings[0]?.title ?? patch.plan.split("\n")[0] ?? patch.patchAttemptId; + return `fix: ${title}`.slice(0, 120); +} + +function renderPatchPrBody(patch: PatchAttempt, findings: FindingRecord[]): string { + const lines = [ + "## Summary", + "", + `- patch attempt: \`${patch.patchAttemptId}\``, + `- status: \`${patch.status}\``, + `- files changed: ${patch.filesChanged.length}`, + "", + "## Findings", + "", + ]; + if (findings.length === 0) { + lines.push("- none linked"); + } else { + for (const finding of findings) { + lines.push(`- \`${finding.findingId}\`: ${finding.title} (${finding.severity})`); + } + } + lines.push("", "## Changed Files", ""); + for (const file of patch.filesChanged) { + lines.push(`- \`${file}\``); + } + lines.push("", "## Validation", ""); + const validation = patch.testResults.length > 0 ? patch.testResults : patch.commandsRun; + if (validation.length === 0) { + lines.push("- none recorded"); + } else { + for (const result of validation) { + lines.push(`- \`${result.command}\` => ${result.exitCode ?? "unknown"}`); + } + } + lines.push("", "## Plan", "", patch.plan, ""); + return `${lines.join("\n")}\n`; +} + +async function gitRelativePatchFiles( + gitRoot: string, + projectRoot: string, + files: string[], +): Promise { + const projectPrefix = await gitRelativePathPrefix(gitRoot, projectRoot); + if (projectPrefix === ".." || projectPrefix.startsWith("../")) { + throw new ClawpatchError( + `project root is outside git repository: ${projectRoot}`, + 2, + "invalid-root", + ); + } + const scopedPrefix = isUsableRelativePrefix(projectPrefix) ? projectPrefix : ""; + return files.map((file) => { + const relativeFile = normalizePath(file); + if ( + relativeFile.startsWith("../") || + relativeFile === ".." || + relativeFile.split("/").includes("..") || + resolve(relativeFile) === relativeFile || + relativeFile.length === 0 + ) { + throw new ClawpatchError(`patch file escapes git repository: ${file}`, 2, "invalid-input"); + } + return scopedPrefix.length === 0 ? relativeFile : `${scopedPrefix}/${relativeFile}`; + }); +} + +function plannedPrCommands( + patch: PatchAttempt, + branch: string, + base: string | null, + title: string, + gitFiles: string[], + draft: boolean, + branchExists: boolean, + stagePlan: PatchStagePlan | null, +): string[] { + const commands: string[] = []; + if (patch.git.commitSha === null) { + const patchBaseSha = assertDefined(patch.git.baseSha, "missing patch base"); + const commitFiles = stagePlan?.commitFiles ?? gitFiles; + const addFiles = stagePlan?.addFiles ?? gitFiles; + const updateFiles = stagePlan?.updateFiles ?? []; + commands.push( + branchExists + ? `git switch ${shellArg(branch)}` + : `git switch -c ${shellArg(branch)} ${shellArg(patchBaseSha)}`, + ); + if (addFiles.length > 0) { + commands.push(`git add -- ${shellPathspecArgs(addFiles)}`); + } + if (updateFiles.length > 0) { + commands.push(`git add -u -- ${shellPathspecArgs(updateFiles)}`); + } + commands.push(`git commit -m ${shellArg(title)} -- ${shellPathspecArgs(commitFiles)}`); + } + commands.push( + patch.git.commitSha === null + ? `git push -u origin ${shellArg(branch)}` + : `git push origin ${shellArg(`${patch.git.commitSha}:refs/heads/${branch}`)}`, + ); + commands.push(`gh ${prCreateArgs(base, branch, title, draft).map(shellArg).join(" ")}`); + return commands; +} + +function prCreateArgs( + base: string | null, + branch: string, + title: string, + draft: boolean, +): string[] { + const args = ["pr", "create", "--head", branch, "--title", title, "--body-file", "-"]; + if (base !== null) { + args.splice(2, 0, "--base", base); + } + if (draft) { + args.push("--draft"); + } + return args; +} + +async function assertPatchWorktree( + patch: PatchAttempt, + gitRoot: string, + stateDir: string, + gitFiles: string[], + force: boolean, +): Promise<{ commitFiles: string[]; stagedOnlyFiles: string[] }> { + if (patch.git.commitSha !== null) { + return { commitFiles: gitFiles, stagedOnlyFiles: [] }; + } + const status = await checkedRun( + "git status", + runCommandArgs( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + gitRoot, + undefined, + { + trimOutput: false, + }, + ), + ); + const statusChanges = gitStatusChanges(status.stdout); + const dirty = uniqueStrings(statusChanges.flatMap((change) => change.paths)); + const statePrefix = await gitRelativePathPrefix(gitRoot, stateDir); + const sourceDirty = dirty.filter((file) => !isStatePath(file, statePrefix)); + if (sourceDirty.length === 0) { + throw new ClawpatchError("no uncommitted patch changes to commit", 2, "invalid-input"); + } + const expected = new Set(gitFiles); + const commitFiles = new Set(gitFiles); + const stagedOnlyFiles = new Set(); + for (const change of statusChanges) { + if (change.secondaryPath === undefined) { + continue; + } + if (expected.has(change.primaryPath) || expected.has(change.secondaryPath)) { + commitFiles.add(change.primaryPath); + commitFiles.add(change.secondaryPath); + stagedOnlyFiles.add(change.secondaryPath); + } + } + const extra = sourceDirty.filter((file) => !commitFiles.has(file)); + if (extra.length > 0 && !force) { + throw new ClawpatchError( + `dirty worktree has files outside patch attempt: ${extra.join(", ")}`, + 3, + "dirty-worktree", + ); + } + const missing = gitFiles.filter((file) => !sourceDirty.includes(file)); + if (missing.length > 0 && !force) { + throw new ClawpatchError( + `patch files are not dirty in the worktree: ${missing.join(", ")}`, + 2, + "invalid-input", + ); + } + return { commitFiles: [...commitFiles], stagedOnlyFiles: [...stagedOnlyFiles] }; +} + +type PatchStagePlan = { + commitFiles: string[]; + addFiles: string[]; + updateFiles: string[]; +}; + +async function patchStagePlan( + root: string, + patchWorktree: { commitFiles: string[]; stagedOnlyFiles: string[] }, +): Promise { + const stagedOnlyFiles = new Set(patchWorktree.stagedOnlyFiles); + const stageableFiles = patchWorktree.commitFiles.filter((file) => !stagedOnlyFiles.has(file)); + const addFiles = await existingGitFiles(root, stageableFiles); + const updateFiles = stageableFiles.filter((file) => !addFiles.includes(file)); + return { commitFiles: patchWorktree.commitFiles, addFiles, updateFiles }; +} + +type GitStatusChange = { + paths: string[]; + primaryPath: string; + secondaryPath: string | undefined; +}; + +function gitStatusChanges(output: string): GitStatusChange[] { + const fields = output.split("\0").filter((field) => field.length > 0); + const changes: GitStatusChange[] = []; + for (let index = 0; index < fields.length; index += 1) { + const field = fields[index] ?? ""; + if (field.length < 4) { + continue; + } + const status = field.slice(0, 2); + const primaryPath = normalizePath(field.slice(3)); + const paths = [primaryPath]; + let secondaryPath: string | undefined; + if (/[RC]/u.test(status)) { + secondaryPath = normalizePath(fields[index + 1] ?? ""); + if (secondaryPath.length > 0) { + paths.push(secondaryPath); + } + index += 1; + } + changes.push({ paths, primaryPath, secondaryPath }); + } + return changes; +} + +function isStatePath(file: string, statePrefix: string): boolean { + return statePrefix.length > 0 && (file === statePrefix || file.startsWith(`${statePrefix}/`)); +} + +async function gitRelativePathPrefix(gitRoot: string, path: string): Promise { + const direct = normalizePath(relative(gitRoot, path)); + if (isUsableRelativePrefix(direct)) { + return direct; + } + const [realGitRoot, realPath] = await Promise.all([ + realpath(gitRoot).catch(() => gitRoot), + realpath(path).catch(() => path), + ]); + const resolved = normalizePath(relative(realGitRoot, realPath)); + if (resolved === "" || isUsableRelativePrefix(resolved)) { + return resolved; + } + const normalizedGitRoot = normalizeDarwinPrivateVar(realGitRoot); + const normalizedPath = normalizeDarwinPrivateVar(realPath); + if (normalizedPath === normalizedGitRoot) { + return ""; + } + if (normalizedPath.startsWith(`${normalizedGitRoot}/`)) { + return normalizedPath.slice(normalizedGitRoot.length + 1); + } + return direct; +} + +function isUsableRelativePrefix(path: string): boolean { + return path.length > 0 && path !== "." && path !== ".." && !path.startsWith("../"); +} + +async function checkedRun( + label: string, + resultPromise: Promise, +): Promise { + const result = await resultPromise; + if (result.exitCode !== 0) { + throw new ClawpatchError( + `${label} failed: ${result.stderr || result.stdout}`, + label.startsWith("gh") ? 7 : 1, + label.startsWith("gh") ? "github-failure" : "git-failure", + ); + } + return result; +} + +function githubCli(): string { + return process.env["CLAWPATCH_GH"] ?? "gh"; +} + +async function localBranchExists(gitRoot: string, branch: string): Promise { + const result = await runCommandArgs( + "git", + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + gitRoot, + ); + return result.exitCode === 0; +} + +async function assertRefAtPatchBase( + gitRoot: string, + ref: string, + patch: PatchAttempt, +): Promise { + const head = await checkedRun( + "git rev-parse", + runCommandArgs("git", ["rev-parse", ref], gitRoot), + ); + const sha = head.stdout.trim(); + if (sha !== patch.git.baseSha) { + const message = [ + `patch attempt ${patch.patchAttemptId} was recorded from ${patch.git.baseSha},`, + `but ${ref} is ${sha}`, + ].join(" "); + throw new ClawpatchError(message, 2, "invalid-input"); + } +} + +function firstUrl(output: string): string | null { + return /https?:\/\/\S+/u.exec(output)?.[0] ?? null; +} + +function gitPathspec(path: string): string { + return `:(literal)${path}`; +} + +function shellPathspecArgs(files: string[]): string { + return files.map((file) => shellArg(gitPathspec(file))).join(" "); +} + +function shellArg(value: string): string { + return /^[A-Za-z0-9_./:@%+=,-]+$/u.test(value) ? value : `'${value.replace(/'/gu, "'\\''")}'`; +} + +function normalizePath(path: string): string { + return path.replace(/\\/gu, "/"); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +async function existingGitFiles(root: string, files: string[]): Promise { + const existing = await Promise.all( + files.map(async (file) => + (await lstat(resolve(root, file)).catch(() => null)) === null ? null : file, + ), + ); + return existing.filter((file): file is string => file !== null); +} + +function normalizeDarwinPrivateVar(path: string): string { + return normalizePath(path).replace(/^\/private\/var\//u, "/var/"); +} + function providerOptions(config: ReturnType) { return { model: config.provider.model, diff --git a/src/cli.ts b/src/cli.ts index 894918d..e6f858c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import { cleanLocksCommand, + ciCommand, doctorCommand, fixCommand, initCommand, @@ -13,6 +14,7 @@ import { revalidateCommand, reviewCommand, nextCommand, + openPrCommand, showCommand, statusCommand, triageCommand, @@ -51,6 +53,8 @@ async function dispatch( return statusCommand(context); case "review": return reviewCommand(context, flags); + case "ci": + return ciCommand(context, flags); case "report": return reportCommand(context, flags); case "show": @@ -61,6 +65,8 @@ async function dispatch( return triageCommand(context, flags); case "fix": return fixCommand(context, flags); + case "open-pr": + return openPrCommand(context, flags); case "revalidate": return revalidateCommand(context, flags); case "doctor": @@ -163,11 +169,22 @@ const commandFlags = { "promptFile", "exportTribunalLedger", ]), + ci: new Set([ + "limit", + "since", + "jobs", + "provider", + "model", + "reasoningEffort", + "skipGitRepoCheck", + "output", + ]), report: new Set(["status", "severity", "feature", "project", "category", "triage", "output"]), show: new Set(["finding"]), next: new Set(["status", "project"]), triage: new Set(["finding", "status", "note"]), fix: new Set(["finding", "provider", "model", "reasoningEffort", "skipGitRepoCheck", "dryRun"]), + "open-pr": new Set(["patch", "base", "branch", "title", "draft", "dryRun", "force"]), revalidate: new Set([ "finding", "all", @@ -191,6 +208,7 @@ const requiredCommandFlags: Partial> show: ["finding"], triage: ["finding", "status"], fix: ["finding"], + "open-pr": ["patch"], }; const valueFlagNames = new Set([ @@ -216,6 +234,10 @@ const valueFlagNames = new Set([ "triage", "project", "note", + "patch", + "base", + "branch", + "title", ]); const booleanFlagNames = new Set([ @@ -230,6 +252,7 @@ const booleanFlagNames = new Set([ "skip-git-repo-check", "force", "all", + "draft", ]); const shortFlagNames = new Set(["-h", "-q", "-v", "-o"]); @@ -420,6 +443,25 @@ Flags: --triage --output --json +`); + return; + } + if (command === "ci") { + process.stdout.write(`clawpatch ci + +Usage: + clawpatch ci [flags] + +Flags: + --since + --limit + --jobs default: 10 + --provider + --model + --reasoning-effort + --skip-git-repo-check + --output + --json `); return; } @@ -476,6 +518,24 @@ Flags: --skip-git-repo-check --dry-run --json +`); + return; + } + if (command === "open-pr") { + process.stdout.write(`clawpatch open-pr + +Usage: + clawpatch open-pr --patch [flags] + +Flags: + --patch + --base + --branch + --title + --draft + --dry-run + --force + --json `); return; } @@ -579,11 +639,13 @@ Commands: map status review + ci report show next triage fix + open-pr revalidate doctor clean-locks diff --git a/src/exec.test.ts b/src/exec.test.ts index 539981c..f8270fc 100644 --- a/src/exec.test.ts +++ b/src/exec.test.ts @@ -31,6 +31,14 @@ describe("runCommandArgs", () => { expect(result.stderr).toContain("clawpatch-missing-executable-for-test"); }); + it("does not surface EPIPE when a child exits before reading stdin", async () => { + const dir = await mkdtemp(join(tmpdir(), "clawpatch-exec-stdin-")); + const input = "x".repeat(1_000_000); + const result = await runCommandArgs(process.execPath, ["-e", "process.exit(0)"], dir, input); + + expect(result.exitCode).toBe(0); + }); + it("terminates commands that exceed a timeout", async () => { const dir = await mkdtemp(join(tmpdir(), "clawpatch-exec-timeout-")); const script = join(dir, "hang.mjs"); @@ -84,7 +92,7 @@ describe("runCommandArgs", () => { "import { writeFileSync } from 'node:fs';", "process.on('SIGTERM', () => {});", "process.send?.('ready');", - `setTimeout(() => writeFileSync(${JSON.stringify(marker)}, 'alive'), 1000);`, + `setTimeout(() => writeFileSync(${JSON.stringify(marker)}, 'alive'), 2500);`, "setInterval(() => {}, 1000);", ].join("\n"), "utf8", @@ -103,9 +111,9 @@ describe("runCommandArgs", () => { ); const result = await runCommandArgs(process.execPath, [parentScript], dir, undefined, { - timeoutMs: 300, + timeoutMs: 1000, }); - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 1200)); expect(result.exitCode).toBe(124); await expect(access(ready)).resolves.toBeUndefined(); diff --git a/src/exec.ts b/src/exec.ts index a370b4b..5e339af 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -46,11 +46,7 @@ export async function runCommandRaw( }); child.on("close", resolve); }); - if (input !== undefined) { - child.stdin.end(input); - } else { - child.stdin.end(); - } + endChildStdin(child, input); const exitCode = await exitCodePromise; if (spawnErrorMessage !== null) { stderr += stderr.length === 0 ? spawnErrorMessage : `\n${spawnErrorMessage}`; @@ -137,11 +133,7 @@ export async function runCommandArgs( }); }, options.timeoutMs); } - if (input !== undefined) { - child.stdin.end(input); - } else { - child.stdin.end(); - } + endChildStdin(child, input); const exitCode = await exitCodePromise; if (spawnErrorMessage !== null) { stderr += stderr.length === 0 ? spawnErrorMessage : `\n${spawnErrorMessage}`; @@ -229,6 +221,19 @@ function signalExitCode(signal: NodeJS.Signals): number { function noop(): void {} +function endChildStdin(child: ReturnType<typeof spawn>, input: string | undefined): void { + const stdin = child.stdin; + if (stdin === null) { + return; + } + stdin.on("error", noop); + if (input !== undefined) { + stdin.end(input); + } else { + stdin.end(); + } +} + function commandSpawnSpec( program: string, args: string[], diff --git a/src/prompt.test.ts b/src/prompt.test.ts new file mode 100644 index 0000000..c078de4 --- /dev/null +++ b/src/prompt.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { REVIEW_PROMPT_FILE_CHAR_LIMIT, buildReviewPromptBundle } from "./prompt.js"; +import { defaultConfig } from "./config.js"; +import { fixtureRoot, writeFixture } from "./test-helpers.js"; +import type { FeatureRecord, ProjectRecord } from "./types.js"; + +describe("review prompt provenance", () => { + it("records included, omitted, and truncated review prompt context", async () => { + const root = await fixtureRoot("clawpatch-prompt-provenance-"); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + await writeFixture(root, "src/extra.ts", "export const extra = 1;\n"); + await writeFixture(root, "tests/index.test.ts", "expect(1).toBe(1);\n"); + await writeFixture(root, "docs/large.md", `${"x".repeat(24_100)}\n`); + const bundle = await buildReviewPromptBundle(root, project(root), feature(), { + ...defaultConfig(), + review: { + ...defaultConfig().review, + maxOwnedFiles: 1, + maxContextFiles: 2, + }, + }); + + expect(bundle.prompt).toContain("Prompt context:"); + expect(bundle.prompt).toContain("--- src/index.ts"); + expect(bundle.prompt).toContain("--- tests/index.test.ts"); + expect(bundle.prompt).not.toContain("--- src/extra.ts"); + expect(bundle.manifest.includedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "src/index.ts", role: "owned", truncated: false }), + expect.objectContaining({ path: "docs/large.md", role: "context", truncated: true }), + ]), + ); + expect(bundle.manifest.omittedFiles).toEqual([ + { path: "src/extra.ts", role: "owned", reason: "maxOwnedFiles" }, + { path: "docs/omitted.md", role: "context", reason: "maxContextFiles" }, + ]); + expect(bundle.manifest.promptBytes).toBeGreaterThan(0); + expect(bundle.manifest.approximateTokens).toBeGreaterThan(0); + }); + + it("marks exact marker-length replacements as truncated", async () => { + const root = await fixtureRoot("clawpatch-prompt-truncated-edge-"); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + await writeFixture( + root, + "docs/large.md", + `${"x".repeat(REVIEW_PROMPT_FILE_CHAR_LIMIT)}TAIL_ONLY_TOKEN`, + ); + const bundle = await buildReviewPromptBundle(root, project(root), feature(), defaultConfig()); + + expect(bundle.manifest.includedFiles).toEqual( + expect.arrayContaining([expect.objectContaining({ path: "docs/large.md", truncated: true })]), + ); + }); +}); + +function project(root: string): ProjectRecord { + return { + schemaVersion: 1, + projectId: "proj_prompt", + name: "prompt", + rootPath: root, + git: { + remoteUrl: null, + defaultBranch: null, + currentBranch: null, + headSha: null, + }, + detected: { + languages: ["typescript"], + frameworks: [], + packageManagers: ["npm"], + commands: defaultConfig().commands, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +function feature(): FeatureRecord { + const now = new Date().toISOString(); + return { + schemaVersion: 1, + featureId: "feat_prompt", + title: "Prompt feature", + summary: "Prompt provenance feature", + kind: "library", + source: "test", + confidence: "high", + entrypoints: [], + ownedFiles: [ + { path: "src/index.ts", reason: "primary" }, + { path: "src/extra.ts", reason: "overflow" }, + ], + contextFiles: [ + { path: "tests/index.test.ts", reason: "test" }, + { path: "docs/large.md", reason: "large doc" }, + { path: "docs/omitted.md", reason: "overflow" }, + ], + tests: [], + tags: [], + trustBoundaries: [], + status: "pending", + lock: null, + findingIds: [], + patchAttemptIds: [], + analysisHistory: [], + createdAt: now, + updatedAt: now, + }; +} diff --git a/src/prompt.ts b/src/prompt.ts index 6831791..e5078a1 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -4,6 +4,34 @@ import { ClawpatchConfig, FeatureRecord, FindingRecord, ProjectRecord } from "./ export type ReviewMode = "default" | "deslopify"; +export const REVIEW_PROMPT_FILE_CHAR_LIMIT = 24_000; + +export type ReviewPromptFileRole = "owned" | "context"; + +export type ReviewPromptFileManifest = { + path: string; + role: ReviewPromptFileRole; + bytes: number; + includedBytes: number; + truncated: boolean; + readable: boolean; + skippedReason: string | null; +}; + +export type ReviewPromptManifest = { + maxOwnedFiles: number; + maxContextFiles: number; + includedFiles: ReviewPromptFileManifest[]; + omittedFiles: Array<{ path: string; role: ReviewPromptFileRole; reason: string }>; + promptBytes: number; + approximateTokens: number; +}; + +export type ReviewPromptBundle = { + prompt: string; + manifest: ReviewPromptManifest; +}; + export function buildAgentMapPrompt(project: ProjectRecord, inventory: unknown): string { return `You are mapping a repository into semantic clawpatch review slices. @@ -62,11 +90,42 @@ export async function buildReviewPrompt( mode: ReviewMode = "default", customPrompt: string | null = null, ): Promise<string> { + return (await buildReviewPromptBundle(root, project, feature, config, mode, customPrompt)).prompt; +} + +export async function buildReviewPromptBundle( + root: string, + project: ProjectRecord, + feature: FeatureRecord, + config: ClawpatchConfig, + mode: ReviewMode = "default", + customPrompt: string | null = null, +): Promise<ReviewPromptBundle> { const owned = feature.ownedFiles.slice(0, config.review.maxOwnedFiles); const context = feature.contextFiles.slice(0, config.review.maxContextFiles); + const omittedFiles = [ + ...feature.ownedFiles.slice(config.review.maxOwnedFiles).map((ref) => ({ + path: ref.path, + role: "owned" as const, + reason: "maxOwnedFiles", + })), + ...feature.contextFiles.slice(config.review.maxContextFiles).map((ref) => ({ + path: ref.path, + role: "context" as const, + reason: "maxContextFiles", + })), + ]; const fileBlocks: string[] = []; - for (const ref of [...owned, ...context]) { - fileBlocks.push(await fileBlock(root, ref.path)); + const includedFiles: ReviewPromptFileManifest[] = []; + for (const ref of owned) { + const file = await fileBlockWithManifest(root, ref.path, "owned"); + fileBlocks.push(file.block); + includedFiles.push(file.manifest); + } + for (const ref of context) { + const file = await fileBlockWithManifest(root, ref.path, "context"); + fileBlocks.push(file.block); + includedFiles.push(file.manifest); } const customBlock = customPrompt !== null && customPrompt.trim() !== "" @@ -76,7 +135,19 @@ ${customPrompt.trim()} ` : ""; - return `You are reviewing one semantic feature for clawpatch. + const promptContext = { + maxOwnedFiles: config.review.maxOwnedFiles, + maxContextFiles: config.review.maxContextFiles, + includedFiles: includedFiles.map(({ path, role, bytes, includedBytes, truncated }) => ({ + path, + role, + bytes, + includedBytes, + truncated, + })), + omittedFiles, + }; + const prompt = `You are reviewing one semantic feature for clawpatch. Return strict JSON only. No markdown fences. @@ -110,6 +181,9 @@ with multiple evidence refs instead of separate one-off findings. Avoid speculative low-evidence findings. Evidence must point at included files. +Prompt context: +${JSON.stringify(promptContext, null, 2)} + JSON shape: { "findings": [ @@ -132,6 +206,17 @@ JSON shape: Files: ${fileBlocks.join("\n\n")}`; + const promptBytes = Buffer.byteLength(prompt, "utf8"); + return { + prompt, + manifest: { + ...promptContext, + includedFiles, + omittedFiles, + promptBytes, + approximateTokens: Math.ceil(prompt.length / 4), + }, + }; } function reviewModeInstructions(mode: ReviewMode): string { @@ -245,19 +330,74 @@ function fixPromptPaths( } async function fileBlock(root: string, path: string): Promise<string> { + return (await fileBlockWithManifest(root, path, "context")).block; +} + +async function fileBlockWithManifest( + root: string, + path: string, + role: ReviewPromptFileRole, +): Promise<{ block: string; manifest: ReviewPromptFileManifest }> { const full = resolve(root, path); if (!isInside(root, full)) { - return `--- ${path}\n[skipped: path escapes repository root]`; + return skippedFileBlock(path, role, "path escapes repository root"); } const realRoot = await realpath(root).catch(() => root); const realFull = await realpath(full).catch(() => full); if (!isInside(realRoot, realFull)) { - return `--- ${path}\n[skipped: path escapes repository root]`; + return skippedFileBlock(path, role, "path escapes repository root"); } - const contents = await readFile(full, "utf8").catch(() => "[unreadable]"); - const trimmed = - contents.length > 24_000 ? `${contents.slice(0, 24_000)}\n...[truncated]` : contents; - return `--- ${path}\n${trimmed}`; + const contents = await readFile(full, "utf8").catch(() => null); + if (contents === null) { + return { + block: `--- ${path}\n[unreadable]`, + manifest: { + path, + role, + bytes: 0, + includedBytes: 0, + truncated: false, + readable: false, + skippedReason: "unreadable", + }, + }; + } + const bytes = Buffer.byteLength(contents, "utf8"); + const truncated = contents.length > REVIEW_PROMPT_FILE_CHAR_LIMIT; + const trimmed = truncated + ? `${contents.slice(0, REVIEW_PROMPT_FILE_CHAR_LIMIT)}\n...[truncated]` + : contents; + return { + block: `--- ${path}\n${trimmed}`, + manifest: { + path, + role, + bytes, + includedBytes: Buffer.byteLength(trimmed, "utf8"), + truncated, + readable: true, + skippedReason: null, + }, + }; +} + +function skippedFileBlock( + path: string, + role: ReviewPromptFileRole, + reason: string, +): { block: string; manifest: ReviewPromptFileManifest } { + return { + block: `--- ${path}\n[skipped: ${reason}]`, + manifest: { + path, + role, + bytes: 0, + includedBytes: 0, + truncated: false, + readable: false, + skippedReason: reason, + }, + }; } function isInside(root: string, candidate: string): boolean { diff --git a/src/provider.ts b/src/provider.ts index 72628e6..3bc387d 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -361,6 +361,60 @@ const mockProvider: Provider = { if (!prompt.includes("TODO_BUG") && !prompt.includes("BUG:")) { return { findings: [], inspected: { files: [], symbols: [], notes: ["mock clean"] } }; } + const evidencePath = prompt.includes("BAD_EVIDENCE") + ? "src/not-included.ts" + : (firstPromptFileWith(prompt, "TODO_BUG") ?? "src/index.ts"); + if (prompt.includes("DESLOPIFY_LATE")) { + return { + findings: [ + { + title: "General bug first", + category: "bug", + severity: "medium", + confidence: "high", + evidence: [ + { + path: evidencePath, + startLine: null, + endLine: null, + symbol: null, + quote: "TODO_BUG", + }, + ], + reasoning: "Mock provider found an explicit bug marker.", + reproduction: null, + recommendation: "Replace marker with real handling.", + whyTestsDoNotAlreadyCoverThis: + "Mock fixtures do not encode this marker as intended behavior.", + suggestedRegressionTest: "Add a focused test that fails when TODO_BUG is present.", + minimumFixScope: "Replace the marker in the owning feature file.", + }, + { + title: "Late simplification finding", + category: "maintainability", + severity: "low", + confidence: "high", + evidence: [ + { + path: evidencePath, + startLine: null, + endLine: null, + symbol: null, + quote: "DESLOPIFY_LATE", + }, + ], + reasoning: "Mock provider returned a simplification finding after a general finding.", + reproduction: null, + recommendation: "Keep the deslopify finding after mode filtering.", + whyTestsDoNotAlreadyCoverThis: + "Mock fixtures need to prove filtering occurs before the finding cap.", + suggestedRegressionTest: null, + minimumFixScope: "Filter before capping.", + }, + ], + inspected: { files: [evidencePath], symbols: [], notes: ["mock mixed findings"] }, + }; + } return { findings: [ { @@ -370,7 +424,7 @@ const mockProvider: Provider = { confidence: "high", evidence: [ { - path: "src/index.ts", + path: evidencePath, startLine: null, endLine: null, symbol: null, @@ -386,7 +440,7 @@ const mockProvider: Provider = { minimumFixScope: "Replace the marker in the owning feature file.", }, ], - inspected: { files: ["src/index.ts"], symbols: [], notes: ["mock finding"] }, + inspected: { files: [evidencePath], symbols: [], notes: ["mock finding"] }, }; }, async fix(): Promise<FixPlanOutput> { @@ -417,6 +471,22 @@ const mockProvider: Provider = { }, }; +function firstPromptFileWith(prompt: string, marker: string): string | null { + const blocks = prompt.split(/^--- /gmu).slice(1); + for (const block of blocks) { + const newline = block.indexOf("\n"); + if (newline === -1) { + continue; + } + const path = block.slice(0, newline).trim(); + const contents = block.slice(newline + 1); + if (path.length > 0 && contents.includes(marker)) { + return path; + } + } + return null; +} + const mockFailProvider: Provider = { name: "mock-fail", async check(): Promise<string> { diff --git a/src/review-validation.test.ts b/src/review-validation.test.ts new file mode 100644 index 0000000..27db552 --- /dev/null +++ b/src/review-validation.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { defaultConfig } from "./config.js"; +import type { ReviewPromptManifest } from "./prompt.js"; +import { validateReviewOutput } from "./review-validation.js"; +import { fixtureRoot, writeFixture } from "./test-helpers.js"; +import type { FeatureRecord, ReviewOutput } from "./types.js"; + +describe("validateReviewOutput", () => { + it("accepts evidence that points at included files, existing lines, and matching quotes", async () => { + const root = await fixtureRoot("clawpatch-review-validation-ok-"); + await writeFixture(root, "src/index.ts", "const value = 'TODO_BUG';\n"); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts"), + output("src/index.ts"), + ), + ).resolves.toMatchObject({ findings: [{ title: "Bug" }] }); + }); + + it("does not reject absolute inspected file metadata", async () => { + const root = await fixtureRoot("clawpatch-review-validation-inspected-"); + await writeFixture(root, "src/index.ts", "const value = 'TODO_BUG';\n"); + const providerOutput = output("src/index.ts"); + providerOutput.inspected.files = [`${root}/src/index.ts`]; + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts"), + providerOutput, + ), + ).resolves.toMatchObject({ findings: [{ title: "Bug" }] }); + }); + + it("rejects evidence for files that were not included in review context", async () => { + const root = await fixtureRoot("clawpatch-review-validation-path-"); + await writeFixture(root, "src/index.ts", "const value = 'TODO_BUG';\n"); + await writeFixture(root, "src/other.ts", "const value = 'TODO_BUG';\n"); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts"), + output("src/other.ts"), + ), + ).rejects.toMatchObject({ code: "malformed-output" }); + }); + + it("rejects stale line ranges and quotes that do not match current file text", async () => { + const root = await fixtureRoot("clawpatch-review-validation-content-"); + await writeFixture(root, "src/index.ts", "const value = 'real';\n"); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts"), + output("src/index.ts", { startLine: 9, endLine: 9, quote: "real" }), + ), + ).rejects.toMatchObject({ code: "malformed-output" }); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts"), + output("src/index.ts", { startLine: 1, endLine: 1, quote: "missing" }), + ), + ).rejects.toMatchObject({ code: "malformed-output" }); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts"), + output("src/index.ts", { startLine: 2, endLine: 2, quote: null }), + ), + ).rejects.toMatchObject({ code: "malformed-output" }); + }); + + it("rejects quotes that only match outside the cited line range", async () => { + const root = await fixtureRoot("clawpatch-review-validation-line-quote-"); + await writeFixture(root, "src/index.ts", "const first = 'TODO_BUG';\nconst second = 'safe';\n"); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts"), + output("src/index.ts", { startLine: 2, endLine: 2, quote: "TODO_BUG" }), + ), + ).rejects.toMatchObject({ code: "malformed-output" }); + }); + + it("rejects evidence that only exists beyond the truncated prompt text", async () => { + const root = await fixtureRoot("clawpatch-review-validation-truncated-"); + await writeFixture(root, "src/index.ts", `${"a".repeat(24_000)}\nconst value = 'TODO_TAIL';\n`); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts", { truncated: true }), + output("src/index.ts", { startLine: null, endLine: null, quote: "TODO_TAIL" }), + ), + ).rejects.toMatchObject({ code: "malformed-output" }); + }); +}); + +function feature(path: string): FeatureRecord { + return { + schemaVersion: 1, + featureId: "feat_test", + title: "Test feature", + summary: "Test feature.", + kind: "library", + source: "test", + confidence: "high", + entrypoints: [{ path, symbol: null, route: null, command: null }], + ownedFiles: [{ path, reason: "test" }], + contextFiles: [], + tests: [], + tags: [], + trustBoundaries: [], + status: "pending", + lock: null, + findingIds: [], + patchAttemptIds: [], + analysisHistory: [], + createdAt: "2026-05-18T00:00:00.000Z", + updatedAt: "2026-05-18T00:00:00.000Z", + }; +} + +function output( + path: string, + evidence: { startLine?: number | null; endLine?: number | null; quote?: string | null } = {}, +): ReviewOutput { + return { + findings: [ + { + title: "Bug", + category: "bug", + severity: "medium", + confidence: "high", + evidence: [ + { + path, + startLine: evidence.startLine ?? 1, + endLine: evidence.endLine ?? 1, + symbol: null, + quote: evidence.quote ?? "TODO_BUG", + }, + ], + reasoning: "Reason.", + reproduction: null, + recommendation: "Fix it.", + whyTestsDoNotAlreadyCoverThis: "No test.", + suggestedRegressionTest: null, + minimumFixScope: "Small.", + }, + ], + inspected: { files: [path], symbols: [], notes: [] }, + }; +} + +function manifest( + path: string, + options: { truncated?: boolean; readable?: boolean } = {}, +): ReviewPromptManifest { + const readable = options.readable ?? true; + return { + maxOwnedFiles: defaultConfig().review.maxOwnedFiles, + maxContextFiles: defaultConfig().review.maxContextFiles, + includedFiles: [ + { + path, + role: "owned", + bytes: readable ? 1 : 0, + includedBytes: readable ? 1 : 0, + truncated: options.truncated ?? false, + readable, + skippedReason: readable ? null : "unreadable", + }, + ], + omittedFiles: [], + promptBytes: 1, + approximateTokens: 1, + }; +} diff --git a/src/review-validation.ts b/src/review-validation.ts new file mode 100644 index 0000000..7566a70 --- /dev/null +++ b/src/review-validation.ts @@ -0,0 +1,157 @@ +import { readFile, realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +import { ClawpatchError } from "./errors.js"; +import { REVIEW_PROMPT_FILE_CHAR_LIMIT, type ReviewPromptManifest } from "./prompt.js"; +import { ClawpatchConfig, FeatureRecord, ReviewOutput } from "./types.js"; + +export async function validateReviewOutput( + root: string, + feature: FeatureRecord, + config: ClawpatchConfig, + manifest: ReviewPromptManifest, + output: ReviewOutput, +): Promise<ReviewOutput> { + const included = includedReviewPaths(feature, config); + const promptFiles = new Map( + manifest.includedFiles.map((file) => [normalizePath(file.path), file]), + ); + const cache = new Map<string, Promise<string>>(); + const findings = output.findings; + for (const finding of findings) { + if (finding.evidence.length === 0) { + throwMalformed(`finding "${finding.title}" has no evidence`); + } + for (const evidence of finding.evidence) { + assertIncludedPath(evidence.path, included, "evidence file"); + const promptFile = promptFiles.get(normalizePath(evidence.path)); + if (promptFile === undefined || !promptFile.readable) { + throwMalformed(`evidence file was not readable in review context: ${evidence.path}`); + } + const contents = await fileContents(root, evidence.path, promptFile.truncated, cache); + assertLineRange(contents, evidence); + assertQuote(contents, evidence); + } + } + return { ...output, findings }; +} + +function includedReviewPaths(feature: FeatureRecord, config: ClawpatchConfig): Set<string> { + return new Set( + [ + ...feature.ownedFiles.slice(0, config.review.maxOwnedFiles).map((ref) => ref.path), + ...feature.contextFiles.slice(0, config.review.maxContextFiles).map((ref) => ref.path), + ].map(normalizePath), + ); +} + +function assertIncludedPath(path: string, included: ReadonlySet<string>, label: string): void { + const normalized = normalizePath(path); + assertSafePath(path, label); + if (!included.has(normalized)) { + throwMalformed(`${label} was not included in review context: ${path}`); + } +} + +function assertSafePath(path: string, label: string): void { + const normalized = normalizePath(path); + if (normalized.startsWith("../") || isAbsolute(normalized)) { + throwMalformed(`${label} escapes repository root: ${path}`); + } +} + +async function fileContents( + root: string, + path: string, + truncated: boolean, + cache: Map<string, Promise<string>>, +): Promise<string> { + const normalized = normalizePath(path); + const key = `${normalized}\0${truncated ? "truncated" : "full"}`; + const existing = cache.get(key); + if (existing !== undefined) { + return existing; + } + const loaded = readIncludedFile(root, normalized, truncated); + cache.set(key, loaded); + return loaded; +} + +async function readIncludedFile(root: string, path: string, truncated: boolean): Promise<string> { + const full = resolve(root, path); + const realRoot = await realpath(root).catch(() => root); + const realFull = await realpath(full).catch(() => null); + if (realFull === null || !isInside(realRoot, realFull)) { + throwMalformed(`evidence file is not readable inside repository: ${path}`); + } + const contents = await readFile(full, "utf8").catch(() => { + throwMalformed(`evidence file is not readable inside repository: ${path}`); + }); + return truncated ? contents.slice(0, REVIEW_PROMPT_FILE_CHAR_LIMIT) : contents; +} + +function assertLineRange( + contents: string, + evidence: ReviewOutput["findings"][number]["evidence"][number], +): void { + const { startLine, endLine } = evidence; + if (startLine === null && endLine === null) { + return; + } + if (startLine === null || endLine === null) { + throwMalformed(`evidence line range must include both startLine and endLine: ${evidence.path}`); + } + if (startLine > endLine) { + throwMalformed(`evidence line range is inverted: ${evidence.path}:${startLine}-${endLine}`); + } + const lineCount = reviewLineCount(contents); + if (endLine > lineCount) { + throwMalformed( + `evidence line range exceeds file length: ${evidence.path}:${startLine}-${endLine}`, + ); + } +} + +function reviewLineCount(contents: string): number { + if (contents.length === 0) { + return 1; + } + const lines = contents.split("\n").length; + return contents.endsWith("\n") ? lines - 1 : lines; +} + +function assertQuote( + contents: string, + evidence: ReviewOutput["findings"][number]["evidence"][number], +): void { + const quote = evidence.quote; + if (quote === null || quote.trim().length === 0) { + return; + } + const target = + evidence.startLine !== null && evidence.endLine !== null + ? contents + .split("\n") + .slice(evidence.startLine - 1, evidence.endLine) + .join("\n") + : contents; + if (!target.includes(quote) && !compactWhitespace(target).includes(compactWhitespace(quote))) { + throwMalformed(`evidence quote does not match file contents: ${evidence.path}`); + } +} + +function isInside(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); +} + +function normalizePath(path: string): string { + return path.replace(/\\/gu, "/").replace(/^\.\/+/u, ""); +} + +function compactWhitespace(value: string): string { + return value.replace(/\s+/gu, " ").trim(); +} + +function throwMalformed(message: string): never { + throw new ClawpatchError(`malformed provider review output: ${message}`, 8, "malformed-output"); +} diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 0ac0482..0f37312 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -15,11 +15,13 @@ import { delimiter, join } from "node:path"; import { fixCommand, cleanLocksCommand, + ciCommand, doctorCommand, initCommand, makeContext, mapCommand, nextCommand, + openPrCommand, reportCommand, revalidateCommand, reviewCommand, @@ -45,12 +47,13 @@ import { statePaths, writeFeature, writeFinding, + writePatchAttempt, } from "./state.js"; import { buildFixPrompt, buildReviewPrompt } from "./prompt.js"; import type { Provider } from "./provider.js"; import { fixtureRoot, testOptions, writeFixture } from "./test-helpers.js"; import { findingRecordSchema } from "./types.js"; -import type { FeatureRecord } from "./types.js"; +import type { FeatureRecord, PatchAttempt } from "./types.js"; async function sinceFixture(prefix: string): Promise<string> { const root = await fixtureRoot(prefix); @@ -240,10 +243,32 @@ describe("workflow", () => { expect(parseArgs(["review", "--skip-git-repo-check"]).flags).toMatchObject({ skipGitRepoCheck: true, }); + expect(parseArgs(["ci", "--skip-git-repo-check"]).flags).toMatchObject({ + skipGitRepoCheck: true, + }); expect(parseArgs(["fix", "--finding", "f", "--dry-run"]).flags).toMatchObject({ dryRun: true, finding: "f", }); + expect( + parseArgs([ + "open-pr", + "--patch", + "pat_123", + "--base", + "main", + "--branch", + "clawpatch/pat_123", + "--draft", + "--dry-run", + ]).flags, + ).toMatchObject({ + patch: "pat_123", + base: "main", + branch: "clawpatch/pat_123", + draft: true, + dryRun: true, + }); }); it("parses review jobs and report filters", () => { @@ -266,6 +291,24 @@ describe("workflow", () => { expect(() => parseArgs(["review", "--mode", "slop"])).toThrow( "invalid --mode; expected default or deslopify", ); + expect( + parseArgs([ + "ci", + "--since", + "origin/main", + "--limit", + "2", + "--jobs", + "1", + "--output", + "report.md", + ]).flags, + ).toMatchObject({ + since: "origin/main", + limit: "2", + jobs: "1", + output: "report.md", + }); expect(parseArgs(["revalidate", "--since", "origin/main"]).flags).toMatchObject({ since: "origin/main", }); @@ -356,6 +399,9 @@ describe("workflow", () => { const reviewed = await reviewCommand(context, { limit: "1" }); const paths = statePaths(join(root, ".clawpatch")); const finding = (await readFindings(paths))[0]; + const reviewedFeature = (await readFeatures(paths)).find( + (feature) => feature.featureId === finding?.featureId, + ); expect(finding).toBeDefined(); await writeFinding(paths, { ...finding!, @@ -392,9 +438,105 @@ describe("workflow", () => { }, ], }); + expect(reviewedFeature?.analysisHistory.at(-1)?.summary).toContain("prompt="); delete process.env["CLAWPATCH_PROVIDER"]; }); + it("runs CI review flow and appends a GitHub step summary", async () => { + const root = await fixtureRoot("clawpatch-ci-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ + name: "ci-flow", + bin: { app: "src/index.ts" }, + scripts: { test: "vitest run" }, + }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + const summaryPath = join(root, "summary.md"); + const reportPath = join(root, "review.md"); + const previousProvider = process.env["CLAWPATCH_PROVIDER"]; + const previousSummary = process.env["GITHUB_STEP_SUMMARY"]; + process.env["CLAWPATCH_PROVIDER"] = "mock"; + process.env["GITHUB_STEP_SUMMARY"] = summaryPath; + try { + const context = await makeContext(testOptions(root)); + const result = await ciCommand(context, { limit: "1", jobs: "1", output: reportPath }); + const summary = await readFile(summaryPath, "utf8"); + const report = await readFile(reportPath, "utf8"); + + expect(result).toMatchObject({ + initialized: true, + mapped: expect.any(Number), + reviewed: 1, + findings: 1, + report: reportPath, + githubStepSummary: summaryPath, + }); + expect(summary).toContain("## Clawpatch review"); + expect(summary).toContain("- findings: 1"); + expect(report).toContain("# clawpatch report"); + } finally { + if (previousProvider === undefined) { + delete process.env["CLAWPATCH_PROVIDER"]; + } else { + process.env["CLAWPATCH_PROVIDER"] = previousProvider; + } + if (previousSummary === undefined) { + delete process.env["GITHUB_STEP_SUMMARY"]; + } else { + process.env["GITHUB_STEP_SUMMARY"] = previousSummary; + } + } + }); + + it("does not count stale report findings as CI review findings", async () => { + const root = await fixtureRoot("clawpatch-ci-stale-findings-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "ci-stale", bin: { app: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + await initGit(root); + await checkCommand(root, "git add package.json src/index.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "initial"'); + const summaryPath = join(root, "summary.md"); + const previousProvider = process.env["CLAWPATCH_PROVIDER"]; + const previousSummary = process.env["GITHUB_STEP_SUMMARY"]; + process.env["CLAWPATCH_PROVIDER"] = "mock"; + process.env["GITHUB_STEP_SUMMARY"] = summaryPath; + try { + const context = await makeContext(testOptions(root)); + await initCommand(context, {}); + await mapCommand(context); + await reviewCommand(context, { limit: "1" }); + + const result = await ciCommand(context, { since: "HEAD", limit: "10" }); + const summary = await readFile(summaryPath, "utf8"); + + expect(result).toMatchObject({ + reviewed: 0, + findings: 0, + reportFindings: 1, + }); + expect(summary).toContain("- findings: 0"); + expect(summary).toContain("- report findings: 1"); + } finally { + if (previousProvider === undefined) { + delete process.env["CLAWPATCH_PROVIDER"]; + } else { + process.env["CLAWPATCH_PROVIDER"] = previousProvider; + } + if (previousSummary === undefined) { + delete process.env["GITHUB_STEP_SUMMARY"]; + } else { + process.env["GITHUB_STEP_SUMMARY"] = previousSummary; + } + } + }); + it.runIf(process.platform !== "win32")( "reviews end-to-end when codex writes fenced JSON with trailing prose", async () => { @@ -2939,6 +3081,48 @@ describe("workflow", () => { delete process.env["CLAWPATCH_PROVIDER"]; }); + it("applies the finding cap after deslopify mode filtering", async () => { + const root = await fixtureRoot("clawpatch-deslopify-cap-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "deslopify-cap" })); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG DESLOPIFY_LATE';\n"); + const previousProvider = process.env["CLAWPATCH_PROVIDER"]; + process.env["CLAWPATCH_PROVIDER"] = "mock"; + try { + const context = await makeContext(testOptions(root)); + const config = defaultConfig(); + config.review.maxFindingsPerFeature = 1; + + await initCommand(context, {}); + await writeFixture(root, ".clawpatch/config.json", JSON.stringify(config, null, 2)); + await mapCommand(context); + const paths = statePaths(join(root, ".clawpatch")); + const sourceFeature = (await readFeatures(paths)).find((feature) => + feature.ownedFiles.some((file) => file.path === "src/index.ts"), + ); + if (sourceFeature === undefined) { + throw new Error("missing source feature"); + } + const reviewed = await reviewCommand(context, { + feature: sourceFeature.featureId, + mode: "deslopify", + }); + const findings = await readFindings(paths); + + expect(reviewed).toMatchObject({ findings: 1 }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + title: "Late simplification finding", + category: "maintainability", + }); + } finally { + if (previousProvider === undefined) { + delete process.env["CLAWPATCH_PROVIDER"]; + } else { + process.env["CLAWPATCH_PROVIDER"] = previousProvider; + } + } + }); + it("does not include escaped feature paths in prompts", async () => { const root = await fixtureRoot("clawpatch-path-escape-"); const siblingSecret = join(root, "..", "secret.txt"); @@ -3014,6 +3198,1044 @@ describe("workflow", () => { expect(symlinkPrompt).not.toContain("do-not-read"); }); + it("previews a PR for an applied patch attempt", async () => { + const root = await fixtureRoot("clawpatch-open-pr-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "open-pr", bin: { open: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + await initGit(root); + await checkCommand(root, "git add package.json src/index.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const previousProvider = process.env["CLAWPATCH_PROVIDER"]; + process.env["CLAWPATCH_PROVIDER"] = "mock"; + try { + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await mapCommand(context); + await reviewCommand(context, { limit: "1" }); + const finding = (await readFindings(paths))[0]; + expect(finding).toBeDefined(); + await writeFixture(root, "src/index.ts", "export const value = 'fixed';\n"); + const baseSha = (await runCommand("git rev-parse HEAD", root)).stdout.trim(); + const now = new Date().toISOString(); + const patch: PatchAttempt = { + schemaVersion: 1, + patchAttemptId: "pat_open_pr", + findingIds: [finding!.findingId], + featureIds: [finding!.featureId], + status: "applied", + plan: "Replace the marker value.", + filesChanged: ["src/index.ts"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha, + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }; + await writePatchAttempt(paths, patch); + + const preview = await openPrCommand(context, { + patch: patch.patchAttemptId, + base: "main", + branch: "clawpatch/pat_open_pr", + dryRun: true, + }); + const stored = (await readPatchAttempts(paths)).find( + (candidate) => candidate.patchAttemptId === patch.patchAttemptId, + ); + const cliPreview = await runCli([ + "--root", + root, + "open-pr", + "--patch", + patch.patchAttemptId, + "--base", + "main", + "--branch", + "clawpatch/pat_open_pr", + "--dry-run", + ]); + + expect(preview).toMatchObject({ + dryRun: true, + patchAttempt: patch.patchAttemptId, + branch: "clawpatch/pat_open_pr", + base: "main", + }); + expect(preview).toMatchObject({ + body: expect.stringContaining("pat_open_pr"), + commands: expect.arrayContaining([ + expect.stringContaining("gh pr create --base main --head clawpatch/pat_open_pr"), + ]), + }); + expect(cliPreview.stdout).toContain("commandsPreview: git switch"); + expect(cliPreview.stdout).toContain("gh pr create --base main --head clawpatch/pat_open_pr"); + expect(stored?.git.prUrl).toBeNull(); + } finally { + if (previousProvider === undefined) { + delete process.env["CLAWPATCH_PROVIDER"]; + } else { + process.env["CLAWPATCH_PROVIDER"] = previousProvider; + } + } + }); + + it("uses a patch branch when the PR base is unknown", async () => { + const root = await fixtureRoot("clawpatch-open-pr-unknown-base-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "open-pr-unknown-base", bin: { open: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + await initGit(root); + await checkCommand(root, "git branch -m develop"); + await checkCommand(root, "git add package.json src/index.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await writeFixture(root, "src/index.ts", "export const value = 'fixed';\n"); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_unknown_base", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Replace the marker value.", + filesChanged: ["src/index.ts"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: "develop", + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + + const preview = await openPrCommand(context, { + patch: "pat_open_pr_unknown_base", + dryRun: true, + }); + + expect(preview).toMatchObject({ + branch: "clawpatch/pat_open_pr_unknown_base", + base: null, + }); + expect(preview).toMatchObject({ + commands: expect.arrayContaining([ + expect.stringContaining("gh pr create --head clawpatch/pat_open_pr_unknown_base"), + ]), + }); + expect(preview).toMatchObject({ + commands: expect.not.arrayContaining([expect.stringContaining("--base main")]), + }); + }); + + it("previews PR commands with execution paths and draft flags", async () => { + const root = await fixtureRoot("clawpatch-open-pr-subdir-"); + const projectRoot = join(root, "packages/app"); + await writeFixture( + root, + "packages/app/package.json", + JSON.stringify({ name: "open-pr-subdir", bin: { open: "src/index.ts" } }), + ); + await writeFixture(root, "packages/app/src/index.ts", "export const value = 'TODO_BUG';\n"); + await initGit(root); + await checkCommand(root, "git add packages"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const context = await makeContext(testOptions(projectRoot)); + const paths = statePaths(join(projectRoot, ".clawpatch")); + await initCommand(context, {}); + await writeFixture(root, "packages/app/src/index.ts", "export const value = 'fixed';\n"); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_subdir", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Replace the marker value.", + filesChanged: ["src/index.ts"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: projectRoot, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + + const preview = await openPrCommand(context, { + patch: "pat_open_pr_subdir", + base: "develop", + branch: "clawpatch/pat_open_pr_subdir", + draft: true, + dryRun: true, + }); + + expect(preview).toMatchObject({ + commands: expect.arrayContaining([ + expect.stringContaining("git add -- ':(literal)packages/app/src/index.ts'"), + expect.stringContaining("gh pr create --base develop --head clawpatch/pat_open_pr_subdir"), + expect.stringContaining("--draft"), + ]), + }); + }); + + it("opens PRs from symlinked project roots with repo-relative patch paths", async () => { + const root = await fixtureRoot("clawpatch-open-pr-symlink-root-"); + const projectRoot = join(root, "packages/app"); + await writeFixture( + root, + "packages/app/package.json", + JSON.stringify({ name: "open-pr-symlink-root" }), + ); + await writeFixture(root, "packages/app/src/index.ts", "export const value = 'TODO_BUG';\n"); + await initGit(root); + await checkCommand(root, "git add packages"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const origin = await fixtureRoot("clawpatch-open-pr-symlink-root-origin-"); + await checkCommand(root, `git init --bare -q ${origin}`); + await checkCommand(root, `git remote add origin ${origin}`); + const linkParent = await fixtureRoot("clawpatch-open-pr-symlink-root-link-"); + const linkedProjectRoot = join(linkParent, "app"); + await symlink(projectRoot, linkedProjectRoot); + const context = await makeContext(testOptions(linkedProjectRoot)); + const paths = statePaths(join(linkedProjectRoot, ".clawpatch")); + await initCommand(context, {}); + await writeFixture(root, "packages/app/src/index.ts", "export const value = 'fixed';\n"); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_symlink_root", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Replace the marker value.", + filesChanged: ["src/index.ts"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: linkedProjectRoot, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + const ghScripts = await fixtureRoot("clawpatch-open-pr-symlink-root-gh-"); + const successGh = join(ghScripts, "success-gh.sh"); + await writeFixture( + ghScripts, + "success-gh.sh", + "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1004\n", + ); + await chmod(successGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = successGh; + const preview = (await openPrCommand(context, { + patch: "pat_open_pr_symlink_root", + base: "main", + branch: "clawpatch/pat_open_pr_symlink_root", + dryRun: true, + })) as { commands: string[] }; + const opened = (await openPrCommand(context, { + patch: "pat_open_pr_symlink_root", + base: "main", + branch: "clawpatch/pat_open_pr_symlink_root", + })) as { commit: string; pr: string }; + const committed = await runCommand(`git show --name-only --format= ${opened.commit}`, root); + + expect(preview.commands).toContain("git add -- ':(literal)packages/app/src/index.ts'"); + expect(opened.pr).toBe("https://github.com/openclaw/clawpatch/pull/1004"); + expect(committed.stdout.trim()).toBe("packages/app/src/index.ts"); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("opens PRs for newly created dangling symlinks", async () => { + const root = await fixtureRoot("clawpatch-open-pr-symlink-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "open-pr-symlink" })); + await initGit(root); + await checkCommand(root, "git add package.json"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const origin = await fixtureRoot("clawpatch-open-pr-symlink-origin-"); + await checkCommand(root, `git init --bare -q ${origin}`); + await checkCommand(root, `git remote add origin ${origin}`); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await symlink("missing-target", join(root, "link")); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_symlink", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Add the symlink.", + filesChanged: ["link"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + const ghScripts = await fixtureRoot("clawpatch-open-pr-symlink-gh-"); + const successGh = join(ghScripts, "success-gh.sh"); + await writeFixture( + ghScripts, + "success-gh.sh", + "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1003\n", + ); + await chmod(successGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = successGh; + const opened = (await openPrCommand(context, { + patch: "pat_open_pr_symlink", + base: "main", + branch: "clawpatch/pat_open_pr_symlink", + })) as { commit: string; pr: string }; + const committed = await runCommand(`git show --name-status --format= ${opened.commit}`, root); + + expect(opened.pr).toBe("https://github.com/openclaw/clawpatch/pull/1003"); + expect(committed.stdout.trim()).toBe("A\tlink"); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("returns an existing PR URL without recreating it", async () => { + const root = await fixtureRoot("clawpatch-open-pr-existing-url-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "open-pr-existing-url" })); + await writeFixture(root, "src/index.ts", "export const value = 'fixed';\n"); + await initGit(root); + await checkCommand(root, "git add package.json src/index.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const commitSha = (await runCommand("git rev-parse HEAD", root)).stdout.trim(); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_existing_url", + findingIds: [], + featureIds: [], + status: "validated", + plan: "Already opened.", + filesChanged: ["src/index.ts"], + commandsRun: [], + testResults: [], + provider: null, + git: { + baseSha: commitSha, + commitSha, + branchName: "clawpatch/pat_open_pr_existing_url", + prUrl: "https://github.com/openclaw/clawpatch/pull/1004", + }, + createdAt: now, + updatedAt: now, + }); + const ghScripts = await fixtureRoot("clawpatch-open-pr-existing-url-gh-"); + const failingGh = join(ghScripts, "fail-gh.sh"); + await writeFixture(ghScripts, "fail-gh.sh", "#!/bin/sh\nexit 42\n"); + await chmod(failingGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = failingGh; + await expect( + openPrCommand(context, { + patch: "pat_open_pr_existing_url", + base: "main", + }), + ).resolves.toMatchObject({ + pr: "https://github.com/openclaw/clawpatch/pull/1004", + branch: "clawpatch/pat_open_pr_existing_url", + commit: commitSha, + }); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("persists the patch commit before failing external PR creation", async () => { + const root = await fixtureRoot("clawpatch-open-pr-retry-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "open-pr-retry", bin: { open: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + await initGit(root); + await checkCommand(root, "git add package.json src/index.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const origin = await fixtureRoot("clawpatch-open-pr-retry-origin-"); + await checkCommand(root, `git init --bare -q ${origin}`); + await checkCommand(root, `git remote add origin ${origin}`); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await writeFixture(root, "src/index.ts", "export const value = 'fixed';\n"); + const now = new Date().toISOString(); + const patch: PatchAttempt = { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_retry", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Replace the marker value.", + filesChanged: ["src/index.ts"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }; + await writePatchAttempt(paths, patch); + const ghScripts = await fixtureRoot("clawpatch-open-pr-gh-"); + const failingGh = join(ghScripts, "fail-gh.sh"); + const successGh = join(ghScripts, "success-gh.sh"); + await writeFixture(ghScripts, "fail-gh.sh", "#!/bin/sh\nexit 42\n"); + await writeFixture( + ghScripts, + "success-gh.sh", + "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/999\n", + ); + await chmod(failingGh, 0o755); + await chmod(successGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = failingGh; + await expect( + openPrCommand(context, { + patch: patch.patchAttemptId, + base: "main", + branch: "clawpatch/pat_open_pr_retry", + }), + ).rejects.toMatchObject({ code: "github-failure" }); + const afterFailure = (await readPatchAttempts(paths)).find( + (candidate) => candidate.patchAttemptId === patch.patchAttemptId, + ); + expect(afterFailure?.git.commitSha).toMatch(/^[a-f0-9]{40}$/u); + expect(afterFailure?.git.branchName).toBe("clawpatch/pat_open_pr_retry"); + const recordedCommit = afterFailure?.git.commitSha; + if (recordedCommit === null || recordedCommit === undefined) { + throw new Error("missing recorded patch commit"); + } + + await writeFixture(root, "src/unrelated.ts", "export const unrelated = true;\n"); + await checkCommand(root, "git add src/unrelated.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "unrelated"'); + const advancedHead = (await runCommand("git rev-parse HEAD", root)).stdout.trim(); + expect(advancedHead).not.toBe(recordedCommit); + + process.env["CLAWPATCH_GH"] = successGh; + await expect( + openPrCommand(context, { + patch: patch.patchAttemptId, + base: "main", + }), + ).resolves.toMatchObject({ + pr: "https://github.com/openclaw/clawpatch/pull/999", + }); + const remoteHead = ( + await runCommand("git ls-remote --heads origin clawpatch/pat_open_pr_retry", root) + ).stdout + .trim() + .split(/\s+/u)[0]; + expect(remoteHead).toBe(recordedCommit); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("creates first PR branches from the recorded patch base", async () => { + const root = await fixtureRoot("clawpatch-open-pr-recorded-base-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "open-pr-recorded-base", bin: { open: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + await initGit(root); + await checkCommand(root, "git add package.json src/index.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const baseSha = (await runCommand("git rev-parse HEAD", root)).stdout.trim(); + const origin = await fixtureRoot("clawpatch-open-pr-recorded-base-origin-"); + await checkCommand(root, `git init --bare -q ${origin}`); + await checkCommand(root, `git remote add origin ${origin}`); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await writeFixture(root, "src/index.ts", "export const value = 'fixed';\n"); + await writeFixture(root, "src/unrelated.ts", "export const unrelated = true;\n"); + await checkCommand(root, "git add src/unrelated.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "unrelated"'); + const advancedHead = (await runCommand("git rev-parse HEAD", root)).stdout.trim(); + expect(advancedHead).not.toBe(baseSha); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_recorded_base", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Replace the marker value.", + filesChanged: ["src/index.ts"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha, + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + const ghScripts = await fixtureRoot("clawpatch-open-pr-recorded-base-gh-"); + const successGh = join(ghScripts, "success-gh.sh"); + await writeFixture( + ghScripts, + "success-gh.sh", + "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1005\n", + ); + await chmod(successGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = successGh; + const opened = (await openPrCommand(context, { + patch: "pat_open_pr_recorded_base", + base: "main", + branch: "clawpatch/pat_open_pr_recorded_base", + })) as { commit: string; pr: string }; + const parent = ( + await runCommand(`git show -s --format=%P ${opened.commit}`, root) + ).stdout.trim(); + const committed = await runCommand(`git show --name-only --format= ${opened.commit}`, root); + + expect(opened.pr).toBe("https://github.com/openclaw/clawpatch/pull/1005"); + expect(parent).toBe(baseSha); + expect(committed.stdout.trim().split("\n")).toEqual(["src/index.ts"]); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("switches to an existing patch branch when opening a PR", async () => { + const root = await fixtureRoot("clawpatch-open-pr-existing-branch-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "open-pr-existing-branch", bin: { open: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + await initGit(root); + await checkCommand(root, "git add package.json src/index.ts"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const origin = await fixtureRoot("clawpatch-open-pr-existing-branch-origin-"); + await checkCommand(root, `git init --bare -q ${origin}`); + await checkCommand(root, `git remote add origin ${origin}`); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await checkCommand(root, "git branch clawpatch/pat_open_pr_existing_branch"); + await writeFixture(root, "src/index.ts", "export const value = 'fixed';\n"); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_existing_branch", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Replace the marker value.", + filesChanged: ["src/index.ts"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: "clawpatch/pat_open_pr_existing_branch", + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + const ghScripts = await fixtureRoot("clawpatch-open-pr-existing-branch-gh-"); + const successGh = join(ghScripts, "success-gh.sh"); + await writeFixture( + ghScripts, + "success-gh.sh", + "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1002\n", + ); + await chmod(successGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = successGh; + const preview = (await openPrCommand(context, { + patch: "pat_open_pr_existing_branch", + base: "main", + dryRun: true, + })) as { commands: string[] }; + const opened = (await openPrCommand(context, { + patch: "pat_open_pr_existing_branch", + base: "main", + })) as { branch: string; pr: string }; + const currentBranch = (await runCommand("git branch --show-current", root)).stdout.trim(); + + expect(preview.commands).toEqual( + expect.arrayContaining(["git switch clawpatch/pat_open_pr_existing_branch"]), + ); + expect(preview.commands).toEqual( + expect.not.arrayContaining(["git switch -c clawpatch/pat_open_pr_existing_branch"]), + ); + expect(opened.pr).toBe("https://github.com/openclaw/clawpatch/pull/1002"); + expect(opened.branch).toBe("clawpatch/pat_open_pr_existing_branch"); + expect(currentBranch).toBe("clawpatch/pat_open_pr_existing_branch"); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("opens PRs for quoted paths without committing pre-staged state", async () => { + const root = await fixtureRoot("clawpatch-open-pr-pathspec-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "open-pr-pathspec", bin: { open: "docs/foo bar.md" } }), + ); + await writeFixture(root, "docs/foo bar.md", "TODO_BUG\n"); + await initGit(root); + await checkCommand(root, "git add package.json docs"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const origin = await fixtureRoot("clawpatch-open-pr-pathspec-origin-"); + await checkCommand(root, `git init --bare -q ${origin}`); + await checkCommand(root, `git remote add origin ${origin}`); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await checkCommand(root, "git add .clawpatch/config.json"); + await writeFixture(root, "docs/foo bar.md", "fixed\n"); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_pathspec", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Replace the marker value.", + filesChanged: ["docs/foo bar.md"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + const ghScripts = await fixtureRoot("clawpatch-open-pr-pathspec-gh-"); + const successGh = join(ghScripts, "success-gh.sh"); + await writeFixture( + ghScripts, + "success-gh.sh", + "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1000\n", + ); + await chmod(successGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = successGh; + const opened = (await openPrCommand(context, { + patch: "pat_open_pr_pathspec", + base: "main", + branch: "clawpatch/pat_open_pr_pathspec", + })) as { commit: string; pr: string }; + const committed = await runCommand(`git show --name-only --format= ${opened.commit}`, root); + const cached = await runCommand("git diff --cached --name-only", root); + + expect(opened.pr).toBe("https://github.com/openclaw/clawpatch/pull/1000"); + expect(committed.stdout.trim().split("\n")).toEqual(["docs/foo bar.md"]); + expect(cached.stdout.trim().split("\n")).toContain(".clawpatch/config.json"); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("opens PRs for literal names that look like git pathspec magic", async () => { + const root = await fixtureRoot("clawpatch-open-pr-literal-pathspec-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "open-pr-literal-pathspec" })); + await writeFixture(root, "README.md", "base\n"); + await initGit(root); + await checkCommand(root, "git add package.json README.md"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const origin = await fixtureRoot("clawpatch-open-pr-literal-pathspec-origin-"); + await checkCommand(root, `git init --bare -q ${origin}`); + await checkCommand(root, `git remote add origin ${origin}`); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await writeFixture(root, ":(top)README.md", "literal\n"); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_literal_pathspec", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Add the reviewed literal pathspec-looking file.", + filesChanged: [":(top)README.md"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + const ghScripts = await fixtureRoot("clawpatch-open-pr-literal-pathspec-gh-"); + const successGh = join(ghScripts, "success-gh.sh"); + await writeFixture( + ghScripts, + "success-gh.sh", + "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1002\n", + ); + await chmod(successGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = successGh; + const preview = (await openPrCommand(context, { + patch: "pat_open_pr_literal_pathspec", + base: "main", + branch: "clawpatch/pat_open_pr_literal_pathspec", + dryRun: true, + })) as { commands: string[] }; + const opened = (await openPrCommand(context, { + patch: "pat_open_pr_literal_pathspec", + base: "main", + branch: "clawpatch/pat_open_pr_literal_pathspec", + })) as { commit: string; pr: string }; + const committed = await runCommand(`git show --name-status --format= ${opened.commit}`, root); + const readme = await readFile(join(root, "README.md"), "utf8"); + + expect(preview.commands).toContain("git add -- ':(literal):(top)README.md'"); + expect(opened.pr).toBe("https://github.com/openclaw/clawpatch/pull/1002"); + expect(committed.stdout.trim()).toBe("A\t:(top)README.md"); + expect(readme).toBe("base\n"); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("opens PRs for staged renames when patch records only the destination", async () => { + const root = await fixtureRoot("clawpatch-open-pr-rename-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "open-pr-rename" })); + await writeFixture(root, "docs/old.md", "TODO_BUG\n"); + await initGit(root); + await checkCommand(root, "git add package.json docs"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const origin = await fixtureRoot("clawpatch-open-pr-rename-origin-"); + await checkCommand(root, `git init --bare -q ${origin}`); + await checkCommand(root, `git remote add origin ${origin}`); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await checkCommand(root, "git mv docs/old.md docs/new.md"); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_rename", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Rename the reviewed file.", + filesChanged: ["docs/new.md"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + const ghScripts = await fixtureRoot("clawpatch-open-pr-rename-gh-"); + const successGh = join(ghScripts, "success-gh.sh"); + await writeFixture( + ghScripts, + "success-gh.sh", + "#!/bin/sh\necho https://github.com/openclaw/clawpatch/pull/1001\n", + ); + await chmod(successGh, 0o755); + const previousGh = process.env["CLAWPATCH_GH"]; + try { + process.env["CLAWPATCH_GH"] = successGh; + const preview = (await openPrCommand(context, { + patch: "pat_open_pr_rename", + base: "main", + branch: "clawpatch/pat_open_pr_rename", + dryRun: true, + })) as { commands: string[] }; + const opened = (await openPrCommand(context, { + patch: "pat_open_pr_rename", + base: "main", + branch: "clawpatch/pat_open_pr_rename", + })) as { commit: string; pr: string }; + const committed = await runCommand(`git show --name-status --format= ${opened.commit}`, root); + + expect(preview.commands).toContain("git add -- ':(literal)docs/new.md'"); + expect(preview.commands).toEqual( + expect.arrayContaining([ + expect.stringMatching(/git commit .*docs\/new\.md.*docs\/old\.md/u), + ]), + ); + expect(opened.pr).toBe("https://github.com/openclaw/clawpatch/pull/1001"); + expect(committed.stdout.trim()).toBe("R100\tdocs/old.md\tdocs/new.md"); + } finally { + if (previousGh === undefined) { + delete process.env["CLAWPATCH_GH"]; + } else { + process.env["CLAWPATCH_GH"] = previousGh; + } + } + }); + + it("previews deletion patch PRs with update staging", async () => { + const root = await fixtureRoot("clawpatch-open-pr-delete-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "open-pr-delete" })); + await writeFixture(root, "docs/old.md", "TODO_BUG\n"); + await initGit(root); + await checkCommand(root, "git add package.json docs"); + await checkCommand(root, 'git -c commit.gpgsign=false commit -q -m "base"'); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + await initCommand(context, {}); + await rm(join(root, "docs/old.md")); + const now = new Date().toISOString(); + await writePatchAttempt(paths, { + schemaVersion: 1, + patchAttemptId: "pat_open_pr_delete", + findingIds: [], + featureIds: [], + status: "applied", + plan: "Delete the reviewed file.", + filesChanged: ["docs/old.md"], + commandsRun: [], + testResults: [ + { + command: "pnpm test", + cwd: root, + exitCode: 0, + durationMs: 1, + stdout: "", + stderr: "", + }, + ], + provider: null, + git: { + baseSha: (await runCommand("git rev-parse HEAD", root)).stdout.trim(), + commitSha: null, + branchName: null, + prUrl: null, + }, + createdAt: now, + updatedAt: now, + }); + + const preview = (await openPrCommand(context, { + patch: "pat_open_pr_delete", + base: "main", + branch: "clawpatch/pat_open_pr_delete", + dryRun: true, + })) as { commands: string[] }; + + expect(preview.commands).toContain("git add -u -- ':(literal)docs/old.md'"); + expect(preview.commands).toEqual( + expect.arrayContaining([expect.stringMatching(/git commit .*docs\/old\.md/u)]), + ); + expect(preview.commands).not.toContain("git add -- docs/old.md"); + }); + it("persists failed patch attempts when provider fix throws", async () => { const root = await fixtureRoot("clawpatch-fix-fail-"); await runCommand(