diff --git a/src/git/gitCleanCodeContextCollector.ts b/src/git/gitCleanCodeContextCollector.ts index fe9f368..d4d7674 100644 --- a/src/git/gitCleanCodeContextCollector.ts +++ b/src/git/gitCleanCodeContextCollector.ts @@ -27,16 +27,19 @@ export async function collectCleanOverlapEvidence( mergeResult: GitMergeTreePairResult, pairIndex: number, repositoryPath: string -): Promise { +): Promise<{ + overlapFiles: string[] + evidence: MergeCodeContextEvidence[] +}> { const context = await pairCodeContext(mergeResult, repositoryPath) const [leftFiles, rightFiles] = await Promise.all([ changedFiles(repositoryPath, context.mergeBaseOid, context.leftOid), changedFiles(repositoryPath, context.mergeBaseOid, context.rightOid) ]) const rightFileSet = new Set(rightFiles) - const commonFiles = leftFiles - .filter(filePath => rightFileSet.has(filePath)) - .sort(compareText) + const commonFiles = [...new Set( + leftFiles.filter(filePath => rightFileSet.has(filePath)) + )].sort(compareText) const prepared: PreparedCodeContextEvidence[] = [] for (const [fileIndex, filePath] of commonFiles.entries()) { @@ -135,13 +138,16 @@ export async function collectCleanOverlapEvidence( } } - return collectPreparedEvidence( - mergeResult, - "clean_hunk_overlap", - context, - prepared, - repositoryPath - ) + return { + overlapFiles: commonFiles, + evidence: await collectPreparedEvidence( + mergeResult, + "clean_hunk_overlap", + context, + prepared, + repositoryPath + ) + } } // base 좌표와 겹치는 merged hunk의 변경 후 좌표를 선택 diff --git a/src/git/gitMergeCodeContextCollector.ts b/src/git/gitMergeCodeContextCollector.ts index 60d325b..932a2b4 100644 --- a/src/git/gitMergeCodeContextCollector.ts +++ b/src/git/gitMergeCodeContextCollector.ts @@ -18,29 +18,41 @@ export async function collect( if (mergeResult.status === "merge_check_failed") { results.push({ pair: mergeResult.pair, + overlapFiles: [], evidence: [] }) continue } try { - results.push({ - pair: mergeResult.pair, - evidence: mergeResult.status === "confirmed_conflict" - ? await collectConflictEvidence( - mergeResult, - pairIndex, - options.repositoryPath - ) - : await collectCleanOverlapEvidence( + if (mergeResult.status === "confirmed_conflict") { + results.push({ + pair: mergeResult.pair, + overlapFiles: sortedUnique(mergeResult.conflictFiles), + evidence: await collectConflictEvidence( mergeResult, pairIndex, options.repositoryPath ) + }) + continue + } + + const collection = await collectCleanOverlapEvidence( + mergeResult, + pairIndex, + options.repositoryPath + ) + + results.push({ + pair: mergeResult.pair, + overlapFiles: collection.overlapFiles, + evidence: collection.evidence }) } catch (error) { results.push({ pair: mergeResult.pair, + overlapFiles: [], evidence: [], errorMessage: diagnosticFor(error) }) @@ -50,6 +62,18 @@ export async function collect( return results } +function sortedUnique(values: string[]): string[] { + return [...new Set(values)].sort(compareText) +} + +function compareText(text: string, other: string): number { + if (text === other) { + return 0 + } + + return text < other ? -1 : 1 +} + function diagnosticFor(error: unknown): string { return utf8Prefix( `Git code context collection failed: ${errorFor(error).message}`, diff --git a/src/git/types.ts b/src/git/types.ts index c75b28c..ed568ba 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -84,6 +84,7 @@ export type MergeCodeContextEvidence = { export type GitMergeCodeContextPairResult = { pair: BranchComparisonPair + overlapFiles: string[] evidence: MergeCodeContextEvidence[] errorMessage?: string } diff --git a/src/risks/conflictGraphBuilder.ts b/src/risks/conflictGraphBuilder.ts new file mode 100644 index 0000000..67c87cb --- /dev/null +++ b/src/risks/conflictGraphBuilder.ts @@ -0,0 +1,297 @@ +import { compareBranchNames } from "../branches/branchPairBuilder.js" +import type { + BranchComparisonPair, + BranchContext +} from "../branches/types.js" +import type { + GitMergeCodeContextPairResult, + GitMergeTreePairResult +} from "../git/types.js" +import type { + BranchConflictGraph, + BranchConflictGraphEdge, + BranchConflictGraphEdgeReason, + BranchConflictGraphNode +} from "./types.js" + +// 입력 branch 조합만 중복 없이 정규화해 충돌 관계 edge로 분류 +export function buildEdges( + pairs: BranchComparisonPair[], + mergeResults: GitMergeTreePairResult[], + codeContextResults: GitMergeCodeContextPairResult[] +): BranchConflictGraphEdge[] { + const mergeResultByPair = firstResultByPair(mergeResults) + const codeContextResultByPair = firstResultByPair(codeContextResults) + + return normalizedPairs(pairs).map(pair => edgeFor( + pair, + mergeResultByPair.get(keyFor(pair)), + codeContextResultByPair.get(keyFor(pair)) + )) +} + +// base branch와 활성 branch의 위험 관계를 양방향 node 집계로 구성 +export function buildGraph( + baseBranch: string, + branches: BranchContext[], + edges: BranchConflictGraphEdge[] +): BranchConflictGraph { + const branchNames = sortedUnique([ + baseBranch, + ...branches.map(branch => branch.name) + ]) + const branchNameSet = new Set(branchNames) + const graphEdges = normalizedGraphEdges(edges, branchNameSet) + + return { + baseBranch, + nodes: branchNames.map(branchName => nodeFor(branchName, graphEdges)), + edges: graphEdges + } +} + +function nodeFor( + branchName: string, + edges: BranchConflictGraphEdge[] +): BranchConflictGraphNode { + let confirmedConflictCount = 0 + let potentialOverlapCount = 0 + const relatedBranchNames = new Set() + + for (const edge of edges) { + const relatedBranchName = relatedBranchNameFor(edge.pair, branchName) + + if (!relatedBranchName) { + continue + } + + if (edge.status === "confirmed_conflict") { + confirmedConflictCount += 1 + relatedBranchNames.add(relatedBranchName) + continue + } + + if (edge.status === "potential_overlap") { + potentialOverlapCount += 1 + relatedBranchNames.add(relatedBranchName) + } + } + + return { + branchName, + confirmedConflictCount, + potentialOverlapCount, + relatedBranchNames: sortedUnique([...relatedBranchNames]) + } +} + +function normalizedGraphEdges( + edges: BranchConflictGraphEdge[], + branchNames: ReadonlySet +): BranchConflictGraphEdge[] { + const edgeByPair = new Map() + + for (const edge of edges) { + const pair = normalizedPair(edge.pair) + + if ( + pair.leftBranchName === pair.rightBranchName || + !branchNames.has(pair.leftBranchName) || + !branchNames.has(pair.rightBranchName) + ) { + continue + } + + const key = keyFor(pair) + + if (!edgeByPair.has(key)) { + edgeByPair.set(key, { + ...edge, + pair + }) + } + } + + return [...edgeByPair.values()].sort((edge, other) => + comparePairs(edge.pair, other.pair) + ) +} + +function relatedBranchNameFor( + pair: BranchComparisonPair, + branchName: string +): string | undefined { + if (pair.leftBranchName === branchName) { + return pair.rightBranchName + } + + if (pair.rightBranchName === branchName) { + return pair.leftBranchName + } + + return undefined +} + +function edgeFor( + pair: BranchComparisonPair, + mergeResult: GitMergeTreePairResult | undefined, + codeContextResult: GitMergeCodeContextPairResult | undefined +): BranchConflictGraphEdge { + if (!mergeResult) { + return errorEdge( + pair, + "merge_check_failed", + "merge-tree result is missing for branch pair" + ) + } + + if (mergeResult.status === "confirmed_conflict") { + return { + pair, + status: "confirmed_conflict", + reasons: [reasonWithFiles( + "confirmed_conflict", + mergeResult.conflictFiles + )] + } + } + + if (mergeResult.status === "merge_check_failed") { + return errorEdge( + pair, + "merge_check_failed", + mergeResult.errorMessage ?? "merge-tree collection failed for branch pair" + ) + } + + if (!codeContextResult) { + return errorEdge( + pair, + "code_context_failed", + "code context result is missing for branch pair" + ) + } + + if (codeContextResult.errorMessage) { + return errorEdge( + pair, + "code_context_failed", + codeContextResult.errorMessage + ) + } + + const hunkFiles = sortedUnique(codeContextResult.evidence + .filter(evidence => evidence.kind === "clean_hunk_overlap") + .map(evidence => evidence.filePath)) + const overlapFiles = sortedUnique(codeContextResult.overlapFiles) + const reasons: BranchConflictGraphEdgeReason[] = [] + + if (hunkFiles.length) { + reasons.push(reasonWithFiles("same_hunk_overlap", hunkFiles)) + } + + if (overlapFiles.length) { + reasons.push(reasonWithFiles("same_file_overlap", overlapFiles)) + } + + if (reasons.length) { + return { + pair, + status: "potential_overlap", + reasons + } + } + + return { + pair, + status: "clean", + reasons: [{ code: "clean_merge" }] + } +} + +function errorEdge( + pair: BranchComparisonPair, + code: "merge_check_failed" | "code_context_failed", + errorMessage: string +): BranchConflictGraphEdge { + return { + pair, + status: "error", + reasons: [{ code }], + errorMessage + } +} + +function reasonWithFiles( + code: "confirmed_conflict" | "same_hunk_overlap" | "same_file_overlap", + files: string[] +): BranchConflictGraphEdgeReason { + return { + code, + files: sortedUnique(files) + } +} + +function normalizedPairs(pairs: BranchComparisonPair[]): BranchComparisonPair[] { + const pairByKey = new Map() + + for (const pair of pairs) { + const normalized = normalizedPair(pair) + + if (normalized.leftBranchName === normalized.rightBranchName) { + continue + } + + pairByKey.set(keyFor(normalized), normalized) + } + + return [...pairByKey.values()].sort(comparePairs) +} + +function firstResultByPair( + results: T[] +): Map { + const resultByPair = new Map() + + for (const result of results) { + const key = keyFor(normalizedPair(result.pair)) + + if (!resultByPair.has(key)) { + resultByPair.set(key, result) + } + } + + return resultByPair +} + +function normalizedPair(pair: BranchComparisonPair): BranchComparisonPair { + return compareBranchNames(pair.leftBranchName, pair.rightBranchName) <= 0 + ? pair + : { + leftBranchName: pair.rightBranchName, + rightBranchName: pair.leftBranchName + } +} + +function keyFor(pair: BranchComparisonPair): string { + return `${pair.leftBranchName}\u0000${pair.rightBranchName}` +} + +function comparePairs( + pair: BranchComparisonPair, + other: BranchComparisonPair +): number { + const leftComparison = compareBranchNames( + pair.leftBranchName, + other.leftBranchName + ) + + return leftComparison || compareBranchNames( + pair.rightBranchName, + other.rightBranchName + ) +} + +function sortedUnique(values: string[]): string[] { + return [...new Set(values)].sort(compareBranchNames) +} diff --git a/src/risks/riskAnalyzer.ts b/src/risks/riskAnalyzer.ts index 7cd0e1f..a22d51e 100644 --- a/src/risks/riskAnalyzer.ts +++ b/src/risks/riskAnalyzer.ts @@ -1,6 +1,9 @@ import type { BranchCheckMetadata } from "../branches/types.js" import { BranchRiskStatus } from "./types.js" import type { + BranchConflictGraph, + BranchConflictGraphEdge, + BranchConflictGraphEdgeReasonCode, BranchChangedHunk, BranchRisk, BranchRiskAnalysisInput, @@ -65,6 +68,186 @@ export function analyze( }) } +// 충돌 관계 graph를 기존 branch risk 결과로 호환 변환 +export function analyzeGraph( + graph: BranchConflictGraph, + inputs: BranchRiskAnalysisInput[], + options: BranchRiskAnalysisOptions = {} +): BranchRisk[] { + const criticalRegexes = options.criticalFilePatterns?.map(wildcardToRegExp) ?? [] + + return inputs.map(input => { + const reasons = buildGraphReasons(graph, input, criticalRegexes) + const score = Math.min( + MAX_SCORE, + reasons.reduce((total, reason) => total + reason.scoreImpact, 0) + ) + + return { + branchName: input.branch.name, + baseBranch: input.branch.baseBranch, + score, + status: statusForScore(score), + reasons + } + }) +} + +function buildGraphReasons( + graph: BranchConflictGraph, + input: BranchRiskAnalysisInput, + criticalRegexes: RegExp[] +): BranchRiskReason[] { + const edges = graph.edges.filter(edge => + edge.pair.leftBranchName === input.branch.name || + edge.pair.rightBranchName === input.branch.name + ) + const conflictEdges = edges.filter(edge => + edge.status === "confirmed_conflict" + ) + + if (conflictEdges.length) { + const evidence = graphReasonEvidence( + conflictEdges, + input.branch.name, + "confirmed_conflict" + ) + + return [{ + code: "confirmed_conflict", + message: "branch 조합의 virtual merge에서 conflict가 확인됨", + scoreImpact: SCORE.confirmedConflict, + files: evidence.files, + branches: evidence.branches + }] + } + + const reasons: BranchRiskReason[] = [] + const errorEdges = edges.filter(edge => edge.status === "error") + + if (errorEdges.length) { + reasons.push({ + code: "merge_check_failed", + message: "branch 조합 확인에 실패함", + scoreImpact: SCORE.mergeCheckFailed, + branches: relatedBranches(errorEdges, input.branch.name) + }) + } + + appendGraphOverlapReason( + reasons, + edges, + input.branch.name, + "same_hunk_overlap", + "다른 branch와 같은 hunk를 수정함", + SCORE.sameHunkOverlap + ) + appendGraphOverlapReason( + reasons, + edges, + input.branch.name, + "same_file_overlap", + "다른 branch와 같은 파일을 수정함", + SCORE.sameFileOverlap + ) + + const failedChecks = input.branch.checks.filter(isFailedCheck) + if (failedChecks.length) { + reasons.push({ + code: "failed_check", + message: "실패한 check metadata가 존재함", + scoreImpact: SCORE.failedCheck, + checks: failedChecks.map(check => check.name).sort() + }) + } + + const criticalFiles = sortedUnique(input.gitSignal.changedFiles + .filter(file => criticalRegexes.some(regex => regex.test(file)))) + if (criticalFiles.length) { + reasons.push({ + code: "critical_file_changed", + message: "critical file pattern에 해당하는 파일을 수정함", + scoreImpact: SCORE.criticalFileChanged, + files: criticalFiles + }) + } + + if (!reasons.length) { + reasons.push({ + code: "clean_merge", + message: "virtual merge에서 conflict가 확인되지 않음", + scoreImpact: 0 + }) + } + + return reasons +} + +function appendGraphOverlapReason( + reasons: BranchRiskReason[], + edges: BranchConflictGraphEdge[], + branchName: string, + code: "same_hunk_overlap" | "same_file_overlap", + message: string, + scoreImpact: number +): void { + const evidence = graphReasonEvidence(edges, branchName, code) + + if (!evidence.branches.length) { + return + } + + reasons.push({ + code, + message, + scoreImpact, + files: evidence.files, + branches: evidence.branches + }) +} + +function graphReasonEvidence( + edges: BranchConflictGraphEdge[], + branchName: string, + code: BranchConflictGraphEdgeReasonCode +): { files: string[], branches: string[] } { + const files: string[] = [] + const branches: string[] = [] + + for (const edge of edges) { + const reason = edge.reasons.find(candidate => candidate.code === code) + + if (!reason) { + continue + } + + files.push(...(reason.files ?? [])) + branches.push(...relatedBranches([edge], branchName)) + } + + return { + files: sortedUnique(files), + branches: sortedUnique(branches) + } +} + +function relatedBranches( + edges: BranchConflictGraphEdge[], + branchName: string +): string[] { + return sortedUnique(edges.flatMap(edge => { + if (edge.pair.leftBranchName === branchName) { + return [edge.pair.rightBranchName] + } + + if (edge.pair.rightBranchName === branchName) { + return [edge.pair.leftBranchName] + } + + return [] + })) +} + // 단일 branch에 적용되는 risk reason을 우선순위 순서대로 생성 function buildReasons(input: { input: BranchRiskAnalysisInput @@ -316,3 +499,7 @@ function wildcardToRegExp(pattern: string): RegExp { function escapeRegExp(value: string): string { return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&") } + +function sortedUnique(values: string[]): string[] { + return [...new Set(values)].sort() +} diff --git a/src/risks/types.ts b/src/risks/types.ts index a616690..d800806 100644 --- a/src/risks/types.ts +++ b/src/risks/types.ts @@ -1,4 +1,7 @@ -import type { BranchContext } from "../branches/types.js" +import type { + BranchComparisonPair, + BranchContext +} from "../branches/types.js" import type { GitMergeSignal } from "../git/types.js" // report와 후속 action 추천에서 사용할 branch risk 단계 @@ -58,3 +61,42 @@ export type BranchRisk = { status: BranchRiskStatus reasons: BranchRiskReason[] } + +export type BranchConflictGraphEdgeStatus = + | "confirmed_conflict" + | "error" + | "potential_overlap" + | "clean" + +export type BranchConflictGraphEdgeReasonCode = + | "confirmed_conflict" + | "merge_check_failed" + | "code_context_failed" + | "same_hunk_overlap" + | "same_file_overlap" + | "clean_merge" + +export type BranchConflictGraphEdgeReason = { + code: BranchConflictGraphEdgeReasonCode + files?: string[] +} + +export type BranchConflictGraphEdge = { + pair: BranchComparisonPair + status: BranchConflictGraphEdgeStatus + reasons: BranchConflictGraphEdgeReason[] + errorMessage?: string +} + +export type BranchConflictGraphNode = { + branchName: string + confirmedConflictCount: number + potentialOverlapCount: number + relatedBranchNames: string[] +} + +export type BranchConflictGraph = { + baseBranch: string + nodes: BranchConflictGraphNode[] + edges: BranchConflictGraphEdge[] +} diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index ef1239d..a98e5cb 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -4,9 +4,14 @@ 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 { collect as collectGitMergeCodeContextResults } from "../git/gitMergeCodeContextCollector.js" import { collect as collectGitMergeTreeResults } from "../git/gitMergeTreeCollector.js" import { collectGitMergeSignalFromPairResult } from "../git/gitMergeSignalCollector.js" -import { analyze as analyzeBranchMergeRisks } from "../risks/riskAnalyzer.js" +import { + buildEdges as buildBranchConflictGraphEdges, + buildGraph as buildBranchConflictGraph +} from "../risks/conflictGraphBuilder.js" +import { analyzeGraph } from "../risks/riskAnalyzer.js" import { build as buildAiPredictionEvidencePayload } from "../ai/evidenceBuilder.js" import { createDefaultAiPredictionClient } from "../ai/openAiPredictionClient.js" import { predict as predictMergeRisksWithAi } from "../ai/predictionRunner.js" @@ -132,6 +137,15 @@ export async function run(options: MergeRiskWatchOptions): Promise { repositoryPath: options.repositoryPath, remoteName: options.remoteName }) + const codeContextResults = await collectGitMergeCodeContextResults(pairResults, { + repositoryPath: options.repositoryPath + }) + const edges = buildBranchConflictGraphEdges( + pairs, + pairResults, + codeContextResults + ) + const graph = buildBranchConflictGraph(options.baseBranch, branches, edges) const pairResultByKey = new Map(pairResults.map(result => [ branchPairKey(result.pair.leftBranchName, result.pair.rightBranchName), result @@ -172,7 +186,7 @@ export async function run(options: MergeRiskWatchOptions): Promise { }) } - const risks = analyzeBranchMergeRisks(inputs, { + const risks = analyzeGraph(graph, inputs, { criticalFilePatterns: options.criticalFilePatterns }) const evidencePayloads = inputs.map((input, index) => diff --git a/tests/git/gitMergeCodeContextCollector.test.ts b/tests/git/gitMergeCodeContextCollector.test.ts index 569be2f..45c6124 100644 --- a/tests/git/gitMergeCodeContextCollector.test.ts +++ b/tests/git/gitMergeCodeContextCollector.test.ts @@ -40,6 +40,7 @@ test("collects four conflict versions and commit metadata without repository mut assert.deepEqual(after, before) assert.equal(result?.errorMessage, undefined) + assert.deepEqual(result?.overlapFiles, ["src/value.ts"]) assert.equal(result?.evidence.length, 1) assert.equal(evidence?.kind, "confirmed_conflict") assert.equal(evidence?.filePath, "src/value.ts") @@ -246,9 +247,11 @@ test("collects only overlapping clean hunks without repository mutation", async assert.deepEqual(overlapAfter, overlapBefore) assert.deepEqual(disjointAfter, disjointBefore) assert.equal(overlap?.evidence.length, 1) + assert.deepEqual(overlap?.overlapFiles, ["src/clean.txt"]) assert.equal(overlap?.evidence[0]?.kind, "clean_hunk_overlap") assert.match(overlap?.evidence[0]?.mergedSnippet.content ?? "", /shared change/) assert.equal(disjoint?.errorMessage, undefined) + assert.deepEqual(disjoint?.overlapFiles, ["src/disjoint.txt"]) assert.deepEqual(disjoint?.evidence, []) } finally { await overlapFixture.remove() diff --git a/tests/risks/conflictGraphBuilder.test.ts b/tests/risks/conflictGraphBuilder.test.ts new file mode 100644 index 0000000..57777fe --- /dev/null +++ b/tests/risks/conflictGraphBuilder.test.ts @@ -0,0 +1,294 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + buildEdges, + buildGraph +} from "../../src/risks/conflictGraphBuilder.js" +import type { + BranchComparisonPair, + BranchContext +} from "../../src/branches/types.js" +import type { + GitMergeCodeContextPairResult, + GitMergeTreePairResult, + MergeCodeContextEvidence +} from "../../src/git/types.js" +import type { BranchConflictGraphEdge } from "../../src/risks/types.js" + +test("normalizes branch pairs, removes duplicates, and ignores unrelated results", () => { + const pair = branchPair("feature/z", "feature/a") + const edges = buildEdges([ + pair, + branchPair("feature/a", "feature/z") + ], [ + mergeResult(pair, "confirmed_conflict", ["src/z.ts", "src/a.ts"]), + mergeResult(branchPair("feature/unused", "main"), "clean") + ], []) + + assert.deepEqual(edges, [{ + pair: branchPair("feature/a", "feature/z"), + status: "confirmed_conflict", + reasons: [{ + code: "confirmed_conflict", + files: ["src/a.ts", "src/z.ts"] + }] + }]) +}) + +test("classifies same hunk and file overlap as potential overlap", () => { + const pair = branchPair("feature/a", "feature/b") + const edges = buildEdges( + [pair], + [mergeResult(pair, "clean")], + [codeContextResult(pair, ["src/shared.ts"], ["src/shared.ts"])] + ) + + assert.deepEqual(edges, [{ + pair, + status: "potential_overlap", + reasons: [{ + code: "same_hunk_overlap", + files: ["src/shared.ts"] + }, { + code: "same_file_overlap", + files: ["src/shared.ts"] + }] + }]) +}) + +test("classifies same file without hunk overlap as potential overlap", () => { + const pair = branchPair("feature/a", "feature/b") + const [edge] = buildEdges( + [pair], + [mergeResult(pair, "clean")], + [codeContextResult(pair, ["src/shared.ts"])] + ) + + assert.equal(edge?.status, "potential_overlap") + assert.deepEqual(edge?.reasons, [{ + code: "same_file_overlap", + files: ["src/shared.ts"] + }]) +}) + +test("classifies a clean merge without overlap as clean", () => { + const pair = branchPair("feature/a", "feature/b") + const [edge] = buildEdges( + [pair], + [mergeResult(pair, "clean")], + [codeContextResult(pair)] + ) + + assert.deepEqual(edge, { + pair, + status: "clean", + reasons: [{ code: "clean_merge" }] + }) +}) + +test("keeps confirmed conflict above code context failure", () => { + const pair = branchPair("feature/a", "feature/b") + const [edge] = buildEdges( + [pair], + [mergeResult(pair, "confirmed_conflict", ["src/shared.ts"])], + [codeContextResult(pair, [], [], "context failed")] + ) + + assert.equal(edge?.status, "confirmed_conflict") + assert.equal(edge?.errorMessage, undefined) +}) + +test("classifies failed or missing evidence as error", () => { + const failedPair = branchPair("feature/a", "feature/b") + const missingContextPair = branchPair("feature/a", "main") + const contextFailurePair = branchPair("feature/b", "main") + const missingMergePair = branchPair("feature/c", "main") + const edges = buildEdges([ + contextFailurePair, + missingMergePair, + failedPair, + missingContextPair + ], [ + mergeResult(failedPair, "merge_check_failed", [], "merge failed"), + mergeResult(missingContextPair, "clean"), + mergeResult(contextFailurePair, "clean") + ], [ + codeContextResult(contextFailurePair, [], [], "context failed") + ]) + + assert.deepEqual(edges.map(edge => ({ + pair: edge.pair, + status: edge.status, + code: edge.reasons[0]?.code, + errorMessage: edge.errorMessage + })), [{ + pair: failedPair, + status: "error", + code: "merge_check_failed", + errorMessage: "merge failed" + }, { + pair: missingContextPair, + status: "error", + code: "code_context_failed", + errorMessage: "code context result is missing for branch pair" + }, { + pair: contextFailurePair, + status: "error", + code: "code_context_failed", + errorMessage: "context failed" + }, { + pair: missingMergePair, + status: "error", + code: "merge_check_failed", + errorMessage: "merge-tree result is missing for branch pair" + }]) +}) + +test("aggregates risky relationships and removes reversed edge duplicates", () => { + const conflictPair = branchPair("feature/a", "feature/b") + const overlapPair = branchPair("feature/a", "main") + const errorPair = branchPair("feature/b", "main") + const graph = buildGraph("main", [ + branchContext("feature/b"), + branchContext("feature/a"), + branchContext("feature/a") + ], [ + graphEdge(errorPair, "error", [{ code: "merge_check_failed" }]), + graphEdge(overlapPair, "potential_overlap", [{ + code: "same_file_overlap", + files: ["src/shared.ts"] + }]), + graphEdge(conflictPair, "confirmed_conflict", [{ + code: "confirmed_conflict", + files: ["src/conflict.ts"] + }]), + graphEdge( + branchPair("feature/b", "feature/a"), + "clean", + [{ code: "clean_merge" }] + ), + graphEdge( + branchPair("feature/unused", "main"), + "confirmed_conflict", + [{ code: "confirmed_conflict" }] + ) + ]) + + assert.equal(graph.baseBranch, "main") + assert.deepEqual(graph.edges.map(edge => edge.pair), [ + conflictPair, + overlapPair, + errorPair + ]) + assert.deepEqual(graph.nodes, [{ + branchName: "feature/a", + confirmedConflictCount: 1, + potentialOverlapCount: 1, + relatedBranchNames: ["feature/b", "main"] + }, { + branchName: "feature/b", + confirmedConflictCount: 1, + potentialOverlapCount: 0, + relatedBranchNames: ["feature/a"] + }, { + branchName: "main", + confirmedConflictCount: 0, + potentialOverlapCount: 1, + relatedBranchNames: ["feature/a"] + }]) +}) + +function branchPair( + leftBranchName: string, + rightBranchName: string +): BranchComparisonPair { + return { + leftBranchName, + rightBranchName + } +} + +function branchContext(name: string): BranchContext { + return { + baseBranch: "main", + name, + headSha: `${name}-sha`, + checks: [] + } +} + +function graphEdge( + pair: BranchComparisonPair, + status: BranchConflictGraphEdge["status"], + reasons: BranchConflictGraphEdge["reasons"] +): BranchConflictGraphEdge { + return { + pair, + status, + reasons + } +} + +function mergeResult( + pair: BranchComparisonPair, + status: GitMergeTreePairResult["status"], + conflictFiles: string[] = [], + errorMessage?: string +): GitMergeTreePairResult { + return { + pair, + status, + conflictFiles, + conflicts: [], + ...(errorMessage ? { errorMessage } : {}) + } +} + +function codeContextResult( + pair: BranchComparisonPair, + overlapFiles: string[] = [], + hunkFiles: string[] = [], + errorMessage?: string +): GitMergeCodeContextPairResult { + return { + pair, + overlapFiles, + evidence: hunkFiles.map(filePath => codeContextEvidence(pair, filePath)), + ...(errorMessage ? { errorMessage } : {}) + } +} + +function codeContextEvidence( + pair: BranchComparisonPair, + filePath: string +): MergeCodeContextEvidence { + const snippet = { + status: "text" as const, + filePath, + content: "content", + startLine: 1, + endLine: 1, + truncated: false + } + const commit = { + oid: "a".repeat(40), + author: "opfic ", + committedAt: "2026-07-16T00:00:00Z", + subject: "subject" + } + + return { + pair, + kind: "clean_hunk_overlap", + filePath, + mergeBaseOid: "b".repeat(40), + mergedTreeOid: "c".repeat(40), + baseCommit: { ...commit, role: "base" }, + leftCommit: { ...commit, role: "left", branchName: pair.leftBranchName }, + rightCommit: { ...commit, role: "right", branchName: pair.rightBranchName }, + baseSnippet: snippet, + leftSnippet: snippet, + rightSnippet: snippet, + mergedSnippet: snippet + } +} diff --git a/tests/risks/riskAnalyzer.test.ts b/tests/risks/riskAnalyzer.test.ts index de4e3d0..e39a4e6 100644 --- a/tests/risks/riskAnalyzer.test.ts +++ b/tests/risks/riskAnalyzer.test.ts @@ -7,6 +7,132 @@ import { type BranchRiskAnalysisInput, type GitMergeSignal } from "../../src/index.js" +import { buildGraph } from "../../src/risks/conflictGraphBuilder.js" +import { analyzeGraph } from "../../src/risks/riskAnalyzer.js" +import type { BranchConflictGraphEdge } from "../../src/risks/types.js" + +test("keeps confirmed graph conflict above every auxiliary reason", () => { + const feature = branch("feature/a", [{ + name: "CI", + status: "completed", + conclusion: "failure" + }]) + const graph = buildGraph("main", [feature, branch("feature/b")], [ + graphEdge("feature/a", "feature/b", "confirmed_conflict", [{ + code: "confirmed_conflict", + files: ["src/conflict.ts"] + }]), + graphEdge("feature/a", "main", "potential_overlap", [{ + code: "same_file_overlap", + files: ["package-lock.json"] + }]) + ]) + const [risk] = analyzeGraph(graph, [input("feature/a", { + status: "clean", + changedFiles: ["package-lock.json"], + conflictFiles: [] + }, [], feature)], { + criticalFilePatterns: ["package-lock.json"] + }) + + assert.equal(risk?.score, 100) + assert.equal(risk?.status, BranchRiskStatus.Critical) + assert.deepEqual(risk?.reasons, [{ + code: "confirmed_conflict", + message: "branch 조합의 virtual merge에서 conflict가 확인됨", + scoreImpact: 100, + files: ["src/conflict.ts"], + branches: ["feature/b"] + }]) +}) + +test("aggregates graph overlap reasons once and preserves input order", () => { + const featureA = branch("feature/a") + const featureB = branch("feature/b") + const graph = buildGraph("main", [featureA, featureB], [ + graphEdge("feature/a", "feature/b", "potential_overlap", [{ + code: "same_hunk_overlap", + files: ["src/shared.ts"] + }, { + code: "same_file_overlap", + files: ["src/shared.ts"] + }]), + graphEdge("feature/a", "main", "potential_overlap", [{ + code: "same_file_overlap", + files: ["src/base.ts"] + }]), + graphEdge("feature/b", "main", "potential_overlap", [{ + code: "same_file_overlap", + files: ["src/base.ts"] + }]) + ]) + const risks = analyzeGraph(graph, [ + input("feature/b", cleanSignal(), [], featureB), + input("feature/a", cleanSignal(), [], featureA) + ]) + + assert.deepEqual(risks.map(risk => risk.branchName), [ + "feature/b", + "feature/a" + ]) + assert.deepEqual(risks.map(risk => risk.score), [85, 85]) + assert.deepEqual(risks[0]?.reasons, [{ + code: "same_hunk_overlap", + message: "다른 branch와 같은 hunk를 수정함", + scoreImpact: 55, + files: ["src/shared.ts"], + branches: ["feature/a"] + }, { + code: "same_file_overlap", + message: "다른 branch와 같은 파일을 수정함", + scoreImpact: 30, + files: ["src/base.ts", "src/shared.ts"], + branches: ["feature/a", "main"] + }]) +}) + +test("maps graph errors and branch auxiliary signals to existing risk reasons", () => { + const feature = branch("feature/a", [{ + name: "CI", + status: "completed", + conclusion: "failure" + }]) + const graph = buildGraph("main", [feature], [ + graphEdge("feature/a", "main", "error", [{ + code: "code_context_failed" + }]) + ]) + const [risk] = analyzeGraph(graph, [input("feature/a", { + status: "clean", + changedFiles: ["package-lock.json"], + conflictFiles: [] + }, [], feature)], { + criticalFilePatterns: ["package-lock.json"] + }) + + assert.equal(risk?.score, 85) + assert.equal(risk?.status, BranchRiskStatus.Critical) + assert.deepEqual(reasonCodes(risk), [ + "merge_check_failed", + "failed_check", + "critical_file_changed" + ]) + assert.deepEqual(risk?.reasons[0]?.branches, ["main"]) +}) + +test("keeps clean graph branches as low risk", () => { + const feature = branch("feature/a") + const graph = buildGraph("main", [feature], [ + graphEdge("feature/a", "main", "clean", [{ code: "clean_merge" }]) + ]) + const [risk] = analyzeGraph(graph, [ + input("feature/a", cleanSignal(), [], feature) + ]) + + assert.equal(risk?.score, 0) + assert.equal(risk?.status, BranchRiskStatus.Low) + assert.deepEqual(reasonCodes(risk), ["clean_merge"]) +}) // confirmed conflict signal이 최상위 risk로 반영되는지 확인 test("marks confirmed conflict as critical risk", () => { @@ -186,6 +312,33 @@ function branch( } } +function graphEdge( + leftBranchName: string, + rightBranchName: string, + status: BranchConflictGraphEdge["status"], + reasons: BranchConflictGraphEdge["reasons"] +): BranchConflictGraphEdge { + return { + pair: { + leftBranchName, + rightBranchName + }, + status, + reasons + } +} + +function cleanSignal(): Pick< + GitMergeSignal, + "status" | "changedFiles" | "conflictFiles" +> { + return { + status: "clean", + changedFiles: [], + conflictFiles: [] + } +} + function reasonCodes( risk: ReturnType[number] | undefined ): string[] { diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index 1ebdb9b..2c99cb0 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -145,7 +145,12 @@ test("writes merge risk debug artifacts", async () => { }>(fixture.debugArtifactDir, "ai-result.json") const deterministicArtifact = await readJson<{ risks?: Array<{ + branchName?: string status?: string + reasons?: Array<{ + code?: string + branches?: string[] + }> }> }>(fixture.debugArtifactDir, "deterministic-evidence.json") const branchSelectionArtifact = await readJson<{ @@ -176,6 +181,21 @@ test("writes merge risk debug artifacts", async () => { "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@feat/#35-artifact" ) assert.equal(deterministicArtifact.risks?.[0]?.status, "critical") + assert.deepEqual(deterministicArtifact.risks?.map(risk => + risk.reasons?.map(reason => reason.code) + ), [[ + "same_hunk_overlap", + "same_file_overlap", + "critical_file_changed" + ], [ + "same_hunk_overlap", + "same_file_overlap", + "critical_file_changed" + ]]) + assert.deepEqual( + deterministicArtifact.risks?.[0]?.reasons?.[0]?.branches, + ["feature/critical-peer"] + ) assert.equal(aiResultArtifact.predictions?.[0]?.status, "predicted") assert.equal(aiResultArtifact.predictions?.[0]?.branchName, "feature/critical") assert.deepEqual(branchSelectionArtifact.selectedBranches?.map(branch => branch.name), [ @@ -223,6 +243,112 @@ test("writes merge risk debug artifacts", async () => { } }) +// 활성 branch 사이의 충돌 확정 관계를 양쪽 branch 위험에 반영하는지 확인 +test("uses active branch conflicts for both branch risks", async () => { + const fixture = await createWorkflowGitFixture({ + peerContent: "peer critical content\n" + }) + const originalFetch = globalThis.fetch + const originalOpenAiApiKey = process.env.OPENAI_API_KEY + const originalDiscordWebhookUrl = process.env.DISCORD_WEBHOOK_URL + + process.env.OPENAI_API_KEY = "openai-secret" + process.env.DISCORD_WEBHOOK_URL = "https://discord.test/webhook-secret" + globalThis.fetch = async input => { + assert.equal(new Request(input).url, "https://discord.test/webhook-secret") + return new Response(null, { status: 204 }) + } + + try { + await run({ + repository: "opficdev/Watcher", + repositoryPath: fixture.repositoryPath, + baseBranch: "main", + criticalFilePatterns: ["critical.txt"], + remoteName: "origin", + githubApiUrl: "https://api.github.test", + debugArtifactDir: fixture.debugArtifactDir + }) + + const deterministicArtifact = await readJson<{ + risks?: Array<{ + branchName?: string + status?: string + reasons?: Array<{ + code?: string + branches?: string[] + }> + }> + }>(fixture.debugArtifactDir, "deterministic-evidence.json") + const aiTargetArtifact = await readJson<{ + targetBranches?: unknown[] + skippedBranches?: Array<{ + branchName?: string + reason?: string + }> + }>(fixture.debugArtifactDir, "ai-target-selection.json") + const aiResultArtifact = await readJson<{ + predictions?: Array<{ + status?: string + branchName?: string + reason?: string + }> + }>(fixture.debugArtifactDir, "ai-result.json") + + assert.deepEqual(deterministicArtifact.risks, [{ + branchName: "feature/critical", + baseBranch: "main", + score: 100, + status: "critical", + reasons: [{ + code: "confirmed_conflict", + message: "branch 조합의 virtual merge에서 conflict가 확인됨", + scoreImpact: 100, + files: ["critical.txt"], + branches: ["feature/critical-peer"] + }] + }, { + branchName: "feature/critical-peer", + baseBranch: "main", + score: 100, + status: "critical", + reasons: [{ + code: "confirmed_conflict", + message: "branch 조합의 virtual merge에서 conflict가 확인됨", + scoreImpact: 100, + files: ["critical.txt"], + branches: ["feature/critical"] + }] + }]) + assert.deepEqual(aiTargetArtifact.targetBranches, []) + assert.deepEqual(aiTargetArtifact.skippedBranches, [{ + branchName: "feature/critical", + baseBranch: "main", + reason: "confirmed_conflict" + }, { + branchName: "feature/critical-peer", + baseBranch: "main", + reason: "confirmed_conflict" + }]) + assert.deepEqual(aiResultArtifact.predictions, [{ + status: "skipped", + branchName: "feature/critical", + baseBranch: "main", + reason: "confirmed_conflict" + }, { + status: "skipped", + branchName: "feature/critical-peer", + baseBranch: "main", + reason: "confirmed_conflict" + }]) + } finally { + globalThis.fetch = originalFetch + restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) + restoreEnv("DISCORD_WEBHOOK_URL", originalDiscordWebhookUrl) + await fixture.remove() + } +}) + // fetch 실패 시 오래된 remote ref에서 changed file과 hunk를 다시 만들지 않는지 확인 test("keeps deterministic evidence empty after pair fetch failure", async () => { const fixture = await createWorkflowGitFixture() @@ -415,7 +541,9 @@ function jsonResponse(body: unknown): Response { }) } -async function createWorkflowGitFixture(): Promise<{ +async function createWorkflowGitFixture(options: { + peerContent?: string +} = {}): Promise<{ repositoryPath: string debugArtifactDir: string committerDate: string @@ -445,7 +573,10 @@ async function createWorkflowGitFixture(): Promise<{ await git(repositoryPath, ["checkout", "main"]) await git(repositoryPath, ["checkout", "-b", "feature/critical-peer"]) - await writeFile(join(repositoryPath, "critical.txt"), "peer critical content\n") + await writeFile( + join(repositoryPath, "critical.txt"), + options.peerContent ?? "feature critical content\n" + ) await git(repositoryPath, ["commit", "-am", "feature critical peer"], commitDates) await git(repositoryPath, ["checkout", "main"])