diff --git a/.github/workflows/pr-merge-conflict-fixer.yaml b/.github/workflows/pr-merge-conflict-fixer.yaml new file mode 100644 index 0000000000..8b5c18f00c --- /dev/null +++ b/.github/workflows/pr-merge-conflict-fixer.yaml @@ -0,0 +1,158 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: PR Conflicts / Resolve + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + scan: + name: Scan conflicting PRs + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: read + outputs: + count: ${{ steps.scan.outputs.count }} + matrix: ${{ steps.scan.outputs.matrix }} + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - id: scan + name: Reproduce PR merges + env: + GITHUB_TOKEN: ${{ github.token }} + run: node --experimental-strip-types --no-warnings tools/pr-merge-conflict-fixer/discover.mts + + resolve: + name: Resolve PR #${{ matrix.item.pr_number }} + needs: scan + if: ${{ needs.scan.outputs.count != '0' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.scan.outputs.matrix) }} + env: + ARTIFACT_DIR: ${{ github.workspace }}/resolution-artifact + OPENSHELL_GATEWAY_ENDPOINT: http://127.0.0.1:8080 + PI_IMAGE: ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd + RESOLUTION_WORKDIR: ${{ github.workspace }}/repo + RESOLVER_CONFIG_DIR: ${{ github.workspace }}/pi-config + SANDBOX_NAME: pr-conflict-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.item.pr_number }} + TRUSTED_CHECKOUT: ${{ github.workspace }}/trusted + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + path: trusted + persist-credentials: false + ref: ${{ github.sha }} + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - id: prepare + name: Reproduce the recorded conflict + env: + MATRIX_ENTRY: ${{ toJSON(matrix.item) }} + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" prepare + + - name: Install OpenShell + run: | + env -u GITHUB_TOKEN -u GH_TOKEN -u PR_REVIEW_ADVISOR_API_KEY \ + NEMOCLAW_NON_INTERACTIVE=1 \ + bash "$TRUSTED_CHECKOUT/scripts/install-openshell.sh" + + - name: Configure OpenShell inference + env: + OPENAI_API_KEY: ${{ secrets.PR_REVIEW_ADVISOR_API_KEY }} + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" configure + + - name: Create the credential-free sandbox + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" create + + - name: Run one Pi conflict-resolution task + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" run + + - name: Export the Git patch + env: + CONFLICT_TREE: ${{ steps.prepare.outputs.conflict_tree }} + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" export + + - name: Delete the sandbox + if: always() + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" delete + + - name: Upload the resolution patch + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-conflict-resolution-${{ matrix.item.pr_number }}-${{ matrix.item.head_sha }}-${{ matrix.item.base_sha }} + path: ${{ env.ARTIFACT_DIR }}/resolution.patch + + publish: + name: Publish PR #${{ matrix.item.pr_number }} + needs: + - scan + - resolve + if: ${{ always() && needs.scan.outputs.count != '0' }} + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.scan.outputs.matrix) }} + env: + ARTIFACT_DIR: ${{ github.workspace }}/resolution-artifact + TRUSTED_CHECKOUT: ${{ github.workspace }}/trusted + steps: + - name: Checkout trusted publisher code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + path: trusted + persist-credentials: false + ref: ${{ github.sha }} + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - id: download + name: Download the resolution patch + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pr-conflict-resolution-${{ matrix.item.pr_number }}-${{ matrix.item.head_sha }}-${{ matrix.item.base_sha }} + path: ${{ env.ARTIFACT_DIR }} + + - name: Validate and publish the merge commit + if: ${{ steps.download.outcome == 'success' }} + env: + GITHUB_TOKEN: ${{ github.token }} + MATRIX_ENTRY: ${{ toJSON(matrix.item) }} + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/publish.mts" diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index 67e7f07263..906f69c34d 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -98,6 +98,10 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ pattern: /(?:^|\/)\.github\/workflows\/pr-e2e-gate\.yaml$/, testsToRun: runTests("test/pr-e2e-gate-workflow.test.ts", "test/pr-e2e-required.test.ts"), }, + { + pattern: /(?:^|\/)\.github\/workflows\/pr-merge-conflict-fixer\.yaml$/, + testsToRun: runTests("test/pr-merge-conflict-fixer-workflow-boundary.test.ts"), + }, { pattern: /(?:^|\/)\.github\/workflows\/(?:hosted-runner-recovery|wsl-e2e|macos-e2e|platform-vitest-main)\.yaml$/, diff --git a/test/pr-merge-conflict-fixer-workflow-boundary.test.ts b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts new file mode 100644 index 0000000000..e4a9c902d6 --- /dev/null +++ b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +const root = path.resolve(import.meta.dirname, ".."); +const workflowSource = fs.readFileSync( + path.join(root, ".github", "workflows", "pr-merge-conflict-fixer.yaml"), + "utf8", +); +const workflow = YAML.parse(workflowSource) as Record; +const policy = YAML.parse( + fs.readFileSync(path.join(root, "tools", "pr-merge-conflict-fixer", "policy.yaml"), "utf8"), +) as Record; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function steps(job: Record): Array> { + return Array.isArray(job.steps) ? (job.steps as Array>) : []; +} + +function required(value: T | undefined, message: string): T { + expect(value, message).toBeDefined(); + return value as T; +} + +function namedStep(job: Record, name: string): Record { + return required( + steps(job).find((candidate) => candidate.name === name), + `Missing workflow step: ${name}`, + ); +} + +function checkout(job: Record): Record { + return required( + steps(job).find((candidate) => String(candidate.uses ?? "").startsWith("actions/checkout@")), + "Missing checkout step", + ); +} + +function resolverInvocation(command: string): string { + return `node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" ${command}`; +} + +describe("PR merge conflict fixer workflow boundary", () => { + const jobs = record(workflow.jobs); + const scan = record(jobs.scan); + const resolve = record(jobs.resolve); + const publish = record(jobs.publish); + + it("runs only after pushes to main with one write stage (#7542)", () => { + expect(record(workflow.on)).toEqual({ push: { branches: ["main"] } }); + expect(workflow.permissions).toEqual({ contents: "read" }); + expect(Object.keys(jobs).sort()).toEqual(["publish", "resolve", "scan"]); + expect(scan.permissions).toEqual({ contents: "read", "pull-requests": "read" }); + expect(resolve.permissions).toEqual({ contents: "read" }); + expect(publish.permissions).toEqual({ contents: "write", "pull-requests": "read" }); + expect(resolve["timeout-minutes"]).toBe(30); + expect( + Object.values(jobs).filter((job) => record(job)["timeout-minutes"] !== undefined), + ).toHaveLength(1); + }); + + it("loads each resolve command from the pushed main SHA (#6952)", () => { + const actionReferences = [scan, resolve, publish] + .flatMap((job) => steps(job)) + .map((step) => step.uses) + .filter((reference): reference is string => typeof reference === "string"); + expect(actionReferences).not.toHaveLength(0); + expect( + actionReferences.every((reference) => /^[^@\s]+\/[^@\s]+@[0-9a-f]{40}$/u.test(reference)), + ).toBe(true); + + for (const job of [scan, resolve, publish]) { + expect(checkout(job).with).toMatchObject({ + "persist-credentials": false, + ref: "${{ github.sha }}", + }); + } + expect(namedStep(publish, "Validate and publish the merge commit").run).toContain( + "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/publish.mts", + ); + + for (const [name, command] of [ + ["Reproduce the recorded conflict", "prepare"], + ["Configure OpenShell inference", "configure"], + ["Create the credential-free sandbox", "create"], + ["Run one Pi conflict-resolution task", "run"], + ["Export the Git patch", "export"], + ["Delete the sandbox", "delete"], + ]) { + expect(namedStep(resolve, name).run).toBe(resolverInvocation(command)); + } + }); + + it("keeps credentials and direct network egress out of Pi (#7542)", () => { + const configure = namedStep(resolve, "Configure OpenShell inference"); + expect(configure.env).toEqual({ + OPENAI_API_KEY: "${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}", + }); + expect(configure.run).toBe(resolverInvocation("configure")); + + const pi = namedStep(resolve, "Run one Pi conflict-resolution task"); + expect(pi.env).toBeUndefined(); + expect(pi.run).toBe(resolverInvocation("run")); + expect(namedStep(resolve, "Install OpenShell").run).toContain( + "env -u GITHUB_TOKEN -u GH_TOKEN -u PR_REVIEW_ADVISOR_API_KEY", + ); + expect(record(resolve.env).OPENSHELL_GATEWAY_ENDPOINT).toBe("http://127.0.0.1:8080"); + expect(record(resolve.env).PI_IMAGE).toBe( + "ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd", + ); + expect(workflowSource.match(/\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gu)).toEqual([ + "${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}", + ]); + expect(workflowSource).not.toMatch( + /\b(?:PAT|GitHub App|checks:\s*write|statuses:\s*write)\b/iu, + ); + expect(policy.network_policies).toEqual({}); + }); + + it("allows only the resolver runtime and sandbox paths (#7542)", () => { + expect(policy.filesystem_policy).toEqual({ + include_workdir: false, + read_only: ["/usr/bin", "/usr/lib", "/usr/share/git-core", "/etc"], + read_write: ["/dev", "/sandbox"], + }); + expect(policy.landlock).toEqual({ compatibility: "hard_requirement" }); + expect(policy.process).toEqual({ run_as_group: "sandbox", run_as_user: "sandbox" }); + expect(record(resolve.env)).toMatchObject({ + ARTIFACT_DIR: "${{ github.workspace }}/resolution-artifact", + RESOLUTION_WORKDIR: "${{ github.workspace }}/repo", + RESOLVER_CONFIG_DIR: "${{ github.workspace }}/pi-config", + TRUSTED_CHECKOUT: "${{ github.workspace }}/trusted", + }); + expect(record(publish.env).ARTIFACT_DIR).toBe("${{ github.workspace }}/resolution-artifact"); + }); + + it("publishes only a successfully exported patch and always deletes the sandbox (#7542)", () => { + const exporter = namedStep(resolve, "Export the Git patch"); + expect(exporter.env).toEqual({ + CONFLICT_TREE: "${{ steps.prepare.outputs.conflict_tree }}", + }); + expect(exporter.run).toBe(resolverInvocation("export")); + + const cleanup = namedStep(resolve, "Delete the sandbox"); + expect(cleanup.if).toBe("always()"); + expect(cleanup.run).toBe(resolverInvocation("delete")); + + const upload = namedStep(resolve, "Upload the resolution patch"); + const download = namedStep(publish, "Download the resolution patch"); + expect(upload.if).toBe("success()"); + expect(download["continue-on-error"]).toBe(true); + expect(record(download.with).name).toBe(record(upload.with).name); + expect(record(download.with).path).toBe("${{ env.ARTIFACT_DIR }}"); + + const publisher = namedStep(publish, "Validate and publish the merge commit"); + expect(publisher.if).toBe("${{ steps.download.outcome == 'success' }}"); + expect(record(publisher.env).GITHUB_TOKEN).toBe("${{ github.token }}"); + }); +}); diff --git a/test/pr-merge-conflict-fixer.test.ts b/test/pr-merge-conflict-fixer.test.ts new file mode 100644 index 0000000000..68b93675bc --- /dev/null +++ b/test/pr-merge-conflict-fixer.test.ts @@ -0,0 +1,542 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + type ConflictMatrixEntry, + type PullRequest, + selectConflictingPullRequests, +} from "../tools/pr-merge-conflict-fixer/discover.mts"; +import { prepareMerge, writeTree } from "../tools/pr-merge-conflict-fixer/merge.mts"; +import { + publishResolution, + validatePublicationState, + validateResolutionPatch, +} from "../tools/pr-merge-conflict-fixer/publish.mts"; +import { + configureOpenShellInference, + createResolutionSandbox, + deleteResolutionSandbox, + exportResolutionPatch, + type ResolverTools, + resolverModelConfiguration, + resolverPrompt, + runResolutionTask, +} from "../tools/pr-merge-conflict-fixer/resolve.mts"; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-conflict-fixer-test-")); + temporaryDirectories.push(directory); + return directory; +} + +function git(repository: string, args: string[]): string { + return execFileSync("git", args, { + cwd: repository, + encoding: "utf8", + env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function write(repository: string, file: string, content: string): void { + const target = path.join(repository, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +function required(value: T | null | undefined, message: string): T { + expect(value, message).not.toBeNull(); + expect(value, message).toBeDefined(); + return value as T; +} + +function resolverEnvironment(): NodeJS.ProcessEnv { + const directory = temporaryDirectory(); + return { + ARTIFACT_DIR: path.join(directory, "artifact"), + CONFLICT_TREE: "a".repeat(40), + GH_TOKEN: "gh-secret", + GITHUB_TOKEN: "github-secret", + HOME: path.join(directory, "home"), + OPENAI_API_KEY: "provider-secret", + OPENSHELL_GATEWAY_ENDPOINT: "http://127.0.0.1:8080", + PATH: "/usr/bin", + PI_IMAGE: "pi-image", + PR_REVIEW_ADVISOR_API_KEY: "advisor-secret", + RESOLUTION_WORKDIR: "/resolution", + RESOLVER_CONFIG_DIR: "/config", + RUNNER_TEMP: directory, + SANDBOX_NAME: "sandbox-test", + TRUSTED_CHECKOUT: "/trusted", + }; +} + +function resolverTools(outputs: string[] = []): ResolverTools { + return { + run: vi.fn(() => outputs.shift() ?? ""), + start: vi.fn(), + wait: vi.fn(async () => undefined), + }; +} + +function createConflictFixture(): { + baseSha: string; + headSha: string; + repository: string; +} { + const repository = temporaryDirectory(); + git(repository, ["init", "--initial-branch=main"]); + git(repository, ["config", "user.name", "Conflict Fixer Test"]); + git(repository, ["config", "user.email", "conflict-fixer@example.test"]); + git(repository, ["config", "commit.gpgsign", "false"]); + write(repository, "conflict.txt", "shared\n"); + git(repository, ["add", "conflict.txt"]); + git(repository, ["commit", "-m", "test: add shared file"]); + + git(repository, ["checkout", "-b", "pull-request"]); + write(repository, "conflict.txt", "pull request\n"); + git(repository, ["add", "conflict.txt"]); + git(repository, ["commit", "-m", "test: change PR side"]); + const headSha = git(repository, ["rev-parse", "HEAD"]); + + git(repository, ["checkout", "main"]); + write(repository, "conflict.txt", "main branch\n"); + write(repository, "main-only.txt", "main\n"); + git(repository, ["add", "conflict.txt", "main-only.txt"]); + git(repository, ["commit", "-m", "test: change main side"]); + const baseSha = git(repository, ["rev-parse", "HEAD"]); + return { baseSha, headSha, repository }; +} + +function entryFor(fixture: ReturnType): ConflictMatrixEntry { + return { + base_sha: fixture.baseSha, + conflict_paths: ["conflict.txt"], + head_ref: "pull-request", + head_sha: fixture.headSha, + pr_number: 42, + }; +} + +function createResolutionPatch( + fixture: ReturnType, + patchPath: string, + mutateRepository: (repository: string) => void = () => undefined, +): string { + const repository = path.join(temporaryDirectory(), "resolver"); + const merge = required( + prepareMerge(fixture.repository, repository, fixture.headSha, fixture.baseSha), + "expected a conflicting merge fixture", + ); + expect(merge.conflictPaths).toEqual(["conflict.txt"]); + write(repository, "conflict.txt", "resolved intent\n"); + git(repository, ["add", "conflict.txt"]); + mutateRepository(repository); + const finalTree = writeTree(repository); + const patch = execFileSync("git", ["diff", "--binary", merge.conflictTree, finalTree], { + cwd: repository, + }); + fs.writeFileSync(patchPath, patch); + return finalTree; +} + +function pullRequest(input: { + baseRef?: string; + headRef?: string; + headRepository?: string; + number: number; + repository?: string; + state?: string; +}): PullRequest { + const repository = input.repository ?? "NVIDIA/NemoClaw"; + return { + base: { ref: input.baseRef ?? "main" }, + head: { + ref: input.headRef ?? `branch-${input.number}`, + repo: + input.headRepository === "deleted" + ? null + : { full_name: input.headRepository ?? repository }, + sha: String(input.number).padStart(40, "0"), + }, + number: input.number, + state: input.state ?? "open", + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("PR merge conflict fixer", () => { + it("skips fork PRs before Git conflict analysis (#7542)", () => { + const checkConflict = vi.fn(() => ["conflict.txt"]); + const selected = selectConflictingPullRequests( + [ + pullRequest({ number: 1 }), + pullRequest({ + headRepository: "contributor/NemoClaw", + number: 2, + }), + ], + "NVIDIA/NemoClaw", + "a".repeat(40), + { checkConflict }, + ); + + expect(selected.map((item) => item.pr_number)).toEqual([1]); + expect(checkConflict).toHaveBeenCalledTimes(1); + }); + + it("selects draft and non-draft same-repository conflicts without labels (#7542)", () => { + const draft = { ...pullRequest({ number: 1 }), draft: true } as PullRequest; + const selected = selectConflictingPullRequests( + [draft, pullRequest({ number: 2 })], + "NVIDIA/NemoClaw", + "b".repeat(40), + { checkConflict: () => ["conflict.txt"] }, + ); + + expect(selected.map((item) => item.pr_number)).toEqual([1, 2]); + }); + + it("accepts a patch that changes only the original conflict paths (#7542)", () => { + const fixture = createConflictFixture(); + const patchPath = path.join(temporaryDirectory(), "resolution.patch"); + const expectedTree = createResolutionPatch(fixture, patchPath); + + const result = validateResolutionPatch({ + entry: entryFor(fixture), + patchPath, + sourceRepository: fixture.repository, + workDirectory: path.join(temporaryDirectory(), "publisher"), + }); + + expect(result.changedPaths).toEqual(["conflict.txt"]); + expect(result.finalTree).toBe(expectedTree); + expect(git(result.repository, ["show", `${result.finalTree}:main-only.txt`])).toBe("main"); + }); + + it("rejects a patch that changes a non-conflict path (#7542)", () => { + const fixture = createConflictFixture(); + const patchPath = path.join(temporaryDirectory(), "resolution.patch"); + createResolutionPatch(fixture, patchPath, (repository) => { + write(repository, "unrelated.txt", "not part of the conflict\n"); + git(repository, ["add", "unrelated.txt"]); + }); + + expect(() => + validateResolutionPatch({ + entry: entryFor(fixture), + patchPath, + sourceRepository: fixture.repository, + workDirectory: path.join(temporaryDirectory(), "publisher"), + }), + ).toThrow(/non-conflict paths: unrelated\.txt/u); + }); + + it("rejects changed main state without comparing the live PR head SHA (#7542)", () => { + const entry: ConflictMatrixEntry = { + base_sha: "a".repeat(40), + conflict_paths: ["conflict.txt"], + head_ref: "feature", + head_sha: "b".repeat(40), + pr_number: 42, + }; + const livePullRequest = { + base: { + ref: "main", + repo: { full_name: "NVIDIA/NemoClaw", node_id: "R_repo" }, + }, + head: { + ref: "feature", + repo: { full_name: "NVIDIA/NemoClaw" }, + }, + state: "open", + }; + + expect(() => + validatePublicationState(entry, "NVIDIA/NemoClaw", livePullRequest, { + object: { sha: "c".repeat(40) }, + }), + ).toThrow(/main changed/u); + expect(() => + validatePublicationState(entry, "NVIDIA/NemoClaw", livePullRequest, { + object: { sha: entry.base_sha }, + }), + ).not.toThrow(); + }); + + it("creates a verified two-parent commit before the atomic head update (#7542)", async () => { + const fixture = createConflictFixture(); + const entry = entryFor(fixture); + const patchPath = path.join(temporaryDirectory(), "resolution.patch"); + const finalTree = createResolutionPatch(fixture, patchPath); + const commitSha = "c".repeat(40); + const requests: Array<{ body: unknown; method: string; path: string }> = []; + const graphql = vi.fn(async (_query: string, variables: Record) => ({ + updateRefs: { + refUpdates: [ + { + afterOid: commitSha, + name: `refs/heads/${entry.head_ref}`, + }, + ], + }, + variables, + })); + const responseHandlers: Record unknown> = { + [`/repos/NVIDIA/NemoClaw/pulls/${entry.pr_number}`]: () => ({ + base: { + ref: "main", + repo: { full_name: "NVIDIA/NemoClaw", node_id: "R_repo" }, + }, + head: { + ref: entry.head_ref, + repo: { full_name: "NVIDIA/NemoClaw" }, + }, + state: "open", + }), + "/repos/NVIDIA/NemoClaw/git/ref/heads/main": () => ({ + object: { sha: entry.base_sha }, + }), + "/repos/NVIDIA/NemoClaw/git/blobs": (body) => { + const encoded = (body as { content: string }).content; + const content = Buffer.from(encoded, "base64"); + const header = Buffer.from(`blob ${content.length}\0`); + return { sha: createHash("sha1").update(header).update(content).digest("hex") }; + }, + "/repos/NVIDIA/NemoClaw/git/trees": () => ({ sha: finalTree }), + "/repos/NVIDIA/NemoClaw/git/commits": () => ({ + sha: commitSha, + verification: { reason: "valid", verified: true }, + }), + }; + const request = vi.fn(async (method: "GET" | "POST", apiPath: string, body?: unknown) => { + requests.push({ body, method, path: apiPath }); + return required(responseHandlers[apiPath], `unexpected request: ${method} ${apiPath}`)(body); + }); + + await expect( + publishResolution({ + entry, + graphql, + patchPath, + repositoryName: "NVIDIA/NemoClaw", + request, + sourceRepository: fixture.repository, + }), + ).resolves.toBe(commitSha); + + const commitRequest = requests.find((item) => item.path.endsWith("/git/commits")); + expect(commitRequest?.body).toEqual({ + message: "merge: resolve conflicts with main", + parents: [entry.head_sha, entry.base_sha], + tree: finalTree, + }); + expect(JSON.stringify(commitRequest?.body)).not.toMatch(/author|committer|signature/u); + expect(graphql).toHaveBeenCalledWith(expect.stringContaining("updateRefs"), { + input: { + refUpdates: [ + { + afterOid: commitSha, + beforeOid: entry.head_sha, + force: false, + name: `refs/heads/${entry.head_ref}`, + }, + ], + repositoryId: "R_repo", + }, + }); + expect(requests.filter((item) => item.path.includes("/pulls/"))).toHaveLength(1); + expect(requests.filter((item) => item.path.endsWith("/git/ref/heads/main"))).toHaveLength(1); + }); + + it("configures approved inference through a loopback gateway (#7542)", async () => { + const env = resolverEnvironment(); + const tools = resolverTools(["/trusted/bin/openshell-sandbox"]); + + await configureOpenShellInference(env, tools); + + const gatewayDirectory = path.join( + required(env.RUNNER_TEMP, "RUNNER_TEMP"), + "openshell-gateway", + ); + const configurationPath = path.join(gatewayDirectory, "gateway.toml"); + const configuration = fs.readFileSync(configurationPath, "utf8"); + expect(configuration).toContain('bind_address = "127.0.0.1:8080"'); + expect(configuration).toContain("allow_unauthenticated_users = true"); + expect(configuration).toContain('supervisor_bin = "/trusted/bin/openshell-sandbox"'); + expect(configuration).not.toContain("provider-secret"); + expect(fs.statSync(configurationPath).mode & 0o777).toBe(0o600); + + const run = vi.mocked(tools.run); + expect(run).toHaveBeenCalledWith( + "openshell", + [ + "provider", + "create", + "--name", + "terra", + "--type", + "openai", + "--credential", + "OPENAI_API_KEY", + "--config", + "OPENAI_BASE_URL=https://inference-api.nvidia.com/v1", + ], + expect.objectContaining({ + env: expect.objectContaining({ OPENAI_API_KEY: "provider-secret" }), + }), + ); + expect(run).toHaveBeenCalledWith( + "openshell", + [ + "inference", + "set", + "--provider", + "terra", + "--model", + "azure/openai/gpt-5.6-terra", + "--timeout", + "900", + ], + expect.anything(), + ); + expect(vi.mocked(tools.start)).toHaveBeenCalledWith( + "openshell-gateway", + ["--config", configurationPath], + expect.objectContaining({ + env: expect.not.objectContaining({ OPENAI_API_KEY: expect.anything() }), + logPath: path.join(gatewayDirectory, "gateway.log"), + }), + ); + expect(run.mock.calls.filter(([, , options]) => options.env.OPENAI_API_KEY)).toHaveLength(1); + const gatewayInfoCalls = run.mock.calls.filter( + ([, args]) => args[0] === "gateway" && args[1] === "info", + ); + expect(gatewayInfoCalls).toHaveLength(2); + expect(gatewayInfoCalls.map(([, , options]) => options.timeout)).toEqual([10_000, 10_000]); + expect( + run.mock.calls.map(([command, args]) => [command, ...args].join(" ")).join("\n"), + ).not.toContain("provider-secret"); + }); + + it("rejects a non-loopback unauthenticated gateway (#7542)", async () => { + const env = resolverEnvironment(); + env.OPENSHELL_GATEWAY_ENDPOINT = "http://192.0.2.1:8080"; + const tools = resolverTools(); + + await expect(configureOpenShellInference(env, tools)).rejects.toThrow( + "OPENSHELL_GATEWAY_ENDPOINT must use a loopback address", + ); + expect(tools.run).not.toHaveBeenCalled(); + expect(tools.start).not.toHaveBeenCalled(); + }); + + it("runs sandbox phases without host credentials (#7542)", () => { + const env = resolverEnvironment(); + const tools = resolverTools(["", "", "", "", "sandbox-test\n", ""]); + + createResolutionSandbox(env, tools); + runResolutionTask(env, tools); + exportResolutionPatch(env, tools); + deleteResolutionSandbox(env, tools); + + const calls = vi.mocked(tools.run).mock.calls; + expect(calls).toHaveLength(6); + expect(required(calls[0], "missing sandbox create call")[1]).toEqual( + expect.arrayContaining([ + "sandbox", + "create", + "--from", + "pi-image", + "--policy", + "/trusted/tools/pr-merge-conflict-fixer/policy.yaml", + "--upload", + "/resolution:/sandbox", + "--upload", + "/config:/sandbox", + "--no-git-ignore", + ]), + ); + expect(required(calls[1], "missing Pi task call")[1]).toEqual( + expect.arrayContaining([ + "sandbox", + "exec", + "--workdir", + "/sandbox/repo", + "PI_CODING_AGENT_DIR=/sandbox/pi-config", + "--model", + "azure/openai/gpt-5.6-terra", + "--no-context-files", + "--no-skills", + "--offline", + ]), + ); + const exportArgs = required(calls[2], "missing patch export call")[1]; + expect(exportArgs).toEqual( + expect.arrayContaining([ + "sandbox", + "exec", + "CONFLICT_TREE=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "/usr/bin/bash", + "-c", + ]), + ); + expect(exportArgs.join("\n")).toContain("git ls-files -u"); + expect(exportArgs.join("\n")).toContain("git diff --binary"); + expect(required(calls[3], "missing patch download call")[1]).toEqual([ + "sandbox", + "download", + "sandbox-test", + "/sandbox/resolution.patch", + `${required(env.ARTIFACT_DIR, "ARTIFACT_DIR")}/`, + ]); + expect(required(calls[4], "missing sandbox list call")[2].capture).toBe(true); + expect(required(calls[5], "missing sandbox delete call")[1]).toEqual([ + "sandbox", + "delete", + "sandbox-test", + ]); + for (const [, , options] of calls) { + expect(options.env.GH_TOKEN).toBeUndefined(); + expect(options.env.GITHUB_TOKEN).toBeUndefined(); + expect(options.env.OPENAI_API_KEY).toBeUndefined(); + expect(options.env.PR_REVIEW_ADVISOR_API_KEY).toBeUndefined(); + } + expect(fs.existsSync(required(env.ARTIFACT_DIR, "ARTIFACT_DIR"))).toBe(true); + }); + + it("skips sandbox cleanup when OpenShell is unavailable (#7542)", () => { + const tools = resolverTools(); + vi.mocked(tools.run).mockImplementation(() => { + throw new Error("openshell unavailable"); + }); + + expect(() => deleteResolutionSandbox(resolverEnvironment(), tools)).not.toThrow(); + expect(tools.run).toHaveBeenCalledOnce(); + }); + + it("configures Pi for credential-free OpenShell inference (#7542)", () => { + const config = JSON.parse(resolverModelConfiguration()); + expect(config.providers.openshell).toMatchObject({ + api: "openai-completions", + apiKey: "unused", + baseUrl: "https://inference.local/v1", + models: [{ id: "azure/openai/gpt-5.6-terra" }], + }); + expect(resolverPrompt()).toContain("Stage every resolved conflict with Git."); + }); +}); diff --git a/tools/pr-merge-conflict-fixer/discover.mts b/tools/pr-merge-conflict-fixer/discover.mts new file mode 100755 index 0000000000..380dc54395 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/discover.mts @@ -0,0 +1,168 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { appendFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { ConflictFixerError, prepareMerge, requireSha } from "./merge.mts"; + +export type PullRequest = { + base: { ref: string }; + head: { + ref: string; + repo: { full_name: string } | null; + sha: string; + }; + number: number; + state: string; +}; + +export type ConflictMatrixEntry = { + base_sha: string; + conflict_paths: string[]; + head_ref: string; + head_sha: string; + pr_number: number; +}; + +export function parseConflictMatrixEntry(value: string): ConflictMatrixEntry { + const parsed = JSON.parse(value) as ConflictMatrixEntry; + if ( + !parsed || + !Number.isInteger(parsed.pr_number) || + parsed.pr_number < 1 || + typeof parsed.head_ref !== "string" || + !Array.isArray(parsed.conflict_paths) || + !parsed.conflict_paths.every((item) => typeof item === "string") + ) { + throw new ConflictFixerError("MATRIX_ENTRY is invalid"); + } + requireSha(parsed.head_sha, "PR head SHA"); + requireSha(parsed.base_sha, "base SHA"); + return parsed; +} + +type DiscoverOptions = { + checkConflict?: (pullRequest: PullRequest, baseSha: string) => string[] | null; +}; + +function required(value: string | undefined, name: string): string { + if (!value) throw new ConflictFixerError(`${name} is required`); + return value; +} + +export function eligibleSameRepositoryPullRequests( + pullRequests: readonly PullRequest[], + repository: string, +): PullRequest[] { + return pullRequests.filter( + (pullRequest) => + pullRequest.state === "open" && + pullRequest.base.ref === "main" && + pullRequest.head.repo?.full_name === repository, + ); +} + +export function selectConflictingPullRequests( + pullRequests: readonly PullRequest[], + repository: string, + baseShaInput: string, + options: DiscoverOptions = {}, +): ConflictMatrixEntry[] { + const baseSha = requireSha(baseShaInput, "base SHA"); + const checkConflict = + options.checkConflict ?? + (() => { + throw new ConflictFixerError("checkConflict is required"); + }); + + return eligibleSameRepositoryPullRequests(pullRequests, repository).flatMap((pullRequest) => { + const conflictPaths = checkConflict(pullRequest, baseSha); + if (!conflictPaths) return []; + return [ + { + base_sha: baseSha, + conflict_paths: conflictPaths, + head_ref: pullRequest.head.ref, + head_sha: requireSha(pullRequest.head.sha, "PR head SHA"), + pr_number: pullRequest.number, + }, + ]; + }); +} + +async function listOpenPullRequests( + repository: string, + token: string, + request: typeof fetch = fetch, +): Promise { + const pullRequests: PullRequest[] = []; + for (let page = 1; ; page += 1) { + const response = await request( + `https://api.github.com/repos/${repository}/pulls?state=open&base=main&per_page=100&page=${page}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (!response.ok) { + throw new ConflictFixerError( + `GitHub pull request listing failed with HTTP ${response.status}`, + ); + } + const pageItems = (await response.json()) as PullRequest[]; + pullRequests.push(...pageItems); + if (pageItems.length < 100) return pullRequests; + } +} + +export async function discoverConflicts(env = process.env): Promise { + const repository = required(env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); + const baseSha = requireSha(required(env.GITHUB_SHA, "GITHUB_SHA"), "GITHUB_SHA"); + const token = required(env.GITHUB_TOKEN, "GITHUB_TOKEN"); + const sourceRepository = required(env.GITHUB_WORKSPACE, "GITHUB_WORKSPACE"); + const pullRequests = await listOpenPullRequests(repository, token); + + return selectConflictingPullRequests(pullRequests, repository, baseSha, { + checkConflict: (pullRequest) => { + const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), `nemoclaw-pr-${pullRequest.number}-`), + ); + const mergeRepository = path.join(temporaryDirectory, "repository"); + try { + const merge = prepareMerge( + sourceRepository, + mergeRepository, + pullRequest.head.sha, + baseSha, + ); + return merge?.conflictPaths ?? null; + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } + }, + }); +} + +async function main(): Promise { + const outputPath = required(process.env.GITHUB_OUTPUT, "GITHUB_OUTPUT"); + const entries = await discoverConflicts(); + appendFileSync( + outputPath, + `count=${entries.length}\nmatrix=${JSON.stringify({ include: entries.map((item) => ({ item })) })}\n`, + ); + console.log(`Selected ${entries.length} conflicting same-repository pull request(s).`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/tools/pr-merge-conflict-fixer/merge.mts b/tools/pr-merge-conflict-fixer/merge.mts new file mode 100644 index 0000000000..5eccdf2c99 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/merge.mts @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +export type MergeState = { + baseSha: string; + conflictPaths: string[]; + conflictTree: string; + headSha: string; + repository: string; +}; + +export class ConflictFixerError extends Error { + constructor(message: string) { + super(message); + this.name = "ConflictFixerError"; + } +} + +const SHA_PATTERN = /^[0-9a-f]{40}$/u; + +export function requireSha(value: string, name: string): string { + if (!SHA_PATTERN.test(value)) { + throw new ConflictFixerError(`${name} must be a lowercase 40-character Git SHA`); + } + return value; +} + +function gitText( + repository: string, + args: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): string { + return execFileSync("git", args, { + cwd: repository, + encoding: "utf8", + env, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function gitBuffer( + repository: string, + args: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): Buffer { + return execFileSync("git", args, { + cwd: repository, + env, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function gitStatus( + repository: string, + args: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): { status: number; stderr: string; stdout: string } { + const result = spawnSync("git", args, { + cwd: repository, + encoding: "utf8", + env, + stdio: ["ignore", "pipe", "pipe"], + }); + return { + status: result.status ?? 1, + stderr: result.stderr ?? "", + stdout: result.stdout ?? "", + }; +} + +function nulPaths(output: Buffer): string[] { + const text = output.toString("utf8"); + if (!text) return []; + const fields = text.split("\0"); + if (fields.at(-1) === "") fields.pop(); + return fields; +} + +function ensureCommit(sourceRepository: string, sha: string): void { + if (gitStatus(sourceRepository, ["cat-file", "-e", `${sha}^{commit}`]).status === 0) return; + const fetched = gitStatus(sourceRepository, ["fetch", "--no-tags", "--force", "origin", sha]); + if (fetched.status !== 0) { + throw new ConflictFixerError( + `Git could not fetch ${sha}: ${fetched.stderr.trim() || fetched.stdout.trim()}`, + ); + } + if (gitStatus(sourceRepository, ["cat-file", "-e", `${sha}^{commit}`]).status !== 0) { + throw new ConflictFixerError(`Git did not fetch commit ${sha}`); + } +} + +export function listUnmergedPaths(repository: string): string[] { + return nulPaths(gitBuffer(repository, ["diff", "--name-only", "--diff-filter=U", "-z"])); +} + +export function listTreeChanges(repository: string, fromTree: string, toTree: string): string[] { + return nulPaths(gitBuffer(repository, ["diff", "--name-only", "-z", fromTree, toTree])); +} + +export function writeTree(repository: string): string { + return requireSha(gitText(repository, ["write-tree"]), "tree"); +} + +export function writeConflictTree(repository: string): string { + const gitDir = path.resolve(repository, gitText(repository, ["rev-parse", "--git-dir"])); + const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "nemoclaw-conflict-index-")); + const temporaryIndex = path.join(temporaryDirectory, "index"); + try { + copyFileSync(path.join(gitDir, "index"), temporaryIndex); + const env = { ...process.env, GIT_INDEX_FILE: temporaryIndex }; + gitText(repository, ["add", "-A"], env); + return requireSha(gitText(repository, ["write-tree"], env), "conflict tree"); + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +export function prepareMerge( + sourceRepository: string, + destination: string, + headShaInput: string, + baseShaInput: string, +): MergeState | null { + const headSha = requireSha(headShaInput, "head SHA"); + const baseSha = requireSha(baseShaInput, "base SHA"); + ensureCommit(sourceRepository, headSha); + ensureCommit(sourceRepository, baseSha); + + rmSync(destination, { force: true, recursive: true }); + const clone = gitStatus(sourceRepository, [ + "clone", + "--no-hardlinks", + "--no-checkout", + sourceRepository, + destination, + ]); + if (clone.status !== 0) { + throw new ConflictFixerError( + `Git could not create the merge workspace: ${clone.stderr.trim() || clone.stdout.trim()}`, + ); + } + + gitText(destination, ["checkout", "--detach", headSha]); + const merge = gitStatus(destination, [ + "-c", + "user.name=NemoClaw Conflict Fixer", + "-c", + "user.email=actions@github.com", + "merge", + "--no-commit", + "--no-ff", + baseSha, + ]); + if (merge.status === 0) return null; + + const conflictPaths = listUnmergedPaths(destination); + if (conflictPaths.length === 0) { + throw new ConflictFixerError( + `Git merge failed without conflict paths: ${merge.stderr.trim() || merge.stdout.trim()}`, + ); + } + + return { + baseSha, + conflictPaths, + conflictTree: writeConflictTree(destination), + headSha, + repository: destination, + }; +} + +export function replaceWithTree(repository: string, tree: string): void { + requireSha(tree, "tree"); + gitText(repository, ["read-tree", "--reset", "-u", tree]); +} + +export function applyResolutionPatch(repository: string, patchPath: string): void { + const applied = gitStatus(repository, ["apply", "--index", "--binary", patchPath]); + if (applied.status !== 0) { + throw new ConflictFixerError( + `Git rejected the resolution patch: ${applied.stderr.trim() || applied.stdout.trim()}`, + ); + } +} + +export function samePaths(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + const sortedLeft = [...left].sort(); + const sortedRight = [...right].sort(); + return sortedLeft.every((value, index) => value === sortedRight[index]); +} diff --git a/tools/pr-merge-conflict-fixer/policy.yaml b/tools/pr-merge-conflict-fixer/policy.yaml new file mode 100644 index 0000000000..e66709aa89 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/policy.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - /usr/bin + - /usr/lib + - /usr/share/git-core + - /etc + read_write: + - /dev + - /sandbox + +landlock: + compatibility: hard_requirement + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: {} diff --git a/tools/pr-merge-conflict-fixer/publish.mts b/tools/pr-merge-conflict-fixer/publish.mts new file mode 100755 index 0000000000..9a94b90dd8 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/publish.mts @@ -0,0 +1,372 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { type ConflictMatrixEntry, parseConflictMatrixEntry } from "./discover.mts"; +import { + applyResolutionPatch, + ConflictFixerError, + listTreeChanges, + prepareMerge, + replaceWithTree, + requireSha, + samePaths, + writeTree, +} from "./merge.mts"; + +type LivePullRequest = { + base: { + ref: string; + repo: { full_name: string; node_id: string }; + }; + head: { + ref: string; + repo: { full_name: string } | null; + }; + state: string; +}; + +type LiveRef = { + object: { sha: string }; +}; + +type GitTreeEntry = { + mode: string; + path: string; + sha: string | null; + type: "blob" | "commit"; +}; + +type GitHubRequest = (method: "GET" | "POST", path: string, body?: unknown) => Promise; + +type GraphqlRequest = (query: string, variables: Record) => Promise; + +function required(value: string | undefined, name: string): string { + if (!value) throw new ConflictFixerError(`${name} is required`); + return value; +} + +export function validatePublicationState( + entry: ConflictMatrixEntry, + repository: string, + pullRequest: LivePullRequest, + mainRef: LiveRef, +): void { + if (pullRequest.state !== "open") throw new ConflictFixerError("The pull request is not open"); + if (pullRequest.base.ref !== "main") { + throw new ConflictFixerError("The pull request no longer targets main"); + } + if ( + pullRequest.base.repo.full_name !== repository || + pullRequest.head.repo?.full_name !== repository + ) { + throw new ConflictFixerError("The pull request is not a same-repository pull request"); + } + if (pullRequest.head.ref !== entry.head_ref) { + throw new ConflictFixerError("The pull request head ref changed after the scan"); + } + if (mainRef.object.sha !== entry.base_sha) { + throw new ConflictFixerError("main changed after the scan"); + } +} + +export function validateResolutionPatch(input: { + entry: ConflictMatrixEntry; + patchPath: string; + sourceRepository: string; + workDirectory: string; +}): { + changedPaths: string[]; + finalTree: string; + repository: string; +} { + const merge = prepareMerge( + input.sourceRepository, + input.workDirectory, + input.entry.head_sha, + input.entry.base_sha, + ); + if (!merge) throw new ConflictFixerError("The recorded revisions no longer conflict"); + if (!samePaths(merge.conflictPaths, input.entry.conflict_paths)) { + throw new ConflictFixerError("The conflict paths do not match the scan result"); + } + + replaceWithTree(merge.repository, merge.conflictTree); + applyResolutionPatch(merge.repository, input.patchPath); + const finalTree = writeTree(merge.repository); + const changedPaths = listTreeChanges(merge.repository, merge.conflictTree, finalTree); + const allowedPaths = new Set(merge.conflictPaths); + const extraPaths = changedPaths.filter((file) => !allowedPaths.has(file)); + if (extraPaths.length > 0) { + throw new ConflictFixerError( + `The resolution patch changes non-conflict paths: ${extraPaths.join(", ")}`, + ); + } + return { changedPaths, finalTree, repository: merge.repository }; +} + +function gitBuffer(repository: string, args: readonly string[]): Buffer { + return execFileSync("git", args, { + cwd: repository, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function changedPathStatuses( + repository: string, + fromTree: string, + toTree: string, +): Array<{ path: string; status: string }> { + const fields = gitBuffer(repository, [ + "diff", + "--name-status", + "--no-renames", + "-z", + fromTree, + toTree, + ]) + .toString("utf8") + .split("\0"); + if (fields.at(-1) === "") fields.pop(); + if (fields.length % 2 !== 0) { + throw new ConflictFixerError("Git returned an invalid changed-path list"); + } + const results: Array<{ path: string; status: string }> = []; + for (let index = 0; index < fields.length; index += 2) { + const status = fields[index]; + const filePath = fields[index + 1]; + if (!status || !filePath || !/^[ADMT]$/u.test(status)) { + throw new ConflictFixerError(`Git returned an unsupported tree change status: ${status}`); + } + results.push({ path: filePath, status }); + } + return results; +} + +function treeEntry(repository: string, tree: string, filePath: string): GitTreeEntry { + const output = gitBuffer(repository, ["ls-tree", "-z", tree, "--", filePath]).toString("utf8"); + const separator = output.indexOf("\t"); + if (separator < 0) throw new ConflictFixerError(`Git tree does not contain ${filePath}`); + const [mode, type, sha] = output.slice(0, separator).split(" "); + if (!mode || (type !== "blob" && type !== "commit") || !sha || !/^[0-9a-f]{40}$/u.test(sha)) { + throw new ConflictFixerError(`Git returned an invalid tree entry for ${filePath}`); + } + return { mode, path: filePath, sha, type }; +} + +async function createGitHubTree(input: { + finalTree: string; + headSha: string; + repository: string; + repositoryName: string; + request: GitHubRequest; +}): Promise { + const entries: GitTreeEntry[] = []; + for (const change of changedPathStatuses(input.repository, input.headSha, input.finalTree)) { + const sourceTree = change.status === "D" ? input.headSha : input.finalTree; + const entry = treeEntry(input.repository, sourceTree, change.path); + if (change.status === "D") { + entries.push({ ...entry, sha: null }); + continue; + } + if (entry.type === "blob") { + const content = gitBuffer(input.repository, ["cat-file", "blob", entry.sha ?? ""]); + const created = (await input.request("POST", `/repos/${input.repositoryName}/git/blobs`, { + content: content.toString("base64"), + encoding: "base64", + })) as { sha?: string }; + if (created.sha !== entry.sha) { + throw new ConflictFixerError(`GitHub returned an unexpected blob SHA for ${entry.path}`); + } + } + entries.push(entry); + } + + const created = (await input.request("POST", `/repos/${input.repositoryName}/git/trees`, { + base_tree: input.headSha, + tree: entries, + })) as { sha?: string }; + if (created.sha !== input.finalTree) { + throw new ConflictFixerError("GitHub returned a tree that differs from the validated tree"); + } + return input.finalTree; +} + +async function publishValidatedTree(input: { + entry: ConflictMatrixEntry; + finalTree: string; + graphql: GraphqlRequest; + pullRequest: LivePullRequest; + repository: string; + repositoryName: string; + request: GitHubRequest; +}): Promise { + const tree = await createGitHubTree({ + finalTree: input.finalTree, + headSha: input.entry.head_sha, + repository: input.repository, + repositoryName: input.repositoryName, + request: input.request, + }); + const commit = (await input.request("POST", `/repos/${input.repositoryName}/git/commits`, { + message: "merge: resolve conflicts with main", + parents: [input.entry.head_sha, input.entry.base_sha], + tree, + })) as { + sha?: string; + verification?: { reason?: string; verified?: boolean }; + }; + const commitSha = requireSha(commit.sha ?? "", "created commit SHA"); + if (commit.verification?.verified !== true) { + throw new ConflictFixerError( + `GitHub did not verify the merge commit: ${commit.verification?.reason ?? "unknown reason"}`, + ); + } + + const mutation = ` + mutation UpdateConflictFixerRef($input: UpdateRefsInput!) { + updateRefs(input: $input) { + refUpdates { + afterOid + name + } + } + } + `; + const result = (await input.graphql(mutation, { + input: { + refUpdates: [ + { + afterOid: commitSha, + beforeOid: input.entry.head_sha, + force: false, + name: `refs/heads/${input.entry.head_ref}`, + }, + ], + repositoryId: input.pullRequest.base.repo.node_id, + }, + })) as { + updateRefs?: { refUpdates?: Array<{ afterOid?: string; name?: string }> }; + }; + const update = result.updateRefs?.refUpdates?.[0]; + if (update?.afterOid !== commitSha || update.name !== `refs/heads/${input.entry.head_ref}`) { + throw new ConflictFixerError("GitHub did not confirm the atomic PR branch update"); + } + return commitSha; +} + +function githubClient(token: string): { graphql: GraphqlRequest; request: GitHubRequest } { + const headers = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }; + const parse = async (response: Response): Promise => { + const body = (await response.json()) as { + data?: unknown; + errors?: Array<{ message?: string }>; + message?: string; + }; + if (!response.ok || body.errors?.length) { + const message = + body.errors + ?.map((error) => error.message) + .filter(Boolean) + .join("; ") || + body.message || + `HTTP ${response.status}`; + throw new ConflictFixerError(`GitHub API request failed: ${message}`); + } + return body.data ?? body; + }; + return { + graphql: async (query, variables) => + parse( + await fetch("https://api.github.com/graphql", { + body: JSON.stringify({ query, variables }), + headers, + method: "POST", + }), + ), + request: async (method, apiPath, body) => + parse( + await fetch(`https://api.github.com${apiPath}`, { + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers, + method, + }), + ), + }; +} + +export async function publishResolution(input: { + entry: ConflictMatrixEntry; + graphql: GraphqlRequest; + patchPath: string; + repositoryName: string; + request: GitHubRequest; + sourceRepository: string; +}): Promise { + const pullRequest = (await input.request( + "GET", + `/repos/${input.repositoryName}/pulls/${input.entry.pr_number}`, + )) as LivePullRequest; + const mainRef = (await input.request( + "GET", + `/repos/${input.repositoryName}/git/ref/heads/main`, + )) as LiveRef; + validatePublicationState(input.entry, input.repositoryName, pullRequest, mainRef); + + const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "nemoclaw-publish-")); + try { + const validated = validateResolutionPatch({ + entry: input.entry, + patchPath: input.patchPath, + sourceRepository: input.sourceRepository, + workDirectory: path.join(temporaryDirectory, "repository"), + }); + return await publishValidatedTree({ + entry: input.entry, + finalTree: validated.finalTree, + graphql: input.graphql, + pullRequest, + repository: validated.repository, + repositoryName: input.repositoryName, + request: input.request, + }); + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +async function main(): Promise { + const entry = parseConflictMatrixEntry(required(process.env.MATRIX_ENTRY, "MATRIX_ENTRY")); + const token = required(process.env.GITHUB_TOKEN, "GITHUB_TOKEN"); + const repositoryName = required(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); + const artifactDirectory = required(process.env.ARTIFACT_DIR, "ARTIFACT_DIR"); + const patchPath = path.join(artifactDirectory, "resolution.patch"); + const client = githubClient(token); + const commitSha = await publishResolution({ + entry, + graphql: client.graphql, + patchPath, + repositoryName, + request: client.request, + sourceRepository: required(process.env.TRUSTED_CHECKOUT, "TRUSTED_CHECKOUT"), + }); + console.log(`Published verified merge commit ${commitSha} to ${entry.head_ref}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/tools/pr-merge-conflict-fixer/resolve.mts b/tools/pr-merge-conflict-fixer/resolve.mts new file mode 100755 index 0000000000..7bbabe4b39 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/resolve.mts @@ -0,0 +1,456 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, spawn } from "node:child_process"; +import { appendFileSync, closeSync, mkdirSync, openSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { type ConflictMatrixEntry, parseConflictMatrixEntry } from "./discover.mts"; +import { ConflictFixerError, prepareMerge, samePaths } from "./merge.mts"; + +export const RESOLVER_MODEL_ID = "azure/openai/gpt-5.6-terra"; + +const HOST_CREDENTIALS = [ + "GH_TOKEN", + "GITHUB_TOKEN", + "OPENAI_API_KEY", + "PR_REVIEW_ADVISOR_API_KEY", +] as const; +const PI_COMMAND = [ + "/usr/bin/node", + "/usr/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + "--provider", + "openshell", + "--model", + RESOLVER_MODEL_ID, + "--thinking", + "medium", + "--tools", + "read,bash,edit,write,grep,find,ls", + "--no-context-files", + "--no-extensions", + "--no-prompt-templates", + "--no-session", + "--no-skills", + "--no-themes", + "--offline", + "--print", + "@/sandbox/pi-config/task.txt", +] as const; +const EXPORT_PATCH_COMMAND = ` +set -euo pipefail +if test -n "$(git ls-files -u)"; then + echo "Pi did not stage every resolved conflict." >&2 + exit 1 +fi +final_tree="$(git write-tree)" +git diff --binary "$CONFLICT_TREE" "$final_tree" > /sandbox/resolution.patch +`.trim(); + +export interface ResolverCommandOptions { + capture?: boolean; + env: NodeJS.ProcessEnv; + timeout?: number; +} + +export interface ResolverStartOptions { + env: NodeJS.ProcessEnv; + logPath: string; +} + +export interface ResolverTools { + run: (command: string, args: readonly string[], options: ResolverCommandOptions) => string; + start: (command: string, args: readonly string[], options: ResolverStartOptions) => void; + wait: (milliseconds: number) => Promise; +} + +function required(value: string | undefined, name: string): string { + if (!value) throw new ConflictFixerError(`${name} is required`); + return value; +} + +export function resolverModelConfiguration(): string { + return `${JSON.stringify( + { + providers: { + openshell: { + api: "openai-completions", + apiKey: "unused", + baseUrl: "https://inference.local/v1", + compat: { + maxTokensField: "max_tokens", + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStore: false, + supportsStrictMode: false, + supportsUsageInStreaming: false, + }, + models: [ + { + contextWindow: 256000, + cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 }, + id: RESOLVER_MODEL_ID, + input: ["text"], + maxTokens: 32768, + name: "GPT-5.6 Terra", + reasoning: false, + }, + ], + }, + }, + }, + null, + 2, + )}\n`; +} + +function gatewayConfiguration(input: { + bindAddress: string; + directory: string; + supervisor: string; +}): string { + return `[openshell] +version = 1 + +[openshell.gateway] +bind_address = "${input.bindAddress}" +compute_drivers = ["docker"] +disable_tls = true + +[openshell.gateway.auth] +allow_unauthenticated_users = true + +[openshell.gateway.gateway_jwt] +signing_key_path = "${path.join(input.directory, "jwt", "signing.pem")}" +public_key_path = "${path.join(input.directory, "jwt", "public.pem")}" +kid_path = "${path.join(input.directory, "jwt", "kid")}" +gateway_id = "pr-conflict-fixer" +ttl_secs = 3600 + +[openshell.drivers.docker] +grpc_endpoint = "http://host.openshell.internal:8080" +supervisor_bin = "${input.supervisor}" +`; +} + +export function resolverPrompt(): string { + return [ + "Resolve the Git merge conflicts in this repository.", + "The repository is merging main into a pull request head.", + "Preserve the intended behavior from both parents.", + "Do not make unrelated changes.", + "Use Git to inspect the merge state.", + "Stage every resolved conflict with Git.", + "Do not create a commit.", + ].join("\n"); +} + +export function prepareResolutionWorkspace(input: { + configDirectory: string; + entry: ConflictMatrixEntry; + sourceRepository: string; + workDirectory: string; +}): string { + const merge = prepareMerge( + input.sourceRepository, + input.workDirectory, + input.entry.head_sha, + input.entry.base_sha, + ); + if (!merge) throw new ConflictFixerError("The recorded PR no longer conflicts with the base SHA"); + if (!samePaths(merge.conflictPaths, input.entry.conflict_paths)) { + throw new ConflictFixerError("The conflict paths do not match the scan result"); + } + + mkdirSync(input.configDirectory, { recursive: true }); + writeFileSync(path.join(input.configDirectory, "models.json"), resolverModelConfiguration(), { + mode: 0o600, + }); + writeFileSync(path.join(input.configDirectory, "task.txt"), `${resolverPrompt()}\n`, { + mode: 0o600, + }); + return merge.conflictTree; +} + +function openshellEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const home = required(env.HOME, "HOME"); + const binaryDirectory = env.XDG_BIN_HOME ?? path.join(home, ".local", "bin"); + return { + ...env, + PATH: [binaryDirectory, env.PATH ?? ""].filter(Boolean).join(path.delimiter), + }; +} + +function credentialFreeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result = openshellEnvironment(env); + for (const name of HOST_CREDENTIALS) delete result[name]; + return result; +} + +function loopbackBindAddress(endpoint: URL): string { + if (!["127.0.0.1", "[::1]"].includes(endpoint.hostname)) { + throw new ConflictFixerError("OPENSHELL_GATEWAY_ENDPOINT must use a loopback address"); + } + return endpoint.host; +} + +const defaultTools: ResolverTools = { + run(command, args, options): string { + const output = execFileSync(command, [...args], { + encoding: "utf8", + env: options.env, + stdio: options.capture ? ["ignore", "pipe", "inherit"] : "inherit", + timeout: options.timeout, + }); + return String(output ?? "").trim(); + }, + start(command, args, options): void { + const log = openSync(options.logPath, "w", 0o600); + try { + const child = spawn(command, [...args], { + detached: true, + env: options.env, + stdio: ["ignore", log, log], + }); + child.on("error", () => undefined); + child.unref(); + } finally { + closeSync(log); + } + }, + wait(milliseconds): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + }, +}; + +export async function configureOpenShellInference( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): Promise { + const providerApiKey = required(env.OPENAI_API_KEY, "OPENAI_API_KEY"); + const commandEnv = credentialFreeEnvironment(env); + const providerEnv = { ...commandEnv, OPENAI_API_KEY: providerApiKey }; + const gatewayDirectory = path.join(required(env.RUNNER_TEMP, "RUNNER_TEMP"), "openshell-gateway"); + const gatewayEndpoint = new URL( + required(env.OPENSHELL_GATEWAY_ENDPOINT, "OPENSHELL_GATEWAY_ENDPOINT"), + ); + const bindAddress = loopbackBindAddress(gatewayEndpoint); + const supervisor = required( + tools.run("which", ["openshell-sandbox"], { capture: true, env: commandEnv }), + "openshell-sandbox", + ); + mkdirSync(gatewayDirectory, { recursive: true }); + tools.run("openshell-gateway", ["generate-certs", "--output-dir", gatewayDirectory], { + env: commandEnv, + }); + const configurationPath = path.join(gatewayDirectory, "gateway.toml"); + writeFileSync( + configurationPath, + gatewayConfiguration({ + bindAddress, + directory: gatewayDirectory, + supervisor, + }), + { mode: 0o600 }, + ); + tools.start("openshell-gateway", ["--config", configurationPath], { + env: commandEnv, + logPath: path.join(gatewayDirectory, "gateway.log"), + }); + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + tools.run("openshell", ["gateway", "info"], { env: commandEnv, timeout: 10_000 }); + break; + } catch { + await tools.wait(1000); + } + } + tools.run("openshell", ["gateway", "info"], { env: commandEnv, timeout: 10_000 }); + tools.run( + "openshell", + [ + "provider", + "create", + "--name", + "terra", + "--type", + "openai", + "--credential", + "OPENAI_API_KEY", + "--config", + "OPENAI_BASE_URL=https://inference-api.nvidia.com/v1", + ], + { env: providerEnv }, + ); + tools.run( + "openshell", + ["inference", "set", "--provider", "terra", "--model", RESOLVER_MODEL_ID, "--timeout", "900"], + { env: commandEnv }, + ); +} + +export function createResolutionSandbox( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): void { + tools.run( + "openshell", + [ + "sandbox", + "create", + "--name", + required(env.SANDBOX_NAME, "SANDBOX_NAME"), + "--from", + required(env.PI_IMAGE, "PI_IMAGE"), + "--policy", + path.join( + required(env.TRUSTED_CHECKOUT, "TRUSTED_CHECKOUT"), + "tools", + "pr-merge-conflict-fixer", + "policy.yaml", + ), + "--upload", + `${required(env.RESOLUTION_WORKDIR, "RESOLUTION_WORKDIR")}:/sandbox`, + "--upload", + `${required(env.RESOLVER_CONFIG_DIR, "RESOLVER_CONFIG_DIR")}:/sandbox`, + "--no-git-ignore", + "--no-tty", + "--", + "/usr/bin/git", + "-C", + "/sandbox/repo", + "status", + "--short", + ], + { env: credentialFreeEnvironment(env) }, + ); +} + +export function runResolutionTask( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): void { + tools.run( + "openshell", + [ + "sandbox", + "exec", + "--name", + required(env.SANDBOX_NAME, "SANDBOX_NAME"), + "--timeout", + "1200", + "--workdir", + "/sandbox/repo", + "--env", + "HOME=/sandbox", + "--env", + "PI_CODING_AGENT_DIR=/sandbox/pi-config", + "--env", + "PI_OFFLINE=1", + "--env", + "TMPDIR=/sandbox", + "--", + ...PI_COMMAND, + ], + { env: credentialFreeEnvironment(env) }, + ); +} + +export function exportResolutionPatch( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): void { + const commandEnv = credentialFreeEnvironment(env); + const sandboxName = required(env.SANDBOX_NAME, "SANDBOX_NAME"); + tools.run( + "openshell", + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--workdir", + "/sandbox/repo", + "--env", + `CONFLICT_TREE=${required(env.CONFLICT_TREE, "CONFLICT_TREE")}`, + "--", + "/usr/bin/bash", + "-c", + EXPORT_PATCH_COMMAND, + ], + { env: commandEnv }, + ); + const artifactDirectory = required(env.ARTIFACT_DIR, "ARTIFACT_DIR"); + mkdirSync(artifactDirectory, { recursive: true }); + tools.run( + "openshell", + ["sandbox", "download", sandboxName, "/sandbox/resolution.patch", `${artifactDirectory}/`], + { env: commandEnv }, + ); +} + +export function deleteResolutionSandbox( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): void { + const commandEnv = credentialFreeEnvironment(env); + const sandboxName = required(env.SANDBOX_NAME, "SANDBOX_NAME"); + let names: string; + try { + names = tools.run("openshell", ["sandbox", "list", "--names"], { + capture: true, + env: commandEnv, + }); + } catch { + return; + } + if (names.split(/\r?\n/u).includes(sandboxName)) { + tools.run("openshell", ["sandbox", "delete", sandboxName], { env: commandEnv }); + } +} + +function prepare(env: NodeJS.ProcessEnv): void { + const entry = parseConflictMatrixEntry(required(env.MATRIX_ENTRY, "MATRIX_ENTRY")); + const conflictTree = prepareResolutionWorkspace({ + configDirectory: required(env.RESOLVER_CONFIG_DIR, "RESOLVER_CONFIG_DIR"), + entry, + sourceRepository: required(env.TRUSTED_CHECKOUT, "TRUSTED_CHECKOUT"), + workDirectory: required(env.RESOLUTION_WORKDIR, "RESOLUTION_WORKDIR"), + }); + appendFileSync(required(env.GITHUB_OUTPUT, "GITHUB_OUTPUT"), `conflict_tree=${conflictTree}\n`); +} + +async function main(): Promise { + const command = required(process.argv[2], "resolve command"); + switch (command) { + case "prepare": + prepare(process.env); + return; + case "configure": + await configureOpenShellInference(process.env); + return; + case "create": + createResolutionSandbox(process.env); + return; + case "run": + runResolutionTask(process.env); + return; + case "export": + exportResolutionPatch(process.env); + return; + case "delete": + deleteResolutionSandbox(process.env); + return; + default: + throw new ConflictFixerError(`Unsupported resolve command: ${command}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +}