From 6010a8b856f7e00b394b42e211bffb50598cf7a2 Mon Sep 17 00:00:00 2001 From: David East Date: Wed, 18 Mar 2026 08:32:15 -0400 Subject: [PATCH] feat(merge): add --all flag for scanning open PRs with safety guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --all, --maxPrs, --labels flags to scan command - Hard cap of 25 PRs (override via --max-prs), mutual exclusion with --prs - discoveredPrs/scannedPrs metadata in output for agent introspection - Decompose github.ts and fixtures into per-function files with barrel re-exports - Decompose scan-handler into ops (scan-types, scan-discover, scan-classify) - Eliminate any types: rawInput→unknown, typed ScanOutput, resolveFileContent() - 18 new tests (44 total, zero regressions) --- .../src/__tests__/fixtures/github-git-blob.ts | 31 ++ .../__tests__/fixtures/github-git-commit.ts | 33 ++ .../src/__tests__/fixtures/github-git-ref.ts | 52 ++++ .../src/__tests__/fixtures/github-git-tree.ts | 36 +++ .../src/__tests__/fixtures/github-git.ts | 25 ++ .../__tests__/fixtures/github-pulls-create.ts | 38 +++ .../__tests__/fixtures/github-pulls-get.ts | 28 ++ .../__tests__/fixtures/github-pulls-list.ts | 47 +++ .../__tests__/fixtures/github-pulls-merge.ts | 27 ++ .../src/__tests__/fixtures/github-pulls.ts | 25 ++ .../__tests__/fixtures/github-repos-branch.ts | 28 ++ .../fixtures/github-repos-compare.ts | 30 ++ .../fixtures/github-repos-contents.ts | 35 +++ .../__tests__/fixtures/github-repos-get.ts | 23 ++ .../src/__tests__/fixtures/github-repos.ts | 22 ++ .../src/__tests__/fixtures/github-state.ts | 100 ++++++ .../merge/src/__tests__/fixtures/github.ts | 290 ++---------------- .../reconcile/scan-discovery.test.ts | 224 ++++++++++++++ .../reconcile/scan-input-validation.test.ts | 115 +++++++ packages/merge/src/cli/scan.command.ts | 20 +- packages/merge/src/reconcile/scan-classify.ts | 57 ++++ packages/merge/src/reconcile/scan-discover.ts | 59 ++++ packages/merge/src/reconcile/scan-handler.ts | 90 +++--- packages/merge/src/reconcile/scan-types.ts | 35 +++ packages/merge/src/reconcile/schemas.ts | 26 +- .../src/reconcile/stage-resolution-handler.ts | 22 +- packages/merge/src/shared/github-git-blob.ts | 35 +++ .../merge/src/shared/github-git-commit.ts | 37 +++ packages/merge/src/shared/github-git-ref.ts | 57 ++++ packages/merge/src/shared/github-git-tree.ts | 45 +++ packages/merge/src/shared/github-git.ts | 22 ++ .../merge/src/shared/github-pulls-create.ts | 39 +++ packages/merge/src/shared/github-pulls-get.ts | 29 ++ .../merge/src/shared/github-pulls-list.ts | 85 +++++ .../merge/src/shared/github-pulls-merge.ts | 35 +++ packages/merge/src/shared/github-pulls.ts | 25 ++ .../merge/src/shared/github-repos-branch.ts | 29 ++ .../merge/src/shared/github-repos-compare.ts | 35 +++ .../merge/src/shared/github-repos-contents.ts | 36 +++ packages/merge/src/shared/github-repos-get.ts | 28 ++ packages/merge/src/shared/github-repos.ts | 22 ++ packages/merge/src/shared/github.ts | 248 ++------------- 42 files changed, 1784 insertions(+), 541 deletions(-) create mode 100644 packages/merge/src/__tests__/fixtures/github-git-blob.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-git-commit.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-git-ref.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-git-tree.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-git.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-pulls-create.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-pulls-get.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-pulls-list.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-pulls-merge.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-pulls.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-repos-branch.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-repos-compare.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-repos-contents.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-repos-get.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-repos.ts create mode 100644 packages/merge/src/__tests__/fixtures/github-state.ts create mode 100644 packages/merge/src/__tests__/reconcile/scan-discovery.test.ts create mode 100644 packages/merge/src/__tests__/reconcile/scan-input-validation.test.ts create mode 100644 packages/merge/src/reconcile/scan-classify.ts create mode 100644 packages/merge/src/reconcile/scan-discover.ts create mode 100644 packages/merge/src/reconcile/scan-types.ts create mode 100644 packages/merge/src/shared/github-git-blob.ts create mode 100644 packages/merge/src/shared/github-git-commit.ts create mode 100644 packages/merge/src/shared/github-git-ref.ts create mode 100644 packages/merge/src/shared/github-git-tree.ts create mode 100644 packages/merge/src/shared/github-git.ts create mode 100644 packages/merge/src/shared/github-pulls-create.ts create mode 100644 packages/merge/src/shared/github-pulls-get.ts create mode 100644 packages/merge/src/shared/github-pulls-list.ts create mode 100644 packages/merge/src/shared/github-pulls-merge.ts create mode 100644 packages/merge/src/shared/github-pulls.ts create mode 100644 packages/merge/src/shared/github-repos-branch.ts create mode 100644 packages/merge/src/shared/github-repos-compare.ts create mode 100644 packages/merge/src/shared/github-repos-contents.ts create mode 100644 packages/merge/src/shared/github-repos-get.ts create mode 100644 packages/merge/src/shared/github-repos.ts diff --git a/packages/merge/src/__tests__/fixtures/github-git-blob.ts b/packages/merge/src/__tests__/fixtures/github-git-blob.ts new file mode 100644 index 0000000..925f721 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-git-blob.ts @@ -0,0 +1,31 @@ +// 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 Git blob operations. + */ + +import crypto from 'crypto'; + +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'), + }; +} diff --git a/packages/merge/src/__tests__/fixtures/github-git-commit.ts b/packages/merge/src/__tests__/fixtures/github-git-commit.ts new file mode 100644 index 0000000..c51264a --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-git-commit.ts @@ -0,0 +1,33 @@ +// 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 Git commit operations. + */ + +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 }; +} diff --git a/packages/merge/src/__tests__/fixtures/github-git-ref.ts b/packages/merge/src/__tests__/fixtures/github-git-ref.ts new file mode 100644 index 0000000..19a710b --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-git-ref.ts @@ -0,0 +1,52 @@ +// 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 Git ref operations. + */ + +import { state } from './github-state.js'; + +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 deleteBranch( + _octokit: any, + _owner: string, + _repo: string, + _branch: string, +) { + // no-op in tests +} diff --git a/packages/merge/src/__tests__/fixtures/github-git-tree.ts b/packages/merge/src/__tests__/fixtures/github-git-tree.ts new file mode 100644 index 0000000..6cc5fcd --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-git-tree.ts @@ -0,0 +1,36 @@ +// 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 Git tree operations. + */ + +export async function getTree( + _octokit: any, + _owner: string, + _repo: string, + _tree_sha: string, +) { + return {}; +} + +export async function createTree( + _octokit: any, + _owner: string, + _repo: string, + _base_tree: string, + _tree: any[], +) { + return { sha: 'new-tree-sha' }; +} diff --git a/packages/merge/src/__tests__/fixtures/github-git.ts b/packages/merge/src/__tests__/fixtures/github-git.ts new file mode 100644 index 0000000..abd34a5 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-git.ts @@ -0,0 +1,25 @@ +// 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 low-level Git object operations. + */ + +export { getTree, createTree } from './github-git-tree.js'; +export { createBlob } from './github-git-blob.js'; +export { + createCommit, + resetCommitCounter, +} from './github-git-commit.js'; +export { createRef, updateRef, deleteBranch } from './github-git-ref.js'; diff --git a/packages/merge/src/__tests__/fixtures/github-pulls-create.ts b/packages/merge/src/__tests__/fixtures/github-pulls-create.ts new file mode 100644 index 0000000..e39fd10 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-pulls-create.ts @@ -0,0 +1,38 @@ +// 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 pull request create operation. + */ + +import { state } from './github-state.js'; + +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; +} diff --git a/packages/merge/src/__tests__/fixtures/github-pulls-get.ts b/packages/merge/src/__tests__/fixtures/github-pulls-get.ts new file mode 100644 index 0000000..e90da25 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-pulls-get.ts @@ -0,0 +1,28 @@ +// 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 pull request get operation. + */ + +import { state } from './github-state.js'; + +export async function getPullRequest( + _octokit: any, + _owner: string, + _repo: string, + pull_number: number, +) { + return state.prs[pull_number]; +} diff --git a/packages/merge/src/__tests__/fixtures/github-pulls-list.ts b/packages/merge/src/__tests__/fixtures/github-pulls-list.ts new file mode 100644 index 0000000..dc0c005 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-pulls-list.ts @@ -0,0 +1,47 @@ +// 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 pull request list operations. + */ + +import { state } from './github-state.js'; + +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 listOpenPullRequests( + _octokit: any, + _owner: string, + _repo: string, + base: string, + options?: { labels?: string[] }, +) { + let prs = state.openPrs?.[base] ?? []; + if (options?.labels && options.labels.length > 0) { + prs = prs.filter((pr: any) => + pr.labels?.some((l: string) => options.labels!.includes(l)), + ); + } + return prs; +} diff --git a/packages/merge/src/__tests__/fixtures/github-pulls-merge.ts b/packages/merge/src/__tests__/fixtures/github-pulls-merge.ts new file mode 100644 index 0000000..ea58bc2 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-pulls-merge.ts @@ -0,0 +1,27 @@ +// 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 pull request merge operation. + */ + +export async function mergePullRequest( + _octokit: any, + _owner: string, + _repo: string, + _pull_number: number, + _merge_method: string = 'merge', +) { + return { sha: 'merge-sha' }; +} diff --git a/packages/merge/src/__tests__/fixtures/github-pulls.ts b/packages/merge/src/__tests__/fixtures/github-pulls.ts new file mode 100644 index 0000000..b62dcfa --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-pulls.ts @@ -0,0 +1,25 @@ +// 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 pull request operations. + */ + +export { getPullRequest } from './github-pulls-get.js'; +export { createPullRequest } from './github-pulls-create.js'; +export { + listPullRequests, + listOpenPullRequests, +} from './github-pulls-list.js'; +export { mergePullRequest } from './github-pulls-merge.js'; diff --git a/packages/merge/src/__tests__/fixtures/github-repos-branch.ts b/packages/merge/src/__tests__/fixtures/github-repos-branch.ts new file mode 100644 index 0000000..7fa01b0 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-repos-branch.ts @@ -0,0 +1,28 @@ +// 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 repository branch operations. + */ + +import { state } from './github-state.js'; + +export async function getBranch( + _octokit: any, + _owner: string, + _repo: string, + branch: string, +) { + return state.branches[branch] || state.branches.main; +} diff --git a/packages/merge/src/__tests__/fixtures/github-repos-compare.ts b/packages/merge/src/__tests__/fixtures/github-repos-compare.ts new file mode 100644 index 0000000..3c2ed63 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-repos-compare.ts @@ -0,0 +1,30 @@ +// 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 repository commit comparison operations. + */ + +import { state } from './github-state.js'; + +export async function compareCommits( + _octokit: any, + _owner: string, + _repo: string, + base: string, + head: string, +) { + const key = `${base}...${head}`; + return state.compares[key] || { files: [] }; +} diff --git a/packages/merge/src/__tests__/fixtures/github-repos-contents.ts b/packages/merge/src/__tests__/fixtures/github-repos-contents.ts new file mode 100644 index 0000000..f864aca --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-repos-contents.ts @@ -0,0 +1,35 @@ +// 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 repository contents operations. + */ + +import crypto from 'crypto'; +import { state } from './github-state.js'; + +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'), + }; +} diff --git a/packages/merge/src/__tests__/fixtures/github-repos-get.ts b/packages/merge/src/__tests__/fixtures/github-repos-get.ts new file mode 100644 index 0000000..921b963 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-repos-get.ts @@ -0,0 +1,23 @@ +// 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 repository get operation. + */ + +import { state } from './github-state.js'; + +export async function getRepo(_octokit: any, _owner: string, _repo: string) { + return state.repo; +} diff --git a/packages/merge/src/__tests__/fixtures/github-repos.ts b/packages/merge/src/__tests__/fixtures/github-repos.ts new file mode 100644 index 0000000..b60cba0 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-repos.ts @@ -0,0 +1,22 @@ +// 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 repository-level query operations. + */ + +export { getBranch } from './github-repos-branch.js'; +export { compareCommits } from './github-repos-compare.js'; +export { getContents } from './github-repos-contents.js'; +export { getRepo } from './github-repos-get.js'; diff --git a/packages/merge/src/__tests__/fixtures/github-state.ts b/packages/merge/src/__tests__/fixtures/github-state.ts new file mode 100644 index 0000000..90d4910 --- /dev/null +++ b/packages/merge/src/__tests__/fixtures/github-state.ts @@ -0,0 +1,100 @@ +// 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. + +/** + * Shared in-memory state for mock GitHub fixtures. + * All domain-specific mock files import from here. + */ + +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, +}; diff --git a/packages/merge/src/__tests__/fixtures/github.ts b/packages/merge/src/__tests__/fixtures/github.ts index 5b3ac89..1a46bcb 100644 --- a/packages/merge/src/__tests__/fixtures/github.ts +++ b/packages/merge/src/__tests__/fixtures/github.ts @@ -13,261 +13,39 @@ // limitations under the License. /** - * Mock GitHub API fixture — DI-compatible (Octokit param ignored in mocks). - * Provides in-memory simulations of all GitHub operations. + * Mock GitHub API fixture — barrel re-export. + * + * Implementation is split by domain to mirror shared/github.ts: + * - github-state.ts → shared in-memory state + * - github-pulls.ts → PR operation mocks + * - github-git.ts → Git object operation mocks + * - github-repos.ts → repository query mocks */ -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 -} +export { state } from './github-state.js'; + +export { + getPullRequest, + createPullRequest, + listPullRequests, + listOpenPullRequests, + mergePullRequest, +} from './github-pulls.js'; + +export { + getTree, + createBlob, + createTree, + createCommit, + resetCommitCounter, + createRef, + updateRef, + deleteBranch, +} from './github-git.js'; + +export { + getBranch, + compareCommits, + getContents, + getRepo, +} from './github-repos.js'; diff --git a/packages/merge/src/__tests__/reconcile/scan-discovery.test.ts b/packages/merge/src/__tests__/reconcile/scan-discovery.test.ts new file mode 100644 index 0000000..6c7075d --- /dev/null +++ b/packages/merge/src/__tests__/reconcile/scan-discovery.test.ts @@ -0,0 +1,224 @@ +// 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, beforeEach, afterEach, vi } 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-discovery'); +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 }); + } +} + +// Helper to generate N PRs with fixture-compatible compare data +function setupOpenPrs( + count: number, + options?: { + overlappingFile?: string; + labels?: string[]; + unlabeledCount?: number; + }, +) { + const prs: any[] = []; + for (let i = 0; i < count; i++) { + const prNum = 200 + i; + const sha = `discover-pr${prNum}-sha`; + const branch = `discover-branch-${prNum}`; + + // Add to state.prs so getPullRequest works + state.prs[prNum] = { head: { sha, ref: branch } }; + + // Add compare data + const filename = + options?.overlappingFile && i < 2 + ? options.overlappingFile + : `src/discovered-${prNum}.ts`; + state.compares[`main-sha-v1...${sha}`] = { + files: [{ filename, status: 'modified' }], + }; + + // Add labels if specified + const hasLabels = + options?.unlabeledCount !== undefined + ? i >= options.unlabeledCount + : true; + prs.push({ + number: prNum, + head: { sha, ref: branch }, + labels: hasLabels && options?.labels ? [...options.labels] : [], + }); + } + + state.openPrs = { main: prs }; +} + +describe('scan --all discovery', () => { + beforeEach(() => { + cleanManifest(); + resetCommitCounter(); + state.refs = {}; + state.pullRequests = undefined; + state.openPrs = undefined; + state.repo = { allow_squash_merge: true, allow_merge_commit: true }; + }); + + afterEach(() => { + cleanManifest(); + }); + + // ─── Group B: Safety Guardrails ─────────────────────────────── + + it('B1: --all rejects when discovered PRs exceed default cap', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + setupOpenPrs(26); + + await expect( + scanHandler({} as any, { + all: true, + repo: 'owner/repo', + base: 'main', + }), + ).rejects.toThrow(/26.*max.*25|--max-prs/i); + }); + + it('B2: --all with maxPrs overrides the default cap', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + setupOpenPrs(30); + + const result = await scanHandler({} as any, { + all: true, + repo: 'owner/repo', + base: 'main', + maxPrs: 50, + }); + expect(result.prs).toHaveLength(30); + }); + + it('B3: --all rejects when discovered PRs exceed custom maxPrs', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + setupOpenPrs(60); + + await expect( + scanHandler({} as any, { + all: true, + repo: 'owner/repo', + base: 'main', + maxPrs: 50, + }), + ).rejects.toThrow(/60.*max.*50|--max-prs/i); + }); + + it('B4: --all with labels filters PRs before cap check', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + // 30 total PRs, but 25 unlabeled + 5 labeled as 'jules-bot' + setupOpenPrs(30, { + labels: ['jules-bot'], + unlabeledCount: 25, + }); + + const result = await scanHandler({} as any, { + all: true, + repo: 'owner/repo', + base: 'main', + labels: ['jules-bot'], + }); + // Should succeed (5 < default cap of 25) and only scan labeled PRs + expect(result.prs).toHaveLength(5); + }); + + // ─── Group C: Discovery Happy Paths ─────────────────────────── + + it('C1: --all discovers open PRs and produces clean result', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + setupOpenPrs(3); + + const result = await scanHandler({} as any, { + all: true, + repo: 'owner/repo', + base: 'main', + }); + expect(result.status).toBe('clean'); + expect(result.cleanFiles).toHaveLength(3); + expect(result.hotZones).toHaveLength(0); + }); + + it('C2: --all detects conflicts among discovered PRs', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + setupOpenPrs(2, { overlappingFile: 'src/shared.ts' }); + + const result = await scanHandler({} as any, { + all: true, + repo: 'owner/repo', + base: 'main', + }); + expect(result.status).toBe('conflicts'); + expect(result.hotZones).toHaveLength(1); + expect(result.hotZones[0].filePath).toBe('src/shared.ts'); + expect(result.hotZones[0].competingPrs).toHaveLength(2); + }); + + it('C3: --all with zero open PRs produces clean result', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + state.openPrs = { main: [] }; + + const result = await scanHandler({} as any, { + all: true, + repo: 'owner/repo', + base: 'main', + }); + expect(result.status).toBe('clean'); + expect(result.prs).toHaveLength(0); + expect(result.cleanFiles).toHaveLength(0); + }); + + // ─── Group D: Output Contract ───────────────────────────────── + + it('D1: --all output includes discoveredPrs and scannedPrs', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + setupOpenPrs(3); + + const result = await scanHandler({} as any, { + all: true, + repo: 'owner/repo', + base: 'main', + }); + expect(result.discoveredPrs).toBe(3); + expect(result.scannedPrs).toBe(3); + }); + + it('D2: explicit prs output omits discoveredPrs', async () => { + const { scanHandler } = await import('../../reconcile/scan-handler.js'); + + const result = await scanHandler({} as any, { + prs: [1, 2], + repo: 'owner/repo', + base: 'main', + }); + expect(result.discoveredPrs).toBeUndefined(); + expect(result.scannedPrs).toBeUndefined(); + }); +}); diff --git a/packages/merge/src/__tests__/reconcile/scan-input-validation.test.ts b/packages/merge/src/__tests__/reconcile/scan-input-validation.test.ts new file mode 100644 index 0000000..7553a04 --- /dev/null +++ b/packages/merge/src/__tests__/reconcile/scan-input-validation.test.ts @@ -0,0 +1,115 @@ +// 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 { ScanInputSchema } from '../../reconcile/schemas.js'; + +describe('ScanInputSchema validation', () => { + // ─── Group A: JSON Input Validation (Schema Layer) ────────── + + it('A1: rejects when neither prs nor all is provided', () => { + expect(() => + ScanInputSchema.parse({ repo: 'owner/repo', base: 'main' }), + ).toThrow(); + }); + + it('A2: rejects when both prs and all are provided', () => { + expect(() => + ScanInputSchema.parse({ + prs: [1, 2], + all: true, + repo: 'owner/repo', + base: 'main', + }), + ).toThrow(); + }); + + it('A3: rejects prs as empty array without all', () => { + expect(() => + ScanInputSchema.parse({ prs: [], repo: 'owner/repo', base: 'main' }), + ).toThrow(); + }); + + it('A4: accepts prs without all (existing behavior)', () => { + const result = ScanInputSchema.parse({ + prs: [1, 2], + repo: 'owner/repo', + base: 'main', + }); + expect(result.prs).toEqual([1, 2]); + expect(result.all).toBeUndefined(); + }); + + it('A5: accepts all without prs', () => { + const result = ScanInputSchema.parse({ + all: true, + repo: 'owner/repo', + base: 'main', + }); + expect(result.all).toBe(true); + expect(result.prs).toBeUndefined(); + }); + + it('A6: accepts all with optional maxPrs and labels', () => { + const result = ScanInputSchema.parse({ + all: true, + repo: 'owner/repo', + base: 'main', + maxPrs: 50, + labels: ['jules-bot'], + }); + expect(result.maxPrs).toBe(50); + expect(result.labels).toEqual(['jules-bot']); + }); + + it('A7: rejects maxPrs as zero or negative', () => { + expect(() => + ScanInputSchema.parse({ + all: true, + repo: 'owner/repo', + base: 'main', + maxPrs: 0, + }), + ).toThrow(); + + expect(() => + ScanInputSchema.parse({ + all: true, + repo: 'owner/repo', + base: 'main', + maxPrs: -5, + }), + ).toThrow(); + }); + + it('A8: rejects all:false without prs', () => { + expect(() => + ScanInputSchema.parse({ + all: false, + repo: 'owner/repo', + base: 'main', + }), + ).toThrow(); + }); + + it('A9: rejects prs with non-number elements', () => { + expect(() => + ScanInputSchema.parse({ + prs: ['abc'], + repo: 'owner/repo', + base: 'main', + }), + ).toThrow(); + }); +}); diff --git a/packages/merge/src/cli/scan.command.ts b/packages/merge/src/cli/scan.command.ts index d462a33..cbd64b6 100644 --- a/packages/merge/src/cli/scan.command.ts +++ b/packages/merge/src/cli/scan.command.ts @@ -25,7 +25,7 @@ export default defineCommand({ args: { json: { type: 'string', - description: 'Raw JSON payload: { "prs": [1,2], "repo": "owner/repo", "base": "main" }', + description: 'Raw JSON payload: { "prs": [1,2], "repo": "owner/repo", "base": "main" } or { "all": true, "repo": "owner/repo", "base": "main" }', }, prs: { type: 'string', @@ -40,13 +40,29 @@ export default defineCommand({ description: 'Base branch name (default: main)', default: 'main', }, + all: { + type: 'boolean', + description: 'Scan all open PRs targeting --base (requires --repo and --base)', + default: false, + }, + maxPrs: { + type: 'string', + description: 'Max PRs to scan when using --all (default: 25)', + }, + labels: { + type: 'string', + description: 'Comma-separated labels to filter PRs (only with --all)', + }, }, async run({ args }) { try { const octokit = createMergeOctokit(); const input = parseJsonInput(args.json) || { - prs: args.prs?.split(',').map(Number) || [], + prs: args.prs?.split(',').map(Number), + all: args.all || undefined, + maxPrs: args.maxPrs ? Number(args.maxPrs) : undefined, + labels: args.labels?.split(','), repo: args.repo || '', base: args.base, }; diff --git a/packages/merge/src/reconcile/scan-classify.ts b/packages/merge/src/reconcile/scan-classify.ts new file mode 100644 index 0000000..d3815e1 --- /dev/null +++ b/packages/merge/src/reconcile/scan-classify.ts @@ -0,0 +1,57 @@ +// 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. + +/** + * File classification ops — partitions a file→PRs map into + * hot zones (conflicts) and clean files (single-owner). + */ + +import type { HotZone, CleanFile } from './scan-types.js'; + +type ChangeType = 'modified' | 'added' | 'deleted'; + +function toChangeType(status: string): ChangeType { + if (status === 'removed') return 'deleted'; + if (status === 'added') return 'added'; + return 'modified'; +} + +export interface ClassificationResult { + hotZones: HotZone[]; + cleanFiles: CleanFile[]; +} + +export function classifyFiles( + fileToPrs: Map, +): ClassificationResult { + const hotZones: HotZone[] = []; + const cleanFiles: CleanFile[] = []; + + 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], + }); + } + } + + return { hotZones, cleanFiles }; +} diff --git a/packages/merge/src/reconcile/scan-discover.ts b/packages/merge/src/reconcile/scan-discover.ts new file mode 100644 index 0000000..1689e37 --- /dev/null +++ b/packages/merge/src/reconcile/scan-discover.ts @@ -0,0 +1,59 @@ +// 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. + +/** + * PR discovery ops — resolves the set of PR IDs to scan, + * either from explicit `--prs` or `--all` auto-discovery. + */ + +import { listOpenPullRequests } from '../shared/github.js'; +import { HardError } from '../shared/errors.js'; +import type { ScanContext } from './scan-types.js'; + +const DEFAULT_MAX_PRS = 25; + +export interface DiscoveryResult { + prIds: number[]; + /** Total open PRs found before cap check. Present only for --all mode. */ + discoveryCount?: number; +} + +export async function discoverPrs(ctx: ScanContext): Promise { + const { input } = ctx; + + if (!input.all) { + return { prIds: input.prs ?? [] }; + } + + const discovered = await listOpenPullRequests( + ctx.octokit, + ctx.owner, + ctx.repo, + ctx.baseBranchName, + input.labels ? { labels: input.labels } : undefined, + ); + + const maxAllowed = input.maxPrs ?? DEFAULT_MAX_PRS; + if (discovered.length > maxAllowed) { + throw new HardError( + `Discovered ${discovered.length} open PRs targeting '${ctx.baseBranchName}', ` + + `which exceeds the max of ${maxAllowed}. Use --max-prs to increase the limit.`, + ); + } + + return { + prIds: discovered.map((pr) => pr.number), + discoveryCount: discovered.length, + }; +} diff --git a/packages/merge/src/reconcile/scan-handler.ts b/packages/merge/src/reconcile/scan-handler.ts index fc4d4c5..1523224 100644 --- a/packages/merge/src/reconcile/scan-handler.ts +++ b/packages/merge/src/reconcile/scan-handler.ts @@ -12,6 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +/** + * Scan handler — thin orchestrator that delegates to ops: + * scan-discover.ts → PR discovery / explicit ID resolution + * scan-classify.ts → file → hot-zone / clean-file classification + * scan-types.ts → shared types (Zod-inferred) + */ + import { Octokit } from '@octokit/rest'; import { ScanInputSchema, ScanOutputSchema } from './schemas.js'; import { @@ -20,14 +27,11 @@ import { compareCommits, } from '../shared/github.js'; import { writeManifest, type Manifest } from './manifest.js'; +import type { ScanContext, ScanOutput } from './scan-types.js'; +import { discoverPrs } from './scan-discover.js'; +import { classifyFiles } from './scan-classify.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) { +export async function scanHandler(octokit: Octokit, rawInput: unknown) { const input = ScanInputSchema.parse(rawInput); const [owner, repo] = input.repo.split('/'); if (!owner || !repo) { @@ -39,27 +43,27 @@ export async function scanHandler(octokit: Octokit, rawInput: any) { const baseBranch = await getBranch(octokit, owner, repo, baseBranchName); const baseSha = baseBranch.commit.sha; + const ctx: ScanContext = { + octokit, + input, + owner, + repo, + baseBranchName, + baseSha, + }; + + // ─── Discover PRs ───────────────────────────────────────────── + const { prIds, discoveryCount } = await discoverPrs(ctx); + + // ─── Collect file changes per PR ────────────────────────────── const prsData: Manifest['prs'] = []; const fileToPrs = new Map(); - for (const prId of input.prs) { + for (const prId of prIds) { const pr = await getPullRequest(octokit, owner, repo, prId); - const headSha = pr.head.sha; - const branch = pr.head.ref; + prsData.push({ id: prId, headSha: pr.head.sha, branch: pr.head.ref }); - prsData.push({ - id: prId, - headSha, - branch, - }); - - const compare = await compareCommits( - octokit, - owner, - repo, - baseSha, - headSha, - ); + const compare = await compareCommits(octokit, owner, repo, baseSha, pr.head.sha); if (compare.files) { for (const file of compare.files) { if (!fileToPrs.has(file.filename)) { @@ -70,55 +74,39 @@ export async function scanHandler(octokit: Octokit, rawInput: any) { } } - 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()}`; + // ─── Classify files ─────────────────────────────────────────── + const { hotZones, cleanFiles } = classifyFiles(fileToPrs); + // ─── Persist manifest ───────────────────────────────────────── const manifest: Manifest = { - batchId, + batchId: `batch-${Date.now()}`, createdAt: new Date().toISOString(), repo: input.repo, - base: { - branch: baseBranchName, - sha: baseSha, - }, + base: { branch: baseBranchName, sha: baseSha }, prs: prsData, resolved: [], hotZones, pending: hotZones.map((hz) => hz.filePath), cleanFiles, }; - writeManifest(manifest); - const output = { + // ─── Build output ───────────────────────────────────────────── + const output: ScanOutput = { 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), + .map(([filePath]) => filePath), })), hotZones, cleanFiles, + ...(discoveryCount !== undefined && { + discoveredPrs: discoveryCount, + scannedPrs: prIds.length, + }), }; return ScanOutputSchema.parse(output); diff --git a/packages/merge/src/reconcile/scan-types.ts b/packages/merge/src/reconcile/scan-types.ts new file mode 100644 index 0000000..4c2e906 --- /dev/null +++ b/packages/merge/src/reconcile/scan-types.ts @@ -0,0 +1,35 @@ +// 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. + +/** + * Shared types for the scan pipeline, derived from Zod schemas. + */ + +import { Octokit } from '@octokit/rest'; +import { z } from 'zod'; +import { ScanInputSchema, ScanOutputSchema } from './schemas.js'; + +export type ScanInput = z.infer; +export type ScanOutput = z.infer; +export type HotZone = ScanOutput['hotZones'][number]; +export type CleanFile = ScanOutput['cleanFiles'][number]; + +export interface ScanContext { + octokit: Octokit; + input: ScanInput; + owner: string; + repo: string; + baseBranchName: string; + baseSha: string; +} diff --git a/packages/merge/src/reconcile/schemas.ts b/packages/merge/src/reconcile/schemas.ts index 31240f0..e572aa3 100644 --- a/packages/merge/src/reconcile/schemas.ts +++ b/packages/merge/src/reconcile/schemas.ts @@ -16,15 +16,29 @@ 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 ScanInputSchema = z + .object({ + prs: z.array(z.number()).optional(), + all: z.boolean().optional(), + maxPrs: z.number().int().positive().optional(), + labels: z.array(z.string()).optional(), + repo: z.string(), + base: z.string().optional(), + includeClean: z.boolean().optional(), + }) + .refine( + (data) => (data.prs && data.prs.length > 0) || data.all === true, + { message: 'Either prs or --all must be provided' }, + ) + .refine( + (data) => !(data.prs && data.prs.length > 0 && data.all === true), + { message: 'Cannot use both --prs and --all' }, + ); export const ScanOutputSchema = z.object({ status: z.enum(['conflicts', 'clean']), + discoveredPrs: z.number().optional(), + scannedPrs: z.number().optional(), base: z.object({ branch: z.string(), sha: z.string(), diff --git a/packages/merge/src/reconcile/stage-resolution-handler.ts b/packages/merge/src/reconcile/stage-resolution-handler.ts index ccbd014..af3cded 100644 --- a/packages/merge/src/reconcile/stage-resolution-handler.ts +++ b/packages/merge/src/reconcile/stage-resolution-handler.ts @@ -20,20 +20,26 @@ import { readManifest, writeManifest } from './manifest.js'; import { validateFilePath } from '../shared/validators.js'; import crypto from 'crypto'; import fs from 'fs'; +import { z } from 'zod'; -export async function stageResolutionHandler(rawInput: any) { - const input = StageResolutionInputSchema.parse(rawInput); - validateFilePath(input.filePath); +type StageInput = z.infer; - let fileContent = ''; - if (input.content !== undefined) { - fileContent = input.content; - } else if (input.fromFile) { +/** Resolve file content from inline content, a file path, or default to empty. */ +function resolveFileContent(input: StageInput): string { + if (input.content !== undefined) return input.content; + if (input.fromFile) { if (!fs.existsSync(input.fromFile)) { throw new Error(`File not found: ${input.fromFile}`); } - fileContent = fs.readFileSync(input.fromFile, 'utf-8'); + return fs.readFileSync(input.fromFile, 'utf-8'); } + return ''; +} + +export async function stageResolutionHandler(rawInput: unknown) { + const input = StageResolutionInputSchema.parse(rawInput); + validateFilePath(input.filePath); + const fileContent = resolveFileContent(input); const manifest = readManifest(); if (!manifest) { diff --git a/packages/merge/src/shared/github-git-blob.ts b/packages/merge/src/shared/github-git-blob.ts new file mode 100644 index 0000000..a90f5ab --- /dev/null +++ b/packages/merge/src/shared/github-git-blob.ts @@ -0,0 +1,35 @@ +// 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. + +/** + * Git blob operations. + */ + +import { Octokit } from '@octokit/rest'; + +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; +} diff --git a/packages/merge/src/shared/github-git-commit.ts b/packages/merge/src/shared/github-git-commit.ts new file mode 100644 index 0000000..542a645 --- /dev/null +++ b/packages/merge/src/shared/github-git-commit.ts @@ -0,0 +1,37 @@ +// 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. + +/** + * Git commit operations. + */ + +import { Octokit } from '@octokit/rest'; + +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; +} diff --git a/packages/merge/src/shared/github-git-ref.ts b/packages/merge/src/shared/github-git-ref.ts new file mode 100644 index 0000000..d92fd97 --- /dev/null +++ b/packages/merge/src/shared/github-git-ref.ts @@ -0,0 +1,57 @@ +// 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. + +/** + * Git ref operations — create, update, delete. + */ + +import { Octokit } from '@octokit/rest'; + +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 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/github-git-tree.ts b/packages/merge/src/shared/github-git-tree.ts new file mode 100644 index 0000000..b9f716b --- /dev/null +++ b/packages/merge/src/shared/github-git-tree.ts @@ -0,0 +1,45 @@ +// 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. + +/** + * Git tree operations — get and create trees. + */ + +import { Octokit } from '@octokit/rest'; + +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 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; +} diff --git a/packages/merge/src/shared/github-git.ts b/packages/merge/src/shared/github-git.ts new file mode 100644 index 0000000..7733b6c --- /dev/null +++ b/packages/merge/src/shared/github-git.ts @@ -0,0 +1,22 @@ +// 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. + +/** + * Low-level Git object operations — trees, blobs, commits, refs. + */ + +export { getTree, createTree } from './github-git-tree.js'; +export { createBlob } from './github-git-blob.js'; +export { createCommit } from './github-git-commit.js'; +export { createRef, updateRef, deleteBranch } from './github-git-ref.js'; diff --git a/packages/merge/src/shared/github-pulls-create.ts b/packages/merge/src/shared/github-pulls-create.ts new file mode 100644 index 0000000..bf85749 --- /dev/null +++ b/packages/merge/src/shared/github-pulls-create.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. + +/** + * Pull request create operation. + */ + +import { Octokit } from '@octokit/rest'; + +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; +} diff --git a/packages/merge/src/shared/github-pulls-get.ts b/packages/merge/src/shared/github-pulls-get.ts new file mode 100644 index 0000000..8f785c2 --- /dev/null +++ b/packages/merge/src/shared/github-pulls-get.ts @@ -0,0 +1,29 @@ +// 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. + +/** + * Pull request get operation. + */ + +import { Octokit } from '@octokit/rest'; + +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; +} diff --git a/packages/merge/src/shared/github-pulls-list.ts b/packages/merge/src/shared/github-pulls-list.ts new file mode 100644 index 0000000..1872a7d --- /dev/null +++ b/packages/merge/src/shared/github-pulls-list.ts @@ -0,0 +1,85 @@ +// 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. + +/** + * Pull request list operations. + */ + +import { Octokit } from '@octokit/rest'; + +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 listOpenPullRequests( + octokit: Octokit, + owner: string, + repo: string, + base: string, + options?: { labels?: string[] }, +): Promise<{ number: number; head: { sha: string; ref: string } }[]> { + const allPrs: { number: number; head: { sha: string; ref: string } }[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const { data } = await octokit.pulls.list({ + owner, + repo, + state: 'open', + base, + per_page: perPage, + page, + }); + + if (data.length === 0) break; + + for (const pr of data) { + // Apply label filtering if specified + if (options?.labels && options.labels.length > 0) { + const prLabels = pr.labels.map((l: any) => + typeof l === 'string' ? l : l.name, + ); + const hasMatchingLabel = options.labels.some((filterLabel) => + prLabels.includes(filterLabel), + ); + if (!hasMatchingLabel) continue; + } + allPrs.push({ + number: pr.number, + head: { sha: pr.head.sha, ref: pr.head.ref }, + }); + } + + if (data.length < perPage) break; + page++; + } + + return allPrs; +} diff --git a/packages/merge/src/shared/github-pulls-merge.ts b/packages/merge/src/shared/github-pulls-merge.ts new file mode 100644 index 0000000..2ca38ed --- /dev/null +++ b/packages/merge/src/shared/github-pulls-merge.ts @@ -0,0 +1,35 @@ +// 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. + +/** + * Pull request merge operation. + */ + +import { Octokit } from '@octokit/rest'; + +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; +} diff --git a/packages/merge/src/shared/github-pulls.ts b/packages/merge/src/shared/github-pulls.ts new file mode 100644 index 0000000..e92d879 --- /dev/null +++ b/packages/merge/src/shared/github-pulls.ts @@ -0,0 +1,25 @@ +// 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. + +/** + * Pull request operations — get, list, create, merge. + */ + +export { getPullRequest } from './github-pulls-get.js'; +export { createPullRequest } from './github-pulls-create.js'; +export { + listPullRequests, + listOpenPullRequests, +} from './github-pulls-list.js'; +export { mergePullRequest } from './github-pulls-merge.js'; diff --git a/packages/merge/src/shared/github-repos-branch.ts b/packages/merge/src/shared/github-repos-branch.ts new file mode 100644 index 0000000..682a135 --- /dev/null +++ b/packages/merge/src/shared/github-repos-branch.ts @@ -0,0 +1,29 @@ +// 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. + +/** + * Repository branch operations. + */ + +import { Octokit } from '@octokit/rest'; + +export async function getBranch( + octokit: Octokit, + owner: string, + repo: string, + branch: string, +) { + const { data } = await octokit.repos.getBranch({ owner, repo, branch }); + return data; +} diff --git a/packages/merge/src/shared/github-repos-compare.ts b/packages/merge/src/shared/github-repos-compare.ts new file mode 100644 index 0000000..17d790a --- /dev/null +++ b/packages/merge/src/shared/github-repos-compare.ts @@ -0,0 +1,35 @@ +// 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. + +/** + * Repository commit comparison operations. + */ + +import { Octokit } from '@octokit/rest'; + +export async function compareCommits( + octokit: Octokit, + owner: string, + repo: string, + base: string, + head: string, +) { + const { data } = await octokit.repos.compareCommits({ + owner, + repo, + base, + head, + }); + return data; +} diff --git a/packages/merge/src/shared/github-repos-contents.ts b/packages/merge/src/shared/github-repos-contents.ts new file mode 100644 index 0000000..cb3d9cf --- /dev/null +++ b/packages/merge/src/shared/github-repos-contents.ts @@ -0,0 +1,36 @@ +// 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. + +/** + * Repository contents operations. + */ + +import { Octokit } from '@octokit/rest'; + +export async function getContents( + octokit: Octokit, + owner: string, + repo: string, + path: string, + ref: string, +) { + 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, + }; +} diff --git a/packages/merge/src/shared/github-repos-get.ts b/packages/merge/src/shared/github-repos-get.ts new file mode 100644 index 0000000..522ac76 --- /dev/null +++ b/packages/merge/src/shared/github-repos-get.ts @@ -0,0 +1,28 @@ +// 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. + +/** + * Repository get operation. + */ + +import { Octokit } from '@octokit/rest'; + +export async function getRepo( + octokit: Octokit, + owner: string, + repo: string, +) { + const { data } = await octokit.repos.get({ owner, repo }); + return data; +} diff --git a/packages/merge/src/shared/github-repos.ts b/packages/merge/src/shared/github-repos.ts new file mode 100644 index 0000000..12d44f7 --- /dev/null +++ b/packages/merge/src/shared/github-repos.ts @@ -0,0 +1,22 @@ +// 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. + +/** + * Repository-level query operations — branches, contents, comparisons. + */ + +export { getBranch } from './github-repos-branch.js'; +export { compareCommits } from './github-repos-compare.js'; +export { getContents } from './github-repos-contents.js'; +export { getRepo } from './github-repos-get.js'; diff --git a/packages/merge/src/shared/github.ts b/packages/merge/src/shared/github.ts index 5ffed2c..e2f3157 100644 --- a/packages/merge/src/shared/github.ts +++ b/packages/merge/src/shared/github.ts @@ -12,224 +12,36 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Octokit } from '@octokit/rest'; - /** - * GitHub operations layer — all functions accept Octokit as the first argument - * for dependency injection (no global singletons). + * GitHub operations layer — barrel re-export. + * + * Implementation is split by domain to reduce merge conflicts: + * - github-pulls.ts → PR operations (get, list, create, merge) + * - github-git.ts → low-level Git objects (tree, blob, commit, ref) + * - github-repos.ts → repository queries (branch, contents, compare) */ -export async function getBranch( - octokit: Octokit, - owner: string, - repo: string, - branch: string, -) { - const { data } = await octokit.repos.getBranch({ owner, repo, branch }); - return data; -} - -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; -} - -export async function compareCommits( - octokit: Octokit, - owner: string, - repo: string, - base: string, - head: string, -) { - const { data } = await octokit.repos.compareCommits({ - owner, - repo, - base, - head, - }); - return data; -} - -export async function getContents( - octokit: Octokit, - owner: string, - repo: string, - path: string, - ref: string, -) { - 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}` }); -} +export { + getPullRequest, + createPullRequest, + listPullRequests, + listOpenPullRequests, + mergePullRequest, +} from './github-pulls.js'; + +export { + getTree, + createBlob, + createTree, + createCommit, + createRef, + updateRef, + deleteBranch, +} from './github-git.js'; + +export { + getBranch, + compareCommits, + getContents, + getRepo, +} from './github-repos.js';