From 34e9404a2b1ce43d01bf4806404d685f5096efef Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:31:20 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20branch=20=EC=A1=B0=ED=95=A9=20repor?= =?UTF-8?q?t=20=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/branches/branchSelector.ts | 5 +- src/index.ts | 26 +- src/reports/branchPairReportBuilder.ts | 303 ++++++++++++++++++ src/reports/branchPairTypes.ts | 102 ++++++ tests/branches/branchSelector.test.ts | 11 +- tests/reports/branchPairReportBuilder.test.ts | 297 +++++++++++++++++ 6 files changed, 740 insertions(+), 4 deletions(-) create mode 100644 src/reports/branchPairReportBuilder.ts create mode 100644 src/reports/branchPairTypes.ts create mode 100644 tests/reports/branchPairReportBuilder.test.ts diff --git a/src/branches/branchSelector.ts b/src/branches/branchSelector.ts index b934766..35828ca 100644 --- a/src/branches/branchSelector.ts +++ b/src/branches/branchSelector.ts @@ -7,7 +7,10 @@ import type { RepositoryBranch } from "./types.js" -const activeBranchWindowMilliseconds = 14 * 24 * 60 * 60 * 1_000 +export const ACTIVE_BRANCH_WINDOW_DAYS = 14 + +const activeBranchWindowMilliseconds = + ACTIVE_BRANCH_WINDOW_DAYS * 24 * 60 * 60 * 1_000 const maximumActiveBranchCount = 30 // 제외 사유가 필요 없는 호출자를 위해 선택된 BranchContext 목록만 반환 diff --git a/src/index.ts b/src/index.ts index 7210fcc..39ec81b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,10 @@ export function watcherRuntimeDescription(): string { } export { collectBranchContexts } from "./branches/branchCollector.js" -export { select as selectWatchedBranches } from "./branches/branchSelector.js" +export { + ACTIVE_BRANCH_WINDOW_DAYS, + select as selectWatchedBranches +} from "./branches/branchSelector.js" export { build as buildAiPredictionEvidencePayload } from "./ai/evidenceBuilder.js" export { createDefaultAiPredictionClient, @@ -46,6 +49,9 @@ export { collectGitMergeSignal } from "./git/gitMergeSignalCollector.js" export { send as sendMergeRiskReport } from "./reportChannels/reportChannel.js" export { analyze as analyzeBranchMergeRisks } from "./risks/riskAnalyzer.js" export { build as buildMergeRiskReport } from "./reports/reportBuilder.js" +export { + build as buildBranchPairMergeRiskReport +} from "./reports/branchPairReportBuilder.js" export { format as formatMergeRiskReportMarkdown } from "./reports/markdownFormatter.js" export { BranchRiskStatus } from "./risks/types.js" @@ -96,7 +102,9 @@ export type { BranchContext, BranchCheckMetadata, BranchPullRequestMetadata, + BranchSelectionResult, BranchSelectionOptions, + ExcludedBranch, RepositoryBranch } from "./branches/types.js" @@ -116,11 +124,14 @@ export { export type { GitMergeSignal, GitMergeSignalCollectionOptions, - GitMergeSignalStatus + GitMergeSignalStatus, + GitMergeTreePairResult } from "./git/types.js" export type { BranchChangedHunk, + BranchConflictGraph, + BranchConflictGraphEdge, BranchRisk, BranchRiskAnalysisInput, BranchRiskAnalysisOptions, @@ -135,3 +146,14 @@ export type { MergeRiskReportOptions, MergeRiskReportSection } from "./reports/types.js" + +export type { + BranchPairMergeRiskReport, + BranchPairMergeRiskReportActivePeriod, + BranchPairMergeRiskReportAiAnalysis, + BranchPairMergeRiskReportBranchImpact, + BranchPairMergeRiskReportExcludedBranch, + BranchPairMergeRiskReportInput, + BranchPairMergeRiskReportMergeError, + BranchPairMergeRiskReportPairItem +} from "./reports/branchPairTypes.js" diff --git a/src/reports/branchPairReportBuilder.ts b/src/reports/branchPairReportBuilder.ts new file mode 100644 index 0000000..376c333 --- /dev/null +++ b/src/reports/branchPairReportBuilder.ts @@ -0,0 +1,303 @@ +import type { AiPredictionPairResult } from "../ai/types.js" +import { compareBranchNames } from "../branches/branchPairBuilder.js" +import type { BranchComparisonPair } from "../branches/types.js" +import type { GitMergeTreePairResult } from "../git/types.js" +import type { + BranchConflictGraphEdge, + BranchConflictGraphEdgeStatus +} from "../risks/types.js" +import type { + BranchPairMergeRiskReport, + BranchPairMergeRiskReportAiAnalysis, + BranchPairMergeRiskReportBranchImpact, + BranchPairMergeRiskReportExcludedBranch, + BranchPairMergeRiskReportInput, + BranchPairMergeRiskReportMergeError, + BranchPairMergeRiskReportPairItem +} from "./branchPairTypes.js" + +const dayMilliseconds = 24 * 60 * 60 * 1_000 + +// branch 조합 분석 결과를 표시 목적의 report 모델로 구성 +export function build( + input: BranchPairMergeRiskReportInput +): BranchPairMergeRiskReport { + const edges = normalizedEdges(input.graph.edges) + const mergeResultByPair = firstMergeResultByPair(input.mergeResults) + const aiResultByPair = firstResultByPair(input.aiResults) + const confirmedConflicts = pairItemsFor( + edges, + "confirmed_conflict", + mergeResultByPair, + aiResultByPair + ) + const potentialRisks = pairItemsFor( + edges, + "potential_overlap", + mergeResultByPair, + aiResultByPair + ) + + return { + baseBranch: input.graph.baseBranch, + generatedAt: new Date(input.generatedAt), + activePeriod: { + dayCount: input.activeBranchWindowDays, + since: new Date( + input.generatedAt.getTime() - input.activeBranchWindowDays * dayMilliseconds + ), + until: new Date(input.generatedAt) + }, + discoveredBranchCount: input.discoveredBranchCount, + watchedBranchCount: new Set( + input.watchedBranches.map(branch => branch.name) + ).size, + comparisonPairCount: edges.length, + confirmedConflicts, + potentialRisks, + branchImpacts: branchImpactsFor( + input.watchedBranches.map(branch => branch.name), + confirmedConflicts, + potentialRisks + ), + cleanPairCount: edges.filter(edge => edge.status === "clean").length, + excludedBranches: reportableExclusions(input.excludedBranches), + mergeErrors: mergeErrorsFor(edges) + } +} + +// 지정 상태의 edge를 commit과 AI 결과가 결합된 report 항목으로 변환 +function pairItemsFor( + edges: BranchConflictGraphEdge[], + status: Extract< + BranchConflictGraphEdgeStatus, + "confirmed_conflict" | "potential_overlap" + >, + mergeResultByPair: ReadonlyMap, + aiResultByPair: ReadonlyMap +): BranchPairMergeRiskReportPairItem[] { + return edges + .filter(edge => edge.status === status) + .map(edge => pairItemFor( + edge, + status, + mergeResultByPair.get(keyFor(edge.pair)), + aiResultByPair.get(keyFor(edge.pair)) + )) +} + +// 정규화된 branch 순서에 맞춰 조합 하나의 상세 항목을 구성 +function pairItemFor( + edge: BranchConflictGraphEdge, + status: BranchPairMergeRiskReportPairItem["status"], + normalizedMergeResult: NormalizedMergeResult | undefined, + aiResult: AiPredictionPairResult | undefined +): BranchPairMergeRiskReportPairItem { + const result = normalizedMergeResult?.result + const reversed = normalizedMergeResult?.reversed ?? false + + return { + pair: edge.pair, + status, + reasons: edge.reasons, + conflicts: result?.conflicts ?? [], + leftCommitOid: reversed ? result?.rightCommitOid : result?.leftCommitOid, + rightCommitOid: reversed ? result?.leftCommitOid : result?.rightCommitOid, + aiAnalysis: aiAnalysisFor(aiResult) + } +} + +// AI 실행 결과의 predicted, failed, skipped 상태를 report 모델로 변환 +function aiAnalysisFor( + result: AiPredictionPairResult | undefined +): BranchPairMergeRiskReportAiAnalysis { + if (!result) { + return { + status: "skipped", + reason: "not_target" + } + } + + if (result.status === "failed") { + return { + status: "failed", + errorMessage: result.errorMessage + } + } + + return { + status: "predicted", + response: result.response + } +} + +// 감시 branch별 확정 conflict와 잠재 위험 조합을 집계 +function branchImpactsFor( + watchedBranchNames: string[], + confirmedConflicts: BranchPairMergeRiskReportPairItem[], + potentialRisks: BranchPairMergeRiskReportPairItem[] +): BranchPairMergeRiskReportBranchImpact[] { + return sortedUnique(watchedBranchNames) + .map(branchName => ({ + branchName, + confirmedConflictPairs: relatedPairsFor(branchName, confirmedConflicts), + potentialRiskPairs: relatedPairsFor(branchName, potentialRisks) + })) + .filter(impact => + impact.confirmedConflictPairs.length !== 0 || + impact.potentialRiskPairs.length !== 0 + ) +} + +// 지정 branch가 포함된 report 항목에서 branch 조합만 추출 +function relatedPairsFor( + branchName: string, + items: BranchPairMergeRiskReportPairItem[] +): BranchComparisonPair[] { + return items + .filter(item => includesBranch(item.pair, branchName)) + .map(item => item.pair) +} + +// branch 조합에 지정 branch가 포함되는지 확인 +function includesBranch( + pair: BranchComparisonPair, + branchName: string +): boolean { + return pair.leftBranchName === branchName || pair.rightBranchName === branchName +} + +// 활성 기간과 최대 개수 기준으로 제외된 branch만 이름순으로 구성 +function reportableExclusions( + excludedBranches: BranchPairMergeRiskReportInput["excludedBranches"] +): BranchPairMergeRiskReportExcludedBranch[] { + return excludedBranches + .filter((branch): branch is typeof branch & { + reason: BranchPairMergeRiskReportExcludedBranch["reason"] + } => branch.reason === "stale_branch" || branch.reason === "branch_limit") + .map(branch => ({ + name: branch.name, + reason: branch.reason + })) + .sort((branch, other) => compareBranchNames(branch.name, other.name)) +} + +// 분석 실패 edge를 오류 원인과 메시지가 포함된 report 항목으로 변환 +function mergeErrorsFor( + edges: BranchConflictGraphEdge[] +): BranchPairMergeRiskReportMergeError[] { + return edges + .filter(edge => edge.status === "error") + .map(edge => ({ + pair: edge.pair, + reasons: edge.reasons, + errorMessage: edge.errorMessage ?? "branch pair merge analysis failed" + })) +} + +// 반대 방향 중복과 자기 조합을 제거하고 branch 이름순으로 edge를 정렬 +function normalizedEdges( + edges: BranchConflictGraphEdge[] +): BranchConflictGraphEdge[] { + const edgeByPair = new Map() + + for (const edge of edges) { + const pair = normalizedPair(edge.pair) + + if (pair.leftBranchName === 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) + ) +} + +type NormalizedMergeResult = { + result: GitMergeTreePairResult + reversed: boolean +} + +// 조합별 첫 merge 결과와 정규화 과정의 방향 전환 여부를 기록 +function firstMergeResultByPair( + results: GitMergeTreePairResult[] +): Map { + const resultByPair = new Map() + + for (const result of results) { + const pair = normalizedPair(result.pair) + const key = keyFor(pair) + + if (!resultByPair.has(key)) { + resultByPair.set(key, { + result, + reversed: pair.leftBranchName !== result.pair.leftBranchName + }) + } + } + + return resultByPair +} + +// branch 조합별 첫 결과를 정규화된 key로 구성 +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 +} + +// branch 이름 비교 순서에 맞춰 조합의 좌우 방향을 정규화 +function normalizedPair(pair: BranchComparisonPair): BranchComparisonPair { + return compareBranchNames(pair.leftBranchName, pair.rightBranchName) <= 0 + ? pair + : { + leftBranchName: pair.rightBranchName, + rightBranchName: pair.leftBranchName + } +} + +// 정규화된 branch 조합을 충돌 없는 map key로 변환 +function keyFor(pair: BranchComparisonPair): string { + return `${pair.leftBranchName}\u0000${pair.rightBranchName}` +} + +// branch 조합을 왼쪽 이름과 오른쪽 이름 순서로 비교 +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/reports/branchPairTypes.ts b/src/reports/branchPairTypes.ts new file mode 100644 index 0000000..32f808a --- /dev/null +++ b/src/reports/branchPairTypes.ts @@ -0,0 +1,102 @@ +import type { + AiPredictionPairResponse, + AiPredictionPairResult +} from "../ai/types.js" +import type { + BranchComparisonPair, + BranchContext, + ExcludedBranch +} from "../branches/types.js" +import type { + GitMergeTreeConflict, + GitMergeTreePairResult +} from "../git/types.js" +import type { + BranchConflictGraph, + BranchConflictGraphEdgeReason, + BranchConflictGraphEdgeStatus +} from "../risks/types.js" + +// branch 조합 report가 다루는 활성 branch 기간 +export type BranchPairMergeRiskReportActivePeriod = { + dayCount: number + since: Date + until: Date +} + +// AI 실행 여부와 결과를 deterministic 조합 결과와 분리해 표현 +export type BranchPairMergeRiskReportAiAnalysis = + | { + status: "predicted" + response: AiPredictionPairResponse + } + | { + status: "skipped" + reason: "not_target" + } + | { + status: "failed" + errorMessage: string + } + +// 확정 conflict 또는 잠재 위험 조합의 report 항목 +export type BranchPairMergeRiskReportPairItem = { + pair: BranchComparisonPair + status: Extract< + BranchConflictGraphEdgeStatus, + "confirmed_conflict" | "potential_overlap" + > + reasons: BranchConflictGraphEdgeReason[] + conflicts: GitMergeTreeConflict[] + leftCommitOid?: string + rightCommitOid?: string + aiAnalysis: BranchPairMergeRiskReportAiAnalysis +} + +// 감시 branch 하나에 영향을 주는 위험 조합 집계 +export type BranchPairMergeRiskReportBranchImpact = { + branchName: string + confirmedConflictPairs: BranchComparisonPair[] + potentialRiskPairs: BranchComparisonPair[] +} + +// report에 표시할 활성 기간 또는 개수 제한 제외 항목 +export type BranchPairMergeRiskReportExcludedBranch = { + name: string + reason: Extract +} + +// 조합 분석에 실패한 branch와 실패 원인 +export type BranchPairMergeRiskReportMergeError = { + pair: BranchComparisonPair + reasons: BranchConflictGraphEdgeReason[] + errorMessage: string +} + +// branch 조합 중심 merge risk report 전체 모델 +export type BranchPairMergeRiskReport = { + baseBranch: string + generatedAt: Date + activePeriod: BranchPairMergeRiskReportActivePeriod + discoveredBranchCount: number + watchedBranchCount: number + comparisonPairCount: number + confirmedConflicts: BranchPairMergeRiskReportPairItem[] + potentialRisks: BranchPairMergeRiskReportPairItem[] + branchImpacts: BranchPairMergeRiskReportBranchImpact[] + cleanPairCount: number + excludedBranches: BranchPairMergeRiskReportExcludedBranch[] + mergeErrors: BranchPairMergeRiskReportMergeError[] +} + +// report builder에 필요한 수집 결과와 branch 선택 정보 +export type BranchPairMergeRiskReportInput = { + generatedAt: Date + activeBranchWindowDays: number + discoveredBranchCount: number + watchedBranches: BranchContext[] + excludedBranches: ExcludedBranch[] + graph: BranchConflictGraph + mergeResults: GitMergeTreePairResult[] + aiResults: AiPredictionPairResult[] +} diff --git a/tests/branches/branchSelector.test.ts b/tests/branches/branchSelector.test.ts index b4d7315..1d5cd08 100644 --- a/tests/branches/branchSelector.test.ts +++ b/tests/branches/branchSelector.test.ts @@ -1,11 +1,20 @@ import test from "node:test" import assert from "node:assert/strict" -import { select, selectWithReasons } from "../../src/branches/branchSelector.js" +import { + ACTIVE_BRANCH_WINDOW_DAYS, + select, + selectWithReasons +} from "../../src/branches/branchSelector.js" import type { RepositoryBranch } from "../../src/branches/types.js" const dayMilliseconds = 24 * 60 * 60 * 1_000 const currentTime = new Date() +// report와 branch 선택이 같은 활성 기간 정책을 사용하는지 확인 +test("exports the active branch window policy", () => { + assert.equal(ACTIVE_BRANCH_WINDOW_DAYS, 14) +}) + // base, default branch가 감시 대상에서 제외되는지 확인 test("excludes base and default branches", () => { const selected = select([ diff --git a/tests/reports/branchPairReportBuilder.test.ts b/tests/reports/branchPairReportBuilder.test.ts new file mode 100644 index 0000000..6d03961 --- /dev/null +++ b/tests/reports/branchPairReportBuilder.test.ts @@ -0,0 +1,297 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + buildBranchPairMergeRiskReport, + type AiConfirmedConflictResponse, + type AiCleanOverlapResponse, + type AiPredictionPairResult, + type BranchComparisonPair, + type BranchConflictGraph, + type BranchConflictGraphEdge, + type BranchContext, + type ExcludedBranch, + type GitMergeTreePairResult +} from "../../src/index.js" + +const generatedAt = new Date("2026-07-17T00:00:00.000Z") + +// 요약과 위험 section을 branch 조합 기준으로 구성하는지 확인 +test("builds branch pair summary and risk sections", () => { + const confirmedPair = pair("feature/a", "main") + const predictedPair = pair("feature/a", "feature/b") + const failedPair = pair("feature/b", "feature/c") + const skippedPair = pair("feature/c", "main") + const graph = conflictGraph([ + edge(confirmedPair, "confirmed_conflict", "confirmed_conflict", ["src/a.ts"]), + edge(predictedPair, "potential_overlap", "same_hunk_overlap", ["src/b.ts"]), + edge(failedPair, "potential_overlap", "same_file_overlap", ["src/c.ts"]), + edge(skippedPair, "potential_overlap", "same_file_overlap", ["src/d.ts"]), + edge(pair("feature/a", "feature/c"), "clean", "clean_merge"), + { + pair: pair("feature/b", "main"), + status: "error", + reasons: [{ code: "merge_check_failed" }], + errorMessage: "merge-tree failed" + } + ]) + + const report = buildBranchPairMergeRiskReport({ + generatedAt, + activeBranchWindowDays: 14, + discoveredBranchCount: 8, + watchedBranches: [branch("feature/a"), branch("feature/b"), branch("feature/c")], + excludedBranches: [], + graph, + mergeResults: [ + mergeResult(pair("main", "feature/a"), "confirmed_conflict", { + leftCommitOid: "main-oid", + rightCommitOid: "feature-a-oid", + conflictFiles: ["src/a.ts"], + conflicts: [{ paths: ["src/a.ts"], type: "content" }] + }), + mergeResult(predictedPair, "clean", { + leftCommitOid: "feature-a-oid", + rightCommitOid: "feature-b-oid" + }), + mergeResult(failedPair, "clean"), + mergeResult(skippedPair, "clean") + ], + aiResults: [ + predictedAiResult(confirmedPair, confirmedResponse(confirmedPair)), + predictedAiResult(predictedPair, cleanOverlapResponse(predictedPair)), + { + status: "failed", + pair: failedPair, + errorMessage: "provider failed" + } + ] + }) + + assert.equal(report.baseBranch, "main") + assert.deepEqual(report.activePeriod, { + dayCount: 14, + since: new Date("2026-07-03T00:00:00.000Z"), + until: generatedAt + }) + assert.equal(report.discoveredBranchCount, 8) + assert.equal(report.watchedBranchCount, 3) + assert.equal(report.comparisonPairCount, 6) + assert.equal(report.cleanPairCount, 1) + assert.equal(report.confirmedConflicts.length, 1) + assert.deepEqual(report.confirmedConflicts[0], { + pair: confirmedPair, + status: "confirmed_conflict", + reasons: [{ code: "confirmed_conflict", files: ["src/a.ts"] }], + conflicts: [{ paths: ["src/a.ts"], type: "content" }], + leftCommitOid: "feature-a-oid", + rightCommitOid: "main-oid", + aiAnalysis: { + status: "predicted", + response: confirmedResponse(confirmedPair) + } + }) + assert.deepEqual( + report.potentialRisks.map(item => item.aiAnalysis.status), + ["predicted", "failed", "skipped"] + ) + assert.deepEqual(report.potentialRisks[2]?.aiAnalysis, { + status: "skipped", + reason: "not_target" + }) + assert.deepEqual(report.mergeErrors, [{ + pair: pair("feature/b", "main"), + reasons: [{ code: "merge_check_failed" }], + errorMessage: "merge-tree failed" + }]) +}) + +// 반대 방향 중복 조합을 한 번만 집계하고 base row를 만들지 않는지 확인 +test("deduplicates pairs and builds watched branch impacts", () => { + const graph = conflictGraph([ + edge(pair("feature/a", "main"), "confirmed_conflict", "confirmed_conflict"), + edge(pair("main", "feature/a"), "confirmed_conflict", "confirmed_conflict"), + edge(pair("feature/b", "feature/a"), "potential_overlap", "same_file_overlap") + ]) + + const report = buildBranchPairMergeRiskReport({ + generatedAt, + activeBranchWindowDays: 14, + discoveredBranchCount: 3, + watchedBranches: [branch("feature/b"), branch("feature/a")], + excludedBranches: [], + graph, + mergeResults: [], + aiResults: [] + }) + + assert.equal(report.comparisonPairCount, 2) + assert.equal(report.confirmedConflicts.length, 1) + assert.deepEqual(report.branchImpacts, [{ + branchName: "feature/a", + confirmedConflictPairs: [pair("feature/a", "main")], + potentialRiskPairs: [pair("feature/a", "feature/b")] + }, { + branchName: "feature/b", + confirmedConflictPairs: [], + potentialRiskPairs: [pair("feature/a", "feature/b")] + }]) + assert.equal( + report.branchImpacts.some(impact => impact.branchName === "main"), + false + ) +}) + +// stale과 branch limit 제외 사유만 report에 남기는지 확인 +test("keeps reportable branch exclusions in deterministic order", () => { + const excludedBranches: ExcludedBranch[] = [{ + name: "main", + sha: "main-sha", + reason: "base_branch" + }, { + name: "feature/z", + sha: "feature-z-sha", + reason: "stale_branch" + }, { + name: "feature/a", + sha: "feature-a-sha", + reason: "branch_limit" + }, { + name: "develop", + sha: "develop-sha", + reason: "default_branch" + }] + + const report = buildBranchPairMergeRiskReport({ + generatedAt, + activeBranchWindowDays: 14, + discoveredBranchCount: 4, + watchedBranches: [], + excludedBranches, + graph: conflictGraph([]), + mergeResults: [], + aiResults: [] + }) + + assert.deepEqual(report.excludedBranches, [{ + name: "feature/a", + reason: "branch_limit" + }, { + name: "feature/z", + reason: "stale_branch" + }]) +}) + +function branch(name: string): BranchContext { + return { + baseBranch: "main", + name, + headSha: `${name}-sha`, + checks: [] + } +} + +function pair( + leftBranchName: string, + rightBranchName: string +): BranchComparisonPair { + return { + leftBranchName, + rightBranchName + } +} + +function edge( + branchPair: BranchComparisonPair, + status: BranchConflictGraphEdge["status"], + reasonCode: BranchConflictGraphEdge["reasons"][number]["code"], + files?: string[] +): BranchConflictGraphEdge { + return { + pair: branchPair, + status, + reasons: [{ code: reasonCode, files }] + } +} + +function conflictGraph(edges: BranchConflictGraphEdge[]): BranchConflictGraph { + return { + baseBranch: "main", + nodes: [], + edges + } +} + +function mergeResult( + branchPair: BranchComparisonPair, + status: GitMergeTreePairResult["status"], + overrides: Partial = {} +): GitMergeTreePairResult { + return { + pair: branchPair, + status, + conflictFiles: [], + conflicts: [], + ...overrides + } +} + +function predictedAiResult( + branchPair: BranchComparisonPair, + response: AiConfirmedConflictResponse | AiCleanOverlapResponse +): AiPredictionPairResult { + return { + status: "predicted", + pair: branchPair, + response + } +} + +function confirmedResponse( + branchPair: BranchComparisonPair +): AiConfirmedConflictResponse { + return { + kind: "confirmed_conflict", + pair: branchPair, + conflictCause: { + summary: "같은 조건을 다르게 수정함", + files: ["src/a.ts"] + }, + integrationOrder: { + strategy: "rebase", + firstBranchName: branchPair.leftBranchName, + secondBranchName: branchPair.rightBranchName, + reason: "첫 변경을 기준으로 정리함", + steps: ["첫 branch 반영", "두 번째 branch rebase"] + }, + patches: [{ + filePath: "src/a.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + reason: "두 변경 의도를 보존함" + }] + } +} + +function cleanOverlapResponse( + branchPair: BranchComparisonPair +): AiCleanOverlapResponse { + return { + kind: "clean_overlap", + pair: branchPair, + overlapCause: { + summary: "같은 파일의 인접 코드를 수정함", + files: ["src/b.ts"] + }, + integrationOrder: { + strategy: "merge", + firstBranchName: branchPair.leftBranchName, + secondBranchName: branchPair.rightBranchName, + reason: "현재 순서로 통합 가능함", + steps: ["첫 branch merge", "두 번째 branch merge"] + }, + preventiveActions: [{ + title: "통합 동작 확인", + description: "두 변경이 함께 동작하는지 확인함", + files: ["src/b.ts"] + }] + } +} From 8f668d9aa739d147741e4cd3188afa29984e76b6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:42:15 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20branch=20=EC=A1=B0=ED=95=A9=20repor?= =?UTF-8?q?t=20Markdown=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 3 + src/reports/branchPairMarkdownFormatter.ts | 299 +++++++++++++++++ .../branchPairMarkdownFormatter.test.ts | 310 ++++++++++++++++++ 3 files changed, 612 insertions(+) create mode 100644 src/reports/branchPairMarkdownFormatter.ts create mode 100644 tests/reports/branchPairMarkdownFormatter.test.ts diff --git a/src/index.ts b/src/index.ts index 39ec81b..677b9a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,9 @@ export { build as buildBranchPairMergeRiskReport } from "./reports/branchPairReportBuilder.js" export { format as formatMergeRiskReportMarkdown } from "./reports/markdownFormatter.js" +export { + format as formatBranchPairMergeRiskReportMarkdown +} from "./reports/branchPairMarkdownFormatter.js" export { BranchRiskStatus } from "./risks/types.js" export type { BranchSource } from "./branches/branchCollector.js" diff --git a/src/reports/branchPairMarkdownFormatter.ts b/src/reports/branchPairMarkdownFormatter.ts new file mode 100644 index 0000000..e6b32ed --- /dev/null +++ b/src/reports/branchPairMarkdownFormatter.ts @@ -0,0 +1,299 @@ +import type { + AiPredictionPairIntegrationOrder, + AiPredictionPairPatch, + AiPredictionPairPreventiveAction +} from "../ai/types.js" +import type { BranchComparisonPair } from "../branches/types.js" +import type { GitMergeTreeConflict } from "../git/types.js" +import type { BranchConflictGraphEdgeReason } from "../risks/types.js" +import type { + BranchPairMergeRiskReport, + BranchPairMergeRiskReportAiAnalysis, + BranchPairMergeRiskReportBranchImpact, + BranchPairMergeRiskReportExcludedBranch, + BranchPairMergeRiskReportMergeError, + BranchPairMergeRiskReportPairItem +} from "./branchPairTypes.js" + +// branch 조합 report 모델을 전달 채널에서 사용할 Markdown 문자열로 변환 +export function format(report: BranchPairMergeRiskReport): string { + return [ + "## Merge Risk Report", + "", + "### Summary", + ...summaryLinesFor(report), + ...pairSectionLinesFor("Confirmed Conflicts", report.confirmedConflicts), + ...pairSectionLinesFor("Potential Risks", report.potentialRisks), + ...branchImpactSectionLinesFor(report.branchImpacts), + ...excludedBranchSectionLinesFor(report.excludedBranches), + ...mergeErrorSectionLinesFor(report.mergeErrors) + ].join("\n") +} + +// 활성 기간과 branch 및 조합 집계를 Summary line으로 구성 +function summaryLinesFor(report: BranchPairMergeRiskReport): string[] { + return [ + `- active period: ${code(report.activePeriod.since.toISOString())} - ` + + `${code(report.activePeriod.until.toISOString())} ` + + `(${code(`${report.activePeriod.dayCount.toString()} days`)})`, + `- base branch: ${code(report.baseBranch)}`, + `- discovered branches: ${report.discoveredBranchCount.toString()}`, + `- watched branches: ${report.watchedBranchCount.toString()}`, + `- compared pairs: ${report.comparisonPairCount.toString()}`, + `- clean pairs: ${report.cleanPairCount.toString()}` + ] +} + +// 확정 conflict 또는 잠재 위험 section과 조합 상세를 구성 +function pairSectionLinesFor( + title: string, + items: BranchPairMergeRiskReportPairItem[] +): string[] { + return sectionLinesFor(title, itemBlocksFor(items)) +} + +// branch 조합 상세 항목 사이에 빈 line을 넣어 읽기 쉬운 block으로 구성 +function itemBlocksFor(items: BranchPairMergeRiskReportPairItem[]): string[] { + const lines: string[] = [] + + for (const [index, item] of items.entries()) { + if (index !== 0) { + lines.push("") + } + + lines.push(...linesForPairItem(item)) + } + + return lines +} + +// 조합 하나의 상태, commit, deterministic 근거, conflict와 AI 결과를 표시 +function linesForPairItem(item: BranchPairMergeRiskReportPairItem): string[] { + return [ + `#### ${pairLabelFor(item.pair)}`, + `- status: ${code(item.status)}`, + "- commits:", + ` - ${code(item.pair.leftBranchName)}: ${commitCode(item.leftCommitOid)}`, + ` - ${code(item.pair.rightBranchName)}: ${commitCode(item.rightCommitOid)}`, + "- reasons:", + ...item.reasons.flatMap(reason => linesForReason(reason)), + "- conflicts:", + ...conflictLinesFor(item.conflicts), + ...aiAnalysisLinesFor(item.aiAnalysis) + ] +} + +// commit OID가 없을 때도 branch별 수집 상태를 명시 +function commitCode(commitOid: string | undefined): string { + return code(commitOid ?? "unavailable") +} + +// deterministic reason code와 관련 파일을 중첩 bullet로 구성 +function linesForReason(reason: BranchConflictGraphEdgeReason): string[] { + const lines = [` - ${code(reason.code)}`] + + if (reason.files?.length) { + lines.push(` - files: ${reason.files.map(code).join(", ")}`) + } + + return lines +} + +// conflict type과 관련 path를 표시하고 없으면 없음 상태를 표시 +function conflictLinesFor(conflicts: GitMergeTreeConflict[]): string[] { + if (conflicts.length === 0) { + return [" - 없음"] + } + + return conflicts.map(conflict => + ` - ${code(conflict.type)}: ${conflict.paths.map(code).join(", ")}` + ) +} + +// AI predicted, skipped, failed 결과를 deterministic 상세 뒤에 추가 +function aiAnalysisLinesFor( + analysis: BranchPairMergeRiskReportAiAnalysis +): string[] { + if (analysis.status === "skipped") { + return [ + "- AI Analysis:", + ` - status: ${code(analysis.status)}`, + ` - reason: ${code(analysis.reason)}` + ] + } + + if (analysis.status === "failed") { + return [ + "- AI Analysis:", + ` - status: ${code(analysis.status)}`, + ` - error: ${analysis.errorMessage}` + ] + } + + const response = analysis.response + const cause = response.kind === "confirmed_conflict" + ? response.conflictCause + : response.overlapCause + const lines = [ + "- AI Analysis:", + ` - status: ${code(analysis.status)}`, + ` - cause: ${cause.summary}`, + ` - files: ${cause.files.map(code).join(", ")}`, + ...recommendedResolutionLinesFor(response.integrationOrder) + ] + + if (response.kind === "confirmed_conflict") { + lines.push(...suggestedPatchLinesFor(response.patches)) + } else { + lines.push(...preventiveActionLinesFor(response.preventiveActions)) + } + + return lines +} + +// AI가 제안한 merge 또는 rebase 순서와 수행 단계를 표시 +function recommendedResolutionLinesFor( + order: AiPredictionPairIntegrationOrder +): string[] { + return [ + "- Recommended Resolution:", + ` - strategy: ${code(order.strategy)}`, + ` - order: ${code(order.firstBranchName)} → ${code(order.secondBranchName)}`, + ` - reason: ${order.reason}`, + " - steps:", + ...order.steps.map((step, index) => ` ${index + 1}. ${step}`) + ] +} + +// 확정 conflict의 파일별 patch와 제안 이유를 안전한 code fence로 표시 +function suggestedPatchLinesFor( + patches: AiPredictionPairPatch[] +): string[] { + return [ + "- Suggested Patch:", + ...patches.flatMap(linesForPatch) + ] +} + +// patch 원문 내부 backtick보다 긴 fence를 선택해 Markdown block으로 구성 +function linesForPatch(patch: AiPredictionPairPatch): string[] { + const fence = fenceFor(patch.patch) + + return [ + ` - ${code(patch.filePath)}: ${patch.reason}`, + ` ${fence}diff`, + ...patch.patch.split("\n").map(line => ` ${line}`), + ` ${fence}` + ] +} + +// 잠재 위험을 줄이기 위한 예방 조치와 관련 파일을 표시 +function preventiveActionLinesFor( + actions: AiPredictionPairPreventiveAction[] +): string[] { + return [ + "- Preventive Actions:", + ...actions.flatMap(action => [ + ` - ${action.title}: ${action.description}`, + ` - files: ${action.files.map(code).join(", ")}` + ]) + ] +} + +// 감시 branch별 확정 conflict와 잠재 위험 조합 집계 section을 구성 +function branchImpactSectionLinesFor( + impacts: BranchPairMergeRiskReportBranchImpact[] +): string[] { + const lines = impacts.flatMap(impact => [ + `- ${code(impact.branchName)}`, + impactLineFor("confirmed", impact.confirmedConflictPairs), + impactLineFor("potential", impact.potentialRiskPairs) + ]) + + return sectionLinesFor("Branch Impact", lines) +} + +// branch 영향 유형별 조합 개수와 조합 목록을 한 line으로 표시 +function impactLineFor( + label: "confirmed" | "potential", + pairs: BranchComparisonPair[] +): string { + const pairList = pairs.length === 0 + ? "없음" + : pairs.map(pairLabelFor).join(", ") + + return ` - ${label} (${pairs.length.toString()}): ${pairList}` +} + +// 활성 기간 또는 개수 제한으로 제외된 branch section을 구성 +function excludedBranchSectionLinesFor( + branches: BranchPairMergeRiskReportExcludedBranch[] +): string[] { + return sectionLinesFor( + "Excluded Branches", + branches.map(branch => `- ${code(branch.name)}: ${code(branch.reason)}`) + ) +} + +// 조합 분석 실패의 reason과 error message section을 구성 +function mergeErrorSectionLinesFor( + errors: BranchPairMergeRiskReportMergeError[] +): string[] { + return sectionLinesFor( + "Merge Errors", + errors.flatMap(error => linesForMergeError(error)) + ) +} + +// merge error 하나를 조합, reason code, error message로 표시 +function linesForMergeError( + error: BranchPairMergeRiskReportMergeError +): string[] { + const reasons = error.reasons.length === 0 + ? "없음" + : error.reasons.map(reason => code(reason.code)).join(", ") + + return [ + `- ${pairLabelFor(error.pair)}`, + ` - reasons: ${reasons}`, + ` - error: ${error.errorMessage}` + ] +} + +// 제목과 내용을 Markdown section으로 묶고 내용이 없으면 없음 상태를 표시 +function sectionLinesFor(title: string, content: string[]): string[] { + return [ + "", + `### ${title}`, + "", + ...(content.length === 0 ? ["없음"] : content) + ] +} + +// branch 조합을 두 개의 inline code와 방향 기호로 표시 +function pairLabelFor(pair: BranchComparisonPair): string { + return `${code(pair.leftBranchName)} ↔ ${code(pair.rightBranchName)}` +} + +// patch 안의 연속 backtick보다 길고 최소 세 개인 code fence를 구성 +function fenceFor(value: string): string { + const matches = value.match(/`+/g) ?? [] + const maxBackticks = Math.max(0, ...matches.map(match => match.length)) + + return "`".repeat(Math.max(3, maxBackticks + 1)) +} + +// Markdown inline code 안의 backtick보다 긴 delimiter를 사용해 code span을 구성 +function code(value: string): string { + const backtick = "`" + + if (!value.includes(backtick)) { + return `${backtick}${value}${backtick}` + } + + const matches = value.match(/`+/g) ?? [] + const maxBackticks = Math.max(...matches.map(match => match.length)) + const delimiter = backtick.repeat(maxBackticks + 1) + + return `${delimiter} ${value} ${delimiter}` +} diff --git a/tests/reports/branchPairMarkdownFormatter.test.ts b/tests/reports/branchPairMarkdownFormatter.test.ts new file mode 100644 index 0000000..8fc5276 --- /dev/null +++ b/tests/reports/branchPairMarkdownFormatter.test.ts @@ -0,0 +1,310 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + formatBranchPairMergeRiskReportMarkdown, + type AiConfirmedConflictResponse, + type AiCleanOverlapResponse, + type BranchComparisonPair, + type BranchPairMergeRiskReport, + type BranchPairMergeRiskReportPairItem +} from "../../src/index.js" + +// 활성 기간과 branch 및 조합 수를 Summary에 표시 +test("formats branch pair report summary", () => { + const markdown = formatBranchPairMergeRiskReportMarkdown(report()) + + assert.match(markdown, /## Merge Risk Report/) + assert.match(markdown, /### Summary/) + assert.match( + markdown, + /- active period: `2026-07-03T00:00:00\.000Z` - `2026-07-17T00:00:00\.000Z` \(`14 days`\)/ + ) + assert.match(markdown, /- base branch: `main`/) + assert.match(markdown, /- discovered branches: 8/) + assert.match(markdown, /- watched branches: 3/) + assert.match(markdown, /- compared pairs: 6/) + assert.match(markdown, /- clean pairs: 1/) + assert.doesNotMatch(markdown, /clean_merge/) +}) + +// 확정 conflict의 commit, 원인, conflict type과 Suggested Patch를 표시 +test("formats confirmed conflict details and suggested patch", () => { + const markdown = formatBranchPairMergeRiskReportMarkdown(report()) + + assert.match(markdown, /### Confirmed Conflicts/) + assert.match(markdown, /#### `feature\/a` ↔ `main`/) + assert.match(markdown, /- status: `confirmed_conflict`/) + assert.match(markdown, /- `feature\/a`: `feature-a-oid`/) + assert.match(markdown, /- `main`: `main-oid`/) + assert.match(markdown, /- `confirmed_conflict`/) + assert.match(markdown, /- files: `src\/a\.ts`/) + assert.match(markdown, /- `content`: `src\/a\.ts`/) + assert.match(markdown, /- AI Analysis:/) + assert.match(markdown, /- cause: 같은 조건을 다르게 수정함/) + assert.match(markdown, /- Recommended Resolution:/) + assert.match(markdown, /- strategy: `rebase`/) + assert.match(markdown, /- order: `feature\/a` → `main`/) + assert.match(markdown, /- Suggested Patch:/) + assert.match(markdown, /- `src\/a\.ts`: 두 변경 의도를 보존함/) + assert.match(markdown, /```diff\n\s*@@ -1 \+1 @@\n\s*-old\n\s*\+new\n\s*```/) + assert.equal(markdown.match(/#### `feature\/a` ↔ `main`/g)?.length, 1) +}) + +// 잠재 위험의 AI 해결 순서와 예방 조치를 표시 +test("formats potential risk analysis and preventive actions", () => { + const markdown = formatBranchPairMergeRiskReportMarkdown(report()) + + assert.match(markdown, /### Potential Risks/) + assert.match(markdown, /#### `feature\/a` ↔ `feature\/b`/) + assert.match(markdown, /- status: `potential_overlap`/) + assert.match(markdown, /- cause: 같은 파일의 인접 코드를 수정함/) + assert.match(markdown, /- strategy: `merge`/) + assert.match(markdown, /- Preventive Actions:/) + assert.match(markdown, /- 통합 동작 확인: 두 변경이 함께 동작하는지 확인함/) +}) + +// AI skipped와 failed 상태에서도 deterministic 위험 상세를 유지 +test("formats skipped and failed AI analysis", () => { + const markdown = formatBranchPairMergeRiskReportMarkdown(report()) + + assert.match( + markdown, + /#### `feature\/b` ↔ `feature\/c`[\s\S]*?- status: `skipped`[\s\S]*?- reason: `not_target`/ + ) + assert.match( + markdown, + /#### `feature\/c` ↔ `main`[\s\S]*?- status: `failed`[\s\S]*?- error: provider failed/ + ) + assert.match(markdown, /- `same_file_overlap`/) +}) + +// 감시 branch별 확정 conflict와 잠재 위험 관계를 개수와 함께 집계 +test("formats watched branch impacts without a base branch row", () => { + const markdown = formatBranchPairMergeRiskReportMarkdown(report()) + + assert.match(markdown, /### Branch Impact/) + assert.match( + markdown, + /- `feature\/a`[\s\S]*?- confirmed \(1\): `feature\/a` ↔ `main`[\s\S]*?- potential \(1\): `feature\/a` ↔ `feature\/b`/ + ) + assert.match( + markdown, + /- `feature\/b`[\s\S]*?- confirmed \(0\): 없음[\s\S]*?- potential \(2\): `feature\/a` ↔ `feature\/b`, `feature\/b` ↔ `feature\/c`/ + ) + assert.doesNotMatch(markdown, /^- `main`$/m) +}) + +// 제외 branch와 merge error를 별도 section에 표시 +test("formats excluded branches and merge errors", () => { + const markdown = formatBranchPairMergeRiskReportMarkdown(report()) + + assert.match(markdown, /### Excluded Branches/) + assert.match(markdown, /- `feature\/old`: `stale_branch`/) + assert.match(markdown, /- `feature\/overflow`: `branch_limit`/) + assert.match(markdown, /### Merge Errors/) + assert.match(markdown, /- `feature\/b` ↔ `main`/) + assert.match(markdown, /- reasons: `merge_check_failed`/) + assert.match(markdown, /- error: merge-tree failed/) +}) + +// 값이 없는 section을 명시하고 inline code와 patch fence를 안전하게 구성 +test("formats empty sections and embedded backticks", () => { + const value = report() + value.confirmedConflicts = [{ + ...value.confirmedConflicts[0]!, + pair: pair("feature/`a`", "main"), + aiAnalysis: { + status: "predicted", + response: { + ...confirmedResponse(pair("feature/`a`", "main")), + patches: [{ + filePath: "src/`a`.ts", + patch: "```diff\n-old\n+new\n```", + reason: "fence 확인" + }] + } + } + }] + value.potentialRisks = [] + value.branchImpacts = [] + value.excludedBranches = [] + value.mergeErrors = [] + + const markdown = formatBranchPairMergeRiskReportMarkdown(value) + + assert.match(markdown, /#### `` feature\/`a` `` ↔ `main`/) + assert.match(markdown, /- `` src\/`a`\.ts ``: fence 확인/) + assert.match(markdown, /````diff\n\s*```diff\n\s*-old\n\s*\+new\n\s*```\n\s*````/) + assert.match(markdown, /### Potential Risks\n\n없음/) + assert.match(markdown, /### Branch Impact\n\n없음/) + assert.match(markdown, /### Excluded Branches\n\n없음/) + assert.match(markdown, /### Merge Errors\n\n없음/) +}) + +function report(): BranchPairMergeRiskReport { + const confirmedPair = pair("feature/a", "main") + const predictedPair = pair("feature/a", "feature/b") + + return { + baseBranch: "main", + generatedAt: new Date("2026-07-17T00:00:00.000Z"), + activePeriod: { + dayCount: 14, + since: new Date("2026-07-03T00:00:00.000Z"), + until: new Date("2026-07-17T00:00:00.000Z") + }, + discoveredBranchCount: 8, + watchedBranchCount: 3, + comparisonPairCount: 6, + confirmedConflicts: [item( + confirmedPair, + "confirmed_conflict", + "confirmed_conflict", + "src/a.ts", + { + status: "predicted", + response: confirmedResponse(confirmedPair) + }, + [{ paths: ["src/a.ts"], type: "content" }] + )], + potentialRisks: [item( + predictedPair, + "potential_overlap", + "same_hunk_overlap", + "src/b.ts", + { + status: "predicted", + response: cleanOverlapResponse(predictedPair) + } + ), item( + pair("feature/b", "feature/c"), + "potential_overlap", + "same_file_overlap", + "src/c.ts", + { + status: "skipped", + reason: "not_target" + } + ), item( + pair("feature/c", "main"), + "potential_overlap", + "same_file_overlap", + "src/d.ts", + { + status: "failed", + errorMessage: "provider failed" + } + )], + branchImpacts: [{ + branchName: "feature/a", + confirmedConflictPairs: [confirmedPair], + potentialRiskPairs: [predictedPair] + }, { + branchName: "feature/b", + confirmedConflictPairs: [], + potentialRiskPairs: [ + predictedPair, + pair("feature/b", "feature/c") + ] + }, { + branchName: "feature/c", + confirmedConflictPairs: [], + potentialRiskPairs: [ + pair("feature/b", "feature/c"), + pair("feature/c", "main") + ] + }], + cleanPairCount: 1, + excludedBranches: [{ + name: "feature/old", + reason: "stale_branch" + }, { + name: "feature/overflow", + reason: "branch_limit" + }], + mergeErrors: [{ + pair: pair("feature/b", "main"), + reasons: [{ code: "merge_check_failed" }], + errorMessage: "merge-tree failed" + }] + } +} + +function item( + branchPair: BranchComparisonPair, + status: BranchPairMergeRiskReportPairItem["status"], + reasonCode: BranchPairMergeRiskReportPairItem["reasons"][number]["code"], + filePath: string, + aiAnalysis: BranchPairMergeRiskReportPairItem["aiAnalysis"], + conflicts: BranchPairMergeRiskReportPairItem["conflicts"] = [] +): BranchPairMergeRiskReportPairItem { + return { + pair: branchPair, + status, + reasons: [{ code: reasonCode, files: [filePath] }], + conflicts, + leftCommitOid: `${branchPair.leftBranchName.replace("feature/", "feature-")}-oid`, + rightCommitOid: `${branchPair.rightBranchName.replace("feature/", "feature-")}-oid`, + aiAnalysis + } +} + +function pair( + leftBranchName: string, + rightBranchName: string +): BranchComparisonPair { + return { + leftBranchName, + rightBranchName + } +} + +function confirmedResponse( + branchPair: BranchComparisonPair +): AiConfirmedConflictResponse { + return { + kind: "confirmed_conflict", + pair: branchPair, + conflictCause: { + summary: "같은 조건을 다르게 수정함", + files: ["src/a.ts"] + }, + integrationOrder: { + strategy: "rebase", + firstBranchName: branchPair.leftBranchName, + secondBranchName: branchPair.rightBranchName, + reason: "첫 변경을 기준으로 정리함", + steps: ["첫 branch 반영", "두 번째 branch rebase"] + }, + patches: [{ + filePath: "src/a.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + reason: "두 변경 의도를 보존함" + }] + } +} + +function cleanOverlapResponse( + branchPair: BranchComparisonPair +): AiCleanOverlapResponse { + return { + kind: "clean_overlap", + pair: branchPair, + overlapCause: { + summary: "같은 파일의 인접 코드를 수정함", + files: ["src/b.ts"] + }, + integrationOrder: { + strategy: "merge", + firstBranchName: branchPair.leftBranchName, + secondBranchName: branchPair.rightBranchName, + reason: "현재 순서로 통합 가능함", + steps: ["첫 branch merge", "두 번째 branch merge"] + }, + preventiveActions: [{ + title: "통합 동작 확인", + description: "두 변경이 함께 동작하는지 확인함", + files: ["src/b.ts"] + }] + } +} From 225d38ed7fd0c201a1ba61dc289240d15d1256dc Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:53:43 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20branch=20=EC=A1=B0=ED=95=A9=20AI=20?= =?UTF-8?q?debug=20=ED=9D=90=EB=A6=84=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/predictionPairRunner.ts | 56 +++++++++++++- src/ai/types.ts | 30 +++++++ src/debug/aiPredictionArtifact.ts | 72 +++++++++++++++++ src/index.ts | 5 ++ tests/ai/predictionPairRunner.test.ts | 99 ++++++++++++++++++++++++ tests/debug/aiPredictionArtifact.test.ts | 84 ++++++++++++++++++++ 6 files changed, 342 insertions(+), 4 deletions(-) diff --git a/src/ai/predictionPairRunner.ts b/src/ai/predictionPairRunner.ts index f310f96..4990608 100644 --- a/src/ai/predictionPairRunner.ts +++ b/src/ai/predictionPairRunner.ts @@ -2,16 +2,17 @@ import { build as buildPrompt } from "./predictionPairPromptBuilder.js" import { validate as validateResponse } from "./predictionPairResponseValidator.js" import type { AiPredictionClient, + AiPredictionPairDebugObserver, AiPredictionPairEvidencePayload, AiPredictionPairResult, - AiPredictionPromptBuildOptions + AiPredictionPairRunOptions } from "./types.js" // branch 조합을 입력 순서대로 실행하고 provider와 validation 실패를 조합별로 격리 export async function predict( payloads: AiPredictionPairEvidencePayload[], client: AiPredictionClient, - options: AiPredictionPromptBuildOptions = {} + options: AiPredictionPairRunOptions = {} ): Promise { const results: AiPredictionPairResult[] = [] @@ -26,11 +27,25 @@ export async function predict( async function resultFor( payload: AiPredictionPairEvidencePayload, client: AiPredictionClient, - options: AiPredictionPromptBuildOptions + options: AiPredictionPairRunOptions ): Promise { try { const prompt = buildPrompt(payload, options) + await notifyDebugObserver( + "onPromptBuilt", + () => options.debugObserver?.onPromptBuilt?.({ + targetPair: targetPairFor(payload), + prompt + }) + ) const response = await client.predict(prompt) + await notifyDebugObserver( + "onResponseReceived", + () => options.debugObserver?.onResponseReceived?.({ + targetPair: targetPairFor(payload), + response + }) + ) return { status: "predicted", @@ -38,14 +53,47 @@ async function resultFor( response: validateResponse(response, payload) } } catch (error) { + const errorMessage = errorMessageFor(error) + await notifyDebugObserver( + "onPredictionFailed", + () => options.debugObserver?.onPredictionFailed?.({ + targetPair: targetPairFor(payload), + errorMessage + }) + ) + return { status: "failed", pair: payload.pair, - errorMessage: errorMessageFor(error) + errorMessage } } } +// debug observer 실패가 branch 조합 prediction을 중단하지 않도록 격리 +async function notifyDebugObserver( + eventName: keyof AiPredictionPairDebugObserver, + action: () => Promise | void | undefined +): Promise { + try { + await action() + } catch (error) { + console.warn( + `Failed to notify pair debug observer (${eventName}): ${errorMessageFor(error)}` + ) + } +} + +// observer가 입력 payload를 변경하지 못하도록 ordered pair metadata를 복사 +function targetPairFor( + payload: AiPredictionPairEvidencePayload +): AiPredictionPairEvidencePayload["pair"] { + return { + leftBranchName: payload.pair.leftBranchName, + rightBranchName: payload.pair.rightBranchName + } +} + // unknown provider 또는 validation 오류를 report 가능한 문자열로 변환 function errorMessageFor(error: unknown): string { return error instanceof Error ? error.message : String(error) diff --git a/src/ai/types.ts b/src/ai/types.ts index 6309ba2..7c83174 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -148,6 +148,31 @@ export type AiPredictionPairFailedResult = { errorMessage: string } +// branch 조합 prompt 생성 시점의 ordered pair와 prompt 정보 +export type AiPredictionPairPromptDebugEvent = { + targetPair: BranchComparisonPair + prompt: AiPredictionPrompt +} + +// branch 조합 provider response 수신 시점의 ordered pair와 원본 응답 +export type AiPredictionPairResponseDebugEvent = { + targetPair: BranchComparisonPair + response: unknown +} + +// branch 조합 provider 호출 또는 응답 검증 실패 정보 +export type AiPredictionPairFailureDebugEvent = { + targetPair: BranchComparisonPair + errorMessage: string +} + +// branch 조합 AI 실행 단계별 debug event를 받는 observer +export type AiPredictionPairDebugObserver = { + onPromptBuilt?(event: AiPredictionPairPromptDebugEvent): void | Promise + onResponseReceived?(event: AiPredictionPairResponseDebugEvent): void | Promise + onPredictionFailed?(event: AiPredictionPairFailureDebugEvent): void | Promise +} + // AI provider에 전달할 system/user prompt 묶음 export type AiPredictionPrompt = { systemPrompt: string @@ -167,6 +192,11 @@ export type AiPredictionPromptBuildOptions = { systemPrompt?: string } +// branch 조합 prompt와 debug observer를 함께 조정하기 위한 실행 설정 +export type AiPredictionPairRunOptions = AiPredictionPromptBuildOptions & { + debugObserver?: AiPredictionPairDebugObserver +} + // provider별 AI 호출 구현이 맞춰야 하는 최소 interface export type AiPredictionClient = { predict(prompt: AiPredictionPrompt): Promise diff --git a/src/debug/aiPredictionArtifact.ts b/src/debug/aiPredictionArtifact.ts index 2eb1689..4e8d772 100644 --- a/src/debug/aiPredictionArtifact.ts +++ b/src/debug/aiPredictionArtifact.ts @@ -20,6 +20,26 @@ type AiPredictionResponseDebugEventInput = { response: unknown } +type AiPredictionPairPromptDebugEventInput = { + targetPair: { + leftBranchName: string + rightBranchName: string + } + prompt: { + systemPrompt: string + userPrompt: string + responseShape?: string + } +} + +type AiPredictionPairResponseDebugEventInput = { + targetPair: { + leftBranchName: string + rightBranchName: string + } + response: unknown +} + export type AiPredictionPromptDebugArtifact = { targetBranches: Array<{ branchName: string @@ -40,6 +60,26 @@ export type AiPredictionResponseDebugArtifact = { response: unknown } +export type AiPredictionPairPromptDebugArtifact = { + targetPair: { + leftBranchName: string + rightBranchName: string + } + prompt: { + systemPrompt: string + userPrompt: string + responseShape?: string + } +} + +export type AiPredictionPairResponseDebugArtifact = { + targetPair: { + leftBranchName: string + rightBranchName: string + } + response: unknown +} + // OpenAI에 전달된 prompt event에서 consumer repository 코드 원문만 metadata로 치환 export function sanitizeAiPredictionPromptDebugEvent( event: AiPredictionPromptDebugEventInput @@ -72,6 +112,38 @@ export function sanitizeAiPredictionResponseDebugEvent( } } +// pair prompt event의 ordered targetPair를 유지하고 코드 원문을 metadata로 치환 +export function sanitizeAiPredictionPairPromptDebugEvent( + event: AiPredictionPairPromptDebugEventInput +): AiPredictionPairPromptDebugArtifact { + return { + targetPair: { + leftBranchName: event.targetPair.leftBranchName, + rightBranchName: event.targetPair.rightBranchName + }, + prompt: { + systemPrompt: event.prompt.systemPrompt, + userPrompt: sanitizedUserPromptFor(event.prompt.userPrompt), + ...(event.prompt.responseShape + ? { responseShape: event.prompt.responseShape } + : {}) + } + } +} + +// pair response event의 ordered targetPair를 유지하고 patch 원문을 metadata로 치환 +export function sanitizeAiPredictionPairResponseDebugEvent( + event: AiPredictionPairResponseDebugEventInput +): AiPredictionPairResponseDebugArtifact { + return { + targetPair: { + leftBranchName: event.targetPair.leftBranchName, + rightBranchName: event.targetPair.rightBranchName + }, + response: sanitizedResponseValueFor(event.response) + } +} + // JSON prompt는 구조를 유지하고 비정형 prompt는 원문 없이 크기와 hash만 기록 function sanitizedUserPromptFor(userPrompt: string): string { try { diff --git a/src/index.ts b/src/index.ts index 677b9a6..f597b07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,15 +75,20 @@ export type { AiPredictionPairBranchMetadata, AiPredictionPairCodeContext, AiPredictionPairCodeContextStatus, + AiPredictionPairDebugObserver, AiPredictionPairEvidencePayload, AiPredictionPairFailedResult, + AiPredictionPairFailureDebugEvent, AiPredictionPairIntegrationOrder, AiPredictionPairMergeStatus, AiPredictionPairPatch, AiPredictionPairPredictedResult, AiPredictionPairPreventiveAction, + AiPredictionPairPromptDebugEvent, AiPredictionPairResponse, + AiPredictionPairResponseDebugEvent, AiPredictionPairResult, + AiPredictionPairRunOptions, AiPredictionPairTargetStatus, AiPredictionPrompt, AiPredictionPromptBuildOptions, diff --git a/tests/ai/predictionPairRunner.test.ts b/tests/ai/predictionPairRunner.test.ts index 7447287..a78bbd2 100644 --- a/tests/ai/predictionPairRunner.test.ts +++ b/tests/ai/predictionPairRunner.test.ts @@ -6,6 +6,10 @@ import { type AiConfirmedConflictResponse, type AiPredictionClient, type AiPredictionPairEvidencePayload, + type AiPredictionPairDebugObserver, + type AiPredictionPairFailureDebugEvent, + type AiPredictionPairPromptDebugEvent, + type AiPredictionPairResponseDebugEvent, type AiPredictionPairTargetStatus, type AiPredictionPrompt, type BranchComparisonPair @@ -89,6 +93,101 @@ test("returns failed result for every pair when provider is unavailable", async assert.equal(client.prompts.length, 2) }) +// pair별 prompt와 response를 ordered targetPair metadata와 함께 통지 +test("notifies pair debug observer with prompt and response", async () => { + const input = payload("feature/a", "feature/b", "potential_overlap") + const observer = new AiPredictionPairDebugObserverSpy() + + const results = await predictBranchPairsWithAi( + [input], + new AiPredictionClientSpy(), + { debugObserver: observer } + ) + + assert.equal(results[0]?.status, "predicted") + assert.deepEqual(observer.promptEvents.map(event => event.targetPair), [input.pair]) + assert.deepEqual(observer.responseEvents.map(event => event.targetPair), [input.pair]) + assert.equal(observer.promptEvents[0]?.prompt.responseShape, "predictionPairCleanOverlap") + assert.deepEqual(observer.responseEvents[0]?.response, cleanOverlapResponse(input.pair)) + assert.deepEqual(observer.failureEvents, []) +}) + +// provider와 response validation 실패를 pair별 failure event로 통지 +test("notifies pair debug observer when prediction fails", async () => { + const providerInput = payload("feature/a", "feature/b", "potential_overlap") + const validationInput = payload("feature/c", "feature/d", "confirmed_conflict") + const observer = new AiPredictionPairDebugObserverSpy() + const client = new AiPredictionClientSpy([ + new Error("provider failed"), + cleanOverlapResponse(validationInput.pair) + ]) + + const results = await predictBranchPairsWithAi( + [providerInput, validationInput], + client, + { debugObserver: observer } + ) + + assert.deepEqual(results.map(result => result.status), ["failed", "failed"]) + assert.deepEqual(observer.failureEvents.map(event => event.targetPair), [ + providerInput.pair, + validationInput.pair + ]) + assert.match(observer.failureEvents[0]?.errorMessage ?? "", /provider failed/) + assert.match( + observer.failureEvents[1]?.errorMessage ?? "", + /must be confirmed_conflict/ + ) +}) + +// debug observer 실패가 provider 결과와 다음 pair 실행을 중단하지 않도록 격리 +test("continues pair prediction when debug observer throws", async () => { + const inputs = [ + payload("feature/a", "feature/b", "potential_overlap"), + payload("feature/c", "feature/d", "confirmed_conflict") + ] + const observer: AiPredictionPairDebugObserver = { + onPromptBuilt: () => { + throw new Error("prompt observer failed") + }, + onResponseReceived: () => { + throw new Error("response observer failed") + }, + onPredictionFailed: () => { + throw new Error("failure observer failed") + } + } + const client = new AiPredictionClientSpy([ + cleanOverlapResponse(inputs[0]!.pair), + new Error("provider failed") + ]) + + const results = await predictBranchPairsWithAi(inputs, client, { + debugObserver: observer + }) + + assert.deepEqual(results.map(result => result.status), ["predicted", "failed"]) + assert.equal(client.prompts.length, 2) +}) + +class AiPredictionPairDebugObserverSpy implements AiPredictionPairDebugObserver { + promptEvents: AiPredictionPairPromptDebugEvent[] = [] + responseEvents: AiPredictionPairResponseDebugEvent[] = [] + failureEvents: AiPredictionPairFailureDebugEvent[] = [] + + onPromptBuilt(event: AiPredictionPairPromptDebugEvent): void { + this.promptEvents.push(event) + } + + onResponseReceived(event: AiPredictionPairResponseDebugEvent): void { + this.responseEvents.push(event) + } + + onPredictionFailed(event: AiPredictionPairFailureDebugEvent): void { + this.failureEvents.push(event) + } +} + class AiPredictionClientSpy implements AiPredictionClient { prompts: AiPredictionPrompt[] = [] private responseIndex = 0 diff --git a/tests/debug/aiPredictionArtifact.test.ts b/tests/debug/aiPredictionArtifact.test.ts index f9730fd..12c347b 100644 --- a/tests/debug/aiPredictionArtifact.test.ts +++ b/tests/debug/aiPredictionArtifact.test.ts @@ -2,6 +2,8 @@ import test from "node:test" import assert from "node:assert/strict" import { createHash } from "node:crypto" import { + sanitizeAiPredictionPairPromptDebugEvent, + sanitizeAiPredictionPairResponseDebugEvent, sanitizeAiPredictionPromptDebugEvent, sanitizeAiPredictionResponseDebugEvent } from "../../src/debug/aiPredictionArtifact.js" @@ -163,3 +165,85 @@ test("replaces AI response patch with metadata", () => { }) assert.doesNotMatch(JSON.stringify(artifact), /consumerSourceMarker/) }) + +// pair prompt artifact에서 ordered targetPair를 유지하고 코드 원문을 제거 +test("sanitizes pair AI prompt debug event", () => { + const content = "const pairSourceMarker = true\n" + const event = { + targetPair: { + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }, + prompt: { + systemPrompt: "Review branch pair", + userPrompt: JSON.stringify({ + snippet: { + filePath: "Sources/Pair.swift", + content, + startLine: 7, + endLine: 7 + } + }), + responseShape: "predictionPairCleanOverlap" + } + } + + const artifact = sanitizeAiPredictionPairPromptDebugEvent(event) + const payload = JSON.parse(artifact.prompt.userPrompt) as { + snippet?: Record + } + + assert.deepEqual(artifact.targetPair, event.targetPair) + assert.deepEqual(payload.snippet, { + filePath: "Sources/Pair.swift", + startLine: 7, + endLine: 7, + contentByteLength: Buffer.byteLength(content, "utf8"), + contentHash: createHash("sha256").update(content, "utf8").digest("hex"), + lineCount: 1 + }) + assert.doesNotMatch(JSON.stringify(artifact), /pairSourceMarker/) + assert.equal(JSON.parse(event.prompt.userPrompt).snippet.content, content) +}) + +// pair response artifact에서 ordered targetPair를 유지하고 patch 원문을 제거 +test("sanitizes pair AI response debug event", () => { + const patch = "@@ -1 +1 @@\n-let pairSourceMarker = false\n+let pairSourceMarker = true" + const event = { + targetPair: { + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }, + response: { + kind: "confirmed_conflict", + patches: [{ + filePath: "Sources/Pair.swift", + patch, + reason: "충돌 상태 갱신" + }] + } + } + + const artifact = sanitizeAiPredictionPairResponseDebugEvent(event) + const response = artifact.response as { + patches?: Array> + } + + assert.deepEqual(artifact.targetPair, event.targetPair) + assert.deepEqual(response.patches?.[0], { + filePath: "Sources/Pair.swift", + patch: { + byteLength: Buffer.byteLength(patch, "utf8"), + lineCount: 3, + hunkRanges: [{ + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1 + }] + }, + reason: "충돌 상태 갱신" + }) + assert.doesNotMatch(JSON.stringify(artifact), /pairSourceMarker/) + assert.equal(event.response.patches[0]?.patch, patch) +}) From c0bbd6d8fac43c411559c5d53d3bb53acf792ff0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:00:14 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20branch=20=EC=A1=B0=ED=95=A9=20workf?= =?UTF-8?q?low=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/debug/aiPredictionArtifact.ts | 39 ++ src/workflows/mergeRiskWatch.ts | 315 +++++++------- tests/debug/aiPredictionArtifact.test.ts | 28 ++ tests/workflows/mergeRiskWatch.test.ts | 496 +++++++++++++---------- 4 files changed, 483 insertions(+), 395 deletions(-) diff --git a/src/debug/aiPredictionArtifact.ts b/src/debug/aiPredictionArtifact.ts index 4e8d772..9ba1589 100644 --- a/src/debug/aiPredictionArtifact.ts +++ b/src/debug/aiPredictionArtifact.ts @@ -40,6 +40,14 @@ type AiPredictionPairResponseDebugEventInput = { response: unknown } +type AiPredictionPairFailureDebugEventInput = { + targetPair: { + leftBranchName: string + rightBranchName: string + } + errorMessage: string +} + export type AiPredictionPromptDebugArtifact = { targetBranches: Array<{ branchName: string @@ -80,6 +88,18 @@ export type AiPredictionPairResponseDebugArtifact = { response: unknown } +export type AiPredictionPairFailureDebugArtifact = { + targetPair: { + leftBranchName: string + rightBranchName: string + } + error: { + messageByteLength: number + messageHash: string + redacted: true + } +} + // OpenAI에 전달된 prompt event에서 consumer repository 코드 원문만 metadata로 치환 export function sanitizeAiPredictionPromptDebugEvent( event: AiPredictionPromptDebugEventInput @@ -144,6 +164,25 @@ export function sanitizeAiPredictionPairResponseDebugEvent( } } +// pair 실패 원문을 재현할 수 없는 크기와 hash metadata로 치환 +export function sanitizeAiPredictionPairFailureDebugEvent( + event: AiPredictionPairFailureDebugEventInput +): AiPredictionPairFailureDebugArtifact { + return { + targetPair: { + leftBranchName: event.targetPair.leftBranchName, + rightBranchName: event.targetPair.rightBranchName + }, + error: { + messageByteLength: Buffer.byteLength(event.errorMessage, "utf8"), + messageHash: createHash("sha256") + .update(event.errorMessage, "utf8") + .digest("hex"), + redacted: true + } + } +} + // JSON prompt는 구조를 유지하고 비정형 prompt는 원문 없이 크기와 hash만 기록 function sanitizedUserPromptFor(userPrompt: string): string { try { diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index f215b23..2aa817d 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -3,29 +3,31 @@ 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 { + ACTIVE_BRANCH_WINDOW_DAYS, + 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 { 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" -import { select as selectAiPredictionTargets } from "../ai/predictionTargetSelector.js" -import { build as buildMergeRiskReport } from "../reports/reportBuilder.js" -import { format as formatMergeRiskReportMarkdown } from "../reports/markdownFormatter.js" +import { build as buildAiPredictionPairRequests } from "../ai/predictionPairRequestBuilder.js" +import { predict as predictBranchPairsWithAi } from "../ai/predictionPairRunner.js" +import { build as buildBranchPairMergeRiskReport } from "../reports/branchPairReportBuilder.js" +import { format as formatBranchPairMergeRiskReportMarkdown } from "../reports/branchPairMarkdownFormatter.js" import { send as sendMergeRiskReport } from "../reportChannels/reportChannel.js" import { - sanitizeAiPredictionPromptDebugEvent, - sanitizeAiPredictionResponseDebugEvent + sanitizeAiPredictionPairFailureDebugEvent, + sanitizeAiPredictionPairPromptDebugEvent, + sanitizeAiPredictionPairResponseDebugEvent } from "../debug/aiPredictionArtifact.js" import { writerFor as debugArtifactWriterFor } from "../debug/debugArtifact.js" import type { - AiPredictionEvidencePayload + AiPredictionPairEvidencePayload, + AiPredictionPairResult } from "../ai/types.js" import type { BranchCheckMetadata, @@ -33,8 +35,7 @@ import type { RepositoryBranch } from "../branches/types.js" import type { - BranchChangedHunk, - BranchRiskAnalysisInput + BranchConflictGraphEdge } from "../risks/types.js" const execFileAsync = promisify(execFile) @@ -113,7 +114,7 @@ export async function runFromEnvironment(): Promise { await run(optionsFromEnvironment()) } -// local git checkout에서 branch signal을 수집하고 report channel로 전송 +// local git checkout에서 branch 조합을 분석하고 report channel로 전송 export async function run(options: MergeRiskWatchOptions): Promise { const generatedAt = new Date() const debugArtifactWriter = debugArtifactWriterFor(options.debugArtifactDir) @@ -150,11 +151,12 @@ export async function run(options: MergeRiskWatchOptions): Promise { codeContextResults ) const graph = buildBranchConflictGraph(options.baseBranch, branches, edges) - const pairResultByKey = new Map(pairResults.map(result => [ - branchPairKey(result.pair.leftBranchName, result.pair.rightBranchName), - result - ])) - const inputs: BranchRiskAnalysisInput[] = [] + const payloads = buildAiPredictionPairRequests( + graph.edges, + pairResults, + codeContextResults + ) + const targetPairKeys = new Set(payloads.map(payload => pairKeyFor(payload.pair))) await debugArtifactWriter?.writeJson("branch-selection.json", { repositoryBranches, @@ -164,93 +166,86 @@ export async function run(options: MergeRiskWatchOptions): Promise { await debugArtifactWriter?.writeJson("branch-pairs.json", { pairs }) + await debugArtifactWriter?.writeJson("deterministic-evidence.json", { + pairResults, + graph + }) + await debugArtifactWriter?.writeJson("ai-target-selection.json", { + targetPairs: payloads.map(aiTargetArtifactFor), + skippedPairs: graph.edges + .filter(edge => !targetPairKeys.has(pairKeyFor(edge.pair))) + .map(aiSkippedArtifactFor) + }) - for (const branch of branches) { - const pairResult = pairResultByKey.get(branchPairKey( - options.baseBranch, - branch.name - )) - const gitSignal = await collectGitMergeSignalFromPairResult(branch, pairResult, { - repositoryPath: options.repositoryPath, - remoteName: options.remoteName - }) - const changedHunks = gitSignal.mergeBaseSha - ? await collectChangedHunks({ - repositoryPath: options.repositoryPath, - remoteName: options.remoteName, - branchName: branch.name, - mergeBaseSha: gitSignal.mergeBaseSha - }) - : [] - - inputs.push({ - branch, - gitSignal, - changedHunks + const promptEvents: Array> = [] + const responseEvents: Array> = [] + const failureEvents: Array> = [] + const predictions = payloads.length === 0 + ? [] + : await predictBranchPairsWithAi( + payloads, + createDefaultAiPredictionClient(), + { + debugObserver: debugArtifactWriter + ? { + // 생성된 pair prompt에서 코드 원문을 제거한 표현만 누적 + onPromptBuilt: event => { + promptEvents.push(sanitizeAiPredictionPairPromptDebugEvent(event)) + }, + // 받은 pair response에서 patch 원문을 제거한 표현만 누적 + onResponseReceived: event => { + responseEvents.push(sanitizeAiPredictionPairResponseDebugEvent(event)) + }, + // pair prediction 실패 원문을 제거한 표현만 누적 + onPredictionFailed: event => { + failureEvents.push( + sanitizeAiPredictionPairFailureDebugEvent(event) + ) + } + } + : undefined + } + ) + + if (debugArtifactWriter && 0 < promptEvents.length) { + await debugArtifactWriter.writeJson("ai-prompt.json", { + events: promptEvents }) } - const risks = analyzeGraph(graph, inputs, { - criticalFilePatterns: options.criticalFilePatterns - }) - const evidencePayloads = inputs.map((input, index) => - buildAiPredictionEvidencePayload(input, risks[index]!) - ) - const aiTargets = selectAiPredictionTargets(evidencePayloads) - const aiTargetSet = new Set(aiTargets) + if (debugArtifactWriter && 0 < responseEvents.length) { + await debugArtifactWriter.writeJson("ai-response.json", { + events: responseEvents + }) + } - await debugArtifactWriter?.writeJson("deterministic-evidence.json", { - inputs, - risks, - evidencePayloads - }) - await debugArtifactWriter?.writeJson("ai-target-selection.json", { - targetBranches: aiTargets.map(aiDebugTargetFor), - skippedBranches: evidencePayloads - .filter(payload => !aiTargetSet.has(payload)) - .map(payload => ({ - ...aiDebugTargetFor(payload), - reason: aiSkippedReasonFor(payload) - })) - }) + if (debugArtifactWriter && 0 < failureEvents.length) { + await debugArtifactWriter.writeJson("ai-error.json", { + events: failureEvents + }) + } - const predictions = await predictMergeRisksWithAi( - evidencePayloads, - createDefaultAiPredictionClient(), - { - debugObserver: debugArtifactWriter - ? { - // 생성된 AI prompt에서 코드 원문을 제거한 표현만 debug artifact로 기록 - onPromptBuilt: event => debugArtifactWriter.writeJson( - "ai-prompt.json", - sanitizeAiPredictionPromptDebugEvent(event) - ), - // 받은 AI response에서 제안 patch 원문을 제거한 표현만 debug artifact로 기록 - onResponseReceived: event => debugArtifactWriter.writeJson( - "ai-response.json", - sanitizeAiPredictionResponseDebugEvent(event) - ), - // AI prediction 실패 정보를 debug artifact로 기록 - onPredictionFailed: event => debugArtifactWriter.writeJson("ai-error.json", event) - } - : undefined - } - ) await debugArtifactWriter?.writeJson("ai-result.json", { - predictions + predictions: predictions.map(aiResultArtifactFor) }) - const report = buildMergeRiskReport(risks.map((risk, index) => ({ - risk, - branch: inputs[index]!.branch, - aiPrediction: predictions[index] - })), options.baseBranch, { - generatedAt + const report = buildBranchPairMergeRiskReport({ + generatedAt, + activeBranchWindowDays: ACTIVE_BRANCH_WINDOW_DAYS, + discoveredBranchCount: repositoryBranches.length, + watchedBranches: branches, + excludedBranches: branchSelection.excluded, + graph, + mergeResults: pairResults, + aiResults: predictions }) - const markdown = formatMergeRiskReportMarkdown(report) - - await debugArtifactWriter?.writeText("report.md", markdown) - + const markdown = formatBranchPairMergeRiskReportMarkdown(report) const result = await sendMergeRiskReport({ markdown }) @@ -260,29 +255,62 @@ export async function run(options: MergeRiskWatchOptions): Promise { } } -// 방향과 무관하게 base 포함 pair 결과를 찾을 식별자를 구성 -function branchPairKey(branchName: string, otherBranchName: string): string { - return branchName < otherBranchName - ? `${branchName}\u0000${otherBranchName}` - : `${otherBranchName}\u0000${branchName}` +// AI 대상 artifact에 ordered pair와 deterministic 상태만 기록 +function aiTargetArtifactFor(payload: AiPredictionPairEvidencePayload): { + pair: AiPredictionPairEvidencePayload["pair"] + status: AiPredictionPairEvidencePayload["targetStatus"] +} { + return { + pair: payload.pair, + status: payload.targetStatus + } } -// AI 대상 선택 artifact에 기록할 branch 식별 정보만 추출 -function aiDebugTargetFor(payload: AiPredictionEvidencePayload): { - branchName: string - baseBranch: string +// AI 대상이 아닌 edge의 상태와 reason code만 기록 +function aiSkippedArtifactFor(edge: BranchConflictGraphEdge): { + pair: BranchConflictGraphEdge["pair"] + status: BranchConflictGraphEdge["status"] + reasons: Array + reason: "not_target" } { return { - branchName: payload.branch.name, - baseBranch: payload.branch.baseBranch + pair: edge.pair, + status: edge.status, + reasons: edge.reasons.map(reason => reason.code), + reason: "not_target" } } -// AI prediction 제외 branch의 deterministic 제외 사유를 분류 -function aiSkippedReasonFor(payload: AiPredictionEvidencePayload): "not_target" | "confirmed_conflict" { - return payload.possibility.reasons.some(reason => reason.code === "confirmed_conflict") - ? "confirmed_conflict" - : "not_target" +// validated pair 결과에서 patch와 실패 원문을 제거한 artifact를 구성 +function aiResultArtifactFor(result: AiPredictionPairResult): unknown { + if (result.status === "failed") { + const failure = sanitizeAiPredictionPairFailureDebugEvent({ + targetPair: result.pair, + errorMessage: result.errorMessage + }) + + return { + status: result.status, + pair: result.pair, + error: failure.error + } + } + + return { + ...result, + response: sanitizeAiPredictionPairResponseDebugEvent({ + targetPair: result.pair, + response: result.response + }).response + } +} + +// 좌우 branch 순서를 보존하는 artifact 선택 key를 구성 +function pairKeyFor(pair: { + leftBranchName: string + rightBranchName: string +}): string { + return `${pair.leftBranchName}\u0000${pair.rightBranchName}` } // remote tracking branch 목록을 BranchSource로 제공 @@ -389,51 +417,6 @@ function warningMessageFor(error: unknown): string { return error instanceof Error ? error.message : String(error) } -// git diff hunk header를 BranchChangedHunk 목록으로 변환 -async function collectChangedHunks(input: { - repositoryPath: string - remoteName: string - branchName: string - mergeBaseSha: string -}): Promise { - const diff = await gitOutput(input.repositoryPath, [ - "diff", - "--unified=0", - input.mergeBaseSha, - `refs/remotes/${input.remoteName}/${input.branchName}` - ]) - const hunks: BranchChangedHunk[] = [] - let filePath: string | undefined - - for (const line of diff.split("\n")) { - if (line.startsWith("diff --git ")) { - filePath = undefined - continue - } - - if (line.startsWith("+++ b/")) { - filePath = line.slice("+++ b/".length) - continue - } - - if (line === "+++ /dev/null") { - filePath = undefined - continue - } - - if (!filePath || !line.startsWith("@@ ")) { - continue - } - - const hunk = changedHunkFrom(line, filePath) - if (hunk) { - hunks.push(hunk) - } - } - - return hunks -} - // raw git line을 remote branch metadata로 변환 function remoteBranchLineFrom(line: string): RemoteBranchLine { const [ref = "", sha = "", author, rawUpdatedAt] = line.split("\t") @@ -453,24 +436,6 @@ function branchNameFrom(ref: string, remoteName: string): string { return ref.startsWith(prefix) ? ref.slice(prefix.length) : ref } -// git diff hunk header의 새 파일 line range를 추출 -function changedHunkFrom(line: string, filePath: string): BranchChangedHunk | undefined { - const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/) - if (!match) { - return undefined - } - - const startLine = Number(match[1]) - const lineCount = Number(match[2] ?? "1") - const endLine = startLine + Math.max(lineCount, 1) - 1 - - return { - filePath, - startLine, - endLine - } -} - // owner/repo 형식의 repository 입력을 REST API path segment로 분리 function repositoryPartsFrom(repository: string): [string, string] { const [owner, repo] = repository.split("/") diff --git a/tests/debug/aiPredictionArtifact.test.ts b/tests/debug/aiPredictionArtifact.test.ts index 12c347b..a817dd1 100644 --- a/tests/debug/aiPredictionArtifact.test.ts +++ b/tests/debug/aiPredictionArtifact.test.ts @@ -2,6 +2,7 @@ import test from "node:test" import assert from "node:assert/strict" import { createHash } from "node:crypto" import { + sanitizeAiPredictionPairFailureDebugEvent, sanitizeAiPredictionPairPromptDebugEvent, sanitizeAiPredictionPairResponseDebugEvent, sanitizeAiPredictionPromptDebugEvent, @@ -247,3 +248,30 @@ test("sanitizes pair AI response debug event", () => { assert.doesNotMatch(JSON.stringify(artifact), /pairSourceMarker/) assert.equal(event.response.patches[0]?.patch, patch) }) + +// pair AI 실패 원문을 저장하지 않고 metadata만 유지하는지 확인 +test("redacts pair AI failure error message", () => { + const errorMessage = "OpenAI failure echoed RAW_PATCH_MARKER" + const artifact = sanitizeAiPredictionPairFailureDebugEvent({ + targetPair: { + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }, + errorMessage + }) + + assert.deepEqual(artifact, { + targetPair: { + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }, + error: { + messageByteLength: Buffer.byteLength(errorMessage, "utf8"), + messageHash: createHash("sha256") + .update(errorMessage, "utf8") + .digest("hex"), + redacted: true + } + }) + assert.doesNotMatch(JSON.stringify(artifact), /RAW_PATCH_MARKER/) +}) diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index 9e3cfcd..f0a71de 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -67,8 +67,8 @@ test("omits empty optional environment values", () => { }) }) -// debug directory가 설정되면 workflow 실행 중 consumer artifact용 파일을 생성하는지 확인 -test("writes merge risk debug artifacts", async () => { +// debug directory가 설정되면 pair 분석 artifact를 원문 없이 생성하는지 확인 +test("writes pair merge risk debug artifacts", async () => { const fixture = await createWorkflowGitFixture() const originalFetch = globalThis.fetch const originalOpenAiApiKey = process.env.OPENAI_API_KEY @@ -82,9 +82,7 @@ test("writes merge risk debug artifacts", async () => { const request = new Request(input, init) if (request.url === "https://discord.test/webhook-secret") { - return new Response(null, { - status: 204 - }) + return new Response(null, { status: 204 }) } openAiRequestCount += 1 @@ -101,32 +99,21 @@ test("writes merge risk debug artifacts", async () => { ?.content return jsonResponse({ - output_text: JSON.stringify({ - predictions: [{ - branchName: "feature/critical", - baseBranch: "main", - prediction: "critical file update needs review", - recommendedActions: [] - }, { - branchName: "feature/critical-peer", - baseBranch: "main", - prediction: "critical file overlap needs review", - recommendedActions: [] - }] - }) + output_text: JSON.stringify(cleanOverlapResponse({ + leftBranchName: "feature/critical", + rightBranchName: "feature/critical-peer" + })) }) } try { await run({ - repository: "opficdev/Watcher", + ...baseOptions(), + githubToken: undefined, repositoryPath: fixture.repositoryPath, baseBranch: "main", - criticalFilePatterns: ["critical.txt"], - remoteName: "origin", - githubApiUrl: "https://api.github.test", debugArtifactDir: fixture.debugArtifactDir, - workflowRef: "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@feat/#35-artifact" + workflowRef: "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@feat/#49" }) const files = (await readdir(fixture.debugArtifactDir)).sort() @@ -138,133 +125,91 @@ test("writes merge risk debug artifacts", async () => { "branch-pairs.json", "branch-selection.json", "deterministic-evidence.json", - "report.md", "run.json" ]) - const runArtifact = await readJson<{ - repository?: string - baseBranch?: string - workflowRef?: string - }>(fixture.debugArtifactDir, "run.json") - const aiResultArtifact = await readJson<{ - predictions?: Array<{ - status?: string - branchName?: string - }> - }>(fixture.debugArtifactDir, "ai-result.json") const aiPromptArtifact = await readJson<{ - prompt?: { - userPrompt?: string - } + events?: Array<{ + targetPair?: { + leftBranchName?: string + rightBranchName?: string + } + prompt?: { + userPrompt?: string + } + }> }>(fixture.debugArtifactDir, "ai-prompt.json") - const aiResponseArtifact = await readJson<{ - response?: { - predictions?: Array<{ - prediction?: string - }> - } - }>(fixture.debugArtifactDir, "ai-response.json") const deterministicArtifact = await readJson<{ - risks?: Array<{ - branchName?: string - status?: string - reasons?: Array<{ - code?: string - branches?: string[] + graph?: { + edges?: Array<{ + pair?: { + leftBranchName?: string + rightBranchName?: string + } + status?: string + reasons?: Array<{ + code?: string + }> }> - }> + } }>(fixture.debugArtifactDir, "deterministic-evidence.json") - const branchSelectionArtifact = await readJson<{ - selectedBranches?: Array<{ - name?: string - updatedAt?: string - }> - excludedBranches?: Array<{ - name?: string - reason?: string + const targetArtifact = await readJson<{ + targetPairs?: Array<{ + pair?: { + leftBranchName?: string + rightBranchName?: string + } + status?: string }> - }>(fixture.debugArtifactDir, "branch-selection.json") - const branchPairsArtifact = await readJson<{ - pairs?: Array<{ - leftBranchName?: string - rightBranchName?: string + skippedPairs?: unknown[] + }>(fixture.debugArtifactDir, "ai-target-selection.json") + const resultArtifact = await readJson<{ + predictions?: Array<{ + status?: string + pair?: { + leftBranchName?: string + rightBranchName?: string + } }> - }>(fixture.debugArtifactDir, "branch-pairs.json") + }>(fixture.debugArtifactDir, "ai-result.json") const combinedArtifact = (await Promise.all(files.map(file => readFile(join(fixture.debugArtifactDir, file), "utf8") ))).join("\n") assert.equal(openAiRequestCount, 1) assert.ok(openAiUserPrompt) - assert.ok(aiPromptArtifact.prompt) - assert.ok(aiPromptArtifact.prompt.userPrompt) - assert.equal(aiPromptArtifact.prompt.userPrompt, openAiUserPrompt) - assert.equal( - aiResponseArtifact.response?.predictions?.[0]?.prediction, - "critical file update needs review" - ) - assert.equal(runArtifact.repository, "opficdev/Watcher") - assert.equal(runArtifact.baseBranch, "main") - assert.equal( - runArtifact.workflowRef, - "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@feat/#35-artifact" + assert.equal(aiPromptArtifact.events?.length, 1) + assert.notEqual(aiPromptArtifact.events?.[0]?.prompt?.userPrompt, openAiUserPrompt) + assert.deepEqual(aiPromptArtifact.events?.[0]?.targetPair, { + leftBranchName: "feature/critical", + rightBranchName: "feature/critical-peer" + }) + const potentialEdge = deterministicArtifact.graph?.edges?.find(edge => + edge.pair?.leftBranchName === "feature/critical" && + edge.pair.rightBranchName === "feature/critical-peer" ) - assert.equal(deterministicArtifact.risks?.[0]?.status, "critical") - assert.deepEqual(deterministicArtifact.risks?.map(risk => - risk.reasons?.map(reason => reason.code) - ), [[ + assert.equal(potentialEdge?.status, "potential_overlap") + assert.deepEqual(potentialEdge?.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), [ - "feature/critical", - "feature/critical-peer" - ]) - assert.deepEqual(branchSelectionArtifact.selectedBranches?.map(branch => branch.updatedAt), [ - fixture.committerDate, - fixture.committerDate + "same_file_overlap" ]) - assert.deepEqual(branchSelectionArtifact.excludedBranches?.map(branch => ({ - name: branch.name, - reason: branch.reason - })), [{ - name: "main", - reason: "base_branch" + assert.deepEqual(targetArtifact.targetPairs, [{ + pair: { + leftBranchName: "feature/critical", + rightBranchName: "feature/critical-peer" + }, + status: "potential_overlap" }]) - assert.deepEqual(branchPairsArtifact.pairs, [{ + assert.equal(targetArtifact.skippedPairs?.length, 2) + assert.equal(resultArtifact.predictions?.[0]?.status, "predicted") + assert.deepEqual(resultArtifact.predictions?.[0]?.pair, { leftBranchName: "feature/critical", rightBranchName: "feature/critical-peer" - }, { - leftBranchName: "feature/critical", - rightBranchName: "main" - }, { - leftBranchName: "feature/critical-peer", - rightBranchName: "main" - }]) + }) assert.equal(await git(fixture.repositoryPath, ["status", "--porcelain=v1"]), "") - assert.equal( - (await git(fixture.repositoryPath, ["worktree", "list", "--porcelain"])) - .split("\n") - .filter(line => line.startsWith("worktree ")) - .length, - 1 - ) assert.doesNotMatch(combinedArtifact, /openai-secret/) assert.doesNotMatch(combinedArtifact, /webhook-secret/) assert.doesNotMatch(combinedArtifact, /feature critical content/) - assert.doesNotMatch(combinedArtifact, /peer critical content/) } finally { globalThis.fetch = originalFetch restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) @@ -273,17 +218,125 @@ test("writes merge risk debug artifacts", async () => { } }) -// 활성 branch 사이의 충돌 확정 관계를 양쪽 branch 위험에 반영하는지 확인 -test("uses active branch conflicts for both branch risks", async () => { +// 확정 conflict 조합을 한 번만 AI로 분석하고 patch 원문은 artifact에서 제거하는지 확인 +test("predicts confirmed conflict pair once", 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 + let openAiRequestCount = 0 + let discordReport = "" + + process.env.OPENAI_API_KEY = "openai-secret" + process.env.DISCORD_WEBHOOK_URL = "https://discord.test/webhook-secret" + globalThis.fetch = async (input, init) => { + const request = new Request(input, init) + + if (request.url === "https://discord.test/webhook-secret") { + discordReport += String((await request.json() as { content?: string }).content ?? "") + return new Response(null, { status: 204 }) + } + + openAiRequestCount += 1 + return jsonResponse({ + output_text: JSON.stringify(confirmedConflictResponse({ + leftBranchName: "feature/critical", + rightBranchName: "feature/critical-peer" + })) + }) + } + + try { + await run({ + ...baseOptions(), + githubToken: undefined, + repositoryPath: fixture.repositoryPath, + baseBranch: "main", + debugArtifactDir: fixture.debugArtifactDir + }) + + const resultArtifact = await readFile( + join(fixture.debugArtifactDir, "ai-result.json"), + "utf8" + ) + + assert.equal(openAiRequestCount, 1) + assert.match(discordReport, /resolved critical content/) + assert.doesNotMatch(resultArtifact, /resolved critical content/) + assert.equal((await readdir(fixture.debugArtifactDir)).includes("report.md"), false) + } finally { + globalThis.fetch = originalFetch + restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) + restoreEnv("DISCORD_WEBHOOK_URL", originalDiscordWebhookUrl) + await fixture.remove() + } +}) + +// provider 오류 원문이 실패 artifact에 기록되지 않는지 확인 +test("redacts provider error detail from debug artifacts", async () => { + const fixture = await createWorkflowGitFixture() + const originalFetch = globalThis.fetch + const originalOpenAiApiKey = process.env.OPENAI_API_KEY + const originalDiscordWebhookUrl = process.env.DISCORD_WEBHOOK_URL + const marker = "RAW_PATCH_MARKER" process.env.OPENAI_API_KEY = "openai-secret" process.env.DISCORD_WEBHOOK_URL = "https://discord.test/webhook-secret" + globalThis.fetch = async input => { + const request = new Request(input) + + if (request.url === "https://discord.test/webhook-secret") { + return new Response(null, { status: 204 }) + } + + return new Response(JSON.stringify({ + error: { + message: marker + } + }), { status: 500 }) + } + + try { + await run({ + ...baseOptions(), + githubToken: undefined, + repositoryPath: fixture.repositoryPath, + baseBranch: "main", + debugArtifactDir: fixture.debugArtifactDir + }) + + const errorArtifact = await readFile( + join(fixture.debugArtifactDir, "ai-error.json"), + "utf8" + ) + const resultArtifact = await readFile( + join(fixture.debugArtifactDir, "ai-result.json"), + "utf8" + ) + + assert.doesNotMatch(errorArtifact, new RegExp(marker)) + assert.doesNotMatch(resultArtifact, new RegExp(marker)) + } finally { + globalThis.fetch = originalFetch + restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) + restoreEnv("DISCORD_WEBHOOK_URL", originalDiscordWebhookUrl) + await fixture.remove() + } +}) + +// same-file-only와 clean 조합은 API key 없이도 AI 호출을 생략하는지 확인 +test("skips AI calls for same-file-only and clean pairs", async () => { + const fixture = await createWorkflowGitFixture({ + separateHunks: true + }) + const originalFetch = globalThis.fetch + const originalOpenAiApiKey = process.env.OPENAI_API_KEY + const originalDiscordWebhookUrl = process.env.DISCORD_WEBHOOK_URL + + delete process.env.OPENAI_API_KEY + 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 }) @@ -291,86 +344,27 @@ test("uses active branch conflicts for both branch risks", async () => { try { await run({ - repository: "opficdev/Watcher", + ...baseOptions(), + githubToken: undefined, 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 + const artifact = await readJson<{ + targetPairs?: unknown[] + skippedPairs?: Array<{ 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 + reasons?: 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" - }]) + assert.deepEqual(artifact.targetPairs, []) + assert.equal(artifact.skippedPairs?.length, 3) + assert.equal(artifact.skippedPairs?.some(pair => + pair.status === "potential_overlap" && + pair.reasons?.includes("same_file_overlap") + ), true) } finally { globalThis.fetch = originalFetch restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) @@ -379,14 +373,14 @@ test("uses active branch conflicts for both branch risks", async () => { } }) -// fetch 실패 시 오래된 remote ref에서 changed file과 hunk를 다시 만들지 않는지 확인 -test("keeps deterministic evidence empty after pair fetch failure", async () => { +// fetch 실패 조합은 API key 없이 error로 유지하고 AI 호출을 만들지 않는지 확인 +test("keeps pair merge errors without AI calls", async () => { const fixture = await createWorkflowGitFixture() const originalOpenAiApiKey = process.env.OPENAI_API_KEY const originalDiscordWebhookUrl = process.env.DISCORD_WEBHOOK_URL const originalFetch = globalThis.fetch - process.env.OPENAI_API_KEY = "openai-secret" + delete process.env.OPENAI_API_KEY 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") @@ -401,33 +395,33 @@ test("keeps deterministic evidence empty after pair fetch failure", async () => join(fixture.repositoryPath, "missing-remote.git") ]) await run({ - repository: "opficdev/Watcher", + ...baseOptions(), + githubToken: undefined, repositoryPath: fixture.repositoryPath, baseBranch: "main", - criticalFilePatterns: [], - remoteName: "origin", - githubApiUrl: "https://api.github.test", debugArtifactDir: fixture.debugArtifactDir }) const artifact = await readJson<{ - inputs?: Array<{ - gitSignal?: { - status?: string - mergeBaseSha?: string - changedFiles?: string[] - } - changedHunks?: unknown[] + pairResults?: Array<{ + status?: string }> + graph?: { + edges?: Array<{ + status?: string + }> + } }>(fixture.debugArtifactDir, "deterministic-evidence.json") + const targetArtifact = await readJson<{ + targetPairs?: unknown[] + }>(fixture.debugArtifactDir, "ai-target-selection.json") - assert.equal(artifact.inputs?.length, 2) - assert.equal(artifact.inputs?.every(input => - input.gitSignal?.status === "merge_check_failed" && - input.gitSignal.mergeBaseSha === undefined && - input.gitSignal.changedFiles?.length === 0 && - input.changedHunks?.length === 0 + assert.equal(artifact.pairResults?.length, 3) + assert.equal(artifact.pairResults?.every(result => + result.status === "merge_check_failed" ), true) + assert.equal(artifact.graph?.edges?.every(edge => edge.status === "error"), true) + assert.deepEqual(targetArtifact.targetPairs, []) } finally { globalThis.fetch = originalFetch restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) @@ -573,6 +567,7 @@ function jsonResponse(body: unknown): Response { async function createWorkflowGitFixture(options: { peerContent?: string + separateHunks?: boolean } = {}): Promise<{ repositoryPath: string debugArtifactDir: string @@ -588,24 +583,33 @@ async function createWorkflowGitFixture(options: { GIT_AUTHOR_DATE: "2000-01-01T00:00:00.000Z", GIT_COMMITTER_DATE: committerDate } + const baseContent = options.separateHunks + ? "first line\nshared line\nthird line\n" + : "base content\n" + const featureContent = options.separateHunks + ? "feature line\nshared line\nthird line\n" + : "feature critical content\n" + const peerContent = options.separateHunks + ? "first line\nshared line\npeer line\n" + : options.peerContent ?? "feature critical content\n" await git(root, ["init", "--initial-branch=main", repositoryPath]) await git(repositoryPath, ["config", "user.email", "opfic@example.com"]) await git(repositoryPath, ["config", "user.name", "opfic"]) - await writeFile(join(repositoryPath, "critical.txt"), "base content\n") + await writeFile(join(repositoryPath, "critical.txt"), baseContent) await git(repositoryPath, ["add", "critical.txt"]) await git(repositoryPath, ["commit", "-m", "initial"]) await git(repositoryPath, ["checkout", "-b", "feature/critical"]) - await writeFile(join(repositoryPath, "critical.txt"), "feature critical content\n") + await writeFile(join(repositoryPath, "critical.txt"), featureContent) await git(repositoryPath, ["commit", "-am", "feature critical"], commitDates) await git(repositoryPath, ["checkout", "main"]) await git(repositoryPath, ["checkout", "-b", "feature/critical-peer"]) await writeFile( join(repositoryPath, "critical.txt"), - options.peerContent ?? "feature critical content\n" + peerContent ) await git(repositoryPath, ["commit", "-am", "feature critical peer"], commitDates) @@ -626,6 +630,58 @@ async function createWorkflowGitFixture(options: { } } +function cleanOverlapResponse(pair: { + leftBranchName: string + rightBranchName: string +}) { + return { + kind: "clean_overlap", + pair, + overlapCause: { + summary: "같은 변경 범위를 수정함", + files: ["critical.txt"] + }, + integrationOrder: { + strategy: "merge", + firstBranchName: pair.leftBranchName, + secondBranchName: pair.rightBranchName, + reason: "먼저 반영한 변경을 기준으로 확인함", + steps: ["첫 branch merge", "두 번째 branch 갱신"] + }, + preventiveActions: [{ + title: "통합 동작 확인", + description: "두 변경이 함께 동작하는지 확인함", + files: ["critical.txt"] + }] + } +} + +function confirmedConflictResponse(pair: { + leftBranchName: string + rightBranchName: string +}) { + return { + kind: "confirmed_conflict", + pair, + conflictCause: { + summary: "같은 줄을 다르게 수정함", + files: ["critical.txt"] + }, + integrationOrder: { + strategy: "rebase", + firstBranchName: pair.leftBranchName, + secondBranchName: pair.rightBranchName, + reason: "첫 변경을 기준으로 충돌을 해결함", + steps: ["첫 branch 반영", "두 번째 branch rebase"] + }, + patches: [{ + filePath: "critical.txt", + patch: "@@ -1 +1 @@\n-old\n+resolved critical content", + reason: "두 변경을 하나의 결과로 정리함" + }] + } +} + async function readJson(directory: string, file: string): Promise { return JSON.parse(await readFile(join(directory, file), "utf8")) as T } From f032d715d4ebf90477bd85ee439dcb6338713472 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:15:13 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20branch=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EA=B2=BD=EB=A1=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/evidenceBuilder.ts | 28 - src/ai/openAiPredictionClient.ts | 79 +-- src/ai/predictionPromptBuilder.ts | 53 -- src/ai/predictionResponseValidator.ts | 115 ----- src/ai/predictionRunner.ts | 179 ------- src/ai/predictionTargetSelector.ts | 17 - src/ai/promptTemplates.ts | 48 -- src/ai/types.ts | 100 +--- src/debug/aiPredictionArtifact.ts | 80 +-- src/git/gitMergeSignalCollector.ts | 183 ------- src/git/types.ts | 16 - src/index.ts | 56 +- src/reports/markdownFormatter.ts | 188 ------- src/reports/reportBuilder.ts | 104 ---- src/reports/types.ts | 45 -- src/risks/riskAnalyzer.ts | 505 ------------------- src/risks/types.ts | 62 +-- tests/ai/evidenceBuilder.test.ts | 92 ---- tests/ai/openAiPredictionClient.test.ts | 104 +--- tests/ai/predictionPromptBuilder.test.ts | 142 ------ tests/ai/predictionResponseValidator.test.ts | 143 ------ tests/ai/predictionRunner.test.ts | 370 -------------- tests/ai/predictionTargetSelector.test.ts | 102 ---- tests/ai/types.test.ts | 78 --- tests/debug/aiPredictionArtifact.test.ts | 191 ++----- tests/git/gitMergeSignalCollector.test.ts | 201 -------- tests/reports/markdownFormatter.test.ts | 228 --------- tests/reports/reportBuilder.test.ts | 230 --------- tests/risks/riskAnalyzer.test.ts | 346 ------------- 29 files changed, 82 insertions(+), 4003 deletions(-) delete mode 100644 src/ai/evidenceBuilder.ts delete mode 100644 src/ai/predictionPromptBuilder.ts delete mode 100644 src/ai/predictionResponseValidator.ts delete mode 100644 src/ai/predictionRunner.ts delete mode 100644 src/ai/predictionTargetSelector.ts delete mode 100644 src/ai/promptTemplates.ts delete mode 100644 src/git/gitMergeSignalCollector.ts delete mode 100644 src/reports/markdownFormatter.ts delete mode 100644 src/reports/reportBuilder.ts delete mode 100644 src/reports/types.ts delete mode 100644 src/risks/riskAnalyzer.ts delete mode 100644 tests/ai/evidenceBuilder.test.ts delete mode 100644 tests/ai/predictionPromptBuilder.test.ts delete mode 100644 tests/ai/predictionResponseValidator.test.ts delete mode 100644 tests/ai/predictionRunner.test.ts delete mode 100644 tests/ai/predictionTargetSelector.test.ts delete mode 100644 tests/ai/types.test.ts delete mode 100644 tests/git/gitMergeSignalCollector.test.ts delete mode 100644 tests/reports/markdownFormatter.test.ts delete mode 100644 tests/reports/reportBuilder.test.ts delete mode 100644 tests/risks/riskAnalyzer.test.ts diff --git a/src/ai/evidenceBuilder.ts b/src/ai/evidenceBuilder.ts deleted file mode 100644 index 2ef7e86..0000000 --- a/src/ai/evidenceBuilder.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { BranchRisk, BranchRiskAnalysisInput } from "../risks/types.js" -import type { AiPredictionEvidencePayload } from "./types.js" - -// deterministic possibility와 원본 분석 입력을 AI prediction용 evidence payload로 결합 -export function build( - input: BranchRiskAnalysisInput, - possibility: BranchRisk -): AiPredictionEvidencePayload { - assertMatchingBranch(input, possibility) - - return { - branch: input.branch, - possibility, - gitSignal: input.gitSignal, - changedHunks: input.changedHunks ?? [] - } -} - -// 다른 branch의 possibility가 섞이면 AI prediction 근거가 오염되므로 명시적으로 차단 -function assertMatchingBranch( - input: BranchRiskAnalysisInput, - possibility: BranchRisk -): void { - if (input.branch.name !== possibility.branchName || - input.branch.baseBranch !== possibility.baseBranch) { - throw new Error("AI prediction evidence must use matching branch possibility") - } -} diff --git a/src/ai/openAiPredictionClient.ts b/src/ai/openAiPredictionClient.ts index 084ea66..c9ed9f4 100644 --- a/src/ai/openAiPredictionClient.ts +++ b/src/ai/openAiPredictionClient.ts @@ -91,13 +91,12 @@ export function createDefaultAiPredictionClient( return new OpenAiPredictionClient(options) } -// Watcher가 검증할 AiPrediction shape를 OpenAI structured output schema로 전달 +// Watcher가 검증할 branch 조합 응답 shape를 OpenAI structured output schema로 전달 function openAiRequestBodyFor( prompt: AiPredictionPrompt, model: string ): Record { - const responseShape = prompt.responseShape ?? "prediction" - const responseFormat = responseFormatFor(responseShape) + const responseFormat = responseFormatFor(prompt.responseShape) return { model, @@ -130,11 +129,6 @@ function responseFormatFor( schema: Record } { switch (responseShape) { - case "predictionBatch": - return { - name: "ai_prediction_batch", - schema: aiPredictionBatchSchema() - } case "predictionPairConfirmedConflict": return { name: "ai_prediction_pair_confirmed_conflict", @@ -145,11 +139,6 @@ function responseFormatFor( name: "ai_prediction_pair_clean_overlap", schema: aiCleanOverlapSchema() } - default: - return { - name: "ai_prediction", - schema: aiPredictionSchema() - } } } @@ -213,44 +202,6 @@ function openAiTextFor(response: OpenAiResponsesApiResponse): string { return text } -// OpenAI가 Watcher prediction batch contract에 맞는 JSON을 반환하도록 요청하는 schema -function aiPredictionBatchSchema(): Record { - return { - type: "object", - additionalProperties: false, - properties: { - predictions: { - type: "array", - items: aiPredictionSchema() - } - }, - required: ["predictions"] - } -} - -// OpenAI가 Watcher prediction 하나의 contract에 맞는 JSON을 반환하도록 요청하는 schema -function aiPredictionSchema(): Record { - return { - type: "object", - additionalProperties: false, - properties: { - branchName: { type: "string" }, - baseBranch: { type: "string" }, - prediction: { type: "string" }, - recommendedActions: { - type: "array", - items: recommendedActionSchema() - } - }, - required: [ - "branchName", - "baseBranch", - "prediction", - "recommendedActions" - ] - } -} - // 확정 conflict 해결 응답을 위한 OpenAI structured output schema function aiConfirmedConflictSchema(): Record { return { @@ -398,29 +349,3 @@ function stringArraySchema(): Record { items: { type: "string" } } } - -// AI recommended action 하나의 JSON 구조를 OpenAI structured output schema로 표현 -function recommendedActionSchema(): Record { - return { - type: "object", - additionalProperties: false, - properties: { - title: { type: "string" }, - description: { type: "string" }, - priority: { - type: "string", - enum: ["low", "medium", "high"] - }, - files: { - type: "array", - items: { type: "string" } - } - }, - required: [ - "title", - "description", - "priority", - "files" - ] - } -} diff --git a/src/ai/predictionPromptBuilder.ts b/src/ai/predictionPromptBuilder.ts deleted file mode 100644 index 637f0b0..0000000 --- a/src/ai/predictionPromptBuilder.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - DEFAULT_AI_PREDICTION_BATCH_SYSTEM_PROMPT, - DEFAULT_AI_PREDICTION_SYSTEM_PROMPT -} from "./promptTemplates.js" -import type { - AiPredictionEvidencePayload, - AiPredictionPrompt, - AiPredictionPromptBuildOptions -} from "./types.js" - -// deterministic evidence만 사용해 AI prediction prompt를 구성 -export function build( - payload: AiPredictionEvidencePayload, - options: AiPredictionPromptBuildOptions = {} -): AiPredictionPrompt { - return { - systemPrompt: options.systemPrompt ?? DEFAULT_AI_PREDICTION_SYSTEM_PROMPT, - userPrompt: JSON.stringify(promptEvidenceFor(payload), null, 2), - responseShape: "prediction" - } -} - -// 여러 branch evidence를 한 번의 AI provider 호출에 전달할 batch prompt로 구성 -export function buildBatch( - payloads: AiPredictionEvidencePayload[], - options: AiPredictionPromptBuildOptions = {} -): AiPredictionPrompt { - return { - systemPrompt: options.systemPrompt ?? DEFAULT_AI_PREDICTION_BATCH_SYSTEM_PROMPT, - userPrompt: JSON.stringify({ - branches: payloads.map(promptEvidenceFor) - }, null, 2), - responseShape: "predictionBatch" - } -} - -// prompt에 필요한 branch metadata와 deterministic evidence만 정제 -function promptEvidenceFor(payload: AiPredictionEvidencePayload): Record { - return { - branch: { - name: payload.branch.name, - baseBranch: payload.branch.baseBranch, - headSha: payload.branch.headSha, - author: payload.branch.author, - updatedAt: payload.branch.updatedAt?.toISOString(), - pullRequest: payload.branch.pullRequest, - checks: payload.branch.checks - }, - deterministicPossibility: payload.possibility, - gitSignal: payload.gitSignal, - changedHunks: payload.changedHunks - } -} diff --git a/src/ai/predictionResponseValidator.ts b/src/ai/predictionResponseValidator.ts deleted file mode 100644 index d52febf..0000000 --- a/src/ai/predictionResponseValidator.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { - AiPrediction, - AiRecommendedAction, - AiRecommendedActionPriority -} from "./types.js" - -const actionPriorities = new Set([ - "low", - "medium", - "high" -]) - -// AI가 반환한 unknown JSON을 Watcher가 사용하는 prediction 모델로 검증 -export function validate(response: unknown): AiPrediction { - return predictionFor(response, "response") -} - -// AI가 반환한 batch JSON을 branch별 prediction 배열로 검증 -export function validateBatch(response: unknown): AiPrediction[] { - const value = objectFor(response, "response") - - return arrayFor(value.predictions, "predictions") - .map((prediction, index) => { - try { - return predictionFor(prediction, `predictions[${index}]`) - } catch { - return undefined - } - }) - .filter((prediction): prediction is AiPrediction => prediction !== undefined) -} - -// AI prediction 하나가 Watcher report에 사용할 수 있는 shape인지 검증 -function predictionFor( - response: unknown, - path: string -): AiPrediction { - const value = objectFor(response, path) - - return { - branchName: stringFor(value.branchName, `${path}.branchName`), - baseBranch: stringFor(value.baseBranch, `${path}.baseBranch`), - prediction: stringFor(value.prediction, `${path}.prediction`), - recommendedActions: arrayFor(value.recommendedActions ?? [], `${path}.recommendedActions`) - .map((action, index) => recommendedActionFor(action, index)) - } -} - -// AI action 항목 하나가 report 가능한 shape인지 검증 -function recommendedActionFor( - action: unknown, - index: number -): AiRecommendedAction { - const value = objectFor(action, `recommendedActions[${index}]`) - const priority = priorityFor(value.priority, `recommendedActions[${index}].priority`) - const files = value.files == null - ? undefined - : arrayFor(value.files, `recommendedActions[${index}].files`) - .map((file, fileIndex) => stringFor(file, `recommendedActions[${index}].files[${fileIndex}]`)) - - return { - title: stringFor(value.title, `recommendedActions[${index}].title`), - description: stringFor(value.description, `recommendedActions[${index}].description`), - priority, - files - } -} - -// action priority가 Watcher가 표시할 수 있는 허용 값인지 검증 -function priorityFor( - value: unknown, - path: string -): AiRecommendedActionPriority { - if (typeof value !== "string" || !actionPriorities.has(value as AiRecommendedActionPriority)) { - throw new Error(`AI prediction response ${path} must be low, medium, or high`) - } - - return value as AiRecommendedActionPriority -} - -// unknown 값이 key 접근 가능한 plain object인지 검증 -function objectFor( - value: unknown, - path: string -): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new Error(`AI prediction response ${path} must be an object`) - } - - return value as Record -} - -// unknown 값이 배열 field인지 검증 -function arrayFor( - value: unknown, - path: string -): unknown[] { - if (!Array.isArray(value)) { - throw new Error(`AI prediction response ${path} must be an array`) - } - - return value -} - -// unknown 값이 비어 있지 않은 문자열 field인지 검증 -function stringFor( - value: unknown, - path: string -): string { - if (typeof value !== "string" || value.length === 0) { - throw new Error(`AI prediction response ${path} must be a non-empty string`) - } - - return value -} diff --git a/src/ai/predictionRunner.ts b/src/ai/predictionRunner.ts deleted file mode 100644 index 69625b0..0000000 --- a/src/ai/predictionRunner.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { buildBatch as buildBatchPrompt } from "./predictionPromptBuilder.js" -import { select as selectTargets } from "./predictionTargetSelector.js" -import { validateBatch as validateBatchResponse } from "./predictionResponseValidator.js" -import type { - AiPrediction, - AiPredictionDebugObserver, - AiPredictionDebugTarget, - AiPredictionClient, - AiPredictionEvidencePayload, - AiPredictionResult, - AiPredictionRunOptions -} from "./types.js" - -// 대상 선택, batch prompt 생성, provider 호출, 응답 검증을 branch별 AI prediction 결과로 연결 -export async function predict( - payloads: AiPredictionEvidencePayload[], - client: AiPredictionClient, - options: AiPredictionRunOptions = {} -): Promise { - const targets = selectTargets(payloads) - const targetSet = new Set(targets) - - if (targets.length === 0) { - return payloads.map(skippedResultFor) - } - - const predictedResults = await predictedResultsFor(targets, client, options) - - return payloads.map(payload => targetSet.has(payload) - ? predictedResults.get(payload) ?? failedResultFor(payload, "AI prediction response is missing") - : skippedResultFor(payload) - ) -} - -// provider 호출과 schema 검증 실패를 선택된 branch 단위 failed 결과로 격리 -async function predictedResultsFor( - targets: AiPredictionEvidencePayload[], - client: AiPredictionClient, - options: AiPredictionRunOptions -): Promise> { - const targetBranches = targets.map(debugTargetFor) - const prompt = buildBatchPrompt(targets, options) - await notifyDebugObserver("onPromptBuilt", () => options.debugObserver?.onPromptBuilt?.({ - targetBranches, - prompt - })) - - let response: unknown - - try { - response = await client.predict(prompt) - } catch (error) { - const errorMessage = errorMessageFor(error) - await notifyDebugObserver("onPredictionFailed", () => options.debugObserver?.onPredictionFailed?.({ - targetBranches, - errorMessage - })) - - return new Map(targets.map(target => [ - target, - failedResultFor(target, errorMessage) - ])) - } - - await notifyDebugObserver("onResponseReceived", () => options.debugObserver?.onResponseReceived?.({ - targetBranches, - response - })) - - try { - const predictions = validateBatchResponse(response) - - return resultsByPayloadFor(targets, predictions) - } catch (error) { - const errorMessage = errorMessageFor(error) - await notifyDebugObserver("onPredictionFailed", () => options.debugObserver?.onPredictionFailed?.({ - targetBranches, - errorMessage - })) - - return new Map(targets.map(target => [ - target, - failedResultFor(target, errorMessage) - ])) - } -} - -// debug observer 실패가 prediction 흐름을 중단하지 않도록 격리 -async function notifyDebugObserver( - eventName: keyof AiPredictionDebugObserver, - action: () => Promise | void | undefined -): Promise { - try { - await action() - } catch (error) { - console.warn(`Failed to notify debug observer (${eventName}): ${errorMessageFor(error)}`) - } -} - -// unknown error를 report 가능한 문자열로 변환 -function errorMessageFor(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -// AI prediction 대상이 아닌 branch의 생략 사유를 report 가능한 값으로 변환 -function skippedReasonFor(payload: AiPredictionEvidencePayload): "not_target" | "confirmed_conflict" { - return payload.possibility.reasons.some(reason => reason.code === "confirmed_conflict") - ? "confirmed_conflict" - : "not_target" -} - -// AI prediction 대상이 아닌 branch를 skipped 결과로 변환 -function skippedResultFor(payload: AiPredictionEvidencePayload): AiPredictionResult { - return { - status: "skipped", - branchName: payload.branch.name, - baseBranch: payload.branch.baseBranch, - reason: skippedReasonFor(payload) - } -} - -// provider 실패나 응답 누락을 branch별 failed 결과로 변환 -function failedResultFor( - payload: AiPredictionEvidencePayload, - errorMessage: string -): AiPredictionResult { - return { - status: "failed", - branchName: payload.branch.name, - baseBranch: payload.branch.baseBranch, - errorMessage - } -} - -// batch 응답을 원래 target payload와 매칭해 branch별 결과로 복원 -function resultsByPayloadFor( - targets: AiPredictionEvidencePayload[], - predictions: AiPrediction[] -): Map { - const predictionsByBranch = new Map(predictions.map(prediction => [ - predictionKeyFor(prediction.branchName, prediction.baseBranch), - prediction - ])) - - return new Map(targets.map(target => { - const prediction = predictionsByBranch.get(predictionKeyFor( - target.branch.name, - target.branch.baseBranch - )) - - return [ - target, - prediction - ? { - status: "predicted", - branchName: target.branch.name, - baseBranch: target.branch.baseBranch, - prediction - } - : failedResultFor(target, "AI prediction response is missing") - ] - })) -} - -// branch 이름만 같은 다른 base branch와 섞이지 않도록 base branch까지 포함해 매칭 -function predictionKeyFor( - branchName: string, - baseBranch: string -): string { - return `${baseBranch}\u0000${branchName}` -} - -// debug artifact에서 branch를 식별할 최소 metadata를 구성 -function debugTargetFor(payload: AiPredictionEvidencePayload): AiPredictionDebugTarget { - return { - branchName: payload.branch.name, - baseBranch: payload.branch.baseBranch - } -} diff --git a/src/ai/predictionTargetSelector.ts b/src/ai/predictionTargetSelector.ts deleted file mode 100644 index cd43c61..0000000 --- a/src/ai/predictionTargetSelector.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - AiPredictionEvidencePayload -} from "./types.js" -import { BranchRiskStatus } from "../risks/types.js" - -// OpenAI 호출량을 줄이기 위해 기본 AI prediction 대상은 critical possibility로 제한 -export const DEFAULT_AI_PREDICTION_TARGET_STATUS = BranchRiskStatus.Critical - -// deterministic possibility status 기준으로 AI prediction 대상 evidence만 선택 -export function select( - payloads: AiPredictionEvidencePayload[] -): AiPredictionEvidencePayload[] { - return payloads.filter(payload => - payload.possibility.status === DEFAULT_AI_PREDICTION_TARGET_STATUS && - !payload.possibility.reasons.some(reason => reason.code === "confirmed_conflict") - ) -} diff --git a/src/ai/promptTemplates.ts b/src/ai/promptTemplates.ts deleted file mode 100644 index 17da04b..0000000 --- a/src/ai/promptTemplates.ts +++ /dev/null @@ -1,48 +0,0 @@ -// AI prediction 기본 system prompt는 provider별 호출부에서 교체 가능해야 하는 정책 템플릿 -export const DEFAULT_AI_PREDICTION_SYSTEM_PROMPT = [ - "You are Watcher's merge risk prediction assistant.", - "Use only the provided deterministic evidence.", - "Do not recalculate or overwrite the possibility score, status, or reasons.", - "Do not describe the deterministic score as a probability or percentage.", - "Use probabilistic wording. Avoid phrases such as guaranteed, will cause, or will result for possibility-based risks.", - "Predict practical merge risk impact and recommend next actions.", - "Write prediction, recommended action titles, and descriptions in Korean.", - "Return only JSON with this shape:", - "{", - " \"branchName\": string,", - " \"baseBranch\": string,", - " \"prediction\": string,", - " \"recommendedActions\": [{", - " \"title\": string,", - " \"description\": string,", - " \"priority\": \"low\" | \"medium\" | \"high\",", - " \"files\": string[]", - " }]", - "}" -].join("\n") - -// 여러 branch evidence를 한 번에 판단할 때 사용하는 batch system prompt -export const DEFAULT_AI_PREDICTION_BATCH_SYSTEM_PROMPT = [ - "You are Watcher's merge risk prediction assistant.", - "Use only the provided deterministic evidence.", - "Do not recalculate or overwrite the possibility score, status, or reasons.", - "Do not describe the deterministic score as a probability or percentage.", - "Use probabilistic wording. Avoid phrases such as guaranteed, will cause, or will result for possibility-based risks.", - "Compare the provided branches together and predict practical merge risk impact.", - "Recommend next actions for each branch.", - "Write prediction, recommended action titles, and descriptions in Korean.", - "Return only JSON with this shape:", - "{", - " \"predictions\": [{", - " \"branchName\": string,", - " \"baseBranch\": string,", - " \"prediction\": string,", - " \"recommendedActions\": [{", - " \"title\": string,", - " \"description\": string,", - " \"priority\": \"low\" | \"medium\" | \"high\",", - " \"files\": string[]", - " }]", - " }]", - "}" -].join("\n") diff --git a/src/ai/types.ts b/src/ai/types.ts index 7c83174..df2b184 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -1,28 +1,16 @@ import type { - BranchComparisonPair, - BranchContext + BranchComparisonPair } from "../branches/types.js" import type { - GitMergeSignal, GitMergeSignalStatus, GitMergeTreeConflict, MergeCodeContextEvidence } from "../git/types.js" import type { - BranchChangedHunk, BranchConflictGraphEdgeReason, - BranchConflictGraphEdgeStatus, - BranchRisk + BranchConflictGraphEdgeStatus } from "../risks/types.js" -// AI prediction 생성에 사용할 deterministic 분석 근거 묶음 -export type AiPredictionEvidencePayload = { - branch: BranchContext - possibility: BranchRisk - gitSignal: GitMergeSignal - changedHunks: BranchChangedHunk[] -} - // AI가 분석할 수 있는 확정 conflict와 critical potential overlap 상태 export type AiPredictionPairTargetStatus = Extract< BranchConflictGraphEdgeStatus, @@ -177,13 +165,11 @@ export type AiPredictionPairDebugObserver = { export type AiPredictionPrompt = { systemPrompt: string userPrompt: string - responseShape?: AiPredictionPromptResponseShape + responseShape: AiPredictionPromptResponseShape } // provider가 structured output schema를 고를 때 사용할 응답 형태 export type AiPredictionPromptResponseShape = - | "prediction" - | "predictionBatch" | "predictionPairConfirmedConflict" | "predictionPairCleanOverlap" @@ -201,83 +187,3 @@ export type AiPredictionPairRunOptions = AiPredictionPromptBuildOptions & { export type AiPredictionClient = { predict(prompt: AiPredictionPrompt): Promise } - -export type AiPredictionDebugTarget = { - branchName: string - baseBranch: string -} - -export type AiPredictionPromptDebugEvent = { - targetBranches: AiPredictionDebugTarget[] - prompt: AiPredictionPrompt -} - -export type AiPredictionResponseDebugEvent = { - targetBranches: AiPredictionDebugTarget[] - response: unknown -} - -export type AiPredictionFailureDebugEvent = { - targetBranches: AiPredictionDebugTarget[] - errorMessage: string -} - -export type AiPredictionDebugObserver = { - onPromptBuilt?(event: AiPredictionPromptDebugEvent): void | Promise - onResponseReceived?(event: AiPredictionResponseDebugEvent): void | Promise - onPredictionFailed?(event: AiPredictionFailureDebugEvent): void | Promise -} - -// AI prediction runner가 prompt 생성과 debug 기록을 조정하기 위한 설정 -export type AiPredictionRunOptions = AiPredictionPromptBuildOptions & { - debugObserver?: AiPredictionDebugObserver -} - -// branch별 AI prediction 실행 결과 -export type AiPredictionResult = - | AiPredictionPredictedResult - | AiPredictionSkippedResult - | AiPredictionFailedResult - -export type AiPredictionPredictedResult = { - status: "predicted" - branchName: string - baseBranch: string - prediction: AiPrediction -} - -export type AiPredictionSkippedResult = { - status: "skipped" - branchName: string - baseBranch: string - reason: "not_target" | "confirmed_conflict" -} - -export type AiPredictionFailedResult = { - status: "failed" - branchName: string - baseBranch: string - errorMessage: string -} - -// AI가 deterministic possibility를 덮어쓰지 않고 추가로 제공하는 예측 결과 -export type AiPrediction = { - branchName: string - baseBranch: string - prediction: string - recommendedActions: AiRecommendedAction[] -} - -// AI가 제안하는 다음 action과 그 action이 필요한 근거 -export type AiRecommendedAction = { - title: string - description: string - priority: AiRecommendedActionPriority - files?: string[] -} - -// report에서 action 정렬과 표시 강도를 결정하기 위한 우선순위 -export type AiRecommendedActionPriority = - | "low" - | "medium" - | "high" diff --git a/src/debug/aiPredictionArtifact.ts b/src/debug/aiPredictionArtifact.ts index 9ba1589..2cfb56c 100644 --- a/src/debug/aiPredictionArtifact.ts +++ b/src/debug/aiPredictionArtifact.ts @@ -1,25 +1,5 @@ import { createHash } from "node:crypto" -type AiPredictionPromptDebugEventInput = { - targetBranches: Array<{ - branchName: string - baseBranch: string - }> - prompt: { - systemPrompt: string - userPrompt: string - responseShape?: string - } -} - -type AiPredictionResponseDebugEventInput = { - targetBranches: Array<{ - branchName: string - baseBranch: string - }> - response: unknown -} - type AiPredictionPairPromptDebugEventInput = { targetPair: { leftBranchName: string @@ -28,7 +8,7 @@ type AiPredictionPairPromptDebugEventInput = { prompt: { systemPrompt: string userPrompt: string - responseShape?: string + responseShape: string } } @@ -48,26 +28,6 @@ type AiPredictionPairFailureDebugEventInput = { errorMessage: string } -export type AiPredictionPromptDebugArtifact = { - targetBranches: Array<{ - branchName: string - baseBranch: string - }> - prompt: { - systemPrompt: string - userPrompt: string - responseShape?: string - } -} - -export type AiPredictionResponseDebugArtifact = { - targetBranches: Array<{ - branchName: string - baseBranch: string - }> - response: unknown -} - export type AiPredictionPairPromptDebugArtifact = { targetPair: { leftBranchName: string @@ -76,7 +36,7 @@ export type AiPredictionPairPromptDebugArtifact = { prompt: { systemPrompt: string userPrompt: string - responseShape?: string + responseShape: string } } @@ -100,38 +60,6 @@ export type AiPredictionPairFailureDebugArtifact = { } } -// OpenAI에 전달된 prompt event에서 consumer repository 코드 원문만 metadata로 치환 -export function sanitizeAiPredictionPromptDebugEvent( - event: AiPredictionPromptDebugEventInput -): AiPredictionPromptDebugArtifact { - return { - targetBranches: event.targetBranches.map(target => ({ - branchName: target.branchName, - baseBranch: target.baseBranch - })), - prompt: { - systemPrompt: event.prompt.systemPrompt, - userPrompt: sanitizedUserPromptFor(event.prompt.userPrompt), - ...(event.prompt.responseShape - ? { responseShape: event.prompt.responseShape } - : {}) - } - } -} - -// provider response에서 제안 patch 원문만 크기와 hunk metadata로 치환 -export function sanitizeAiPredictionResponseDebugEvent( - event: AiPredictionResponseDebugEventInput -): AiPredictionResponseDebugArtifact { - return { - targetBranches: event.targetBranches.map(target => ({ - branchName: target.branchName, - baseBranch: target.baseBranch - })), - response: sanitizedResponseValueFor(event.response) - } -} - // pair prompt event의 ordered targetPair를 유지하고 코드 원문을 metadata로 치환 export function sanitizeAiPredictionPairPromptDebugEvent( event: AiPredictionPairPromptDebugEventInput @@ -144,9 +72,7 @@ export function sanitizeAiPredictionPairPromptDebugEvent( prompt: { systemPrompt: event.prompt.systemPrompt, userPrompt: sanitizedUserPromptFor(event.prompt.userPrompt), - ...(event.prompt.responseShape - ? { responseShape: event.prompt.responseShape } - : {}) + responseShape: event.prompt.responseShape } } } diff --git a/src/git/gitMergeSignalCollector.ts b/src/git/gitMergeSignalCollector.ts deleted file mode 100644 index b2c5e56..0000000 --- a/src/git/gitMergeSignalCollector.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { execFile } from "node:child_process" -import { promisify } from "node:util" -import { build as buildBranchComparisonPairs } from "../branches/branchPairBuilder.js" -import type { BranchContext } from "../branches/types.js" -import { collect as collectGitMergeTreeResults } from "./gitMergeTreeCollector.js" -import type { - GitMergeSignal, - GitMergeSignalCollectionOptions, - GitMergeTreePairResult -} from "./types.js" - -const execFileAsync = promisify(execFile) - -// remote branch를 가져와 merge base, 변경 파일, 가상 merge 결과를 수집 -export async function collectGitMergeSignal( - branch: BranchContext, - options: GitMergeSignalCollectionOptions -): Promise { - const pair = buildBranchComparisonPairs(branch.baseBranch, [branch])[0] - - if (!pair) { - return failedSignal(branch, "branch comparison pair is missing") - } - - const results = await collectGitMergeTreeResults([pair], { - repositoryPath: options.repositoryPath, - remoteName: options.remoteName - }) - - return collectGitMergeSignalFromPairResult(branch, results[0], options) -} - -// 미리 수집한 base 포함 pair 결과를 기존 branch 단위 GitMergeSignal로 변환 -export async function collectGitMergeSignalFromPairResult( - branch: BranchContext, - pairResult: GitMergeTreePairResult | undefined, - options: GitMergeSignalCollectionOptions -): Promise { - if (!pairResult) { - return failedSignal(branch, "merge-tree result is missing for branch pair") - } - - if (!matches(branch, pairResult)) { - return failedSignal(branch, "merge-tree result does not match branch pair") - } - - if (pairResult.failureStage === "preparation") { - return failedSignal( - branch, - pairResult.errorMessage ?? "merge-tree preparation failed" - ) - } - - const remote = options.remoteName ?? "origin" - const baseRef = `refs/remotes/${remote}/${branch.baseBranch}` - const headRef = `refs/remotes/${remote}/${branch.name}` - let mergeBaseSha: string | undefined - let changedFiles: string[] = [] - - try { - mergeBaseSha = await gitOutput(options.repositoryPath, [ - "merge-base", - baseRef, - headRef - ]) - changedFiles = await gitLines(options.repositoryPath, [ - "diff", - "--name-only", - mergeBaseSha, - headRef - ]) - - return { - status: pairResult.status, - baseBranch: branch.baseBranch, - branchName: branch.name, - mergeBaseSha, - changedFiles, - conflictFiles: pairResult.conflictFiles, - ...(pairResult.errorMessage - ? { errorMessage: pairResult.errorMessage } - : {}) - } - } catch (error) { - return { - status: "merge_check_failed", - baseBranch: branch.baseBranch, - branchName: branch.name, - mergeBaseSha, - changedFiles, - conflictFiles: [], - errorMessage: pairResult.status === "merge_check_failed" - ? pairResult.errorMessage ?? formatGitError(error) - : formatGitError(error) - } - } -} - -// pair 결과가 기존 base와 branch 관계를 나타내는지 확인 -function matches(branch: BranchContext, result: GitMergeTreePairResult): boolean { - const names = new Set([ - result.pair.leftBranchName, - result.pair.rightBranchName - ]) - - return names.size === 2 && - names.has(branch.baseBranch) && - names.has(branch.name) -} - -// pair를 구성하거나 찾지 못한 branch를 기존 실패 signal로 표현 -function failedSignal(branch: BranchContext, errorMessage: string): GitMergeSignal { - return { - status: "merge_check_failed", - baseBranch: branch.baseBranch, - branchName: branch.name, - changedFiles: [], - conflictFiles: [], - errorMessage - } -} - -// git stdout을 비어 있지 않은 line 목록으로 변환 -async function gitLines(cwd: string, args: string[]): Promise { - const output = await gitOutput(cwd, args) - return output.split("\n").filter(line => line.length) -} - -// git command 성공 stdout을 반환하고 실패 결과를 오류로 변환 -async function gitOutput(cwd: string, args: string[]): Promise { - const result = await gitResult(cwd, args) - - if (result.exitCode !== 0) { - throw new Error(result.stderr || result.stdout || `git ${args.join(" ")} failed`) - } - - return result.stdout.trim() -} - -// git command를 실행하고 성공과 실패를 동일한 결과 구조로 정규화 -async function gitResult( - cwd: string, - args: string[] -): Promise<{ exitCode: number, stdout: string, stderr: string }> { - try { - const result = await execFileAsync("git", args, { - cwd, - maxBuffer: 10 * 1024 * 1024 - }) - - return { - exitCode: 0, - stdout: result.stdout, - stderr: result.stderr - } - } catch (error) { - const gitError = error as { - code?: number | string - stdout?: string - stderr?: string - message?: string - } - - return { - exitCode: typeof gitError.code === "number" ? gitError.code : 1, - stdout: gitError.stdout ?? "", - stderr: gitError.stderr ?? gitError.message ?? "" - } - } -} - -// unknown git 오류를 signal에 기록할 문자열로 변환 -function formatGitError(error: unknown): string { - if (error instanceof Error) { - return error.message.trim() - } - - if (typeof error === "string") { - return error.trim() - } - - return "unknown git error" -} diff --git a/src/git/types.ts b/src/git/types.ts index ed568ba..e5cbab0 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -131,19 +131,3 @@ export type GitMergeTreeCollectorDependencies = { collectRound: GitMergeTreeRoundCollector availableParallelism(): number } - -export type GitMergeSignal = { - status: GitMergeSignalStatus - baseBranch: string - branchName: string - mergeBaseSha?: string - changedFiles: string[] - conflictFiles: string[] - errorMessage?: string -} - -export type GitMergeSignalCollectionOptions = { - repositoryPath: string - remoteName?: string - worktreeRoot?: string -} diff --git a/src/index.ts b/src/index.ts index f597b07..cad0264 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,54 +10,30 @@ export { ACTIVE_BRANCH_WINDOW_DAYS, select as selectWatchedBranches } from "./branches/branchSelector.js" -export { build as buildAiPredictionEvidencePayload } from "./ai/evidenceBuilder.js" export { createDefaultAiPredictionClient, DEFAULT_OPENAI_PREDICTION_MODEL, OPENAI_API_KEY_ENV_NAME, OpenAiPredictionClient } from "./ai/openAiPredictionClient.js" -export { - build as buildAiPredictionPrompt, - buildBatch as buildAiPredictionBatchPrompt -} from "./ai/predictionPromptBuilder.js" export { build as buildAiPredictionPairPrompt } from "./ai/predictionPairPromptBuilder.js" -export { - DEFAULT_AI_PREDICTION_BATCH_SYSTEM_PROMPT, - DEFAULT_AI_PREDICTION_SYSTEM_PROMPT -} from "./ai/promptTemplates.js" export { DEFAULT_AI_CLEAN_OVERLAP_SYSTEM_PROMPT, DEFAULT_AI_CONFIRMED_CONFLICT_SYSTEM_PROMPT } from "./ai/predictionPairPromptTemplates.js" -export { predict as predictMergeRisksWithAi } from "./ai/predictionRunner.js" export { predict as predictBranchPairsWithAi } from "./ai/predictionPairRunner.js" -export { - DEFAULT_AI_PREDICTION_TARGET_STATUS, - select as selectAiPredictionTargets -} from "./ai/predictionTargetSelector.js" -export { - validate as validateAiPredictionResponse, - validateBatch as validateAiPredictionBatchResponse -} from "./ai/predictionResponseValidator.js" export { validate as validateAiPredictionPairResponse } from "./ai/predictionPairResponseValidator.js" -export { collectGitMergeSignal } from "./git/gitMergeSignalCollector.js" export { send as sendMergeRiskReport } from "./reportChannels/reportChannel.js" -export { analyze as analyzeBranchMergeRisks } from "./risks/riskAnalyzer.js" -export { build as buildMergeRiskReport } from "./reports/reportBuilder.js" export { build as buildBranchPairMergeRiskReport } from "./reports/branchPairReportBuilder.js" -export { format as formatMergeRiskReportMarkdown } from "./reports/markdownFormatter.js" export { format as formatBranchPairMergeRiskReportMarkdown } from "./reports/branchPairMarkdownFormatter.js" -export { BranchRiskStatus } from "./risks/types.js" - export type { BranchSource } from "./branches/branchCollector.js" export type { @@ -65,13 +41,7 @@ export type { } from "./ai/openAiPredictionClient.js" export type { - AiPrediction, AiPredictionClient, - AiPredictionDebugObserver, - AiPredictionDebugTarget, - AiPredictionEvidencePayload, - AiPredictionFailedResult, - AiPredictionFailureDebugEvent, AiPredictionPairBranchMetadata, AiPredictionPairCodeContext, AiPredictionPairCodeContextStatus, @@ -92,15 +62,7 @@ export type { AiPredictionPairTargetStatus, AiPredictionPrompt, AiPredictionPromptBuildOptions, - AiPredictionPromptDebugEvent, AiPredictionPromptResponseShape, - AiPredictionPredictedResult, - AiPredictionResponseDebugEvent, - AiPredictionResult, - AiPredictionRunOptions, - AiPredictionSkippedResult, - AiRecommendedAction, - AiRecommendedActionPriority, AiCleanOverlapResponse, AiConfirmedConflictResponse } from "./ai/types.js" @@ -130,31 +92,15 @@ export { } from "./reportChannels/types.js" export type { - GitMergeSignal, - GitMergeSignalCollectionOptions, GitMergeSignalStatus, GitMergeTreePairResult } from "./git/types.js" export type { - BranchChangedHunk, BranchConflictGraph, - BranchConflictGraphEdge, - BranchRisk, - BranchRiskAnalysisInput, - BranchRiskAnalysisOptions, - BranchRiskReason, - BranchRiskReasonCode + BranchConflictGraphEdge } from "./risks/types.js" -export type { - MergeRiskReport, - MergeRiskReportInput, - MergeRiskReportItem, - MergeRiskReportOptions, - MergeRiskReportSection -} from "./reports/types.js" - export type { BranchPairMergeRiskReport, BranchPairMergeRiskReportActivePeriod, diff --git a/src/reports/markdownFormatter.ts b/src/reports/markdownFormatter.ts deleted file mode 100644 index 3c2b77a..0000000 --- a/src/reports/markdownFormatter.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { - AiPredictionFailedResult, - AiPredictionPredictedResult, - AiPredictionResult, - AiPredictionSkippedResult, - AiRecommendedAction -} from "../ai/types.js" -import type { - MergeRiskReport, - MergeRiskReportItem -} from "./types.js" -import type { BranchRiskReason } from "../risks/types.js" - -// MergeRiskReport 모델을 GitHub comment나 stdout에 붙일 Markdown 문자열로 변환 -export function format(report: MergeRiskReport): string { - const lines = [ - "## Merge Risk Report", - "", - `- base branch: ${code(report.baseBranch)}`, - `- watched branches: ${report.totalBranchCount.toString()}` - ] - - if (report.sections.length === 0) { - lines.push("", "감시 대상 branch 없음") - return lines.join("\n") - } - - for (const section of report.sections) { - lines.push("", `### ${section.title}`) - - for (const item of section.items) { - lines.push(...linesForItem(item)) - } - } - - return lines.join("\n") -} - -// branch 하나의 score, metadata, reason을 Markdown block으로 구성 -function linesForItem(item: MergeRiskReportItem): string[] { - const sameHunkFiles = new Set(item.reasons - .filter(reason => reason.code === "same_hunk_overlap") - .flatMap(reason => reason.files ?? [])) - - return [ - "", - `#### ${code(item.branchName)}`, - `- score/status: ${code(item.score.toString())} / ${code(item.status)}`, - ...metadataLinesFor(item), - "- reasons:", - ...item.reasons - .map(reason => compactSameFileOverlapReason(reason, sameHunkFiles)) - .flatMap(reason => linesForReason(reason)), - ...aiPredictionLinesFor(item.aiPrediction) - ] -} - -// optional branch metadata가 있을 때만 report line으로 표시 -function metadataLinesFor(item: MergeRiskReportItem): string[] { - const lines: string[] = [] - - if (item.author) { - lines.push(`- author: ${code(item.author)}`) - } - - if (item.updatedAt) { - lines.push(`- updated: ${code(item.updatedAt.toISOString())}`) - } - - return lines -} - -// same hunk로 이미 설명된 file만 same file reason에서 제거해 중복 표시를 줄임 -function compactSameFileOverlapReason( - reason: BranchRiskReason, - sameHunkFiles: Set -): BranchRiskReason { - if (reason.code !== "same_file_overlap" || !reason.files?.length || sameHunkFiles.size === 0) { - return reason - } - - const files = reason.files.filter(file => !sameHunkFiles.has(file)) - - return { - ...reason, - files, - branches: 0 < files.length ? reason.branches : undefined - } -} - -// deterministic reason의 code, score 영향, 관련 metadata를 Markdown bullet로 구성 -function linesForReason(reason: BranchRiskReason): string[] { - const lines = [ - ` - ${code(reason.code)} (+${reason.scoreImpact.toString()}): ${reason.message}` - ] - - if (reason.files?.length) { - lines.push(` - files: ${reason.files.map(code).join(", ")}`) - } - - if (reason.branches?.length) { - lines.push(` - branches: ${reason.branches.map(code).join(", ")}`) - } - - if (reason.checks?.length) { - lines.push(` - checks: ${reason.checks.map(code).join(", ")}`) - } - - return lines -} - -// AI prediction 결과가 있을 때 deterministic reason과 분리된 Markdown block으로 표시 -function aiPredictionLinesFor(prediction: AiPredictionResult | undefined): string[] { - if (!prediction) { - return [] - } - - if (prediction.status === "predicted") { - return predictedLinesFor(prediction) - } - - if (prediction.status === "skipped") { - return skippedLinesFor(prediction) - } - - return failedLinesFor(prediction) -} - -// AI가 생성한 prediction과 action을 표시 -function predictedLinesFor(result: AiPredictionPredictedResult): string[] { - const lines = [ - "- ai prediction:", - ` - prediction: ${result.prediction.prediction}` - ] - - if (result.prediction.recommendedActions.length) { - lines.push(" - recommended actions:") - lines.push(...result.prediction.recommendedActions.flatMap(action => actionLinesFor(action))) - } - - return lines -} - -// AI prediction을 생략한 이유를 표시 -function skippedLinesFor(result: AiPredictionSkippedResult): string[] { - return [ - "- ai prediction:", - ` - status: ${code(result.status)}`, - ` - reason: ${code(result.reason)}` - ] -} - -// provider 호출이나 schema 검증 실패가 branch report를 깨지 않도록 실패 이유만 표시 -function failedLinesFor(result: AiPredictionFailedResult): string[] { - return [ - "- ai prediction:", - ` - status: ${code(result.status)}`, - ` - error: ${result.errorMessage}` - ] -} - -// AI recommended action 하나를 우선순위, 설명, 관련 파일로 표시 -function actionLinesFor(action: AiRecommendedAction): string[] { - const lines = [ - ` - ${code(action.priority)} ${action.title}: ${action.description}` - ] - - if (action.files?.length) { - lines.push(` - files: ${action.files.map(code).join(", ")}`) - } - - return lines -} - -// Markdown inline code 안의 backtick보다 긴 delimiter를 사용해 code span을 구성 -function code(value: string): string { - const backtick = "`" - - if (!value.includes(backtick)) { - return `${backtick}${value}${backtick}` - } - - const matches = value.match(/`+/g) ?? [] - const maxBackticks = Math.max(...matches.map(match => match.length)) - const delimiter = backtick.repeat(maxBackticks + 1) - - return `${delimiter} ${value} ${delimiter}` -} diff --git a/src/reports/reportBuilder.ts b/src/reports/reportBuilder.ts deleted file mode 100644 index 81f1f48..0000000 --- a/src/reports/reportBuilder.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { BranchRiskStatus } from "../risks/types.js" -import type { BranchRiskStatus as BranchRiskStatusType } from "../risks/types.js" -import type { - MergeRiskReport, - MergeRiskReportInput, - MergeRiskReportItem, - MergeRiskReportOptions, - MergeRiskReportSection -} from "./types.js" - -// report에서 risk가 높은 section부터 보여주기 위한 고정 정렬 순서 -const statusOrder: BranchRiskStatusType[] = [ - BranchRiskStatus.Critical, - BranchRiskStatus.High, - BranchRiskStatus.Medium, - BranchRiskStatus.Low -] - -// 별도 title 설정이 없을 때 사용하는 기본 section title -const defaultSectionTitles: Record = { - [BranchRiskStatus.Critical]: "Critical", - [BranchRiskStatus.High]: "High", - [BranchRiskStatus.Medium]: "Medium", - [BranchRiskStatus.Low]: "Low" -} - -// branch risk 목록을 status별 section으로 묶은 report 모델로 변환 -export function build( - inputs: MergeRiskReportInput[], - baseBranch: string, - options: MergeRiskReportOptions = {} -): MergeRiskReport { - // 기본 title 위에 호출자가 넘긴 title만 덮어씀 - const titles = { - ...defaultSectionTitles, - ...options.sectionTitles - } - // 정해진 status 순서대로 section을 만들고 item이 없는 section은 제외 - const sections = statusOrder - .map(status => buildSection(inputs, status, titles[status])) - .filter((section): section is MergeRiskReportSection => 0 < (section?.items.length ?? 0)) - - return { - baseBranch, - generatedAt: options.generatedAt ?? new Date(), - sections, - totalBranchCount: inputs.length - } -} - -// 하나의 risk status에 해당하는 branch item section을 생성 -function buildSection( - inputs: MergeRiskReportInput[], - status: BranchRiskStatusType, - title: string -): MergeRiskReportSection | undefined { - // 같은 section 안에서는 score가 높은 branch를 먼저 보여주고 동점이면 이름으로 정렬 - const items = inputs - .filter(input => input.risk.status === status) - .map(toReportItem) - .sort(compareReportItems) - - if (items.length === 0) { - return undefined - } - - return { status, title, items } -} - -// risk 계산 결과와 branch metadata를 report item 형태로 평탄화 -function toReportItem(input: MergeRiskReportInput): MergeRiskReportItem { - return { - branchName: input.risk.branchName, - baseBranch: input.risk.baseBranch, - score: input.risk.score, - status: input.risk.status, - reasons: input.risk.reasons, - author: input.branch.author, - updatedAt: input.branch.updatedAt, - pullRequest: input.branch.pullRequest, - aiPrediction: input.aiPrediction, - branch: input.branch - } -} - -// report item 정렬 기준: score 내림차순, branch 이름 오름차순 -function compareReportItems( - item: MergeRiskReportItem, - other: MergeRiskReportItem -): number { - if (item.score !== other.score) { - return other.score - item.score - } - - if (item.branchName < other.branchName) { - return -1 - } - - if (item.branchName > other.branchName) { - return 1 - } - - return 0 -} diff --git a/src/reports/types.ts b/src/reports/types.ts deleted file mode 100644 index 19f134d..0000000 --- a/src/reports/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { BranchContext, BranchPullRequestMetadata } from "../branches/types.js" -import type { BranchRisk, BranchRiskReason, BranchRiskStatus } from "../risks/types.js" -import type { AiPredictionResult } from "../ai/types.js" - -// report 전체가 어떤 base branch 기준으로 생성됐는지 표현 -export type MergeRiskReport = { - baseBranch: string - generatedAt: Date - sections: MergeRiskReportSection[] - totalBranchCount: number -} - -// 같은 risk status를 가진 branch item 묶음 -export type MergeRiskReportSection = { - status: BranchRiskStatus - title: string - items: MergeRiskReportItem[] -} - -// report에 표시할 branch 단위 결과 -export type MergeRiskReportItem = { - branchName: string - baseBranch: string - score: number - status: BranchRiskStatus - reasons: BranchRiskReason[] - author?: string - updatedAt?: Date - pullRequest?: BranchPullRequestMetadata - aiPrediction?: AiPredictionResult - branch: BranchContext -} - -// report builder가 받는 branch risk, branch metadata, optional AI prediction 묶음 -export type MergeRiskReportInput = { - risk: BranchRisk - branch: BranchContext - aiPrediction?: AiPredictionResult -} - -// report 생성 시점과 제목 정책을 조정하기 위한 설정 -export type MergeRiskReportOptions = { - generatedAt?: Date - sectionTitles?: Partial> -} diff --git a/src/risks/riskAnalyzer.ts b/src/risks/riskAnalyzer.ts deleted file mode 100644 index a22d51e..0000000 --- a/src/risks/riskAnalyzer.ts +++ /dev/null @@ -1,505 +0,0 @@ -import type { BranchCheckMetadata } from "../branches/types.js" -import { BranchRiskStatus } from "./types.js" -import type { - BranchConflictGraph, - BranchConflictGraphEdge, - BranchConflictGraphEdgeReasonCode, - BranchChangedHunk, - BranchRisk, - BranchRiskAnalysisInput, - BranchRiskAnalysisOptions, - BranchRiskReason, - BranchRiskStatus as BranchRiskStatusType -} from "./types.js" - -// risk score는 report에서 비교하기 쉽도록 0-100 범위로 제한 -const MAX_SCORE = 100 - -// 각 rule이 branch risk score에 더하는 가중치 -const SCORE = { - confirmedConflict: 100, - mergeCheckFailed: 40, - sameHunkOverlap: 55, - sameFileOverlap: 30, - failedCheck: 20, - criticalFileChanged: 25 -} as const - -// GitHub check conclusion 중 merge risk를 높이는 실패 계열 상태 -const failedCheckConclusions = new Set([ - "action_required", - "cancelled", - "failure", - "timed_out" -]) - -// branch별 git/check/overlap signal을 deterministic risk score와 reason으로 변환 -export function analyze( - inputs: BranchRiskAnalysisInput[], - options: BranchRiskAnalysisOptions = {} -): BranchRisk[] { - // 전체 branch 입력을 먼저 훑어 branch 간 파일/hunk overlap을 계산 - const fileOverlaps = buildFileOverlaps(inputs) - const hunkOverlaps = buildHunkOverlaps(inputs) - // repository마다 민감한 파일을 설정으로 주입할 수 있도록 wildcard를 정규식으로 변환 - const criticalRegexes = options.criticalFilePatterns?.map(wildcardToRegExp) ?? [] - - return inputs.map(input => { - // 개별 branch에 적용되는 rule reason 목록 - const reasons = buildReasons({ - input, - fileOverlaps: fileOverlaps.get(input.branch.name) ?? new Map(), - hunkOverlaps: hunkOverlaps.get(input.branch.name) ?? new Map(), - criticalRegexes - }) - // reason별 점수를 합산하되 최대 100점을 넘지 않도록 제한 - 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 - } - }) -} - -// 충돌 관계 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 - fileOverlaps: Map> - hunkOverlaps: Map> - criticalRegexes: RegExp[] -}): BranchRiskReason[] { - // matched rule이 없으면 clean_merge reason을 넣기 위해 누적 - const reasons: BranchRiskReason[] = [] - - if (input.input.gitSignal.status === "confirmed_conflict") { - return [ - { - code: "confirmed_conflict", - message: "virtual merge에서 conflict가 확인됨", - scoreImpact: SCORE.confirmedConflict, - files: input.input.gitSignal.conflictFiles - } - ] - } - - if (input.input.gitSignal.status === "merge_check_failed") { - reasons.push({ - code: "merge_check_failed", - message: "virtual merge 확인에 실패함", - scoreImpact: SCORE.mergeCheckFailed - }) - } - - // 같은 line range까지 겹치는 파일은 same file보다 강한 signal로 먼저 기록 - const hunkOverlapFiles = [...input.hunkOverlaps.keys()].sort() - if (0 < hunkOverlapFiles.length) { - reasons.push({ - code: "same_hunk_overlap", - message: "다른 branch와 같은 hunk를 수정함", - scoreImpact: SCORE.sameHunkOverlap, - files: hunkOverlapFiles, - branches: branchesFor(input.hunkOverlaps) - }) - } - - // 다른 branch와 같은 파일을 수정하면 conflict 가능성을 높이는 약한 signal로 기록 - const fileOverlapFiles = [...input.fileOverlaps.keys()].sort() - if (0 < fileOverlapFiles.length) { - reasons.push({ - code: "same_file_overlap", - message: "다른 branch와 같은 파일을 수정함", - scoreImpact: SCORE.sameFileOverlap, - files: fileOverlapFiles, - branches: branchesFor(input.fileOverlaps) - }) - } - - // branch metadata에 실패한 check가 있으면 merge 준비 상태가 나쁜 signal로 기록 - const failedChecks = input.input.branch.checks.filter(isFailedCheck) - if (0 < failedChecks.length) { - reasons.push({ - code: "failed_check", - message: "실패한 check metadata가 존재함", - scoreImpact: SCORE.failedCheck, - checks: failedChecks.map(check => check.name).sort() - }) - } - - // repository별 critical file pattern과 매칭되는 변경 파일을 기록 - const criticalFiles = input.input.gitSignal.changedFiles - .filter(file => input.criticalRegexes.some(regex => regex.test(file))) - .sort() - if (0 < criticalFiles.length) { - reasons.push({ - code: "critical_file_changed", - message: "critical file pattern에 해당하는 파일을 수정함", - scoreImpact: SCORE.criticalFileChanged, - files: criticalFiles - }) - } - - if (reasons.length === 0) { - reasons.push({ - code: "clean_merge", - message: "virtual merge에서 conflict가 확인되지 않음", - scoreImpact: 0 - }) - } - - return reasons -} - -// 변경 파일이 둘 이상의 branch에 등장하는지 계산 -function buildFileOverlaps( - inputs: BranchRiskAnalysisInput[] -): Map>> { - // 파일별로 해당 파일을 수정한 branch 목록을 수집 - const fileToBranches = new Map>() - - for (const input of inputs) { - for (const file of input.gitSignal.changedFiles) { - const branches = fileToBranches.get(file) ?? new Set() - branches.add(input.branch.name) - fileToBranches.set(file, branches) - } - } - - // branch별로 겹친 파일과 상대 branch 목록을 재구성 - const overlaps = new Map>>() - - for (const [file, branches] of fileToBranches) { - if (branches.size < 2) { - continue - } - - for (const branch of branches) { - const branchOverlaps = overlaps.get(branch) ?? new Map>() - branchOverlaps.set(file, without(branches, branch)) - overlaps.set(branch, branchOverlaps) - } - } - - return overlaps -} - -// branch 쌍을 비교해 같은 파일의 line range가 겹치는 hunk를 계산 -function buildHunkOverlaps( - inputs: BranchRiskAnalysisInput[] -): Map>> { - // branch -> file -> overlapping branch 목록 - const overlaps = new Map>>() - - for (let index = 0; index < inputs.length; index += 1) { - for (let otherIndex = index + 1; otherIndex < inputs.length; otherIndex += 1) { - const input = inputs[index] - const other = inputs[otherIndex] - - const overlappingFiles = overlappingHunkFiles( - input.changedHunks ?? [], - other.changedHunks ?? [] - ) - - for (const file of overlappingFiles) { - addOverlap(overlaps, input.branch.name, file, other.branch.name) - addOverlap(overlaps, other.branch.name, file, input.branch.name) - } - } - } - - return overlaps -} - -// branch별 overlap map에 겹친 파일과 상대 branch를 기록 -function addOverlap( - overlaps: Map>>, - branchName: string, - file: string, - otherBranchName: string -): void { - const branchOverlaps = overlaps.get(branchName) ?? new Map>() - const branches = branchOverlaps.get(file) ?? new Set() - branches.add(otherBranchName) - branchOverlaps.set(file, branches) - overlaps.set(branchName, branchOverlaps) -} - -// 두 branch의 hunk 목록 중 line range가 겹치는 파일 목록을 반환 -function overlappingHunkFiles( - hunks: BranchChangedHunk[], - otherHunks: BranchChangedHunk[] -): string[] { - // 같은 파일에서 여러 hunk가 겹쳐도 reason에는 파일을 한 번만 표시 - const files = new Set() - - for (const hunk of hunks) { - for (const other of otherHunks) { - if (hunk.filePath === other.filePath && overlapsLineRange(hunk, other)) { - files.add(hunk.filePath) - } - } - } - - return [...files] -} - -// 두 line range가 서로 겹치는지 확인 -function overlapsLineRange( - hunk: BranchChangedHunk, - other: BranchChangedHunk -): boolean { - return hunk.startLine <= other.endLine && other.startLine <= hunk.endLine -} - -// overlap map에 들어 있는 상대 branch 목록을 중복 없이 정렬 -function branchesFor(overlaps: Map>): string[] { - return [...new Set([...overlaps.values()].flatMap(branches => [...branches]))].sort() -} - -// 현재 branch를 제외한 나머지 branch 목록을 Set으로 반환 -function without(branches: Set, current: string): Set { - return new Set([...branches].filter(branch => branch !== current)) -} - -// GitHub check metadata가 실패 계열 conclusion인지 확인 -function isFailedCheck(check: BranchCheckMetadata): boolean { - return Boolean(check.conclusion && failedCheckConclusions.has(check.conclusion)) -} - -// score 구간을 사람이 읽을 수 있는 risk status로 변환 -function statusForScore(score: number): BranchRiskStatusType { - if (80 <= score) { - return BranchRiskStatus.Critical - } - - if (50 <= score) { - return BranchRiskStatus.High - } - - if (25 <= score) { - return BranchRiskStatus.Medium - } - - return BranchRiskStatus.Low -} - -// critical file pattern을 경로 전체에 매칭되는 정규식으로 변환 -function wildcardToRegExp(pattern: string): RegExp { - // **는 경로 구분자를 포함하고, *는 단일 path segment 내부만 매칭 - const segments: string[] = [] - - for (let index = 0; index < pattern.length; index += 1) { - const character = pattern[index] - const next = pattern[index + 1] - - if (character === "*" && next === "*") { - segments.push(".*") - index += 1 - continue - } - - if (character === "*") { - segments.push("[^/]*") - continue - } - - segments.push(escapeRegExp(character ?? "")) - } - - return new RegExp(`^${segments.join("")}$`) -} - -// wildcard가 아닌 문자를 정규식 literal로 안전하게 이스케이프 -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 d800806..84fd036 100644 --- a/src/risks/types.ts +++ b/src/risks/types.ts @@ -1,66 +1,6 @@ import type { - BranchComparisonPair, - BranchContext + BranchComparisonPair } from "../branches/types.js" -import type { GitMergeSignal } from "../git/types.js" - -// report와 후속 action 추천에서 사용할 branch risk 단계 -export const BranchRiskStatus = { - Low: "low", - Medium: "medium", - High: "high", - Critical: "critical" -} as const - -export type BranchRiskStatus = typeof BranchRiskStatus[keyof typeof BranchRiskStatus] - -// risk reason을 deterministic하게 분류하기 위한 rule code -export type BranchRiskReasonCode = - | "clean_merge" - | "confirmed_conflict" - | "merge_check_failed" - | "same_file_overlap" - | "same_hunk_overlap" - | "failed_check" - | "critical_file_changed" - -// 같은 파일 안에서 수정된 line range를 표현하는 Git diff hunk metadata -export type BranchChangedHunk = { - filePath: string - startLine: number - endLine: number -} - -// risk analyzer가 branch 하나를 평가할 때 필요한 입력 묶음 -export type BranchRiskAnalysisInput = { - branch: BranchContext - gitSignal: GitMergeSignal - changedHunks?: BranchChangedHunk[] -} - -// repository별로 risk rule을 조정하기 위한 설정 -export type BranchRiskAnalysisOptions = { - criticalFilePatterns?: string[] -} - -// score가 올라간 이유와 report에 보여줄 근거 metadata -export type BranchRiskReason = { - code: BranchRiskReasonCode - message: string - scoreImpact: number - files?: string[] - branches?: string[] - checks?: string[] -} - -// branch 하나에 대한 최종 merge conflict 가능성 분석 결과 -export type BranchRisk = { - branchName: string - baseBranch: string - score: number - status: BranchRiskStatus - reasons: BranchRiskReason[] -} export type BranchConflictGraphEdgeStatus = | "confirmed_conflict" diff --git a/tests/ai/evidenceBuilder.test.ts b/tests/ai/evidenceBuilder.test.ts deleted file mode 100644 index 33712ce..0000000 --- a/tests/ai/evidenceBuilder.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - buildAiPredictionEvidencePayload, - type BranchContext, - type BranchRisk, - type BranchRiskAnalysisInput, - type GitMergeSignal -} from "../../src/index.js" - -// deterministic possibility와 branch 분석 입력을 AI prediction evidence로 결합하는지 확인 -test("builds ai prediction evidence payload", () => { - const input = analysisInput("feature/watch", [{ - filePath: "src/shared.ts", - startLine: 12, - endLine: 24 - }]) - const evidence = buildAiPredictionEvidencePayload(input, possibility("feature/watch")) - - assert.equal(evidence.branch.name, "feature/watch") - assert.equal(evidence.possibility.score, 55) - assert.equal(evidence.gitSignal.changedFiles[0], "src/shared.ts") - assert.equal(evidence.changedHunks[0]?.filePath, "src/shared.ts") -}) - -// changedHunks가 없는 branch도 빈 배열 evidence로 안정적으로 변환되는지 확인 -test("uses empty changed hunks when input has no hunks", () => { - const evidence = buildAiPredictionEvidencePayload( - analysisInput("feature/watch"), - possibility("feature/watch") - ) - - assert.deepEqual(evidence.changedHunks, []) -}) - -// 다른 branch의 possibility가 섞이면 AI prediction 근거 오염을 막는지 확인 -test("rejects mismatched branch possibility", () => { - assert.throws( - () => buildAiPredictionEvidencePayload( - analysisInput("feature/watch"), - possibility("feature/other") - ), - /matching branch possibility/ - ) -}) - -function analysisInput( - branchName: string, - changedHunks: BranchRiskAnalysisInput["changedHunks"] = undefined -): BranchRiskAnalysisInput { - const context = branch(branchName) - - return { - branch: context, - gitSignal: gitSignal(branchName), - changedHunks - } -} - -function branch(name: string): BranchContext { - return { - baseBranch: "main", - name, - headSha: `${name}-sha`, - checks: [] - } -} - -function gitSignal(branchName: string): GitMergeSignal { - return { - status: "clean", - baseBranch: "main", - branchName, - changedFiles: ["src/shared.ts"], - conflictFiles: [] - } -} - -function possibility(branchName: string): BranchRisk { - return { - branchName, - baseBranch: "main", - score: 55, - status: "high", - reasons: [{ - code: "same_hunk_overlap", - message: "다른 branch와 같은 hunk를 수정함", - scoreImpact: 35, - files: ["src/shared.ts"] - }] - } -} diff --git a/tests/ai/openAiPredictionClient.test.ts b/tests/ai/openAiPredictionClient.test.ts index 9914b59..f186ef2 100644 --- a/tests/ai/openAiPredictionClient.test.ts +++ b/tests/ai/openAiPredictionClient.test.ts @@ -38,7 +38,7 @@ test("creates OpenAI client as default AI prediction client", async () => { const response = await client.predict(prompt()) - assert.deepEqual(response, validPrediction()) + assert.deepEqual(response, validConfirmedConflictResponse()) }) // Watcher prompt가 OpenAI Responses API payload로 변환되는지 확인 @@ -75,51 +75,12 @@ test("sends prompt to OpenAI responses endpoint", async () => { assert.equal(body.input[0]?.role, "developer") assert.equal(body.input[0]?.content, "Return JSON only.") assert.equal(body.input[1]?.role, "user") - assert.equal(body.input[1]?.content, "{\"branch\":\"feature/a\"}") + assert.equal(body.input[1]?.content, "{\"pair\":{\"leftBranchName\":\"feature/a\",\"rightBranchName\":\"feature/b\"}}") assert.equal(body.text.format.type, "json_schema") - assert.equal(body.text.format.name, "ai_prediction") + assert.equal(body.text.format.name, "ai_prediction_pair_confirmed_conflict") assert.equal(body.text.format.strict, true) }) -// batch prompt는 OpenAI structured output schema도 predictions 배열로 요청하는지 확인 -test("sends batch response schema to OpenAI", async () => { - const fetcher = fetchSpy(validOpenAiBatchResponse()) - const client = new OpenAiPredictionClient({ - apiKey: "openai-key", - fetch: fetcher - }) - - await client.predict(batchPrompt()) - - const request = fetcher.requests[0] - const body = JSON.parse(request?.init.body as string) as { - text: { - format: { - name: string - schema: { - additionalProperties?: boolean - properties: { - predictions?: { - items?: { - additionalProperties?: boolean - properties?: Record - } - } - } - } - } - } - } - - assert.equal(body.text.format.name, "ai_prediction_batch") - assert.equal(body.text.format.schema.additionalProperties, false) - assert.notEqual(body.text.format.schema.properties.predictions, undefined) - const predictionSchema = body.text.format.schema.properties.predictions?.items - assert.equal(predictionSchema?.additionalProperties, false) - assert.equal(predictionSchema?.properties?.confidence, undefined) - assert.equal(predictionSchema?.properties?.falsePositiveNotes, undefined) -}) - // 확정 conflict 요청이 patch 전용 schema를 사용하는지 확인 test("sends confirmed conflict pair response schema to OpenAI", async () => { const spy = fetchSpy(validOpenAiResponse()) @@ -128,7 +89,7 @@ test("sends confirmed conflict pair response schema to OpenAI", async () => { fetch: spy }) - await client.predict(confirmedConflictPairPrompt()) + await client.predict(prompt()) const body = JSON.parse(String(spy.requests[0]?.init.body)) as PairRequestBody const schema = body.text.format.schema @@ -164,7 +125,7 @@ test("sends clean overlap pair response schema to OpenAI", async () => { assert.equal(schema.properties.preventiveActions?.minItems, 1) }) -// OpenAI HTTP 실패가 branch 단위 failed result로 격리될 수 있도록 Error로 노출되는지 확인 +// OpenAI HTTP 실패가 branch 조합 failed result로 격리될 수 있도록 Error로 노출되는지 확인 test("throws when OpenAI request fails", async () => { const client = new OpenAiPredictionClient({ apiKey: "openai-key", @@ -281,21 +242,6 @@ function fetchSpy( function prompt(): AiPredictionPrompt { return { systemPrompt: "Return JSON only.", - userPrompt: "{\"branch\":\"feature/a\"}" - } -} - -function batchPrompt(): AiPredictionPrompt { - return { - systemPrompt: "Return JSON only.", - userPrompt: "{\"branches\":[{\"branch\":\"feature/a\"}]}", - responseShape: "predictionBatch" - } -} - -function confirmedConflictPairPrompt(): AiPredictionPrompt { - return { - systemPrompt: "Return confirmed conflict JSON only.", userPrompt: "{\"pair\":{\"leftBranchName\":\"feature/a\",\"rightBranchName\":\"feature/b\"}}", responseShape: "predictionPairConfirmedConflict" } @@ -314,31 +260,35 @@ function validOpenAiResponse(): unknown { return { output: [{ content: [{ - text: JSON.stringify(validPrediction()) + text: JSON.stringify(validConfirmedConflictResponse()) }] }] } } -function validOpenAiBatchResponse(): unknown { - return { - output_text: JSON.stringify({ - predictions: [validPrediction()] - }) - } -} - -// Watcher AI prediction schema를 만족하는 parsed JSON fixture -function validPrediction(): unknown { +// Watcher branch 조합 prediction schema를 만족하는 parsed JSON fixture +function validConfirmedConflictResponse(): unknown { return { - branchName: "feature/a", - baseBranch: "main", - prediction: "shared file 변경이 겹쳐 rebase 확인이 필요함", - recommendedActions: [{ - title: "base branch rebase", - description: "shared file 변경을 먼저 rebase해 실제 conflict 여부를 확인함", - priority: "high", + kind: "confirmed_conflict", + pair: { + leftBranchName: "feature/a", + rightBranchName: "feature/b" + }, + conflictCause: { + summary: "shared file conflict", files: ["src/shared.ts"] + }, + integrationOrder: { + strategy: "merge", + firstBranchName: "feature/a", + secondBranchName: "feature/b", + reason: "resolve shared changes", + steps: ["merge feature/a", "merge feature/b"] + }, + patches: [{ + filePath: "src/shared.ts", + patch: "@@ -1 +1 @@", + reason: "resolve conflict" }] } } diff --git a/tests/ai/predictionPromptBuilder.test.ts b/tests/ai/predictionPromptBuilder.test.ts deleted file mode 100644 index 9e1eec2..0000000 --- a/tests/ai/predictionPromptBuilder.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - DEFAULT_AI_PREDICTION_BATCH_SYSTEM_PROMPT, - DEFAULT_AI_PREDICTION_SYSTEM_PROMPT, - buildAiPredictionBatchPrompt, - buildAiPredictionPrompt, - type AiPredictionEvidencePayload, - type BranchContext, - type BranchRisk, - type GitMergeSignal -} from "../../src/index.js" - -// AI prompt가 deterministic score를 덮어쓰지 말라는 계약을 포함하는지 확인 -test("builds prompt that preserves deterministic possibility", () => { - const prompt = buildAiPredictionPrompt(payload()) - - assert.equal(prompt.systemPrompt, DEFAULT_AI_PREDICTION_SYSTEM_PROMPT) - assert.match(prompt.systemPrompt, /Do not recalculate or overwrite/) - assert.match(prompt.systemPrompt, /Do not describe the deterministic score as a probability/) - assert.match(prompt.systemPrompt, /Avoid phrases such as guaranteed, will cause, or will result/) - assert.match(prompt.systemPrompt, /Write prediction, recommended action titles, and descriptions in Korean/) - assert.match(prompt.systemPrompt, /Return only JSON/) - assert.match(prompt.systemPrompt, /recommendedActions/) - assert.doesNotMatch(prompt.systemPrompt, /confidence/) - assert.doesNotMatch(prompt.systemPrompt, /falsePositiveNotes/) -}) - -// user prompt가 raw diff 대신 정제된 evidence만 JSON으로 전달하는지 확인 -test("builds user prompt with structured evidence", () => { - const prompt = buildAiPredictionPrompt(payload()) - const evidence = JSON.parse(prompt.userPrompt) as Record - const branch = evidence.branch as Record - const possibility = evidence.deterministicPossibility as Record - const gitSignal = evidence.gitSignal as Record - const changedHunks = evidence.changedHunks as Record[] - - assert.equal(branch.name, "feature/watch") - assert.equal(branch.updatedAt, "2026-06-22T00:00:00.000Z") - assert.equal(possibility.score, 55) - assert.deepEqual(gitSignal.changedFiles, ["src/shared.ts"]) - assert.equal(changedHunks[0]?.filePath, "src/shared.ts") -}) - -// batch prompt는 여러 branch evidence를 하나의 provider 호출 입력으로 묶는지 확인 -test("builds batch user prompt with structured evidence list", () => { - const prompt = buildAiPredictionBatchPrompt([ - payload("feature/a"), - payload("feature/b") - ]) - const evidence = JSON.parse(prompt.userPrompt) as { - branches: Array<{ - branch: { - name: string - } - }> - } - - assert.equal(prompt.systemPrompt, DEFAULT_AI_PREDICTION_BATCH_SYSTEM_PROMPT) - assert.equal(prompt.responseShape, "predictionBatch") - assert.deepEqual(evidence.branches.map(branch => branch.branch.name), [ - "feature/a", - "feature/b" - ]) -}) - -// prompt 출력이 provider와 무관한 system/user 문자열로만 구성되는지 확인 -test("keeps prompt provider agnostic", () => { - const prompt = buildAiPredictionPrompt(payload()) - - assert.equal(typeof prompt.systemPrompt, "string") - assert.equal(typeof prompt.userPrompt, "string") - assert.equal("model" in prompt, false) -}) - -// 실행 환경에 따라 system prompt를 교체할 수 있는지 확인 -test("allows caller provided system prompt", () => { - const prompt = buildAiPredictionPrompt(payload(), { - systemPrompt: "Return only compact JSON." - }) - - assert.equal(prompt.systemPrompt, "Return only compact JSON.") -}) - -function payload(branchName = "feature/watch"): AiPredictionEvidencePayload { - return { - branch: branch(branchName), - possibility: possibility(branchName), - gitSignal: gitSignal(branchName), - changedHunks: [{ - filePath: "src/shared.ts", - startLine: 12, - endLine: 24 - }] - } -} - -function branch(name = "feature/watch"): BranchContext { - return { - baseBranch: "main", - name, - headSha: `${name}-sha`, - author: "opfic", - updatedAt: new Date("2026-06-22T00:00:00.000Z"), - checks: [{ - name: "CI", - status: "completed", - conclusion: "failure" - }], - pullRequest: { - number: 15, - title: "AI prediction", - url: "https://github.com/opficdev/Watcher/pull/15" - } - } -} - -function possibility(branchName = "feature/watch"): BranchRisk { - return { - branchName, - baseBranch: "main", - score: 55, - status: "high", - reasons: [{ - code: "same_hunk_overlap", - message: "다른 branch와 같은 hunk를 수정함", - scoreImpact: 35, - files: ["src/shared.ts"] - }] - } -} - -function gitSignal(branchName = "feature/watch"): GitMergeSignal { - return { - status: "clean", - baseBranch: "main", - branchName, - mergeBaseSha: "merge-base-sha", - changedFiles: ["src/shared.ts"], - conflictFiles: [] - } -} diff --git a/tests/ai/predictionResponseValidator.test.ts b/tests/ai/predictionResponseValidator.test.ts deleted file mode 100644 index 718cdf5..0000000 --- a/tests/ai/predictionResponseValidator.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - validateAiPredictionBatchResponse, - validateAiPredictionResponse -} from "../../src/index.js" - -// AI prediction JSON이 기대한 모델이면 그대로 통과하는지 확인 -test("validates ai prediction response", () => { - const prediction = validateAiPredictionResponse({ - branchName: "feature/watch", - baseBranch: "main", - prediction: "shared module 변경 의도가 겹쳐 rebase 우선 확인이 필요함", - recommendedActions: [{ - title: "base branch rebase", - description: "shared.ts 변경을 먼저 rebase해 실제 conflict 여부를 확인함", - priority: "high", - files: ["src/shared.ts"] - }] - }) - - assert.equal(prediction.branchName, "feature/watch") - assert.equal(prediction.recommendedActions[0]?.priority, "high") -}) - -// batch AI prediction JSON이 branch별 prediction 배열이면 그대로 통과하는지 확인 -test("validates ai prediction batch response", () => { - const predictions = validateAiPredictionBatchResponse({ - predictions: [ - validResponse("feature/a"), - validResponse("feature/b") - ] - }) - - assert.deepEqual(predictions.map(prediction => prediction.branchName), [ - "feature/a", - "feature/b" - ]) -}) - -// batch prediction 일부가 잘못되어도 유효한 prediction은 유지되는지 확인 -test("keeps valid predictions when batch contains invalid item", () => { - const predictions = validateAiPredictionBatchResponse({ - predictions: [ - validResponse("feature/a"), - { - branchName: "feature/b", - baseBranch: "main", - prediction: "shared module 변경 의도가 겹쳐 rebase 우선 확인이 필요함", - recommendedActions: [{ - title: "base branch rebase", - description: "shared.ts 변경을 먼저 rebase해 실제 conflict 여부를 확인함", - priority: "urgent" - }] - } - ] - }) - - assert.deepEqual(predictions.map(prediction => prediction.branchName), ["feature/a"]) -}) - -// action priority가 허용된 값이 아니면 AI 응답을 거부하는지 확인 -test("rejects invalid action priority", () => { - assert.throws( - () => validateAiPredictionResponse({ - ...validResponse(), - recommendedActions: [{ - title: "check files", - description: "shared.ts 확인", - priority: "urgent" - }] - }), - /priority/ - ) -}) - -// recommendedActions가 누락되거나 null이면 빈 배열로 보정되는지 확인 -test("defaults nullish recommended actions", () => { - const prediction = validateAiPredictionResponse({ - branchName: "feature/watch", - baseBranch: "main", - prediction: "shared module 변경 의도가 겹쳐 rebase 우선 확인이 필요함", - recommendedActions: null - }) - - assert.deepEqual(prediction.recommendedActions, []) -}) - -// recommendedActions가 배열이 아닌 값이면 AI 응답을 거부하는지 확인 -test("rejects non-array recommended actions", () => { - assert.throws( - () => validateAiPredictionResponse({ - ...validResponse(), - recommendedActions: "none" - }), - /recommendedActions/ - ) -}) - -// action files가 null이면 optional field 없음으로 처리하는지 확인 -test("defaults null action files", () => { - const prediction = validateAiPredictionResponse({ - ...validResponse(), - recommendedActions: [{ - title: "check files", - description: "shared.ts 확인", - priority: "medium", - files: null - }] - }) - - assert.equal(prediction.recommendedActions[0]?.files, undefined) -}) - -// files가 있으면 string 배열이어야 하는지 확인 -test("rejects non-string action files", () => { - assert.throws( - () => validateAiPredictionResponse({ - ...validResponse(), - recommendedActions: [{ - title: "check files", - description: "shared.ts 확인", - priority: "medium", - files: ["src/shared.ts", 10] - }] - }), - /files\[1\]/ - ) -}) - -function validResponse(branchName = "feature/watch"): Record { - return { - branchName, - baseBranch: "main", - prediction: "shared module 변경 의도가 겹쳐 rebase 우선 확인이 필요함", - recommendedActions: [{ - title: "base branch rebase", - description: "shared.ts 변경을 먼저 rebase해 실제 conflict 여부를 확인함", - priority: "high", - files: ["src/shared.ts"] - }] - } -} diff --git a/tests/ai/predictionRunner.test.ts b/tests/ai/predictionRunner.test.ts deleted file mode 100644 index 4260a8b..0000000 --- a/tests/ai/predictionRunner.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - BranchRiskStatus, - predictMergeRisksWithAi, - type AiPredictionClient, - type AiPredictionFailureDebugEvent, - type AiPredictionEvidencePayload, - type AiPredictionPrompt, - type AiPredictionPromptDebugEvent, - type AiPredictionResponseDebugEvent, - type BranchContext, - type BranchRisk, - type BranchRiskReasonCode, - type GitMergeSignal -} from "../../src/index.js" - -// critical branch만 한 번의 batch AI client 호출로 prediction을 요청하는지 확인 -test("predicts selected critical merge risk payloads", async () => { - const client = new AiPredictionClientSpy() - const results = await predictMergeRisksWithAi([ - payload("feature/high", 55, BranchRiskStatus.High), - payload("feature/critical", 100, BranchRiskStatus.Critical) - ], client) - - assert.deepEqual(results.map(result => result.status), ["skipped", "predicted"]) - assert.equal(client.prompts.length, 1) - assert.equal(client.prompts[0]?.responseShape, "predictionBatch") - const predicted = results[1] - assert.equal( - predicted?.status === "predicted" ? predicted.prediction.branchName : undefined, - "feature/critical" - ) -}) - -// score가 높아도 critical status가 아니면 AI prediction을 생략하는지 확인 -test("skips non-critical payloads", async () => { - const client = new AiPredictionClientSpy() - const results = await predictMergeRisksWithAi([ - payload("feature/high", 90, BranchRiskStatus.High) - ], client) - - assert.deepEqual(results.map(result => result.status), ["skipped"]) - assert.equal(results[0]?.status === "skipped" ? results[0].reason : undefined, "not_target") - assert.equal(client.prompts.length, 0) -}) - -// 이미 Git conflict가 확정된 branch는 AI prediction을 생략하고 확정 충돌 사유를 기록하는지 확인 -test("records confirmed conflict skip reason", async () => { - const client = new AiPredictionClientSpy() - const [result] = await predictMergeRisksWithAi([ - payload("feature/conflict", 100, BranchRiskStatus.Critical, "confirmed_conflict") - ], client) - - assert.equal(result?.status, "skipped") - assert.equal(result?.status === "skipped" ? result.reason : undefined, "confirmed_conflict") - assert.equal(client.prompts.length, 0) -}) - -// 여러 critical branch를 한 번의 AI 응답으로 다시 branch별 결과에 매칭하는지 확인 -test("maps batch prediction response to selected payload order", async () => { - const client = new AiPredictionClientSpy({ - predictions: [ - validResponse("feature/b"), - validResponse("feature/a") - ] - }) - const results = await predictMergeRisksWithAi([ - payload("feature/a", 100, BranchRiskStatus.Critical), - payload("feature/b", 100, BranchRiskStatus.Critical) - ], client) - - assert.deepEqual(results.map(result => result.status), ["predicted", "predicted"]) - assert.equal( - results[0]?.status === "predicted" ? results[0].prediction.branchName : undefined, - "feature/a" - ) - assert.equal( - results[1]?.status === "predicted" ? results[1].prediction.branchName : undefined, - "feature/b" - ) - assert.equal(client.prompts.length, 1) -}) - -// AI client 오류가 전체 실행 실패가 아니라 선택된 branch 단위 failed 결과로 기록되는지 확인 -test("records failed result when client throws", async () => { - const client = new AiPredictionClientSpy(new Error("provider failed")) - const results = await predictMergeRisksWithAi([ - payload("feature/a", 100, BranchRiskStatus.Critical), - payload("feature/b", 100, BranchRiskStatus.Critical) - ], client) - - assert.deepEqual(results.map(result => result.status), ["failed", "failed"]) - assert.match(results[0]?.status === "failed" ? results[0].errorMessage : "", /provider failed/) - assert.match(results[1]?.status === "failed" ? results[1].errorMessage : "", /provider failed/) -}) - -// schema validation 실패가 branch 단위 failed 결과로 기록되는지 확인 -test("records failed result when response is invalid", async () => { - const client = new AiPredictionClientSpy({ - predictions: [{ - branchName: "feature/critical", - baseBranch: "main", - prediction: "invalid priority", - recommendedActions: [{ - title: "base branch rebase", - description: "shared.ts 확인", - priority: "urgent" - }] - }] - }) - const [result] = await predictMergeRisksWithAi([ - payload("feature/critical", 100, BranchRiskStatus.Critical) - ], client) - - assert.equal(result?.status, "failed") - assert.match(result?.status === "failed" ? result.errorMessage : "", /AI prediction response is missing/) -}) - -// batch 응답 중 일부만 검증에 실패하면 해당 branch만 failed 처리하는지 확인 -test("keeps valid batch predictions when one response item is invalid", async () => { - const client = new AiPredictionClientSpy({ - predictions: [ - validResponse("feature/a"), - { - ...(validResponse("feature/b") as Record), - recommendedActions: [{ - title: "base branch rebase", - description: "shared.ts 확인", - priority: "urgent" - }] - } - ] - }) - const results = await predictMergeRisksWithAi([ - payload("feature/a", 100, BranchRiskStatus.Critical), - payload("feature/b", 100, BranchRiskStatus.Critical) - ], client) - - assert.equal(results[0]?.status, "predicted") - assert.equal(results[1]?.status, "failed") - assert.match(results[1]?.status === "failed" ? results[1].errorMessage : "", /AI prediction response is missing/) -}) - -// prompt builder 옵션이 runner를 통해 AI client까지 전달되는지 확인 -test("passes custom system prompt to client", async () => { - const client = new AiPredictionClientSpy() - await predictMergeRisksWithAi([ - payload("feature/critical", 100, BranchRiskStatus.Critical) - ], client, { - systemPrompt: "Return compact JSON." - }) - - assert.equal(client.prompts[0]?.systemPrompt, "Return compact JSON.") -}) - -// AI prompt와 provider response를 debug observer로 전달하는지 확인 -test("notifies debug observer with prompt and response", async () => { - const prompts: AiPredictionPromptDebugEvent[] = [] - const responses: AiPredictionResponseDebugEvent[] = [] - const client = new AiPredictionClientSpy() - - await predictMergeRisksWithAi([ - payload("feature/critical", 100, BranchRiskStatus.Critical) - ], client, { - debugObserver: { - onPromptBuilt: event => { - prompts.push(event) - }, - onResponseReceived: event => { - responses.push(event) - } - } - }) - - assert.deepEqual(prompts[0]?.targetBranches, [{ - branchName: "feature/critical", - baseBranch: "main" - }]) - assert.equal(prompts[0]?.prompt.responseShape, "predictionBatch") - assert.deepEqual(responses[0]?.targetBranches, [{ - branchName: "feature/critical", - baseBranch: "main" - }]) - assert.deepEqual(responses[0]?.response, validBatchResponse(["feature/critical"])) -}) - -// AI provider나 response 검증 실패를 debug observer로 전달하는지 확인 -test("notifies debug observer when prediction fails", async () => { - const failures: AiPredictionFailureDebugEvent[] = [] - const client = new AiPredictionClientSpy(new Error("provider failed")) - - await predictMergeRisksWithAi([ - payload("feature/critical", 100, BranchRiskStatus.Critical) - ], client, { - debugObserver: { - onPredictionFailed: event => { - failures.push(event) - } - } - }) - - assert.deepEqual(failures[0]?.targetBranches, [{ - branchName: "feature/critical", - baseBranch: "main" - }]) - assert.match(failures[0]?.errorMessage ?? "", /provider failed/) -}) - -// prompt debug observer가 실패해도 AI prediction은 계속 진행되는지 확인 -test("continues prediction when prompt debug observer throws", async () => { - const client = new AiPredictionClientSpy() - const results = await suppressConsoleWarn(() => predictMergeRisksWithAi([ - payload("feature/critical", 100, BranchRiskStatus.Critical) - ], client, { - debugObserver: { - onPromptBuilt: () => { - throw new Error("prompt artifact failed") - } - } - })) - - assert.equal(client.prompts.length, 1) - assert.equal(results[0]?.status, "predicted") -}) - -// response debug observer가 실패해도 provider response 검증과 결과 매핑은 계속되는지 확인 -test("continues prediction when response debug observer throws", async () => { - const client = new AiPredictionClientSpy() - const results = await suppressConsoleWarn(() => predictMergeRisksWithAi([ - payload("feature/critical", 100, BranchRiskStatus.Critical) - ], client, { - debugObserver: { - onResponseReceived: () => { - throw new Error("response artifact failed") - } - } - })) - - assert.equal(results[0]?.status, "predicted") -}) - -// failure debug observer가 실패해도 branch별 failed result는 반환되는지 확인 -test("returns failed prediction when failure debug observer throws", async () => { - const client = new AiPredictionClientSpy(new Error("provider failed")) - const results = await suppressConsoleWarn(() => predictMergeRisksWithAi([ - payload("feature/critical", 100, BranchRiskStatus.Critical) - ], client, { - debugObserver: { - onPredictionFailed: () => { - throw new Error("failure artifact failed") - } - } - })) - - assert.equal(results[0]?.status, "failed") - assert.match(results[0]?.status === "failed" ? results[0].errorMessage : "", /provider failed/) -}) - -class AiPredictionClientSpy implements AiPredictionClient { - prompts: AiPredictionPrompt[] = [] - - constructor(private readonly response?: unknown) {} - - async predict(prompt: AiPredictionPrompt): Promise { - this.prompts.push(prompt) - - if (this.response instanceof Error) { - throw this.response - } - - return this.response ?? validBatchResponse(branchNamesFrom(prompt)) - } -} - -function branchNamesFrom(prompt: AiPredictionPrompt): string[] { - const evidence = JSON.parse(prompt.userPrompt) as { - branches: Array<{ - branch: { - name: string - } - }> - } - - return evidence.branches.map(branch => branch.branch.name) -} - -async function suppressConsoleWarn(operation: () => Promise): Promise { - const originalWarn = console.warn - - console.warn = () => {} - - try { - return await operation() - } finally { - console.warn = originalWarn - } -} - -function payload( - branchName: string, - score: number, - status: BranchRiskStatus, - reasonCode: BranchRiskReasonCode = "same_hunk_overlap" -): AiPredictionEvidencePayload { - return { - branch: branch(branchName), - possibility: possibility(branchName, score, status, reasonCode), - gitSignal: gitSignal(branchName), - changedHunks: [] - } -} - -function branch(name: string): BranchContext { - return { - baseBranch: "main", - name, - headSha: `${name}-sha`, - checks: [] - } -} - -function possibility( - branchName: string, - score: number, - status: BranchRiskStatus, - reasonCode: BranchRiskReasonCode -): BranchRisk { - return { - branchName, - baseBranch: "main", - score, - status, - reasons: [{ - code: reasonCode, - message: "다른 branch와 같은 hunk를 수정함", - scoreImpact: score - }] - } -} - -function gitSignal(branchName: string): GitMergeSignal { - return { - status: "clean", - baseBranch: "main", - branchName, - changedFiles: ["src/shared.ts"], - conflictFiles: [] - } -} - -function validBatchResponse(branchNames: string[]): unknown { - return { - predictions: branchNames.map(validResponse) - } -} - -function validResponse(branchName: string): unknown { - return { - branchName, - baseBranch: "main", - prediction: "shared module 변경 의도가 겹쳐 rebase 우선 확인이 필요함", - recommendedActions: [{ - title: "base branch rebase", - description: "shared.ts 변경을 먼저 rebase해 실제 conflict 여부를 확인함", - priority: "high", - files: ["src/shared.ts"] - }] - } -} diff --git a/tests/ai/predictionTargetSelector.test.ts b/tests/ai/predictionTargetSelector.test.ts deleted file mode 100644 index 8cec880..0000000 --- a/tests/ai/predictionTargetSelector.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - BranchRiskStatus, - DEFAULT_AI_PREDICTION_TARGET_STATUS, - selectAiPredictionTargets, - type AiPredictionEvidencePayload, - type BranchContext, - type BranchRisk, - type GitMergeSignal -} from "../../src/index.js" -import type { BranchRiskReasonCode } from "../../src/risks/types.js" - -// 기본 기준으로 critical possibility만 AI prediction 대상으로 선택하는지 확인 -test("selects critical targets by default", () => { - const selected = selectAiPredictionTargets([ - payload("feature/low", 20, BranchRiskStatus.Low), - payload("feature/high", 55, BranchRiskStatus.High), - payload("feature/critical", 100, DEFAULT_AI_PREDICTION_TARGET_STATUS) - ]) - - assert.deepEqual(selected.map(target => target.branch.name), ["feature/critical"]) -}) - -// score가 높아도 critical status가 아니면 OpenAI 호출 대상에서 제외되는지 확인 -test("skips high score non-critical targets", () => { - const selected = selectAiPredictionTargets([ - payload("feature/high", 90, BranchRiskStatus.High) - ]) - - assert.deepEqual(selected, []) -}) - -// critical status면 score 값과 별개로 AI prediction 대상에 포함되는지 확인 -test("includes critical status targets", () => { - const selected = selectAiPredictionTargets([ - payload("feature/critical", 80, BranchRiskStatus.Critical) - ]) - - assert.deepEqual(selected.map(target => target.branch.name), ["feature/critical"]) -}) - -// 이미 Git conflict가 확정된 branch는 AI prediction 없이 deterministic report만 사용하는지 확인 -test("skips confirmed conflict targets", () => { - const selected = selectAiPredictionTargets([ - payload("feature/conflict", 100, BranchRiskStatus.Critical, "confirmed_conflict") - ]) - - assert.deepEqual(selected, []) -}) - -function payload( - branchName: string, - score: number, - status: BranchRiskStatus, - reasonCode: BranchRiskReasonCode = "merge_check_failed" -): AiPredictionEvidencePayload { - return { - branch: branch(branchName), - possibility: possibility(branchName, score, status, reasonCode), - gitSignal: gitSignal(branchName), - changedHunks: [] - } -} - -function branch(name: string): BranchContext { - return { - baseBranch: "main", - name, - headSha: `${name}-sha`, - checks: [] - } -} - -function possibility( - branchName: string, - score: number, - status: BranchRiskStatus, - reasonCode: BranchRiskReasonCode -): BranchRisk { - return { - branchName, - baseBranch: "main", - score, - status, - reasons: [{ - code: reasonCode, - message: "virtual merge 확인에 실패함", - scoreImpact: score - }] - } -} - -function gitSignal(branchName: string): GitMergeSignal { - return { - status: "clean", - baseBranch: "main", - branchName, - changedFiles: ["src/shared.ts"], - conflictFiles: [] - } -} diff --git a/tests/ai/types.test.ts b/tests/ai/types.test.ts deleted file mode 100644 index 49b3b1b..0000000 --- a/tests/ai/types.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import type { - AiPrediction, - AiPredictionEvidencePayload, - BranchContext, - BranchRisk, - GitMergeSignal -} from "../../src/index.js" - -// AI prediction이 deterministic possibility를 덮어쓰지 않고 별도 결과로 표현되는지 확인 -test("models ai prediction separately from deterministic possibility", () => { - const prediction: AiPrediction = { - branchName: "feature/watch", - baseBranch: "main", - prediction: "shared module 변경 의도가 겹쳐 rebase 우선 확인이 필요함", - recommendedActions: [{ - title: "base branch rebase", - description: "shared.ts 변경을 먼저 rebase해 실제 conflict 여부를 확인함", - priority: "high", - files: ["src/shared.ts"] - }] - } - - assert.equal(prediction.branchName, "feature/watch") - assert.equal(prediction.recommendedActions[0]?.priority, "high") -}) - -// AI에 정제된 deterministic evidence payload를 전달할 수 있는지 확인 -test("models ai prediction evidence payload", () => { - const evidence: AiPredictionEvidencePayload = { - branch: branch(), - possibility: possibility(), - gitSignal: gitSignal(), - changedHunks: [{ - filePath: "src/shared.ts", - startLine: 12, - endLine: 24 - }] - } - - assert.equal(evidence.possibility.score, 55) - assert.equal(evidence.gitSignal.changedFiles[0], "src/shared.ts") -}) - -function branch(): BranchContext { - return { - baseBranch: "main", - name: "feature/watch", - headSha: "feature-watch-sha", - checks: [] - } -} - -function possibility(): BranchRisk { - return { - branchName: "feature/watch", - baseBranch: "main", - score: 55, - status: "high", - reasons: [{ - code: "same_hunk_overlap", - message: "다른 branch와 같은 hunk를 수정함", - scoreImpact: 35, - files: ["src/shared.ts"] - }] - } -} - -function gitSignal(): GitMergeSignal { - return { - status: "clean", - baseBranch: "main", - branchName: "feature/watch", - changedFiles: ["src/shared.ts"], - conflictFiles: [] - } -} diff --git a/tests/debug/aiPredictionArtifact.test.ts b/tests/debug/aiPredictionArtifact.test.ts index a817dd1..1a8fc1b 100644 --- a/tests/debug/aiPredictionArtifact.test.ts +++ b/tests/debug/aiPredictionArtifact.test.ts @@ -4,68 +4,58 @@ import { createHash } from "node:crypto" import { sanitizeAiPredictionPairFailureDebugEvent, sanitizeAiPredictionPairPromptDebugEvent, - sanitizeAiPredictionPairResponseDebugEvent, - sanitizeAiPredictionPromptDebugEvent, - sanitizeAiPredictionResponseDebugEvent + sanitizeAiPredictionPairResponseDebugEvent } from "../../src/debug/aiPredictionArtifact.js" -// AI prompt artifact가 코드 원문 대신 file과 line metadata를 기록하는지 확인 -test("replaces AI prompt code context with metadata", () => { - const content = "const consumerSourceMarker = true\nreturn consumerSourceMarker\n" +// pair prompt artifact에서 ordered targetPair를 유지하고 코드 원문을 제거 +test("sanitizes pair AI prompt debug event", () => { + const content = "const pairSourceMarker = true\n" const event = { - targetBranches: [{ - branchName: "feature/left", - baseBranch: "main" - }], + targetPair: { + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }, prompt: { - systemPrompt: "Review merge risk", + systemPrompt: "Review branch pair", userPrompt: JSON.stringify({ - codeContext: { - evidence: [{ - leftSnippet: { - status: "text", - filePath: "Sources/Feature.swift", - content, - startLine: 12, - endLine: 13, - truncated: false - } - }] + snippet: { + filePath: "Sources/Pair.swift", + content, + startLine: 7, + endLine: 7 } - }, null, 2), + }), responseShape: "predictionPairCleanOverlap" } } - const artifact = sanitizeAiPredictionPromptDebugEvent(event) + const artifact = sanitizeAiPredictionPairPromptDebugEvent(event) const payload = JSON.parse(artifact.prompt.userPrompt) as { - codeContext?: { - evidence?: Array<{ - leftSnippet?: Record - }> - } + snippet?: Record } - assert.deepEqual(payload.codeContext?.evidence?.[0]?.leftSnippet, { - status: "text", - filePath: "Sources/Feature.swift", - startLine: 12, - endLine: 13, - truncated: false, + assert.deepEqual(artifact.targetPair, event.targetPair) + assert.deepEqual(payload.snippet, { + filePath: "Sources/Pair.swift", + startLine: 7, + endLine: 7, contentByteLength: Buffer.byteLength(content, "utf8"), contentHash: createHash("sha256").update(content, "utf8").digest("hex"), - lineCount: 2 + lineCount: 1 }) - assert.equal(JSON.parse(event.prompt.userPrompt).codeContext.evidence[0].leftSnippet.content, content) - assert.doesNotMatch(JSON.stringify(artifact), /consumerSourceMarker/) + assert.doesNotMatch(JSON.stringify(artifact), /pairSourceMarker/) + assert.equal(JSON.parse(event.prompt.userPrompt).snippet.content, content) }) -// 빈 코드 문맥도 0 byte 원문 대신 line과 hash metadata를 기록하는지 확인 -test("records metadata for empty AI prompt code context", () => { - const artifact = sanitizeAiPredictionPromptDebugEvent({ - targetBranches: [], +// 빈 pair 코드 문맥도 0 byte 원문 대신 line과 hash metadata를 기록하는지 확인 +test("records metadata for empty pair AI prompt code context", () => { + const artifact = sanitizeAiPredictionPairPromptDebugEvent({ + targetPair: { + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }, prompt: { - systemPrompt: "Review merge risk", + systemPrompt: "Review branch pair", userPrompt: JSON.stringify({ snippet: { filePath: "Sources/Empty.swift", @@ -74,7 +64,8 @@ test("records metadata for empty AI prompt code context", () => { endLine: 1, truncated: false } - }) + }), + responseShape: "predictionPairCleanOverlap" } }) const payload = JSON.parse(artifact.prompt.userPrompt) as { @@ -92,119 +83,27 @@ test("records metadata for empty AI prompt code context", () => { }) }) -// 비정형 user prompt도 원문을 저장하지 않고 크기와 hash만 기록하는지 확인 -test("redacts non-JSON AI user prompt", () => { +// 비정형 pair user prompt도 원문을 저장하지 않고 크기와 hash만 기록하는지 확인 +test("redacts non-JSON pair AI user prompt", () => { const userPrompt = "consumer repository source" - const event = { - targetBranches: [], - prompt: { - systemPrompt: "Review merge risk", - userPrompt - } - } - - const artifact = sanitizeAiPredictionPromptDebugEvent(event) - - assert.deepEqual(JSON.parse(artifact.prompt.userPrompt), { - contentByteLength: Buffer.byteLength(userPrompt, "utf8"), - contentHash: createHash("sha256").update(userPrompt, "utf8").digest("hex"), - redacted: true - }) - assert.equal(event.prompt.userPrompt, userPrompt) - assert.doesNotMatch(JSON.stringify(artifact), /consumer repository source/) -}) - -// AI response artifact가 제안 patch 원문 대신 크기와 hunk 범위를 기록하는지 확인 -test("replaces AI response patch with metadata", () => { - const patch = [ - "--- a/Sources/Feature.swift", - "+++ b/Sources/Feature.swift", - "@@ -10,2 +10,2 @@", - "-let consumerSourceMarker = false", - "+let consumerSourceMarker = true", - "@@ -30 +30,3 @@ apply", - "-return value", - "+return value", - "+return result" - ].join("\n") - const artifact = sanitizeAiPredictionResponseDebugEvent({ - targetBranches: [{ - branchName: "feature/left", - baseBranch: "main" - }], - response: { - kind: "confirmed_conflict", - patches: [{ - filePath: "Sources/Feature.swift", - patch, - reason: "충돌 상태 갱신" - }] - } - }) - const response = artifact.response as { - patches?: Array> - } - - assert.deepEqual(response.patches?.[0], { - filePath: "Sources/Feature.swift", - patch: { - byteLength: Buffer.byteLength(patch, "utf8"), - lineCount: 9, - hunkRanges: [{ - oldStart: 10, - oldCount: 2, - newStart: 10, - newCount: 2 - }, { - oldStart: 30, - oldCount: 1, - newStart: 30, - newCount: 3 - }] - }, - reason: "충돌 상태 갱신" - }) - assert.doesNotMatch(JSON.stringify(artifact), /consumerSourceMarker/) -}) - -// pair prompt artifact에서 ordered targetPair를 유지하고 코드 원문을 제거 -test("sanitizes pair AI prompt debug event", () => { - const content = "const pairSourceMarker = true\n" - const event = { + const artifact = sanitizeAiPredictionPairPromptDebugEvent({ targetPair: { leftBranchName: "feature/left", rightBranchName: "feature/right" }, prompt: { systemPrompt: "Review branch pair", - userPrompt: JSON.stringify({ - snippet: { - filePath: "Sources/Pair.swift", - content, - startLine: 7, - endLine: 7 - } - }), + userPrompt, responseShape: "predictionPairCleanOverlap" } - } - - const artifact = sanitizeAiPredictionPairPromptDebugEvent(event) - const payload = JSON.parse(artifact.prompt.userPrompt) as { - snippet?: Record - } + }) - assert.deepEqual(artifact.targetPair, event.targetPair) - assert.deepEqual(payload.snippet, { - filePath: "Sources/Pair.swift", - startLine: 7, - endLine: 7, - contentByteLength: Buffer.byteLength(content, "utf8"), - contentHash: createHash("sha256").update(content, "utf8").digest("hex"), - lineCount: 1 + assert.deepEqual(JSON.parse(artifact.prompt.userPrompt), { + contentByteLength: Buffer.byteLength(userPrompt, "utf8"), + contentHash: createHash("sha256").update(userPrompt, "utf8").digest("hex"), + redacted: true }) - assert.doesNotMatch(JSON.stringify(artifact), /pairSourceMarker/) - assert.equal(JSON.parse(event.prompt.userPrompt).snippet.content, content) + assert.doesNotMatch(JSON.stringify(artifact), /consumer repository source/) }) // pair response artifact에서 ordered targetPair를 유지하고 patch 원문을 제거 diff --git a/tests/git/gitMergeSignalCollector.test.ts b/tests/git/gitMergeSignalCollector.test.ts deleted file mode 100644 index 7a0fe1e..0000000 --- a/tests/git/gitMergeSignalCollector.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { execFile } from "node:child_process" -import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { promisify } from "node:util" -import { collectGitMergeSignal, type BranchContext } from "../../src/index.js" -import { collectGitMergeSignalFromPairResult } from "../../src/git/gitMergeSignalCollector.js" - -const execFileAsync = promisify(execFile) - -// conflict가 없는 branch를 clean signal과 변경 파일 목록으로 표현하는지 확인 -test("collects clean merge signal", async () => { - const fixture = await createGitFixture() - - try { - const signal = await collectGitMergeSignal(branch("main", "feature/clean"), { - repositoryPath: fixture.repositoryPath, - worktreeRoot: fixture.worktreeRoot - }) - - assert.equal(signal.status, "clean") - assert.deepEqual(signal.changedFiles, ["clean.txt"]) - assert.deepEqual(signal.conflictFiles, []) - assert.match(signal.mergeBaseSha ?? "", /^[0-9a-f]{40}$/) - assert.deepEqual(await readdir(fixture.worktreeRoot), []) - } finally { - await fixture.remove() - } -}) - -// 같은 파일의 같은 위치를 수정한 branch를 confirmed_conflict signal로 표현하는지 확인 -test("collects confirmed conflict signal", async () => { - const fixture = await createGitFixture() - - try { - const signal = await collectGitMergeSignal(branch("main", "feature/conflict"), { - repositoryPath: fixture.repositoryPath, - worktreeRoot: fixture.worktreeRoot - }) - - assert.equal(signal.status, "confirmed_conflict") - assert.deepEqual(signal.changedFiles, ["shared.txt"]) - assert.deepEqual(signal.conflictFiles, ["shared.txt"]) - assert.deepEqual(await readdir(fixture.worktreeRoot), []) - } finally { - await fixture.remove() - } -}) - -// fetch나 merge 준비가 실패하면 merge_check_failed signal로 표현하는지 확인 -test("collects merge check failure signal", async () => { - const fixture = await createGitFixture() - - try { - const signal = await collectGitMergeSignal(branch("main", "feature/missing"), { - repositoryPath: fixture.repositoryPath, - worktreeRoot: fixture.worktreeRoot - }) - - assert.equal(signal.status, "merge_check_failed") - assert.deepEqual(signal.changedFiles, []) - assert.deepEqual(signal.conflictFiles, []) - assert.match(signal.errorMessage ?? "", /feature\/missing/) - assert.deepEqual(await readdir(fixture.worktreeRoot), []) - } finally { - await fixture.remove() - } -}) - -// base branch가 조합의 오른쪽에 있어도 기존 branch signal로 변환하는지 확인 -test("converts a base pair result independent of pair direction", async () => { - const fixture = await createGitFixture() - - try { - const signal = await collectGitMergeSignalFromPairResult( - branch("main", "feature/conflict"), - { - pair: { - leftBranchName: "feature/conflict", - rightBranchName: "main" - }, - status: "confirmed_conflict", - mergedTreeOid: "1".repeat(40), - conflictFiles: ["shared.txt"], - conflicts: [{ - paths: ["shared.txt"], - type: "CONFLICT (contents)" - }] - }, - { - repositoryPath: fixture.repositoryPath, - worktreeRoot: fixture.worktreeRoot - } - ) - - assert.equal(signal.status, "confirmed_conflict") - assert.deepEqual(signal.changedFiles, ["shared.txt"]) - assert.deepEqual(signal.conflictFiles, ["shared.txt"]) - assert.deepEqual(await readdir(fixture.worktreeRoot), []) - } finally { - await fixture.remove() - } -}) - -// fetch와 ref 고정 실패에서는 오래된 ref의 변경 정보를 다시 사용하지 않는지 확인 -test("keeps changed files empty after merge-tree preparation failure", async () => { - const fixture = await createGitFixture() - - try { - const signal = await collectGitMergeSignalFromPairResult( - branch("main", "feature/conflict"), - { - pair: { - leftBranchName: "feature/conflict", - rightBranchName: "main" - }, - status: "merge_check_failed", - conflictFiles: [], - conflicts: [], - errorMessage: "git fetch failed for remote origin", - failureStage: "preparation" - }, - { - repositoryPath: fixture.repositoryPath - } - ) - - assert.equal(signal.status, "merge_check_failed") - assert.equal(signal.mergeBaseSha, undefined) - assert.deepEqual(signal.changedFiles, []) - assert.deepEqual(signal.conflictFiles, []) - } finally { - await fixture.remove() - } -}) - -async function createGitFixture(): Promise<{ - repositoryPath: string - worktreeRoot: string - remove(): Promise -}> { - const root = await mkdtemp(join(tmpdir(), "watcher-git-fixture-")) - const repositoryPath = join(root, "repository") - const remotePath = join(root, "remote.git") - const worktreeRoot = join(root, "worktrees") - - await git(root, ["init", "--initial-branch=main", repositoryPath]) - await git(repositoryPath, ["config", "user.email", "opfic@example.com"]) - await git(repositoryPath, ["config", "user.name", "opfic"]) - - await writeFile(join(repositoryPath, "shared.txt"), "value=base\n") - await git(repositoryPath, ["add", "shared.txt"]) - await git(repositoryPath, ["commit", "-m", "initial"]) - - await git(repositoryPath, ["checkout", "-b", "feature/conflict"]) - await writeFile(join(repositoryPath, "shared.txt"), "value=feature\n") - await git(repositoryPath, ["commit", "-am", "feature conflict"]) - - await git(repositoryPath, ["checkout", "main"]) - await writeFile(join(repositoryPath, "shared.txt"), "value=main\n") - await git(repositoryPath, ["commit", "-am", "main conflict"]) - - await git(repositoryPath, ["checkout", "-b", "feature/clean"]) - await writeFile(join(repositoryPath, "clean.txt"), "clean\n") - await git(repositoryPath, ["add", "clean.txt"]) - await git(repositoryPath, ["commit", "-m", "feature clean"]) - - await git(repositoryPath, ["checkout", "main"]) - await git(root, ["init", "--bare", remotePath]) - await git(repositoryPath, ["remote", "add", "origin", remotePath]) - await git(repositoryPath, ["push", "--quiet", "origin", "main", "feature/conflict", "feature/clean"]) - await mkdir(worktreeRoot) - - return { - repositoryPath, - worktreeRoot, - async remove(): Promise { - await rm(root, { recursive: true, force: true }) - } - } -} - -function branch(baseBranch: string, name: string): BranchContext { - return { - baseBranch, - name, - headSha: `${name}-sha`, - checks: [] - } -} - -async function git(cwd: string, args: string[]): Promise { - const result = await execFileAsync("git", args, { - cwd, - maxBuffer: 10 * 1024 * 1024 - }) - - return result.stdout.trim() -} diff --git a/tests/reports/markdownFormatter.test.ts b/tests/reports/markdownFormatter.test.ts deleted file mode 100644 index 54bd094..0000000 --- a/tests/reports/markdownFormatter.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - BranchRiskStatus, - formatMergeRiskReportMarkdown, - type AiPredictionResult, - type BranchRiskReason, - type MergeRiskReport -} from "../../src/index.js" - -// report 기본 요약과 status section이 Markdown으로 변환되는지 확인 -test("formats merge risk report summary and sections", () => { - const markdown = formatMergeRiskReportMarkdown(report()) - - assert.match(markdown, /## Merge Risk Report/) - assert.match(markdown, /- base branch: `main`/) - assert.match(markdown, /- watched branches: 1/) - assert.match(markdown, /### High/) - assert.match(markdown, /#### `feature\/risk`/) - assert.match(markdown, /- score\/status: `65` \/ `high`/) -}) - -// branch metadata는 유지하되 Pull Request metadata는 Markdown item에서 제외되는지 확인 -test("formats branch metadata without pull request link", () => { - const markdown = formatMergeRiskReportMarkdown(report()) - - assert.match(markdown, /- author: `opfic`/) - assert.match(markdown, /- updated: `2026-06-22T01:00:00.000Z`/) - assert.doesNotMatch(markdown, /pull request/) - assert.doesNotMatch(markdown, /github\.com\/opficdev\/Watcher\/pull\/12/) -}) - -// deterministic reason의 관련 파일, branch, check metadata가 표시되는지 확인 -test("formats deterministic reason metadata", () => { - const markdown = formatMergeRiskReportMarkdown(report()) - - assert.match(markdown, /- `same_hunk_overlap` \(\+35\): 다른 branch와 같은 hunk를 수정함/) - assert.match(markdown, /- files: `src\/shared.ts`/) - assert.match(markdown, /- branches: `feature\/other`/) - assert.match(markdown, /- checks: `build`/) -}) - -// same hunk와 same file overlap이 같이 있으면 중복 file, branch 목록을 축약하는지 확인 -test("compacts duplicated same file overlap metadata", () => { - const markdown = formatMergeRiskReportMarkdown(report(undefined, { - reasons: [ - { - code: "same_hunk_overlap", - message: "다른 branch와 같은 hunk를 수정함", - scoreImpact: 55, - files: ["src/shared.ts"], - branches: ["feature/other"] - }, - { - code: "same_file_overlap", - message: "다른 branch와 같은 파일을 수정함", - scoreImpact: 30, - files: ["src/shared.ts"], - branches: ["feature/other"] - } - ] - })) - - assert.match(markdown, /- `same_hunk_overlap` \(\+55\): 다른 branch와 같은 hunk를 수정함/) - assert.match(markdown, /- `same_file_overlap` \(\+30\): 다른 branch와 같은 파일을 수정함/) - assert.equal(markdown.match(/- files: `src\/shared\.ts`/g)?.length, 1) - assert.equal(markdown.match(/- branches: `feature\/other`/g)?.length, 1) -}) - -// hunk와 겹치지 않는 same file overlap 정보는 축약 과정에서도 보존되는지 확인 -test("keeps non-hunk same file overlap metadata", () => { - const markdown = formatMergeRiskReportMarkdown(report(undefined, { - reasons: [ - { - code: "same_hunk_overlap", - message: "다른 branch와 같은 hunk를 수정함", - scoreImpact: 55, - files: ["src/a.ts"], - branches: ["feature/a"] - }, - { - code: "same_file_overlap", - message: "다른 branch와 같은 파일을 수정함", - scoreImpact: 30, - files: ["src/a.ts", "src/b.ts"], - branches: ["feature/a", "feature/b"] - } - ] - })) - - assert.equal(markdown.match(/`src\/a\.ts`/g)?.length, 1) - assert.match(markdown, /- files: `src\/b.ts`/) - assert.match(markdown, /- branches: `feature\/a`, `feature\/b`/) -}) - -// inline code 내부 backtick이 Markdown code span 문법을 깨지 않도록 delimiter를 늘리는지 확인 -test("formats inline code containing backticks", () => { - const markdown = formatMergeRiskReportMarkdown(report(undefined, { - branchName: "feature/`risk`", - reasonFile: "src/`shared`.ts" - })) - - assert.match(markdown, /#### `` feature\/`risk` ``/) - assert.match(markdown, /- files: `` src\/`shared`\.ts ``/) -}) - -// AI predicted 결과를 action 중심으로 축약해 표시하는지 확인 -test("formats predicted AI result", () => { - const markdown = formatMergeRiskReportMarkdown(report(predictedAiResult("feature/risk"))) - - assert.match(markdown, /- ai prediction:/) - assert.match(markdown, /- prediction: 공유 파일 변경 의도가 겹칠 가능성 있음/) - assert.match(markdown, /- recommended actions:/) - assert.match(markdown, /- `high` base branch rebase: 최신 main 기준으로 rebase 후 실제 충돌 여부 확인/) - assert.match(markdown, /- files: `src\/shared.ts`/) - assert.doesNotMatch(markdown, /confidence/) - assert.doesNotMatch(markdown, /false positive notes/) -}) - -// AI skipped 결과를 report에 표시하는지 확인 -test("formats skipped AI result", () => { - const markdown = formatMergeRiskReportMarkdown(report({ - status: "skipped", - branchName: "feature/risk", - baseBranch: "main", - reason: "not_target" - })) - - assert.match(markdown, /- ai prediction:/) - assert.match(markdown, /- status: `skipped`/) - assert.match(markdown, /- reason: `not_target`/) -}) - -// AI failed 결과를 deterministic report 유지 상태로 표시하는지 확인 -test("formats failed AI result", () => { - const markdown = formatMergeRiskReportMarkdown(report({ - status: "failed", - branchName: "feature/risk", - baseBranch: "main", - errorMessage: "OpenAI request failed" - })) - - assert.match(markdown, /- ai prediction:/) - assert.match(markdown, /- status: `failed`/) - assert.match(markdown, /- error: OpenAI request failed/) -}) - -// section이 없으면 감시 대상 branch 없음 상태를 표시하는지 확인 -test("formats empty report", () => { - const markdown = formatMergeRiskReportMarkdown({ - baseBranch: "main", - generatedAt: new Date("2026-06-22T00:00:00.000Z"), - sections: [], - totalBranchCount: 0 - }) - - assert.match(markdown, /- watched branches: 0/) - assert.match(markdown, /감시 대상 branch 없음/) -}) - -function report( - aiPrediction?: AiPredictionResult, - options: { - branchName?: string - reasonFile?: string - reasons?: BranchRiskReason[] - } = {} -): MergeRiskReport { - const branchName = options.branchName ?? "feature/risk" - - return { - baseBranch: "main", - generatedAt: new Date("2026-06-22T00:00:00.000Z"), - totalBranchCount: 1, - sections: [{ - status: BranchRiskStatus.High, - title: "High", - items: [{ - branchName, - baseBranch: "main", - score: 65, - status: BranchRiskStatus.High, - author: "opfic", - updatedAt: new Date("2026-06-22T01:00:00.000Z"), - pullRequest: { - number: 12, - title: "Report item", - url: "https://github.com/opficdev/Watcher/pull/12", - author: "opfic" - }, - branch: { - baseBranch: "main", - name: branchName, - headSha: "feature-risk-sha", - checks: [] - }, - reasons: options.reasons ?? [{ - code: "same_hunk_overlap", - message: "다른 branch와 같은 hunk를 수정함", - scoreImpact: 35, - files: [options.reasonFile ?? "src/shared.ts"], - branches: ["feature/other"], - checks: ["build"] - }], - aiPrediction - }] - }] - } -} - -function predictedAiResult(branchName: string): AiPredictionResult { - return { - status: "predicted", - branchName, - baseBranch: "main", - prediction: { - branchName, - baseBranch: "main", - prediction: "공유 파일 변경 의도가 겹칠 가능성 있음", - recommendedActions: [{ - title: "base branch rebase", - description: "최신 main 기준으로 rebase 후 실제 충돌 여부 확인", - priority: "high", - files: ["src/shared.ts"] - }] - } - } -} diff --git a/tests/reports/reportBuilder.test.ts b/tests/reports/reportBuilder.test.ts deleted file mode 100644 index 424e542..0000000 --- a/tests/reports/reportBuilder.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - BranchRiskStatus, - buildMergeRiskReport, - type BranchContext, - type BranchRisk, - type BranchRiskReasonCode, - type BranchRiskStatus as BranchRiskStatusType, - type AiPredictionResult, - type MergeRiskReportInput -} from "../../src/index.js" - -// risk status 우선순서대로 section을 구성하는지 확인 -test("groups report sections by risk status order", () => { - const report = buildMergeRiskReport([ - input("feature/low", BranchRiskStatus.Low, 0), - input("feature/critical", BranchRiskStatus.Critical, 100), - input("feature/medium", BranchRiskStatus.Medium, 25), - input("feature/high", BranchRiskStatus.High, 50) - ], "main", { - generatedAt: new Date("2026-06-22T00:00:00.000Z") - }) - - assert.equal(report.baseBranch, "main") - assert.equal(report.totalBranchCount, 4) - assert.deepEqual(report.sections.map(section => section.status), [ - BranchRiskStatus.Critical, - BranchRiskStatus.High, - BranchRiskStatus.Medium, - BranchRiskStatus.Low - ]) -}) - -// 같은 section 내부 branch를 score 내림차순과 branch 이름 오름차순으로 정렬하는지 확인 -test("sorts report items by score and branch name", () => { - const report = buildMergeRiskReport([ - input("feature/b", BranchRiskStatus.High, 70), - input("feature/c", BranchRiskStatus.High, 90), - input("feature/a", BranchRiskStatus.High, 70) - ], "main") - - assert.deepEqual(report.sections[0]?.items.map(item => item.branchName), [ - "feature/c", - "feature/a", - "feature/b" - ]) -}) - -// 같은 score의 branch 이름은 locale 영향을 받지 않는 문자열 비교 순서로 정렬하는지 확인 -test("sorts tied report items with deterministic string order", () => { - const report = buildMergeRiskReport([ - input("feature/ä", BranchRiskStatus.High, 70), - input("feature/z", BranchRiskStatus.High, 70) - ], "main") - - assert.deepEqual(report.sections[0]?.items.map(item => item.branchName), [ - "feature/z", - "feature/ä" - ]) -}) - -// 비어 있는 status section은 report에서 제외되는지 확인 -test("omits empty report sections", () => { - const report = buildMergeRiskReport([ - input("feature/medium", BranchRiskStatus.Medium, 25) - ], "main") - - assert.deepEqual(report.sections.map(section => section.status), [BranchRiskStatus.Medium]) -}) - -// section title을 옵션으로 대체할 수 있는지 확인 -test("uses custom section titles", () => { - const report = buildMergeRiskReport([ - input("feature/critical", BranchRiskStatus.Critical, 100) - ], "main", { - sectionTitles: { - [BranchRiskStatus.Critical]: "충돌 확인" - } - }) - - assert.equal(report.sections[0]?.title, "충돌 확인") -}) - -// branch author와 updatedAt metadata를 report item에 보존하는지 확인 -test("keeps branch author and updated time metadata", () => { - const updatedAt = new Date("2026-06-22T01:00:00.000Z") - const report = buildMergeRiskReport([ - input("feature/metadata", BranchRiskStatus.Medium, 25, { - author: "opfic", - updatedAt - }) - ], "main") - - const item = report.sections[0]?.items[0] - assert.equal(item?.author, "opfic") - assert.equal(item?.updatedAt, updatedAt) -}) - -// 연결된 Pull Request metadata가 없어도 report item 생성이 가능한지 확인 -test("keeps pull request metadata optional", () => { - const report = buildMergeRiskReport([ - input("feature/no-pr", BranchRiskStatus.Low, 0) - ], "main") - - assert.equal(report.sections[0]?.items[0]?.pullRequest, undefined) -}) - -// 연결된 Pull Request metadata를 report item에 보존하는지 확인 -test("keeps pull request metadata when present", () => { - const report = buildMergeRiskReport([ - input("feature/pr", BranchRiskStatus.High, 50, { - pullRequest: { - number: 12, - title: "Report item", - url: "https://github.com/opficdev/Watcher/pull/12", - author: "opfic" - } - }) - ], "main") - - assert.deepEqual(report.sections[0]?.items[0]?.pullRequest, { - number: 12, - title: "Report item", - url: "https://github.com/opficdev/Watcher/pull/12", - author: "opfic" - }) -}) - -// AI predicted 결과를 report item에 보존하는지 확인 -test("keeps predicted AI result when present", () => { - const prediction = predictedAiResult("feature/ai") - const report = buildMergeRiskReport([ - input("feature/ai", BranchRiskStatus.High, 70, {}, prediction) - ], "main") - - assert.deepEqual(report.sections[0]?.items[0]?.aiPrediction, prediction) -}) - -// AI skipped 결과를 deterministic report와 분리해 보존하는지 확인 -test("keeps skipped AI result when present", () => { - const prediction: AiPredictionResult = { - status: "skipped", - branchName: "feature/skipped", - baseBranch: "main", - reason: "not_target" - } - const report = buildMergeRiskReport([ - input("feature/skipped", BranchRiskStatus.Low, 0, {}, prediction) - ], "main") - - assert.deepEqual(report.sections[0]?.items[0]?.aiPrediction, prediction) -}) - -// AI failed 결과를 deterministic report와 분리해 보존하는지 확인 -test("keeps failed AI result when present", () => { - const prediction: AiPredictionResult = { - status: "failed", - branchName: "feature/failed", - baseBranch: "main", - errorMessage: "OpenAI request failed" - } - const report = buildMergeRiskReport([ - input("feature/failed", BranchRiskStatus.Medium, 25, {}, prediction) - ], "main") - - assert.deepEqual(report.sections[0]?.items[0]?.aiPrediction, prediction) -}) - -function input( - branchName: string, - status: BranchRiskStatusType, - score: number, - metadata: Partial = {}, - aiPrediction?: AiPredictionResult -): MergeRiskReportInput { - const context = branch(branchName, metadata) - - return { - branch: context, - risk: { - branchName, - baseBranch: context.baseBranch, - score, - status, - reasons: [reason("clean_merge")] - }, - aiPrediction - } -} - -function branch( - name: string, - metadata: Partial = {} -): BranchContext { - return { - baseBranch: "main", - name, - headSha: `${name}-sha`, - checks: [], - ...metadata - } -} - -function reason(code: BranchRiskReasonCode): BranchRisk["reasons"][number] { - return { - code, - message: code, - scoreImpact: 0 - } -} - -function predictedAiResult(branchName: string): AiPredictionResult { - return { - status: "predicted", - branchName, - baseBranch: "main", - prediction: { - branchName, - baseBranch: "main", - prediction: "공유 파일 변경 의도가 겹칠 가능성 있음", - recommendedActions: [{ - title: "base branch rebase", - description: "최신 main 기준으로 rebase 후 실제 충돌 여부 확인", - priority: "high", - files: ["src/shared.ts"] - }] - } - } -} diff --git a/tests/risks/riskAnalyzer.test.ts b/tests/risks/riskAnalyzer.test.ts deleted file mode 100644 index e39a4e6..0000000 --- a/tests/risks/riskAnalyzer.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - BranchRiskStatus, - analyzeBranchMergeRisks, - type BranchContext, - 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", () => { - const [risk] = analyzeBranchMergeRisks([ - input("feature/conflict", { - status: "confirmed_conflict", - changedFiles: ["shared.ts"], - conflictFiles: ["shared.ts"] - }) - ]) - - assert.equal(risk?.status, BranchRiskStatus.Critical) - assert.equal(risk?.score, 100) - assert.deepEqual(risk?.reasons.map(reason => reason.code), ["confirmed_conflict"]) - assert.deepEqual(risk?.reasons[0]?.files, ["shared.ts"]) -}) - -// 여러 branch가 같은 파일을 수정하면 same file overlap risk가 생성되는지 확인 -test("adds same file overlap risk", () => { - const risks = analyzeBranchMergeRisks([ - input("feature/a", { - status: "clean", - changedFiles: ["src/shared.ts"], - conflictFiles: [] - }), - input("feature/b", { - status: "clean", - changedFiles: ["src/shared.ts"], - conflictFiles: [] - }) - ]) - - assert.deepEqual(reasonCodes(risks[0]), ["same_file_overlap"]) - assert.equal(risks[0]?.status, BranchRiskStatus.Medium) - assert.equal(risks[0]?.score, 30) - assert.deepEqual(risks[0]?.reasons[0]?.files, ["src/shared.ts"]) - assert.deepEqual(risks[0]?.reasons[0]?.branches, ["feature/b"]) -}) - -// 여러 branch가 같은 파일의 겹치는 line range를 수정하면 same hunk overlap risk가 생성되는지 확인 -test("adds same hunk overlap risk", () => { - const risks = analyzeBranchMergeRisks([ - input("feature/a", { - status: "clean", - changedFiles: ["src/shared.ts"], - conflictFiles: [] - }, [{ filePath: "src/shared.ts", startLine: 10, endLine: 20 }]), - input("feature/b", { - status: "clean", - changedFiles: ["src/shared.ts"], - conflictFiles: [] - }, [{ filePath: "src/shared.ts", startLine: 18, endLine: 30 }]) - ]) - - assert.deepEqual(reasonCodes(risks[0]), ["same_hunk_overlap", "same_file_overlap"]) - assert.equal(risks[0]?.status, BranchRiskStatus.Critical) - assert.equal(risks[0]?.score, 85) - assert.deepEqual(risks[0]?.reasons[0]?.files, ["src/shared.ts"]) - assert.deepEqual(risks[0]?.reasons[0]?.branches, ["feature/b"]) -}) - -// 같은 파일을 수정하더라도 line range가 겹치지 않으면 same hunk overlap으로 보지 않는지 확인 -test("does not add same hunk risk for separated hunks", () => { - const risks = analyzeBranchMergeRisks([ - input("feature/a", { - status: "clean", - changedFiles: ["src/shared.ts"], - conflictFiles: [] - }, [{ filePath: "src/shared.ts", startLine: 10, endLine: 20 }]), - input("feature/b", { - status: "clean", - changedFiles: ["src/shared.ts"], - conflictFiles: [] - }, [{ filePath: "src/shared.ts", startLine: 21, endLine: 30 }]) - ]) - - assert.deepEqual(reasonCodes(risks[0]), ["same_file_overlap"]) - assert.equal(risks[0]?.status, BranchRiskStatus.Medium) - assert.equal(risks[0]?.score, 30) -}) - -// 실패한 check metadata가 branch risk에 반영되는지 확인 -test("adds failed check risk", () => { - const [risk] = analyzeBranchMergeRisks([ - input("feature/check", { - status: "clean", - changedFiles: ["src/app.ts"], - conflictFiles: [] - }, [], branch("feature/check", [{ - name: "CI", - status: "completed", - conclusion: "failure" - }])) - ]) - - assert.deepEqual(reasonCodes(risk), ["failed_check"]) - assert.equal(risk?.status, BranchRiskStatus.Low) - assert.equal(risk?.score, 20) - assert.deepEqual(risk?.reasons[0]?.checks, ["CI"]) -}) - -// 설정으로 주입한 critical file pattern이 risk에 반영되는지 확인 -test("adds critical file risk", () => { - const [risk] = analyzeBranchMergeRisks([ - input("feature/package", { - status: "clean", - changedFiles: [".github/workflows/ci.yml", "package-lock.json", "src/app.ts"], - conflictFiles: [] - }) - ], { - criticalFilePatterns: ["package-lock.json", ".github/**"] - }) - - assert.deepEqual(reasonCodes(risk), ["critical_file_changed"]) - assert.equal(risk?.status, BranchRiskStatus.Medium) - assert.equal(risk?.score, 25) - assert.deepEqual(risk?.reasons[0]?.files, [".github/workflows/ci.yml", "package-lock.json"]) -}) - -// merge check 실패가 중간 risk로 반영되고 이미 수집된 변경 파일과 결합되는지 확인 -test("adds merge check failure risk", () => { - const [risk] = analyzeBranchMergeRisks([ - input("feature/error", { - status: "merge_check_failed", - changedFiles: ["src/app.ts"], - conflictFiles: [], - errorMessage: "fetch failed" - }) - ]) - - assert.deepEqual(reasonCodes(risk), ["merge_check_failed"]) - assert.equal(risk?.status, BranchRiskStatus.Medium) - assert.equal(risk?.score, 40) -}) - -// risk signal이 없으면 deterministic low risk reason을 생성하는지 확인 -test("adds clean merge reason when no risk rules match", () => { - const [risk] = analyzeBranchMergeRisks([ - input("feature/clean", { - status: "clean", - changedFiles: ["src/app.ts"], - conflictFiles: [] - }) - ]) - - assert.equal(risk?.status, BranchRiskStatus.Low) - assert.equal(risk?.score, 0) - assert.deepEqual(reasonCodes(risk), ["clean_merge"]) -}) - -function input( - name: string, - signal: Pick, - changedHunks: BranchRiskAnalysisInput["changedHunks"] = [], - context: BranchContext = branch(name) -): BranchRiskAnalysisInput { - return { - branch: context, - changedHunks, - gitSignal: { - baseBranch: context.baseBranch, - branchName: name, - ...signal - } - } -} - -function branch( - name: string, - checks: BranchContext["checks"] = [] -): BranchContext { - return { - baseBranch: "main", - name, - headSha: `${name}-sha`, - checks - } -} - -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[] { - return risk?.reasons.map(reason => reason.code) ?? [] -} From d78c78a1ac13e4f427b592a94f208816c44f70ec Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:36:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20branch=20=EC=A1=B0=ED=95=A9=20w?= =?UTF-8?q?orkflow=20=EA=B3=84=EC=95=BD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/roles.md | 24 +++--- .agents/rules/architecture.md | 47 +++++------ .agents/rules/project-workflows.md | 4 +- .agents/workflows.md | 12 +-- .codex/agents/code_reviewer.toml | 2 +- .codex/agents/documentation_writer.toml | 2 +- .github/workflows/merge-risk-watch.yml | 15 +--- README.md | 87 ++++++++------------- docs/examples/consumer-merge-risk-watch.yml | 3 - src/branches/branchSelector.ts | 2 +- src/index.ts | 2 +- src/workflows/mergeRiskWatch.ts | 11 --- tests/index.test.ts | 2 +- tests/workflows/mergeRiskWatch.test.ts | 8 -- 14 files changed, 86 insertions(+), 135 deletions(-) diff --git a/.agents/roles.md b/.agents/roles.md index 57e4034..6aa7ba9 100644 --- a/.agents/roles.md +++ b/.agents/roles.md @@ -37,15 +37,15 @@ Default role-to-model and execution assignment: | --- | --- | --- | --- | | Planner | active main agent | `Primary` | Always for live issues, PR scope, architecture scope, or implementation planning | | Implementer | active main agent | `Primary` | Always for TypeScript production code, tests, module boundaries, public exports, provider behavior, workflows, releases, or GitHub writes | -| Architecture Watcher | `architecture_watcher` | `Lightweight` for preflight, `Primary` for final boundary verdict | Any finding is `Block` or `Needs Owner Decision`, or the change touches deterministic scoring, AI authority, external-service data, reusable workflow contracts, secrets, or release packaging | +| Architecture Watcher | `architecture_watcher` | `Lightweight` for preflight, `Primary` for final boundary verdict | Any finding is `Block` or `Needs Owner Decision`, or the change touches deterministic pair classification, AI authority, external-service data, reusable workflow contracts, secrets, or release packaging | | Code Reviewer | `code_reviewer` | `Lightweight` for first pass, `Primary` for final blocking review | Findings involve runtime behavior, data loss, secret exposure, provider failure isolation, workflow behavior, or test strategy | | Verification Runner | `verification_runner` | `Lightweight` | Verification fails, the failure cause is unclear, or a source or workflow fix is needed | | GitHub/CI Analyst | `github_ci_analyst` | `Lightweight` | CI root cause requires code or workflow changes, release state is ambiguous, or review comments conflict | -| Documentation Writer | `documentation_writer` | `Lightweight` | Text must explain score policy, AI behavior, security boundaries, reusable workflow contracts, release risk, CI root cause, or PR scope tradeoffs | +| Documentation Writer | `documentation_writer` | `Lightweight` | Text must explain pair graph policy, AI behavior, security boundaries, reusable workflow contracts, release risk, CI root cause, or PR scope tradeoffs | Project-scoped custom agents live in `.codex/agents/`. Their TOML files pin the concrete model and sandbox for spawned sessions; this table is the canonical role-to-agent routing map. -Do not assign `Lightweight` as the only model for production TypeScript implementation, deterministic score or status changes, provider contracts, reusable workflow inputs or secrets, release packaging, public exports, commits, pushes, PR creation, or final integration. +Do not assign `Lightweight` as the only model for production TypeScript implementation, deterministic pair status or reason changes, provider contracts, reusable workflow inputs or secrets, release packaging, public exports, commits, pushes, PR creation, or final integration. ### Model dispatch requirements @@ -141,7 +141,7 @@ Planner must produce this packet before handing work to another role. - Stop conditions: ``` -Use `Architecture risk: possible` when the task touches module ownership, deterministic scoring, AI authority or data shape, external-service boundaries, secret redaction, public exports, reusable workflow contracts, release packaging, or architecture documentation. +Use `Architecture risk: possible` when the task touches module ownership, deterministic pair classification, AI authority or data shape, external-service boundaries, secret redaction, public exports, reusable workflow contracts, release packaging, or architecture documentation. ## Role activation @@ -193,7 +193,7 @@ May: - Trace the owning source module, test file, workflow, and public documentation for the requested behavior. - Separate deterministic policy, AI assistance, report formatting, report delivery, debug output, and runtime orchestration scope. - Decide which roles are required and which checks can run without live services. -- Ask the user when score policy, data exposure, public workflow contracts, release behavior, or ownership is ambiguous. +- Ask the user when pair graph policy, data exposure, public workflow contracts, release behavior, or ownership is ambiguous. Must not: @@ -272,7 +272,7 @@ Must inspect: - Current and proposed owning module for each changed behavior. - Imports and dependency direction among `branches`, `git`, `risks`, `ai`, `reports`, `reportChannels`, `debug`, and `workflows`. -- Whether normalized input still produces the same deterministic score, status, reason, and report result when behavior is not in scope. +- Whether normalized input still produces the same deterministic pair status, reason, and report result when behavior is not in scope. - Whether AI target selection and provider results remain additive and validated. - Whether provider failures remain isolated without removing deterministic results. - Data sent to GitHub, OpenAI, Discord, logs, and debug artifacts, including secret and raw-source exposure. @@ -283,7 +283,7 @@ Must inspect: Must not: - Edit files. -- Approve ambiguous score, security, workflow, public API, or release decisions by assumption. +- Approve ambiguous pair graph, security, workflow, public API, or release decisions by assumption. - Treat a passing build as proof that deterministic or consumer-facing contracts are unchanged. - Hide architecture decisions inside refactor, test, build-fix, or documentation wording. @@ -313,14 +313,14 @@ Code Reviewer is a read-only diff reviewer. May: - Inspect `git diff`, changed source, tests, package scripts, workflows, README, and related contracts. -- Recompute representative deterministic cases from the tests and verify score caps, precedence, overlap suppression, skip/fail mapping, and report output. +- Recompute representative deterministic cases from the tests and verify pair status precedence, overlap reasons, skip/fail mapping, and report output. - Check strict typing, async failure behavior, environment fallbacks, path handling, provider response validation, Discord chunking, and secret redaction. - Verify whether the change matches the task packet and current issue or PR body. Must prioritize: -- Incorrect branch selection, Git signal interpretation, score or status changes, and report regressions. -- Provider calls for the wrong targets, unvalidated output, lost deterministic results, or batch result misalignment. +- Incorrect branch selection, Git signal interpretation, pair status or reason changes, and report regressions. +- Provider calls for the wrong targets, unvalidated output, lost deterministic results, or pair result ordering errors. - Secret exposure, raw data expansion, unsafe error messages, or debug artifact regressions. - Reusable workflow, CI, release, or consumer contract drift. - Missing success, failure, boundary, and fallback tests. @@ -437,7 +437,7 @@ Must: - Write PR and review content in Korean and end sentences in noun form. - Keep implementation names, paths, commands, environment variables, workflow names, branch names, issue numbers, and commit hashes unchanged. -- Explain deterministic possibility separately from AI prediction. +- Explain deterministic pair results separately from AI prediction. - Keep reusable workflow inputs, secrets, permissions, source resolution, debug artifact behavior, report fallback, and release contents aligned with implementation. - Mention only verification commands that were actually run. @@ -469,7 +469,7 @@ Before reporting completion: - Confirm workflow changes were checked against inputs, secrets, permissions, source resolution, and README contracts. - Confirm docs-only changes received diff, file-presence, Markdown, and TOML checks without claiming TypeScript or live-service verification. - Confirm `git status --short` contains no generated or unrelated files added by the task. -- Report unresolved owner decisions instead of silently changing score, security, workflow, public API, or release policy. +- Report unresolved owner decisions instead of silently changing pair graph, security, workflow, public API, or release policy. ## Example workflows diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md index d22a235..bfa780c 100644 --- a/.agents/rules/architecture.md +++ b/.agents/rules/architecture.md @@ -15,8 +15,8 @@ Watcher is a standalone TypeScript/Node automation repository. `package.json`, ` Read this file before work that changes any of these areas: - Ownership or dependency direction across `src/branches`, `src/git`, `src/risks`, `src/ai`, `src/reports`, `src/reportChannels`, `src/debug`, or `src/workflows`. -- Deterministic branch selection, merge signals, risk scores, statuses, reasons, or report results. -- OpenAI target selection, prompt construction, response validation, failure isolation, or provider batching. +- Deterministic branch selection, branch 조합, merge signals, graph statuses, reasons, or report results. +- OpenAI 조합 대상 선택, prompt construction, response validation, failure isolation, or request ordering. - GitHub, OpenAI, Discord, filesystem, environment-variable, or child-process boundaries. - Reusable workflow inputs, secrets, permissions, source resolution, debug artifacts, or release packaging. - Public exports from `src/index.ts`, shared contracts, or README architecture explanations. @@ -49,8 +49,9 @@ flowchart LR Remote["Remote refs and GitHub metadata"] Collection["Workflow runtime collection"] Selection["Branch selection"] - Git["Virtual merge signal and changed hunks"] - Risk["Deterministic risk analysis"] + Pair["Branch pair construction"] + Git["Virtual merge and code context"] + Risk["Deterministic conflict graph"] Target["AI target selection and evidence"] OpenAI["Optional OpenAI prediction"] Report["Report construction and Markdown"] @@ -59,7 +60,8 @@ flowchart LR Remote --> Collection Collection --> Selection - Selection --> Git + Selection --> Pair + Pair --> Git Git --> Risk Risk --> Target Target --> OpenAI @@ -78,40 +80,41 @@ flowchart LR | Module | Owns | Ask before | | --- | --- | --- | -| `src/branches` | Branch, check, and PR metadata contracts plus base/default exclusion and branch selection | Adding Git execution, risk analysis, report formatting, or provider calls | +| `src/branches` | Branch, check, and PR metadata contracts plus base/default exclusion, branch selection, and pair construction | Adding Git execution, graph classification, report formatting, or provider calls | | `src/git` | Branch fetch, merge-base, virtual merge, changed-file, conflict-file, and merge-failure signals | Moving hunk parsing, GitHub metadata, product risk policy, or report text into this module | -| `src/risks` | Deterministic score, status, reason, overlap, and precedence policy | Changing score values, thresholds, precedence, or same-input results | -| `src/ai` | AI target selection, evidence shaping, prompt construction, provider call, response validation, and branch failure isolation | Replacing deterministic results, sending broader source data, changing provider contract, or exposing unvalidated responses | +| `src/risks` | Deterministic pair graph status, reason, overlap, error, and precedence policy | Changing status classification, reason precedence, or same-input results | +| `src/ai` | AI pair target selection, evidence shaping, prompt construction, provider call, response validation, and pair failure isolation | Replacing deterministic results, sending broader source data, changing provider contract, or exposing unvalidated responses | | `src/reports` | Provider-neutral report model construction and Markdown formatting | Adding transport behavior or leaking internal-only evidence | | `src/reportChannels` | Report delivery, Discord chunking, stdout fallback, and transport error redaction | Adding a new channel or changing secret and failure behavior | | `src/debug` | Optional redacted diagnostic artifacts | Adding secrets, raw file contents, raw diffs, or unbounded provider data | -| `src/workflows` | Environment parsing, remote-ref listing, GitHub check and PR metadata collection, diff-hunk parsing, runtime orchestration, and delivery failure propagation | Adding deterministic score policy, AI response policy, report formatting, or channel transport policy | +| `src/workflows` | Environment parsing, remote-ref listing, GitHub check and PR metadata collection, runtime orchestration, debug artifact composition, and delivery failure propagation | Adding deterministic graph policy, AI response policy, report formatting, or channel transport policy | | `src/index.ts` | Deliberate public exports | Expanding the public contract without consumer impact review | ## Boundary rules -- Keep branch discovery and selection independent from risk scoring. -- Keep Git signal collection independent from product score policy. +- Keep branch discovery and selection independent from pair graph classification. +- Keep Git signal collection independent from graph status and reason policy. - Keep deterministic results reproducible for the same normalized input. -- Keep AI prediction additive. Provider output must not erase or rewrite deterministic possibility results. -- Select AI targets from deterministic results and skip confirmed conflicts when the current policy requires no provider call. +- Keep AI prediction additive. Provider output must not erase or rewrite deterministic graph results. +- Select every `confirmed_conflict` pair and only `potential_overlap` pairs with `same_hunk_overlap` for AI prediction. Skip same-file-only, `clean`, and `error` pairs. +- Do not create an AI client or call a provider when no pair is selected. - Validate provider responses before mapping them into reports. -- Isolate provider failure by branch and retain deterministic reporting. +- Run selected pair requests in order, isolate provider failure by pair, and retain deterministic reporting. - Keep report construction independent from Discord delivery. - Keep debug output optional, redacted, and bounded. -- Preserve the current `src/workflows/mergeRiskWatch.ts` ownership of remote-ref listing, GitHub metadata collection, hunk parsing, and pipeline composition. Do not add deterministic score, AI response, report formatting, or report channel policy there. +- Preserve the current `src/workflows/mergeRiskWatch.ts` ownership of remote-ref listing, GitHub metadata collection, pipeline composition, and optional debug artifact writes. Do not add deterministic graph, AI response, report formatting, or report channel policy there. ## Deterministic and AI decision boundary ```mermaid flowchart TD - Evidence["Normalized branch and Git evidence"] - Deterministic["Deterministic risk result"] + Evidence["Normalized pair and Git evidence"] + Deterministic["Deterministic graph edge"] Eligible{"Eligible for AI prediction?"} Skip["Keep deterministic result with skipped status"] Predict["Build bounded evidence and call provider"] Validate{"Response valid?"} - Add["Add prediction and recommended actions"] + Add["Add pair analysis and resolution"] Fail["Keep deterministic result with failed status"] Evidence --> Deterministic @@ -123,12 +126,12 @@ flowchart TD Validate -->|No| Fail ``` -Do not let provider output change deterministic scores, statuses, or reasons unless the user explicitly approves a product-contract change and the tests and README are updated together. +Do not let provider output change deterministic pair statuses or reasons unless the user explicitly approves a product-contract change and the tests and README are updated together. ## External service boundaries - GitHub access belongs at branch and Git metadata collection or workflow orchestration boundaries. -- OpenAI access belongs behind `openAiPredictionClient` and `predictionRunner`; prompt and response contracts remain separately testable. +- OpenAI access belongs behind `openAiPredictionClient` and `predictionPairRunner`; prompt and response contracts remain separately testable. - Discord access belongs behind the report channel abstraction; missing `DISCORD_WEBHOOK_URL` preserves stdout fallback. - Tests must replace external providers and report channels with fakes or injected functions and must not call live services. - Error messages and debug artifacts must not expose credentials or webhook URLs. @@ -154,7 +157,7 @@ Do not let provider output change deterministic scores, statuses, or reasons unl The runtime processing order is: ```text -workflow collection -> branch selection -> virtual merge and hunk evidence -> risks -> AI assistance -> reports -> report channel +workflow collection -> branch selection -> branch pairs -> virtual merge and code context -> conflict graph -> pair AI assistance -> reports -> report channel workflow orchestration writes optional debug artifacts throughout the run ``` @@ -176,7 +179,7 @@ Shared types should stay with the module that owns their meaning. Do not create Stop and ask the user before editing when any of these decisions are not already fixed by the request or current repository contract: -- A score, threshold, signal precedence, branch exclusion, or report status changes. +- A pair status, reason precedence, branch exclusion, or report status changes. - AI becomes authoritative over deterministic results. - Additional source, diff, PR, check, prompt, response, or secret data leaves the process or enters debug artifacts. - A reusable workflow input, secret, permission, default, trigger, or release resolution rule changes. diff --git a/.agents/rules/project-workflows.md b/.agents/rules/project-workflows.md index fe60441..d2520fa 100644 --- a/.agents/rules/project-workflows.md +++ b/.agents/rules/project-workflows.md @@ -65,7 +65,7 @@ This reference holds Watcher-specific working rules that should live with the pr ## Consumer workflow contract - Treat `.github/workflows/merge-risk-watch.yml` and the corresponding README sections as one consumer-facing contract. -- Keep `repository`, `base_branch`, `default_branch`, `critical_file_patterns`, `watcher_version`, and `upload_debug_artifact` aligned across workflow and documentation. +- Keep `repository`, `base_branch`, `default_branch`, `watcher_version`, and `upload_debug_artifact` aligned across workflow and documentation. - Keep `watcher_github_token`, `openai_api_key`, and optional `discord_webhook_url` aligned with runtime environment mapping. - Preserve release-tag asset download and branch/SHA source-build fallback behavior unless the change explicitly revises it. - Update consumer examples only when their public contract changes. @@ -80,6 +80,6 @@ This reference holds Watcher-specific working rules that should live with the pr ## Documentation alignment -- Update README behavior descriptions when public inputs, secrets, environment variables, score policy, AI behavior, debug artifacts, report behavior, or release behavior changes. +- Update README behavior descriptions when public inputs, secrets, environment variables, pair graph policy, AI behavior, debug artifacts, report behavior, or release behavior changes. - Do not update README for an internal refactor that leaves the documented contract unchanged. - Keep AI workflow documents under `.agents/` and custom agent configurations under `.codex/agents/`. diff --git a/.agents/workflows.md b/.agents/workflows.md index a6cd473..a929b3e 100644 --- a/.agents/workflows.md +++ b/.agents/workflows.md @@ -38,7 +38,7 @@ Do not skip the task packet. The task packet is the contract between models. Stop and ask the user before editing when: - The task packet conflicts with `AGENTS.md`. -- The requested fix requires changing deterministic scores, statuses, signal precedence, AI authority, data exposure, secret handling, public workflow inputs, release packaging, or public exports without an explicit contract. +- The requested fix requires changing deterministic pair statuses, reason precedence, AI authority, data exposure, secret handling, public workflow inputs, release packaging, or public exports without an explicit contract. - A role needs to call live GitHub, OpenAI, Discord, workflow dispatch, tag, release, PR, issue, or comment operations without current-turn authorization. - A required `Lightweight` or `Fast` custom agent cannot be loaded or selected through the connected side-task surface with its exact `task_name`, its pinned model is unavailable, or current tool policy requires user permission that has not been granted. - The current issue or PR scope is unclear after live GitHub inspection. @@ -53,7 +53,7 @@ Do not apply the custom-agent stop condition only because external `codex exec`, | User request | Workflow | | --- | --- | | "이슈 구현", issue number, feature, bug fix | Issue-driven implementation | -| Deterministic score, AI, external service, reusable workflow, release, public export, architecture docs | Architecture-sensitive implementation | +| Deterministic pair graph, AI, external service, reusable workflow, release, public export, architecture docs | Architecture-sensitive implementation | | PR review comment, unresolved thread, requested changes | Review-thread follow-up | | Failing GitHub Actions, CI log, reusable workflow, release failure | CI failure triage | | PR body, release note, README, issue wording | Documentation-only writing | @@ -141,8 +141,8 @@ Implementer must not proceed on `Block` or `Needs Owner Decision`. - Relevant source imports and module ownership. - Existing source and tests for the changed contract. -- Deterministic score, status, reason, precedence, and same-input behavior. -- AI target selection, evidence shape, prompt, response validation, failure isolation, and batching. +- Deterministic pair status, reason, precedence, and same-input behavior. +- AI target selection, evidence shape, prompt, response validation, pair failure isolation, and request ordering. - GitHub, OpenAI, Discord, environment-variable, child-process, filesystem, and debug-artifact data flow. - `package.json`, `tsconfig.json`, `src/index.ts`, `README.md`, and relevant `.github/workflows/*` when affected. - Reusable workflow inputs, secrets, permissions, source resolution, and release asset compatibility. @@ -256,7 +256,7 @@ Use for PR body, issue text, release note, README wording, review reply draft, c - When the Documentation Writer role is required, the main agent must dispatch the draft through `documentation_writer` before writing the final response. - If dispatch requires explicit user permission and it has not been granted, ask before drafting, returning, or posting the Documentation Writer output. - `Primary` must review the output against `.github/pull_request_template.md`, issue scope, implementation, tests, workflows, and actual diff. -- Keep deterministic possibility separate from AI prediction and do not claim live-service verification that was not run. +- Keep deterministic pair results separate from AI prediction and do not claim live-service verification that was not run. - If the user asks only for text, return text directly and do not create files. - If documentation files are changed, keep the change scoped to the requested document. @@ -396,7 +396,7 @@ Include the selected workflow name in the task packet `Source` or `Goal` field s - Source: - Goal: Address accepted review feedback without expanding PR scope. - Scope: Apply only required review fixes confirmed by GitHub/CI Analyst and Planner. -- Out of scope: Optional suggestions, unrelated cleanup, new score or architecture policy, live `npm run watch`, release actions. +- Out of scope: Optional suggestions, unrelated cleanup, new pair classification or architecture policy, live `npm run watch`, release actions. - Expected changed files: - Current owner: - Architecture risk: none / possible / confirmed diff --git a/.codex/agents/code_reviewer.toml b/.codex/agents/code_reviewer.toml index 1cccf0a..984b0a5 100644 --- a/.codex/agents/code_reviewer.toml +++ b/.codex/agents/code_reviewer.toml @@ -7,7 +7,7 @@ developer_instructions = """ Read AGENTS.md and .agents/roles.md before reviewing. Act only as the Code Reviewer defined in .agents/roles.md. Review the assigned diff and related source, tests, workflows, and documentation without editing files, staging changes, committing, pushing, or changing GitHub state. -Prioritize branch selection, Git signal handling, deterministic scores and statuses, AI target and response mapping, provider failure isolation, secret exposure, debug artifacts, report output, workflow contracts, release behavior, scope drift, and missing tests over style preferences. +Prioritize branch selection, Git signal handling, deterministic pair statuses and reasons, AI target and response mapping, provider failure isolation, secret exposure, debug artifacts, report output, workflow contracts, release behavior, scope drift, and missing tests over style preferences. Do not review generated dist output as source and do not run npm run watch. Return exactly the Code Review Result format from .agents/roles.md with file and line references when possible. """ diff --git a/.codex/agents/documentation_writer.toml b/.codex/agents/documentation_writer.toml index 0c7f6c6..794fd54 100644 --- a/.codex/agents/documentation_writer.toml +++ b/.codex/agents/documentation_writer.toml @@ -8,7 +8,7 @@ Read AGENTS.md and .agents/roles.md before drafting. Act only as the Documentation Writer defined in .agents/roles.md. Match .github/pull_request_template.md, the actual diff, current implementation, tests, workflows, and live issue or PR state when available. Write PR and review prose in Korean with noun-form endings while preserving implementation names, paths, commands, environment variables, workflow names, branch names, issue numbers, and commit hashes. -Keep deterministic possibility distinct from AI prediction and keep inputs, secrets, permissions, debug artifacts, report fallback, and release behavior aligned with the repository. +Keep deterministic pair results distinct from AI prediction and keep inputs, secrets, permissions, debug artifacts, report fallback, and release behavior aligned with the repository. Edit only documentation files named in the task packet. Do not edit TypeScript, tests, package configuration, or workflows, and do not create GitHub content unless explicitly authorized. Return exactly the Documentation Result format from .agents/roles.md unless the task packet requests direct Markdown draft output. """ diff --git a/.github/workflows/merge-risk-watch.yml b/.github/workflows/merge-risk-watch.yml index a951943..562ca1a 100644 --- a/.github/workflows/merge-risk-watch.yml +++ b/.github/workflows/merge-risk-watch.yml @@ -8,7 +8,7 @@ on: required: true type: string base_branch: - description: "merge risk를 비교할 기준 branch" + description: "비교 조합에 포함할 기준 branch" required: true type: string default_branch: @@ -16,11 +16,6 @@ on: required: false type: string default: "" - critical_file_patterns: - description: "critical file wildcard pattern 목록. 줄바꿈으로 구분" - required: false - type: string - default: "" watcher_version: description: "테스트할 Watcher release tag. 비워두면 workflow ref 기준" required: false @@ -48,7 +43,7 @@ on: required: true type: string base_branch: - description: "merge risk를 비교할 기준 branch" + description: "비교 조합에 포함할 기준 branch" required: true type: string default_branch: @@ -56,11 +51,6 @@ on: required: false type: string default: "" - critical_file_patterns: - description: "critical file wildcard pattern 목록. 줄바꿈으로 구분" - required: false - type: string - default: "" watcher_version: description: "테스트할 Watcher release tag. 비워두면 workflow ref 기준" required: false @@ -163,7 +153,6 @@ jobs: WATCHER_DEBUG_ARTIFACT_DIR: ${{ inputs.upload_debug_artifact && format('{0}/watcher/debug', github.workspace) || '' }} WATCHER_BASE_BRANCH: ${{ inputs.base_branch }} WATCHER_DEFAULT_BRANCH: ${{ inputs.default_branch }} - WATCHER_CRITICAL_FILE_PATTERNS: ${{ inputs.critical_file_patterns }} run: npm run watch - name: Upload Watcher debug artifact diff --git a/README.md b/README.md index 2eb3899..e64c7cd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Watcher -Watcher는 consumer repository의 active branch를 감시하고 merge conflict 가능성을 report하는 TypeScript/Node 자동화 도구입니다. +Watcher는 consumer repository의 active branch 조합을 분석하고 merge risk를 report하는 TypeScript/Node 자동화 도구입니다. -consumer repository는 Watcher 코드를 복사하지 않고 workflow 파일 하나만 추가해 reusable workflow를 호출합니다. Watcher repository는 reusable workflow, deterministic possibility 계산, AI prediction, report channel 구현을 소유합니다. +consumer repository는 Watcher 코드를 복사하지 않고 workflow 파일 하나만 추가해 reusable workflow를 호출합니다. Watcher repository는 reusable workflow, branch 조합 분석, AI prediction, report channel 구현을 소유합니다. ## 설치 @@ -10,7 +10,7 @@ consumer repository에 workflow 파일 하나를 추가합니다. 전체 예시 운영 환경에서는 `uses: opficdev/Watcher/.github/workflows/merge-risk-watch.yml@0.1.0`처럼 release tag를 ref로 고정합니다. Watcher는 이 ref를 기준으로 같은 tag의 release asset을 자동으로 다운로드합니다. -예시에서 consumer repository에 맞게 `base_branch`, `default_branch`, `critical_file_patterns`, secret 이름을 조정합니다. +예시에서 consumer repository에 맞게 `base_branch`, `default_branch`, secret 이름을 조정합니다. ## Consumer repository secrets @@ -52,17 +52,16 @@ reusable workflow는 다음 input을 받습니다. | input | 필수 여부 | 기본값 | 용도 | | --- | --- | --- | --- | | `repository` | 필수 | 없음 | 감시할 repository. `owner/repo` 형식 | -| `base_branch` | 필수 | 없음 | merge risk를 비교할 기준 branch | +| `base_branch` | 필수 | 없음 | 비교 조합에 포함할 기준 branch | | `default_branch` | 선택 | 빈 값 | 감시 대상에서 제외할 default branch | -| `critical_file_patterns` | 선택 | 빈 값 | score에 반영할 critical file wildcard pattern 목록. 줄바꿈으로 구분 | | `watcher_version` | 선택 | 빈 값 | 수동 테스트에 사용할 Watcher release tag. 비워두면 workflow ref 기준 | | `upload_debug_artifact` | 선택 | `false` | AI prediction 원인 추적용 debug artifact를 consumer workflow run에 업로드할지 여부 | -`critical_file_patterns`에서 `*`는 단일 path segment 내부를 매칭하고 `**`는 path separator를 포함해 매칭합니다. - ## Branch 운영 기준 -Watcher는 `base_branch`와 `default_branch`를 제외한 remote branch를 감시 대상으로 수집합니다. branch 이름에 맞춰야 하는 prefix나 regex는 요구하지 않습니다. +Watcher는 `base_branch`와 `default_branch`를 active branch 선택 대상에서 제외합니다. `base_branch`는 선택된 active branch와 함께 비교 집합에 별도로 포함하고, 이 집합에서 중복 없는 모든 branch 조합을 이름순으로 구성합니다. branch 이름에 맞춰야 하는 prefix나 regex는 요구하지 않습니다. + +최근 14일 안에 갱신된 branch를 최근 갱신 순으로 최대 30개까지 선택합니다. 기간을 벗어난 branch와 개수 제한을 넘은 branch는 report의 `Excluded Branches`에 제외 사유와 함께 표시합니다. Watcher는 merge된 branch를 직접 삭제하지 않습니다. consumer repository의 `Settings` > `General` > `Pull Requests`에서 `Automatically delete head branches` 옵션을 켜야 합니다. 이 옵션을 켜면 merge된 branch가 자동으로 삭제되어 이미 merge된 branch를 계속 감시하는 상황을 줄일 수 있습니다. @@ -83,56 +82,42 @@ release asset에는 실행에 필요한 `dist/src`와 `package.json`이 포함 branch나 SHA ref로 reusable workflow를 호출하면 개발용 fallback으로 Watcher source를 checkout하고 `npm ci`, `npm run build`를 실행합니다. -## 충돌 가능성 점수화 - -Watcher는 merge 가능/불가능을 단정하지 않고 branch별 signal을 충돌 가능성 score와 reason으로 변환합니다. 이 값은 같은 입력에 대해 항상 같은 결과가 나와야 하는 기본 판단층입니다. - -기본 입력은 branch metadata, git merge signal, 변경 파일, 변경 범위입니다. 변경 범위는 같은 파일 안에서 수정된 line range를 뜻하며 여러 branch가 같은 line range를 수정할수록 conflict 가능성을 높게 봅니다. Git diff에서는 이런 변경 범위를 hunk라고 부르며 Watcher는 같은 파일의 hunk line range가 겹치는지를 비교합니다. +## Branch 조합 분석 -| signal | score | 의미 | -| --- | ---: | --- | -| `confirmed_conflict` | 100 | virtual merge에서 실제 conflict가 확인됨 | -| `same_hunk_overlap` | 55 | 여러 branch가 같은 파일의 겹치는 변경 범위를 수정함 | -| `merge_check_failed` | 40 | fetch, merge-base, virtual merge 확인 단계가 실패함 | -| `same_file_overlap` | 30 | 여러 branch가 같은 파일을 수정함 | -| `critical_file_changed` | 25 | 설정한 critical file pattern에 해당하는 파일이 수정됨 | -| `failed_check` | 20 | branch metadata에 실패한 check가 존재함 | -| `clean_merge` | 0 | virtual merge에서 conflict가 확인되지 않음 | +Watcher는 `base_branch`와 선택된 active branch 전체에서 중복 없는 모든 조합을 만들고, 각 조합에 virtual merge와 변경 문맥 수집을 수행합니다. 같은 입력은 항상 같은 조합 순서, 상태, reason을 만들어야 합니다. -각 branch의 score는 적용된 signal의 점수를 합산하고 최대 100점으로 제한합니다. - -| score | status | -| ---: | --- | -| 80-100 | `critical` | -| 50-79 | `high` | -| 25-49 | `medium` | -| 0-24 | `low` | +| status | reason | 의미 | +| --- | --- | --- | +| `confirmed_conflict` | `confirmed_conflict` | virtual merge에서 conflict가 확인됨 | +| `error` | `merge_check_failed` | merge 결과를 수집하지 못함 | +| `error` | `code_context_failed` | 변경 문맥을 수집하지 못함 | +| `potential_overlap` | `same_hunk_overlap` | clean merge 조합이 같은 파일의 겹치는 hunk를 수정함 | +| `potential_overlap` | `same_file_overlap` | clean merge 조합이 같은 파일을 수정함 | +| `clean` | `clean_merge` | conflict와 변경 겹침이 확인되지 않음 | -`confirmed_conflict`는 최상위 signal입니다. 이 signal이 있으면 다른 reason을 추가로 합산하지 않고 `critical` risk로 처리합니다. +`confirmed_conflict`는 virtual merge 결과를 우선합니다. merge 또는 변경 문맥 수집에 실패하면 해당 조합만 `error`로 격리합니다. clean merge에서는 hunk와 파일 겹침을 reason으로 기록하고, 겹침이 없으면 `clean`으로 분류합니다. -각 reason은 report에 code, message, score impact, 관련 file, 관련 branch, 관련 check metadata로 표시됩니다. 다만 `same_hunk_overlap`이 있는 branch에서는 중복되는 `same_file_overlap`의 file, branch 목록을 다시 반복하지 않습니다. 이 정보가 AI prediction에 전달되는 정제된 evidence입니다. +Report의 `Summary`에는 active period, base branch, 발견·감시 branch 수, 비교·clean 조합 수를 표시합니다. 상세 결과는 `Confirmed Conflicts`, `Potential Risks`, `Branch Impact`, `Excluded Branches`, `Merge Errors`로 나눕니다. 확정 conflict와 잠재 위험 항목에는 branch 조합, 상태, commit OID, reason, 관련 파일, conflict type, AI 결과를 표시합니다. ## AI-assisted prediction -AI prediction은 deterministic possibility score를 대체하지 않습니다. Watcher는 deterministic evidence를 OpenAI API에 전달하고 AI는 실무 관점의 prediction과 recommended actions를 추가합니다. +AI prediction은 branch 조합의 결정론적 상태와 reason을 대체하지 않습니다. Watcher는 선택된 조합의 제한된 evidence를 OpenAI API에 전달하고 AI 분석과 해결 방안을 추가합니다. 기본 AI provider는 OpenAI Responses API입니다. consumer repository에는 `OPENAI_API_KEY` secret을 설정해야 합니다. -AI prediction 대상은 기본적으로 `critical` possibility입니다. 이미 virtual merge에서 conflict가 확정된 branch는 AI 호출 없이 deterministic report만 사용합니다. 그 외 낮은 status의 branch는 AI 호출을 생략하고 `skipped` 상태로 report에 표시됩니다. +AI prediction 대상은 모든 `confirmed_conflict` 조합과 `same_hunk_overlap` reason이 있는 `potential_overlap` 조합입니다. `same_file_overlap`만 있는 `potential_overlap` 조합은 호출 대상에서 제외하고 `skipped` 상태로 표시합니다. `clean`은 Summary의 개수로만 집계하고 `error`는 `Merge Errors`에 표시하며 AI 상태를 붙이지 않습니다. 대상 조합이 없으면 AI client를 만들거나 provider를 호출하지 않습니다. -provider 호출량을 줄이기 위해 선택된 branch prediction은 report 단위 batch 요청으로 한 번에 실행합니다. +선택된 조합은 정렬된 순서대로 하나씩 provider에 요청합니다. 한 조합의 provider 호출이나 응답 검증이 실패해도 다음 조합 분석과 결정론적 report는 유지합니다. AI prediction 결과는 다음 상태 중 하나입니다. | status | 의미 | | --- | --- | -| `predicted` | OpenAI API 응답을 검증했고 prediction과 recommended actions를 report에 포함함 | +| `predicted` | OpenAI API 응답을 검증했고 conflict 또는 overlap 원인, 통합 순서, patch 또는 예방 조치를 report에 포함함 | | `skipped` | AI prediction 대상이 아니어서 provider 호출을 생략함 | -| `failed` | provider 호출이나 응답 검증에 실패해 branch 단위 실패로 격리함 | - -AI provider가 실패해도 deterministic possibility report는 유지됩니다. 실패한 branch는 `failed` 상태와 error message를 report에 포함합니다. +| `failed` | provider 호출이나 응답 검증에 실패해 조합 단위 실패로 격리함 | -Report에는 `Low` section, AI prediction `skipped` 상태, branch `updated` 시각, provider error 요약을 유지합니다. Pull Request metadata는 내부 evidence로만 사용할 수 있으며 Markdown report에는 출력하지 않습니다. +AI provider가 실패해도 조합의 결정론적 상태와 reason은 유지됩니다. `confirmed_conflict` 분석에는 conflict 원인, merge 또는 rebase 순서, 해결 단계, `Suggested Patch`를 포함합니다. `same_hunk_overlap` 분석에는 overlap 원인, 통합 순서, 예방 조치를 포함합니다. ## Debug artifact @@ -149,18 +134,17 @@ artifact에는 다음 파일이 포함됩니다. | 파일 | 내용 | | --- | --- | -| `run.json` | repository, base branch, default branch, critical file patterns, Watcher workflow ref | +| `run.json` | repository, repository path, base branch, default branch, remote, GitHub API URL, 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 요청 대상 branch, system prompt, response shape, 코드 원문을 제외한 file·line range·길이·hash·잘림 여부 | +| `deterministic-evidence.json` | 조합별 merge 결과와 conflict graph | +| `ai-target-selection.json` | AI 호출 대상 조합과 제외된 조합의 상태·reason | +| `ai-prompt.json` | OpenAI 요청 대상 조합과 코드 원문을 제외한 file·line range·길이·hash·잘림 여부. prompt가 있을 때만 생성 | | `ai-response.json` | provider response와 제안 patch 원문을 제외한 byte length·line count·hunk range | | `ai-error.json` | provider 호출 또는 response validation 실패 요약. 실패가 없으면 생성되지 않을 수 있음 | -| `ai-result.json` | response validation 이후 branch별 AI prediction, skipped, failed 매핑 결과 | -| `report.md` | 최종 Markdown report | +| `ai-result.json` | response validation 이후 대상 조합의 `predicted`, `failed` 결과. 대상이 없어도 생성 | -debug artifact에는 `GITHUB_TOKEN`, `WATCHER_GITHUB_TOKEN`, `OPENAI_API_KEY`, `DISCORD_WEBHOOK_URL`을 기록하지 않습니다. raw file content와 raw diff 전문도 포함하지 않습니다. +`ai-response.json`은 응답이 있을 때만, `ai-error.json`은 실패가 있을 때만 생성합니다. debug artifact에는 `GITHUB_TOKEN`, `WATCHER_GITHUB_TOKEN`, `OPENAI_API_KEY`, `DISCORD_WEBHOOK_URL`을 기록하지 않습니다. raw source, raw patch, provider 오류 원문도 포함하지 않습니다. ## Report channel @@ -191,8 +175,6 @@ WATCHER_REPOSITORY=owner/repo \ WATCHER_REPOSITORY_PATH=/path/to/watched-repository \ WATCHER_BASE_BRANCH=develop \ WATCHER_DEFAULT_BRANCH=main \ -WATCHER_CRITICAL_FILE_PATTERNS='package-lock.json -.github/workflows/**' \ WATCHER_DEBUG_ARTIFACT_DIR=/tmp/watcher-debug \ GITHUB_TOKEN=fine-grained-pat \ OPENAI_API_KEY=openai-api-key \ @@ -216,10 +198,9 @@ npm test | 설정 | 테스트 값 | | --- | --- | -| `base_branch` | 기준 branch. 예: `develop` | +| `base_branch` | 비교 조합에 포함할 branch. 예: `develop` | | `default_branch` | 제외할 default branch. 예: `main` | | `watcher_version` | 테스트할 Watcher release tag. 비워두면 workflow ref 기준 | -| `critical_file_patterns` | 테스트할 critical file pattern. 예: `package-lock.json`, `.github/workflows/**` | | `upload_debug_artifact` | 문제 원인 추적이 필요할 때만 `true` | 3. consumer repository secret을 설정합니다. @@ -242,7 +223,7 @@ Discord 메시지가 정상적으로 도착하면 consumer repository의 예시 local test는 Watcher 내부 로직이 기대한 입력을 처리하는지 확인합니다. GitHub Actions의 reusable workflow, repository checkout, remote branch fetch, 실제 API 권한, schedule timing은 검증하지 않습니다. -scheduled run은 consumer repository의 실제 remote branch를 fetch하고, `base_branch`와 `default_branch`를 제외한 branch를 대상으로 merge signal과 metadata를 다시 수집합니다. 따라서 local test가 통과해도 consumer repository의 token permission, branch 정리 상태, OpenAI API key, Discord webhook 상태가 잘못되면 scheduled run에서 실패할 수 있습니다. +scheduled run은 consumer repository의 실제 remote branch를 fetch하고, `base_branch`와 선택된 active branch의 모든 조합을 대상으로 merge signal과 변경 문맥을 다시 수집합니다. `default_branch`는 active branch 선택 대상에서 제외합니다. 따라서 local test가 통과해도 consumer repository의 token permission, branch 정리 상태, OpenAI API key, Discord webhook 상태가 잘못되면 scheduled run에서 실패할 수 있습니다. ## Troubleshooting @@ -252,7 +233,7 @@ scheduled run은 consumer repository의 실제 remote branch를 fetch하고, `ba | checkout 또는 fetch 실패 | `WATCHER_GITHUB_TOKEN`의 Repository access, `Contents: Read-only`, workflow `contents: read` 확인 | | PR metadata가 비어 있음 | `WATCHER_GITHUB_TOKEN`의 `Pull requests: Read-only`, commit에 연결된 PR 존재 여부 확인 | | check metadata가 비어 있음 | 해당 branch head SHA의 check run 존재 여부 확인 | -| AI prediction이 `skipped`로 표시됨 | deterministic possibility status가 `critical`인지와 `confirmed_conflict`가 아닌지 확인 | +| AI prediction이 `skipped`로 표시됨 | 조합이 `confirmed_conflict`인지 또는 `potential_overlap`에 `same_hunk_overlap` reason이 있는지 확인 | | AI prediction이 `failed`로 표시됨 | `OPENAI_API_KEY` secret, OpenAI API 응답 형식, rate limit 상태 확인 | | Discord 전송이 되지 않음 | `DISCORD_WEBHOOK_URL` secret, Discord incoming webhook URL, webhook channel 권한 확인 | | merge된 branch가 계속 감시됨 | GitHub `Automatically delete head branches` 설정과 원격 branch 삭제 상태 확인 | diff --git a/docs/examples/consumer-merge-risk-watch.yml b/docs/examples/consumer-merge-risk-watch.yml index 682b758..4588039 100644 --- a/docs/examples/consumer-merge-risk-watch.yml +++ b/docs/examples/consumer-merge-risk-watch.yml @@ -28,9 +28,6 @@ jobs: default_branch: main watcher_version: ${{ inputs.watcher_version }} upload_debug_artifact: ${{ inputs.upload_debug_artifact || false }} - critical_file_patterns: | - package-lock.json - .github/workflows/** secrets: watcher_github_token: ${{ secrets.WATCHER_GITHUB_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} diff --git a/src/branches/branchSelector.ts b/src/branches/branchSelector.ts index 35828ca..868135d 100644 --- a/src/branches/branchSelector.ts +++ b/src/branches/branchSelector.ts @@ -96,7 +96,7 @@ function exclusionReasonFor( options: BranchSelectionOptions, activeBranchCutoff: Date ): BranchExclusionReason | undefined { - // base, default branch는 비교 기준이므로 감시 대상에서 제외 + // base branch는 조합에 별도 포함하고 default branch는 감시하지 않으므로 선택에서 제외 if (branch.name === options.baseBranch) { return "base_branch" } diff --git a/src/index.ts b/src/index.ts index cad0264..cddbda6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export const watcherRuntimeName = "Watcher" // Watcher 실행 구성의 설명 문자열을 반환 export function watcherRuntimeDescription(): string { - return `${watcherRuntimeName} merge conflict probability automation` + return `${watcherRuntimeName} branch pair merge risk automation` } export { collectBranchContexts } from "./branches/branchCollector.js" diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index 2aa817d..843c4e0 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -45,7 +45,6 @@ type MergeRiskWatchOptions = { repositoryPath: string baseBranch: string defaultBranch?: string - criticalFilePatterns: string[] remoteName: string githubApiUrl: string githubToken?: string @@ -100,7 +99,6 @@ export function optionsFromEnvironment( repositoryPath, baseBranch, defaultBranch, - criticalFilePatterns: patternsFrom(optionalEnv(env, "WATCHER_CRITICAL_FILE_PATTERNS")), remoteName: optionalEnv(env, "WATCHER_REMOTE_NAME") ?? "origin", githubApiUrl: optionalEnv(env, "WATCHER_GITHUB_API_URL") ?? "https://api.github.com", githubToken: optionalEnv(env, "GITHUB_TOKEN"), @@ -124,7 +122,6 @@ export async function run(options: MergeRiskWatchOptions): Promise { repositoryPath: options.repositoryPath, baseBranch: options.baseBranch, defaultBranch: options.defaultBranch, - criticalFilePatterns: options.criticalFilePatterns, remoteName: options.remoteName, githubApiUrl: options.githubApiUrl, workflowRef: options.workflowRef, @@ -489,14 +486,6 @@ function githubUrlFor( return url.toString() } -// 줄바꿈으로 전달된 critical file pattern 값을 정리 -function patternsFrom(value: string | undefined): string[] { - return value - ?.split(/\r?\n/) - .map(pattern => pattern.trim()) - .filter(Boolean) ?? [] -} - // stdout을 단일 문자열로 반환하는 git command helper async function gitOutput(cwd: string, args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd }) diff --git a/tests/index.test.ts b/tests/index.test.ts index 628f8d3..23fae64 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -4,5 +4,5 @@ import { watcherRuntimeDescription, watcherRuntimeName } from "../src/index.js" test("exports Watcher runtime identity", () => { assert.equal(watcherRuntimeName, "Watcher") - assert.equal(watcherRuntimeDescription(), "Watcher merge conflict probability automation") + assert.equal(watcherRuntimeDescription(), "Watcher branch pair merge risk automation") }) diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index f0a71de..d351c36 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -20,7 +20,6 @@ test("builds merge risk watch options from environment", () => { WATCHER_REPOSITORY_PATH: "/tmp/repository", WATCHER_BASE_BRANCH: "develop", WATCHER_DEFAULT_BRANCH: "main", - WATCHER_CRITICAL_FILE_PATTERNS: "package-lock.json\n.github/**", WATCHER_GITHUB_API_URL: "https://api.github.test", WATCHER_DEBUG_ARTIFACT_DIR: "/tmp/watcher-debug", WATCHER_WORKFLOW_REF: "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@develop", @@ -32,10 +31,6 @@ test("builds merge risk watch options from environment", () => { repositoryPath: "/tmp/repository", baseBranch: "develop", defaultBranch: "main", - criticalFilePatterns: [ - "package-lock.json", - ".github/**" - ], remoteName: "origin", githubApiUrl: "https://api.github.test", githubToken: "github-token", @@ -51,7 +46,6 @@ test("omits empty optional environment values", () => { WATCHER_REPOSITORY_PATH: "/tmp/repository", WATCHER_BASE_BRANCH: "develop", WATCHER_DEFAULT_BRANCH: " ", - WATCHER_CRITICAL_FILE_PATTERNS: "\n \n", GITHUB_TOKEN: " " }) @@ -60,7 +54,6 @@ test("omits empty optional environment values", () => { repositoryPath: "/tmp/repository", baseBranch: "develop", defaultBranch: undefined, - criticalFilePatterns: [], remoteName: "origin", githubApiUrl: "https://api.github.com", githubToken: undefined @@ -549,7 +542,6 @@ function baseOptions() { repository: "opficdev/Watcher", repositoryPath: "/tmp/repository", baseBranch: "develop", - criticalFilePatterns: [], remoteName: "origin", githubApiUrl: "https://api.github.test", githubToken: "github-token"