fix: stabilize backend prisma and lint

This commit is contained in:
2026-04-06 20:21:40 +09:00
parent 90ab555915
commit a8ff05c96a
35 changed files with 989 additions and 360 deletions

View File

@@ -33,8 +33,10 @@ const SISTER_ROLES: Record<string, string> = {
function toneFromText(text: string): 'default' | 'warn' | 'ok' | 'active' {
const normalized = text.toLowerCase();
if (/(fail|error|blocker|offline|invalid|drift)/.test(normalized)) return 'warn';
if (/(passed|deploy|merged|restart_ok|write_ok|successful)/.test(normalized)) return 'ok';
if (/(fail|error|blocker|offline|invalid|drift)/.test(normalized))
return 'warn';
if (/(passed|deploy|merged|restart_ok|write_ok|successful)/.test(normalized))
return 'ok';
if (/(review|qa|handoff|sync|update)/.test(normalized)) return 'active';
return 'default';
}
@@ -45,18 +47,28 @@ function nodeStateFromEvidence(params: {
name: string;
}): 'idle' | 'active' | 'review' | 'blocked' | 'ready' {
const { sisterStatus, activity, name } = params;
const text = `${activity?.action ?? ''} ${activity?.detail ?? ''}`.toLowerCase();
const text =
`${activity?.action ?? ''} ${activity?.detail ?? ''}`.toLowerCase();
if (name !== 'user' && sisterStatus === 'offline') return 'blocked';
if (/(review|qa)/.test(text)) return 'review';
if (name === 'erang' && /(deploy|merged|ready for deploy|deployed)/.test(text)) return 'ready';
if (
name === 'erang' &&
/(deploy|merged|ready for deploy|deployed)/.test(text)
)
return 'ready';
if (sisterStatus === 'working') return 'active';
if (activity) return 'ready';
return 'idle';
}
function summarizeActivity(activity?: ActivityRecord | null, fallback = '최근 이벤트 없음') {
return activity?.detail?.trim() || activity?.action?.replace(/_/g, ' ') || fallback;
function summarizeActivity(
activity?: ActivityRecord | null,
fallback = '최근 이벤트 없음',
) {
return (
activity?.detail?.trim() || activity?.action?.replace(/_/g, ' ') || fallback
);
}
function parseQaSummary(content: string): string {
@@ -65,7 +77,9 @@ function parseQaSummary(content: string): string {
if (taskLine) return taskLine.replace(/^###\s+/, '').trim();
const bullet = lines.find((line) => /^-\s+/.test(line));
if (bullet) return bullet.replace(/^-\s+/, '').trim();
return lines.find((line) => line.length > 0 && !line.startsWith('#')) ?? 'QA note';
return (
lines.find((line) => line.length > 0 && !line.startsWith('#')) ?? 'QA note'
);
}
function inferQaAuthor(path: string, content: string): string {
@@ -101,7 +115,8 @@ export class DashboardService {
]);
const activities = rawActivities as ActivityRecord[];
const latestProjectActivity = activities.find((item) => item.project?.name) ?? null;
const latestProjectActivity =
activities.find((item) => item.project?.name) ?? null;
const latestBySister = new Map<string, ActivityRecord>();
for (const item of activities) {
if (item.sister?.name && !latestBySister.has(item.sister.name)) {
@@ -110,18 +125,29 @@ export class DashboardService {
}
const focusProject = latestProjectActivity?.project?.name
? projects.find((project) => project.name === latestProjectActivity.project?.name) ?? null
: projects.find((project) => ['IMPLEMENT', 'QA', 'READY FOR DEPLOY'].includes(project.phase)) ?? null;
? (projects.find(
(project) => project.name === latestProjectActivity.project?.name,
) ?? null)
: (projects.find((project) =>
['IMPLEMENT', 'QA', 'READY FOR DEPLOY'].includes(project.phase),
) ?? null);
const reviewLoopCount = activities.filter((item) => /(review|qa)/i.test(`${item.action} ${item.detail ?? ''}`)).length;
const escalationCount = projects.filter((project) => (project.blockerCount ?? 0) > 0).length;
const reviewLoopCount = activities.filter((item) =>
/(review|qa)/i.test(`${item.action} ${item.detail ?? ''}`),
).length;
const escalationCount = projects.filter(
(project) => (project.blockerCount ?? 0) > 0,
).length;
const nodes = [
{
id: 'user',
label: 'User',
role: 'Request / Approval',
state: nodeStateFromEvidence({ name: 'user', activity: latestProjectActivity }),
state: nodeStateFromEvidence({
name: 'user',
activity: latestProjectActivity,
}),
detail: latestProjectActivity
? `${latestProjectActivity.project?.name ?? 'project'} · ${summarizeActivity(latestProjectActivity, '최근 프로젝트 이벤트 없음')}`
: '명시적으로 기록된 handoff / request 로그가 아직 없어.',
@@ -130,9 +156,16 @@ export class DashboardService {
const activity = latestBySister.get(sister.name) ?? null;
return {
id: sister.name,
label: sister.name === 'erang' ? 'Irang' : `${sister.name.slice(0, 1).toUpperCase()}${sister.name.slice(1)}`,
label:
sister.name === 'erang'
? 'Irang'
: `${sister.name.slice(0, 1).toUpperCase()}${sister.name.slice(1)}`,
role: SISTER_ROLES[sister.name] ?? 'Unknown',
state: nodeStateFromEvidence({ name: sister.name, sisterStatus: sister.status, activity }),
state: nodeStateFromEvidence({
name: sister.name,
sisterStatus: sister.status,
activity,
}),
detail: activity
? summarizeActivity(activity)
: sister.status === 'offline'
@@ -147,22 +180,29 @@ export class DashboardService {
const harnessItems = activities
.filter((item) => item.action === 'harness_updated')
.slice(0, 4)
.map((item) => ({
id: `harness-${item.id}`,
title: item.detail ?? 'Harness updated',
body: '관리자 harness 편집 로그에서 직접 가져온 기록이야.',
tone: toneFromText(`${item.action} ${item.detail ?? ''}`),
author: item.sister?.name ? this.toDisplayName(item.sister.name) : 'System',
time: item.createdAt.toISOString(),
category: 'harness',
source: `activity:${item.action}`,
} satisfies OpsBoardItem));
.map(
(item) =>
({
id: `harness-${item.id}`,
title: item.detail ?? 'Harness updated',
body: '관리자 harness 편집 로그에서 직접 가져온 기록이야.',
tone: toneFromText(`${item.action} ${item.detail ?? ''}`),
author: item.sister?.name
? this.toDisplayName(item.sister.name)
: 'System',
time: item.createdAt.toISOString(),
category: 'harness',
source: `activity:${item.action}`,
}) satisfies OpsBoardItem,
);
const qaItems = await this.getLatestQaBoardItems(projects.map((project) => ({
name: project.name,
repoName: extractRepoName(project.repoUrl),
updatedAt: project.updatedAt,
})));
const qaItems = await this.getLatestQaBoardItems(
projects.map((project) => ({
name: project.name,
repoName: extractRepoName(project.repoUrl),
updatedAt: project.updatedAt,
})),
);
const board = [...harnessItems, ...qaItems]
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
@@ -184,7 +224,10 @@ export class DashboardService {
? `${focusProject.name} · ${focusProject.currentSprint ?? focusProject.phase}`
: 'No active pipeline',
focus: latestProjectActivity
? summarizeActivity(latestProjectActivity, '최근 프로젝트 이벤트 없음')
? summarizeActivity(
latestProjectActivity,
'최근 프로젝트 이벤트 없음',
)
: '최근 프로젝트 activity 기준으로 확정된 handoff가 아직 없어.',
reviewLoopCount,
escalationCount,
@@ -195,45 +238,57 @@ export class DashboardService {
};
}
private async getLatestQaBoardItems(projects: { name: string; repoName: string; updatedAt: string }[]) {
const docs = await Promise.all(projects.map(async (project) => {
if (!project.repoName) return [] as OpsBoardItem[];
private async getLatestQaBoardItems(
projects: { name: string; repoName: string; updatedAt: string }[],
) {
const docs = await Promise.all(
projects.map(async (project) => {
if (!project.repoName) return [] as OpsBoardItem[];
const pathGroups = await Promise.all(
QA_PATH_CANDIDATES.map((path) => this.gitea.getRepoTree(project.repoName, path)),
);
const pathGroups = await Promise.all(
QA_PATH_CANDIDATES.map((path) =>
this.gitea.getRepoTree(project.repoName, path),
),
);
const qaFiles = Array.from(
new Set(
pathGroups
.flat()
.filter((path) => /(?:SPRINT|HOTFIX)-\d+.*(?:review|qa).*\.md$/i.test(path)),
),
);
const qaFiles = Array.from(
new Set(
pathGroups
.flat()
.filter((path) =>
/(?:SPRINT|HOTFIX)-\d+.*(?:review|qa).*\.md$/i.test(path),
),
),
);
const latestPath = qaFiles.sort().at(-1);
if (!latestPath) return [] as OpsBoardItem[];
const latestPath = qaFiles.sort().at(-1);
if (!latestPath) return [] as OpsBoardItem[];
const content = await this.gitea.getRawFile(project.repoName, latestPath);
if (!content) return [] as OpsBoardItem[];
const content = await this.gitea.getRawFile(
project.repoName,
latestPath,
);
if (!content) return [] as OpsBoardItem[];
const summary = parseQaSummary(content);
const label = latestPath.split('/').pop()?.replace(/\.md$/i, '') ?? latestPath;
const tone = toneFromText(content);
const summary = parseQaSummary(content);
const label =
latestPath.split('/').pop()?.replace(/\.md$/i, '') ?? latestPath;
const tone = toneFromText(content);
return [
{
id: `qa-${project.repoName}-${label}`,
title: label,
body: summary,
tone,
author: inferQaAuthor(latestPath, content),
time: project.updatedAt,
category: 'qa-log',
source: `${project.repoName}:${latestPath}`,
} satisfies OpsBoardItem,
];
}));
return [
{
id: `qa-${project.repoName}-${label}`,
title: label,
body: summary,
tone,
author: inferQaAuthor(latestPath, content),
time: project.updatedAt,
category: 'qa-log',
source: `${project.repoName}:${latestPath}`,
} satisfies OpsBoardItem,
];
}),
);
return docs.flat();
}