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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codex/agents/architecture_watcher.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion .codex/agents/code_reviewer.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion .codex/agents/documentation_writer.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion .codex/agents/github_ci_analyst.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion .codex/agents/verification_runner.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/ai/predictionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ function predictionKeyFor(
return `${baseBranch}\u0000${branchName}`
}

// debug artifact에서 branch를 식별할 최소 metadata를 구성
function debugTargetFor(payload: AiPredictionEvidencePayload): AiPredictionDebugTarget {
return {
branchName: payload.branch.name,
Expand Down
1 change: 1 addition & 0 deletions src/branches/branchCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type BranchSource = {
listBranches(): Promise<RepositoryBranch[]>
}

// BranchSource에서 branch를 수집하고 감시 대상 BranchContext만 반환
export async function collectBranchContexts(
source: BranchSource,
options: BranchSelectionOptions
Expand Down
80 changes: 72 additions & 8 deletions src/branches/branchSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,46 @@ import type {
RepositoryBranch
} from "./types.js"

const activeBranchWindowMilliseconds = 14 * 24 * 60 * 60 * 1_000
const maximumActiveBranchCount = 30

// 제외 사유가 필요 없는 호출자를 위해 선택된 BranchContext 목록만 반환
export function select(
branches: RepositoryBranch[],
options: BranchSelectionOptions
): BranchContext[] {
return selectWithReasons(branches, options).selected
}

// 활성 기간과 최대 개수 기준을 적용해 선택 branch와 제외 사유를 함께 구성
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
}

Expand All @@ -42,6 +59,19 @@ export function selectWithReasons(
}
}

// 제외된 branch의 식별 정보와 제외 사유를 기록할 모델로 변환
function excludedBranchFor(
branch: RepositoryBranch,
reason: BranchExclusionReason
): ExcludedBranch {
return {
name: branch.name,
sha: branch.sha,
reason
}
}

// 감시 대상으로 선택된 branch를 분석 단계의 BranchContext로 변환
function branchContextFor(
branch: RepositoryBranch,
options: BranchSelectionOptions
Expand All @@ -57,9 +87,11 @@ function branchContextFor(
}
}

// base, default, 갱신 시각 기준으로 branch의 제외 사유를 결정
function exclusionReasonFor(
branch: RepositoryBranch,
options: BranchSelectionOptions
options: BranchSelectionOptions,
activeBranchCutoff: Date
): BranchExclusionReason | undefined {
// base, default branch는 비교 기준이므로 감시 대상에서 제외
if (branch.name === options.baseBranch) {
Expand All @@ -70,5 +102,37 @@ function exclusionReasonFor(
return "default_branch"
}

const updatedTime = branch.updatedAt?.getTime()
if (
updatedTime === undefined ||
Number.isNaN(updatedTime) ||
updatedTime < activeBranchCutoff.getTime()
) {
return "stale_branch"
}

return undefined
}

// 활성 branch를 최근 갱신 순으로 정렬하고 동률이면 이름 순으로 정렬
function compareActiveBranches(
branch: RepositoryBranch,
other: RepositoryBranch
): number {
const branchTime = branch.updatedAt!.getTime()
const otherTime = other.updatedAt!.getTime()
Comment thread
opficdev marked this conversation as resolved.

if (branchTime !== otherTime) {
return otherTime - branchTime
}

if (branch.name < other.name) {
return -1
}

if (other.name < branch.name) {
return 1
}

return 0
}
2 changes: 2 additions & 0 deletions src/branches/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type BranchSelectionOptions = {
export type BranchExclusionReason =
| "base_branch"
| "default_branch"
| "stale_branch"
| "branch_limit"

export type ExcludedBranch = {
name: string
Expand Down
6 changes: 6 additions & 0 deletions src/debug/debugArtifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,23 @@ export type DebugArtifactWriter = {
writeText(name: string, value: string): Promise<void>
}

// directory가 설정된 경우에만 파일 기반 debug artifact writer를 생성
export function writerFor(directory: string | undefined): DebugArtifactWriter | undefined {
return directory
? new FileDebugArtifactWriter(directory)
: undefined
}

class FileDebugArtifactWriter implements DebugArtifactWriter {
// debug artifact를 저장할 directory를 보관
constructor(private readonly directory: string) {}

// 값을 날짜 변환 규칙이 적용된 JSON 문자열로 직렬화해 저장
async writeJson(name: string, value: unknown): Promise<void> {
await this.writeText(name, `${JSON.stringify(value, jsonValueFor, 2)}\n`)
}

// debug artifact directory를 준비하고 text 파일을 쓰되 실패는 경고로 격리
async writeText(name: string, value: string): Promise<void> {
try {
await mkdir(this.directory, {
Expand All @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions src/git/gitMergeSignalCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,6 +57,7 @@ export async function collectGitMergeSignal(
}
}

// 임시 worktree에서 merge를 시도해 repository 변경 없이 충돌 여부를 확인
async function runVirtualMerge(input: {
branch: BranchContext
options: GitMergeSignalCollectionOptions
Expand Down Expand Up @@ -135,6 +137,7 @@ async function runVirtualMerge(input: {
}
}

// 지정 branch의 최신 remote ref를 로컬 remote tracking ref로 갱신
async function fetchBranch(
repositoryPath: string,
remote: string,
Expand All @@ -148,11 +151,13 @@ async function fetchBranch(
])
}

// git stdout을 비어 있지 않은 line 목록으로 변환
async function gitLines(cwd: string, args: string[]): Promise<string[]> {
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<string> {
const result = await gitResult(cwd, args)

Expand All @@ -163,6 +168,7 @@ async function gitOutput(cwd: string, args: string[]): Promise<string> {
return result.stdout.trim()
}

// git command를 실행하고 성공과 실패를 동일한 결과 구조로 정규화
async function gitResult(
cwd: string,
args: string[]
Expand Down Expand Up @@ -194,6 +200,7 @@ async function gitResult(
}
}

// unknown git 오류를 signal에 기록할 문자열로 변환
function formatGitError(error: unknown): string {
if (error instanceof Error) {
return error.message.trim()
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const watcherRuntimeName = "Watcher"

// Watcher 실행 구성의 설명 문자열을 반환
export function watcherRuntimeDescription(): string {
return `${watcherRuntimeName} merge conflict probability automation`
}
Expand Down
13 changes: 11 additions & 2 deletions src/workflows/mergeRiskWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export async function run(options: MergeRiskWatchOptions): Promise<void> {
const branchSelection = selectBranchesWithReasons(repositoryBranches, {
baseBranch: options.baseBranch,
defaultBranch: options.defaultBranch
})
}, generatedAt)
const branches = branchSelection.selected
const inputs: BranchRiskAnalysisInput[] = []

Expand Down Expand Up @@ -184,8 +184,11 @@ export async function run(options: MergeRiskWatchOptions): Promise<void> {
{
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
Expand Down Expand Up @@ -215,6 +218,7 @@ export async function run(options: MergeRiskWatchOptions): Promise<void> {
}
}

// AI 대상 선택 artifact에 기록할 branch 식별 정보만 추출
function aiDebugTargetFor(payload: AiPredictionEvidencePayload): {
branchName: string
baseBranch: string
Expand All @@ -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"
Expand All @@ -240,11 +245,12 @@ function branchSourceFor(options: MergeRiskWatchOptions): {
: undefined

return {
// remote tracking branch와 선택적 GitHub metadata를 함께 수집
listBranches: async () => {
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
Expand Down Expand Up @@ -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<GitHubCheckRunsResponse>(options, [
Expand All @@ -302,6 +309,7 @@ export function githubMetadataClientFor(options: MergeRiskWatchOptions): {
return []
}
},
// commit과 연결된 pull request에서 branch 일치 항목을 우선하고 없으면 첫 metadata를 사용
pullRequestFor: async (sha, branchName) => {
const pulls = await githubJson<GitHubPullRequestResponse[]>(options, [
"repos",
Expand All @@ -327,6 +335,7 @@ export function githubMetadataClientFor(options: MergeRiskWatchOptions): {
}
}

// unknown error를 경고에 사용할 문자열로 변환
function warningMessageFor(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
Expand Down
9 changes: 6 additions & 3 deletions tests/branches/branchCollector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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 {
Expand All @@ -31,6 +33,7 @@ class InMemoryBranchSource implements BranchSource {
function branch(name: string): RepositoryBranch {
return {
name,
sha: `${name}-sha`
sha: `${name}-sha`,
updatedAt: currentTime
}
}
Loading