Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
46 changes: 46 additions & 0 deletions src/branches/branchPairBuilder.ts
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
opficdev marked this conversation as resolved.
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
}
5 changes: 5 additions & 0 deletions src/branches/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export type BranchSelectionResult = {
excluded: ExcludedBranch[]
}

export type BranchComparisonPair = {
leftBranchName: string
rightBranchName: string
}

export type BranchContext = {
baseBranch: string
name: string
Expand Down
5 changes: 5 additions & 0 deletions src/workflows/mergeRiskWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -125,13 +126,17 @@ export async function run(options: MergeRiskWatchOptions): Promise<void> {
defaultBranch: options.defaultBranch
}, generatedAt)
const branches = branchSelection.selected
const pairs = buildBranchComparisonPairs(options.baseBranch, branches)
const inputs: BranchRiskAnalysisInput[] = []

await debugArtifactWriter?.writeJson("branch-selection.json", {
repositoryBranches,
selectedBranches: branchSelection.selected,
excludedBranches: branchSelection.excluded
})
await debugArtifactWriter?.writeJson("branch-pairs.json", {
pairs
})

for (const branch of branches) {
const gitSignal = await collectGitMergeSignal(branch, {
Expand Down
104 changes: 104 additions & 0 deletions tests/branches/branchPairBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -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: []
}
}
17 changes: 17 additions & 0 deletions tests/workflows/mergeRiskWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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/)
Expand Down