From e57ccbb4a3270a99b45f7d4c9a700402aa724aa0 Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:20:26 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=8C=80=EC=A1=B0=C2=B7=EC=B9=B4=ED=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/error-code-sync/index.mjs | 274 +++++++++++ .github/scripts/error-code-sync/lib.mjs | 457 +++++++++++++++++++ .github/scripts/error-code-sync/lib.test.mjs | 416 +++++++++++++++++ 3 files changed, 1147 insertions(+) create mode 100644 .github/scripts/error-code-sync/index.mjs create mode 100644 .github/scripts/error-code-sync/lib.mjs create mode 100644 .github/scripts/error-code-sync/lib.test.mjs diff --git a/.github/scripts/error-code-sync/index.mjs b/.github/scripts/error-code-sync/index.mjs new file mode 100644 index 00000000..0ae99b7c --- /dev/null +++ b/.github/scripts/error-code-sync/index.mjs @@ -0,0 +1,274 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + BOT_MARKER, + applyCatalogChanges, + collectServerCodes, + diffCodes, + parseCategoryStatuses, + parseClientCatalog, + parseRegistry, + renderDiscordMessage, + renderPrBody, + stableBody, +} from './lib.mjs'; + +const SERVER_REPO = process.env.SERVER_REPO ?? 'TeamPiKi/core'; +const BASE = process.env.BASE_BRANCH ?? 'dev'; +const BOT_BRANCH = process.env.BOT_BRANCH ?? 'chore/error-code-sync'; +const CATALOG = 'packages/core/src/consts/errorCode.ts'; +const DRY_RUN = process.env.DRY_RUN === '1'; +const PR_TITLE = 'chore: 서버 에러 코드 카탈로그 동기화'; +const BOT_NAME = 'github-actions[bot]'; +const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com'; +const PAUSED_MARKER = ''; + +const run = (cmd, args, options = {}) => + execFileSync(cmd, args, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'inherit'], + maxBuffer: 64 * 1024 * 1024, + ...options, + }).trim(); + +const tryRun = (cmd, args) => { + const r = spawnSync(cmd, args, { encoding: 'utf8' }); + return r.status === 0 ? r.stdout.trim() : null; +}; + +const gh = (...args) => run('gh', args); + +const serverFile = (path, ref) => + gh( + 'api', + '-H', + 'Accept: application/vnd.github.raw', + `repos/${SERVER_REPO}/contents/${path}?ref=${ref}` + ); + +const fetchServerCodes = ref => { + const sha = gh('api', `repos/${SERVER_REPO}/commits/${ref}`, '--jq', '.sha'); + const tree = JSON.parse(gh('api', `repos/${SERVER_REPO}/git/trees/${sha}?recursive=1`)); + if (tree.truncated) throw new Error('서버 레포 트리가 잘려 파일을 모두 찾을 수 없음'); + const paths = tree.tree + .filter(t => t.type === 'blob' && t.path.startsWith('src/main/')) + .map(t => t.path); + const findOne = basename => { + const hits = paths.filter(p => p.endsWith(`/${basename}`)); + if (hits.length !== 1) + throw new Error(`${basename} 를 하나로 특정하지 못함 (${hits.length}개)`); + return hits[0]; + }; + + const registry = parseRegistry(serverFile(findOne('ErrorCodeCatalog.kt'), sha)); + const categoryStatus = parseCategoryStatuses(serverFile(findOne('ErrorCategory.kt'), sha)); + const enumSources = new Map( + registry.map(({ enumName }) => [enumName, serverFile(findOne(`${enumName}.kt`), sha)]) + ); + return { sha, codes: collectServerCodes({ registry, enumSources, categoryStatus }) }; +}; + +const findReferences = (code, constantNames) => { + const patterns = [...constantNames.map(n => `ERROR_CODE.${n}`), `'${code}'`, `"${code}"`]; + const out = tryRun('git', [ + 'grep', + '-n', + '-F', + '-w', + ...patterns.flatMap(p => ['-e', p]), + '--', + 'apps', + 'packages', + `:(exclude)${CATALOG}`, + ]); + return out ? out.split('\n').map(line => line.split(':').slice(0, 2).join(':')) : []; +}; + +const formatCatalog = text => + `${run('pnpm', ['exec', 'prettier', '--stdin-filepath', CATALOG], { input: text })}\n`; + +const writeTemp = (name, text) => { + const path = join(mkdtempSync(join(tmpdir(), 'error-code-sync-')), name); + writeFileSync(path, text); + return path; +}; + +const notifyDiscord = async text => { + const token = process.env.DISCORD_BOT_TOKEN; + const channel = process.env.DISCORD_CHANNEL_ID; + if (!token || !channel) { + console.log('::warning::Discord 시크릿이 없어 알림 생략'); + return; + } + const res = await fetch(`https://discord.com/api/v10/channels/${channel}/messages`, { + method: 'POST', + headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ content: text, allowed_mentions: { parse: [] } }), + }); + if (!res.ok) console.log(`::warning::Discord 전송 실패 (HTTP ${res.status})`); +}; + +const findOpenBotPr = () => + JSON.parse( + gh( + 'pr', + 'list', + '--head', + BOT_BRANCH, + '--base', + BASE, + '--state', + 'open', + '--json', + 'number,url,body' + ) + ).find(pr => pr.body.includes(BOT_MARKER)) ?? null; + +const remoteBranchExists = () => + tryRun('git', ['ls-remote', '--exit-code', '--heads', 'origin', BOT_BRANCH]) !== null; + +const humanCommitsOnBotBranch = () => { + run('git', [ + 'fetch', + '--quiet', + 'origin', + `+refs/heads/${BOT_BRANCH}:refs/remotes/origin/${BOT_BRANCH}`, + ]); + return run('git', ['log', '--format=%ae', `origin/${BASE}..origin/${BOT_BRANCH}`]) + .split('\n') + .filter(email => email && email !== BOT_EMAIL); +}; + +const pauseOnce = pr => { + const comments = JSON.parse(gh('pr', 'view', String(pr.number), '--json', 'comments')).comments; + if (comments.some(c => c.body.includes(PAUSED_MARKER))) return; + gh( + 'pr', + 'comment', + String(pr.number), + '--body', + `${PAUSED_MARKER}\n사람이 올린 커밋이 있어 자동 갱신을 멈춥니다. 서버와의 차이가 새로 생기면 이 PR 을 머지하거나 닫은 뒤 다음 실행에서 다시 맞춥니다.` + ); +}; + +const dev = fetchServerCodes('dev'); +let main = null; +try { + main = fetchServerCodes('main'); +} catch (error) { + console.log(`::warning::서버 main 조회 실패, 운영 반영 여부는 생략: ${error.message}`); +} + +const source = readFileSync(CATALOG, 'utf8'); +const diff = diffCodes({ + server: dev.codes, + serverMainCodes: main ? new Set(main.codes.keys()) : null, + client: parseClientCatalog(source), +}); +const references = new Map(diff.removed.map(r => [r.code, findReferences(r.code, r.constants)])); +const result = applyCatalogChanges(source, diff, { references }); +const formatted = formatCatalog(result.source); +const catalogChanged = formatted !== formatCatalog(source); +const hasDifference = catalogChanged || result.manual.length > 0; +const body = renderPrBody({ + ...result, + serverRepo: SERVER_REPO, + serverDevSha: dev.sha, + serverMainSha: main?.sha ?? null, + clientSha: run('git', ['rev-parse', 'HEAD']), +}); + +if (DRY_RUN) { + const after = writeTemp('errorCode.ts', formatted); + const patch = spawnSync('git', ['diff', '--no-index', '--', CATALOG, after], { + encoding: 'utf8', + }); + console.log(patch.stdout || '카탈로그 변경 없음'); + console.log(hasDifference ? body : '서버와 카탈로그가 일치함'); + process.exit(0); +} + +const openPr = findOpenBotPr(); + +if (!hasDifference) { + if (openPr) { + gh( + 'pr', + 'close', + String(openPr.number), + '--comment', + '서버 등록 목록과 카탈로그가 다시 일치해서 닫습니다.', + '--delete-branch' + ); + console.log(`봇 PR #${openPr.number} 닫음`); + } else { + console.log('서버와 카탈로그가 일치함'); + } + process.exit(0); +} + +if (openPr && remoteBranchExists()) { + if (humanCommitsOnBotBranch().length > 0) { + pauseOnce(openPr); + console.log(`봇 PR #${openPr.number} 에 사람 커밋이 있어 갱신하지 않음`); + process.exit(0); + } + const remoteCatalog = tryRun('git', ['show', `origin/${BOT_BRANCH}:${CATALOG}`]); + if ( + remoteCatalog !== null && + `${remoteCatalog}\n` === formatted && + stableBody(openPr.body) === stableBody(body) + ) { + console.log(`봇 PR #${openPr.number} 이 이미 최신`); + process.exit(0); + } +} + +writeFileSync(CATALOG, formatted); +run('git', ['switch', '--quiet', '-C', BOT_BRANCH]); +run('git', ['add', CATALOG]); +run('git', [ + '-c', + `user.name=${BOT_NAME}`, + '-c', + `user.email=${BOT_EMAIL}`, + 'commit', + '--quiet', + '--allow-empty', + '-m', + PR_TITLE, +]); +run('git', ['push', '--quiet', '--force', 'origin', `HEAD:refs/heads/${BOT_BRANCH}`]); + +const bodyFile = writeTemp('body.md', body); +let url; +let action; +if (openPr) { + gh('pr', 'edit', String(openPr.number), '--body-file', bodyFile); + url = openPr.url; + action = 'updated'; +} else { + url = gh( + 'pr', + 'create', + '--base', + BASE, + '--head', + BOT_BRANCH, + '--title', + PR_TITLE, + '--body-file', + bodyFile, + '--label', + 'chore' + ); + action = 'created'; +} + +// GITHUB_TOKEN 으로 만든 PR 은 pull_request 워크플로우를 깨우지 않아 필수 체크를 수동 실행으로 채움 +gh('workflow', 'run', 'ci.yml', '--ref', BOT_BRANCH); +await notifyDiscord(renderDiscordMessage({ url, action, ...result })); +console.log(`봇 PR ${action === 'created' ? '생성' : '갱신'}: ${url}`); diff --git a/.github/scripts/error-code-sync/lib.mjs b/.github/scripts/error-code-sync/lib.mjs new file mode 100644 index 00000000..71130a79 --- /dev/null +++ b/.github/scripts/error-code-sync/lib.mjs @@ -0,0 +1,457 @@ +const CODE = '[A-Z]+(?:-[A-Z0-9]+)+'; + +export const CLIENT_EXCLUDED_PREFIXES = ['APPLE']; + +export const BOT_MARKER = ''; + +const HTTP_STATUS = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + METHOD_NOT_ALLOWED: 405, + CONFLICT: 409, + UNSUPPORTED_MEDIA_TYPE: 415, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, +}; + +export const codePrefix = code => code.split('-')[0]; + +export const compareCodes = (a, b) => { + const pa = a.split('-'); + const pb = b.split('-'); + for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) { + const x = pa[i]; + const y = pb[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + if (x === y) continue; + if (/^\d+$/.test(x) && /^\d+$/.test(y)) return Number(x) - Number(y); + return x < y ? -1 : 1; + } + return 0; +}; + +export const stripKotlinComments = src => { + let out = ''; + let inString = false; + for (let i = 0; i < src.length; i += 1) { + const c = src[i]; + const n = src[i + 1]; + if (inString) { + out += c; + if (c === '\\') { + out += n ?? ''; + i += 1; + } else if (c === '"') { + inString = false; + } + continue; + } + if (c === '"') { + inString = true; + out += c; + } else if (c === '/' && n === '/') { + while (i < src.length && src[i] !== '\n') i += 1; + out += '\n'; + } else if (c === '/' && n === '*') { + const end = src.indexOf('*/', i + 2); + i = end === -1 ? src.length : end + 1; + out += ' '; + } else { + out += c; + } + } + return out; +}; + +const PAIRS = { '(': ')', '{': '}' }; + +const matchClose = (src, open) => { + const o = src[open]; + const c = PAIRS[o]; + let depth = 0; + let inString = false; + for (let i = open; i < src.length; i += 1) { + const ch = src[i]; + if (inString) { + if (ch === '\\') i += 1; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === o) depth += 1; + else if (ch === c) { + depth -= 1; + if (depth === 0) return i; + } + } + throw new Error(`'${o}' 의 닫는 괄호를 찾지 못함`); +}; + +const parseAddAllArg = arg => { + const m = arg.match(/^([A-Z][A-Za-z0-9]*)\.entries(?:\.filter\s*\{([\s\S]*)\})?$/); + if (!m) throw new Error(`addAll 인자를 해석할 수 없음: ${arg}`); + const [, enumName, filterBody] = m; + if (filterBody === undefined) return { enumName, excluded: [] }; + const excluded = filterBody.split('&&').map(cond => { + const c = cond.trim().match(/^it\s*!=\s*([A-Z][A-Za-z0-9]*)\.([A-Z][A-Z0-9_]*)$/); + if (!c || c[1] !== enumName) throw new Error(`filter 조건을 해석할 수 없음: ${cond.trim()}`); + return c[2]; + }); + return { enumName, excluded }; +}; + +export const parseRegistry = src => { + const code = stripKotlinComments(src); + const objectAt = code.indexOf('object ErrorCodeRegistry'); + if (objectAt === -1) throw new Error('ErrorCodeRegistry 를 찾지 못함'); + const listAt = code.indexOf('buildList', objectAt); + const open = listAt === -1 ? -1 : code.indexOf('{', listAt); + if (open === -1) throw new Error('ErrorCodeRegistry.all 의 buildList 를 찾지 못함'); + const body = code.slice(open + 1, matchClose(code, open)); + + const entries = []; + let rest = ''; + let i = 0; + while (i < body.length) { + const at = body.indexOf('addAll(', i); + if (at === -1) { + rest += body.slice(i); + break; + } + rest += body.slice(i, at); + const paren = at + 'addAll'.length; + const close = matchClose(body, paren); + entries.push(parseAddAllArg(body.slice(paren + 1, close).trim())); + i = close + 1; + } + if (rest.trim()) + throw new Error(`ErrorCodeRegistry 에 해석할 수 없는 구문: ${rest.trim().slice(0, 80)}`); + if (entries.length === 0) throw new Error('ErrorCodeRegistry 에 등록된 enum 이 없음'); + return entries; +}; + +const unescapeKotlin = s => s.replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t' })[c] ?? c); + +export const parseEnumEntries = src => { + const code = stripKotlinComments(src); + const entryRe = new RegExp( + String.raw`\b([A-Z][A-Z0-9_]*)\(\s*"(${CODE})"\s*,\s*ErrorCategory\.([A-Z_]+)\s*,\s*"((?:[^"\\]|\\.)*)"`, + 'g' + ); + const entries = [...code.matchAll(entryRe)].map(m => ({ + name: m[1], + code: m[2], + category: m[3], + message: unescapeKotlin(m[4]), + })); + if (entries.length === 0) throw new Error('enum 항목을 찾지 못함'); + const literals = [...code.matchAll(new RegExp(`"(${CODE})"`, 'g'))].length; + if (literals !== entries.length) { + throw new Error(`해석하지 못한 code 가 있음 (code ${literals}개, 항목 ${entries.length}개)`); + } + return entries; +}; + +export const parseCategoryStatuses = src => { + const code = stripKotlinComments(src); + return new Map( + [...code.matchAll(/\b([A-Z][A-Z_]*)\(\s*HttpStatus\.([A-Z_]+)/g)].map(m => [ + m[1], + HTTP_STATUS[m[2]] ?? null, + ]) + ); +}; + +export const collectServerCodes = ({ registry, enumSources, categoryStatus }) => { + const codes = new Map(); + for (const { enumName, excluded } of registry) { + const src = enumSources.get(enumName); + if (src === undefined) throw new Error(`${enumName} 파일이 없음`); + const entries = parseEnumEntries(src); + for (const name of excluded) { + if (!entries.some(e => e.name === name)) + throw new Error(`${enumName} 에 제외 대상 ${name} 이 없음`); + } + for (const entry of entries) { + if (excluded.includes(entry.name)) continue; + if (codes.has(entry.code)) throw new Error(`중복 code: ${entry.code}`); + codes.set(entry.code, { + ...entry, + enumName, + status: categoryStatus.get(entry.category) ?? null, + }); + } + } + return codes; +}; + +const BLOCKS = { + map: { + start: /^export const ERROR_MESSAGE_MAP = \{$/, + end: /^\} as const;$/, + entry: new RegExp(`^\\s*'(${CODE})':`), + }, + const: { + start: /^export const ERROR_CODE = \{$/, + end: /^\} as const satisfies /, + entry: new RegExp(`^\\s*[A-Z][A-Z0-9_]*: '(${CODE})',$`), + }, +}; + +const GROUP_COMMENT = /^\s*\/\*\*.*\*\/$/; + +const blockRange = (lines, kind) => { + const { start, end } = BLOCKS[kind]; + const s = lines.findIndex(l => start.test(l)); + const e = s === -1 ? -1 : lines.findIndex((l, i) => i > s && end.test(l)); + if (s === -1 || e === -1) + throw new Error( + `카탈로그에서 ${kind === 'map' ? 'ERROR_MESSAGE_MAP' : 'ERROR_CODE'} 블록을 찾지 못함` + ); + return { start: s, end: e }; +}; + +const entryCode = (line, kind) => line.match(BLOCKS[kind].entry)?.[1] ?? null; + +export const parseClientCatalog = src => { + const lines = src.split('\n'); + const map = blockRange(lines, 'map'); + const consts = blockRange(lines, 'const'); + const messageCodes = lines + .slice(map.start + 1, map.end) + .map(l => entryCode(l, 'map')) + .filter(Boolean); + const constants = lines + .slice(consts.start + 1, consts.end) + .map(l => l.match(new RegExp(`^\\s*([A-Z][A-Z0-9_]*): '(${CODE})',$`))) + .filter(Boolean) + .map(m => ({ name: m[1], code: m[2] })); + return { messageCodes, constants }; +}; + +export const diffCodes = ({ + server, + serverMainCodes, + client, + excludedPrefixes = CLIENT_EXCLUDED_PREFIXES, +}) => { + const isExcluded = code => excludedPrefixes.includes(codePrefix(code)); + const clientCodes = new Set(client.messageCodes); + const added = [...server.values()] + .filter(e => !clientCodes.has(e.code) && !isExcluded(e.code)) + .map(e => ({ ...e, inMain: serverMainCodes ? serverMainCodes.has(e.code) : null })) + .sort((a, b) => compareCodes(a.code, b.code)); + const removed = client.messageCodes + .filter(code => !server.has(code) && !isExcluded(code)) + .sort(compareCodes) + .map(code => ({ + code, + constants: client.constants.filter(k => k.code === code).map(k => k.name), + })); + return { added, removed }; +}; + +export const suggestConstantName = (entry, constants) => { + const prefix = codePrefix(entry.code); + const group = constants.filter(k => codePrefix(k.code) === prefix).map(k => k.name.split('_')); + let head = [prefix]; + if (group.length >= 2) { + const common = group.reduce((acc, tokens) => { + let i = 0; + while (i < acc.length && acc[i] === tokens[i]) i += 1; + return acc.slice(0, i); + }); + if (common.length > 0 && group.every(tokens => tokens.length > common.length)) head = common; + } + const own = entry.name.split('_').filter(t => t !== prefix); + return [...head, ...(own.length > 0 ? own : entry.name.split('_'))].join('_'); +}; + +const escapeTs = s => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + +const insertEntry = (lines, kind, code, newLine) => { + const { start, end } = blockRange(lines, kind); + const prefix = codePrefix(code); + const same = []; + for (let i = start + 1; i < end; i += 1) { + const c = entryCode(lines[i], kind); + if (c && codePrefix(c) === prefix) same.push({ i, c }); + } + const next = [...lines]; + if (same.length > 0) { + const ordered = /-\d+$/.test(code); + const after = ordered + ? same.find(s => /-\d+$/.test(s.c) && compareCodes(s.c, code) > 0) + : undefined; + next.splice(after ? after.i : same[same.length - 1].i + 1, 0, newLine); + return next; + } + const lead = lines[end - 1].trim() === '' ? [] : ['']; + next.splice(end, 0, ...lead, ` /** ${prefix} */`, newLine); + return next; +}; + +const removeEntry = (lines, kind, code) => { + const { start, end } = blockRange(lines, kind); + return lines.filter((l, i) => i <= start || i >= end || entryCode(l, kind) !== code); +}; + +const tidyBlock = (lines, kind) => { + const { start, end } = blockRange(lines, kind); + const inner = lines.slice(start + 1, end); + const kept = inner.filter((l, i) => { + if (!GROUP_COMMENT.test(l)) return true; + const next = inner[i + 1]; + return next !== undefined && entryCode(next, kind) !== null; + }); + const collapsed = kept.filter( + (l, i) => !(l.trim() === '' && (i === 0 || kept[i - 1].trim() === '')) + ); + while (collapsed.length > 0 && collapsed[collapsed.length - 1].trim() === '') collapsed.pop(); + return [...lines.slice(0, start + 1), ...collapsed, ...lines.slice(end)]; +}; + +export const applyCatalogChanges = (src, { added, removed }, { references = new Map() } = {}) => { + const { constants } = parseClientCatalog(src); + const known = [...constants]; + let lines = src.split('\n'); + const applied = { added: [], removed: [] }; + const manual = []; + + for (const entry of added) { + if (entry.message.includes('${')) { + manual.push({ code: entry.code, reason: '서버 문구에 변수가 있음', detail: entry.message }); + continue; + } + const name = suggestConstantName(entry, known); + if (known.some(k => k.name === name)) { + manual.push({ + code: entry.code, + reason: `상수 이름 \`${name}\` 이 이미 있음`, + detail: entry.message, + }); + continue; + } + lines = insertEntry( + lines, + 'map', + entry.code, + ` '${entry.code}': '${escapeTs(entry.message)}',` + ); + lines = insertEntry(lines, 'const', entry.code, ` ${name}: '${entry.code}',`); + known.push({ name, code: entry.code }); + applied.added.push({ ...entry, constantName: name }); + } + + for (const { code, constants: names } of removed) { + const refs = references.get(code) ?? []; + if (refs.length > 0) { + manual.push({ + code, + reason: '서버에서 사라졌지만 프론트 코드가 참조 중', + detail: refs.join(' · '), + }); + continue; + } + lines = removeEntry(removeEntry(lines, 'map', code), 'const', code); + applied.removed.push({ code, constants: names }); + } + + lines = tidyBlock(tidyBlock(lines, 'map'), 'const'); + return { source: lines.join('\n'), applied, manual }; +}; + +const cell = s => String(s).replace(/\|/g, '\\|').replace(/\n/g, ' '); + +const short = sha => (sha ? `\`${sha.slice(0, 7)}\`` : '확인 불가'); + +const MAIN_LABEL = { true: '반영됨', false: 'dev 에만 있음', null: '확인 불가' }; + +export const renderPrBody = ({ + applied, + manual, + serverRepo, + serverDevSha, + serverMainSha, + clientSha, +}) => { + const summary = `추가 ${applied.added.length} · 삭제 ${applied.removed.length} · 직접 처리 ${manual.length}`; + const parts = [ + BOT_MARKER, + '## 작업 요약', + '', + `- 서버 에러 코드 등록 목록과 프론트 에러 코드 카탈로그의 차이를 맞춥니다 (${summary})`, + '', + '## 작업 세부 내용', + '', + '### 배경', + '', + `매일 서버 \`${serverRepo}\` 의 \`ErrorCodeRegistry\` 와 \`packages/core/src/consts/errorCode.ts\` 를 대조하는 워크플로우가 만든 PR 입니다. 새 코드에는 서버 문구를 그대로 넣었습니다. 화면에 맞게 다듬을 곳이 있으면 이 브랜치에 커밋해 주세요. 사람 커밋이 생기면 봇은 이 PR 을 더 갱신하지 않습니다.`, + ]; + + if (applied.added.length > 0) { + parts.push( + '', + '### 추가', + '', + '| code | HTTP | 서버 문구 | 상수 (초안) | 서버 운영(main) |', + '|---|---|---|---|---|', + ...applied.added.map( + e => + `| \`${e.code}\` | ${e.status ?? '?'} | ${cell(e.message)} | \`${e.constantName}\` | ${MAIN_LABEL[e.inMain]} |` + ) + ); + } + + if (applied.removed.length > 0) { + parts.push( + '', + '### 삭제', + '', + '서버 등록 목록에서 사라졌고 프론트 코드에서 참조하지 않는 코드입니다.', + '', + '| code | 상수 |', + '|---|---|', + ...applied.removed.map( + r => `| \`${r.code}\` | ${r.constants.map(n => `\`${n}\``).join(' · ') || '없음'} |` + ) + ); + } + + if (manual.length > 0) { + parts.push( + '', + '### 직접 처리 필요', + '', + '자동으로 고치지 않은 코드입니다.', + '', + '| code | 이유 | 상세 |', + '|---|---|---|', + ...manual.map(m => `| \`${m.code}\` | ${cell(m.reason)} | ${cell(m.detail)} |`) + ); + } + + parts.push( + '', + '### 기준', + '', + `- 서버 \`${serverRepo}\` dev ${short(serverDevSha)} · main ${short(serverMainSha)}`, + `- 프론트 dev ${short(clientSha)}`, + '', + '## 스크린샷', + '', + '## 연관 이슈', + '' + ); + return parts.join('\n'); +}; + +export const stableBody = body => body.replace(/### 기준[\s\S]*?(?=\n## )/, ''); + +export const renderDiscordMessage = ({ url, action, applied, manual }) => + `🔄 서버 에러 코드 카탈로그 동기화 PR ${action === 'created' ? '생성' : '갱신'} · 추가 ${applied.added.length} · 삭제 ${applied.removed.length} · 직접 처리 ${manual.length}\n<${url}>`; diff --git a/.github/scripts/error-code-sync/lib.test.mjs b/.github/scripts/error-code-sync/lib.test.mjs new file mode 100644 index 00000000..34e7c8a9 --- /dev/null +++ b/.github/scripts/error-code-sync/lib.test.mjs @@ -0,0 +1,416 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + applyCatalogChanges, + collectServerCodes, + compareCodes, + diffCodes, + parseCategoryStatuses, + parseClientCatalog, + parseEnumEntries, + parseRegistry, + renderPrBody, + stableBody, + suggestConstantName, +} from './lib.mjs'; + +const registrySource = body => `package x + +// 등록 안 한 enum: addAll(AnnouncementImageErrorCode.entries) 는 주석이라 무시돼야 한다 +object ErrorCodeRegistry { + val all: List = + buildList { +${body} + // 도메인 이관 시 여기에 addAll(XxxErrorCode.entries) 추가 + } +} + +fun errorCodeCatalogMarkdown(codes: List): String = "" +`; + +const CLIENT = `export const ERROR_MESSAGE_MAP = { + /** 공통 4xx */ + 'COMMON-UNAUTHORIZED': '로그인이 필요해요.', + + /** WISH */ + 'WISH-004': '이미 삭제된 아이템이에요.', + 'WISH-006': '한 번에 최대 100개까지 삭제할 수 있어요.', + 'WISH-010': '지금은 더 담을 수 없어요.', + + /** ITEM */ + 'ITEM-003': '상품 이름을 입력해 주세요.', +} as const; + +export const ERROR_CODE = { + /** 공통 */ + COMMON_UNAUTHORIZED: 'COMMON-UNAUTHORIZED', + + /** 위시 */ + WISH_NOT_FOUND: 'WISH-004', + WISH_INVALID_ID_COUNT: 'WISH-006', + WISH_RATE_LIMITED: 'WISH-010', + + /** 상품 */ + ITEM_NAME_REQUIRED_FOR_READY: 'ITEM-003', +} as const satisfies Record; +`; + +const serverEntry = (code, name, message, extra = {}) => [ + code, + { code, name, message, category: 'CONFLICT', status: 409, enumName: 'XErrorCode', ...extra }, +]; + +test('등록 목록에서 addAll 과 filter 제외를 읽고 주석 속 enum 이름은 무시한다', () => { + const registry = parseRegistry( + registrySource(` addAll(CommonErrorCode.entries) + addAll(WishErrorCode.entries) + addAll(ImageStorageErrorCode.entries.filter { it != ImageStorageErrorCode.DELETE_FAILED })`) + ); + + assert.deepEqual(registry, [ + { enumName: 'CommonErrorCode', excluded: [] }, + { enumName: 'WishErrorCode', excluded: [] }, + { enumName: 'ImageStorageErrorCode', excluded: ['DELETE_FAILED'] }, + ]); +}); + +test('filter 조건 여러 개를 && 로 이으면 모두 제외 대상으로 읽는다', () => { + const registry = parseRegistry( + registrySource( + ' addAll(ImageStorageErrorCode.entries.filter { it != ImageStorageErrorCode.A && it != ImageStorageErrorCode.B })' + ) + ); + + assert.deepEqual(registry[0].excluded, ['A', 'B']); +}); + +test('등록 목록에 addAll 이 아닌 구문이 있으면 실패한다', () => { + assert.throws( + () => parseRegistry(registrySource(' add(WishErrorCode.NOT_FOUND)')), + /해석할 수 없는 구문/ + ); +}); + +test('등록 목록의 filter 형식을 모르면 실패한다', () => { + assert.throws( + () => + parseRegistry( + registrySource(' addAll(WishErrorCode.entries.filter { it.public })') + ), + /filter 조건을 해석할 수 없음/ + ); +}); + +test('enum 항목을 한 줄·여러 줄 모두 읽고 문구 속 따옴표와 쉼표를 보존한다', () => { + const entries = parseEnumEntries(`enum class WishErrorCode( + override val code: String, +) : ErrorCode { + // 결번 "WISH-099" 는 주석이라 항목이 아니다 + NOT_FOUND("WISH-004", ErrorCategory.NOT_FOUND, "이미 삭제된 아이템이에요."), + FAILED_NOT_REFRESHABLE( + "WISH-008", + ErrorCategory.CONFLICT, + "\\"추출 실패\\" 항목은, 직접 입력해 주세요.", + ), +}`); + + assert.deepEqual(entries, [ + { + name: 'NOT_FOUND', + code: 'WISH-004', + category: 'NOT_FOUND', + message: '이미 삭제된 아이템이에요.', + }, + { + name: 'FAILED_NOT_REFRESHABLE', + code: 'WISH-008', + category: 'CONFLICT', + message: '"추출 실패" 항목은, 직접 입력해 주세요.', + }, + ]); +}); + +test('enum 에 형식을 모르는 code 가 있으면 실패한다', () => { + assert.throws( + () => + parseEnumEntries(`enum class X { + A("WISH-001", ErrorCategory.CONFLICT, "a"), + B("WISH-002", "b", ErrorCategory.CONFLICT), +}`), + /해석하지 못한 code/ + ); +}); + +test('등록된 enum 만 모으고 filter 제외 항목은 빼며 category 로 HTTP status 를 붙인다', () => { + const categoryStatus = + parseCategoryStatuses(`enum class ErrorCategory(val httpStatus: HttpStatus) { + CONFLICT(HttpStatus.CONFLICT, "a"), + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "b"), +}`); + const codes = collectServerCodes({ + registry: [{ enumName: 'ItemErrorCode', excluded: ['OLD'] }], + enumSources: new Map([ + [ + 'ItemErrorCode', + `QUOTA_EXCEEDED("ITEM-006", ErrorCategory.TOO_MANY_REQUESTS, "지금은 추가할 수 없어요.") + OLD("ITEM-007", ErrorCategory.CONFLICT, "x")`, + ], + ]), + categoryStatus, + }); + + assert.deepEqual([...codes.keys()], ['ITEM-006']); + assert.equal(codes.get('ITEM-006').status, 429); +}); + +test('등록된 enum 파일이 없거나 code 가 겹치면 실패한다', () => { + assert.throws( + () => + collectServerCodes({ + registry: [{ enumName: 'MissingErrorCode', excluded: [] }], + enumSources: new Map(), + categoryStatus: new Map(), + }), + /파일이 없음/ + ); + const dup = 'A("WISH-001", ErrorCategory.CONFLICT, "a")'; + assert.throws( + () => + collectServerCodes({ + registry: [ + { enumName: 'A', excluded: [] }, + { enumName: 'B', excluded: [] }, + ], + enumSources: new Map([ + ['A', dup], + ['B', dup], + ]), + categoryStatus: new Map(), + }), + /중복 code/ + ); +}); + +test('대조 결과는 서버에만·프론트에만을 나누고 APPLE 접두어는 양쪽 모두 제외한다', () => { + const server = new Map([ + serverEntry('COMMON-UNAUTHORIZED', 'UNAUTHORIZED', '로그인이 필요해요.'), + serverEntry('WISH-004', 'NOT_FOUND', '이미 삭제된 아이템이에요.'), + serverEntry('WISH-006', 'INVALID_ID_COUNT', '최대 100개'), + serverEntry('ITEM-003', 'NAME_REQUIRED_FOR_READY', '이름'), + serverEntry('ITEM-006', 'QUOTA_EXCEEDED', '지금은 추가할 수 없어요.'), + serverEntry('LINK-009', 'NEW', '새 코드'), + serverEntry('APPLE-001', 'TOKEN', '애플'), + ]); + + const { added, removed } = diffCodes({ + server, + serverMainCodes: new Set(['ITEM-006']), + client: parseClientCatalog(CLIENT), + }); + + assert.deepEqual( + added.map(e => [e.code, e.inMain]), + [ + ['ITEM-006', true], + ['LINK-009', false], + ] + ); + assert.deepEqual(removed, [{ code: 'WISH-010', constants: ['WISH_RATE_LIMITED'] }]); +}); + +test('서버 main 을 못 읽었으면 운영 반영 여부는 null 이다', () => { + const { added } = diffCodes({ + server: new Map([serverEntry('ITEM-006', 'QUOTA_EXCEEDED', 'a')]), + serverMainCodes: null, + client: parseClientCatalog(CLIENT), + }); + + assert.equal(added[0].inMain, null); +}); + +test('상수 이름은 묶음의 공통 앞부분에 enum 이름을 붙이고 enum 속 접두어 단어는 뺀다', () => { + const constants = [ + { name: 'PRODUCT_IMAGE_EMPTY_IMAGE', code: 'PRODUCTIMAGE-001' }, + { name: 'PRODUCT_IMAGE_UNKNOWN_TYPE', code: 'PRODUCTIMAGE-002' }, + { name: 'TOURNAMENT_NOT_FOUND', code: 'TOURNAMENT-002' }, + ]; + + assert.equal( + suggestConstantName({ code: 'PRODUCTIMAGE-004', name: 'TOO_LARGE' }, constants), + 'PRODUCT_IMAGE_TOO_LARGE' + ); + assert.equal( + suggestConstantName({ code: 'TOURNAMENT-050', name: 'FORBIDDEN_TOURNAMENT' }, constants), + 'TOURNAMENT_FORBIDDEN' + ); + assert.equal( + suggestConstantName({ code: 'ITEM-006', name: 'QUOTA_EXCEEDED' }, constants), + 'ITEM_QUOTA_EXCEEDED' + ); + assert.equal(suggestConstantName({ code: 'ITEM-007', name: 'ITEM' }, constants), 'ITEM_ITEM'); +}); + +test('새 코드는 같은 묶음 안 코드 순서 자리에 넣고 묶음이 없으면 새 묶음을 만든다', () => { + const { source, applied } = applyCatalogChanges(CLIENT, { + added: [ + { + code: 'WISH-005', + name: 'INVALID_IMAGE_COUNT', + message: "이미지는 '1~5장'만 올릴 수 있어요.", + }, + { code: 'LINK-001', name: 'INVALID_FORMAT', message: '올바른 링크 형식이 아니에요.' }, + ], + removed: [], + }); + + assert.equal(applied.added.length, 2); + assert.match( + source, + /'WISH-004': '이미 삭제된 아이템이에요.',\n {2}'WISH-005': '이미지는 \\'1~5장\\'만 올릴 수 있어요.',\n {2}'WISH-006'/ + ); + assert.match( + source, + /WISH_NOT_FOUND: 'WISH-004',\n {2}WISH_INVALID_IMAGE_COUNT: 'WISH-005',\n {2}WISH_INVALID_ID_COUNT/ + ); + assert.match( + source, + /'ITEM-003': '상품 이름을 입력해 주세요.',\n\n {2}\/\*\* LINK \*\/\n {2}'LINK-001'/ + ); + assert.match( + source, + /ITEM_NAME_REQUIRED_FOR_READY: 'ITEM-003',\n\n {2}\/\*\* LINK \*\/\n {2}LINK_INVALID_FORMAT: 'LINK-001',\n\} as const satisfies/ + ); +}); + +test('참조 없는 코드는 맵과 상수에서 지우고 비게 된 묶음 주석도 지운다', () => { + const { source, applied } = applyCatalogChanges(CLIENT, { + added: [], + removed: [ + { code: 'WISH-010', constants: ['WISH_RATE_LIMITED'] }, + { code: 'ITEM-003', constants: ['ITEM_NAME_REQUIRED_FOR_READY'] }, + ], + }); + + assert.deepEqual( + applied.removed.map(r => r.code), + ['WISH-010', 'ITEM-003'] + ); + assert.doesNotMatch(source, /WISH-010|ITEM-003|\/\*\* ITEM \*\/|\/\*\* 상품 \*\//); + assert.match(source, /'WISH-006': '한 번에 최대 100개까지 삭제할 수 있어요.',\n\} as const;/); + assert.match(source, /WISH_INVALID_ID_COUNT: 'WISH-006',\n\} as const satisfies/); +}); + +test('프론트가 아직 참조하는 코드는 지우지 않고 직접 처리로 넘긴다', () => { + const { source, applied, manual } = applyCatalogChanges( + CLIENT, + { added: [], removed: [{ code: 'WISH-010', constants: ['WISH_RATE_LIMITED'] }] }, + { references: new Map([['WISH-010', ['apps/web/src/a.ts:3']]]) } + ); + + assert.equal(source, CLIENT); + assert.equal(applied.removed.length, 0); + assert.deepEqual(manual, [ + { + code: 'WISH-010', + reason: '서버에서 사라졌지만 프론트 코드가 참조 중', + detail: 'apps/web/src/a.ts:3', + }, + ]); +}); + +test('문구에 변수가 있거나 상수 이름이 겹치면 넣지 않고 직접 처리로 넘긴다', () => { + const { source, manual } = applyCatalogChanges(CLIENT, { + added: [ + { + code: 'USER-012', + name: 'NICKNAME_TOO_LONG', + message: '닉네임은 ${User.MAX}자까지 입력할 수 있어요.', + }, + { code: 'WISH-011', name: 'RATE_LIMITED', message: '겹치는 이름' }, + ], + removed: [], + }); + + assert.equal(source, CLIENT); + assert.deepEqual( + manual.map(m => [m.code, m.reason]), + [ + ['USER-012', '서버 문구에 변수가 있음'], + ['WISH-011', '상수 이름 `WISH_RATE_LIMITED` 이 이미 있음'], + ] + ); +}); + +test('수정한 카탈로그로 다시 대조하면 차이가 없다', () => { + const server = new Map([ + serverEntry('COMMON-UNAUTHORIZED', 'UNAUTHORIZED', '로그인이 필요해요.'), + serverEntry('WISH-004', 'NOT_FOUND', 'a'), + serverEntry('WISH-006', 'INVALID_ID_COUNT', 'b'), + serverEntry('ITEM-003', 'NAME_REQUIRED_FOR_READY', 'c'), + serverEntry('ITEM-006', 'QUOTA_EXCEEDED', '지금은 추가할 수 없어요.'), + ]); + const first = applyCatalogChanges( + CLIENT, + diffCodes({ server, serverMainCodes: null, client: parseClientCatalog(CLIENT) }) + ); + + const second = diffCodes({ + server, + serverMainCodes: null, + client: parseClientCatalog(first.source), + }); + const again = applyCatalogChanges(first.source, second); + + assert.deepEqual(second, { added: [], removed: [] }); + assert.equal(again.source, first.source); +}); + +test('PR 본문은 있는 항목의 표만 넣고 문구 속 | 를 이스케이프한다', () => { + const body = renderPrBody({ + applied: { + added: [ + { + code: 'ITEM-006', + status: 429, + message: 'a | b', + constantName: 'ITEM_QUOTA_EXCEEDED', + inMain: true, + }, + ], + removed: [], + }, + manual: [{ code: 'USER-012', reason: '서버 문구에 변수가 있음', detail: '${x}' }], + serverRepo: 'TeamPiKi/core', + serverDevSha: 'aaaaaaaaaa', + serverMainSha: null, + clientSha: 'bbbbbbbbbb', + }); + + assert.match(body, /추가 1 · 삭제 0 · 직접 처리 1/); + assert.match(body, /\| `ITEM-006` \| 429 \| a \\\| b \| `ITEM_QUOTA_EXCEEDED` \| 반영됨 \|/); + assert.doesNotMatch(body, /### 삭제/); + assert.match(body, /### 직접 처리 필요/); + assert.match(body, /dev `aaaaaaa` · main 확인 불가/); +}); + +test('본문 비교용 형태는 기준 커밋 절만 뺀다', () => { + const base = { + applied: { added: [], removed: [{ code: 'WISH-010', constants: ['WISH_RATE_LIMITED'] }] }, + manual: [], + serverRepo: 'TeamPiKi/core', + serverMainSha: 'm', + clientSha: 'c', + }; + + assert.equal( + stableBody(renderPrBody({ ...base, serverDevSha: 'aaaaaaa' })), + stableBody(renderPrBody({ ...base, serverDevSha: 'bbbbbbb' })) + ); + assert.doesNotMatch(stableBody(renderPrBody({ ...base, serverDevSha: 'a' })), /### 기준/); +}); + +test('code 비교는 숫자 부분을 수로 비교한다', () => { + assert.ok(compareCodes('WISH-009', 'WISH-010') < 0); + assert.ok(compareCodes('TOURNAMENT-100', 'TOURNAMENT-099') > 0); +}); From c15038ce19dad90b390286569ec7487c108646ca Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:20:27 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 1 + .github/workflows/error-code-sync.yml | 59 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 .github/workflows/error-code-sync.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f79060ab..58493900 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - dev + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/error-code-sync.yml b/.github/workflows/error-code-sync.yml new file mode 100644 index 00000000..01a363c3 --- /dev/null +++ b/.github/workflows/error-code-sync.yml @@ -0,0 +1,59 @@ +name: Error Code Sync + +on: + schedule: + - cron: '23 1 * * *' # 10:23 KST + workflow_dispatch: + +permissions: {} + +concurrency: + group: error-code-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + actions: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: dev + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache pnpm store + uses: actions/cache@v6 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Unit test + run: node --test .github/scripts/error-code-sync/lib.test.mjs + + - name: 서버 에러 코드 대조 + env: + GH_TOKEN: ${{ github.token }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_CHANNEL_ID: ${{ secrets.DISCORD_PR_CHANNEL_ID }} + run: node .github/scripts/error-code-sync/index.mjs From 385725ca00a7ee2c0b7a286584aa18283a22012b Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:19:58 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B0=B8=EC=A1=B0=20=EA=B2=80=EC=83=89=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=A4=91?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/error-code-sync/index.mjs | 35 +++++++++++++++-------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/scripts/error-code-sync/index.mjs b/.github/scripts/error-code-sync/index.mjs index 0ae99b7c..847decc2 100644 --- a/.github/scripts/error-code-sync/index.mjs +++ b/.github/scripts/error-code-sync/index.mjs @@ -73,18 +73,29 @@ const fetchServerCodes = ref => { const findReferences = (code, constantNames) => { const patterns = [...constantNames.map(n => `ERROR_CODE.${n}`), `'${code}'`, `"${code}"`]; - const out = tryRun('git', [ - 'grep', - '-n', - '-F', - '-w', - ...patterns.flatMap(p => ['-e', p]), - '--', - 'apps', - 'packages', - `:(exclude)${CATALOG}`, - ]); - return out ? out.split('\n').map(line => line.split(':').slice(0, 2).join(':')) : []; + const r = spawnSync( + 'git', + [ + 'grep', + '-n', + '-F', + '-w', + ...patterns.flatMap(p => ['-e', p]), + '--', + 'apps', + 'packages', + `:(exclude)${CATALOG}`, + ], + { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 } + ); + if (r.status === 1) return []; + if (r.status !== 0) { + throw new Error(`${code} 참조 검색 실패: ${r.error?.message ?? r.stderr.trim()}`); + } + return r.stdout + .trim() + .split('\n') + .map(line => line.split(':').slice(0, 2).join(':')); }; const formatCatalog = text => From 5e6ed55e43513f4810abb84a7deda227318da02b Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:19:58 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20PR=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=EC=97=90=EC=84=9C=20=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=C2=B7=EC=97=B0=EA=B4=80=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=A0=88=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/error-code-sync/lib.mjs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/scripts/error-code-sync/lib.mjs b/.github/scripts/error-code-sync/lib.mjs index 71130a79..cce68e6a 100644 --- a/.github/scripts/error-code-sync/lib.mjs +++ b/.github/scripts/error-code-sync/lib.mjs @@ -442,16 +442,12 @@ export const renderPrBody = ({ '', `- 서버 \`${serverRepo}\` dev ${short(serverDevSha)} · main ${short(serverMainSha)}`, `- 프론트 dev ${short(clientSha)}`, - '', - '## 스크린샷', - '', - '## 연관 이슈', '' ); return parts.join('\n'); }; -export const stableBody = body => body.replace(/### 기준[\s\S]*?(?=\n## )/, ''); +export const stableBody = body => body.replace(/### 기준[\s\S]*?(?=\n## |$)/, ''); export const renderDiscordMessage = ({ url, action, applied, manual }) => `🔄 서버 에러 코드 카탈로그 동기화 PR ${action === 'created' ? '생성' : '갱신'} · 추가 ${applied.added.length} · 삭제 ${applied.removed.length} · 직접 처리 ${manual.length}\n<${url}>`; From faf9f0d5724203c0fa95ca47fb187c6cc4da8695 Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:19:59 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=AC=B8=EA=B5=AC=EC=9D=98=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=C2=B7=ED=83=AD=20=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=ED=94=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/error-code-sync/lib.mjs | 10 ++++++++-- .github/scripts/error-code-sync/lib.test.mjs | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/scripts/error-code-sync/lib.mjs b/.github/scripts/error-code-sync/lib.mjs index cce68e6a..0bc03a3b 100644 --- a/.github/scripts/error-code-sync/lib.mjs +++ b/.github/scripts/error-code-sync/lib.mjs @@ -135,7 +135,7 @@ export const parseRegistry = src => { return entries; }; -const unescapeKotlin = s => s.replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t' })[c] ?? c); +const unescapeKotlin = s => s.replace(/\\(.)/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' })[c] ?? c); export const parseEnumEntries = src => { const code = stripKotlinComments(src); @@ -272,7 +272,13 @@ export const suggestConstantName = (entry, constants) => { return [...head, ...(own.length > 0 ? own : entry.name.split('_'))].join('_'); }; -const escapeTs = s => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +const escapeTs = s => + s + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); const insertEntry = (lines, kind, code, newLine) => { const { start, end } = blockRange(lines, kind); diff --git a/.github/scripts/error-code-sync/lib.test.mjs b/.github/scripts/error-code-sync/lib.test.mjs index 34e7c8a9..2b055f99 100644 --- a/.github/scripts/error-code-sync/lib.test.mjs +++ b/.github/scripts/error-code-sync/lib.test.mjs @@ -283,6 +283,16 @@ test('새 코드는 같은 묶음 안 코드 순서 자리에 넣고 묶음이 ); }); +test('문구 속 줄바꿈·캐리지 리턴·탭은 TS 문자열 이스케이프로 넣는다', () => { + const [[, entry]] = parseEnumEntries(`enum class XErrorCode { + MULTI_LINE("WISH-005", ErrorCategory.CONFLICT, "첫 줄\\n둘째\\r줄\\t탭"), +}`).map(e => [e.code, e]); + assert.equal(entry.message, '첫 줄\n둘째\r줄\t탭'); + + const { source } = applyCatalogChanges(CLIENT, { added: [entry], removed: [] }); + assert.match(source, /'WISH-005': '첫 줄\\n둘째\\r줄\\t탭',\n/); +}); + test('참조 없는 코드는 맵과 상수에서 지우고 비게 된 묶음 주석도 지운다', () => { const { source, applied } = applyCatalogChanges(CLIENT, { added: [],