From d4c5858dacc80ad184706930317e4e645d4d6641 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:41:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20Spark=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=20=EC=B6=94=EB=A1=A0=20=EC=88=98=EC=A4=80=20?= =?UTF-8?q?=EC=83=81=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/agents/architecture_watcher.toml | 2 +- .codex/agents/code_reviewer.toml | 2 +- .codex/agents/documentation_writer.toml | 2 +- .codex/agents/github_ci_analyst.toml | 2 +- .codex/agents/verification_runner.toml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.codex/agents/architecture_watcher.toml b/.codex/agents/architecture_watcher.toml index a5b393b..6b2d8e7 100644 --- a/.codex/agents/architecture_watcher.toml +++ b/.codex/agents/architecture_watcher.toml @@ -1,7 +1,7 @@ name = "architecture_watcher" description = "Read-only Watcher architecture boundary reviewer for module ownership, deterministic risk, AI assistance, external services, workflow contracts, secrets, debug artifacts, and release packaging." model = "gpt-5.3-codex-spark" -model_reasoning_effort = "medium" +model_reasoning_effort = "xhigh" sandbox_mode = "read-only" developer_instructions = """ Read AGENTS.md and .agents/roles.md before reviewing. diff --git a/.codex/agents/code_reviewer.toml b/.codex/agents/code_reviewer.toml index 1275686..1cccf0a 100644 --- a/.codex/agents/code_reviewer.toml +++ b/.codex/agents/code_reviewer.toml @@ -1,7 +1,7 @@ name = "code_reviewer" description = "Read-only Watcher code reviewer focused on correctness, deterministic regressions, AI failure isolation, secret safety, workflow contracts, scope drift, and missing verification." model = "gpt-5.3-codex-spark" -model_reasoning_effort = "medium" +model_reasoning_effort = "xhigh" sandbox_mode = "read-only" developer_instructions = """ Read AGENTS.md and .agents/roles.md before reviewing. diff --git a/.codex/agents/documentation_writer.toml b/.codex/agents/documentation_writer.toml index 2b33cd7..0c7f6c6 100644 --- a/.codex/agents/documentation_writer.toml +++ b/.codex/agents/documentation_writer.toml @@ -1,7 +1,7 @@ name = "documentation_writer" description = "Watcher documentation writer for PR bodies, issue text, review replies, release notes, README behavior, consumer workflow guidance, and troubleshooting." model = "gpt-5.3-codex-spark" -model_reasoning_effort = "medium" +model_reasoning_effort = "xhigh" sandbox_mode = "workspace-write" developer_instructions = """ Read AGENTS.md and .agents/roles.md before drafting. diff --git a/.codex/agents/github_ci_analyst.toml b/.codex/agents/github_ci_analyst.toml index 1644fa2..5991351 100644 --- a/.codex/agents/github_ci_analyst.toml +++ b/.codex/agents/github_ci_analyst.toml @@ -1,7 +1,7 @@ name = "github_ci_analyst" description = "Read-only Watcher GitHub and CI analyst for live issues, PR review threads, checks, reusable workflows, tags, releases, and Actions logs." model = "gpt-5.3-codex-spark" -model_reasoning_effort = "medium" +model_reasoning_effort = "xhigh" sandbox_mode = "read-only" developer_instructions = """ Read AGENTS.md and .agents/roles.md before analysis. diff --git a/.codex/agents/verification_runner.toml b/.codex/agents/verification_runner.toml index b20142d..088fa8c 100644 --- a/.codex/agents/verification_runner.toml +++ b/.codex/agents/verification_runner.toml @@ -1,7 +1,7 @@ name = "verification_runner" description = "Watcher verification runner for fresh TypeScript builds, compiled tests, targeted tests, Markdown and TOML checks, diff validation, and generated-file inspection." model = "gpt-5.3-codex-spark" -model_reasoning_effort = "medium" +model_reasoning_effort = "xhigh" sandbox_mode = "workspace-write" developer_instructions = """ Read AGENTS.md and .agents/roles.md before verification. From bd66e5b35477f12986fdd948b12303f8a2edda7b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:45:35 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=99=9C=EC=84=B1=20branch=20?= =?UTF-8?q?=EC=84=A0=EB=B3=84=20=EA=B8=B0=EC=A4=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/branches/branchSelector.ts | 74 +++++++++++++++++++--- src/branches/types.ts | 2 + src/workflows/mergeRiskWatch.ts | 4 +- tests/branches/branchCollector.test.ts | 9 ++- tests/branches/branchSelector.test.ts | 88 ++++++++++++++++++++++++-- tests/workflows/mergeRiskWatch.test.ts | 46 +++++++++++++- 6 files changed, 200 insertions(+), 23 deletions(-) diff --git a/src/branches/branchSelector.ts b/src/branches/branchSelector.ts index 744adcc..aa7ad58 100644 --- a/src/branches/branchSelector.ts +++ b/src/branches/branchSelector.ts @@ -7,6 +7,9 @@ import type { RepositoryBranch } from "./types.js" +const activeBranchWindowMilliseconds = 14 * 24 * 60 * 60 * 1_000 +const maximumActiveBranchCount = 30 + export function select( branches: RepositoryBranch[], options: BranchSelectionOptions @@ -16,20 +19,32 @@ export function select( export function selectWithReasons( branches: RepositoryBranch[], - options: BranchSelectionOptions + options: BranchSelectionOptions, + currentTime = new Date() ): BranchSelectionResult { const selected: BranchContext[] = [] const excluded: ExcludedBranch[] = [] + const activeBranchCutoff = new Date( + currentTime.getTime() - activeBranchWindowMilliseconds + ) + const activeBranches: RepositoryBranch[] = [] for (const branch of branches) { - const reason = exclusionReasonFor(branch, options) + const reason = exclusionReasonFor(branch, options, activeBranchCutoff) if (reason) { - excluded.push({ - name: branch.name, - sha: branch.sha, - reason - }) + excluded.push(excludedBranchFor(branch, reason)) + continue + } + + activeBranches.push(branch) + } + + activeBranches.sort(compareActiveBranches) + + for (const [index, branch] of activeBranches.entries()) { + if (maximumActiveBranchCount <= index) { + excluded.push(excludedBranchFor(branch, "branch_limit")) continue } @@ -42,6 +57,17 @@ export function selectWithReasons( } } +function excludedBranchFor( + branch: RepositoryBranch, + reason: BranchExclusionReason +): ExcludedBranch { + return { + name: branch.name, + sha: branch.sha, + reason + } +} + function branchContextFor( branch: RepositoryBranch, options: BranchSelectionOptions @@ -59,7 +85,8 @@ function branchContextFor( function exclusionReasonFor( branch: RepositoryBranch, - options: BranchSelectionOptions + options: BranchSelectionOptions, + activeBranchCutoff: Date ): BranchExclusionReason | undefined { // base, default branch는 비교 기준이므로 감시 대상에서 제외 if (branch.name === options.baseBranch) { @@ -70,5 +97,36 @@ function exclusionReasonFor( return "default_branch" } + const updatedTime = branch.updatedAt?.getTime() + if ( + updatedTime === undefined || + Number.isNaN(updatedTime) || + updatedTime < activeBranchCutoff.getTime() + ) { + return "stale_branch" + } + return undefined } + +function compareActiveBranches( + branch: RepositoryBranch, + other: RepositoryBranch +): number { + const branchTime = branch.updatedAt!.getTime() + const otherTime = other.updatedAt!.getTime() + + if (branchTime !== otherTime) { + return otherTime - branchTime + } + + if (branch.name < other.name) { + return -1 + } + + if (other.name < branch.name) { + return 1 + } + + return 0 +} diff --git a/src/branches/types.ts b/src/branches/types.ts index ef5e36a..151f32c 100644 --- a/src/branches/types.ts +++ b/src/branches/types.ts @@ -28,6 +28,8 @@ export type BranchSelectionOptions = { export type BranchExclusionReason = | "base_branch" | "default_branch" + | "stale_branch" + | "branch_limit" export type ExcludedBranch = { name: string diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index c895e30..4b0f033 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -123,7 +123,7 @@ export async function run(options: MergeRiskWatchOptions): Promise { const branchSelection = selectBranchesWithReasons(repositoryBranches, { baseBranch: options.baseBranch, defaultBranch: options.defaultBranch - }) + }, generatedAt) const branches = branchSelection.selected const inputs: BranchRiskAnalysisInput[] = [] @@ -244,7 +244,7 @@ function branchSourceFor(options: MergeRiskWatchOptions): { const lines = await gitLines(options.repositoryPath, [ "for-each-ref", `refs/remotes/${options.remoteName}`, - "--format=%(refname:short)%09%(objectname)%09%(authorname)%09%(authordate:iso-strict)" + "--format=%(refname:short)%09%(objectname)%09%(authorname)%09%(committerdate:iso-strict)" ]) const branches = lines diff --git a/tests/branches/branchCollector.test.ts b/tests/branches/branchCollector.test.ts index f5a1996..82f26a5 100644 --- a/tests/branches/branchCollector.test.ts +++ b/tests/branches/branchCollector.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict" import { collectBranchContexts } from "../../src/branches/branchCollector.js" import type { BranchSource, RepositoryBranch } from "../../src/index.js" +const currentTime = new Date() + // source에서 수집한 branch 목록을 Watcher 내부 branch context로 변환하는지 확인 test("collects watched branch contexts from source", async () => { const source = new InMemoryBranchSource([ @@ -15,9 +17,9 @@ test("collects watched branch contexts from source", async () => { baseBranch: "main" }) - assert.deepEqual(contexts.map(context => context.name), ["feature/watch", "bot/dependency"]) + assert.deepEqual(contexts.map(context => context.name), ["bot/dependency", "feature/watch"]) assert.equal(contexts[0]?.baseBranch, "main") - assert.equal(contexts[0]?.headSha, "feature/watch-sha") + assert.equal(contexts[0]?.headSha, "bot/dependency-sha") }) class InMemoryBranchSource implements BranchSource { @@ -31,6 +33,7 @@ class InMemoryBranchSource implements BranchSource { function branch(name: string): RepositoryBranch { return { name, - sha: `${name}-sha` + sha: `${name}-sha`, + updatedAt: currentTime } } diff --git a/tests/branches/branchSelector.test.ts b/tests/branches/branchSelector.test.ts index 070a1d6..b4d7315 100644 --- a/tests/branches/branchSelector.test.ts +++ b/tests/branches/branchSelector.test.ts @@ -3,6 +3,9 @@ import assert from "node:assert/strict" import { 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() + // base, default branch가 감시 대상에서 제외되는지 확인 test("excludes base and default branches", () => { const selected = select([ @@ -20,13 +23,13 @@ test("excludes base and default branches", () => { // base/default branch 제외 사유를 debug artifact에 남길 수 있는지 확인 test("records branch exclusion reasons", () => { const result = selectWithReasons([ - branch("main"), - branch("develop"), + branchWithoutUpdatedAt("main"), + branch("develop", new Date(currentTime.getTime() - 15 * dayMilliseconds)), branch("feature/watch") ], { baseBranch: "develop", defaultBranch: "main" - }) + }, currentTime) assert.deepEqual(result.selected.map(branch => branch.name), ["feature/watch"]) assert.deepEqual(result.excluded, [{ @@ -41,7 +44,7 @@ test("records branch exclusion reasons", () => { }) // base/default branch를 제외한 모든 branch가 감시 대상으로 유지되는지 확인 -test("selects every non-base branch", () => { +test("selects every active non-base branch", () => { const selected = select([ branch("feature/watch"), branch("fix/webhook"), @@ -52,10 +55,10 @@ test("selects every non-base branch", () => { }) assert.deepEqual(selected.map(branch => branch.name), [ + "bot/dependency", "feature/watch", "fix/webhook", - "release/1.0", - "bot/dependency" + "release/1.0" ]) }) @@ -105,7 +108,70 @@ test("builds branch context metadata", () => { ]) }) -function branch(name: string, updatedAt?: Date): RepositoryBranch { +// 14일 경계는 포함하고 더 오래되었거나 시각이 없는 branch는 제외하는지 확인 +test("selects branches updated within the inclusive 14 day window", () => { + const result = selectWithReasons([ + branch("feature/boundary", new Date(currentTime.getTime() - 14 * dayMilliseconds)), + branch("feature/stale", new Date(currentTime.getTime() - 14 * dayMilliseconds - 1)), + branchWithoutUpdatedAt("feature/missing") + ], { + baseBranch: "main" + }, currentTime) + + assert.deepEqual(result.selected.map(branch => branch.name), ["feature/boundary"]) + assert.deepEqual(result.excluded, [{ + name: "feature/stale", + sha: "feature/stale-sha", + reason: "stale_branch" + }, { + name: "feature/missing", + sha: "feature/missing-sha", + reason: "stale_branch" + }]) +}) + +// 최근 갱신 순으로 정렬하고 같은 시각은 branch 이름 순으로 정렬하는지 확인 +test("sorts active branches by updated time and branch name", () => { + const selected = select([ + branch("feature/older", new Date(currentTime.getTime() - dayMilliseconds)), + branch("feature/ä"), + branch("feature/z"), + branch("feature/a") + ], { + baseBranch: "main" + }) + + assert.deepEqual(selected.map(branch => branch.name), [ + "feature/a", + "feature/z", + "feature/ä", + "feature/older" + ]) +}) + +// 최근 branch 30개만 선택하고 나머지 branch 제외 사유를 남기는지 확인 +test("limits active branches and records overflow reasons", () => { + const branches = Array.from({ length: 31 }, (_, index) => branch( + `feature/${String(index).padStart(2, "0")}`, + new Date(currentTime.getTime() - index * 1_000) + )) + + const result = selectWithReasons(branches, { + baseBranch: "main" + }, currentTime) + + assert.equal(result.selected.length, 30) + assert.deepEqual(result.selected.map(branch => branch.name), branches + .slice(0, 30) + .map(branch => branch.name)) + assert.deepEqual(result.excluded, [{ + name: "feature/30", + sha: "feature/30-sha", + reason: "branch_limit" + }]) +}) + +function branch(name: string, updatedAt: Date | undefined = currentTime): RepositoryBranch { return { name, sha: `${name}-sha`, @@ -113,3 +179,11 @@ function branch(name: string, updatedAt?: Date): RepositoryBranch { updatedAt } } + +function branchWithoutUpdatedAt(name: string): RepositoryBranch { + return { + name, + sha: `${name}-sha`, + author: "opfic" + } +} diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index f94375c..f181bbf 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -147,6 +147,16 @@ test("writes merge risk debug artifacts", async () => { status?: string }> }>(fixture.debugArtifactDir, "deterministic-evidence.json") + const branchSelectionArtifact = await readJson<{ + selectedBranches?: Array<{ + name?: string + updatedAt?: string + }> + excludedBranches?: Array<{ + name?: string + reason?: string + }> + }>(fixture.debugArtifactDir, "branch-selection.json") const combinedArtifact = (await Promise.all(files.map(file => readFile(join(fixture.debugArtifactDir, file), "utf8") ))).join("\n") @@ -161,6 +171,21 @@ test("writes merge risk debug artifacts", async () => { assert.equal(deterministicArtifact.risks?.[0]?.status, "critical") 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 + ]) + assert.deepEqual(branchSelectionArtifact.excludedBranches?.map(branch => ({ + name: branch.name, + reason: branch.reason + })), [{ + name: "main", + reason: "base_branch" + }]) assert.doesNotMatch(combinedArtifact, /openai-secret/) assert.doesNotMatch(combinedArtifact, /webhook-secret/) assert.doesNotMatch(combinedArtifact, /feature critical content/) @@ -311,12 +336,18 @@ function jsonResponse(body: unknown): Response { async function createWorkflowGitFixture(): Promise<{ repositoryPath: string debugArtifactDir: string + committerDate: string remove(): Promise }> { const root = await mkdtemp(join(tmpdir(), "watcher-workflow-fixture-")) const repositoryPath = join(root, "repository") const remotePath = join(root, "remote.git") const debugArtifactDir = join(root, "debug") + const committerDate = new Date(Math.floor(Date.now() / 1_000) * 1_000).toISOString() + const commitDates = { + GIT_AUTHOR_DATE: "2000-01-01T00:00:00.000Z", + GIT_COMMITTER_DATE: committerDate + } await git(root, ["init", "--initial-branch=main", repositoryPath]) await git(repositoryPath, ["config", "user.email", "opfic@example.com"]) @@ -328,12 +359,12 @@ async function createWorkflowGitFixture(): Promise<{ await git(repositoryPath, ["checkout", "-b", "feature/critical"]) await writeFile(join(repositoryPath, "critical.txt"), "feature critical content\n") - await git(repositoryPath, ["commit", "-am", "feature critical"]) + 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"), "peer critical content\n") - await git(repositoryPath, ["commit", "-am", "feature critical peer"]) + await git(repositoryPath, ["commit", "-am", "feature critical peer"], commitDates) await git(repositoryPath, ["checkout", "main"]) await git(root, ["init", "--bare", remotePath]) @@ -345,6 +376,7 @@ async function createWorkflowGitFixture(): Promise<{ return { repositoryPath, debugArtifactDir, + committerDate, async remove(): Promise { await rm(root, { recursive: true, force: true }) } @@ -376,9 +408,17 @@ async function suppressConsoleWarn(operation: () => Promise): Promise { } } -async function git(cwd: string, args: string[]): Promise { +async function git( + cwd: string, + args: string[], + env: NodeJS.ProcessEnv = {} +): Promise { const result = await execFileAsync("git", args, { cwd, + env: { + ...process.env, + ...env + }, maxBuffer: 10 * 1024 * 1024 }) From f98e1cf6c5c92234f7b177b4b3b13c506baff1a0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:04:16 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20=ED=95=A8=EC=88=98=20=EC=97=AD=ED=95=A0=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/predictionRunner.ts | 1 + src/branches/branchCollector.ts | 1 + src/branches/branchSelector.ts | 6 ++++++ src/debug/debugArtifact.ts | 6 ++++++ src/git/gitMergeSignalCollector.ts | 7 +++++++ src/index.ts | 1 + src/workflows/mergeRiskWatch.ts | 9 +++++++++ 7 files changed, 31 insertions(+) diff --git a/src/ai/predictionRunner.ts b/src/ai/predictionRunner.ts index 2007ce0..69625b0 100644 --- a/src/ai/predictionRunner.ts +++ b/src/ai/predictionRunner.ts @@ -170,6 +170,7 @@ function predictionKeyFor( return `${baseBranch}\u0000${branchName}` } +// debug artifact에서 branch를 식별할 최소 metadata를 구성 function debugTargetFor(payload: AiPredictionEvidencePayload): AiPredictionDebugTarget { return { branchName: payload.branch.name, diff --git a/src/branches/branchCollector.ts b/src/branches/branchCollector.ts index f659c34..53d4385 100644 --- a/src/branches/branchCollector.ts +++ b/src/branches/branchCollector.ts @@ -5,6 +5,7 @@ export type BranchSource = { listBranches(): Promise } +// BranchSource에서 branch를 수집하고 감시 대상 BranchContext만 반환 export async function collectBranchContexts( source: BranchSource, options: BranchSelectionOptions diff --git a/src/branches/branchSelector.ts b/src/branches/branchSelector.ts index aa7ad58..b934766 100644 --- a/src/branches/branchSelector.ts +++ b/src/branches/branchSelector.ts @@ -10,6 +10,7 @@ import type { const activeBranchWindowMilliseconds = 14 * 24 * 60 * 60 * 1_000 const maximumActiveBranchCount = 30 +// 제외 사유가 필요 없는 호출자를 위해 선택된 BranchContext 목록만 반환 export function select( branches: RepositoryBranch[], options: BranchSelectionOptions @@ -17,6 +18,7 @@ export function select( return selectWithReasons(branches, options).selected } +// 활성 기간과 최대 개수 기준을 적용해 선택 branch와 제외 사유를 함께 구성 export function selectWithReasons( branches: RepositoryBranch[], options: BranchSelectionOptions, @@ -57,6 +59,7 @@ export function selectWithReasons( } } +// 제외된 branch의 식별 정보와 제외 사유를 기록할 모델로 변환 function excludedBranchFor( branch: RepositoryBranch, reason: BranchExclusionReason @@ -68,6 +71,7 @@ function excludedBranchFor( } } +// 감시 대상으로 선택된 branch를 분석 단계의 BranchContext로 변환 function branchContextFor( branch: RepositoryBranch, options: BranchSelectionOptions @@ -83,6 +87,7 @@ function branchContextFor( } } +// base, default, 갱신 시각 기준으로 branch의 제외 사유를 결정 function exclusionReasonFor( branch: RepositoryBranch, options: BranchSelectionOptions, @@ -109,6 +114,7 @@ function exclusionReasonFor( return undefined } +// 활성 branch를 최근 갱신 순으로 정렬하고 동률이면 이름 순으로 정렬 function compareActiveBranches( branch: RepositoryBranch, other: RepositoryBranch diff --git a/src/debug/debugArtifact.ts b/src/debug/debugArtifact.ts index 0f36877..1a75ef6 100644 --- a/src/debug/debugArtifact.ts +++ b/src/debug/debugArtifact.ts @@ -6,6 +6,7 @@ export type DebugArtifactWriter = { writeText(name: string, value: string): Promise } +// directory가 설정된 경우에만 파일 기반 debug artifact writer를 생성 export function writerFor(directory: string | undefined): DebugArtifactWriter | undefined { return directory ? new FileDebugArtifactWriter(directory) @@ -13,12 +14,15 @@ export function writerFor(directory: string | undefined): DebugArtifactWriter | } class FileDebugArtifactWriter implements DebugArtifactWriter { + // debug artifact를 저장할 directory를 보관 constructor(private readonly directory: string) {} + // 값을 날짜 변환 규칙이 적용된 JSON 문자열로 직렬화해 저장 async writeJson(name: string, value: unknown): Promise { await this.writeText(name, `${JSON.stringify(value, jsonValueFor, 2)}\n`) } + // debug artifact directory를 준비하고 text 파일을 쓰되 실패는 경고로 격리 async writeText(name: string, value: string): Promise { try { await mkdir(this.directory, { @@ -31,10 +35,12 @@ class FileDebugArtifactWriter implements DebugArtifactWriter { } } +// unknown error를 경고에 사용할 문자열로 변환 function errorMessageFor(error: unknown): string { return error instanceof Error ? error.message : String(error) } +// JSON 직렬화 과정에서 Date를 ISO 문자열로 변환 function jsonValueFor(_key: string, value: unknown): unknown { if (value instanceof Date) { return value.toISOString() diff --git a/src/git/gitMergeSignalCollector.ts b/src/git/gitMergeSignalCollector.ts index 0dbfecc..7d17afd 100644 --- a/src/git/gitMergeSignalCollector.ts +++ b/src/git/gitMergeSignalCollector.ts @@ -8,6 +8,7 @@ import type { GitMergeSignal, GitMergeSignalCollectionOptions } from "./types.js const execFileAsync = promisify(execFile) +// remote branch를 가져와 merge base, 변경 파일, 가상 merge 결과를 수집 export async function collectGitMergeSignal( branch: BranchContext, options: GitMergeSignalCollectionOptions @@ -56,6 +57,7 @@ export async function collectGitMergeSignal( } } +// 임시 worktree에서 merge를 시도해 repository 변경 없이 충돌 여부를 확인 async function runVirtualMerge(input: { branch: BranchContext options: GitMergeSignalCollectionOptions @@ -135,6 +137,7 @@ async function runVirtualMerge(input: { } } +// 지정 branch의 최신 remote ref를 로컬 remote tracking ref로 갱신 async function fetchBranch( repositoryPath: string, remote: string, @@ -148,11 +151,13 @@ async function fetchBranch( ]) } +// 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) @@ -163,6 +168,7 @@ async function gitOutput(cwd: string, args: string[]): Promise { return result.stdout.trim() } +// git command를 실행하고 성공과 실패를 동일한 결과 구조로 정규화 async function gitResult( cwd: string, args: string[] @@ -194,6 +200,7 @@ async function gitResult( } } +// unknown git 오류를 signal에 기록할 문자열로 변환 function formatGitError(error: unknown): string { if (error instanceof Error) { return error.message.trim() diff --git a/src/index.ts b/src/index.ts index 9019d68..b49ae51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export const watcherRuntimeName = "Watcher" +// Watcher 실행 구성의 설명 문자열을 반환 export function watcherRuntimeDescription(): string { return `${watcherRuntimeName} merge conflict probability automation` } diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index 4b0f033..95992e7 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -184,8 +184,11 @@ export async function run(options: MergeRiskWatchOptions): Promise { { debugObserver: debugArtifactWriter ? { + // 생성된 AI prompt를 debug artifact로 기록 onPromptBuilt: event => debugArtifactWriter.writeJson("ai-prompt.json", event), + // 받은 AI response를 debug artifact로 기록 onResponseReceived: event => debugArtifactWriter.writeJson("ai-response.json", event), + // AI prediction 실패 정보를 debug artifact로 기록 onPredictionFailed: event => debugArtifactWriter.writeJson("ai-error.json", event) } : undefined @@ -215,6 +218,7 @@ export async function run(options: MergeRiskWatchOptions): Promise { } } +// AI 대상 선택 artifact에 기록할 branch 식별 정보만 추출 function aiDebugTargetFor(payload: AiPredictionEvidencePayload): { branchName: string baseBranch: string @@ -225,6 +229,7 @@ function aiDebugTargetFor(payload: AiPredictionEvidencePayload): { } } +// AI prediction 제외 branch의 deterministic 제외 사유를 분류 function aiSkippedReasonFor(payload: AiPredictionEvidencePayload): "not_target" | "confirmed_conflict" { return payload.possibility.reasons.some(reason => reason.code === "confirmed_conflict") ? "confirmed_conflict" @@ -240,6 +245,7 @@ function branchSourceFor(options: MergeRiskWatchOptions): { : undefined return { + // remote tracking branch와 선택적 GitHub metadata를 함께 수집 listBranches: async () => { const lines = await gitLines(options.repositoryPath, [ "for-each-ref", @@ -278,6 +284,7 @@ export function githubMetadataClientFor(options: MergeRiskWatchOptions): { const [owner, repo] = repositoryPartsFrom(options.repository) return { + // commit ref의 GitHub check run metadata를 조회하고 실패 시 빈 목록으로 격리 checksFor: async ref => { try { const response = await githubJson(options, [ @@ -302,6 +309,7 @@ export function githubMetadataClientFor(options: MergeRiskWatchOptions): { return [] } }, + // commit과 연결된 pull request에서 branch 일치 항목을 우선하고 없으면 첫 metadata를 사용 pullRequestFor: async (sha, branchName) => { const pulls = await githubJson(options, [ "repos", @@ -327,6 +335,7 @@ export function githubMetadataClientFor(options: MergeRiskWatchOptions): { } } +// unknown error를 경고에 사용할 문자열로 변환 function warningMessageFor(error: unknown): string { return error instanceof Error ? error.message : String(error) }