2952 lines
88 KiB
TypeScript
Executable File
2952 lines
88 KiB
TypeScript
Executable File
import { z } from 'zod';
|
|
import { getEnv } from '../config/env.js';
|
|
import { db } from '../db/client.js';
|
|
import {
|
|
normalizeLegacyAutomationBehaviorType,
|
|
resolveAutomationType,
|
|
resolveStoredAutomationTypeId,
|
|
type AutomationBehaviorType,
|
|
} from './automation-type-config-service.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;
|
|
exposeConfiguredAutomationType?: boolean;
|
|
};
|
|
|
|
export const statusSchema = z.enum(planStatuses);
|
|
|
|
export const setupSchema = z.object({
|
|
recreate: z.boolean().optional(),
|
|
});
|
|
|
|
function resolvePlanAutomationTypeAlias(value: unknown) {
|
|
return normalizeLegacyAutomationBehaviorType(value);
|
|
}
|
|
|
|
export const planAutomationTypeSchema = z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120));
|
|
|
|
export const createPlanSchema = z.object({
|
|
workId: z.string().trim().optional().default('작업ID'),
|
|
note: z.string().default(''),
|
|
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).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.string().trim().min(1).max(120).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 = string;
|
|
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<typeof planReleaseReviewMetadataSchema>;
|
|
|
|
const planFailureWorkerStatuses = new Set<PlanWorkerStatus>([
|
|
'브랜치실패',
|
|
'자동작업실패',
|
|
'release반영실패',
|
|
'main반영실패',
|
|
]);
|
|
const functionCheckEditableStatuses = new Set<PlanStatus>(['작업완료', '릴리즈완료', '완료']);
|
|
|
|
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 AutomationBehaviorType)
|
|
? (normalizedValue as AutomationBehaviorType)
|
|
: 'none';
|
|
}
|
|
|
|
function shouldSkipLifecycleSourceWork(row: Record<string, unknown> | 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<string, unknown>,
|
|
options?: PlanRowOptions,
|
|
) {
|
|
return {
|
|
id: row.id,
|
|
workId: row.work_id,
|
|
note: options?.maskNote ? maskPlanNote(row.note) : row.note,
|
|
automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
|
|
automationBehaviorType: 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<string, unknown>) {
|
|
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<string, unknown>) {
|
|
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<string, unknown>) {
|
|
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<PlanAutomationUsageSnapshot> & {
|
|
tokenTotals?: Partial<PlanAutomationUsageSnapshot['tokenTotals']>;
|
|
};
|
|
const tokenTotals: Partial<PlanAutomationUsageSnapshot['tokenTotals']> = 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<string, unknown> | 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<ReturnType<typeof mapPlanSourceWorkRow>, '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<string, number>();
|
|
|
|
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<string, number>) {
|
|
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<string, unknown>,
|
|
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<Pick<ReturnType<typeof mapPlanSourceWorkRow>, 'summary' | 'commandLog' | 'createdAt'>>,
|
|
row: Record<string, unknown>,
|
|
reviewCheckedAt: string | null,
|
|
): PlanAutomationUsageSnapshot | null {
|
|
const totals = new Map<string, number>();
|
|
|
|
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<number, string>();
|
|
}
|
|
|
|
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<number, string>();
|
|
|
|
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('automation_type_id', (table) => {
|
|
table.string('automation_type_id', 120).nullable();
|
|
});
|
|
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' });
|
|
await db(PLAN_TABLE)
|
|
.whereNull('automation_type_id')
|
|
.update({
|
|
automation_type_id: db.raw('automation_type'),
|
|
});
|
|
}
|
|
|
|
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<typeof createPlanSchema>) {
|
|
await ensurePlanTable();
|
|
const workId = normalizePlanWorkId(payload.workId);
|
|
const automationType = await resolveAutomationType(payload.automationType);
|
|
|
|
const rows = await db(PLAN_TABLE)
|
|
.insert({
|
|
work_id: workId,
|
|
note: payload.note,
|
|
status: '등록',
|
|
automation_type: automationType.behaviorType,
|
|
automation_type_id: automationType.id,
|
|
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<typeof createPlanSchema>) {
|
|
await ensurePlanTable();
|
|
const workId = normalizePlanWorkId(payload.workId);
|
|
const automationType = await resolveAutomationType(payload.automationType);
|
|
|
|
const rows = await db(PLAN_TABLE)
|
|
.insert({
|
|
work_id: workId,
|
|
note: payload.note,
|
|
status: '완료',
|
|
automation_type: automationType.behaviorType,
|
|
automation_type_id: automationType.id,
|
|
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) {
|
|
const automationType = await resolveAutomationType(args.automationType);
|
|
return {
|
|
row: await createPlanItem({
|
|
workId,
|
|
note: args.note,
|
|
automationType: automationType.id,
|
|
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 = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? 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 ||
|
|
String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id ||
|
|
(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.behaviorType,
|
|
automation_type_id: nextAutomationType.id,
|
|
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.behaviorType,
|
|
automation_type_id: nextAutomationType.id,
|
|
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<typeof updatePlanSchema>) {
|
|
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 = await resolveAutomationType(
|
|
payload.automationType ?? currentRow.automation_type_id ?? 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.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') &&
|
|
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.behaviorType,
|
|
automation_type_id: nextAutomationType.id,
|
|
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<PlanWorkerStatus, '자동완료' | 'main반영완료'> = '자동완료',
|
|
) {
|
|
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<number, ReturnType<typeof mapPlanSourceWorkRow>>();
|
|
}
|
|
|
|
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<number, ReturnType<typeof mapPlanSourceWorkRow>>();
|
|
const fallbackMap = new Map<number, ReturnType<typeof mapPlanSourceWorkRow>>();
|
|
|
|
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<number, Record<string, unknown>>();
|
|
}
|
|
|
|
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<number, Record<string, unknown>>();
|
|
|
|
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<string>(['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<string>();
|
|
|
|
(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<string, unknown>,
|
|
latestSourceWork: ReturnType<typeof mapPlanSourceWorkRow> | undefined,
|
|
reviewRow: Record<string, unknown> | 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<typeof updatePlanReleaseReviewSchema>,
|
|
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<PlanRowOptions, 'maskNote'>) {
|
|
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,
|
|
exposeConfiguredAutomationType: true,
|
|
});
|
|
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<number, { issueTags: string[]; hasOpenIssues: boolean }>();
|
|
}
|
|
|
|
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<number, { issueTags: string[]; hasOpenIssues: boolean }>();
|
|
|
|
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<PlanWorkerStatus, '브랜치실패' | '자동작업실패' | 'release반영실패' | 'main반영실패'>,
|
|
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<PlanRowOptions, 'maskNote'>) {
|
|
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)) ?? '',
|
|
exposeConfiguredAutomationType: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function getPlanItemById(id: number, options?: Pick<PlanRowOptions, 'maskNote'>) {
|
|
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) ?? '',
|
|
exposeConfiguredAutomationType: true,
|
|
});
|
|
}
|
|
|
|
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));
|
|
}
|