import { z } from 'zod'; import { getEnv } from '../config/env.js'; import { db } from '../db/client.js'; import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js'; export const PLAN_TABLE = 'plan_items'; export const PLAN_ISSUE_TABLE = 'plan_issue_histories'; export const PLAN_ACTION_TABLE = 'plan_action_histories'; export const PLAN_SOURCE_WORK_TABLE = 'plan_source_work_histories'; export const PLAN_RELEASE_REVIEW_TABLE = 'plan_release_reviews'; export const planStatuses = ['등록', '작업중', '작업완료', '릴리즈완료', '완료'] as const; export const planAutomationTypes = ['none', 'plan', 'command_execution', 'non_source_work', 'auto_worker'] as const; export const planWorkerStatuses = [ '대기', '브랜치생성중', '브랜치준비', '자동작업중', 'release반영대기', 'release반영중', 'release반영완료', 'main반영대기', 'main반영중', 'main반영완료', '자동완료', '브랜치실패', 'release반영실패', 'main반영실패', '자동작업실패', '작업취소', ] as const; export const planReleaseReviewStatuses = ['pending', 'reviewing', 'approved', 'changes-requested'] as const; export type PlanAutomationUsageSnapshot = { tokenTotals: { total: number; input: number; output: number; cached: number; reasoning: number; }; totalTokens: number; retryCount: number; sourceWorkCount: number; processingStartedAt: string | null; processingEndedAt: string | null; processingEndedAtSource: string | null; processingDurationSeconds: number | null; }; type PlanRowOptions = { issueTags?: string[]; hasOpenIssues?: boolean; maskNote?: boolean; noteMasked?: boolean; releaseReviewNote?: string; }; export const statusSchema = z.enum(planStatuses); export const setupSchema = z.object({ recreate: z.boolean().optional(), }); function resolvePlanAutomationTypeAlias(value: unknown) { if (typeof value !== 'string') { return value; } const normalizedValue = value.trim(); if (normalizedValue === 'plan_registration') { return 'plan'; } if (normalizedValue === 'general_development') { return 'auto_worker'; } return normalizedValue; } export const planAutomationTypeSchema = z.preprocess( resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes), ); export const createPlanSchema = z.object({ workId: z.string().trim().optional().default('작업ID'), note: z.string().default(''), automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).default('none')), releaseTarget: z.string().trim().min(1).default('release'), jangsingProcessingRequired: z.boolean().default(true), autoDeployToMain: z.boolean().default(true), repeatRequestEnabled: z.boolean().default(false), repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60), }); export const updatePlanSchema = z.object({ workId: z.string().trim().optional(), note: z.string().optional(), automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).optional()), releaseTarget: z.string().trim().min(1).optional(), jangsingProcessingRequired: z.boolean().optional(), autoDeployToMain: z.boolean().optional(), repeatRequestEnabled: z.boolean().optional(), repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(), }); export const updatePlanJangsingProcessingSchema = z.object({ jangsingProcessingRequired: z.boolean(), }); export const issueActionSchema = z.object({ actionNote: z.string().trim().min(1), resolve: z.boolean().default(false), retry: z.boolean().default(false), }); const planListStatusFilterAliases = new Set(['all', 'in-progress', 'done', 'error']); export const listPlanQuerySchema = z.object({ status: z.preprocess((value) => { if (typeof value !== 'string') { return value; } const normalizedValue = value.trim(); if (!normalizedValue || planListStatusFilterAliases.has(normalizedValue)) { return undefined; } return normalizedValue; }, statusSchema.optional()), }); export type PlanStatus = (typeof planStatuses)[number]; export type PlanAutomationType = (typeof planAutomationTypes)[number]; export type PlanWorkerStatus = (typeof planWorkerStatuses)[number]; export type PlanReleaseReviewStatus = (typeof planReleaseReviewStatuses)[number]; export const planReleaseReviewStatusSchema = z.enum(planReleaseReviewStatuses); const planReleaseReviewMetadataSchema = z.object({ summary: z.string().trim().min(1).max(500).optional(), pageSelectionIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), checkedPageSelectionIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), docIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), componentIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), widgetIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), }); export const updatePlanReleaseReviewSchema = z.object({ status: planReleaseReviewStatusSchema.optional(), reviewNote: z.string().max(4000).optional(), metadata: planReleaseReviewMetadataSchema.optional(), }); export type PlanReleaseReviewMetadata = z.infer; const planFailureWorkerStatuses = new Set([ '브랜치실패', '자동작업실패', 'release반영실패', 'main반영실패', ]); const functionCheckEditableStatuses = new Set(['작업완료', '릴리즈완료', '완료']); const automationIssueTags = ['#브랜치실패', '#자동작업실패', '#release반영실패', '#main반영실패'] as const; const WORKLOG_EVIDENCE_PATH_PATTERN = /^docs\/(?:worklogs\/.+\.md|assets\/worklogs\/.+)/i; function sanitizeBranchToken(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48); } function normalizePlanWorkId(value?: string | null) { const workId = String(value ?? '').trim(); const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); if (!workId || normalized === '작업id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') { return '작업ID'; } return workId; } export function normalizePlanAutomationType(value: unknown): PlanAutomationType { const normalizedValue = resolvePlanAutomationTypeAlias(value); return planAutomationTypes.includes(normalizedValue as PlanAutomationType) ? (normalizedValue as PlanAutomationType) : 'none'; } function shouldSkipLifecycleSourceWork(row: Record | undefined | null) { return normalizePlanAutomationType(row?.automation_type) === 'plan'; } export function formatPlanNotificationLabel(workId: string | null | undefined, id: number) { const normalizedWorkId = normalizePlanWorkId(workId); return normalizedWorkId === '작업ID' ? `#${id}` : normalizedWorkId; } export function maskPlanNote(value: unknown) { const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return ''; } const words = normalized.split(' ').filter(Boolean); if (words.length === 1) { const [word] = words; if (word.length <= 1) { return '*'; } return `${word[0]}${'*'.repeat(word.length - 1)}`; } return words .map((word, index) => { if (word.length <= 1) { return '*'; } if (index === 0) { return `${word[0]}${'*'.repeat(word.length - 1)}`; } if (index === words.length - 1) { return `${'*'.repeat(word.length - 1)}${word[word.length - 1]}`; } return word; }) .join(' '); } export function normalizeChangedFiles(rawChangedFiles: string[] | null | undefined) { return [...new Set((rawChangedFiles ?? []).map((file) => String(file ?? '').trim()).filter(Boolean))]; } export function filterRetryWorklogEvidencePaths( changedFiles: string[], existingChangedFilesList: string[][], ) { const normalizedChangedFiles = normalizeChangedFiles(changedFiles); const existingEvidencePaths = new Set( existingChangedFilesList .flatMap((files) => normalizeChangedFiles(files)) .filter((file) => WORKLOG_EVIDENCE_PATH_PATTERN.test(file)), ); return normalizedChangedFiles.filter( (file) => !WORKLOG_EVIDENCE_PATH_PATTERN.test(file) || !existingEvidencePaths.has(file), ); } export function buildPlanBranchName(workId: string, id: number) { const token = sanitizeBranchToken(workId) || `plan-${id}`; const prefix = /^auto-worklog-\d{4}-\d{2}-\d{2}$/i.test(String(workId ?? '').trim()) ? 'hotfix' : 'feature'; return `${prefix}/plan-${id}-${token}`; } export function shouldUseLocalMainPlanMode(automationType: unknown) { const env = getEnv(); return Boolean(env.PLAN_LOCAL_MAIN_MODE) && normalizePlanAutomationType(automationType) !== 'auto_worker'; } export function mapPlanRow( row: Record, options?: PlanRowOptions, ) { return { id: row.id, workId: row.work_id, note: options?.maskNote ? maskPlanNote(row.note) : row.note, automationType: normalizePlanAutomationType(row.automation_type), releaseReviewNote: options?.releaseReviewNote ?? '', noteMasked: Boolean(options?.noteMasked), status: row.status, jangsingProcessingRequired: typeof row.jangsing_processing_required === 'boolean' ? row.jangsing_processing_required : row.normal_processing_level === '상', autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false), repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60), assignedBranch: row.assigned_branch, releaseTarget: row.release_target, workerStatus: row.worker_status, lastError: row.last_error, issueTags: options?.issueTags ?? [], hasOpenIssues: options?.hasOpenIssues ?? false, startedAt: row.started_at, completedAt: row.completed_at, mergedAt: row.merged_at, usageSnapshot: mapPlanAutomationUsageSnapshot(row.usage_snapshot), createdAt: row.created_at, updatedAt: row.updated_at, }; } export function mapPlanIssueRow(row: Record) { return { id: row.id, planItemId: row.plan_item_id, issueTag: row.issue_tag, message: row.message, actionNote: row.action_note, resolved: row.resolved, resolvedAt: row.resolved_at, createdAt: row.created_at, }; } export function mapPlanActionRow(row: Record) { return { id: row.id, planItemId: row.plan_item_id, actionType: row.action_type, note: row.note, createdAt: row.created_at, }; } export function mapPlanSourceWorkRow(row: Record) { const changedFilesText = String(row.changed_files ?? '[]'); let changedFiles: string[] = []; const sourceFilesText = String(row.source_files ?? '[]'); type SourceFileRow = { path: string; previousPath: string | null; status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown'; language: string; content: string; }; let sourceFiles: SourceFileRow[] = []; try { changedFiles = normalizeChangedFiles(JSON.parse(changedFilesText) as string[]); } catch { changedFiles = []; } try { sourceFiles = JSON.parse(sourceFilesText) as SourceFileRow[]; } catch { sourceFiles = []; } return { id: row.id, planItemId: row.plan_item_id, summary: row.summary, branchName: row.branch_name, commitHash: row.commit_hash, previewUrl: row.preview_url ?? null, changedFiles, commandLog: row.command_log, diffText: row.diff_text, sourceFiles, createdAt: row.created_at, }; } function normalizePlanAutomationUsageSnapshotMetric(value: unknown) { const normalized = Number(value ?? 0); return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0; } export function mapPlanAutomationUsageSnapshot(value: unknown): PlanAutomationUsageSnapshot | null { let parsedValue = value; if (typeof value === 'string') { const trimmedValue = value.trim(); if (!trimmedValue) { return null; } try { parsedValue = JSON.parse(trimmedValue); } catch { return null; } } if (!parsedValue || typeof parsedValue !== 'object') { return null; } const snapshot = parsedValue as Partial & { tokenTotals?: Partial; }; const tokenTotals: Partial = snapshot.tokenTotals ?? {}; return { tokenTotals: { total: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.total), input: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.input), output: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.output), cached: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.cached), reasoning: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.reasoning), }, totalTokens: normalizePlanAutomationUsageSnapshotMetric(snapshot.totalTokens), retryCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.retryCount), sourceWorkCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.sourceWorkCount), processingStartedAt: typeof snapshot.processingStartedAt === 'string' && snapshot.processingStartedAt.trim() ? snapshot.processingStartedAt : null, processingEndedAt: typeof snapshot.processingEndedAt === 'string' && snapshot.processingEndedAt.trim() ? snapshot.processingEndedAt : null, processingEndedAtSource: typeof snapshot.processingEndedAtSource === 'string' && snapshot.processingEndedAtSource.trim() ? snapshot.processingEndedAtSource : null, processingDurationSeconds: Number.isFinite(Number(snapshot.processingDurationSeconds)) ? Math.max(0, Math.round(Number(snapshot.processingDurationSeconds))) : null, }; } export function mapPlanReleaseReviewMetadata(value: unknown): PlanReleaseReviewMetadata { if (typeof value === 'string' && value.trim()) { try { return planReleaseReviewMetadataSchema.parse(JSON.parse(value)); } catch { return {}; } } if (value && typeof value === 'object') { try { return planReleaseReviewMetadataSchema.parse(value); } catch { return {}; } } return {}; } export function mapPlanReleaseReviewRow(row: Record | null | undefined, planItemId: number) { const metadata = mapPlanReleaseReviewMetadata(row?.metadata ?? null); return { id: row?.id ? Number(row.id) : null, planItemId, status: (row?.status ? String(row.status) : 'pending') as PlanReleaseReviewStatus, reviewNote: row?.review_note ? String(row.review_note) : '', checkedByClientId: row?.checked_by_client_id ? String(row.checked_by_client_id) : null, checkedByNickname: row?.checked_by_nickname ? String(row.checked_by_nickname) : null, checkedAt: row?.checked_at ? String(row.checked_at) : null, metadata, createdAt: row?.created_at ? String(row.created_at) : null, updatedAt: row?.updated_at ? String(row.updated_at) : null, }; } function extractAutomationTokenUsageText(sourceWork: Pick, 'summary' | 'commandLog'>) { const candidates = [ typeof sourceWork.commandLog === 'string' ? sourceWork.commandLog : null, typeof sourceWork.summary === 'string' ? sourceWork.summary : null, ]; for (const candidate of candidates) { if (!candidate) { continue; } const line = candidate .split('\n') .map((entry) => entry.trim()) .find((entry) => /^토큰 사용량:\s*/.test(entry)); if (line) { return line.replace(/^토큰 사용량:\s*/u, '').trim(); } } return null; } function parseTokenMetricValue(valueText: string, unitText: string | undefined) { const normalizedValue = Number(String(valueText ?? '').replace(/,/g, '')); if (!Number.isFinite(normalizedValue)) { return null; } const unit = String(unitText ?? '').trim().toLowerCase(); if (unit === 'k') { return Math.round(normalizedValue * 1_000); } if (unit === 'm') { return Math.round(normalizedValue * 1_000_000); } return Math.round(normalizedValue); } function parseAutomationTokenUsageMetrics(tokenUsageText: string) { const normalizedText = tokenUsageText .replace(/^tokens?\s+used\s*:?\s*/iu, '') .replace(/\(([^)]+)\)/g, ', $1') .trim(); const metrics = new Map(); for (const match of normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu)) { const label = match[3]?.toLowerCase() ?? ''; const value = parseTokenMetricValue(match[1] ?? '', match[2]); if (!label || value === null) { continue; } metrics.set(label, value); } for (const match of normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu)) { const label = match[1]?.toLowerCase() ?? ''; const value = parseTokenMetricValue(match[2] ?? '', match[3]); if (!label || value === null) { continue; } metrics.set(label, value); } if (metrics.size > 0) { return metrics; } const fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i); if (!fallbackMatch) { return null; } const fallbackValue = parseTokenMetricValue(fallbackMatch[1], fallbackMatch[2]); if (fallbackValue === null) { return null; } return new Map([['total', fallbackValue]]); } function getAutomationTotalTokenCount(metrics: Map) { const total = metrics.get('total'); if (typeof total === 'number' && Number.isFinite(total)) { return total; } return ['input', 'output', 'cached', 'reasoning'].reduce((sum, key) => sum + (metrics.get(key) ?? 0), 0); } function isPlanAutomationWorkerActive(workerStatus: unknown) { return [ '브랜치생성중', '브랜치준비', '자동작업중', 'release반영대기', 'release반영중', 'main반영대기', 'main반영중', ].includes(String(workerStatus ?? '').trim()); } async function getLatestPlanReviewCheckedAt(planItemId: number) { const row = await db(PLAN_RELEASE_REVIEW_TABLE) .select('checked_at', 'updated_at', 'id') .where({ plan_item_id: planItemId }) .orderBy([ { column: 'updated_at', order: 'desc' }, { column: 'id', order: 'desc' }, ]) .first(); return row?.checked_at ? String(row.checked_at) : null; } function resolvePlanProcessingEndedAt( row: Record, reviewCheckedAt: string | null, ): { endedAt: string | null; source: string | null } { if (reviewCheckedAt) { return { endedAt: reviewCheckedAt, source: 'review_checked_at', }; } if (row.merged_at) { return { endedAt: String(row.merged_at), source: 'merged_at', }; } if (row.completed_at) { return { endedAt: String(row.completed_at), source: 'completed_at', }; } if (row.updated_at && row.started_at && !isPlanAutomationWorkerActive(row.worker_status)) { return { endedAt: String(row.updated_at), source: 'updated_at', }; } if (row.started_at && isPlanAutomationWorkerActive(row.worker_status)) { return { endedAt: new Date().toISOString(), source: 'in_progress', }; } return { endedAt: null, source: null, }; } function buildPlanAutomationUsageSnapshot( sourceWorks: Array, 'summary' | 'commandLog' | 'createdAt'>>, row: Record, reviewCheckedAt: string | null, ): PlanAutomationUsageSnapshot | null { const totals = new Map(); sourceWorks.forEach((sourceWork) => { const tokenUsageText = extractAutomationTokenUsageText(sourceWork); if (!tokenUsageText) { return; } const metrics = parseAutomationTokenUsageMetrics(tokenUsageText); if (!metrics) { return; } metrics.forEach((value, key) => { totals.set(key, (totals.get(key) ?? 0) + value); }); }); const sourceWorkCount = sourceWorks.length; const processingStartedAt = row.started_at ? String(row.started_at) : sourceWorks[0]?.createdAt ? String(sourceWorks[0].createdAt) : null; const { endedAt, source } = resolvePlanProcessingEndedAt(row, reviewCheckedAt); const processingDurationSeconds = processingStartedAt && endedAt ? Math.max( 0, Math.round((new Date(endedAt).getTime() - new Date(processingStartedAt).getTime()) / 1_000), ) : null; const totalTokens = getAutomationTotalTokenCount(totals); if (sourceWorkCount === 0 && !processingStartedAt && totalTokens === 0) { return null; } return { tokenTotals: { total: Math.max(0, totals.get('total') ?? 0), input: Math.max(0, totals.get('input') ?? 0), output: Math.max(0, totals.get('output') ?? 0), cached: Math.max(0, totals.get('cached') ?? 0), reasoning: Math.max(0, totals.get('reasoning') ?? 0), }, totalTokens, retryCount: Math.max(0, sourceWorkCount - 1), sourceWorkCount, processingStartedAt, processingEndedAt: endedAt, processingEndedAtSource: source, processingDurationSeconds, }; } export async function syncPlanAutomationUsageSnapshot(planItemId: number) { await ensurePlanTable(); const row = await db(PLAN_TABLE).where({ id: planItemId }).first(); if (!row) { return null; } const sourceWorkRows = await db(PLAN_SOURCE_WORK_TABLE) .select('summary', 'command_log', 'created_at') .where({ plan_item_id: planItemId }) .orderBy('created_at', 'asc') .orderBy('id', 'asc'); const reviewCheckedAt = await getLatestPlanReviewCheckedAt(planItemId); const snapshot = buildPlanAutomationUsageSnapshot( sourceWorkRows.map((sourceWorkRow) => ({ summary: sourceWorkRow.summary, commandLog: sourceWorkRow.command_log, createdAt: String(sourceWorkRow.created_at), })), row, reviewCheckedAt, ); await db(PLAN_TABLE) .where({ id: planItemId }) .update({ usage_snapshot: snapshot ? JSON.stringify(snapshot) : null, }); return snapshot; } async function listPlanReleaseReviewNoteMap(planItemIds: number[]) { if (planItemIds.length === 0) { return new Map(); } const rows = await db(PLAN_RELEASE_REVIEW_TABLE) .select('plan_item_id', 'review_note', 'updated_at', 'id') .whereIn('plan_item_id', planItemIds) .orderBy([ { column: 'updated_at', order: 'desc' }, { column: 'id', order: 'desc' }, ]); const noteMap = new Map(); for (const row of rows) { const planItemId = Number(row.plan_item_id); if (!Number.isFinite(planItemId) || noteMap.has(planItemId)) { continue; } noteMap.set(planItemId, row.review_note ? String(row.review_note) : ''); } return noteMap; } async function ensurePlanFailureIssueHistory(planItemId: number) { const planRow = await db(PLAN_TABLE).where({ id: planItemId }).first(); if (!planRow || !planRow.worker_status || !planFailureWorkerStatuses.has(planRow.worker_status)) { return; } if (!planRow.last_error) { return; } const existingIssue = await db(PLAN_ISSUE_TABLE) .where({ plan_item_id: planItemId, issue_tag: `#${planRow.worker_status}`, message: planRow.last_error, }) .first(); if (existingIssue) { return; } await db(PLAN_ISSUE_TABLE).insert({ plan_item_id: planItemId, issue_tag: `#${planRow.worker_status}`, message: planRow.last_error, resolved: false, }); } async function ensureColumn( columnName: string, addColumn: (table: any) => void, ) { const hasColumn = await db.schema.hasColumn(PLAN_TABLE, columnName); if (hasColumn) { return; } await db.schema.alterTable(PLAN_TABLE, (table) => { addColumn(table); }); } async function syncPlanColumns() { const hasIssueNoteColumn = await db.schema.hasColumn(PLAN_TABLE, 'issue_note'); if (hasIssueNoteColumn) { await db.schema.alterTable(PLAN_TABLE, (table) => { table.dropColumn('issue_note'); }); } await ensureColumn('assigned_branch', (table) => { table.string('assigned_branch', 200).nullable(); }); await ensureColumn('release_target', (table) => { table.string('release_target', 120).notNullable().defaultTo('release'); }); await ensureColumn('automation_type', (table) => { table.string('automation_type', 40).notNullable().defaultTo('none'); }); await ensureColumn('auto_deploy_to_main', (table) => { table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); }); await ensureColumn('repeat_request_enabled', (table) => { table.boolean('repeat_request_enabled').notNullable().defaultTo(false); }); await ensureColumn('repeat_interval_minutes', (table) => { table.integer('repeat_interval_minutes').notNullable().defaultTo(60); }); await ensureColumn('normal_processing_level', (table) => { table.string('normal_processing_level', 20).notNullable().defaultTo('중'); }); await ensureColumn('jangsing_processing_required', (table) => { table.boolean('jangsing_processing_required').notNullable().defaultTo(true); }); await ensureColumn('worker_status', (table) => { table.string('worker_status', 80).nullable(); }); await ensureColumn('last_error', (table) => { table.text('last_error').nullable(); }); await ensureColumn('locked_by', (table) => { table.string('locked_by', 120).nullable(); }); await ensureColumn('locked_at', (table) => { table.timestamp('locked_at', { useTz: true }).nullable(); }); await ensureColumn('started_at', (table) => { table.timestamp('started_at', { useTz: true }).nullable(); }); await ensureColumn('completed_at', (table) => { table.timestamp('completed_at', { useTz: true }).nullable(); }); await ensureColumn('merged_at', (table) => { table.timestamp('merged_at', { useTz: true }).nullable(); }); await ensureColumn('usage_snapshot', (table) => { table.text('usage_snapshot').nullable(); }); await db(PLAN_TABLE) .where({ automation_type: 'plan_registration' }) .update({ automation_type: 'plan' }); await db(PLAN_TABLE) .where({ automation_type: 'general_development' }) .update({ automation_type: 'auto_worker' }); } async function dropPlanWorkIdUniqueConstraint() { await db.raw( ` do $$ declare constraint_name text; begin for constraint_name in select con.conname from pg_constraint con join pg_class rel on rel.oid = con.conrelid join pg_namespace nsp on nsp.oid = rel.relnamespace join pg_attribute att on att.attrelid = rel.oid and att.attnum = any(con.conkey) where nsp.nspname = current_schema() and rel.relname = '${PLAN_TABLE}' and con.contype = 'u' and att.attname = 'work_id' loop execute format('alter table %I.%I drop constraint %I', current_schema(), '${PLAN_TABLE}', constraint_name); end loop; end $$; `, ); } async function ensurePlanIssueTable() { const exists = await db.schema.hasTable(PLAN_ISSUE_TABLE); if (exists) { return; } await db.schema.createTable(PLAN_ISSUE_TABLE, (table) => { table.increments('id').primary(); table.integer('plan_item_id').notNullable().index(); table.string('issue_tag', 120).notNullable(); table.text('message').notNullable(); table.text('action_note').nullable(); table.boolean('resolved').notNullable().defaultTo(false); table.timestamp('resolved_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); } async function ensurePlanActionTable() { const exists = await db.schema.hasTable(PLAN_ACTION_TABLE); if (exists) { return; } await db.schema.createTable(PLAN_ACTION_TABLE, (table) => { table.increments('id').primary(); table.integer('plan_item_id').notNullable().index(); table.string('action_type', 120).notNullable(); table.text('note').notNullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); } async function ensurePlanSourceWorkTable() { const exists = await db.schema.hasTable(PLAN_SOURCE_WORK_TABLE); if (exists) { const hasPreviewUrlColumn = await db.schema.hasColumn(PLAN_SOURCE_WORK_TABLE, 'preview_url'); if (!hasPreviewUrlColumn) { await db.schema.alterTable(PLAN_SOURCE_WORK_TABLE, (table) => { table.string('preview_url', 2000).nullable(); }); } const hasSourceFilesColumn = await db.schema.hasColumn(PLAN_SOURCE_WORK_TABLE, 'source_files'); if (!hasSourceFilesColumn) { await db.schema.alterTable(PLAN_SOURCE_WORK_TABLE, (table) => { table.text('source_files').notNullable().defaultTo('[]'); }); } return; } await db.schema.createTable(PLAN_SOURCE_WORK_TABLE, (table) => { table.increments('id').primary(); table.integer('plan_item_id').notNullable().index(); table.text('summary').notNullable(); table.string('branch_name', 200).notNullable(); table.string('commit_hash', 80).nullable(); table.string('preview_url', 2000).nullable(); table.text('changed_files').notNullable().defaultTo('[]'); table.text('command_log').nullable(); table.text('diff_text').nullable(); table.text('source_files').notNullable().defaultTo('[]'); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); } async function ensurePlanReleaseReviewTable() { const exists = await db.schema.hasTable(PLAN_RELEASE_REVIEW_TABLE); if (!exists) { await db.schema.createTable(PLAN_RELEASE_REVIEW_TABLE, (table) => { table.increments('id').primary(); table.integer('plan_item_id').notNullable().unique().index(); table.string('status', 40).notNullable().defaultTo('pending'); table.text('review_note').notNullable().defaultTo(''); table.string('checked_by_client_id', 120).nullable(); table.string('checked_by_nickname', 80).nullable(); table.timestamp('checked_at', { useTz: true }).nullable(); table.text('metadata').notNullable().defaultTo('{}'); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); return; } const requiredColumns: Array<[string, (table: any) => void]> = [ ['plan_item_id', (table) => table.integer('plan_item_id').notNullable().unique().index()], ['status', (table) => table.string('status', 40).notNullable().defaultTo('pending')], ['review_note', (table) => table.text('review_note').notNullable().defaultTo('')], ['checked_by_client_id', (table) => table.string('checked_by_client_id', 120).nullable()], ['checked_by_nickname', (table) => table.string('checked_by_nickname', 80).nullable()], ['checked_at', (table) => table.timestamp('checked_at', { useTz: true }).nullable()], ['metadata', (table) => table.text('metadata').notNullable().defaultTo('{}')], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredColumns) { const hasColumn = await db.schema.hasColumn(PLAN_RELEASE_REVIEW_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(PLAN_RELEASE_REVIEW_TABLE, (table) => { createColumn(table); }); } } } export async function ensurePlanTable() { const exists = await db.schema.hasTable(PLAN_TABLE); if (!exists) { try { await db.schema.createTable(PLAN_TABLE, (table) => { table.increments('id').primary(); table.string('work_id', 120).notNullable().unique(); table.text('note').notNullable().defaultTo(''); table.string('status', 40).notNullable().defaultTo('등록'); table.boolean('jangsing_processing_required').notNullable().defaultTo(true); table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); table.string('assigned_branch', 200).nullable(); table.string('release_target', 120).notNullable().defaultTo('release'); table.string('worker_status', 80).nullable(); table.text('last_error').nullable(); table.string('locked_by', 120).nullable(); table.timestamp('locked_at', { useTz: true }).nullable(); table.timestamp('started_at', { useTz: true }).nullable(); table.timestamp('completed_at', { useTz: true }).nullable(); table.timestamp('merged_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); } catch (error) { const dbError = error as { code?: string }; if (dbError.code !== '42P07' && dbError.code !== '23505') { throw error; } } } await syncPlanColumns(); await dropPlanWorkIdUniqueConstraint(); await ensurePlanIssueTable(); await ensurePlanActionTable(); await ensurePlanSourceWorkTable(); await ensurePlanReleaseReviewTable(); await db(PLAN_TABLE) .whereNull('jangsing_processing_required') .update({ jangsing_processing_required: true, }); await db(PLAN_TABLE) .whereIn('status', ['작업완료', '릴리즈완료', '완료'] as never[]) .whereNull('completed_at') .update({ completed_at: db.raw('coalesce(merged_at, updated_at, created_at)'), updated_at: db.fn.now(), }); await db(PLAN_TABLE) .where({ status: '이슈' as never }) .update({ status: '등록', worker_status: db.raw("coalesce(worker_status, '브랜치실패')"), updated_at: db.fn.now(), }); await db(PLAN_TABLE) .where({ status: '개발완료' as never }) .update({ status: '작업완료', worker_status: db.raw("case when worker_status in ('병합대기','병합중') then 'release반영대기' when worker_status='병합완료' then 'main반영완료' else worker_status end"), updated_at: db.fn.now(), }); await db(PLAN_TABLE) .whereIn('worker_status', ['release반영완료', 'main반영대기', 'main반영중', 'main반영실패'] as never[]) .whereNot({ status: '완료' as never }) .update({ status: '릴리즈완료', updated_at: db.fn.now(), }); } export async function createPlanItem(payload: z.infer) { await ensurePlanTable(); const workId = normalizePlanWorkId(payload.workId); const rows = await db(PLAN_TABLE) .insert({ work_id: workId, note: payload.note, status: '등록', automation_type: normalizePlanAutomationType(payload.automationType), release_target: payload.releaseTarget, jangsing_processing_required: payload.jangsingProcessingRequired, auto_deploy_to_main: payload.autoDeployToMain, repeat_request_enabled: payload.repeatRequestEnabled, repeat_interval_minutes: payload.repeatIntervalMinutes, worker_status: '대기', last_error: null, updated_at: db.fn.now(), }) .returning('*'); return rows[0]; } export async function createCompletedPlanExecutionLogItem(payload: z.infer) { await ensurePlanTable(); const workId = normalizePlanWorkId(payload.workId); const rows = await db(PLAN_TABLE) .insert({ work_id: workId, note: payload.note, status: '완료', automation_type: normalizePlanAutomationType(payload.automationType), release_target: payload.releaseTarget, jangsing_processing_required: payload.jangsingProcessingRequired, auto_deploy_to_main: payload.autoDeployToMain, repeat_request_enabled: payload.repeatRequestEnabled, repeat_interval_minutes: payload.repeatIntervalMinutes, worker_status: '자동완료', last_error: null, started_at: db.fn.now(), completed_at: db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); if (rows[0]?.id) { await syncPlanAutomationUsageSnapshot(Number(rows[0].id)); } return rows[0]; } export async function upsertAutoPlanItem(args: { workId: string; note: string; releaseTarget: string; jangsingProcessingRequired: boolean; autoDeployToMain: boolean; automationType?: PlanAutomationType; requeue: boolean; }) { await ensurePlanTable(); const workId = normalizePlanWorkId(args.workId); const existingRow = await db(PLAN_TABLE) .where({ work_id: workId }) .orderBy('id', 'desc') .first(); if (!existingRow) { return { row: await createPlanItem({ workId, note: args.note, automationType: normalizePlanAutomationType(args.automationType), releaseTarget: args.releaseTarget, jangsingProcessingRequired: args.jangsingProcessingRequired, autoDeployToMain: args.autoDeployToMain, repeatRequestEnabled: false, repeatIntervalMinutes: 60, }), action: 'created' as const, }; } const nextReleaseTarget = args.releaseTarget || existingRow.release_target || 'release'; const nextAutomationType = normalizePlanAutomationType(args.automationType ?? existingRow.automation_type); const nextJangsingProcessingRequired = args.jangsingProcessingRequired; const nextAutoDeployToMain = args.autoDeployToMain; const nextNote = args.note; const currentJangsingProcessingRequired = typeof existingRow.jangsing_processing_required === 'boolean' ? existingRow.jangsing_processing_required : existingRow.normal_processing_level === '상'; const hasPayloadChange = existingRow.note !== nextNote || normalizePlanAutomationType(existingRow.automation_type) !== nextAutomationType || (existingRow.release_target ?? 'release') !== nextReleaseTarget || Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain || Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired; if (!args.requeue) { if (!hasPayloadChange || existingRow.status !== '등록') { return { row: existingRow, action: 'unchanged' as const, }; } const rows = await db(PLAN_TABLE) .where({ id: existingRow.id }) .update({ note: nextNote, automation_type: nextAutomationType, release_target: nextReleaseTarget, jangsing_processing_required: nextJangsingProcessingRequired, auto_deploy_to_main: nextAutoDeployToMain, updated_at: db.fn.now(), }) .returning('*'); return { row: rows[0], action: 'updated' as const, }; } const rows = await db(PLAN_TABLE) .where({ id: existingRow.id }) .update({ work_id: workId, note: nextNote, status: '등록', assigned_branch: null, automation_type: nextAutomationType, release_target: nextReleaseTarget, jangsing_processing_required: nextJangsingProcessingRequired, auto_deploy_to_main: nextAutoDeployToMain, worker_status: '대기', last_error: null, locked_by: null, locked_at: null, started_at: null, completed_at: null, merged_at: null, updated_at: db.fn.now(), }) .returning('*'); await createPlanActionHistory( Number(existingRow.id), '자동갱신', '업무일지 자동화 설정 기준으로 Plan 항목을 최신 상태로 갱신했습니다.', ); await syncPlanAutomationUsageSnapshot(Number(existingRow.id)); return { row: rows[0], action: 'requeued' as const, }; } export async function updatePlanItem(id: number, payload: z.infer) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const nextWorkId = payload.workId === undefined ? currentRow.work_id : normalizePlanWorkId(payload.workId); const nextAutomationType = payload.automationType ?? normalizePlanAutomationType(currentRow.automation_type); const nextReleaseTarget = payload.releaseTarget ?? currentRow.release_target ?? 'release'; const nextJangsingProcessingRequired = payload.jangsingProcessingRequired ?? (typeof currentRow.jangsing_processing_required === 'boolean' ? currentRow.jangsing_processing_required : currentRow.normal_processing_level === '상'); const nextAutoDeployToMain = payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true; const nextRepeatRequestEnabled = payload.repeatRequestEnabled ?? currentRow.repeat_request_enabled ?? false; const nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60; const nextNote = payload.note ?? currentRow.note; const isOnlyJangsingUpdate = nextWorkId === currentRow.work_id && nextAutomationType === normalizePlanAutomationType(currentRow.automation_type) && nextReleaseTarget === (currentRow.release_target ?? 'release') && nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) && nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) && nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) && nextNote === currentRow.note; if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) { return updatePlanItemJangsingProcessingRequired(id, nextJangsingProcessingRequired); } if (currentRow.started_at || currentRow.status !== '등록') { throw new Error('작업시작 이후에는 원본 요청을 수정할 수 없습니다. 추가 조치사항은 이력에 기록해 주세요.'); } const rows = await db(PLAN_TABLE) .where({ id }) .update({ work_id: nextWorkId, note: nextNote, status: currentRow.status, automation_type: nextAutomationType, release_target: nextReleaseTarget, jangsing_processing_required: nextJangsingProcessingRequired, auto_deploy_to_main: nextAutoDeployToMain, repeat_request_enabled: nextRepeatRequestEnabled, repeat_interval_minutes: nextRepeatIntervalMinutes, worker_status: currentRow.worker_status, last_error: currentRow.last_error, completed_at: currentRow.completed_at, updated_at: db.fn.now(), }) .returning('*'); return rows[0]; } export async function updatePlanItemJangsingProcessingRequired( id: number, jangsingProcessingRequired: boolean, ) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } if (!functionCheckEditableStatuses.has(currentRow.status)) { throw new Error('기능동작확인은 작업완료 건만 수정할 수 있습니다.'); } const rows = await db(PLAN_TABLE) .where({ id }) .update({ jangsing_processing_required: jangsingProcessingRequired, updated_at: db.fn.now(), }) .returning('*'); return rows[0] ?? null; } export async function deletePlanItem(id: number) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } await db.transaction(async (trx) => { await trx(PLAN_SOURCE_WORK_TABLE).where({ plan_item_id: id }).delete(); await trx(PLAN_ACTION_TABLE).where({ plan_item_id: id }).delete(); await trx(PLAN_ISSUE_TABLE).where({ plan_item_id: id }).delete(); await trx(PLAN_TABLE).where({ id }).delete(); }); return currentRow; } export async function getBoardPostLinkedToPlanItem(planItemId: number) { const boardPostsTable = 'board_posts'; const hasBoardPostsTable = await db.schema.hasTable(boardPostsTable); if (!hasBoardPostsTable) { return null; } return db(boardPostsTable).select('id', 'title').where({ automation_plan_item_id: planItemId }).first(); } export async function markPlanAsDevelopmentComplete(id: number) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const rows = await db(PLAN_TABLE) .where({ id }) .update({ status: '작업완료', jangsing_processing_required: true, worker_status: 'release반영대기', last_error: null, locked_by: null, locked_at: null, completed_at: currentRow.completed_at ?? db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); await createPlanLifecycleSourceWorkHistory( id, '작업완료 처리로 release 반영 대기 상태로 전환했습니다.', currentRow.assigned_branch ?? currentRow.release_target ?? 'release', currentRow.assigned_branch ? `현재 작업 브랜치: ${currentRow.assigned_branch}` : `release 대기 브랜치: ${currentRow.release_target ?? 'release'}`, ); await createPlanActionHistory(id, '작업완료', '작업완료 처리'); await syncPlanAutomationUsageSnapshot(id); return rows[0]; } export async function markPlanAsCompleted( id: number, note?: string, workerStatus: Extract = '자동완료', ) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const rows = await db(PLAN_TABLE) .where({ id }) .update({ status: '완료', worker_status: workerStatus, last_error: null, locked_by: null, locked_at: null, completed_at: currentRow.completed_at ?? db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); const sourceWorkCountRow = await db(PLAN_SOURCE_WORK_TABLE) .where({ plan_item_id: id }) .count<{ count: string }>('id as count') .first(); const sourceWorkCount = Number(sourceWorkCountRow?.count ?? 0); if (sourceWorkCount === 0 && !shouldSkipLifecycleSourceWork(currentRow)) { await createPlanLifecycleSourceWorkHistory( id, note ?? '작업 결과를 검토하고 완료 처리했습니다.', currentRow.assigned_branch ?? currentRow.release_target ?? getEnv().PLAN_MAIN_BRANCH, currentRow.assigned_branch ? `완료 기준 브랜치: ${currentRow.assigned_branch}` : `완료 기준 브랜치: ${currentRow.release_target ?? getEnv().PLAN_MAIN_BRANCH}`, ); } await createPlanActionHistory(id, '완료처리', note ?? '작업을 완료 처리했습니다.'); await syncPlanAutomationUsageSnapshot(id); return rows[0]; } export async function markPlanAsStarted(id: number) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const rows = await db(PLAN_TABLE) .where({ id }) .update({ status: '작업중', worker_status: currentRow.worker_status === '대기' ? null : currentRow.worker_status, last_error: currentRow.last_error, started_at: currentRow.started_at ?? db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); await createPlanActionHistory(id, '작업시작', '작업을 시작했습니다.'); await syncPlanAutomationUsageSnapshot(id); return rows[0]; } export async function retryPlanBranch(id: number) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const rows = await db(PLAN_TABLE) .where({ id }) .update({ status: '등록', worker_status: '대기', last_error: null, locked_by: null, locked_at: null, updated_at: db.fn.now(), }) .returning('*'); await createPlanActionHistory(id, '브랜치재시도', '브랜치 재시도를 요청했습니다.'); await syncPlanAutomationUsageSnapshot(id); return rows[0]; } export async function retryPlanMerge(id: number) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const isMainRetry = currentRow.worker_status === 'main반영실패' || currentRow.worker_status === 'main반영대기' || currentRow.worker_status === 'main반영중' || currentRow.status === '릴리즈완료'; const rows = await db(PLAN_TABLE) .where({ id }) .update({ status: isMainRetry ? '릴리즈완료' : '작업완료', worker_status: isMainRetry ? 'main반영대기' : 'release반영대기', last_error: null, locked_by: null, locked_at: null, updated_at: db.fn.now(), }) .returning('*'); await createPlanActionHistory( id, isMainRetry ? 'main반영재시도' : 'release반영재시도', isMainRetry ? 'main 일괄 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.', ); await syncPlanAutomationUsageSnapshot(id); return rows[0]; } export async function requestPlanMainMerge(id: number) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } if (currentRow.status !== '릴리즈완료') { return { item: await getPlanItemById(id), message: 'main 반영 요청은 release 반영이 완료된 이후에만 가능합니다.', }; } const releaseTarget = String(currentRow.release_target ?? 'release'); const pendingRows = await db(PLAN_TABLE) .select('id') .where({ status: '릴리즈완료', release_target: releaseTarget }); const targetIds = pendingRows.map((row) => Number(row.id)).filter(Number.isFinite); await db(PLAN_TABLE) .whereIn('id', targetIds.length > 0 ? targetIds : [id]) .update({ last_error: null, locked_by: null, locked_at: null, worker_status: 'main반영대기', updated_at: db.fn.now(), }); await createPlanActionHistory( id, 'main반영요청', `${releaseTarget} 브랜치 기준 main 일괄 반영을 요청했습니다.`, ); await Promise.all((targetIds.length > 0 ? targetIds : [id]).map((targetId) => syncPlanAutomationUsageSnapshot(targetId))); return { item: await getPlanItemById(id), message: `${releaseTarget} 브랜치 기준으로 ${Math.max(targetIds.length, 1)}건 main 일괄 반영을 요청했습니다.`, }; } export async function retryPlanWork(id: number) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const rows = await db(PLAN_TABLE) .where({ id }) .update({ status: '작업중', worker_status: '브랜치준비', last_error: null, locked_by: null, locked_at: null, updated_at: db.fn.now(), }) .returning('*'); await createPlanActionHistory(id, '작업재처리', '자동 작업 재처리를 요청했습니다.'); return rows[0]; } export async function isPlanLockedByWorker(id: number, workerId: string) { await ensurePlanTable(); const row = await db(PLAN_TABLE) .select('id') .where({ id, locked_by: workerId }) .first(); return Boolean(row); } export async function resumePlanDevelopmentFromRelease(id: number, actionNote: string) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } if (currentRow.status !== '릴리즈완료') { return { didScheduleRetry: false, item: await getPlanItemById(id), message: null, }; } const rows = await db(PLAN_TABLE) .where({ id }) .update({ status: '작업중', worker_status: '브랜치준비', last_error: null, locked_by: null, locked_at: null, merged_at: null, updated_at: db.fn.now(), }) .returning('*'); await syncPlanAutomationUsageSnapshot(id); await createPlanActionHistory( id, '릴리즈추가조치', `release 상태 추가 조치로 개발을 재개했습니다.\n${actionNote}`, ); return { didScheduleRetry: false, item: await getPlanItemById(id), message: 'release 상태의 추가 조치를 반영해 추가 개발로 전환했습니다.', row: rows[0], }; } export async function queuePlanRetryFromFailure(id: number, actionNote?: string) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } return { didScheduleRetry: false, item: await getPlanItemById(id), message: shouldTriggerRetryFromActionNote(actionNote ?? '') ? '조치 이력을 저장했습니다. 자동 재시작은 하지 않았습니다. 필요하면 재시도 액션을 직접 실행해 주세요.' : '조치 이력을 저장했습니다. 자동화는 중단된 상태로 유지합니다.', }; } export function shouldResumePlanDevelopmentFromIssueAction( status: PlanStatus | string | null | undefined, retry: boolean, ) { return retry && status === '릴리즈완료'; } async function queuePlanRetryForCurrentStatus(id: number, workerStatus: string | null | undefined) { if (workerStatus === '자동작업중') { await retryPlanWork(id); return { didScheduleRetry: true, item: await getPlanItemById(id), message: '실행 중인 자동 작업을 정리하고 이슈 조치를 반영하도록 다시 실행합니다.', }; } if (workerStatus === '브랜치실패') { await retryPlanBranch(id); return { didScheduleRetry: true, item: await getPlanItemById(id), message: '이슈 조치를 반영해 브랜치 재시도를 예약했습니다.', }; } if (workerStatus === '자동작업실패') { await retryPlanWork(id); return { didScheduleRetry: true, item: await getPlanItemById(id), message: '이슈 조치를 반영해 자동 작업 재처리를 예약했습니다.', }; } if (workerStatus === 'release반영실패') { await retryPlanMerge(id); return { didScheduleRetry: true, item: await getPlanItemById(id), message: '이슈 조치를 반영해 release 반영 재처리를 예약했습니다.', }; } if (workerStatus === 'main반영실패') { return { didScheduleRetry: true, ...(await requestPlanMainMerge(id)), }; } return { didScheduleRetry: false, item: await getPlanItemById(id), message: '이슈 조치 이력을 저장했고 자동 재처리 대상은 없습니다.', }; } export async function queuePlanRetryFromIssueAction(id: number, actionNote: string, retry = false) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } if (!retry) { return { didScheduleRetry: false, item: await getPlanItemById(id), message: '이슈 조치 이력을 저장했습니다. 재처리는 요청하지 않았습니다.', }; } if (shouldResumePlanDevelopmentFromIssueAction(currentRow.status, retry)) { const resumeResult = await resumePlanDevelopmentFromRelease(id, actionNote); return { ...resumeResult, didScheduleRetry: Boolean(resumeResult?.message), }; } return queuePlanRetryForCurrentStatus(id, currentRow.worker_status); } export async function cancelPlanRelease(id: number) { await ensurePlanTable(); const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const releaseTarget = currentRow.release_target ?? 'release'; const isReleaseMergeFailure = currentRow.status === '작업완료' && currentRow.worker_status === 'release반영실패'; const targetRows = isReleaseMergeFailure ? [] : await db(PLAN_TABLE) .select('*') .where({ release_target: releaseTarget, status: '릴리즈완료' }); const targetIds = isReleaseMergeFailure ? [id] : [...new Set(targetRows.map((row) => Number(row.id)).concat(id))]; const historyMessage = isReleaseMergeFailure ? `release(${releaseTarget}) 반영 실패 상태에서 작업을 취소 처리했습니다.` : `release(${releaseTarget}) 배포 내역을 롤백하고 작업을 취소 처리했습니다.`; const resultMessage = isReleaseMergeFailure ? `release(${releaseTarget}) 반영 실패 상태에서 작업취소로 완료 처리했습니다.` : `release(${releaseTarget}) 배포 내역을 롤백하고 작업취소로 완료 처리했습니다.`; if (targetIds.length === 0) { return { item: await getPlanItemById(id), message: '취소할 release 반영 이력이 없습니다.', }; } await db(PLAN_TABLE) .whereIn('id', targetIds) .update({ status: '완료', worker_status: '작업취소', last_error: null, locked_by: null, locked_at: null, completed_at: db.fn.now(), updated_at: db.fn.now(), }); for (const targetId of targetIds) { await createPlanActionHistory( targetId, '작업취소', historyMessage, ); const issueRow = await createPlanIssueHistory( targetId, '작업취소', historyMessage, ); await db(PLAN_ISSUE_TABLE) .where({ id: issueRow.id }) .update({ resolved: true, resolved_at: db.fn.now(), }); await syncPlanAutomationUsageSnapshot(targetId); } return { item: await getPlanItemById(id), message: resultMessage, }; } export async function createPlanActionHistory(planItemId: number, actionType: string, note: string) { await ensurePlanTable(); const rows = await db(PLAN_ACTION_TABLE) .insert({ plan_item_id: planItemId, action_type: actionType, note, }) .returning('*'); return rows[0]; } export async function resolveAutomationIssueHistories(planItemId: number) { await ensurePlanTable(); await db(PLAN_ISSUE_TABLE) .where({ plan_item_id: planItemId, resolved: false }) .whereIn('issue_tag', [...automationIssueTags]) .update({ resolved: true, resolved_at: db.fn.now(), }); } export async function listPlanActionHistories(planItemId: number) { await ensurePlanTable(); return db(PLAN_ACTION_TABLE) .select('*') .where({ plan_item_id: planItemId }) .orderBy('created_at', 'desc') .orderBy('id', 'desc'); } export async function createPlanSourceWorkHistory( planItemId: number, payload: { summary: string; branchName: string; commitHash?: string | null; previewUrl?: string | null; changedFiles?: string[]; commandLog?: string | null; diffText?: string | null; sourceFiles?: Array<{ path: string; previousPath?: string | null; status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown'; language: string; content: string; }>; }, ) { await ensurePlanTable(); const rawChangedFiles = payload.changedFiles ?? []; const changedFiles = normalizeChangedFiles(rawChangedFiles); let filteredChangedFiles = changedFiles; if (changedFiles.some((file) => WORKLOG_EVIDENCE_PATH_PATTERN.test(file))) { const rows = await db(PLAN_SOURCE_WORK_TABLE) .select('changed_files') .where({ plan_item_id: planItemId }) .orderBy('created_at', 'asc') .orderBy('id', 'asc'); const existingChangedFilesList = rows.map((row) => { try { return JSON.parse(String(row.changed_files ?? '[]')) as string[]; } catch { return []; } }); filteredChangedFiles = filterRetryWorklogEvidencePaths(changedFiles, existingChangedFilesList); } const rows = await db(PLAN_SOURCE_WORK_TABLE) .insert({ plan_item_id: planItemId, summary: payload.summary, branch_name: payload.branchName, commit_hash: payload.commitHash ?? null, preview_url: payload.previewUrl ?? null, changed_files: JSON.stringify(filteredChangedFiles), command_log: payload.commandLog ?? null, diff_text: payload.diffText ?? null, source_files: JSON.stringify(payload.sourceFiles ?? []), }) .returning('*'); await syncPlanAutomationUsageSnapshot(planItemId); return rows[0]; } async function createPlanLifecycleSourceWorkHistory( planItemId: number, summary: string, branchName: string, commandLog?: string | null, previewUrl?: string | null, ) { return createPlanSourceWorkHistory(planItemId, { summary, branchName, previewUrl, changedFiles: [], commandLog: commandLog ?? null, diffText: null, sourceFiles: [], }); } export async function listPlanSourceWorkHistories(planItemId: number) { await ensurePlanTable(); return db(PLAN_SOURCE_WORK_TABLE) .select('*') .where({ plan_item_id: planItemId }) .orderBy('created_at', 'desc') .orderBy('id', 'desc'); } export async function getPlanSourceWorkHistory(planItemId: number, sourceWorkId: number) { await ensurePlanTable(); return db(PLAN_SOURCE_WORK_TABLE) .select('*') .where({ id: sourceWorkId, plan_item_id: planItemId, }) .first(); } export async function listLatestPlanSourceWorkMap(planItemIds: number[]) { if (planItemIds.length === 0) { return new Map>(); } const rows = await db(PLAN_SOURCE_WORK_TABLE) .select('*') .whereIn('plan_item_id', planItemIds) .orderBy('created_at', 'desc') .orderBy('id', 'desc'); const latestMap = new Map>(); const fallbackMap = new Map>(); rows.forEach((row) => { const planItemId = Number(row.plan_item_id); const mappedRow = mapPlanSourceWorkRow(row); const hasMeaningfulChanges = mappedRow.changedFiles.length > 0 || mappedRow.sourceFiles.length > 0; if (!fallbackMap.has(planItemId)) { fallbackMap.set(planItemId, mappedRow); } if (!latestMap.has(planItemId) && hasMeaningfulChanges) { latestMap.set(planItemId, mappedRow); } }); planItemIds.forEach((planItemId) => { if (!latestMap.has(planItemId) && fallbackMap.has(planItemId)) { latestMap.set(planItemId, fallbackMap.get(planItemId)!); } }); return latestMap; } async function listPlanReleaseReviewRowMap(planItemIds: number[]) { if (planItemIds.length === 0) { return new Map>(); } const rows = await db(PLAN_RELEASE_REVIEW_TABLE) .select('*') .whereIn('plan_item_id', planItemIds) .orderBy('updated_at', 'desc') .orderBy('id', 'desc'); const reviewMap = new Map>(); rows.forEach((row) => { const planItemId = Number(row.plan_item_id); if (!reviewMap.has(planItemId)) { reviewMap.set(planItemId, row); } }); return reviewMap; } function summarizePlanReviewText(value: string) { return String(value ?? '') .replace(/\s+/g, ' ') .trim() .slice(0, 220); } function inferReleaseReviewPageSelectionIds(changedFiles: string[]) { const selectionIds = new Set(['page:plans:release-review', 'page:plans:release']); changedFiles.forEach((file) => { const normalized = String(file ?? '').trim(); if (!normalized) { return; } if (normalized.startsWith('src/features/board/') || normalized.includes('/BoardPage.tsx')) { selectionIds.add('page:plans:board'); } if (normalized.startsWith('src/features/history/')) { selectionIds.add('page:plans:history'); } if (normalized.startsWith('src/components/') || normalized.startsWith('src/features/layout/')) { selectionIds.add('page:apis:components'); } if (normalized.startsWith('src/widgets/')) { selectionIds.add('page:apis:widgets'); } }); return Array.from(selectionIds); } function inferReleaseReviewDocIds(changedFiles: string[]) { return Array.from( new Set( changedFiles .map((file) => { const normalized = String(file ?? '').trim(); return normalized.startsWith('docs/') ? normalized.replace(/[^\w-]+/g, '-') : ''; }) .filter(Boolean), ), ); } function toKebabCase(value: string) { return value .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .replace(/[_\s]+/g, '-') .toLowerCase(); } function extractSampleTargetIds( sourceFiles: Array<{ path: string; content: string }> | undefined, changedFiles: string[], pathPrefix: string, ) { const targetIds = new Set(); (sourceFiles ?? []).forEach((sourceFile) => { const normalizedPath = String(sourceFile.path ?? '').trim(); if (!normalizedPath.startsWith(pathPrefix)) { return; } const matches = String(sourceFile.content ?? '').matchAll(/componentId\s*:\s*['"]([^'"]+)['"]/g); for (const match of matches) { const targetId = String(match[1] ?? '').trim(); if (targetId) { targetIds.add(targetId); } } }); changedFiles.forEach((file) => { const normalized = String(file ?? '').trim(); if (!normalized.startsWith(pathPrefix)) { return; } const relativePath = normalized.slice(pathPrefix.length); const firstSegment = relativePath.split('/').filter(Boolean)[0]; if (firstSegment) { targetIds.add(toKebabCase(firstSegment)); } }); return Array.from(targetIds); } function inferReleaseReviewComponentIds( sourceFiles: Array<{ path: string; content: string }> | undefined, changedFiles: string[], ) { return extractSampleTargetIds(sourceFiles, changedFiles, 'src/components/'); } function inferReleaseReviewWidgetIds( sourceFiles: Array<{ path: string; content: string }> | undefined, changedFiles: string[], ) { return extractSampleTargetIds(sourceFiles, changedFiles, 'src/widgets/'); } function buildReleaseReviewSummary( row: Record, latestSourceWork: ReturnType | undefined, reviewRow: Record | undefined, ) { const metadata = mapPlanReleaseReviewMetadata(reviewRow?.metadata ?? null); const candidateSummary = metadata.summary ?? summarizePlanReviewText(String(latestSourceWork?.summary ?? '')) ?? summarizePlanReviewText(String(row.note ?? '')); return candidateSummary || summarizePlanReviewText(String(row.note ?? '')); } export async function upsertPlanReleaseReview( planItemId: number, payload: z.infer, actor?: { clientId?: string | null; nickname?: string | null; }, ) { await ensurePlanTable(); const currentItem = await db(PLAN_TABLE).where({ id: planItemId }).first(); if (!currentItem) { return null; } const currentRow = await db(PLAN_RELEASE_REVIEW_TABLE).where({ plan_item_id: planItemId }).first(); const currentMetadata = mapPlanReleaseReviewMetadata(currentRow?.metadata ?? null); const nextStatus = payload.status ?? ((currentRow?.status ? String(currentRow.status) : 'pending') as PlanReleaseReviewStatus); const nextReviewNote = payload.reviewNote ?? String(currentRow?.review_note ?? ''); const nextMetadata = payload.metadata ? { ...currentMetadata, ...payload.metadata } : currentMetadata; const hasReviewerTrace = nextStatus !== 'pending' || nextReviewNote.trim().length > 0; const nextRow = { plan_item_id: planItemId, status: nextStatus, review_note: nextReviewNote, checked_by_client_id: hasReviewerTrace ? actor?.clientId?.trim() || null : null, checked_by_nickname: hasReviewerTrace ? actor?.nickname?.trim() || actor?.clientId?.trim() || null : null, checked_at: hasReviewerTrace ? db.fn.now() : null, metadata: JSON.stringify(nextMetadata), updated_at: db.fn.now(), }; if (currentRow) { const rows = await db(PLAN_RELEASE_REVIEW_TABLE) .where({ plan_item_id: planItemId }) .update(nextRow) .returning('*'); await syncPlanAutomationUsageSnapshot(planItemId); return mapPlanReleaseReviewRow(rows[0], planItemId); } const rows = await db(PLAN_RELEASE_REVIEW_TABLE) .insert({ ...nextRow, created_at: db.fn.now(), }) .returning('*'); await syncPlanAutomationUsageSnapshot(planItemId); return mapPlanReleaseReviewRow(rows[0], planItemId); } export async function listPlanReleaseReviewBoardItems(options?: Pick) { await ensurePlanTable(); const rows = await db(PLAN_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc'); const releaseRows = rows.filter((row) => { const workerStatus = String(row.worker_status ?? ''); return ( row.status === '릴리즈완료' || row.status === '완료' || ['release반영중', 'main반영대기', 'main반영중', 'main반영실패', 'release반영완료'].includes(workerStatus) ); }); const planItemIds = releaseRows.map((row) => Number(row.id)); const issueSummaryMap = await listPlanIssueSummaries(planItemIds); const latestSourceWorkMap = await listLatestPlanSourceWorkMap(planItemIds); const reviewRowMap = await listPlanReleaseReviewRowMap(planItemIds); return releaseRows.map((row) => { const planItemId = Number(row.id); const planItem = mapPlanRow(row, { ...(issueSummaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false }), maskNote: options?.maskNote, noteMasked: options?.maskNote, }); const latestSourceWork = latestSourceWorkMap.get(planItemId) ?? null; const reviewRow = reviewRowMap.get(planItemId); const review = mapPlanReleaseReviewRow(reviewRow, planItemId); const changedFiles = latestSourceWork?.changedFiles ?? []; const sourceFiles = latestSourceWork?.sourceFiles ?? []; const metadata = { ...review.metadata, summary: review.metadata.summary ?? buildReleaseReviewSummary(row, latestSourceWork ?? undefined, reviewRow), pageSelectionIds: review.metadata.pageSelectionIds ?? inferReleaseReviewPageSelectionIds(changedFiles), docIds: review.metadata.docIds ?? inferReleaseReviewDocIds(changedFiles), componentIds: review.metadata.componentIds ?? inferReleaseReviewComponentIds(sourceFiles, changedFiles), widgetIds: review.metadata.widgetIds ?? inferReleaseReviewWidgetIds(sourceFiles, changedFiles), }; return { planItem, review: { ...review, metadata, }, latestSourceWork, }; }); } export async function createPlanIssueHistory( planItemId: number, issueTag: string, message: string, ) { await ensurePlanTable(); const normalizedIssueTag = issueTag.startsWith('#') ? issueTag : `#${issueTag}`; const previousIssueWithAction = await db(PLAN_ISSUE_TABLE) .select('*') .where({ plan_item_id: planItemId, issue_tag: normalizedIssueTag, message, }) .whereNotNull('action_note') .orderBy('created_at', 'desc') .orderBy('id', 'desc') .first(); const rows = await db(PLAN_ISSUE_TABLE) .insert({ plan_item_id: planItemId, issue_tag: normalizedIssueTag, message, action_note: previousIssueWithAction?.action_note ?? null, }) .returning('*'); return rows[0]; } export async function appendLatestIssueAction(planItemId: number, actionNote: string, resolve = false) { await ensurePlanTable(); const issueRow = await db(PLAN_ISSUE_TABLE) .where({ plan_item_id: planItemId }) .orderBy('resolved', 'asc') .orderBy('created_at', 'desc') .first(); if (!issueRow) { throw new Error('기록할 이슈 이력이 없습니다.'); } const nextActionNote = issueRow.action_note ? `${issueRow.action_note}\n[${new Date().toISOString()}] ${actionNote}` : `[${new Date().toISOString()}] ${actionNote}`; const rows = await db(PLAN_ISSUE_TABLE) .where({ id: issueRow.id }) .update({ action_note: nextActionNote, resolved: resolve ? true : issueRow.resolved, resolved_at: resolve ? db.fn.now() : issueRow.resolved_at, }) .returning('*'); await createPlanActionHistory( planItemId, resolve ? '이슈해결조치' : '이슈조치', actionNote, ); return rows[0]; } export async function listPlanIssueHistories(planItemId: number) { await ensurePlanTable(); await ensurePlanFailureIssueHistory(planItemId); return db(PLAN_ISSUE_TABLE) .select('*') .where({ plan_item_id: planItemId }) .orderBy('created_at', 'desc') .orderBy('id', 'desc'); } export async function listPlanIssueSummaries(planItemIds: number[]) { await ensurePlanTable(); if (planItemIds.length === 0) { return new Map(); } for (const planItemId of planItemIds) { await ensurePlanFailureIssueHistory(planItemId); } const rows = await db(PLAN_ISSUE_TABLE) .select('*') .whereIn('plan_item_id', planItemIds) .orderBy('created_at', 'desc') .orderBy('id', 'desc'); const summaryMap = new Map(); rows.forEach((row) => { const planItemId = Number(row.plan_item_id); const current = summaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false }; if (!row.resolved && typeof row.issue_tag === 'string' && !current.issueTags.includes(row.issue_tag)) { current.issueTags.push(row.issue_tag); } if (!row.resolved) { current.hasOpenIssues = true; } summaryMap.set(planItemId, current); }); return summaryMap; } export async function claimNextPlanForBranch(workerId: string) { await ensurePlanTable(); return db.transaction(async (trx) => { const row = await trx(PLAN_TABLE) .select('*') .where({ status: '등록' }) .where((builder) => { builder.whereNull('worker_status').orWhere('worker_status', '대기'); }) .orderBy('created_at', 'asc') .forUpdate() .skipLocked() .first(); if (!row) { return null; } const assignedBranch = shouldUseLocalMainPlanMode(row.automation_type) ? String(getEnv().PLAN_MAIN_BRANCH) : buildPlanBranchName(String(row.work_id), Number(row.id)); const rows = await trx(PLAN_TABLE) .where({ id: row.id }) .update({ status: '작업중', assigned_branch: assignedBranch, worker_status: '브랜치생성중', last_error: null, locked_by: workerId, locked_at: db.fn.now(), started_at: row.started_at ?? db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); return rows[0]; }); } export async function claimNextPlanForExecution(workerId: string) { await ensurePlanTable(); return db.transaction(async (trx) => { const row = await trx(PLAN_TABLE) .select('*') .where({ status: '작업중', worker_status: '브랜치준비' }) .whereNotNull('assigned_branch') .orderBy('updated_at', 'asc') .forUpdate() .skipLocked() .first(); if (!row) { return null; } const rows = await trx(PLAN_TABLE) .where({ id: row.id }) .update({ worker_status: '자동작업중', last_error: null, locked_by: workerId, locked_at: db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); return rows[0]; }); } export async function claimNextPlanForMerge(workerId: string) { await ensurePlanTable(); return db.transaction(async (trx) => { const row = await trx(PLAN_TABLE) .select('*') .where({ status: '작업완료' }) .whereNotNull('assigned_branch') .where((builder) => { builder.whereNull('worker_status').orWhere('worker_status', 'release반영대기'); }) .orderBy('updated_at', 'asc') .forUpdate() .skipLocked() .first(); if (!row) { return null; } const rows = await trx(PLAN_TABLE) .where({ id: row.id }) .update({ worker_status: 'release반영중', last_error: null, locked_by: workerId, locked_at: db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); return rows[0]; }); } export async function claimNextPlanForMainMerge(workerId: string) { await ensurePlanTable(); return db.transaction(async (trx) => { const candidateRows = await trx(PLAN_TABLE) .select('*') .where({ status: '릴리즈완료', worker_status: 'main반영대기' }) .whereNotNull('assigned_branch') .orderBy('updated_at', 'asc') .forUpdate(); const row = candidateRows[0]; if (!row) { return null; } const pendingReleaseRow = await trx(PLAN_TABLE) .select('id') .where({ release_target: row.release_target, status: '작업완료' }) .first(); if (pendingReleaseRow) { return null; } const batchRows = await trx(PLAN_TABLE) .select('id') .where({ status: '릴리즈완료', release_target: row.release_target }) .whereIn('worker_status', ['main반영대기', 'main반영실패'] as never[]); const batchIds = batchRows.map((candidate) => Number(candidate.id)).filter(Number.isFinite); const rows = await trx(PLAN_TABLE) .whereIn('id', batchIds.length > 0 ? batchIds : [Number(row.id)]) .update({ worker_status: 'main반영중', last_error: null, locked_by: workerId, locked_at: db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); return rows[0] ?? null; }); } export async function markPlanBranchReady(id: number, workerId: string) { const rows = await db(PLAN_TABLE) .where({ id }) .where({ locked_by: workerId }) .update({ worker_status: '브랜치준비', locked_by: null, locked_at: null, updated_at: db.fn.now(), }) .returning('*'); if (!rows[0]) { return null; } return rows[0]; } export async function markPlanWorkCompleted(id: number, workerId: string, note?: string) { const currentRow = await db(PLAN_TABLE).where({ id }).first(); const rows = await db(PLAN_TABLE) .where({ id }) .where({ locked_by: workerId }) .update({ status: '작업완료', jangsing_processing_required: true, worker_status: 'release반영대기', last_error: null, locked_by: null, locked_at: null, updated_at: db.fn.now(), }) .returning('*'); if (!rows[0]) { return null; } if (currentRow) { await createPlanLifecycleSourceWorkHistory( id, '자동 작업 완료로 release 반영 대기 상태로 전환했습니다.', currentRow.assigned_branch ?? currentRow.release_target ?? 'release', currentRow.assigned_branch ? `현재 작업 브랜치: ${currentRow.assigned_branch}` : `release 대기 브랜치: ${currentRow.release_target ?? 'release'}`, ); } await createPlanActionHistory(id, '작업완료', note ?? '자동 작업을 마치고 release 반영을 대기합니다.'); await resolveAutomationIssueHistories(id); return rows[0]; } export async function markPlanReleaseMerged(id: number, workerId: string) { const currentRow = await db(PLAN_TABLE).where({ id }).first(); const autoDeployToMain = Boolean(currentRow?.auto_deploy_to_main ?? true); const rows = await db(PLAN_TABLE) .where({ id }) .where({ locked_by: workerId }) .update({ status: '릴리즈완료', worker_status: autoDeployToMain ? 'main반영대기' : 'release반영완료', last_error: null, locked_by: null, locked_at: null, updated_at: db.fn.now(), }) .returning('*'); if (!rows[0]) { return null; } if (currentRow) { await createPlanLifecycleSourceWorkHistory( id, 'release 브랜치 반영을 완료했습니다.', currentRow.release_target ?? 'release', `release 브랜치: ${currentRow.release_target ?? 'release'}`, ); } await createPlanActionHistory( id, 'release반영완료', autoDeployToMain ? 'release 브랜치 반영을 완료했고 main 자동 반영을 예약했습니다.' : 'release 브랜치 반영을 완료했습니다.', ); await resolveAutomationIssueHistories(id); await syncPlanAutomationUsageSnapshot(id); return rows[0]; } export async function markPlanMerged(id: number, workerId: string) { const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow || currentRow.locked_by !== workerId) { return null; } const batchRows = await db(PLAN_TABLE) .select('*') .where({ status: '릴리즈완료', release_target: currentRow.release_target }) .whereIn('worker_status', ['main반영대기', 'main반영중', 'main반영실패'] as never[]); const batchIds = batchRows.map((row) => Number(row.id)).filter(Number.isFinite); const targetRows = batchRows.length > 0 ? batchRows : [currentRow]; const rows = await db(PLAN_TABLE) .whereIn('id', batchIds.length > 0 ? batchIds : [id]) .update({ status: '완료', worker_status: 'main반영완료', last_error: null, locked_by: null, locked_at: null, merged_at: db.fn.now(), completed_at: db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); for (const row of targetRows) { const planId = Number(row.id); await createPlanLifecycleSourceWorkHistory( planId, `${getEnv().PLAN_MAIN_BRANCH} 브랜치 반영을 완료했습니다.`, getEnv().PLAN_MAIN_BRANCH, `${row.release_target ?? 'release'} -> ${getEnv().PLAN_MAIN_BRANCH}`, ); await createPlanActionHistory( planId, 'main반영완료', `${row.release_target ?? 'release'} 브랜치를 ${getEnv().PLAN_MAIN_BRANCH} 브랜치에 일괄 반영 완료했습니다.`, ); await resolveAutomationIssueHistories(planId); await syncPlanAutomationUsageSnapshot(planId); } return { mergedRow: rows[0] ?? null, notificationRows: targetRows, }; } export async function markPlanMainMergeFailure(releaseTarget: string, workerId: string, errorMessage: string) { await ensurePlanTable(); const rows = await db(PLAN_TABLE) .where({ status: '릴리즈완료', release_target: releaseTarget, locked_by: workerId }) .whereIn('worker_status', ['main반영대기', 'main반영중'] as never[]) .update({ worker_status: 'main반영실패', last_error: errorMessage, locked_by: null, locked_at: null, updated_at: db.fn.now(), }) .returning('*'); for (const row of rows) { await createPlanActionHistory( Number(row.id), 'main반영실패', `${releaseTarget} 브랜치 기준 main 일괄 반영에 실패했습니다.`, ); await syncPlanAutomationUsageSnapshot(Number(row.id)); } return rows; } export async function markPlanAutomationFailure( id: number, workerId: string, workerStatus: Extract, errorMessage: string, ) { const currentRow = await db(PLAN_TABLE).where({ id }).first(); if (!currentRow) { return null; } const nextStatus = workerStatus === '브랜치실패' ? '등록' : workerStatus === '자동작업실패' ? '작업중' : workerStatus === 'main반영실패' ? '릴리즈완료' : '작업완료'; const rows = await db(PLAN_TABLE) .where({ id }) .where({ locked_by: workerId }) .update({ status: nextStatus, worker_status: workerStatus, last_error: errorMessage, locked_by: null, locked_at: null, updated_at: db.fn.now(), }) .returning('*'); if (!rows[0]) { return null; } const issueHistory = await createPlanIssueHistory(id, workerStatus, errorMessage); if (issueHistory?.action_note) { await createPlanActionHistory( id, '오류재발', `동일 오류가 다시 발생했습니다.\n기존 조치내역:\n${issueHistory.action_note}`, ); } await syncPlanAutomationUsageSnapshot(id); return rows[0]; } export async function listPlanItems(status?: PlanStatus, options?: Pick) { await ensurePlanTable(); const buildListQuery = () => { const builder = db(PLAN_TABLE).select('*').orderBy('id', 'desc'); if (status) { builder.where('status', status); } return builder; }; let rows = await buildListQuery(); const missingSnapshotIds = rows .filter((row) => row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '') .map((row) => Number(row.id)) .filter(Number.isFinite); if (missingSnapshotIds.length > 0) { await Promise.all(missingSnapshotIds.map((planItemId) => syncPlanAutomationUsageSnapshot(planItemId))); rows = await buildListQuery(); } const planItemIds = rows.map((row) => Number(row.id)); const issueSummaryMap = await listPlanIssueSummaries(planItemIds); const releaseReviewNoteMap = await listPlanReleaseReviewNoteMap(planItemIds); return rows.map((row) => mapPlanRow(row, { ...(issueSummaryMap.get(Number(row.id)) ?? { issueTags: [], hasOpenIssues: false }), maskNote: options?.maskNote, noteMasked: options?.maskNote, releaseReviewNote: releaseReviewNoteMap.get(Number(row.id)) ?? '', }), ); } export async function getPlanItemById(id: number, options?: Pick) { await ensurePlanTable(); await ensurePlanFailureIssueHistory(id); let row = await db(PLAN_TABLE).where({ id }).first(); if (!row) { return null; } if (row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '') { await syncPlanAutomationUsageSnapshot(id); row = await db(PLAN_TABLE).where({ id }).first(); } const issueSummaryMap = await listPlanIssueSummaries([id]); const releaseReviewNoteMap = await listPlanReleaseReviewNoteMap([id]); return mapPlanRow(row, { ...(issueSummaryMap.get(id) ?? { issueTags: [], hasOpenIssues: false }), maskNote: options?.maskNote, noteMasked: options?.maskNote, releaseReviewNote: releaseReviewNoteMap.get(id) ?? '', }); } function normalizePreviewLookupValue(value: string | null | undefined, stripSearch = false) { const trimmed = String(value ?? '').trim(); if (!trimmed) { return ''; } try { const url = new URL(trimmed); url.hash = ''; if (stripSearch) { url.search = ''; } return url.toString().replace(/\/+$/, ''); } catch { const withoutHash = trimmed.replace(/#.*$/, ''); const withoutSearch = stripSearch ? withoutHash.replace(/\?.*$/, '') : withoutHash; return withoutSearch.replace(/\/+$/, ''); } } function buildPreviewLookupCandidates(value: string | null | undefined) { return Array.from( new Set([ normalizePreviewLookupValue(value, false), normalizePreviewLookupValue(value, true), ].filter(Boolean)), ); } export async function findLatestPlanItem() { await ensurePlanTable(); const row = await db(PLAN_TABLE).select('id').orderBy('id', 'desc').first(); if (!row?.id) { return null; } return getPlanItemById(Number(row.id)); } export async function findPlanItemByWorkId(workId: string) { await ensurePlanTable(); const normalizedWorkId = normalizePlanWorkId(workId); if (normalizedWorkId === '작업ID') { return null; } const rows = await db(PLAN_TABLE) .select('id', 'work_id') .orderBy('id', 'desc'); const matchedRow = rows.find((row) => normalizePlanWorkId(String(row.work_id ?? '')) === normalizedWorkId); if (!matchedRow?.id) { return null; } return getPlanItemById(Number(matchedRow.id)); } export async function findPlanItemByPreviewUrl(previewUrl: string) { await ensurePlanTable(); const lookupCandidates = new Set(buildPreviewLookupCandidates(previewUrl)); if (lookupCandidates.size === 0) { return null; } const rows = await db(PLAN_SOURCE_WORK_TABLE) .select('plan_item_id', 'preview_url') .whereNotNull('preview_url') .orderBy('created_at', 'desc') .orderBy('id', 'desc'); const matchedRow = rows.find((row) => buildPreviewLookupCandidates(String(row.preview_url ?? '')).some((candidate) => lookupCandidates.has(candidate)), ); if (!matchedRow?.plan_item_id) { return null; } return getPlanItemById(Number(matchedRow.plan_item_id)); }