diff --git a/README.md b/README.md index 0f5d644..fcd915e 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ artifact에는 다음 파일이 포함됩니다. | --- | --- | | `run.json` | repository, base branch, default branch, critical file patterns, Watcher workflow ref | | `branch-selection.json` | 수집된 branch, 감시 대상 branch, 제외된 branch와 사유 | +| `branch-pairs.json` | base branch와 감시 대상 branch 전체의 이름순 비교 조합 | | `deterministic-evidence.json` | git merge signal, changed files, changed hunks, check/PR metadata, deterministic risk 결과 | | `ai-target-selection.json` | AI 호출 대상 branch와 skipped branch 사유 | | `ai-prompt.json` | OpenAI에 전달한 system prompt, user prompt, response shape | diff --git a/src/branches/branchPairBuilder.ts b/src/branches/branchPairBuilder.ts new file mode 100644 index 0000000..5e0514f --- /dev/null +++ b/src/branches/branchPairBuilder.ts @@ -0,0 +1,46 @@ +import type { + BranchComparisonPair, + BranchContext +} from "./types.js" + +// base branch와 활성 branch에서 중복 없는 비교 조합을 이름순으로 구성 +export function build( + baseBranch: string, + branches: BranchContext[] +): BranchComparisonPair[] { + const branchNames = [...new Set([ + baseBranch, + ...branches.map(branch => branch.name) + ])].sort(compareBranchNames) + const pairs: BranchComparisonPair[] = [] + + for (let leftIndex = 0; leftIndex < branchNames.length; leftIndex += 1) { + const leftBranchName = branchNames[leftIndex]! + + for ( + let rightIndex = leftIndex + 1; + rightIndex < branchNames.length; + rightIndex += 1 + ) { + pairs.push({ + leftBranchName, + rightBranchName: branchNames[rightIndex]! + }) + } + } + + return pairs +} + +// 실행 환경에 무관한 문자열 비교로 branch 이름 순서를 결정 +function compareBranchNames(branchName: string, otherBranchName: string): number { + if (branchName < otherBranchName) { + return -1 + } + + if (otherBranchName < branchName) { + return 1 + } + + return 0 +} diff --git a/src/branches/types.ts b/src/branches/types.ts index 151f32c..cca6ccc 100644 --- a/src/branches/types.ts +++ b/src/branches/types.ts @@ -42,6 +42,11 @@ export type BranchSelectionResult = { excluded: ExcludedBranch[] } +export type BranchComparisonPair = { + leftBranchName: string + rightBranchName: string +} + export type BranchContext = { baseBranch: string name: string diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index 95992e7..395533c 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process" import { fileURLToPath } from "node:url" import { resolve } from "node:path" import { promisify } from "node:util" +import { build as buildBranchComparisonPairs } from "../branches/branchPairBuilder.js" import { selectWithReasons as selectBranchesWithReasons } from "../branches/branchSelector.js" import { collectGitMergeSignal } from "../git/gitMergeSignalCollector.js" import { analyze as analyzeBranchMergeRisks } from "../risks/riskAnalyzer.js" @@ -125,6 +126,7 @@ export async function run(options: MergeRiskWatchOptions): Promise { defaultBranch: options.defaultBranch }, generatedAt) const branches = branchSelection.selected + const pairs = buildBranchComparisonPairs(options.baseBranch, branches) const inputs: BranchRiskAnalysisInput[] = [] await debugArtifactWriter?.writeJson("branch-selection.json", { @@ -132,6 +134,9 @@ export async function run(options: MergeRiskWatchOptions): Promise { selectedBranches: branchSelection.selected, excludedBranches: branchSelection.excluded }) + await debugArtifactWriter?.writeJson("branch-pairs.json", { + pairs + }) for (const branch of branches) { const gitSignal = await collectGitMergeSignal(branch, { diff --git a/tests/branches/branchPairBuilder.test.ts b/tests/branches/branchPairBuilder.test.ts new file mode 100644 index 0000000..e3c9207 --- /dev/null +++ b/tests/branches/branchPairBuilder.test.ts @@ -0,0 +1,104 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { build } from "../../src/branches/branchPairBuilder.js" +import type { BranchContext } from "../../src/branches/types.js" + +// 활성 branch가 없으면 비교 조합을 만들지 않는지 확인 +test("returns no pairs without active branches", () => { + assert.deepEqual(build("main", []), []) +}) + +// base branch와 활성 branch 하나를 한 번만 조합하는지 확인 +test("pairs the base branch with one active branch", () => { + assert.deepEqual(build("develop", [branch("feature/watch", "develop")]), [{ + leftBranchName: "develop", + rightBranchName: "feature/watch" + }]) +}) + +// 중복 이름과 자기 자신 조합을 제외하고 방향 없는 조합만 만드는지 확인 +test("excludes duplicate, self, and reversed pairs", () => { + const pairs = build("main", [ + branch("feature/b"), + branch("feature/a"), + branch("feature/a"), + branch("main") + ]) + + assert.deepEqual(pairs, [{ + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, { + leftBranchName: "feature/a", + rightBranchName: "main" + }, { + leftBranchName: "feature/b", + rightBranchName: "main" + }]) +}) + +// 입력 순서와 무관하게 branch 이름 기준의 같은 조합 순서를 만드는지 확인 +test("builds pairs in deterministic branch name order", () => { + const pairs = build("main", [ + branch("feature/z"), + branch("feature/a"), + branch("feature/m") + ]) + const reorderedPairs = build("main", [ + branch("feature/m"), + branch("feature/z"), + branch("feature/a") + ]) + + assert.deepEqual(reorderedPairs, pairs) + assert.deepEqual(pairs[0], { + leftBranchName: "feature/a", + rightBranchName: "feature/m" + }) + assert.deepEqual(pairs.at(-1), { + leftBranchName: "feature/z", + rightBranchName: "main" + }) +}) + +// 지역화 규칙이 아닌 문자열 비교로 대소문자 branch 순서를 고정하는지 확인 +test("uses non-localized string comparison", () => { + const pairs = build("main", [ + branch("feature/a"), + branch("feature/Z") + ]) + + assert.deepEqual(pairs, [{ + leftBranchName: "feature/Z", + rightBranchName: "feature/a" + }, { + leftBranchName: "feature/Z", + rightBranchName: "main" + }, { + leftBranchName: "feature/a", + rightBranchName: "main" + }]) +}) + +// 활성 branch 30개와 base branch에서 최대 465개 조합을 만드는지 확인 +test("builds 465 pairs for 30 active branches", () => { + const branches = Array.from({ length: 30 }, (_, index) => + branch(`feature/${String(index).padStart(2, "0")}`) + ) + + const pairs = build("main", branches) + + assert.equal(pairs.length, 465) + assert.equal(new Set(pairs.map(pair => + `${pair.leftBranchName}\u0000${pair.rightBranchName}` + )).size, 465) +}) + +function branch(name: string, baseBranch = "main"): BranchContext { + return { + baseBranch, + name, + headSha: `${name}-sha`, + checks: [] + } +} diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index f181bbf..39e18f3 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -125,6 +125,7 @@ test("writes merge risk debug artifacts", async () => { "ai-response.json", "ai-result.json", "ai-target-selection.json", + "branch-pairs.json", "branch-selection.json", "deterministic-evidence.json", "report.md", @@ -157,6 +158,12 @@ test("writes merge risk debug artifacts", async () => { reason?: string }> }>(fixture.debugArtifactDir, "branch-selection.json") + const branchPairsArtifact = await readJson<{ + pairs?: Array<{ + leftBranchName?: string + rightBranchName?: string + }> + }>(fixture.debugArtifactDir, "branch-pairs.json") const combinedArtifact = (await Promise.all(files.map(file => readFile(join(fixture.debugArtifactDir, file), "utf8") ))).join("\n") @@ -186,6 +193,16 @@ test("writes merge risk debug artifacts", async () => { name: "main", reason: "base_branch" }]) + assert.deepEqual(branchPairsArtifact.pairs, [{ + leftBranchName: "feature/critical", + rightBranchName: "feature/critical-peer" + }, { + leftBranchName: "feature/critical", + rightBranchName: "main" + }, { + leftBranchName: "feature/critical-peer", + rightBranchName: "main" + }]) assert.doesNotMatch(combinedArtifact, /openai-secret/) assert.doesNotMatch(combinedArtifact, /webhook-secret/) assert.doesNotMatch(combinedArtifact, /feature critical content/)