diff --git a/packages/merge/README.md b/packages/merge/README.md index 71fa57a..871808c 100644 --- a/packages/merge/README.md +++ b/packages/merge/README.md @@ -1,38 +1,22 @@ # Jules Merge -Detect and surface merge conflicts between a coding agent's changes and the base branch — before or during CI. +Reconcile overlapping PR changes from parallel AI agents — scan, resolve, push, merge. -## Check for conflicts in CI +## Workflow -```bash -npx @google/jules-merge check-conflicts \ - --repo your-org/your-repo \ - --pr 42 \ - --sha abc123 ``` - -When a merge has already been attempted, `check-conflicts` reads conflict markers from the filesystem and returns structured JSON with the affected files, their conflict markers, and a task directive that tells the agent exactly what to resolve. - -## Check for conflicts proactively - -```bash -npx @google/jules-merge check-conflicts \ - --session 7439826373470093109 \ - --repo your-org/your-repo +1. scan — detect overlapping files and build the reconciliation manifest +2. get-contents — fetch file versions (base, main, pr:) for each hot zone +3. stage-resolution — write resolved content for each conflicted file +4. status — confirm all files resolved (ready: true, pending is empty) +5. push — create the multi-parent reconciliation commit and PR +6. merge — merge the reconciliation PR using a merge commit ``` -This queries the Jules SDK for the session's changed files and compares them against recent commits on the base branch. If files overlap, it returns the remote file content (`remoteShadowContent`) so the agent can resolve conflicts without needing `git pull`. - -## Generate a CI workflow +## Quick Start ```bash -npx @google/jules-merge init -``` - -Writes `.github/workflows/jules-merge-check.yml` to your repo. The workflow runs on every pull request: it attempts a merge, and if conflicts exist, runs `check-conflicts` to produce structured output that Jules can act on. - -```bash -npx @google/jules-merge init --base-branch develop --force +npx @google/jules-merge scan --json '{"prs":[10,11],"repo":"owner/repo"}' ``` ## Installation @@ -41,154 +25,112 @@ npx @google/jules-merge init --base-branch develop --force npm i @google/jules-merge ``` -For session-based checks, set authentication: +## Authentication + +Uses the same auth pattern as Fleet. The CLI resolves auth internally — no external decode steps. + +**GitHub App (recommended):** ``` -JULES_API_KEY Required for session mode. -GITHUB_TOKEN Required. GitHub PAT with repo access. +FLEET_APP_ID App ID +FLEET_APP_PRIVATE_KEY_BASE64 Base64-encoded private key (canonical) +FLEET_APP_INSTALLATION_ID Installation ID ``` -Or use GitHub App authentication: +Legacy names (`GITHUB_APP_*`) are accepted with a deprecation warning. + +**Token (fallback):** ``` -GITHUB_APP_ID App ID -GITHUB_APP_PRIVATE_KEY_BASE64 Base64-encoded private key -GITHUB_APP_INSTALLATION_ID Installation ID +GITHUB_TOKEN or GH_TOKEN ``` ## CLI Reference -### `jules-merge check-conflicts` +All commands support `--json ` for agent-first usage. -Detect merge conflicts. Mode is inferred from the arguments provided. +### `jules-merge scan` -``` -jules-merge check-conflicts [options] +Scan PRs for overlapping file changes and build the reconciliation manifest. -Session mode (proactive): - --session Jules session ID - --repo - --base Base branch (default: main) - -Git mode (CI failure): - --pr Pull request number - --sha Failing commit SHA - --repo +```bash +jules-merge scan --json '{"prs":[10,11],"repo":"owner/repo","base":"main"}' +jules-merge scan --prs 10,11 --repo owner/repo --base main ``` -**Session mode** queries the Jules SDK for changed files and compares them against remote commits. Returns `remoteShadowContent` for each conflicting file. +### `jules-merge get-contents` -**Git mode** reads `git status` for unmerged files and extracts conflict markers. Returns a `taskDirective` with resolution instructions. +Fetch file content from base, main, or a specific PR. -### `jules-merge init` +```bash +jules-merge get-contents --json '{"filePath":"src/config.ts","source":"pr:10","repo":"owner/repo"}' +``` -Generate a GitHub Actions workflow for automated conflict detection. +### `jules-merge stage-resolution` -``` -jules-merge init [options] +Stage a resolved file for the reconciliation commit. -Options: - --output-dir Directory to write into (default: .) - --workflow-name Filename without .yml (default: jules-merge-check) - --base-branch Branch to check against (default: main) - --force Overwrite existing file +```bash +jules-merge stage-resolution --json '{"filePath":"src/config.ts","parents":["main","10","11"],"content":"resolved content"}' ``` -## Programmatic API +### `jules-merge status` -All handlers are exported for use in scripts, CI pipelines, or other packages. +Show reconciliation manifest status. -```ts -import { - SessionCheckHandler, - GitCheckHandler, - InitHandler, -} from '@google/jules-merge'; +```bash +jules-merge status ``` -### `SessionCheckHandler` - -Compares a Jules session's changed files against remote commits on the base branch. +### `jules-merge push` -```ts -const handler = new SessionCheckHandler(octokit, julesClient); -const result = await handler.execute({ - sessionId: '7439826373470093109', - repo: 'your-org/your-repo', - base: 'main', -}); +Create the multi-parent reconciliation commit and PR. -if (result.success && result.data.status === 'conflict') { - for (const conflict of result.data.conflicts) { - console.log(`${conflict.filePath}: ${conflict.conflictReason}`); - console.log(conflict.remoteShadowContent); - } -} +```bash +jules-merge push --json '{"branch":"reconcile/batch","message":"Reconcile PRs","repo":"owner/repo"}' ``` -Returns `{ status: 'clean' | 'conflict', conflicts: [...] }` on success. Each conflict includes `filePath`, `conflictReason`, and `remoteShadowContent`. - -### `GitCheckHandler` +Supports `--mergeStrategy sequential` (default, enables GitHub auto-close) or `octopus`. -Reads conflict markers from the local filesystem after a failed merge. +### `jules-merge merge` -```ts -const handler = new GitCheckHandler(); -const result = await handler.execute({ - repo: 'your-org/your-repo', - pullRequestNumber: 42, - failingCommitSha: 'abc123', -}); +Merge the reconciliation PR. Always uses merge commit — never squash or rebase. -if (result.success) { - console.log(result.data.taskDirective); - for (const file of result.data.affectedFiles) { - console.log(`${file.filePath}: ${file.gitConflictMarkers}`); - } -} +```bash +jules-merge merge --json '{"pr":999,"repo":"owner/repo"}' ``` -Returns `{ taskDirective, priority, affectedFiles: [...] }` on success. Each file includes `filePath`, `baseCommitSha`, and `gitConflictMarkers`. - -### `InitHandler` +### `jules-merge schema` -Generates a GitHub Actions workflow file. +Print JSON schema for command inputs/outputs. -```ts -const handler = new InitHandler(); -const result = await handler.execute({ - outputDir: '.', - workflowName: 'jules-merge-check', - baseBranch: 'main', - force: false, -}); - -if (result.success) { - console.log(`Created: ${result.data.filePath}`); -} +```bash +jules-merge schema scan +jules-merge schema --all ``` -Returns `{ filePath, content }` on success. - -### `buildWorkflowYaml` - -Generate the workflow YAML string without writing to disk. +## Programmatic API ```ts -import { buildWorkflowYaml } from '@google/jules-merge'; +import { + scanHandler, + getContentsHandler, + stageResolutionHandler, + statusHandler, + pushHandler, + mergeHandler, + createMergeOctokit, +} from '@google/jules-merge'; -const yaml = buildWorkflowYaml({ - workflowName: 'merge-check', - baseBranch: 'main', -}); +const octokit = createMergeOctokit(); +const scan = await scanHandler(octokit, { prs: [10, 11], repo: 'owner/repo' }); ``` -## MCP Server +All handlers take an Octokit instance as the first argument (dependency injection). -The package exposes an MCP server with two tools: +## MCP Server -- **`check_conflicts`** — Detects merge conflicts (session or git mode) -- **`init_workflow`** — Generates a CI workflow file +7 tools: `scan_fleet`, `get_file_contents`, `stage_resolution`, `get_status`, `push_reconciliation`, `merge_reconciliation`, `get_schema`. ```bash jules-merge mcp diff --git a/packages/merge/SKILL.md b/packages/merge/SKILL.md new file mode 100644 index 0000000..7fc5f8d --- /dev/null +++ b/packages/merge/SKILL.md @@ -0,0 +1,71 @@ +--- +name: jules-merge +--- + +# jules-merge Agent Skill + +## Workflow Order + +Always execute commands in this sequence: + +1. `scan` — detect conflicts and build the manifest +2. `get-contents` — fetch file versions (base, main, pr:) for each hot zone +3. `stage-resolution` — write resolved content for each conflicted file +4. `status` — confirm all files resolved (`ready: true`, `pending` is empty) +5. `push` — create the multi-parent reconciliation commit and PR +6. `merge` — merge the reconciliation PR using a merge commit + +## Command Reference + +### `scan` +- Required: `prs` (array of PR numbers), `repo` (owner/repo) +- Optional: `base` (branch name, default: main), `includeClean` + +### `get-contents` +- `source` values: `"base"` | `"main"` | `"pr:"` (e.g. `"pr:42"`) +- Always check `totalLines` in the response — if it exceeds your context budget, surface to the user rather than attempting to process the file +- `"base"` = the common ancestor commit (merge base), not main + +### `stage-resolution` +- `parents` format: `"main,,"` — always start with `"main"`, followed by the PR numbers (as strings) that touch the file +- Example: `["main", "10", "11"]` +- Either `content` (inline string) or `fromFile` (local path) must be provided +- Use `--dry-run` to validate without writing to the manifest + +### `push` +- `mergeStrategy` values: `"sequential"` (default) | `"octopus"` + - `sequential`: creates N 2-parent merge commits in a chain — required for GitHub auto-close + - `octopus`: creates a single N-parent commit — use for non-GitHub platforms or atomic history +- Check `warnings` in the output — if `"BASE_SHA_MISMATCH"` is present, re-run `scan` before merging +- `--dry-run` is safe to call at any time; it validates without writing +- `push` is idempotent: calling it twice on the same branch reuses the existing PR +- When using `sequential`, the output includes `mergeChain` — an array of `{ commitSha, parents, prId }` per step + +### `merge` +- Always uses merge commit strategy — never squash or rebase +- This preserves the ancestry chain that auto-closes fleet PRs via GitHub's "closes" detection + +## Exit Codes + +| Code | Meaning | Action | +|------|---------|--------| +| `0` | Success | Continue | +| `1` | Recoverable conflict | Surface to user or re-scan | +| `2` | Hard error | Abort and surface to user | + +## Key Invariants + +- **Merge strategy**: always `merge` (not squash/rebase) — squash breaks the ancestry chain that closes fleet PRs +- **Push merge strategy**: default `sequential` creates 2-parent chain for GitHub compatibility; use `octopus` only for non-GitHub platforms +- **parents format**: `["main", "", ""]` — the string `"main"` is always first, followed by PR numbers as strings +- **Scan before push**: if `push` returns `warnings: ["BASE_SHA_MISMATCH"]`, re-run `scan` to refresh the base SHA before proceeding +- **Context window**: check `totalLines` on every `get-contents` response; if a file is too large for your context budget, surface to the user rather than processing it +- **Idempotency**: `scan` overwrites the manifest; `push` reuses an existing open PR on the same branch +- **No pending files**: `push` will throw if `status.pending` is non-empty — resolve all hot zones first + +## Schema Introspection + +``` +jules-merge schema # input/output schema for one command +jules-merge schema --all # all schemas at once +``` diff --git a/packages/merge/build.ts b/packages/merge/build.ts index 9145a8b..3e8bca8 100644 --- a/packages/merge/build.ts +++ b/packages/merge/build.ts @@ -19,12 +19,12 @@ const shared = { format: 'esm' as const, root: './src', external: [ - '@google/jules-sdk', '@octokit/auth-app', '@octokit/rest', '@modelcontextprotocol/sdk', 'citty', 'zod', + 'zod-to-json-schema', ], outdir: './dist', naming: '[dir]/[name].mjs', diff --git a/packages/merge/package.json b/packages/merge/package.json index 199a35a..b1e12fa 100644 --- a/packages/merge/package.json +++ b/packages/merge/package.json @@ -2,7 +2,7 @@ "name": "@google/jules-merge", "version": "0.0.3", "type": "module", - "description": "Predictive conflict detection for parallel AI agents", + "description": "Reconcile overlapping PR changes from parallel AI agents", "types": "./dist/merge/src/index.d.ts", "bin": { "jules-merge": "./dist/cli/index.mjs" @@ -37,11 +37,11 @@ "access": "public" }, "dependencies": { - "@google/jules-sdk": "^0.1.0", "@octokit/auth-app": "^8.2.0", "@octokit/rest": "^21.0.0", "citty": "^0.1.6", - "zod": "^3.25.0" + "zod": "^3.25.0", + "zod-to-json-schema": "^3.24.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.1" diff --git a/packages/merge/src/__tests__/conflicts/git-handler.test.ts b/packages/merge/src/__tests__/conflicts/git-handler.test.ts deleted file mode 100644 index 465e06e..0000000 --- a/packages/merge/src/__tests__/conflicts/git-handler.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock shared modules -vi.mock('../../shared/git.js', () => ({ - gitStatusUnmerged: vi.fn(), - gitMergeBase: vi.fn(), -})); -vi.mock('node:fs/promises', () => ({ - readFile: vi.fn(), -})); - -import { GitCheckHandler } from '../../conflicts/git-handler.js'; -import { gitStatusUnmerged, gitMergeBase } from '../../shared/git.js'; -import { readFile } from 'node:fs/promises'; - -describe('GitCheckHandler', () => { - let handler: GitCheckHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new GitCheckHandler(); - }); - - it('parses conflict markers and builds task directive', async () => { - vi.mocked(gitStatusUnmerged).mockResolvedValue({ - ok: true, - data: ['src/a.ts'], - }); - vi.mocked(readFile).mockResolvedValue( - 'line1\n<<<<<<< HEAD\nour code\n=======\ntheir code\n>>>>>>> main\nline2' as any, - ); - vi.mocked(gitMergeBase).mockResolvedValue({ - ok: true, - data: 'abc123', - }); - - const result = await handler.execute({ - repo: 'owner/repo', - pullRequestNumber: 42, - failingCommitSha: 'def456', - }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.priority).toBe('critical'); - expect(result.data.affectedFiles).toHaveLength(1); - expect(result.data.affectedFiles[0].filePath).toBe('src/a.ts'); - expect(result.data.affectedFiles[0].baseCommitSha).toBe('abc123'); - expect(result.data.affectedFiles[0].gitConflictMarkers).toContain('<<<<<<< HEAD'); - expect(result.data.taskDirective).toContain('PR #42'); - } - }); - - it('returns success with empty affectedFiles when no conflicts found', async () => { - vi.mocked(gitStatusUnmerged).mockResolvedValue({ - ok: true, - data: [], - }); - - const result = await handler.execute({ - repo: 'owner/repo', - pullRequestNumber: 42, - failingCommitSha: 'def456', - }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.affectedFiles).toHaveLength(0); - expect(result.data.priority).toBe('standard'); - expect(result.data.taskDirective).toContain('No merge conflicts'); - } - }); - - // Regression test: ensure no-conflict result would cause CLI to exit 0 - // Previously, no conflicts returned success: false, causing CI to fail - // even when there were no actual merge conflicts. - it('regression: no conflicts produces exit code 0 (success: true)', async () => { - vi.mocked(gitStatusUnmerged).mockResolvedValue({ - ok: true, - data: [], - }); - - const result = await handler.execute({ - repo: 'owner/repo', - pullRequestNumber: 1, - failingCommitSha: 'abc', - }); - - // The CLI does: process.exit(result.success ? 0 : 1) - // This MUST be 0 when there are no conflicts - const exitCode = result.success ? 0 : 1; - expect(exitCode).toBe(0); - }); - - it('returns GIT_STATUS_FAILED on git error', async () => { - vi.mocked(gitStatusUnmerged).mockResolvedValue({ - ok: false, - error: 'git not found', - }); - - const result = await handler.execute({ - repo: 'owner/repo', - pullRequestNumber: 42, - failingCommitSha: 'def456', - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('GIT_STATUS_FAILED'); - } - }); -}); diff --git a/packages/merge/src/__tests__/conflicts/git-spec.test.ts b/packages/merge/src/__tests__/conflicts/git-spec.test.ts deleted file mode 100644 index ebb23dd..0000000 --- a/packages/merge/src/__tests__/conflicts/git-spec.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect } from 'vitest'; -import { GitCheckInputSchema } from '../../conflicts/git-spec.js'; - -describe('GitCheckInputSchema', () => { - const validInput = { - repo: 'owner/repo', - pullRequestNumber: 42, - failingCommitSha: 'abc123def', - }; - - it('accepts valid input', () => { - const result = GitCheckInputSchema.safeParse(validInput); - expect(result.success).toBe(true); - }); - - const invalidCases = [ - { - name: 'rejects pullRequestNumber <= 0', - input: { ...validInput, pullRequestNumber: 0 }, - }, - { - name: 'rejects empty failingCommitSha', - input: { ...validInput, failingCommitSha: '' }, - }, - { - name: 'rejects repo without /', - input: { ...validInput, repo: 'noslash' }, - }, - ]; - - it.each(invalidCases)('$name', ({ input }) => { - const result = GitCheckInputSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); diff --git a/packages/merge/src/__tests__/conflicts/session-handler.test.ts b/packages/merge/src/__tests__/conflicts/session-handler.test.ts deleted file mode 100644 index 22af33b..0000000 --- a/packages/merge/src/__tests__/conflicts/session-handler.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock shared modules -vi.mock('@google/jules-sdk', () => ({ jules: {} })); -vi.mock('../../shared/session.js', () => ({ - getSessionChangedFiles: vi.fn(), - createJulesClient: {}, -})); -vi.mock('../../shared/github.js', () => ({ - compareCommits: vi.fn(), - getFileContent: vi.fn(), - createOctokit: vi.fn(), -})); - -import { SessionCheckHandler } from '../../conflicts/session-handler.js'; -import { getSessionChangedFiles } from '../../shared/session.js'; -import { compareCommits, getFileContent } from '../../shared/github.js'; - -describe('SessionCheckHandler', () => { - const mockOctokit = {} as any; - const mockJulesClient = {} as any; - let handler: SessionCheckHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new SessionCheckHandler(mockOctokit, mockJulesClient); - }); - - it('returns clean status when no file overlap', async () => { - vi.mocked(getSessionChangedFiles).mockResolvedValue([ - { path: 'src/a.ts', changeType: 'modified' }, - ]); - vi.mocked(compareCommits).mockResolvedValue(['src/b.ts', 'src/c.ts']); - - const result = await handler.execute({ - sessionId: 'session-123', - repo: 'owner/repo', - base: 'main', - }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.status).toBe('clean'); - expect(result.data.conflicts).toEqual([]); - } - }); - - it('returns conflict status with shadow content when files overlap', async () => { - vi.mocked(getSessionChangedFiles).mockResolvedValue([ - { path: 'src/a.ts', changeType: 'modified' }, - { path: 'src/b.ts', changeType: 'modified' }, - ]); - vi.mocked(compareCommits).mockResolvedValue(['src/b.ts', 'src/c.ts']); - vi.mocked(getFileContent).mockResolvedValue('remote content of b.ts'); - - const result = await handler.execute({ - sessionId: 'session-123', - repo: 'owner/repo', - base: 'main', - }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.status).toBe('conflict'); - expect(result.data.conflicts).toHaveLength(1); - expect(result.data.conflicts[0]).toEqual({ - filePath: 'src/b.ts', - conflictReason: 'Remote commit modified this file since branch creation.', - remoteShadowContent: 'remote content of b.ts', - }); - } - }); - - it('returns SESSION_QUERY_FAILED on SDK error', async () => { - vi.mocked(getSessionChangedFiles).mockRejectedValue( - new Error('Session not found'), - ); - - const result = await handler.execute({ - sessionId: 'bad-session', - repo: 'owner/repo', - base: 'main', - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('SESSION_QUERY_FAILED'); - } - }); - - it('returns GITHUB_API_ERROR on GitHub failure', async () => { - vi.mocked(getSessionChangedFiles).mockResolvedValue([ - { path: 'src/a.ts', changeType: 'modified' }, - ]); - vi.mocked(compareCommits).mockRejectedValue( - Object.assign(new Error('Forbidden'), { status: 403 }), - ); - - const result = await handler.execute({ - sessionId: 'session-123', - repo: 'owner/repo', - base: 'main', - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('GITHUB_API_ERROR'); - } - }); -}); diff --git a/packages/merge/src/__tests__/conflicts/session-spec.test.ts b/packages/merge/src/__tests__/conflicts/session-spec.test.ts deleted file mode 100644 index 9f9c5e4..0000000 --- a/packages/merge/src/__tests__/conflicts/session-spec.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect } from 'vitest'; -import { SessionCheckInputSchema } from '../../conflicts/session-spec.js'; - -describe('SessionCheckInputSchema', () => { - const validInput = { - sessionId: 'session-123', - repo: 'owner/repo', - base: 'main', - }; - - it('accepts valid input', () => { - const result = SessionCheckInputSchema.safeParse(validInput); - expect(result.success).toBe(true); - }); - - it('defaults base to main', () => { - const result = SessionCheckInputSchema.safeParse({ - sessionId: 'session-123', - repo: 'owner/repo', - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.base).toBe('main'); - } - }); - - const invalidCases = [ - { - name: 'rejects empty sessionId', - input: { ...validInput, sessionId: '' }, - }, - { - name: 'rejects empty repo', - input: { ...validInput, repo: '' }, - }, - { - name: 'rejects repo without /', - input: { ...validInput, repo: 'noslash' }, - }, - ]; - - it.each(invalidCases)('$name', ({ input }) => { - const result = SessionCheckInputSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); diff --git a/packages/merge/src/__tests__/fixtures/github.ts b/packages/merge/src/__tests__/fixtures/github.ts new file mode 100644 index 0000000..5b3ac89 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github.ts @@ -0,0 +1,273 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Mock GitHub API fixture — DI-compatible (Octokit param ignored in mocks). + * Provides in-memory simulations of all GitHub operations. + */ + +import crypto from 'crypto'; + +export const state: any = { + branches: { + main: { + commit: { + sha: 'main-sha-v1', + commit: { tree: { sha: 'tree-main-v1' } }, + }, + }, + }, + prs: { + 1: { head: { sha: 'pr1-head-sha', ref: 'branch1' } }, + 2: { head: { sha: 'pr2-head-sha', ref: 'branch2' } }, + 3: { head: { sha: 'pr3-head-sha', ref: 'branch3' } }, + 10: { head: { sha: 'pr10-head-sha', ref: 'branch10' } }, + 11: { head: { sha: 'pr11-head-sha', ref: 'branch11' } }, + 20: { head: { sha: 'pr20-head-sha', ref: 'branch20' } }, + 21: { head: { sha: 'pr21-head-sha', ref: 'branch21' } }, + 30: { head: { sha: 'pr30-head-sha', ref: 'branch30' } }, + 31: { head: { sha: 'pr31-head-sha', ref: 'branch31' } }, + 40: { head: { sha: 'pr40-head-sha', ref: 'branch40' } }, + 41: { head: { sha: 'pr41-head-sha', ref: 'branch41' } }, + 42: { head: { sha: 'pr42-head-sha', ref: 'branch42' } }, + 50: { head: { sha: 'pr50-head-sha', ref: 'branch50' } }, + 51: { head: { sha: 'pr51-head-sha', ref: 'branch51' } }, + }, + compares: { + 'main-sha-v1...pr1-head-sha': { + files: [{ filename: 'src/auth.ts', status: 'modified' }], + }, + 'main-sha-v1...pr2-head-sha': { + files: [{ filename: 'src/payments.ts', status: 'modified' }], + }, + 'main-sha-v1...pr3-head-sha': { + files: [{ filename: 'src/logging.ts', status: 'modified' }], + }, + 'main-sha-v1...pr10-head-sha': { + files: [{ filename: 'src/config.ts', status: 'modified' }], + merge_base_commit: { sha: 'base-sha' }, + }, + 'main-sha-v1...pr11-head-sha': { + files: [{ filename: 'src/config.ts', status: 'modified' }], + }, + 'main-sha-v1...pr20-head-sha': { files: [] }, + 'main-sha-v1...pr21-head-sha': { files: [] }, + 'main-sha-v1...pr40-head-sha': { + files: [ + { filename: 'src/a.ts', status: 'modified' }, + { filename: 'src/c.ts', status: 'modified' }, + ], + }, + 'main-sha-v1...pr41-head-sha': { + files: [ + { filename: 'src/a.ts', status: 'modified' }, + { filename: 'src/b.ts', status: 'modified' }, + ], + }, + 'main-sha-v1...pr42-head-sha': { + files: [ + { filename: 'src/b.ts', status: 'modified' }, + { filename: 'src/c.ts', status: 'modified' }, + ], + }, + // PR 50: deletes src/deprecated.ts; PR 51: adds src/newfeature.ts — no overlap, clean batch + 'main-sha-v1...pr50-head-sha': { + files: [{ filename: 'src/deprecated.ts', status: 'removed' }], + }, + 'main-sha-v1...pr51-head-sha': { + files: [{ filename: 'src/newfeature.ts', status: 'added' }], + }, + }, + contents: { + 'src/config.ts|base-sha': 'export const DEFAULT_TIMEOUT = 5000;', + 'src/config.ts|pr10-head-sha': 'export const DEFAULT_TIMEOUT = 10000;', + 'src/config.ts|pr11-head-sha': 'export const DEFAULT_TIMEOUT = 3000;', + }, + repo: { + allow_squash_merge: true, + allow_merge_commit: true, + }, + refs: {} as Record, +}; + +// DI-compatible mocks: octokit parameter is accepted but ignored. + +export async function getBranch( + _octokit: any, + _owner: string, + _repo: string, + branch: string, +) { + return state.branches[branch] || state.branches.main; +} + +export async function getPullRequest( + _octokit: any, + _owner: string, + _repo: string, + pull_number: number, +) { + return state.prs[pull_number]; +} + +export async function compareCommits( + _octokit: any, + _owner: string, + _repo: string, + base: string, + head: string, +) { + const key = `${base}...${head}`; + return state.compares[key] || { files: [] }; +} + +export async function getContents( + _octokit: any, + _owner: string, + _repo: string, + path: string, + ref: string, +) { + const key = `${path}|${ref}`; + const content = state.contents[key] || `content of ${path} at ${ref}`; + return { + content, + sha: crypto.createHash('sha1').update(content).digest('hex'), + }; +} + +export async function getTree( + _octokit: any, + _owner: string, + _repo: string, + _tree_sha: string, +) { + return {}; +} + +export async function createBlob( + _octokit: any, + _owner: string, + _repo: string, + content: string, + _encoding: 'utf-8' | 'base64' = 'utf-8', +) { + return { + sha: crypto.createHash('sha1').update(content).digest('hex'), + }; +} + +export async function createTree( + _octokit: any, + _owner: string, + _repo: string, + _base_tree: string, + _tree: any[], +) { + return { sha: 'new-tree-sha' }; +} + +let commitCounter = 0; +export function resetCommitCounter() { + commitCounter = 0; +} +export async function createCommit( + _octokit: any, + _owner: string, + _repo: string, + _message: string, + _tree: string, + parents: string[], +) { + commitCounter++; + return { sha: `new-commit-sha-${commitCounter}`, parents }; +} + +export async function createRef( + _octokit: any, + _owner: string, + _repo: string, + ref: string, + sha: string, +) { + state.refs[ref] = sha; + return { ref, sha }; +} + +export async function updateRef( + _octokit: any, + _owner: string, + _repo: string, + ref: string, + sha: string, + _force: boolean = false, +) { + if (!state.refs[ref]) throw new Error('Not found'); + state.refs[ref] = sha; + return { ref, sha }; +} + +export async function createPullRequest( + _octokit: any, + _owner: string, + _repo: string, + title: string, + head: string, + _base: string, + _body?: string, +) { + const pr = { + number: 999, + html_url: 'https://github.com/owner/repo/pull/999', + title, + }; + state.pullRequests = state.pullRequests ?? {}; + state.pullRequests[head] = pr; + return pr; +} + +export async function listPullRequests( + _octokit: any, + _owner: string, + _repo: string, + head: string, + _base: string, + _state: string = 'open', +) { + const stored = state.pullRequests?.[head]; + return stored ? [stored] : []; +} + +export async function getRepo(_octokit: any, _owner: string, _repo: string) { + return state.repo; +} + +export async function mergePullRequest( + _octokit: any, + _owner: string, + _repo: string, + _pull_number: number, + _merge_method: string = 'merge', +) { + return { sha: 'merge-sha' }; +} + +export async function deleteBranch( + _octokit: any, + _owner: string, + _repo: string, + _branch: string, +) { + // no-op in tests +} diff --git a/packages/merge/src/__tests__/init/init-handler.test.ts b/packages/merge/src/__tests__/init/init-handler.test.ts deleted file mode 100644 index ec8289c..0000000 --- a/packages/merge/src/__tests__/init/init-handler.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { InitHandler } from '../../init/init-handler.js'; - -describe('InitHandler', () => { - let handler: InitHandler; - let tempDir: string; - - beforeEach(async () => { - handler = new InitHandler(); - tempDir = await mkdtemp(join(tmpdir(), 'init-test-')); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - it('creates workflow file in .github/workflows/', async () => { - const result = await handler.execute({ - outputDir: tempDir, - workflowName: 'jules-merge-check', - baseBranch: 'main', - force: false, - }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.filePath).toContain('.github/workflows/jules-merge-check.yml'); - expect(result.data.content).toContain('name: jules-merge-check'); - - // Verify file was actually written - const written = await readFile(result.data.filePath, 'utf-8'); - expect(written).toBe(result.data.content); - } - }); - - it('returns DIRECTORY_NOT_FOUND for nonexistent path', async () => { - const result = await handler.execute({ - outputDir: '/nonexistent/path/xyz', - workflowName: 'check', - baseBranch: 'main', - force: false, - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('DIRECTORY_NOT_FOUND'); - } - }); - - it('returns FILE_ALREADY_EXISTS without force', async () => { - // First create - await handler.execute({ - outputDir: tempDir, - workflowName: 'check', - baseBranch: 'main', - force: false, - }); - - // Second create should fail - const result = await handler.execute({ - outputDir: tempDir, - workflowName: 'check', - baseBranch: 'main', - force: false, - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('FILE_ALREADY_EXISTS'); - } - }); - - it('overwrites existing file with force: true', async () => { - // First create - await handler.execute({ - outputDir: tempDir, - workflowName: 'check', - baseBranch: 'main', - force: false, - }); - - // Second create with force - const result = await handler.execute({ - outputDir: tempDir, - workflowName: 'check', - baseBranch: 'develop', - force: true, - }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.content).toContain('branches: [develop]'); - } - }); - - it('returns DIRECTORY_NOT_FOUND when path is a file', async () => { - const filePath = join(tempDir, 'not-a-dir'); - await writeFile(filePath, 'content'); - - const result = await handler.execute({ - outputDir: filePath, - workflowName: 'check', - baseBranch: 'main', - force: false, - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('DIRECTORY_NOT_FOUND'); - } - }); -}); diff --git a/packages/merge/src/__tests__/init/init-spec.test.ts b/packages/merge/src/__tests__/init/init-spec.test.ts deleted file mode 100644 index 2521de9..0000000 --- a/packages/merge/src/__tests__/init/init-spec.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect } from 'vitest'; -import { InitInputSchema } from '../../init/init-spec.js'; - -describe('InitInputSchema', () => { - it('accepts valid input with all fields', () => { - const result = InitInputSchema.safeParse({ - outputDir: '/tmp/repo', - workflowName: 'my-check', - baseBranch: 'develop', - force: true, - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.outputDir).toBe('/tmp/repo'); - expect(result.data.workflowName).toBe('my-check'); - expect(result.data.baseBranch).toBe('develop'); - expect(result.data.force).toBe(true); - } - }); - - it('applies defaults for optional fields', () => { - const result = InitInputSchema.safeParse({}); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.outputDir).toBe('.'); - expect(result.data.workflowName).toBe('jules-merge-check'); - expect(result.data.baseBranch).toBe('main'); - expect(result.data.force).toBe(false); - } - }); - - const invalidCases = [ - { - name: 'rejects empty outputDir', - input: { outputDir: '' }, - }, - { - name: 'rejects empty workflowName', - input: { workflowName: '' }, - }, - { - name: 'rejects empty baseBranch', - input: { baseBranch: '' }, - }, - ]; - - it.each(invalidCases)('$name', ({ input }) => { - const result = InitInputSchema.safeParse(input); - expect(result.success).toBe(false); - }); -}); diff --git a/packages/merge/src/__tests__/init/templates.test.ts b/packages/merge/src/__tests__/init/templates.test.ts deleted file mode 100644 index e4c5837..0000000 --- a/packages/merge/src/__tests__/init/templates.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect } from 'vitest'; -import { buildWorkflowYaml } from '../../init/templates.js'; - -describe('buildWorkflowYaml', () => { - it('includes workflow name in output', () => { - const yaml = buildWorkflowYaml({ - workflowName: 'my-merge-check', - baseBranch: 'main', - }); - expect(yaml).toContain('name: my-merge-check'); - }); - - it('targets the specified base branch', () => { - const yaml = buildWorkflowYaml({ - workflowName: 'check', - baseBranch: 'develop', - }); - expect(yaml).toContain('branches: [develop]'); - }); - - it('includes checkout step with fetch-depth 0', () => { - const yaml = buildWorkflowYaml({ - workflowName: 'check', - baseBranch: 'main', - }); - expect(yaml).toContain('fetch-depth: 0'); - }); - - it('includes npx jules-merge check-conflicts command', () => { - const yaml = buildWorkflowYaml({ - workflowName: 'check', - baseBranch: 'main', - }); - expect(yaml).toContain('npx @google/jules-merge check-conflicts'); - }); - - it('includes generated-by comment', () => { - const yaml = buildWorkflowYaml({ - workflowName: 'check', - baseBranch: 'main', - }); - expect(yaml).toContain('# Generated by jules-merge init'); - }); -}); diff --git a/packages/merge/src/__tests__/reconcile/critical-paths.test.ts b/packages/merge/src/__tests__/reconcile/critical-paths.test.ts new file mode 100644 index 0000000..4dc7e0e --- /dev/null +++ b/packages/merge/src/__tests__/reconcile/critical-paths.test.ts @@ -0,0 +1,392 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { state, resetCommitCounter } from '../fixtures/github.js'; + +// Mock the shared github module with our DI-compatible fixtures +vi.mock('../../shared/github.js', async () => { + return await import('../fixtures/github.js'); +}); + +// Mock the manifest to use a temp directory +const MANIFEST_DIR = path.join(process.cwd(), '.jules-test-merge'); +const MANIFEST_PATH = path.join(MANIFEST_DIR, 'manifest.json'); + +vi.stubEnv('JULES_MERGE_MANIFEST_PATH', MANIFEST_PATH); + +function cleanManifest() { + if (fs.existsSync(MANIFEST_DIR)) { + fs.rmSync(MANIFEST_DIR, { recursive: true }); + } +} + +describe('critical-paths', () => { + beforeEach(() => { + cleanManifest(); + resetCommitCounter(); + state.refs = {}; + state.pullRequests = undefined; + state.repo = { allow_squash_merge: true, allow_merge_commit: true }; + }); + + afterEach(() => { + cleanManifest(); + }); + + // ─── 1. Clean batch — no overlapping files ─────────────────── + + it('scan: detects no conflicts for non-overlapping PRs', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const result = await scanHandler({} as any, { + prs: [1, 2, 3], + repo: 'owner/repo', + base: 'main', + }); + expect(result.status).toBe('clean'); + expect(result.hotZones).toHaveLength(0); + expect(result.cleanFiles).toHaveLength(3); + }); + + // ─── 2. Textual conflicts + resolution ──────────────────────── + + it('scan→stage→status→push: full resolution pipeline', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const { stageResolutionHandler } = await import( + '../../reconcile/stage-resolution-handler.js' + ); + const { statusHandler } = await import('../../reconcile/status-handler.js'); + const { pushHandler } = await import('../../reconcile/push-handler.js'); + + // Scan PRs that both modify src/config.ts + const scan = await scanHandler({} as any, { + prs: [10, 11], + repo: 'owner/repo', + base: 'main', + }); + expect(scan.status).toBe('conflicts'); + expect(scan.hotZones).toHaveLength(1); + expect(scan.hotZones[0].filePath).toBe('src/config.ts'); + + // Status should show 1 pending + const status1 = await statusHandler({}); + expect(status1.ready).toBe(false); + expect(status1.pending).toHaveLength(1); + + // Stage resolution + await stageResolutionHandler({ + filePath: 'src/config.ts', + parents: ['main', '10', '11'], + content: 'export const DEFAULT_TIMEOUT = 7500;', + }); + + // Status should now be ready + const status2 = await statusHandler({}); + expect(status2.ready).toBe(true); + expect(status2.pending).toHaveLength(0); + + // Push + const pushResult = await pushHandler({} as any, { + branch: 'reconcile/config', + message: 'Reconcile config', + repo: 'owner/repo', + }); + expect(pushResult.status).toBe('pushed'); + expect(pushResult.pullRequest?.number).toBe(999); + }); + + // ─── 3. Input hardening ─────────────────────────────────────── + + it('rejects file paths with path traversal', async () => { + const { validateFilePath } = await import('../../shared/validators.js'); + expect(() => validateFilePath('../etc/passwd')).toThrow('PATH_TRAVERSAL'); + }); + + it('rejects file paths with control characters', async () => { + const { validateFilePath } = await import('../../shared/validators.js'); + expect(() => validateFilePath('src/foo\x00.ts')).toThrow('CONTROL_CHAR'); + }); + + it('rejects branch names starting with refs/', async () => { + const { validateBranchName } = await import('../../shared/validators.js'); + expect(() => validateBranchName('refs/heads/main')).toThrow( + 'RESERVED_BRANCH', + ); + }); + + it('rejects branch names ending with .lock', async () => { + const { validateBranchName } = await import('../../shared/validators.js'); + expect(() => validateBranchName('my-branch.lock')).toThrow( + 'INVALID_BRANCH', + ); + }); + + // ─── 4. dry-run behavior ────────────────────────────────────── + + it('stage-resolution --dry-run does not modify manifest', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const { stageResolutionHandler } = await import( + '../../reconcile/stage-resolution-handler.js' + ); + const { statusHandler } = await import('../../reconcile/status-handler.js'); + + await scanHandler({} as any, { + prs: [10, 11], + repo: 'owner/repo', + }); + + const dryResult = await stageResolutionHandler({ + filePath: 'src/config.ts', + parents: ['main', '10', '11'], + content: 'dry run content', + dryRun: true, + }); + expect(dryResult.status).toBe('staged'); + + // Manifest should still show pending + const status = await statusHandler({}); + expect(status.pending).toHaveLength(1); + }); + + // ─── 5. Push dry-run ────────────────────────────────────────── + + it('push --dry-run returns validation without creating refs', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const { stageResolutionHandler } = await import( + '../../reconcile/stage-resolution-handler.js' + ); + const { pushHandler } = await import('../../reconcile/push-handler.js'); + + await scanHandler({} as any, { + prs: [10, 11], + repo: 'owner/repo', + }); + await stageResolutionHandler({ + filePath: 'src/config.ts', + parents: ['main', '10', '11'], + content: 'resolved', + }); + + const result = await pushHandler({} as any, { + branch: 'reconcile/test', + message: 'Test', + repo: 'owner/repo', + dryRun: true, + }); + expect(result.status).toBe('dry-run'); + expect(result.commitSha).toBeUndefined(); + }); + + // ─── 6. Squash-merge protection ─────────────────────────────── + + it('push rejects when repo only allows squash merges', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const { stageResolutionHandler } = await import( + '../../reconcile/stage-resolution-handler.js' + ); + const { pushHandler } = await import('../../reconcile/push-handler.js'); + + await scanHandler({} as any, { + prs: [10, 11], + repo: 'owner/repo', + }); + await stageResolutionHandler({ + filePath: 'src/config.ts', + parents: ['main', '10', '11'], + content: 'resolved', + }); + + // Simulate squash-only repo + state.repo = { allow_squash_merge: true, allow_merge_commit: false }; + + await expect( + pushHandler({} as any, { + branch: 'reconcile/test', + message: 'Test', + repo: 'owner/repo', + }), + ).rejects.toThrow('squash'); + }); + + // ─── 7. Sequential vs. Octopus merge strategies ─────────────── + + it('push with sequential strategy creates a merge chain', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const { stageResolutionHandler } = await import( + '../../reconcile/stage-resolution-handler.js' + ); + const { pushHandler } = await import('../../reconcile/push-handler.js'); + + await scanHandler({} as any, { + prs: [10, 11], + repo: 'owner/repo', + }); + await stageResolutionHandler({ + filePath: 'src/config.ts', + parents: ['main', '10', '11'], + content: 'resolved for sequential', + }); + + const result = await pushHandler({} as any, { + branch: 'reconcile/sequential', + message: 'Sequential merge', + repo: 'owner/repo', + mergeStrategy: 'sequential', + }); + expect(result.status).toBe('pushed'); + expect(result.mergeChain).toBeDefined(); + expect(result.mergeChain).toHaveLength(2); + // Each step should have exactly 2 parents + result.mergeChain!.forEach((step) => { + expect(step.parents).toHaveLength(2); + }); + }); + + it('push with octopus strategy creates a single multi-parent commit', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const { stageResolutionHandler } = await import( + '../../reconcile/stage-resolution-handler.js' + ); + const { pushHandler } = await import('../../reconcile/push-handler.js'); + + await scanHandler({} as any, { + prs: [10, 11], + repo: 'owner/repo', + }); + await stageResolutionHandler({ + filePath: 'src/config.ts', + parents: ['main', '10', '11'], + content: 'resolved for octopus', + }); + + const result = await pushHandler({} as any, { + branch: 'reconcile/octopus', + message: 'Octopus merge', + repo: 'owner/repo', + mergeStrategy: 'octopus', + }); + expect(result.status).toBe('pushed'); + expect(result.mergeChain).toBeUndefined(); // Octopus has no chain + expect(result.parents).toHaveLength(3); // base + 2 PRs + }); + + // ─── 8. Correct parent linking in sequential chain ──────────── + + it('sequential merge chain has correct parent linking', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const { stageResolutionHandler } = await import( + '../../reconcile/stage-resolution-handler.js' + ); + const { pushHandler } = await import('../../reconcile/push-handler.js'); + + await scanHandler({} as any, { + prs: [10, 11], + repo: 'owner/repo', + }); + await stageResolutionHandler({ + filePath: 'src/config.ts', + parents: ['main', '10', '11'], + content: 'resolved', + }); + + const result = await pushHandler({} as any, { + branch: 'reconcile/chain', + message: 'Chain test', + repo: 'owner/repo', + mergeStrategy: 'sequential', + }); + + const chain = result.mergeChain!; + // First commit: parents = [baseSha, pr10.headSha] + expect(chain[0].parents[0]).toBe('main-sha-v1'); + expect(chain[0].parents[1]).toBe('pr10-head-sha'); + // Second commit: parents = [firstCommitSha, pr11.headSha] + expect(chain[1].parents[0]).toBe(chain[0].commitSha); + expect(chain[1].parents[1]).toBe('pr11-head-sha'); + }); + + // ─── 9. Single PR handling ──────────────────────────────────── + + it('single PR scan produces clean result with no hot zones', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const result = await scanHandler({} as any, { + prs: [1], + repo: 'owner/repo', + }); + expect(result.status).toBe('clean'); + expect(result.hotZones).toHaveLength(0); + expect(result.cleanFiles).toHaveLength(1); + }); + + // ─── 10. get-contents base source uses merge base ───────────── + + it('get-contents with source=base resolves to merge base SHA', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const { getContentsHandler } = await import( + '../../reconcile/get-contents-handler.js' + ); + + await scanHandler({} as any, { + prs: [10, 11], + repo: 'owner/repo', + }); + + const result = await getContentsHandler({} as any, { + filePath: 'src/config.ts', + source: 'base', + repo: 'owner/repo', + }); + // Should have used the merge_base_commit.sha (base-sha) not main-sha-v1 + expect(result.content).toBe('export const DEFAULT_TIMEOUT = 5000;'); + }); + + // ─── 11. Deleted files in tree ──────────────────────────────── + + it('scan correctly marks deleted files', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + const result = await scanHandler({} as any, { + prs: [50, 51], + repo: 'owner/repo', + }); + expect(result.status).toBe('clean'); + + const deprecated = result.cleanFiles.find( + (f) => f.filePath === 'src/deprecated.ts', + ); + expect(deprecated).toBeDefined(); + }); + + // ─── 12. Error types & exit codes ───────────────────────────── + + it('ConflictError has exit code 1', async () => { + const { ConflictError, getExitCode } = await import('../../shared/errors.js'); + const err = new ConflictError('test'); + expect(err.exitCode).toBe(1); + expect(getExitCode(err)).toBe(1); + }); + + it('HardError has exit code 2', async () => { + const { HardError, getExitCode } = await import('../../shared/errors.js'); + const err = new HardError('test'); + expect(err.exitCode).toBe(2); + expect(getExitCode(err)).toBe(2); + }); + + it('unknown errors get exit code 2', async () => { + const { getExitCode } = await import('../../shared/errors.js'); + expect(getExitCode(new Error('generic'))).toBe(2); + }); +}); diff --git a/packages/merge/src/__tests__/shared/auth.test.ts b/packages/merge/src/__tests__/shared/auth.test.ts new file mode 100644 index 0000000..79828d9 --- /dev/null +++ b/packages/merge/src/__tests__/shared/auth.test.ts @@ -0,0 +1,140 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('getAuthOptions', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + // Clear all auth-related vars + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + delete process.env.FLEET_APP_ID; + delete process.env.FLEET_APP_PRIVATE_KEY_BASE64; + delete process.env.FLEET_APP_PRIVATE_KEY; + delete process.env.FLEET_APP_INSTALLATION_ID; + delete process.env.GITHUB_APP_ID; + delete process.env.GITHUB_APP_PRIVATE_KEY_BASE64; + delete process.env.GITHUB_APP_PRIVATE_KEY; + delete process.env.GITHUB_APP_INSTALLATION_ID; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('Cycle 1: returns token auth when GITHUB_TOKEN is set', async () => { + process.env.GITHUB_TOKEN = 'ghp_test_token_123'; + + const { getAuthOptions } = await import('../../shared/auth.js'); + const opts = getAuthOptions(); + + expect(opts).toBeDefined(); + expect(opts!.auth).toBe('ghp_test_token_123'); + }); + + it('Cycle 2: throws when no credentials are set', async () => { + const { getAuthOptions } = await import('../../shared/auth.js'); + + expect(() => getAuthOptions()).toThrow('GitHub auth not configured'); + }); + + it('Cycle 3: FLEET_APP_* provides App auth', async () => { + process.env.FLEET_APP_ID = '12345'; + process.env.FLEET_APP_PRIVATE_KEY_BASE64 = Buffer.from( + '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----', + ).toString('base64'); + process.env.FLEET_APP_INSTALLATION_ID = '67890'; + + const { getAuthOptions } = await import('../../shared/auth.js'); + const opts = getAuthOptions(); + + expect(opts!.authStrategy).toBeDefined(); + expect((opts!.auth as any).appId).toBe('12345'); + expect((opts!.auth as any).installationId).toBe(67890); + }); + + it('Cycle 4: partial App config (missing key) throws diagnostic', async () => { + process.env.FLEET_APP_ID = '12345'; + process.env.FLEET_APP_INSTALLATION_ID = '67890'; + + const { getAuthOptions } = await import('../../shared/auth.js'); + + expect(() => getAuthOptions()).toThrow('partially configured'); + }); + + it('Cycle 5: partial App config (missing installation ID) throws diagnostic', async () => { + process.env.FLEET_APP_ID = '12345'; + process.env.FLEET_APP_PRIVATE_KEY_BASE64 = Buffer.from( + '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----', + ).toString('base64'); + + const { getAuthOptions } = await import('../../shared/auth.js'); + + expect(() => getAuthOptions()).toThrow('FLEET_APP_INSTALLATION_ID'); + }); + + it('Cycle 6: GH_TOKEN fallback works', async () => { + process.env.GH_TOKEN = 'gh_cli_token'; + + const { getAuthOptions } = await import('../../shared/auth.js'); + const opts = getAuthOptions(); + + expect(opts!.auth).toBe('gh_cli_token'); + }); + + it('Cycle 7: GITHUB_APP_* legacy vars accepted with deprecation warning', async () => { + process.env.FLEET_APP_ID = '12345'; + process.env.GITHUB_APP_PRIVATE_KEY_BASE64 = Buffer.from( + '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----', + ).toString('base64'); + process.env.FLEET_APP_INSTALLATION_ID = '67890'; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); + + const { getAuthOptions } = await import('../../shared/auth.js'); + const opts = getAuthOptions(); + + expect(opts!.authStrategy).toBeDefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('legacy env var'), + ); + warnSpy.mockRestore(); + }); + + it('Cycle 8: FLEET_APP_PRIVATE_KEY_BASE64 is preferred over legacy names', async () => { + process.env.FLEET_APP_ID = '12345'; + process.env.FLEET_APP_PRIVATE_KEY_BASE64 = Buffer.from( + '-----BEGIN RSA PRIVATE KEY-----\ncanonical\n-----END RSA PRIVATE KEY-----', + ).toString('base64'); + process.env.GITHUB_APP_PRIVATE_KEY_BASE64 = Buffer.from( + '-----BEGIN RSA PRIVATE KEY-----\nlegacy\n-----END RSA PRIVATE KEY-----', + ).toString('base64'); + process.env.FLEET_APP_INSTALLATION_ID = '67890'; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); + + const { getAuthOptions } = await import('../../shared/auth.js'); + const opts = getAuthOptions(); + + // Should NOT warn because canonical name was used + expect(warnSpy).not.toHaveBeenCalled(); + // Key should contain 'canonical' + expect((opts!.auth as any).privateKey).toContain('canonical'); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/merge/src/__tests__/shared/git.test.ts b/packages/merge/src/__tests__/shared/git.test.ts deleted file mode 100644 index 595b241..0000000 --- a/packages/merge/src/__tests__/shared/git.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock child_process -vi.mock('node:child_process', () => ({ - execFile: vi.fn(), -})); - -vi.mock('node:util', () => ({ - promisify: (fn: any) => fn, -})); - -import { execFile } from 'node:child_process'; - -describe('git.ts', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('gitStatusUnmerged', () => { - it('parses UU lines from porcelain output', async () => { - vi.mocked(execFile).mockImplementation( - ((_cmd: any, _args: any, _opts: any) => { - return Promise.resolve({ - stdout: 'UU src/a.ts\nUU src/b.ts\nM src/c.ts\n', - stderr: '', - }); - }) as any, - ); - - const { gitStatusUnmerged } = await import('../../shared/git.js'); - const result = await gitStatusUnmerged(); - expect(result).toEqual({ ok: true, data: ['src/a.ts', 'src/b.ts'] }); - }); - - it('returns empty array when no conflicts', async () => { - vi.mocked(execFile).mockImplementation( - ((_cmd: any, _args: any, _opts: any) => { - return Promise.resolve({ stdout: 'M src/c.ts\n', stderr: '' }); - }) as any, - ); - - const { gitStatusUnmerged } = await import('../../shared/git.js'); - const result = await gitStatusUnmerged(); - expect(result).toEqual({ ok: true, data: [] }); - }); - - it('returns error on exec failure', async () => { - vi.mocked(execFile).mockImplementation( - ((_cmd: any, _args: any, _opts: any) => { - return Promise.reject(new Error('git not found')); - }) as any, - ); - - const { gitStatusUnmerged } = await import('../../shared/git.js'); - const result = await gitStatusUnmerged(); - expect(result).toEqual({ ok: false, error: 'git not found' }); - }); - }); - - describe('gitMergeBase', () => { - it('returns trimmed SHA', async () => { - vi.mocked(execFile).mockImplementation( - ((_cmd: any, _args: any, _opts: any) => { - return Promise.resolve({ stdout: 'abc123def456\n', stderr: '' }); - }) as any, - ); - - const { gitMergeBase } = await import('../../shared/git.js'); - const result = await gitMergeBase('HEAD', 'main'); - expect(result).toEqual({ ok: true, data: 'abc123def456' }); - }); - }); -}); diff --git a/packages/merge/src/__tests__/shared/github.test.ts b/packages/merge/src/__tests__/shared/github.test.ts deleted file mode 100644 index 603ccfe..0000000 --- a/packages/merge/src/__tests__/shared/github.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -describe('github.ts', () => { - describe('compareCommits', () => { - it('returns file paths from comparison', async () => { - const mockOctokit = { - repos: { - compareCommits: vi.fn().mockResolvedValue({ - data: { - files: [ - { filename: 'src/a.ts' }, - { filename: 'src/b.ts' }, - ], - }, - }), - }, - }; - - const { compareCommits } = await import('../../shared/github.js'); - const result = await compareCommits(mockOctokit as any, 'owner', 'repo', 'main', 'HEAD'); - expect(result).toEqual(['src/a.ts', 'src/b.ts']); - }); - - it('handles 403 rate limit error', async () => { - const mockOctokit = { - repos: { - compareCommits: vi.fn().mockRejectedValue( - Object.assign(new Error('rate limit'), { status: 403 }), - ), - }, - }; - - const { compareCommits } = await import('../../shared/github.js'); - await expect( - compareCommits(mockOctokit as any, 'owner', 'repo', 'main', 'HEAD'), - ).rejects.toThrow('rate limit'); - }); - - it('handles 404 not found error', async () => { - const mockOctokit = { - repos: { - compareCommits: vi.fn().mockRejectedValue( - Object.assign(new Error('Not Found'), { status: 404 }), - ), - }, - }; - - const { compareCommits } = await import('../../shared/github.js'); - await expect( - compareCommits(mockOctokit as any, 'owner', 'repo', 'main', 'HEAD'), - ).rejects.toThrow(); - }); - }); - - describe('getFileContent', () => { - it('decodes base64 content', async () => { - const content = Buffer.from('export const foo = 42;').toString('base64'); - const mockOctokit = { - repos: { - getContent: vi.fn().mockResolvedValue({ - data: { content, encoding: 'base64' }, - }), - }, - }; - - const { getFileContent } = await import('../../shared/github.js'); - const result = await getFileContent(mockOctokit as any, 'owner', 'repo', 'src/foo.ts', 'main'); - expect(result).toBe('export const foo = 42;'); - }); - - it('returns empty string on 404', async () => { - const mockOctokit = { - repos: { - getContent: vi.fn().mockRejectedValue( - Object.assign(new Error('Not Found'), { status: 404 }), - ), - }, - }; - - const { getFileContent } = await import('../../shared/github.js'); - const result = await getFileContent(mockOctokit as any, 'owner', 'repo', 'missing.ts', 'main'); - expect(result).toBe(''); - }); - }); -}); diff --git a/packages/merge/src/__tests__/shared/session.test.ts b/packages/merge/src/__tests__/shared/session.test.ts deleted file mode 100644 index 0b36b43..0000000 --- a/packages/merge/src/__tests__/shared/session.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { SessionFileInfo } from '../../shared/session.js'; - -// Mock @google/jules-sdk before any imports touch it -vi.mock('@google/jules-sdk', () => ({ - jules: {}, -})); - -// Mock the session module to inject our own client behavior -const mockHydrate = vi.fn().mockResolvedValue(undefined); -const mockSnapshot = vi.fn(); -const mockSession = vi.fn().mockReturnValue({ - activities: { hydrate: mockHydrate }, - snapshot: mockSnapshot, -}); - -function makeClient() { - return { session: mockSession } as any; -} - -// Import AFTER mocks are set up -import { getSessionChangedFiles } from '../../shared/session.js'; - -describe('getSessionChangedFiles', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockSession.mockReturnValue({ - activities: { hydrate: mockHydrate }, - snapshot: mockSnapshot, - }); - }); - - it('aggregates files from activity changeSet artifacts when session is busy', async () => { - const activities = [ - { - id: 'act-1', - type: 'progressUpdated', - artifacts: [ - { - type: 'changeSet', - parsed: () => ({ - files: [ - { path: 'src/a.ts', changeType: 'modified', additions: 5, deletions: 2 }, - { path: 'src/b.ts', changeType: 'created', additions: 10, deletions: 0 }, - ], - summary: { totalFiles: 2, created: 1, modified: 1, deleted: 0 }, - }), - }, - ], - }, - { - id: 'act-2', - type: 'progressUpdated', - artifacts: [ - { - type: 'changeSet', - parsed: () => ({ - files: [ - { path: 'src/c.ts', changeType: 'created', additions: 3, deletions: 0 }, - ], - summary: { totalFiles: 1, created: 1, modified: 0, deleted: 0 }, - }), - }, - ], - }, - ]; - - mockSnapshot.mockResolvedValue({ - state: 'inProgress', - activities, - changeSet: () => undefined, - }); - - const result = await getSessionChangedFiles(makeClient(), 'session-123'); - - expect(result).toEqual([ - { path: 'src/a.ts', changeType: 'modified' }, - { path: 'src/b.ts', changeType: 'created' }, - { path: 'src/c.ts', changeType: 'created' }, - ]); - }); - - it('uses outcome changeSet when session is stable', async () => { - mockSnapshot.mockResolvedValue({ - state: 'completed', - activities: [], - changeSet: () => ({ - type: 'changeSet', - parsed: () => ({ - files: [ - { path: 'src/x.ts', changeType: 'modified', additions: 3, deletions: 1 }, - ], - summary: { totalFiles: 1, created: 0, modified: 1, deleted: 0 }, - }), - }), - }); - - const result = await getSessionChangedFiles(makeClient(), 'session-456'); - - expect(result).toEqual([ - { path: 'src/x.ts', changeType: 'modified' }, - ]); - }); - - it('omits files where net change is created→deleted', async () => { - const activities = [ - { - id: 'act-1', - type: 'progressUpdated', - artifacts: [ - { - type: 'changeSet', - parsed: () => ({ - files: [ - { path: 'src/temp.ts', changeType: 'created', additions: 5, deletions: 0 }, - ], - summary: { totalFiles: 1, created: 1, modified: 0, deleted: 0 }, - }), - }, - ], - }, - { - id: 'act-2', - type: 'progressUpdated', - artifacts: [ - { - type: 'changeSet', - parsed: () => ({ - files: [ - { path: 'src/temp.ts', changeType: 'deleted', additions: 0, deletions: 5 }, - ], - summary: { totalFiles: 1, created: 0, modified: 0, deleted: 1 }, - }), - }, - ], - }, - ]; - - mockSnapshot.mockResolvedValue({ - state: 'inProgress', - activities, - changeSet: () => undefined, - }); - - const result = await getSessionChangedFiles(makeClient(), 'session-789'); - expect(result).toEqual([]); - }); - - it('treats created→modified as created', async () => { - const activities = [ - { - id: 'act-1', - type: 'progressUpdated', - artifacts: [ - { - type: 'changeSet', - parsed: () => ({ - files: [ - { path: 'src/new.ts', changeType: 'created', additions: 10, deletions: 0 }, - ], - summary: { totalFiles: 1, created: 1, modified: 0, deleted: 0 }, - }), - }, - ], - }, - { - id: 'act-2', - type: 'progressUpdated', - artifacts: [ - { - type: 'changeSet', - parsed: () => ({ - files: [ - { path: 'src/new.ts', changeType: 'modified', additions: 2, deletions: 1 }, - ], - summary: { totalFiles: 1, created: 0, modified: 1, deleted: 0 }, - }), - }, - ], - }, - ]; - - mockSnapshot.mockResolvedValue({ - state: 'inProgress', - activities, - changeSet: () => undefined, - }); - - const result = await getSessionChangedFiles(makeClient(), 'session-abc'); - - expect(result).toEqual([ - { path: 'src/new.ts', changeType: 'created' }, - ]); - }); - - it('returns empty array for empty session', async () => { - mockSnapshot.mockResolvedValue({ - state: 'completed', - activities: [], - changeSet: () => undefined, - }); - - const result = await getSessionChangedFiles(makeClient(), 'session-empty'); - expect(result).toEqual([]); - }); -}); diff --git a/packages/merge/src/cli/check-conflicts.command.ts b/packages/merge/src/cli/check-conflicts.command.ts deleted file mode 100644 index 528fb07..0000000 --- a/packages/merge/src/cli/check-conflicts.command.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { defineCommand } from 'citty'; -import { SessionCheckInputSchema } from '../conflicts/session-spec.js'; -import { SessionCheckHandler } from '../conflicts/session-handler.js'; -import { GitCheckInputSchema } from '../conflicts/git-spec.js'; -import { GitCheckHandler } from '../conflicts/git-handler.js'; -import { createOctokit } from '../shared/github.js'; -import { createJulesClient } from '../shared/session.js'; - -export default defineCommand({ - meta: { - name: 'check-conflicts', - description: - 'Check for merge conflicts between a Jules session and the remote base branch, or parse an existing CI merge failure.', - }, - args: { - // Session mode args - session: { - type: 'string', - description: 'Jules session ID (triggers session mode)', - }, - repo: { - type: 'string', - description: 'Repository in owner/repo format', - }, - base: { - type: 'string', - description: 'Base branch (session mode only)', - default: 'main', - }, - // Git mode args - pr: { - type: 'string', - description: 'Pull request number (triggers git mode)', - }, - sha: { - type: 'string', - description: 'Failing commit SHA (git mode only)', - }, - }, - async run({ args }) { - // Infer mode from which arguments are present - const isSessionMode = !!args.session; - const isGitMode = !!args.pr; - - if (!isSessionMode && !isGitMode) { - console.error( - 'Either --session (session mode) or --pr (git mode) is required.', - ); - process.exit(2); - } - - if (isSessionMode && isGitMode) { - console.error('Cannot use both --session and --pr. Pick one mode.'); - process.exit(2); - } - - let result: any; - - if (isSessionMode) { - if (!args.repo) { - console.error('--repo is required in session mode.'); - process.exit(2); - } - const input = SessionCheckInputSchema.parse({ - sessionId: args.session, - repo: args.repo, - base: args.base, - }); - const handler = new SessionCheckHandler(createOctokit(), createJulesClient); - result = await handler.execute(input); - } else { - if (!args.repo || !args.sha) { - console.error('--repo and --sha are required in git mode.'); - process.exit(2); - } - const input = GitCheckInputSchema.parse({ - repo: args.repo, - pullRequestNumber: parseInt(args.pr!, 10), - failingCommitSha: args.sha, - }); - const handler = new GitCheckHandler(); - result = await handler.execute(input); - } - - process.stdout.write(JSON.stringify(result, null, 2) + '\n'); - process.exit(result.success ? 0 : 1); - }, -}); diff --git a/packages/merge/src/cli/get-contents.command.ts b/packages/merge/src/cli/get-contents.command.ts new file mode 100644 index 0000000..58d0f6e --- /dev/null +++ b/packages/merge/src/cli/get-contents.command.ts @@ -0,0 +1,64 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { defineCommand } from 'citty'; +import { createMergeOctokit } from '../shared/auth.js'; +import { getContentsHandler } from '../reconcile/get-contents-handler.js'; +import { parseJsonInput, getExitCode } from '../shared/errors.js'; + +export default defineCommand({ + meta: { + name: 'get-contents', + description: 'Fetch file content from base, main, or a specific PR', + }, + args: { + json: { + type: 'string', + description: 'Raw JSON payload: { "filePath": "src/foo.ts", "source": "base", "repo": "owner/repo" }', + }, + file: { + type: 'string', + description: 'File path within the repo', + }, + source: { + type: 'string', + description: 'Content source: "base", "main", or "pr:"', + }, + repo: { + type: 'string', + description: 'Repository in owner/repo format', + }, + baseSha: { + type: 'string', + description: 'Explicit base SHA (optional, used with source=base)', + }, + }, + async run({ args }) { + try { + const octokit = createMergeOctokit(); + const input = + parseJsonInput(args.json) || { + filePath: args.file || '', + source: args.source || '', + repo: args.repo || '', + baseSha: args.baseSha, + }; + const result = await getContentsHandler(octokit, input); + console.log(JSON.stringify(result, null, 2)); + } catch (err: any) { + console.error(JSON.stringify({ error: err.message })); + process.exit(getExitCode(err)); + } + }, +}); diff --git a/packages/merge/src/cli/index.ts b/packages/merge/src/cli/index.ts index 0fa8d91..560dc13 100644 --- a/packages/merge/src/cli/index.ts +++ b/packages/merge/src/cli/index.ts @@ -41,8 +41,8 @@ const subCommands = await discoverCommands(); const main = defineCommand({ meta: { name: 'jules-merge', - version: '0.0.1', - description: 'Predictive conflict detection for parallel AI agents', + version: '0.0.3', + description: 'Reconcile overlapping PR changes from parallel AI agents', }, subCommands, }); diff --git a/packages/merge/src/cli/init.command.ts b/packages/merge/src/cli/init.command.ts deleted file mode 100644 index a88405a..0000000 --- a/packages/merge/src/cli/init.command.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { defineCommand } from 'citty'; -import { InitInputSchema } from '../init/init-spec.js'; -import { InitHandler } from '../init/init-handler.js'; - -export default defineCommand({ - meta: { - name: 'init', - description: - 'Generate a GitHub Actions workflow for merge conflict detection.', - }, - args: { - 'output-dir': { - type: 'string', - description: 'Directory to write .github/workflows/ into (defaults to .)', - default: '.', - }, - 'workflow-name': { - type: 'string', - description: 'Workflow filename (without .yml)', - default: 'jules-merge-check', - }, - 'base-branch': { - type: 'string', - description: 'Base branch to check against', - default: 'main', - }, - force: { - type: 'boolean', - description: 'Overwrite existing workflow file', - default: false, - }, - }, - async run({ args }) { - const input = InitInputSchema.parse({ - outputDir: args['output-dir'], - workflowName: args['workflow-name'], - baseBranch: args['base-branch'], - force: args.force, - }); - - const handler = new InitHandler(); - const result = await handler.execute(input); - - if (result.success) { - console.log(`✅ Created ${result.data.filePath}`); - } else { - console.error(`❌ ${result.error.message}`); - if (result.error.suggestion) { - console.error(` ${result.error.suggestion}`); - } - process.exit(1); - } - }, -}); diff --git a/packages/merge/src/conflicts/index.ts b/packages/merge/src/cli/mcp.command.ts similarity index 50% rename from packages/merge/src/conflicts/index.ts rename to packages/merge/src/cli/mcp.command.ts index 27bc24f..4f30cf0 100644 --- a/packages/merge/src/conflicts/index.ts +++ b/packages/merge/src/cli/mcp.command.ts @@ -12,7 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -export * from './session-spec.js'; -export { SessionCheckHandler } from './session-handler.js'; -export * from './git-spec.js'; -export { GitCheckHandler } from './git-handler.js'; +import { defineCommand } from 'citty'; + +export default defineCommand({ + meta: { + name: 'mcp', + description: 'Start the Jules Merge MCP server on stdio', + }, + args: {}, + async run() { + const { createMergeServer } = await import('../mcp/server.js'); + const { StdioServerTransport } = await import( + '@modelcontextprotocol/sdk/server/stdio.js' + ); + const server = createMergeServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Jules Merge MCP server running on stdio'); + }, +}); diff --git a/packages/merge/src/cli/merge.command.ts b/packages/merge/src/cli/merge.command.ts new file mode 100644 index 0000000..35cc2e5 --- /dev/null +++ b/packages/merge/src/cli/merge.command.ts @@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { defineCommand } from 'citty'; +import { createMergeOctokit } from '../shared/auth.js'; +import { mergeHandler } from '../reconcile/merge-handler.js'; +import { parseJsonInput, getExitCode } from '../shared/errors.js'; + +export default defineCommand({ + meta: { + name: 'merge', + description: 'Merge the reconciliation PR using a merge commit', + }, + args: { + json: { + type: 'string', + description: 'Raw JSON payload: { "pr": 999, "repo": "owner/repo" }', + }, + pr: { + type: 'string', + description: 'PR number to merge', + }, + repo: { + type: 'string', + description: 'Repository in owner/repo format', + }, + }, + async run({ args }) { + try { + const octokit = createMergeOctokit(); + const input = + parseJsonInput(args.json) || { + pr: Number(args.pr) || 0, + repo: args.repo || '', + }; + const result = await mergeHandler(octokit, input); + console.log(JSON.stringify(result, null, 2)); + } catch (err: any) { + console.error(JSON.stringify({ error: err.message })); + process.exit(getExitCode(err)); + } + }, +}); diff --git a/packages/merge/src/cli/push.command.ts b/packages/merge/src/cli/push.command.ts new file mode 100644 index 0000000..c8b03f2 --- /dev/null +++ b/packages/merge/src/cli/push.command.ts @@ -0,0 +1,83 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { defineCommand } from 'citty'; +import { createMergeOctokit } from '../shared/auth.js'; +import { pushHandler } from '../reconcile/push-handler.js'; +import { parseJsonInput, getExitCode } from '../shared/errors.js'; + +export default defineCommand({ + meta: { + name: 'push', + description: 'Create the multi-parent reconciliation commit and PR', + }, + args: { + json: { + type: 'string', + description: 'Raw JSON payload', + }, + branch: { + type: 'string', + description: 'Target branch name for the reconciliation PR', + }, + message: { + type: 'string', + description: 'Commit message', + }, + repo: { + type: 'string', + description: 'Repository in owner/repo format', + }, + dryRun: { + type: 'boolean', + description: 'Validate without pushing', + default: false, + }, + mergeStrategy: { + type: 'string', + description: '"sequential" (default) or "octopus"', + default: 'sequential', + }, + prTitle: { + type: 'string', + description: 'Custom PR title (defaults to commit message)', + }, + prBody: { + type: 'string', + description: 'Custom PR body', + }, + }, + async run({ args }) { + try { + const octokit = createMergeOctokit(); + const input = + parseJsonInput(args.json) || { + branch: args.branch || '', + message: args.message || '', + repo: args.repo || '', + dryRun: args.dryRun, + mergeStrategy: args.mergeStrategy as + | 'sequential' + | 'octopus', + prTitle: args.prTitle, + prBody: args.prBody, + }; + const result = await pushHandler(octokit, input); + console.log(JSON.stringify(result, null, 2)); + } catch (err: any) { + console.error(JSON.stringify({ error: err.message })); + process.exit(getExitCode(err)); + } + }, +}); diff --git a/packages/merge/src/cli/scan.command.ts b/packages/merge/src/cli/scan.command.ts new file mode 100644 index 0000000..d462a33 --- /dev/null +++ b/packages/merge/src/cli/scan.command.ts @@ -0,0 +1,60 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { defineCommand } from 'citty'; +import { createMergeOctokit } from '../shared/auth.js'; +import { scanHandler } from '../reconcile/scan-handler.js'; +import { parseJsonInput, getExitCode } from '../shared/errors.js'; + +export default defineCommand({ + meta: { + name: 'scan', + description: 'Scan PRs for overlapping file changes and build the reconciliation manifest', + }, + args: { + json: { + type: 'string', + description: 'Raw JSON payload: { "prs": [1,2], "repo": "owner/repo", "base": "main" }', + }, + prs: { + type: 'string', + description: 'Comma-separated PR numbers (alternative to --json)', + }, + repo: { + type: 'string', + description: 'Repository in owner/repo format', + }, + base: { + type: 'string', + description: 'Base branch name (default: main)', + default: 'main', + }, + }, + async run({ args }) { + try { + const octokit = createMergeOctokit(); + const input = + parseJsonInput(args.json) || { + prs: args.prs?.split(',').map(Number) || [], + repo: args.repo || '', + base: args.base, + }; + const result = await scanHandler(octokit, input); + console.log(JSON.stringify(result, null, 2)); + } catch (err: any) { + console.error(JSON.stringify({ error: err.message })); + process.exit(getExitCode(err)); + } + }, +}); diff --git a/packages/merge/src/cli/schema.command.ts b/packages/merge/src/cli/schema.command.ts new file mode 100644 index 0000000..6315e50 --- /dev/null +++ b/packages/merge/src/cli/schema.command.ts @@ -0,0 +1,39 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { defineCommand } from 'citty'; +import { schemaHandler } from '../reconcile/schema-handler.js'; + +export default defineCommand({ + meta: { + name: 'schema', + description: 'Print JSON schema for a command (input/output)', + }, + args: { + command: { + type: 'positional', + description: 'Command name (scan, get-contents, stage-resolution, status, push, merge)', + required: false, + }, + all: { + type: 'boolean', + description: 'Print all schemas at once', + default: false, + }, + }, + async run({ args }) { + const result = schemaHandler(args.command, { all: args.all }); + console.log(JSON.stringify(result, null, 2)); + }, +}); diff --git a/packages/merge/src/cli/stage-resolution.command.ts b/packages/merge/src/cli/stage-resolution.command.ts new file mode 100644 index 0000000..7af6197 --- /dev/null +++ b/packages/merge/src/cli/stage-resolution.command.ts @@ -0,0 +1,73 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { defineCommand } from 'citty'; +import { stageResolutionHandler } from '../reconcile/stage-resolution-handler.js'; +import { parseJsonInput, getExitCode } from '../shared/errors.js'; + +export default defineCommand({ + meta: { + name: 'stage-resolution', + description: 'Stage a resolved file for the reconciliation commit', + }, + args: { + json: { + type: 'string', + description: 'Raw JSON payload', + }, + file: { + type: 'string', + description: 'File path within the repo', + }, + parents: { + type: 'string', + description: 'Comma-separated parents: "main,10,11"', + }, + content: { + type: 'string', + description: 'Inline resolved content', + }, + fromFile: { + type: 'string', + description: 'Path to a local file containing the resolved content', + }, + note: { + type: 'string', + description: 'Optional note for the resolution', + }, + dryRun: { + type: 'boolean', + description: 'Validate without writing to the manifest', + default: false, + }, + }, + async run({ args }) { + try { + const input = + parseJsonInput(args.json) || { + filePath: args.file || '', + parents: args.parents?.split(',') || [], + content: args.content, + fromFile: args.fromFile, + note: args.note, + dryRun: args.dryRun, + }; + const result = await stageResolutionHandler(input); + console.log(JSON.stringify(result, null, 2)); + } catch (err: any) { + console.error(JSON.stringify({ error: err.message })); + process.exit(getExitCode(err)); + } + }, +}); diff --git a/packages/merge/src/cli/status.command.ts b/packages/merge/src/cli/status.command.ts new file mode 100644 index 0000000..ea66c30 --- /dev/null +++ b/packages/merge/src/cli/status.command.ts @@ -0,0 +1,40 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { defineCommand } from 'citty'; +import { statusHandler } from '../reconcile/status-handler.js'; +import { parseJsonInput, getExitCode } from '../shared/errors.js'; + +export default defineCommand({ + meta: { + name: 'status', + description: 'Show reconciliation manifest status', + }, + args: { + json: { + type: 'string', + description: 'Raw JSON payload', + }, + }, + async run({ args }) { + try { + const input = parseJsonInput(args.json) || {}; + const result = await statusHandler(input); + console.log(JSON.stringify(result, null, 2)); + } catch (err: any) { + console.error(JSON.stringify({ error: err.message })); + process.exit(getExitCode(err)); + } + }, +}); diff --git a/packages/merge/src/conflicts/git-handler.ts b/packages/merge/src/conflicts/git-handler.ts deleted file mode 100644 index 3523722..0000000 --- a/packages/merge/src/conflicts/git-handler.ts +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { readFile } from 'node:fs/promises'; -import type { GitCheckSpec, GitCheckInput, GitCheckResult } from './git-spec.js'; -import { ok, fail } from '../shared/result.js'; -import { gitStatusUnmerged, gitMergeBase } from '../shared/git.js'; - -export class GitCheckHandler implements GitCheckSpec { - async execute(input: GitCheckInput): Promise { - try { - // 1. Get unmerged files - const statusResult = await gitStatusUnmerged(); - if (!statusResult.ok) { - return fail( - 'GIT_STATUS_FAILED', - `Failed to get git status: ${statusResult.error}`, - true, - 'Ensure git is available and the working directory is a repository.', - ); - } - - const unmergedFiles = statusResult.data; - - // 2. No conflicts — this is the happy path - if (unmergedFiles.length === 0) { - return ok({ - taskDirective: 'No merge conflicts detected.', - priority: 'standard' as const, - affectedFiles: [], - }); - } - - // 3. Read files and extract conflict markers - const affectedFiles = await Promise.all( - unmergedFiles.map(async (filePath) => { - let content: string; - try { - content = await readFile(filePath, 'utf-8'); - } catch (error: any) { - return { - filePath, - baseCommitSha: '', - gitConflictMarkers: `[Error reading file: ${error.message}]`, - }; - } - - // Extract conflict marker blocks - const markers = extractConflictMarkers(content); - - return { - filePath, - baseCommitSha: '', // Will be filled in below - gitConflictMarkers: markers, - }; - }), - ); - - // 4. Get merge base SHA - const mergeBaseResult = await gitMergeBase(input.failingCommitSha, 'HEAD'); - const baseSha = mergeBaseResult.ok ? mergeBaseResult.data : 'unknown'; - - // Set baseSha on all affected files - for (const file of affectedFiles) { - file.baseCommitSha = baseSha; - } - - // 5. Build task directive - const taskDirective = [ - `MERGE CONFLICT RESOLUTION REQUIRED for PR #${input.pullRequestNumber}.`, - `Failing commit: ${input.failingCommitSha}.`, - `${affectedFiles.length} file(s) have unresolved conflicts.`, - `Review the gitConflictMarkers for each file and rewrite the code to resolve all conflicts.`, - ].join('\n'); - - return ok({ - taskDirective, - priority: 'critical' as const, - affectedFiles, - }); - } catch (error: any) { - return fail( - 'UNKNOWN_ERROR', - error instanceof Error ? error.message : String(error), - false, - ); - } - } -} - -/** - * Extract conflict marker blocks from file content. - * Each block starts with <<<<<<< and ends with >>>>>>>. - */ -function extractConflictMarkers(content: string): string { - const lines = content.split('\n'); - const blocks: string[] = []; - let inConflict = false; - let currentBlock: string[] = []; - - for (const line of lines) { - if (line.startsWith('<<<<<<<')) { - inConflict = true; - currentBlock = [line]; - } else if (line.startsWith('>>>>>>>') && inConflict) { - currentBlock.push(line); - blocks.push(currentBlock.join('\n')); - inConflict = false; - currentBlock = []; - } else if (inConflict) { - currentBlock.push(line); - } - } - - return blocks.join('\n---\n'); -} diff --git a/packages/merge/src/conflicts/git-spec.ts b/packages/merge/src/conflicts/git-spec.ts deleted file mode 100644 index 5533dc4..0000000 --- a/packages/merge/src/conflicts/git-spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { z } from 'zod'; - -// INPUT -export const GitCheckInputSchema = z.object({ - repo: z - .string() - .min(1) - .refine((s) => s.includes('/'), 'Must be in owner/repo format'), - pullRequestNumber: z.number().int().positive(), - failingCommitSha: z.string().min(1), -}); -export type GitCheckInput = z.infer; - -// ERROR CODES (exhaustive) -export const GitCheckErrorCode = z.enum([ - 'GIT_STATUS_FAILED', - 'FILE_READ_FAILED', - 'UNKNOWN_ERROR', -]); -export type GitCheckErrorCode = z.infer; - -// SUCCESS DATA -export interface GitCheckData { - taskDirective: string; - priority: 'standard' | 'critical'; - affectedFiles: Array<{ - filePath: string; - baseCommitSha: string; - gitConflictMarkers: string; - }>; -} - -// RESULT (Discriminated Union) -export interface GitCheckSuccess { - success: true; - data: GitCheckData; -} -export interface GitCheckFailure { - success: false; - error: { - code: GitCheckErrorCode; - message: string; - recoverable: boolean; - suggestion?: string; - }; -} -export type GitCheckResult = GitCheckSuccess | GitCheckFailure; - -// INTERFACE (Capability) -export interface GitCheckSpec { - execute(input: GitCheckInput): Promise; -} diff --git a/packages/merge/src/conflicts/session-handler.ts b/packages/merge/src/conflicts/session-handler.ts deleted file mode 100644 index 7089427..0000000 --- a/packages/merge/src/conflicts/session-handler.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Octokit } from '@octokit/rest'; -import type { JulesClient } from '@google/jules-sdk'; -import type { SessionCheckSpec, SessionCheckInput, SessionCheckResult } from './session-spec.js'; -import { ok, fail } from '../shared/result.js'; -import { getSessionChangedFiles } from '../shared/session.js'; -import { compareCommits, getFileContent } from '../shared/github.js'; - -export class SessionCheckHandler implements SessionCheckSpec { - constructor( - private octokit: Octokit, - private julesClient: JulesClient, - ) {} - - async execute(input: SessionCheckInput): Promise { - try { - const [owner, repo] = input.repo.split('/'); - - // 1. Session changed files via Jules SDK - let sessionPaths: string[]; - try { - const sessionFiles = await getSessionChangedFiles( - this.julesClient, - input.sessionId, - ); - sessionPaths = sessionFiles.map((f) => f.path); - } catch (error: any) { - return fail( - 'SESSION_QUERY_FAILED', - `Failed to query session ${input.sessionId}: ${error.message}`, - true, - 'Verify the session ID is correct and JULES_API_KEY is set.', - ); - } - - // 2. Remote changed files via GitHub API - let remoteFiles: string[]; - try { - remoteFiles = await compareCommits( - this.octokit, - owner, - repo, - input.base, - 'HEAD', - ); - } catch (error: any) { - return fail( - 'GITHUB_API_ERROR', - `Failed to compare commits: ${error.message}`, - true, - 'Check GITHUB_TOKEN and repository access.', - ); - } - - // 3. Intersect session files with remote changes - const remoteSet = new Set(remoteFiles); - const overlapping = sessionPaths.filter((f) => remoteSet.has(f)); - - // 4. Clean - if (overlapping.length === 0) { - return ok({ - status: 'clean' as const, - message: 'No conflicts detected.', - conflicts: [], - }); - } - - // 5. Build shadow content for each overlapping file - const conflicts = await Promise.all( - overlapping.map(async (filePath) => { - const remoteShadowContent = await getFileContent( - this.octokit, - owner, - repo, - filePath, - input.base, - ); - return { - filePath, - conflictReason: - 'Remote commit modified this file since branch creation.', - remoteShadowContent, - }; - }), - ); - - return ok({ - status: 'conflict' as const, - message: `The remote ${input.base} branch has advanced. Rebase required for ${overlapping.join(', ')}.`, - conflicts, - }); - } catch (error: any) { - return fail( - 'UNKNOWN_ERROR', - error instanceof Error ? error.message : String(error), - false, - ); - } - } -} diff --git a/packages/merge/src/conflicts/session-spec.ts b/packages/merge/src/conflicts/session-spec.ts deleted file mode 100644 index 92adb7f..0000000 --- a/packages/merge/src/conflicts/session-spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { z } from 'zod'; - -// INPUT — "Parse, don't validate" -export const SessionCheckInputSchema = z.object({ - sessionId: z.string().min(1, 'Session ID is required'), - repo: z - .string() - .min(1) - .refine((s) => s.includes('/'), 'Must be in owner/repo format'), - base: z.string().default('main'), -}); -export type SessionCheckInput = z.infer; - -// ERROR CODES (exhaustive) -export const SessionCheckErrorCode = z.enum([ - 'SESSION_QUERY_FAILED', - 'GITHUB_API_ERROR', - 'RATE_LIMIT_EXCEEDED', - 'UNKNOWN_ERROR', -]); -export type SessionCheckErrorCode = z.infer; - -// SUCCESS DATA -export interface SessionCheckData { - status: 'clean' | 'conflict'; - message: string; - conflicts: Array<{ - filePath: string; - conflictReason: string; - remoteShadowContent: string; - }>; -} - -// RESULT (Discriminated Union) -export interface SessionCheckSuccess { - success: true; - data: SessionCheckData; -} -export interface SessionCheckFailure { - success: false; - error: { - code: SessionCheckErrorCode; - message: string; - recoverable: boolean; - suggestion?: string; - }; -} -export type SessionCheckResult = SessionCheckSuccess | SessionCheckFailure; - -// INTERFACE (Capability) -export interface SessionCheckSpec { - execute(input: SessionCheckInput): Promise; -} diff --git a/packages/merge/src/index.ts b/packages/merge/src/index.ts index 20aa433..153271e 100644 --- a/packages/merge/src/index.ts +++ b/packages/merge/src/index.ts @@ -12,13 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Conflict detection -export * from './conflicts/index.js'; - -// Init / workflow generation -export * from './init/index.js'; - -// Shared utilities -export { ok, fail } from './shared/result.js'; -export { getSessionChangedFiles, createJulesClient } from './shared/session.js'; -export type { SessionFileInfo } from './shared/session.js'; +export * from './reconcile/index.js'; +export { createMergeOctokit, getAuthOptions } from './shared/auth.js'; +export { ConflictError, HardError, getExitCode, parseJsonInput } from './shared/errors.js'; +export { validateFilePath, validateBranchName } from './shared/validators.js'; +export * from './shared/github.js'; diff --git a/packages/merge/src/init/init-handler.ts b/packages/merge/src/init/init-handler.ts deleted file mode 100644 index e0d718b..0000000 --- a/packages/merge/src/init/init-handler.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { mkdir, writeFile, access, stat } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; -import type { InitSpec, InitInput, InitResult } from './init-spec.js'; -import { ok, fail } from '../shared/result.js'; -import { buildWorkflowYaml } from './templates.js'; - -export class InitHandler implements InitSpec { - async execute(input: InitInput): Promise { - try { - const outputDir = resolve(input.outputDir); - - // 1. Validate output directory exists - try { - const stats = await stat(outputDir); - if (!stats.isDirectory()) { - return fail( - 'DIRECTORY_NOT_FOUND', - `${outputDir} is not a directory.`, - true, - 'Provide a valid directory path with --output-dir.', - ); - } - } catch { - return fail( - 'DIRECTORY_NOT_FOUND', - `Directory does not exist: ${outputDir}`, - true, - 'Create the directory first or provide a valid path.', - ); - } - - // 2. Resolve workflow file path - const workflowDir = join(outputDir, '.github', 'workflows'); - const filePath = join(workflowDir, `${input.workflowName}.yml`); - - // 3. Check if file already exists (unless force) - if (!input.force) { - try { - await access(filePath); - return fail( - 'FILE_ALREADY_EXISTS', - `Workflow file already exists: ${filePath}`, - true, - 'Use --force to overwrite.', - ); - } catch { - // File doesn't exist — good - } - } - - // 4. Generate content - const content = buildWorkflowYaml({ - workflowName: input.workflowName, - baseBranch: input.baseBranch, - }); - - // 5. Write file - try { - await mkdir(workflowDir, { recursive: true }); - await writeFile(filePath, content, 'utf-8'); - } catch (error: any) { - return fail( - 'WRITE_FAILED', - `Failed to write workflow file: ${error.message}`, - true, - ); - } - - return ok({ filePath, content }); - } catch (error: any) { - return fail( - 'UNKNOWN_ERROR', - error instanceof Error ? error.message : String(error), - false, - ); - } - } -} diff --git a/packages/merge/src/init/init-spec.ts b/packages/merge/src/init/init-spec.ts deleted file mode 100644 index d8442c8..0000000 --- a/packages/merge/src/init/init-spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { z } from 'zod'; - -// INPUT — "Parse, don't validate" -export const InitInputSchema = z.object({ - outputDir: z.string().min(1).default('.'), - workflowName: z.string().min(1).default('jules-merge-check'), - baseBranch: z.string().min(1).default('main'), - force: z.boolean().default(false), -}); -export type InitInput = z.infer; - -// ERROR CODES (exhaustive) -export const InitErrorCode = z.enum([ - 'DIRECTORY_NOT_FOUND', - 'FILE_ALREADY_EXISTS', - 'WRITE_FAILED', - 'UNKNOWN_ERROR', -]); -export type InitErrorCode = z.infer; - -// SUCCESS DATA -export interface InitData { - filePath: string; - content: string; -} - -// RESULT (Discriminated Union) -export interface InitSuccess { - success: true; - data: InitData; -} -export interface InitFailure { - success: false; - error: { - code: InitErrorCode; - message: string; - recoverable: boolean; - suggestion?: string; - }; -} -export type InitResult = InitSuccess | InitFailure; - -// INTERFACE (Capability) -export interface InitSpec { - execute(input: InitInput): Promise; -} diff --git a/packages/merge/src/init/templates.ts b/packages/merge/src/init/templates.ts deleted file mode 100644 index d9c9e94..0000000 --- a/packages/merge/src/init/templates.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -export interface WorkflowTemplateOptions { - workflowName: string; - baseBranch: string; -} - -/** - * Build the GitHub Actions workflow YAML for merge conflict detection. - * Isolated from handler logic so template changes never conflict with - * file-writing logic changes. - */ -export function buildWorkflowYaml(options: WorkflowTemplateOptions): string { - return `# Generated by jules-merge init -# This workflow checks for merge conflicts on pull requests. -name: ${options.workflowName} - -on: - pull_request: - branches: [${options.baseBranch}] - -permissions: - contents: read - pull-requests: read - -jobs: - check-conflicts: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Attempt merge - id: merge - continue-on-error: true - run: | - git config user.name "github-actions" - git config user.email "github-actions@github.com" - git fetch origin \${{ github.event.pull_request.base.ref }} - git merge origin/\${{ github.event.pull_request.base.ref }} --no-commit --no-ff - - - name: Check for conflicts - if: steps.merge.outcome == 'failure' - run: | - npx @google/jules-merge check-conflicts \\ - --repo \${{ github.repository }} \\ - --pr \${{ github.event.pull_request.number }} \\ - --sha \${{ github.event.pull_request.head.sha }} - - - name: No conflicts - if: steps.merge.outcome == 'success' - run: echo "✅ No merge conflicts detected" -`; -} diff --git a/packages/merge/src/mcp/server.ts b/packages/merge/src/mcp/server.ts index 1a7a3f6..4e22cd8 100644 --- a/packages/merge/src/mcp/server.ts +++ b/packages/merge/src/mcp/server.ts @@ -15,71 +15,108 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; -import { SessionCheckInputSchema } from '../conflicts/session-spec.js'; -import { SessionCheckHandler } from '../conflicts/session-handler.js'; -import { GitCheckInputSchema } from '../conflicts/git-spec.js'; -import { GitCheckHandler } from '../conflicts/git-handler.js'; -import { createOctokit } from '../shared/github.js'; -import { createJulesClient } from '../shared/session.js'; +import { createMergeOctokit } from '../shared/auth.js'; +import { scanHandler } from '../reconcile/scan-handler.js'; +import { getContentsHandler } from '../reconcile/get-contents-handler.js'; +import { stageResolutionHandler } from '../reconcile/stage-resolution-handler.js'; +import { statusHandler } from '../reconcile/status-handler.js'; +import { pushHandler } from '../reconcile/push-handler.js'; +import { mergeHandler } from '../reconcile/merge-handler.js'; +import { schemaHandler } from '../reconcile/schema-handler.js'; export function createMergeServer(): McpServer { const server = new McpServer({ name: 'jules-merge', - version: '0.0.1', + version: '0.0.3', }); server.tool( - 'check_conflicts', - 'Check for merge conflicts. Provide sessionId for proactive checks against remote, or pullRequestNumber + failingCommitSha for CI failure diagnosis.', + 'scan_fleet', + 'Scan fleet PRs for overlapping file changes and build the reconciliation manifest', { - // Common + prs: z.array(z.number()).describe('PR numbers to scan'), repo: z.string().describe('Repository in owner/repo format'), - // Session mode (proactive) - sessionId: z + base: z.string().default('main').describe('Base branch name'), + }, + async ({ prs, repo, base }) => { + const octokit = createMergeOctokit(); + const result = await scanHandler(octokit, { prs, repo, base }); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(result, null, 2) }, + ], + }; + }, + ); + + server.tool( + 'get_file_contents', + 'Fetch file contents from main, base, or a specific PR head', + { + filePath: z.string().describe('File path within the repo'), + source: z + .string() + .describe('Content source: "base", "main", or "pr:"'), + repo: z.string().describe('Repository in owner/repo format'), + baseSha: z .string() .optional() - .describe('Jules session ID — triggers proactive session mode'), - base: z + .describe('Explicit base SHA (used with source=base)'), + }, + async ({ filePath, source, repo, baseSha }) => { + const octokit = createMergeOctokit(); + const result = await getContentsHandler(octokit, { + filePath, + source, + repo, + baseSha, + }); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(result, null, 2) }, + ], + }; + }, + ); + + server.tool( + 'stage_resolution', + 'Record resolved file content in the reconciliation manifest', + { + filePath: z.string().describe('File path within the repo'), + parents: z + .array(z.string()) + .describe('Parent sources: ["main", "10", "11"]'), + content: z .string() - .default('main') - .describe('Base branch name (session mode only)'), - // Git mode (CI failure) - pullRequestNumber: z - .number() .optional() - .describe('Pull request number — triggers CI failure mode'), - failingCommitSha: z + .describe('Inline resolved content'), + fromFile: z .string() .optional() - .describe('Failing commit SHA (CI failure mode only)'), + .describe('Local file path containing the resolved content'), + note: z.string().optional().describe('Optional resolution note'), + dryRun: z + .boolean() + .optional() + .describe('Validate without writing to the manifest'), }, - async ({ repo, sessionId, base, pullRequestNumber, failingCommitSha }) => { - let result: any; - - if (sessionId) { - const input = SessionCheckInputSchema.parse({ sessionId, repo, base }); - const handler = new SessionCheckHandler(createOctokit(), createJulesClient); - result = await handler.execute(input); - } else if (pullRequestNumber && failingCommitSha) { - const input = GitCheckInputSchema.parse({ - repo, - pullRequestNumber, - failingCommitSha, - }); - const handler = new GitCheckHandler(); - result = await handler.execute(input); - } else { - result = { - success: false, - error: { - code: 'INVALID_INPUT', - message: - 'Provide sessionId for proactive checks, or pullRequestNumber + failingCommitSha for CI failure diagnosis.', - recoverable: false, - }, - }; - } + async (args) => { + const result = await stageResolutionHandler(args); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(result, null, 2) }, + ], + }; + }, + ); + server.tool( + 'get_status', + 'Get current reconciliation status — shows pending, resolved, and clean files', + {}, + async () => { + const result = await statusHandler({}); return { content: [ { type: 'text' as const, text: JSON.stringify(result, null, 2) }, @@ -89,33 +126,71 @@ export function createMergeServer(): McpServer { ); server.tool( - 'init_workflow', - 'Generate a GitHub Actions workflow file for automated merge conflict detection.', + 'push_reconciliation', + 'Create the multi-parent reconciliation commit and PR via Git Data API', { - outputDir: z - .string() - .default('.') - .describe('Directory to write .github/workflows/ into'), - workflowName: z + branch: z .string() - .default('jules-merge-check') - .describe('Workflow filename (without .yml)'), - baseBranch: z - .string() - .default('main') - .describe('Base branch to check against'), - force: z + .describe('Branch name for the reconciliation PR'), + message: z.string().describe('Commit message'), + repo: z.string().describe('Repository in owner/repo format'), + dryRun: z .boolean() - .default(false) - .describe('Overwrite existing workflow file'), + .optional() + .describe('Validate without pushing'), + mergeStrategy: z + .enum(['sequential', 'octopus']) + .optional() + .describe('"sequential" (default) or "octopus"'), + prTitle: z.string().optional().describe('Custom PR title'), + prBody: z.string().optional().describe('Custom PR body'), }, - async ({ outputDir, workflowName, baseBranch, force }) => { - const { InitHandler } = await import('../init/init-handler.js'); - const { InitInputSchema } = await import('../init/init-spec.js'); - const input = InitInputSchema.parse({ outputDir, workflowName, baseBranch, force }); - const handler = new InitHandler(); - const result = await handler.execute(input); + async (args) => { + const octokit = createMergeOctokit(); + const result = await pushHandler(octokit, args); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(result, null, 2) }, + ], + }; + }, + ); + server.tool( + 'merge_reconciliation', + 'Merge the reconciliation PR using a merge commit. Always uses merge strategy — never squash or rebase — to preserve the ancestry chain that auto-closes fleet PRs.', + { + pr: z.number().describe('PR number to merge'), + repo: z.string().describe('Repository in owner/repo format'), + }, + async ({ pr, repo }) => { + const octokit = createMergeOctokit(); + const result = await mergeHandler(octokit, { pr, repo }); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(result, null, 2) }, + ], + }; + }, + ); + + server.tool( + 'get_schema', + 'Get JSON schemas for command inputs/outputs', + { + command: z + .string() + .optional() + .describe( + 'Command name: scan, get-contents, stage-resolution, status, push, merge', + ), + all: z + .boolean() + .optional() + .describe('Return all schemas at once'), + }, + async ({ command, all }) => { + const result = schemaHandler(command, { all }); return { content: [ { type: 'text' as const, text: JSON.stringify(result, null, 2) }, diff --git a/packages/merge/src/reconcile/get-contents-handler.ts b/packages/merge/src/reconcile/get-contents-handler.ts new file mode 100644 index 0000000..6762d5d --- /dev/null +++ b/packages/merge/src/reconcile/get-contents-handler.ts @@ -0,0 +1,116 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Octokit } from '@octokit/rest'; +import { + GetContentsInputSchema, + GetContentsOutputSchema, +} from './schemas.js'; +import { + getContents, + compareCommits, + getPullRequest, +} from '../shared/github.js'; +import { readManifest } from './manifest.js'; +import { validateFilePath } from '../shared/validators.js'; + +export async function getContentsHandler(octokit: Octokit, rawInput: any) { + const input = GetContentsInputSchema.parse(rawInput); + validateFilePath(input.filePath); + const [owner, repo] = input.repo.split('/'); + if (!owner || !repo) { + throw new Error('Repo must be in owner/repo format'); + } + + const manifest = readManifest(); + let refToFetch = ''; + + if (input.source === 'main') { + refToFetch = manifest?.base.branch || 'main'; + } else if (input.source === 'base' && input.baseSha) { + refToFetch = input.baseSha; + } else if (input.source === 'base') { + if (!manifest || manifest.prs.length === 0) { + throw new Error( + 'Cannot resolve base source without a valid manifest or explicit baseSha', + ); + } + + // Find PRs that actually touch this file so we compare against the right head. + const hotZone = (manifest.hotZones ?? []).find( + (hz) => hz.filePath === input.filePath, + ); + const cleanFile = manifest.cleanFiles.find( + (cf) => cf.filePath === input.filePath, + ); + const relevantPrIds: number[] = + hotZone?.competingPrs ?? + (cleanFile + ? [cleanFile.sourcePr] + : manifest.prs.map((p) => p.id)); + + const relevantPrs = manifest.prs.filter((p) => + relevantPrIds.includes(p.id), + ); + + let mergeBaseSha: string | undefined; + for (const pr of relevantPrs) { + const compare = await compareCommits( + octokit, + owner, + repo, + manifest.base.sha, + pr.headSha, + ); + if ((compare as any).merge_base_commit) { + mergeBaseSha = (compare as any).merge_base_commit.sha; + break; + } + } + + if (!mergeBaseSha) { + throw new Error( + `Could not find merge base commit for ${input.filePath}`, + ); + } + refToFetch = mergeBaseSha; + } else if (input.source.startsWith('pr:')) { + const prStr = input.source.replace('pr:', ''); + if (!/^\d+$/.test(prStr)) { + throw new Error(`Invalid PR source: ${input.source}`); + } + const prId = parseInt(prStr, 10); + const pr = await getPullRequest(octokit, owner, repo, prId); + refToFetch = pr.head.sha; + } else { + throw new Error(`Invalid source: ${input.source}`); + } + + const fileData = await getContents( + octokit, + owner, + repo, + input.filePath, + refToFetch, + ); + + return GetContentsOutputSchema.parse({ + filePath: input.filePath, + source: input.source, + sha: fileData.sha, + content: fileData.content, + encoding: 'utf-8', + totalLines: fileData.content.split('\n').length, + }); +} diff --git a/packages/merge/src/init/index.ts b/packages/merge/src/reconcile/index.ts similarity index 56% rename from packages/merge/src/init/index.ts rename to packages/merge/src/reconcile/index.ts index 4115fe0..9d82b7b 100644 --- a/packages/merge/src/init/index.ts +++ b/packages/merge/src/reconcile/index.ts @@ -12,7 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -export * from './init-spec.js'; -export { InitHandler } from './init-handler.js'; -export { buildWorkflowYaml } from './templates.js'; -export type { WorkflowTemplateOptions } from './templates.js'; +export * from './schemas.js'; +export * from './manifest.js'; +export { scanHandler } from './scan-handler.js'; +export { getContentsHandler } from './get-contents-handler.js'; +export { stageResolutionHandler } from './stage-resolution-handler.js'; +export { statusHandler } from './status-handler.js'; +export { pushHandler } from './push-handler.js'; +export { mergeHandler } from './merge-handler.js'; +export { schemaHandler } from './schema-handler.js'; diff --git a/packages/merge/src/reconcile/manifest.ts b/packages/merge/src/reconcile/manifest.ts new file mode 100644 index 0000000..a302d33 --- /dev/null +++ b/packages/merge/src/reconcile/manifest.ts @@ -0,0 +1,104 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import fs from 'fs'; +import path from 'path'; +import { z } from 'zod'; + +export const ManifestSchema = z.object({ + batchId: z.string(), + createdAt: z.string(), + repo: z.string(), + base: z.object({ + branch: z.string(), + sha: z.string(), + }), + prs: z.array( + z.object({ + id: z.number(), + headSha: z.string(), + branch: z.string(), + }), + ), + resolved: z.array( + z.object({ + filePath: z.string(), + parents: z.array(z.string()), + contentSha: z.string(), + note: z.string().optional(), + stagedAt: z.string(), + _stagedContent: z.string().optional(), + }), + ), + hotZones: z + .array( + z.object({ + filePath: z.string(), + competingPrs: z.array(z.number()), + changeType: z.enum(['modified', 'added', 'deleted']), + }), + ) + .optional(), + pending: z.array(z.string()), + cleanFiles: z.array( + z.object({ + filePath: z.string(), + sourcePr: z.number(), + changeType: z.enum(['modified', 'added', 'deleted']).optional(), + }), + ), +}); + +export type Manifest = z.infer; + +const MANIFEST_PATH = + process.env.JULES_MERGE_MANIFEST_PATH || + path.join(process.cwd(), '.jules', 'merge', 'manifest.json'); + +export function getManifestPath(): string { + return MANIFEST_PATH; +} + +export function readManifest(): Manifest | null { + try { + // One-time migration: .jules-merge/ → .jules/merge/ + const legacyPath = path.join( + process.cwd(), + '.jules-merge', + 'manifest.json', + ); + if (!fs.existsSync(MANIFEST_PATH) && fs.existsSync(legacyPath)) { + const dir = path.dirname(MANIFEST_PATH); + fs.mkdirSync(dir, { recursive: true }); + fs.renameSync(legacyPath, MANIFEST_PATH); + } + + if (!fs.existsSync(MANIFEST_PATH)) { + return null; + } + const data = fs.readFileSync(MANIFEST_PATH, 'utf-8'); + const json = JSON.parse(data); + return ManifestSchema.parse(json); + } catch (err) { + return null; + } +} + +export function writeManifest(manifest: Manifest): void { + const dir = path.dirname(MANIFEST_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), 'utf-8'); +} diff --git a/packages/merge/src/reconcile/merge-handler.ts b/packages/merge/src/reconcile/merge-handler.ts new file mode 100644 index 0000000..303fa88 --- /dev/null +++ b/packages/merge/src/reconcile/merge-handler.ts @@ -0,0 +1,55 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Octokit } from '@octokit/rest'; +import { MergeInputSchema, MergeOutputSchema } from './schemas.js'; +import { getPullRequest, mergePullRequest } from '../shared/github.js'; +import { HardError } from '../shared/errors.js'; + +export async function mergeHandler(octokit: Octokit, rawInput: any) { + const input = MergeInputSchema.parse(rawInput); + const [owner, repo] = input.repo.split('/'); + if (!owner || !repo) { + throw new Error('Repo must be in owner/repo format'); + } + + const pr = await getPullRequest(octokit, owner, repo, input.pr); + + if (pr.state !== 'open') { + throw new HardError( + `PR #${input.pr} is not open (state: ${pr.state})`, + ); + } + + if (!pr.mergeable) { + throw new HardError( + `PR #${input.pr} is not mergeable — resolve conflicts first`, + ); + } + + const result = await mergePullRequest( + octokit, + owner, + repo, + input.pr, + 'merge', + ); + + return MergeOutputSchema.parse({ + status: 'merged', + pr: input.pr, + sha: result.sha, + url: pr.html_url, + }); +} diff --git a/packages/merge/src/reconcile/push-commits.ts b/packages/merge/src/reconcile/push-commits.ts new file mode 100644 index 0000000..75a8727 --- /dev/null +++ b/packages/merge/src/reconcile/push-commits.ts @@ -0,0 +1,71 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as github from '../shared/github.js'; +import type { PushContext, CommitResult } from './push-types.js'; + +export async function createMergeCommits( + ctx: PushContext, + treeSha: string, +): Promise { + const strategy = ctx.input.mergeStrategy || 'sequential'; + const allParents = [ + ctx.baseSha, + ...ctx.manifest.prs.map((p) => p.headSha), + ]; + + if (strategy === 'octopus' || ctx.manifest.prs.length === 1) { + // Single multi-parent commit + const commit = await github.createCommit( + ctx.octokit, + ctx.owner, + ctx.repo, + ctx.input.message, + treeSha, + allParents, + ); + return { + finalSha: commit.sha, + parents: allParents, + }; + } + + // Sequential: chain of 2-parent commits + const mergeChain: CommitResult['mergeChain'] = []; + let currentSha = ctx.baseSha; + + for (const pr of ctx.manifest.prs) { + const parents = [currentSha, pr.headSha]; + const commit = await github.createCommit( + ctx.octokit, + ctx.owner, + ctx.repo, + `${ctx.input.message} (merge PR #${pr.id})`, + treeSha, + parents, + ); + mergeChain.push({ + commitSha: commit.sha, + parents, + prId: pr.id, + }); + currentSha = commit.sha; + } + + return { + finalSha: currentSha, + parents: allParents, + mergeChain, + }; +} diff --git a/packages/merge/src/reconcile/push-handler.ts b/packages/merge/src/reconcile/push-handler.ts new file mode 100644 index 0000000..32a6d9d --- /dev/null +++ b/packages/merge/src/reconcile/push-handler.ts @@ -0,0 +1,110 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Octokit } from '@octokit/rest'; +import { PushOutputSchema } from './schemas.js'; +import * as github from '../shared/github.js'; +import { validatePushInput } from './push-validate.js'; +import { buildTreeOverlay } from './push-tree.js'; +import { createMergeCommits } from './push-commits.js'; + +export async function pushHandler(octokit: Octokit, rawInput: any) { + const ctx = await validatePushInput(octokit, rawInput); + + const tree = await buildTreeOverlay(ctx); + + const allParents = [ + ctx.baseSha, + ...ctx.manifest.prs.map((p) => p.headSha), + ]; + + if (ctx.input.dryRun) { + return PushOutputSchema.parse({ + status: 'dry-run', + parents: allParents, + filesUploaded: tree.filesUploaded, + filesCarried: tree.filesCarried, + warnings: ctx.warnings.length > 0 ? ctx.warnings : undefined, + }); + } + + const newTree = await github.createTree( + ctx.octokit, + ctx.owner, + ctx.repo, + ctx.baseTreeSha, + tree.overlay, + ); + + const commits = await createMergeCommits(ctx, newTree.sha); + + // Publish: create/update ref + find-or-create PR + const refName = `refs/heads/${ctx.input.branch}`; + try { + await github.updateRef( + ctx.octokit, + ctx.owner, + ctx.repo, + refName, + commits.finalSha, + true, + ); + } catch { + await github.createRef( + ctx.octokit, + ctx.owner, + ctx.repo, + refName, + commits.finalSha, + ); + } + + const existingPrs = await github.listPullRequests( + ctx.octokit, + ctx.owner, + ctx.repo, + ctx.input.branch, + ctx.baseBranchName, + 'open', + ); + const pullRequest = + existingPrs.length > 0 + ? existingPrs[0] + : await github.createPullRequest( + ctx.octokit, + ctx.owner, + ctx.repo, + ctx.input.prTitle || ctx.input.message, + ctx.input.branch, + ctx.baseBranchName, + ctx.input.prBody || + 'Reconciliation PR created by Jules Merge', + ); + + return PushOutputSchema.parse({ + status: 'pushed', + commitSha: commits.finalSha, + branch: ctx.input.branch, + pullRequest: { + number: pullRequest.number, + url: pullRequest.html_url || '', + title: pullRequest.title, + }, + parents: commits.parents, + mergeChain: commits.mergeChain, + filesUploaded: tree.filesUploaded, + filesCarried: tree.filesCarried, + warnings: ctx.warnings.length > 0 ? ctx.warnings : undefined, + }); +} diff --git a/packages/merge/src/reconcile/push-tree.ts b/packages/merge/src/reconcile/push-tree.ts new file mode 100644 index 0000000..cf112f3 --- /dev/null +++ b/packages/merge/src/reconcile/push-tree.ts @@ -0,0 +1,80 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as github from '../shared/github.js'; +import type { PushContext, TreeResult } from './push-types.js'; + +export async function buildTreeOverlay(ctx: PushContext): Promise { + const overlay: any[] = []; + let filesUploaded = 0; + let filesCarried = 0; + + // Add clean files + for (const cf of ctx.manifest.cleanFiles) { + const changeType = (cf as any).changeType; + if (changeType === 'deleted') { + overlay.push({ + path: cf.filePath, + mode: '100644', + type: 'blob', + sha: null, + }); + filesCarried++; + continue; + } + + const sourcePr = ctx.manifest.prs.find((p) => p.id === cf.sourcePr); + if (!sourcePr) continue; + + const fileData = await github.getContents( + ctx.octokit, + ctx.owner, + ctx.repo, + cf.filePath, + sourcePr.headSha, + ); + const blob = await github.createBlob( + ctx.octokit, + ctx.owner, + ctx.repo, + fileData.content, + ); + overlay.push({ + path: cf.filePath, + mode: '100644', + type: 'blob', + sha: blob.sha, + }); + filesCarried++; + } + + // Add resolved files + for (const resolved of ctx.manifest.resolved) { + const blob = await github.createBlob( + ctx.octokit, + ctx.owner, + ctx.repo, + resolved._stagedContent || '', + ); + overlay.push({ + path: resolved.filePath, + mode: '100644', + type: 'blob', + sha: blob.sha, + }); + filesUploaded++; + } + + return { overlay, filesUploaded, filesCarried }; +} diff --git a/packages/merge/src/reconcile/push-types.ts b/packages/merge/src/reconcile/push-types.ts new file mode 100644 index 0000000..718d4fb --- /dev/null +++ b/packages/merge/src/reconcile/push-types.ts @@ -0,0 +1,44 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Octokit } from '@octokit/rest'; +import type { Manifest } from './manifest.js'; +import { z } from 'zod'; +import { PushInputSchema } from './schemas.js'; + +export type PushInput = z.infer; + +export interface PushContext { + octokit: Octokit; + input: PushInput; + owner: string; + repo: string; + manifest: Manifest; + baseBranchName: string; + baseSha: string; + baseTreeSha: string; + warnings: string[]; +} + +export interface TreeResult { + overlay: any[]; + filesUploaded: number; + filesCarried: number; +} + +export interface CommitResult { + finalSha: string; + parents: string[]; + mergeChain?: { commitSha: string; parents: string[]; prId: number }[]; +} diff --git a/packages/merge/src/reconcile/push-validate.ts b/packages/merge/src/reconcile/push-validate.ts new file mode 100644 index 0000000..699be60 --- /dev/null +++ b/packages/merge/src/reconcile/push-validate.ts @@ -0,0 +1,86 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Octokit } from '@octokit/rest'; +import { PushInputSchema } from './schemas.js'; +import { readManifest } from './manifest.js'; +import { validateBranchName } from '../shared/validators.js'; +import { ConflictError, HardError } from '../shared/errors.js'; +import * as github from '../shared/github.js'; +import type { PushContext } from './push-types.js'; + +export async function validatePushInput( + octokit: Octokit, + rawInput: any, +): Promise { + const input = PushInputSchema.parse(rawInput); + validateBranchName(input.branch); + + const [owner, repo] = input.repo.split('/'); + if (!owner || !repo) { + throw new HardError('Repo must be in owner/repo format'); + } + + const manifest = readManifest(); + if (!manifest) { + throw new HardError( + 'No active reconciliation manifest found. Run scan first.', + ); + } + + if (manifest.pending.length > 0) { + throw new ConflictError( + `Cannot push: ${manifest.pending.length} file(s) still pending resolution.`, + ); + } + + const warnings: string[] = []; + + // Check base SHA freshness + const currentBase = await github.getBranch( + octokit, + owner, + repo, + manifest.base.branch, + ); + if (currentBase.commit.sha !== manifest.base.sha) { + warnings.push('BASE_SHA_MISMATCH'); + } + + const baseSha = manifest.base.sha; + const baseTreeSha = currentBase.commit.commit.tree.sha; + + // Verify merge strategy compatibility + const repoInfo = await github.getRepo(octokit, owner, repo); + if ( + !(repoInfo as any).allow_merge_commit && + (repoInfo as any).allow_squash_merge + ) { + throw new HardError( + 'Repository only allows squash merges. Jules Merge requires merge commits to preserve ancestry chain.', + ); + } + + return { + octokit, + input, + owner, + repo, + manifest, + baseBranchName: manifest.base.branch, + baseSha, + baseTreeSha, + warnings, + }; +} diff --git a/packages/merge/src/reconcile/scan-handler.ts b/packages/merge/src/reconcile/scan-handler.ts new file mode 100644 index 0000000..fc4d4c5 --- /dev/null +++ b/packages/merge/src/reconcile/scan-handler.ts @@ -0,0 +1,125 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Octokit } from '@octokit/rest'; +import { ScanInputSchema, ScanOutputSchema } from './schemas.js'; +import { + getBranch, + getPullRequest, + compareCommits, +} from '../shared/github.js'; +import { writeManifest, type Manifest } from './manifest.js'; + +function toChangeType(status: string): 'modified' | 'added' | 'deleted' { + if (status === 'removed') return 'deleted'; + if (status === 'added') return 'added'; + return 'modified'; +} + +export async function scanHandler(octokit: Octokit, rawInput: any) { + const input = ScanInputSchema.parse(rawInput); + const [owner, repo] = input.repo.split('/'); + if (!owner || !repo) { + throw new Error('Repo must be in owner/repo format'); + } + + const baseBranchName = + input.base || process.env.JULES_MERGE_BASE_BRANCH || 'main'; + const baseBranch = await getBranch(octokit, owner, repo, baseBranchName); + const baseSha = baseBranch.commit.sha; + + const prsData: Manifest['prs'] = []; + const fileToPrs = new Map(); + + for (const prId of input.prs) { + const pr = await getPullRequest(octokit, owner, repo, prId); + const headSha = pr.head.sha; + const branch = pr.head.ref; + + prsData.push({ + id: prId, + headSha, + branch, + }); + + const compare = await compareCommits( + octokit, + owner, + repo, + baseSha, + headSha, + ); + if (compare.files) { + for (const file of compare.files) { + if (!fileToPrs.has(file.filename)) { + fileToPrs.set(file.filename, { prs: [], status: file.status! }); + } + fileToPrs.get(file.filename)!.prs.push(prId); + } + } + } + + const hotZones: any[] = []; + const cleanFiles: any[] = []; + + for (const [filePath, data] of fileToPrs.entries()) { + if (data.prs.length > 1) { + hotZones.push({ + filePath, + competingPrs: data.prs, + changeType: toChangeType(data.status), + }); + } else { + cleanFiles.push({ + filePath, + sourcePr: data.prs[0], + changeType: toChangeType(data.status), + }); + } + } + + const batchId = `batch-${Date.now()}`; + + const manifest: Manifest = { + batchId, + createdAt: new Date().toISOString(), + repo: input.repo, + base: { + branch: baseBranchName, + sha: baseSha, + }, + prs: prsData, + resolved: [], + hotZones, + pending: hotZones.map((hz) => hz.filePath), + cleanFiles, + }; + + writeManifest(manifest); + + const output = { + status: hotZones.length > 0 ? 'conflicts' : 'clean', + base: manifest.base, + prs: prsData.map((pr) => ({ + ...pr, + files: Array.from(fileToPrs.entries()) + .filter(([_, data]) => data.prs.includes(pr.id)) + .map(([filePath, _]) => filePath), + })), + hotZones, + cleanFiles, + }; + + return ScanOutputSchema.parse(output); +} diff --git a/packages/merge/src/reconcile/schema-handler.ts b/packages/merge/src/reconcile/schema-handler.ts new file mode 100644 index 0000000..8278fb5 --- /dev/null +++ b/packages/merge/src/reconcile/schema-handler.ts @@ -0,0 +1,72 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { zodToJsonSchema } from 'zod-to-json-schema'; +import * as schemas from './schemas.js'; + +export function schemaHandler(command?: string, options?: any) { + const schemaMap: Record = { + scan: { + input: schemas.ScanInputSchema, + output: schemas.ScanOutputSchema, + }, + 'get-contents': { + input: schemas.GetContentsInputSchema, + output: schemas.GetContentsOutputSchema, + }, + 'stage-resolution': { + input: schemas.StageResolutionInputSchema, + output: schemas.StageResolutionOutputSchema, + }, + status: { + input: schemas.StatusInputSchema, + output: schemas.StatusOutputSchema, + }, + push: { + input: schemas.PushInputSchema, + output: schemas.PushOutputSchema, + }, + merge: { + input: schemas.MergeInputSchema, + output: schemas.MergeOutputSchema, + }, + }; + + if (options?.all) { + const allSchemas: any = {}; + for (const [cmd, pair] of Object.entries(schemaMap)) { + allSchemas[cmd] = { + input: zodToJsonSchema(pair.input, `${cmd}Input`), + output: zodToJsonSchema(pair.output, `${cmd}Output`), + }; + } + return allSchemas; + } + + if (!command) { + throw new Error('Must provide a command name or use --all'); + } + + if (!schemaMap[command]) { + throw new Error(`Unknown command: ${command}`); + } + + return { + input: zodToJsonSchema(schemaMap[command].input, `${command}Input`), + output: zodToJsonSchema( + schemaMap[command].output, + `${command}Output`, + ), + }; +} diff --git a/packages/merge/src/reconcile/schemas.ts b/packages/merge/src/reconcile/schemas.ts new file mode 100644 index 0000000..31240f0 --- /dev/null +++ b/packages/merge/src/reconcile/schemas.ts @@ -0,0 +1,179 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { z } from 'zod'; + +// ─── Scan ─────────────────────────────────────────────────────── + +export const ScanInputSchema = z.object({ + prs: z.array(z.number()), + repo: z.string(), + base: z.string().optional(), + includeClean: z.boolean().optional(), +}); + +export const ScanOutputSchema = z.object({ + status: z.enum(['conflicts', 'clean']), + base: z.object({ + branch: z.string(), + sha: z.string(), + }), + prs: z.array( + z.object({ + id: z.number(), + headSha: z.string(), + branch: z.string(), + files: z.array(z.string()), + }), + ), + hotZones: z.array( + z.object({ + filePath: z.string(), + competingPrs: z.array(z.number()), + changeType: z.enum(['modified', 'added', 'deleted']), + }), + ), + cleanFiles: z.array( + z.object({ + filePath: z.string(), + sourcePr: z.number(), + }), + ), +}); + +// ─── Get Contents ─────────────────────────────────────────────── + +export const GetContentsInputSchema = z.object({ + filePath: z.string(), + source: z.string(), + repo: z.string(), + baseSha: z.string().optional(), +}); + +export const GetContentsOutputSchema = z.object({ + filePath: z.string(), + source: z.string(), + sha: z.string(), + content: z.string(), + encoding: z.literal('utf-8'), + totalLines: z.number(), +}); + +// ─── Stage Resolution ─────────────────────────────────────────── + +export const StageResolutionInputSchema = z + .object({ + filePath: z.string(), + parents: z.array(z.string()), + content: z.string().optional(), + fromFile: z.string().optional(), + note: z.string().optional(), + dryRun: z.boolean().optional(), + }) + .refine((data) => data.content !== undefined || data.fromFile !== undefined, { + message: 'Either content or fromFile must be provided', + }); + +export const StageResolutionOutputSchema = z.object({ + status: z.literal('staged'), + filePath: z.string(), + pending: z.number(), + resolved: z.number(), +}); + +// ─── Status ───────────────────────────────────────────────────── + +export const StatusInputSchema = z.object({ + manifest: z.string().optional(), +}); + +export const StatusOutputSchema = z.object({ + batchId: z.string(), + ready: z.boolean(), + resolved: z.array( + z.object({ + filePath: z.string(), + parents: z.array(z.string()), + note: z.string().optional(), + }), + ), + pending: z.array( + z.object({ + filePath: z.string(), + competingPrs: z.array(z.number()), + }), + ), + cleanFiles: z.array( + z.object({ + filePath: z.string(), + sourcePr: z.number(), + changeType: z.enum(['modified', 'added', 'deleted']).optional(), + }), + ), +}); + +// ─── Merge ────────────────────────────────────────────────────── + +export const MergeInputSchema = z.object({ + pr: z.number(), + repo: z.string(), +}); + +export const MergeOutputSchema = z.object({ + status: z.literal('merged'), + pr: z.number(), + sha: z.string(), + url: z.string(), +}); + +// ─── Push ─────────────────────────────────────────────────────── + +export const PushInputSchema = z.object({ + branch: z.string(), + message: z.string(), + repo: z.string(), + dryRun: z.boolean().optional(), + prTitle: z.string().optional(), + prBody: z.string().optional(), + mergeStrategy: z + .enum(['sequential', 'octopus']) + .default('sequential') + .optional(), +}); + +export const PushOutputSchema = z.object({ + status: z.enum(['pushed', 'dry-run']), + commitSha: z.string().optional(), + branch: z.string().optional(), + pullRequest: z + .object({ + number: z.number(), + url: z.string(), + title: z.string(), + }) + .optional(), + parents: z.array(z.string()), + mergeChain: z + .array( + z.object({ + commitSha: z.string(), + parents: z.array(z.string()), + prId: z.number(), + }), + ) + .optional(), + filesUploaded: z.number(), + filesCarried: z.number(), + warnings: z.array(z.string()).optional(), +}); diff --git a/packages/merge/src/reconcile/stage-resolution-handler.ts b/packages/merge/src/reconcile/stage-resolution-handler.ts new file mode 100644 index 0000000..ccbd014 --- /dev/null +++ b/packages/merge/src/reconcile/stage-resolution-handler.ts @@ -0,0 +1,82 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + StageResolutionInputSchema, + StageResolutionOutputSchema, +} from './schemas.js'; +import { readManifest, writeManifest } from './manifest.js'; +import { validateFilePath } from '../shared/validators.js'; +import crypto from 'crypto'; +import fs from 'fs'; + +export async function stageResolutionHandler(rawInput: any) { + const input = StageResolutionInputSchema.parse(rawInput); + validateFilePath(input.filePath); + + let fileContent = ''; + if (input.content !== undefined) { + fileContent = input.content; + } else if (input.fromFile) { + if (!fs.existsSync(input.fromFile)) { + throw new Error(`File not found: ${input.fromFile}`); + } + fileContent = fs.readFileSync(input.fromFile, 'utf-8'); + } + + const manifest = readManifest(); + if (!manifest) { + throw new Error( + 'No active reconciliation manifest found. Run scan first.', + ); + } + + // Remove from pending + manifest.pending = manifest.pending.filter((p) => p !== input.filePath); + + // Add to resolved + const contentSha = crypto + .createHash('sha256') + .update(fileContent) + .digest('hex'); + const existingIndex = manifest.resolved.findIndex( + (r) => r.filePath === input.filePath, + ); + + const newResolved = { + filePath: input.filePath, + parents: input.parents, + contentSha, + note: input.note, + stagedAt: new Date().toISOString(), + _stagedContent: fileContent, + }; + + if (existingIndex >= 0) { + manifest.resolved[existingIndex] = newResolved; + } else { + manifest.resolved.push(newResolved); + } + + if (!input.dryRun) { + writeManifest(manifest); + } + + return StageResolutionOutputSchema.parse({ + status: 'staged', + filePath: input.filePath, + pending: manifest.pending.length, + resolved: manifest.resolved.length, + }); +} diff --git a/packages/merge/src/reconcile/status-handler.ts b/packages/merge/src/reconcile/status-handler.ts new file mode 100644 index 0000000..9a2ffae --- /dev/null +++ b/packages/merge/src/reconcile/status-handler.ts @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { StatusInputSchema, StatusOutputSchema } from './schemas.js'; +import { readManifest } from './manifest.js'; + +export async function statusHandler(rawInput: any) { + const input = StatusInputSchema.parse(rawInput); + + const manifest = readManifest(); + if (!manifest) { + throw new Error('No active reconciliation manifest found.'); + } + + const hotZoneMap = new Map( + (manifest.hotZones ?? []).map((hz) => [hz.filePath, hz.competingPrs]), + ); + + const pending = manifest.pending.map((filePath) => ({ + filePath, + competingPrs: + hotZoneMap.get(filePath) ?? manifest.prs.map((p) => p.id), + })); + + const ready = manifest.pending.length === 0; + + return StatusOutputSchema.parse({ + batchId: manifest.batchId, + ready, + resolved: manifest.resolved.map((r) => ({ + filePath: r.filePath, + parents: r.parents, + note: r.note, + })), + pending, + cleanFiles: manifest.cleanFiles, + }); +} diff --git a/packages/merge/src/shared/auth.ts b/packages/merge/src/shared/auth.ts new file mode 100644 index 0000000..e0fafba --- /dev/null +++ b/packages/merge/src/shared/auth.ts @@ -0,0 +1,119 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Octokit } from '@octokit/rest'; +import { createAppAuth } from '@octokit/auth-app'; +import { resolvePrivateKey } from './resolve-key.js'; + +/** + * Canonical env var name for the private key. + * Legacy alternatives (FLEET_APP_PRIVATE_KEY, GITHUB_APP_PRIVATE_KEY_BASE64) + * are accepted with a deprecation warning. + */ +const CANONICAL_KEY_NAME = 'FLEET_APP_PRIVATE_KEY_BASE64'; + +/** Private key env var candidates, in priority order. */ +const KEY_CANDIDATES = [ + { name: 'FLEET_APP_PRIVATE_KEY_BASE64', canonical: true }, + { name: 'FLEET_APP_PRIVATE_KEY', canonical: false }, + { name: 'GITHUB_APP_PRIVATE_KEY_BASE64', canonical: false }, + { name: 'GITHUB_APP_PRIVATE_KEY', canonical: false }, +] as const; + +/** + * Detect auth mode from environment variables and return Octokit constructor options. + * + * Auth resolution protocol: + * + * 1. App auth — requires all three: + * - FLEET_APP_ID (or GITHUB_APP_ID) + * - FLEET_APP_INSTALLATION_ID (or GITHUB_APP_INSTALLATION_ID) + * - Private key (first found from KEY_CANDIDATES) + * If partial (ID set but key missing) → throw a clear error. + * + * 2. Token auth — GITHUB_TOKEN or GH_TOKEN + * + * 3. No auth → throw listing all checked env vars. + */ +export function getAuthOptions(): ConstructorParameters[0] { + const appId = + process.env.FLEET_APP_ID || process.env.GITHUB_APP_ID; + const installationId = + process.env.FLEET_APP_INSTALLATION_ID || + process.env.GITHUB_APP_INSTALLATION_ID; + + // Find the first available private key + let privateKeyValue: string | undefined; + let privateKeySource: string | undefined; + for (const candidate of KEY_CANDIDATES) { + const val = process.env[candidate.name]; + if (val) { + privateKeyValue = val; + privateKeySource = candidate.name; + if (!candidate.canonical) { + console.warn( + `⚠ Using legacy env var ${candidate.name} — prefer ${CANONICAL_KEY_NAME}`, + ); + } + break; + } + } + + // Partial App config detection + const hasAppId = Boolean(appId); + const hasInstallId = Boolean(installationId); + const hasKey = Boolean(privateKeyValue); + + if (hasAppId || hasInstallId) { + // At least one App var is set — require all three + if (!hasKey) { + const checkedNames = KEY_CANDIDATES.map((c) => c.name).join(', '); + throw new Error( + `App auth partially configured: ${hasAppId ? 'FLEET_APP_ID' : 'FLEET_APP_INSTALLATION_ID'} is set but no private key found.\n` + + `Checked: ${checkedNames}\n` + + `Either set all App auth vars or remove FLEET_APP_ID to use token auth.`, + ); + } + if (!hasAppId || !hasInstallId) { + throw new Error( + `App auth partially configured: missing ${!hasAppId ? 'FLEET_APP_ID' : 'FLEET_APP_INSTALLATION_ID'}.\n` + + `Set all three: FLEET_APP_ID, ${CANONICAL_KEY_NAME}, FLEET_APP_INSTALLATION_ID.`, + ); + } + + return { + authStrategy: createAppAuth, + auth: { + appId, + privateKey: resolvePrivateKey(privateKeyValue, undefined), + installationId: Number(installationId), + }, + }; + } + + // Token auth fallback + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) { + return { auth: token }; + } + + throw new Error( + 'GitHub auth not configured. Set FLEET_APP_ID + FLEET_APP_PRIVATE_KEY_BASE64 + FLEET_APP_INSTALLATION_ID for App auth, or GITHUB_TOKEN for token auth.', + ); +} + +/** Create a new authenticated Octokit instance. */ +export function createMergeOctokit(): Octokit { + return new Octokit(getAuthOptions()); +} diff --git a/packages/merge/src/shared/errors.ts b/packages/merge/src/shared/errors.ts new file mode 100644 index 0000000..de18c2a --- /dev/null +++ b/packages/merge/src/shared/errors.ts @@ -0,0 +1,60 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Parses the value of a --json option. + * + * Returns the parsed object when given a valid JSON string. + * Returns null when the flag was not provided (undefined) or was mistakenly + * set to a boolean (which happens if the option is declared without ). + * Throws on malformed JSON. + */ +export function parseJsonInput( + jsonFlag: string | undefined, +): Record | null { + if (jsonFlag === undefined || typeof jsonFlag !== 'string') { + return null; + } + return JSON.parse(jsonFlag); +} + +/** + * Signals a conflict or action-required state — exit code 1. + * Use for recoverable situations (stale base SHA, pending hot zones). + */ +export class ConflictError extends Error { + readonly exitCode = 1; + constructor(message: string) { + super(message); + this.name = 'ConflictError'; + } +} + +/** + * Signals an unrecoverable error — exit code 2. + * Use for missing manifests, invalid API responses, programmer errors. + */ +export class HardError extends Error { + readonly exitCode = 2; + constructor(message: string) { + super(message); + this.name = 'HardError'; + } +} + +/** Maps any thrown value to the correct process exit code. */ +export function getExitCode(error: unknown): 1 | 2 { + if (error instanceof ConflictError) return 1; + return 2; +} diff --git a/packages/merge/src/shared/github.ts b/packages/merge/src/shared/github.ts index 81b284c..5ffed2c 100644 --- a/packages/merge/src/shared/github.ts +++ b/packages/merge/src/shared/github.ts @@ -13,92 +13,223 @@ // limitations under the License. import { Octokit } from '@octokit/rest'; -import { createAppAuth } from '@octokit/auth-app'; /** - * Create an authenticated Octokit instance. - * - * Auth priority (matches @google/jules-fleet): - * 1. GitHub App (GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY[_BASE64] + GITHUB_APP_INSTALLATION_ID) - * 2. PAT fallback (GITHUB_TOKEN or GH_TOKEN) + * GitHub operations layer — all functions accept Octokit as the first argument + * for dependency injection (no global singletons). */ -export function createOctokit(): Octokit { - const appId = process.env.GITHUB_APP_ID; - const privateKeyBase64 = process.env.GITHUB_APP_PRIVATE_KEY_BASE64; - const privateKeyRaw = process.env.GITHUB_APP_PRIVATE_KEY; - const installationId = process.env.GITHUB_APP_INSTALLATION_ID; - - if (appId && (privateKeyBase64 || privateKeyRaw) && installationId) { - const privateKey = privateKeyBase64 - ? Buffer.from(privateKeyBase64, 'base64').toString('utf-8') - : privateKeyRaw!; - return new Octokit({ - authStrategy: createAppAuth, - auth: { - appId, - privateKey, - installationId: Number(installationId), - }, - }); - } - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; - if (token) { - return new Octokit({ auth: token }); - } +export async function getBranch( + octokit: Octokit, + owner: string, + repo: string, + branch: string, +) { + const { data } = await octokit.repos.getBranch({ owner, repo, branch }); + return data; +} - throw new Error( - 'GitHub auth not configured. Set GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY + GITHUB_APP_INSTALLATION_ID for App auth, or GITHUB_TOKEN for PAT auth.', - ); +export async function getPullRequest( + octokit: Octokit, + owner: string, + repo: string, + pull_number: number, +) { + const { data } = await octokit.pulls.get({ owner, repo, pull_number }); + return data; } -/** - * Compare two commits and return the list of changed file paths. - */ export async function compareCommits( octokit: Octokit, owner: string, repo: string, base: string, head: string, -): Promise { +) { const { data } = await octokit.repos.compareCommits({ owner, repo, base, head, }); - return (data.files ?? []).map((f) => f.filename); + return data; } -/** - * Get the content of a file from a specific ref. - * Returns empty string if file is not found (404). - */ -export async function getFileContent( +export async function getContents( octokit: Octokit, owner: string, repo: string, path: string, ref: string, -): Promise { - try { - const { data } = await octokit.repos.getContent({ - owner, - repo, - path, - ref, - }); - - if ('content' in data && typeof data.content === 'string') { - return Buffer.from(data.content, 'base64').toString('utf-8'); - } - - return ''; - } catch (error: any) { - if (error?.status === 404) { - return ''; - } - throw error; +) { + const { data } = await octokit.repos.getContent({ owner, repo, path, ref }); + if (Array.isArray(data) || data.type !== 'file') { + throw new Error(`Expected file at ${path}`); } + return { + content: Buffer.from(data.content, 'base64').toString('utf8'), + sha: data.sha, + }; +} + +export async function getTree( + octokit: Octokit, + owner: string, + repo: string, + tree_sha: string, +) { + const { data } = await octokit.git.getTree({ owner, repo, tree_sha }); + return data; +} + +export async function createBlob( + octokit: Octokit, + owner: string, + repo: string, + content: string, + encoding: 'utf-8' | 'base64' = 'utf-8', +) { + const { data } = await octokit.git.createBlob({ + owner, + repo, + content, + encoding, + }); + return data; +} + +export async function createTree( + octokit: Octokit, + owner: string, + repo: string, + base_tree: string, + tree: any[], +) { + const { data } = await octokit.git.createTree({ + owner, + repo, + base_tree, + tree, + }); + return data; +} + +export async function createCommit( + octokit: Octokit, + owner: string, + repo: string, + message: string, + tree: string, + parents: string[], +) { + const { data } = await octokit.git.createCommit({ + owner, + repo, + message, + tree, + parents, + }); + return data; +} + +export async function createRef( + octokit: Octokit, + owner: string, + repo: string, + ref: string, + sha: string, +) { + const { data } = await octokit.git.createRef({ owner, repo, ref, sha }); + return data; +} + +export async function updateRef( + octokit: Octokit, + owner: string, + repo: string, + ref: string, + sha: string, + force: boolean = false, +) { + const { data } = await octokit.git.updateRef({ + owner, + repo, + ref: ref.replace(/^refs\//, ''), + sha, + force, + }); + return data; +} + +export async function createPullRequest( + octokit: Octokit, + owner: string, + repo: string, + title: string, + head: string, + base: string, + body?: string, +) { + const { data } = await octokit.pulls.create({ + owner, + repo, + title, + head, + base, + body, + }); + return data; +} + +export async function listPullRequests( + octokit: Octokit, + owner: string, + repo: string, + head: string, + base: string, + state: 'open' | 'closed' | 'all' = 'open', +) { + const { data } = await octokit.pulls.list({ + owner, + repo, + head: `${owner}:${head}`, + base, + state, + per_page: 1, + }); + return data; +} + +export async function getRepo( + octokit: Octokit, + owner: string, + repo: string, +) { + const { data } = await octokit.repos.get({ owner, repo }); + return data; +} + +export async function mergePullRequest( + octokit: Octokit, + owner: string, + repo: string, + pull_number: number, + merge_method: 'merge' | 'squash' | 'rebase' = 'merge', +) { + const { data } = await octokit.pulls.merge({ + owner, + repo, + pull_number, + merge_method, + }); + return data; +} + +export async function deleteBranch( + octokit: Octokit, + owner: string, + repo: string, + branch: string, +) { + await octokit.git.deleteRef({ owner, repo, ref: `heads/${branch}` }); } diff --git a/packages/merge/src/shared/resolve-key.ts b/packages/merge/src/shared/resolve-key.ts new file mode 100644 index 0000000..8f29d8a --- /dev/null +++ b/packages/merge/src/shared/resolve-key.ts @@ -0,0 +1,43 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Resolve a private key from environment variable input. + * + * Accepts two formats: + * 1. Raw PEM (with real newlines or literal \n strings) + * 2. Base64-encoded PEM (`base64 < key.pem | tr -d '\n'`) + * + * Auto-detects the format: if the value starts with `-----BEGIN`, + * it's treated as raw PEM regardless of the env var name. + */ +export function resolvePrivateKey( + base64Value: string | undefined, + rawValue: string | undefined, +): string { + const value = base64Value || rawValue; + if (!value) { + throw new Error( + 'No private key provided. Set FLEET_APP_PRIVATE_KEY_BASE64 (recommended).', + ); + } + + // Auto-detect: if it looks like PEM, use it directly + if (value.trimStart().startsWith('-----BEGIN')) { + return value.replace(/\\n/g, '\n'); + } + + // Otherwise, treat as base64 + return Buffer.from(value, 'base64').toString('utf-8'); +} diff --git a/packages/merge/src/shared/validators.ts b/packages/merge/src/shared/validators.ts new file mode 100644 index 0000000..5405bf2 --- /dev/null +++ b/packages/merge/src/shared/validators.ts @@ -0,0 +1,62 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export function validateFilePath(filePath: string): void { + if (filePath.includes("\x00") || /[\x01-\x1f\x7f]/.test(filePath)) { + throw new Error( + `CONTROL_CHAR: File path contains control characters: ${filePath}`, + ); + } + const normalized = filePath.replace(/\\/g, "/"); + const parts = normalized.split("/"); + if (parts.some((p) => p === "..")) { + throw new Error( + `PATH_TRAVERSAL: File path escapes repo root: ${filePath}`, + ); + } +} + +export function validateBranchName(branch: string): void { + if (branch.startsWith("refs/")) { + throw new Error( + `RESERVED_BRANCH: Branch name must not start with refs/: ${branch}`, + ); + } + // git ref rules: no spaces, no control chars, no consecutive dots, no trailing dot/slash/lock + if (/\s/.test(branch)) { + throw new Error( + `INVALID_BRANCH: Branch name contains spaces: ${branch}`, + ); + } + if (/[\x00-\x1f\x7f~^:?*\[\\]/.test(branch)) { + throw new Error( + `INVALID_BRANCH: Branch name contains invalid characters: ${branch}`, + ); + } + if (/\.\./.test(branch)) { + throw new Error( + `INVALID_BRANCH: Branch name contains consecutive dots: ${branch}`, + ); + } + if (/\.$/.test(branch) || /\/$/.test(branch)) { + throw new Error( + `INVALID_BRANCH: Branch name ends with dot or slash: ${branch}`, + ); + } + if (/\.lock$/.test(branch)) { + throw new Error( + `INVALID_BRANCH: Branch name ends with .lock: ${branch}`, + ); + } +}