From 730e7604d7f8823e9d72cf38552615b5af2ca3cf Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 4 Mar 2026 20:01:04 +0900 Subject: [PATCH 01/13] =?UTF-8?q?ci:=20PR=20AI=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=ED=99=94=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=8F=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/pr-ai-description-lib.spec.mjs | 187 ++++ .github/scripts/pr-ai-description-lib.mjs | 737 +++++++++++++++ .github/scripts/pr-ai-description.mjs | 847 ++++++++++++++++++ .github/workflows/pr-ai-description.yml | 45 + 4 files changed, 1816 insertions(+) create mode 100644 .github/scripts/__tests__/pr-ai-description-lib.spec.mjs create mode 100644 .github/scripts/pr-ai-description-lib.mjs create mode 100644 .github/scripts/pr-ai-description.mjs create mode 100644 .github/workflows/pr-ai-description.yml diff --git a/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs b/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs new file mode 100644 index 0000000..4eb3a2c --- /dev/null +++ b/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildExcludeGlobs, + buildLimitedDiff, + decideCompareFallback, + filterDiffFiles, + filterKnownLabels, + renderSummaryBlock, + upsertSummaryBlock, + validateAiSummaryJson, +} from '../pr-ai-description-lib.mjs'; + +test('기본 제외 패턴과 추가 패턴이 파일 필터링에 적용된다', () => { + const files = [ + { filename: 'yarn.lock', patch: '@@ -1 +1 @@' }, + { filename: 'src/main.ts', patch: '@@ -1 +1 @@' }, + { filename: 'dist/main.js', patch: '@@ -1 +1 @@' }, + { filename: 'snapshots/user.snap', patch: '@@ -1 +1 @@' }, + { filename: 'logs/error.log', patch: '@@ -1 +1 @@' }, + ]; + + const excludeGlobs = buildExcludeGlobs('logs/**'); + const result = filterDiffFiles(files, excludeGlobs); + + assert.equal(result.included.length, 1); + assert.equal(result.included[0].filename, 'src/main.ts'); + assert.equal(result.excludedFilesCount, 4); +}); + +test('Diff 절단 시 메타가 계산되고 최종 bytes가 한도를 넘지 않는다', () => { + const entries = [ + { filename: 'src/a.ts', status: 'modified', patch: 'line\n'.repeat(15) }, + { filename: 'src/b.ts', status: 'modified', patch: 'line\n'.repeat(15) }, + { filename: 'src/c.ts', status: 'modified', patch: 'line\n'.repeat(15) }, + ]; + + const limited = buildLimitedDiff(entries, 230); + + assert.equal(limited.meta.totalFiles, 3); + assert.equal(limited.meta.truncated, true); + assert.ok(limited.meta.includedFiles < 3); + assert.ok(limited.meta.finalBytes <= 230); + assert.match(limited.diffText, /# diff-truncation-meta/); +}); + +test('JSON schema 검증 성공/실패를 구분한다', () => { + const validPayload = { + title: '사용자 조회 API 개선', + summary: '응답 필드와 예외 처리를 정리했습니다.', + changes: [ + { + area: 'user', + description: '조회 조건 검증 로직 추가', + importance: 'medium', + }, + ], + impact: { + api: 'medium', + db: 'low', + security: 'low', + performance: 'low', + operations: 'low', + tests: 'medium', + }, + checklist: ['resolver 통합 테스트 확인'], + risks: ['기존 캐시 키와 충돌 가능성 점검 필요'], + labels: ['backend', 'api'], + }; + + const validated = validateAiSummaryJson(validPayload); + assert.equal(validated.title, validPayload.title); + assert.deepEqual(validated.labels, ['backend', 'api']); + + assert.throws( + () => + validateAiSummaryJson({ + ...validPayload, + impact: { + ...validPayload.impact, + api: 'critical', + }, + }), + /invalid-impact-api-value/, + ); + + assert.throws( + () => + validateAiSummaryJson({ + ...validPayload, + unknown: 'x', + }), + /invalid-root-additional-property/, + ); +}); + +test('마커 블록이 있으면 교체하고 없으면 하단에 추가한다', () => { + const summary = { + title: 'AI 제목', + summary: '요약', + changes: [{ area: 'auth', description: '가드 수정', importance: 'high' }], + impact: { + api: 'low', + db: 'low', + security: 'medium', + performance: 'low', + operations: 'low', + tests: 'medium', + }, + checklist: ['테스트 실행'], + risks: ['권한 정책 확인'], + labels: [], + }; + + const block = renderSummaryBlock(summary, { + diffSource: 'compare', + finalBytes: 120, + excludedFilesCount: 1, + truncated: false, + assigneesAdded: ['chanwoo7'], + labelsAdded: ['backend'], + unknownLabelsIgnoredCount: 0, + }); + + const bodyWithoutMarker = '기존 본문'; + const appended = upsertSummaryBlock(bodyWithoutMarker, block); + assert.match(appended, /기존 본문/); + assert.match(appended, //); + + const bodyWithMarker = [ + '앞부분', + '', + 'old', + '', + '뒷부분', + ].join('\n'); + + const replaced = upsertSummaryBlock(bodyWithMarker, block); + assert.match(replaced, /앞부분/); + assert.match(replaced, /뒷부분/); + assert.equal((replaced.match(//g) ?? []).length, 1); + assert.equal((replaced.match(//g) ?? []).length, 1); + assert.doesNotMatch(replaced, /\nold\n/); +}); + +test('Compare API fallback 조건에서 patch 누락 1개만 있어도 fallback 된다', () => { + const files = [ + { filename: 'src/a.ts', status: 'modified', patch: '@@ -1 +1 @@' }, + { filename: 'src/b.ts', status: 'modified' }, + ]; + + const decision = decideCompareFallback({ + files, + excludeGlobs: buildExcludeGlobs(''), + maxFiles: 10, + }); + + assert.equal(decision.useFallback, true); + assert.equal(decision.reason, 'compare-missing-patch'); +}); + +test('Compare API 성공 조건이면 fallback 없이 진행한다', () => { + const files = [ + { filename: 'src/a.ts', status: 'modified', patch: '@@ -1 +1 @@' }, + { filename: 'src/b.ts', status: 'added', patch: '@@ -0,0 +1 @@' }, + ]; + + const decision = decideCompareFallback({ + files, + excludeGlobs: buildExcludeGlobs(''), + maxFiles: 10, + }); + + assert.equal(decision.useFallback, false); + assert.equal(decision.included.length, 2); +}); + +test('레포 라벨 목록 기준으로 unknown 라벨을 제거한다', () => { + const aiLabels = ['Bug', 'invalid', 'db', 'BUG', '']; + const repoLabels = ['bug', 'feature', 'db']; + + const result = filterKnownLabels(aiLabels, repoLabels); + + assert.deepEqual(result.applicableLabels, ['bug', 'db']); + assert.equal(result.unknownLabelsIgnoredCount, 2); +}); diff --git a/.github/scripts/pr-ai-description-lib.mjs b/.github/scripts/pr-ai-description-lib.mjs new file mode 100644 index 0000000..aa3c66f --- /dev/null +++ b/.github/scripts/pr-ai-description-lib.mjs @@ -0,0 +1,737 @@ +import path from 'node:path'; + +export const MARKER_START = ''; +export const MARKER_END = ''; + +export const DEFAULT_EXCLUDE_GLOBS = [ + 'yarn.lock', + 'package-lock.json', + 'pnpm-lock.yaml', + '**/*.snap', + 'dist/**', + 'coverage/**', + '**/*.map', +]; + +const IMPORTANCE_VALUES = new Set(['low', 'medium', 'high']); +const IMPACT_VALUES = new Set(['low', 'medium', 'high']); +const IMPACT_KEYS = [ + 'api', + 'db', + 'security', + 'performance', + 'operations', + 'tests', +]; + +export const AI_RESPONSE_JSON_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['title', 'summary', 'changes', 'impact', 'checklist', 'risks'], + properties: { + title: { type: 'string' }, + summary: { type: 'string' }, + changes: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['area', 'description', 'importance'], + properties: { + area: { type: 'string' }, + description: { type: 'string' }, + importance: { + type: 'string', + enum: ['low', 'medium', 'high'], + }, + }, + }, + }, + impact: { + type: 'object', + additionalProperties: false, + required: IMPACT_KEYS, + properties: { + api: { type: 'string', enum: ['low', 'medium', 'high'] }, + db: { type: 'string', enum: ['low', 'medium', 'high'] }, + security: { type: 'string', enum: ['low', 'medium', 'high'] }, + performance: { type: 'string', enum: ['low', 'medium', 'high'] }, + operations: { type: 'string', enum: ['low', 'medium', 'high'] }, + tests: { type: 'string', enum: ['low', 'medium', 'high'] }, + }, + }, + checklist: { + type: 'array', + items: { type: 'string' }, + }, + risks: { + type: 'array', + items: { type: 'string' }, + }, + labels: { + type: 'array', + items: { type: 'string' }, + }, + }, +}; + +function normalizePath(filePath) { + return filePath.replaceAll('\\\\', '/').replace(/^\.\//, ''); +} + +function escapeRegexCharacter(char) { + if (/[-/\\^$*+?.()|[\]{}]/.test(char)) { + return `\\${char}`; + } + + return char; +} + +function globToRegExp(globPattern) { + const pattern = normalizePath(globPattern.trim()); + let regexSource = '^'; + + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + + if (char === '*') { + const nextChar = pattern[index + 1]; + + if (nextChar === '*') { + const trailingSlash = pattern[index + 2] === '/'; + + if (trailingSlash) { + regexSource += '(?:.*\\/)?'; + index += 2; + } else { + regexSource += '.*'; + index += 1; + } + + continue; + } + + regexSource += '[^/]*'; + continue; + } + + if (char === '?') { + regexSource += '[^/]'; + continue; + } + + regexSource += escapeRegexCharacter(char); + } + + regexSource += '$'; + + return new RegExp(regexSource); +} + +export function parseAdditionalExcludeGlobs(rawValue) { + if (typeof rawValue !== 'string' || rawValue.trim().length === 0) { + return []; + } + + return rawValue + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +export function buildExcludeGlobs(rawValue) { + return [...DEFAULT_EXCLUDE_GLOBS, ...parseAdditionalExcludeGlobs(rawValue)]; +} + +export function createExcludeMatcher(globs) { + const patterns = globs + .map((globItem) => globItem.trim()) + .filter((globItem) => globItem.length > 0) + .map((globItem) => globToRegExp(globItem)); + + return (filePath) => { + const normalized = normalizePath(filePath); + + return patterns.some((pattern) => pattern.test(normalized)); + }; +} + +export function filterDiffFiles(files, globs) { + const isExcluded = createExcludeMatcher(globs); + const included = []; + let excludedFilesCount = 0; + + for (const file of files) { + const currentPath = typeof file.filename === 'string' ? file.filename : ''; + + if (currentPath.length === 0) { + continue; + } + + if (isExcluded(currentPath)) { + excludedFilesCount += 1; + continue; + } + + included.push({ ...file, filename: normalizePath(currentPath) }); + } + + return { + included, + excludedFilesCount, + }; +} + +export function decideCompareFallback({ files, excludeGlobs, maxFiles }) { + if (!Array.isArray(files)) { + return { + useFallback: true, + reason: 'compare-files-invalid', + included: [], + excludedFilesCount: 0, + }; + } + + if (files.length > maxFiles) { + return { + useFallback: true, + reason: 'compare-max-files-exceeded', + included: [], + excludedFilesCount: 0, + }; + } + + const { included, excludedFilesCount } = filterDiffFiles(files, excludeGlobs); + + if (included.length === 0) { + return { + useFallback: true, + reason: 'compare-no-files-after-exclusion', + included, + excludedFilesCount, + }; + } + + const missingPatchFiles = included.filter( + (file) => typeof file.patch !== 'string' || file.patch.trim().length === 0, + ); + + if (missingPatchFiles.length > 0) { + return { + useFallback: true, + reason: 'compare-missing-patch', + included, + excludedFilesCount, + }; + } + + const validPatchFiles = included.filter( + (file) => typeof file.patch === 'string' && file.patch.trim().length > 0, + ); + + if (validPatchFiles.length === 0) { + return { + useFallback: true, + reason: 'compare-no-valid-patch', + included, + excludedFilesCount, + }; + } + + return { + useFallback: false, + reason: null, + included, + excludedFilesCount, + }; +} + +export function normalizeDiffEntries(files) { + return files.map((file) => ({ + filename: file.filename, + status: file.status ?? 'modified', + previousFilename: + typeof file.previous_filename === 'string' ? file.previous_filename : undefined, + patch: + typeof file.patch === 'string' && file.patch.trim().length > 0 + ? file.patch + : '(no textual patch available)', + })); +} + +export function formatDiffEntry(entry) { + const sourceText = + typeof entry.previousFilename === 'string' && entry.previousFilename.length > 0 + ? ` (from ${entry.previousFilename})` + : ''; + + const header = `diff --file ${entry.status} ${entry.filename}${sourceText}`; + const patchText = + typeof entry.patch === 'string' && entry.patch.length > 0 + ? entry.patch.trimEnd() + : '(no textual patch available)'; + + return `${header}\n${patchText}\n`; +} + +function byteLength(text) { + return Buffer.byteLength(text, 'utf8'); +} + +function trimTextToUtf8Bytes(text, maxBytes) { + if (maxBytes <= 0) { + return ''; + } + + let result = ''; + let usedBytes = 0; + + for (const char of text) { + const charBytes = byteLength(char); + + if (usedBytes + charBytes > maxBytes) { + break; + } + + result += char; + usedBytes += charBytes; + } + + return result; +} + +function renderTruncationNotice(meta) { + return [ + '# diff-truncation-meta', + `totalFiles: ${meta.totalFiles}`, + `includedFiles: ${meta.includedFiles}`, + `omittedFiles: ${meta.omittedFiles}`, + `finalBytes: ${meta.finalBytes}`, + `truncated: ${String(meta.truncated)}`, + ].join('\n'); +} + +function composeDiff(chunks, totalFiles, forceTruncated) { + const body = chunks.join('\n'); + const truncated = forceTruncated || chunks.length < totalFiles; + + if (!truncated) { + const finalBytes = byteLength(body); + + return { + diffText: body, + meta: { + totalFiles, + includedFiles: chunks.length, + omittedFiles: totalFiles - chunks.length, + finalBytes, + truncated, + }, + }; + } + + const baseMeta = { + totalFiles, + includedFiles: chunks.length, + omittedFiles: totalFiles - chunks.length, + finalBytes: 0, + truncated: true, + }; + + let notice = renderTruncationNotice(baseMeta); + let withNotice = body.length > 0 ? `${body}\n${notice}` : notice; + const firstBytes = byteLength(withNotice); + + const finalMeta = { + ...baseMeta, + finalBytes: firstBytes, + }; + + notice = renderTruncationNotice(finalMeta); + withNotice = body.length > 0 ? `${body}\n${notice}` : notice; + + return { + diffText: withNotice, + meta: { + ...finalMeta, + finalBytes: byteLength(withNotice), + }, + }; +} + +export function buildLimitedDiff(entries, maxBytes) { + const safeMaxBytes = Number.isInteger(maxBytes) && maxBytes > 0 ? maxBytes : 102400; + const chunks = entries.map((entry) => formatDiffEntry(entry)); + const selected = []; + + for (const chunk of chunks) { + const candidate = composeDiff([...selected, chunk], chunks.length, false); + + if (candidate.meta.finalBytes > safeMaxBytes) { + break; + } + + selected.push(chunk); + } + + let composed = composeDiff(selected, chunks.length, selected.length < chunks.length); + + while (composed.meta.finalBytes > safeMaxBytes && selected.length > 0) { + selected.pop(); + composed = composeDiff(selected, chunks.length, true); + } + + if (composed.meta.finalBytes > safeMaxBytes) { + const trimmedText = trimTextToUtf8Bytes(composed.diffText, safeMaxBytes); + + return { + diffText: trimmedText, + meta: { + totalFiles: chunks.length, + includedFiles: 0, + omittedFiles: chunks.length, + finalBytes: byteLength(trimmedText), + truncated: chunks.length > 0, + }, + }; + } + + return composed; +} + +export function maskSensitiveContent(text) { + if (typeof text !== 'string' || text.length === 0) { + return ''; + } + + return text + .replaceAll(/(Authorization\s*[:=]\s*)([^\n\r]+)/gi, '$1[REDACTED]') + .replaceAll(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]') + .replaceAll( + /(\"?(?:password|secret|token|apiKey)\"?\s*[:=]\s*)\"([^\"\n\r]*)\"/gi, + '$1"[REDACTED]"', + ) + .replaceAll( + /(\"?(?:password|secret|token|apiKey)\"?\s*[:=]\s*)([^\s,\n\r]+)/gi, + '$1[REDACTED]', + ); +} + +function asNonEmptyString(value, keyName) { + if (typeof value !== 'string') { + throw new Error(`invalid-${keyName}-type`); + } + + const trimmed = value.trim(); + + if (trimmed.length === 0) { + throw new Error(`invalid-${keyName}-empty`); + } + + return trimmed; +} + +function validateStringArray(value, keyName) { + if (!Array.isArray(value)) { + throw new Error(`invalid-${keyName}-type`); + } + + return value.map((item, index) => { + if (typeof item !== 'string') { + throw new Error(`invalid-${keyName}-${index}-type`); + } + + const trimmed = item.trim(); + + if (trimmed.length === 0) { + throw new Error(`invalid-${keyName}-${index}-empty`); + } + + return trimmed; + }); +} + +export function validateAiSummaryJson(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('invalid-root-object'); + } + + const rootAllowedKeys = new Set([ + 'title', + 'summary', + 'changes', + 'impact', + 'checklist', + 'risks', + 'labels', + ]); + + for (const key of Object.keys(payload)) { + if (!rootAllowedKeys.has(key)) { + throw new Error(`invalid-root-additional-property:${key}`); + } + } + + const title = asNonEmptyString(payload.title, 'title'); + const summary = asNonEmptyString(payload.summary, 'summary'); + + if (!Array.isArray(payload.changes)) { + throw new Error('invalid-changes-type'); + } + + const changes = payload.changes.map((change, index) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) { + throw new Error(`invalid-changes-${index}-type`); + } + + const changeAllowedKeys = new Set(['area', 'description', 'importance']); + + for (const key of Object.keys(change)) { + if (!changeAllowedKeys.has(key)) { + throw new Error(`invalid-changes-${index}-additional-property:${key}`); + } + } + + const area = asNonEmptyString(change.area, `changes-${index}-area`); + const description = asNonEmptyString( + change.description, + `changes-${index}-description`, + ); + + if (typeof change.importance !== 'string') { + throw new Error(`invalid-changes-${index}-importance-type`); + } + + const importance = change.importance.trim(); + + if (!IMPORTANCE_VALUES.has(importance)) { + throw new Error(`invalid-changes-${index}-importance-value`); + } + + return { + area, + description, + importance, + }; + }); + + const impact = payload.impact; + + if (!impact || typeof impact !== 'object' || Array.isArray(impact)) { + throw new Error('invalid-impact-type'); + } + + for (const key of Object.keys(impact)) { + if (!IMPACT_KEYS.includes(key)) { + throw new Error(`invalid-impact-additional-property:${key}`); + } + } + + const normalizedImpact = {}; + + for (const key of IMPACT_KEYS) { + if (typeof impact[key] !== 'string') { + throw new Error(`invalid-impact-${key}-type`); + } + + const value = impact[key].trim(); + + if (!IMPACT_VALUES.has(value)) { + throw new Error(`invalid-impact-${key}-value`); + } + + normalizedImpact[key] = value; + } + + const checklist = validateStringArray(payload.checklist, 'checklist'); + const risks = validateStringArray(payload.risks, 'risks'); + + let labels = []; + + if (payload.labels !== undefined) { + labels = validateStringArray(payload.labels, 'labels'); + } + + return { + title, + summary, + changes, + impact: normalizedImpact, + checklist, + risks, + labels, + }; +} + +function joinList(values, fallbackValue) { + if (!Array.isArray(values) || values.length === 0) { + return fallbackValue; + } + + return values.join(', '); +} + +export function renderSummaryBlock(summary, meta) { + const changeLines = + summary.changes.length > 0 + ? summary.changes.map( + (change) => + `- [${change.importance}] ${change.area}: ${change.description}`, + ) + : ['- [low] general: 변경사항 정보가 없습니다.']; + + const checklistLines = + summary.checklist.length > 0 + ? summary.checklist.map((item) => `- ${item}`) + : ['- 없음']; + + const riskLines = + summary.risks.length > 0 + ? summary.risks.map((item) => `- ${item}`) + : ['- 없음']; + + return [ + MARKER_START, + '## AI PR 요약', + '', + '### 제목 제안', + summary.title, + '', + '### 요약', + summary.summary, + '', + '### 변경사항', + ...changeLines, + '', + '### 영향도', + `- API: ${summary.impact.api}`, + `- DB: ${summary.impact.db}`, + `- Security: ${summary.impact.security}`, + `- Performance: ${summary.impact.performance}`, + `- Operations: ${summary.impact.operations}`, + `- Tests: ${summary.impact.tests}`, + '', + '### 체크리스트', + ...checklistLines, + '', + '### 리스크', + ...riskLines, + '', + '### 메타', + `- Diff Source: ${meta.diffSource}`, + `- Diff Bytes: ${meta.finalBytes}`, + `- Excluded Files: ${meta.excludedFilesCount}`, + `- Truncated: ${String(meta.truncated)}`, + `- Assignees Added: ${joinList(meta.assigneesAdded, 'none')}`, + `- Labels Added: ${joinList(meta.labelsAdded, 'none')}`, + `- Unknown Labels Ignored: ${meta.unknownLabelsIgnoredCount}`, + MARKER_END, + ].join('\n'); +} + +export function upsertSummaryBlock(prBody, block) { + const existingBody = typeof prBody === 'string' ? prBody : ''; + const escapedStart = MARKER_START.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedEnd = MARKER_END.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const blockPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm'); + + if (blockPattern.test(existingBody)) { + return existingBody.replace(blockPattern, block); + } + + if (existingBody.trim().length === 0) { + return block; + } + + return `${existingBody.trimEnd()}\n\n${block}`; +} + +export function filterKnownLabels(aiLabels, repoLabelNames) { + const canonicalLabelMap = new Map(); + + for (const labelName of repoLabelNames) { + if (typeof labelName !== 'string') { + continue; + } + + const trimmed = labelName.trim(); + + if (trimmed.length === 0) { + continue; + } + + canonicalLabelMap.set(trimmed.toLowerCase(), trimmed); + } + + const applicableLabels = []; + const seen = new Set(); + let unknownLabelsIgnoredCount = 0; + + for (const rawLabel of aiLabels) { + if (typeof rawLabel !== 'string') { + unknownLabelsIgnoredCount += 1; + continue; + } + + const trimmed = rawLabel.trim(); + + if (trimmed.length === 0) { + unknownLabelsIgnoredCount += 1; + continue; + } + + const canonical = canonicalLabelMap.get(trimmed.toLowerCase()); + + if (!canonical) { + unknownLabelsIgnoredCount += 1; + continue; + } + + if (seen.has(canonical.toLowerCase())) { + continue; + } + + seen.add(canonical.toLowerCase()); + applicableLabels.push(canonical); + } + + return { + applicableLabels, + unknownLabelsIgnoredCount, + }; +} + +export function shouldApplyTitle({ applyTitle, aiTitle, existingTitle, labelNames }) { + if (!applyTitle) { + return false; + } + + const hasTitleLock = labelNames.some( + (labelName) => typeof labelName === 'string' && labelName.toLowerCase() === 'ai-title-lock', + ); + + if (hasTitleLock) { + return false; + } + + const normalizedAiTitle = typeof aiTitle === 'string' ? aiTitle.trim() : ''; + + if (normalizedAiTitle.length < 5) { + return false; + } + + const normalizedExistingTitle = + typeof existingTitle === 'string' ? existingTitle.trim() : ''; + + return normalizedAiTitle !== normalizedExistingTitle; +} + +export function toGitDiffFilePath(rawPath) { + const normalized = normalizePath(rawPath); + + if (normalized.length === 0) { + return normalized; + } + + return path.posix.normalize(normalized); +} diff --git a/.github/scripts/pr-ai-description.mjs b/.github/scripts/pr-ai-description.mjs new file mode 100644 index 0000000..05f3213 --- /dev/null +++ b/.github/scripts/pr-ai-description.mjs @@ -0,0 +1,847 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import process from 'node:process'; + +import { + AI_RESPONSE_JSON_SCHEMA, + buildExcludeGlobs, + buildLimitedDiff, + decideCompareFallback, + filterDiffFiles, + filterKnownLabels, + maskSensitiveContent, + normalizeDiffEntries, + renderSummaryBlock, + shouldApplyTitle, + toGitDiffFilePath, + upsertSummaryBlock, + validateAiSummaryJson, +} from './pr-ai-description-lib.mjs'; + +const TARGET_ASSIGNEE = 'chanwoo7'; +const DEFAULT_MAX_DIFF_BYTES = 102400; +const DEFAULT_MAX_FILES = 300; +const DEFAULT_OPENAI_MODEL = 'gpt-4.1-mini'; + +function logInfo(message, payload) { + if (payload === undefined) { + console.log(`[pr-ai] ${message}`); + return; + } + + console.log(`[pr-ai] ${message}`, payload); +} + +function logWarn(message, payload) { + if (payload === undefined) { + console.warn(`[pr-ai][warn] ${message}`); + return; + } + + console.warn(`[pr-ai][warn] ${message}`, payload); +} + +async function writeStepSummary(line) { + const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY; + + if (!stepSummaryPath) { + return; + } + + await fs.appendFile(stepSummaryPath, `${line}\n`, 'utf8'); +} + +function parseBooleanEnv(rawValue, defaultValue) { + if (rawValue === undefined || rawValue === null || rawValue === '') { + return defaultValue; + } + + const normalized = String(rawValue).trim().toLowerCase(); + + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + + return defaultValue; +} + +function parseIntegerEnv(rawValue, defaultValue) { + if (rawValue === undefined || rawValue === null || rawValue === '') { + return defaultValue; + } + + const parsed = Number.parseInt(String(rawValue), 10); + + if (Number.isInteger(parsed) && parsed > 0) { + return parsed; + } + + return defaultValue; +} + +function ensureEnv(name) { + const value = process.env[name]; + + if (!value || value.trim().length === 0) { + throw new Error(`missing-required-env:${name}`); + } + + return value; +} + +function parseRepository() { + const repository = ensureEnv('GITHUB_REPOSITORY'); + const [owner, repo] = repository.split('/'); + + if (!owner || !repo) { + throw new Error(`invalid-github-repository:${repository}`); + } + + return { + owner, + repo, + }; +} + +function createGitHubRequest({ githubToken }) { + const apiBaseUrl = process.env.GITHUB_API_URL ?? 'https://api.github.com'; + + return async function githubRequest(method, routePath, options = {}) { + const controller = new AbortController(); + const timeoutMs = options.timeoutMs ?? 15000; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${apiBaseUrl}${routePath}`, { + method, + headers: { + Authorization: `Bearer ${githubToken}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'User-Agent': 'caquick-pr-ai-description', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }); + + if (response.status === 204) { + return null; + } + + const rawText = await response.text(); + let data = null; + + if (rawText.length > 0) { + try { + data = JSON.parse(rawText); + } catch { + data = { raw: rawText }; + } + } + + if (!response.ok) { + const error = new Error(`github-api-error:${response.status}:${routePath}`); + error.status = response.status; + error.response = data; + throw error; + } + + return data; + } catch (error) { + if (error.name === 'AbortError') { + const timeoutError = new Error(`github-api-timeout:${routePath}`); + timeoutError.status = 408; + throw timeoutError; + } + + throw error; + } finally { + clearTimeout(timeoutId); + } + }; +} + +function isPermissionError(error) { + const status = typeof error?.status === 'number' ? error.status : 0; + + return status === 401 || status === 403; +} + +async function readEventPayload() { + const eventPath = ensureEnv('GITHUB_EVENT_PATH'); + const raw = await fs.readFile(eventPath, 'utf8'); + + return JSON.parse(raw); +} + +async function fetchRepositoryLabels(githubRequest, owner, repo) { + const labels = []; + let page = 1; + + while (true) { + const response = await githubRequest( + 'GET', + `/repos/${owner}/${repo}/labels?per_page=100&page=${page}`, + ); + + if (!Array.isArray(response) || response.length === 0) { + break; + } + + for (const label of response) { + if (label && typeof label.name === 'string') { + labels.push(label.name); + } + } + + if (response.length < 100) { + break; + } + + page += 1; + } + + return labels; +} + +async function tryCompareDiff({ + githubRequest, + owner, + repo, + baseSha, + headSha, + excludeGlobs, + maxFiles, +}) { + try { + const compare = await githubRequest( + 'GET', + `/repos/${owner}/${repo}/compare/${baseSha}...${headSha}`, + { + timeoutMs: 20000, + }, + ); + + const files = Array.isArray(compare?.files) ? compare.files : []; + const decision = decideCompareFallback({ + files, + excludeGlobs, + maxFiles, + }); + + if (decision.useFallback) { + return { + useFallback: true, + reason: decision.reason, + entries: [], + excludedFilesCount: decision.excludedFilesCount, + }; + } + + return { + useFallback: false, + reason: null, + entries: normalizeDiffEntries(decision.included), + excludedFilesCount: decision.excludedFilesCount, + }; + } catch (error) { + return { + useFallback: true, + reason: `compare-api-error:${error.message}`, + entries: [], + excludedFilesCount: 0, + }; + } +} + +function runGitCommand(args) { + return execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function mapGitStatus(rawStatus) { + if (rawStatus.startsWith('R')) { + return 'renamed'; + } + + if (rawStatus.startsWith('A')) { + return 'added'; + } + + if (rawStatus.startsWith('D')) { + return 'removed'; + } + + if (rawStatus.startsWith('M')) { + return 'modified'; + } + + return 'modified'; +} + +function parseNameStatus(nameStatusText) { + const rows = nameStatusText + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const files = []; + + for (const row of rows) { + const parts = row.split('\t'); + + if (parts.length < 2) { + continue; + } + + const rawStatus = parts[0]; + + if (rawStatus.startsWith('R') && parts.length >= 3) { + files.push({ + status: mapGitStatus(rawStatus), + previous_filename: parts[1], + filename: parts[2], + }); + continue; + } + + files.push({ + status: mapGitStatus(rawStatus), + filename: parts[1], + }); + } + + return files; +} + +function collectDiffFromGit({ + baseSha, + headSha, + excludeGlobs, + maxFiles, +}) { + runGitCommand([ + 'fetch', + '--no-tags', + '--prune', + '--depth=1', + 'origin', + baseSha, + headSha, + ]); + + const range = `${baseSha}...${headSha}`; + const nameStatus = runGitCommand([ + 'diff', + '--no-color', + '--diff-algorithm=histogram', + '--name-status', + range, + ]); + + const parsedFiles = parseNameStatus(nameStatus); + + if (parsedFiles.length > maxFiles) { + throw new Error('git-diff-max-files-exceeded'); + } + + const { included, excludedFilesCount } = filterDiffFiles(parsedFiles, excludeGlobs); + + if (included.length === 0) { + throw new Error('git-diff-no-files-after-exclusion'); + } + + const entries = included.map((file) => { + const patch = runGitCommand([ + 'diff', + '--no-color', + '--diff-algorithm=histogram', + range, + '--', + toGitDiffFilePath(file.filename), + ]); + + return { + ...file, + patch, + }; + }); + + return { + entries: normalizeDiffEntries(entries), + excludedFilesCount, + }; +} + +function buildOpenAiPrompt({ + pr, + repositoryLabels, + diffText, +}) { + const prMeta = { + number: pr.number, + title: pr.title, + author: pr.user?.login ?? 'unknown', + baseRef: pr.base?.ref ?? 'unknown', + headRef: pr.head?.ref ?? 'unknown', + commits: pr.commits ?? 0, + changedFiles: pr.changed_files ?? 0, + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + }; + + return [ + '다음 Pull Request 정보를 기반으로 한국어 PR 요약 JSON을 생성하세요.', + '코드 식별자/파일 경로/에러 메시지는 원문을 유지하세요.', + 'labels는 아래 제공된 레포 라벨 목록에서만 선택하세요.', + '', + `PR Meta:\n${JSON.stringify(prMeta, null, 2)}`, + '', + `Repository Labels:\n${JSON.stringify(repositoryLabels, null, 2)}`, + '', + `Diff:\n${diffText}`, + ].join('\n'); +} + +function extractChatCompletionContent(responseData) { + const content = responseData?.choices?.[0]?.message?.content; + + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + return content + .map((item) => { + if (typeof item?.text === 'string') { + return item.text; + } + + return ''; + }) + .join(''); + } + + return ''; +} + +async function requestOpenAiSummary({ + openAiApiKey, + model, + prompt, +}) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 45000); + + try { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${openAiApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + temperature: 0.2, + messages: [ + { + role: 'system', + content: + 'You are a senior backend engineer. Return only JSON that matches the schema.', + }, + { + role: 'user', + content: prompt, + }, + ], + response_format: { + type: 'json_schema', + json_schema: { + name: 'pr_ai_summary', + strict: true, + schema: AI_RESPONSE_JSON_SCHEMA, + }, + }, + }), + signal: controller.signal, + }); + + if (!response.ok) { + const rawBody = await response.text(); + const error = new Error(`openai-api-error:${response.status}`); + error.response = rawBody; + throw error; + } + + const responseData = await response.json(); + const content = extractChatCompletionContent(responseData); + + if (!content || content.trim().length === 0) { + throw new Error('openai-empty-response'); + } + + let parsed; + + try { + parsed = JSON.parse(content); + } catch { + throw new Error('openai-json-parse-failed'); + } + + try { + return validateAiSummaryJson(parsed); + } catch (error) { + const validationError = new Error(`openai-schema-validation-failed:${error.message}`); + validationError.cause = error; + throw validationError; + } + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('openai-timeout'); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +async function patchPullRequest({ + githubRequest, + owner, + repo, + number, + title, + body, +}) { + const payload = { + body, + }; + + if (typeof title === 'string') { + payload.title = title; + } + + return githubRequest('PATCH', `/repos/${owner}/${repo}/pulls/${number}`, { + body: payload, + }); +} + +async function addAssignee({ + githubRequest, + owner, + repo, + number, + currentAssignees, +}) { + const normalizedAssignees = currentAssignees.map((assignee) => assignee.toLowerCase()); + + if (normalizedAssignees.includes(TARGET_ASSIGNEE.toLowerCase())) { + return []; + } + + try { + await githubRequest('POST', `/repos/${owner}/${repo}/issues/${number}/assignees`, { + body: { assignees: [TARGET_ASSIGNEE] }, + }); + + return [TARGET_ASSIGNEE]; + } catch (error) { + if (isPermissionError(error)) { + logWarn('assignee update skipped due to permission issue', { + status: error.status, + }); + await writeStepSummary( + '- Assignee update skipped (permission issue on same-repo PR).', + ); + + return []; + } + + throw error; + } +} + +async function addLabels({ + githubRequest, + owner, + repo, + number, + labelsToAdd, +}) { + if (labelsToAdd.length === 0) { + return []; + } + + try { + await githubRequest('POST', `/repos/${owner}/${repo}/issues/${number}/labels`, { + body: { labels: labelsToAdd }, + }); + + return labelsToAdd; + } catch (error) { + if (isPermissionError(error)) { + logWarn('label update skipped due to permission issue', { + status: error.status, + }); + await writeStepSummary( + '- Label update skipped (permission issue on same-repo PR).', + ); + + return []; + } + + throw error; + } +} + +function uniqueStringList(values) { + const result = []; + const seen = new Set(); + + for (const value of values) { + if (typeof value !== 'string') { + continue; + } + + const trimmed = value.trim(); + + if (trimmed.length === 0) { + continue; + } + + const key = trimmed.toLowerCase(); + + if (seen.has(key)) { + continue; + } + + seen.add(key); + result.push(trimmed); + } + + return result; +} + +async function run() { + const githubToken = ensureEnv('GITHUB_TOKEN'); + const openAiApiKey = ensureEnv('OPENAI_API_KEY'); + const openAiModel = process.env.OPENAI_MODEL || DEFAULT_OPENAI_MODEL; + const maxDiffBytes = parseIntegerEnv( + process.env.PR_AI_MAX_DIFF_BYTES, + DEFAULT_MAX_DIFF_BYTES, + ); + const maxFiles = parseIntegerEnv(process.env.PR_AI_MAX_FILES, DEFAULT_MAX_FILES); + const applyTitle = parseBooleanEnv(process.env.PR_AI_APPLY_TITLE, true); + const excludeGlobs = buildExcludeGlobs(process.env.PR_AI_EXCLUDE_GLOBS); + + const payload = await readEventPayload(); + const pullRequest = payload.pull_request; + + if (!pullRequest) { + throw new Error('pull_request payload not found'); + } + + const isFork = pullRequest.head?.repo?.full_name !== payload.repository?.full_name; + + if (isFork) { + logInfo('fork PR detected. skip by policy.'); + await writeStepSummary('- Fork PR detected: skipped by policy.'); + return; + } + + const { owner, repo } = parseRepository(); + const githubRequest = createGitHubRequest({ githubToken }); + const prNumber = pullRequest.number; + const baseSha = pullRequest.base?.sha; + const headSha = pullRequest.head?.sha; + + if (typeof baseSha !== 'string' || typeof headSha !== 'string') { + throw new Error('base/head sha missing from payload'); + } + + let repositoryLabels = []; + + try { + repositoryLabels = await fetchRepositoryLabels(githubRequest, owner, repo); + } catch (error) { + if (isPermissionError(error)) { + logWarn('failed to read repository labels due to permission issue', { + status: error.status, + }); + await writeStepSummary( + '- Repository labels could not be loaded (permission issue).', + ); + repositoryLabels = []; + } else { + throw error; + } + } + + const compareResult = await tryCompareDiff({ + githubRequest, + owner, + repo, + baseSha, + headSha, + excludeGlobs, + maxFiles, + }); + + let diffSource = 'compare'; + let diffEntries = compareResult.entries; + let excludedFilesCount = compareResult.excludedFilesCount; + + if (compareResult.useFallback) { + logWarn('compare diff unavailable. fallback to git diff.', { + reason: compareResult.reason, + }); + + const gitResult = collectDiffFromGit({ + baseSha, + headSha, + excludeGlobs, + maxFiles, + }); + + diffSource = 'git'; + diffEntries = gitResult.entries; + excludedFilesCount = gitResult.excludedFilesCount; + } + + if (diffEntries.length === 0) { + throw new Error('no-diff-entries-for-ai'); + } + + const maskedEntries = diffEntries.map((entry) => ({ + ...entry, + patch: maskSensitiveContent(entry.patch), + })); + + const limitedDiff = buildLimitedDiff(maskedEntries, maxDiffBytes); + const maskedDiff = limitedDiff.diffText; + + if (maskedDiff.trim().length === 0) { + throw new Error('masked-diff-is-empty'); + } + + const prompt = buildOpenAiPrompt({ + pr: pullRequest, + repositoryLabels, + diffText: maskedDiff, + }); + + const aiSummary = await requestOpenAiSummary({ + openAiApiKey, + model: openAiModel, + prompt, + }); + + const currentAssignees = uniqueStringList( + Array.isArray(pullRequest.assignees) + ? pullRequest.assignees.map((assignee) => assignee?.login) + : [], + ); + + const currentLabels = uniqueStringList( + Array.isArray(pullRequest.labels) + ? pullRequest.labels.map((label) => label?.name) + : [], + ); + + const assigneesAdded = await addAssignee({ + githubRequest, + owner, + repo, + number: prNumber, + currentAssignees, + }); + + const aiLabelCandidates = uniqueStringList(aiSummary.labels); + const { applicableLabels, unknownLabelsIgnoredCount } = filterKnownLabels( + aiLabelCandidates, + repositoryLabels, + ); + + const labelsToAdd = applicableLabels.filter( + (labelName) => !currentLabels.some((current) => current.toLowerCase() === labelName.toLowerCase()), + ); + + const labelsAdded = await addLabels({ + githubRequest, + owner, + repo, + number: prNumber, + labelsToAdd, + }); + + const block = renderSummaryBlock(aiSummary, { + diffSource, + finalBytes: limitedDiff.meta.finalBytes, + excludedFilesCount, + truncated: limitedDiff.meta.truncated, + assigneesAdded, + labelsAdded, + unknownLabelsIgnoredCount, + }); + + const updatedBody = upsertSummaryBlock(pullRequest.body ?? '', block); + + const titleShouldChange = shouldApplyTitle({ + applyTitle, + aiTitle: aiSummary.title, + existingTitle: pullRequest.title, + labelNames: currentLabels, + }); + + const nextTitle = titleShouldChange ? aiSummary.title : undefined; + + if (updatedBody !== (pullRequest.body ?? '') || typeof nextTitle === 'string') { + await patchPullRequest({ + githubRequest, + owner, + repo, + number: prNumber, + title: nextTitle, + body: updatedBody, + }); + } + + logInfo('PR AI description update completed', { + diffSource, + finalBytes: limitedDiff.meta.finalBytes, + excludedFilesCount, + truncated: limitedDiff.meta.truncated, + labelsAppliedCount: labelsAdded.length, + unknownLabelsIgnoredCount, + }); + + await writeStepSummary('## PR AI Summary Result'); + await writeStepSummary(`- Diff Source: ${diffSource}`); + await writeStepSummary(`- Diff Bytes: ${limitedDiff.meta.finalBytes}`); + await writeStepSummary(`- Excluded Files: ${excludedFilesCount}`); + await writeStepSummary(`- Truncated: ${String(limitedDiff.meta.truncated)}`); + await writeStepSummary(`- Labels Added: ${labelsAdded.join(', ') || 'none'}`); + await writeStepSummary( + `- Unknown Labels Ignored: ${unknownLabelsIgnoredCount}`, + ); +} + +run().catch(async (error) => { + logWarn('PR AI description workflow failed', { + message: error?.message ?? 'unknown-error', + status: error?.status, + }); + + await writeStepSummary('## PR AI Summary Failed'); + await writeStepSummary(`- Error: ${error?.message ?? 'unknown-error'}`); + + process.exit(1); +}); diff --git a/.github/workflows/pr-ai-description.yml b/.github/workflows/pr-ai-description.yml new file mode 100644 index 0000000..c4f96c6 --- /dev/null +++ b/.github/workflows/pr-ai-description.yml @@ -0,0 +1,45 @@ +name: PR AI Description + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + +concurrency: + group: pr-ai-description-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pr-ai-description: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js (24.x) + uses: actions/setup-node@v4 + with: + node-version: '24.x' + + - name: Run PR AI helper unit tests + run: node --test .github/scripts/__tests__/*.spec.mjs + + - name: Generate AI PR description + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} + PR_AI_MAX_DIFF_BYTES: ${{ vars.PR_AI_MAX_DIFF_BYTES }} + PR_AI_APPLY_TITLE: ${{ vars.PR_AI_APPLY_TITLE }} + PR_AI_EXCLUDE_GLOBS: ${{ vars.PR_AI_EXCLUDE_GLOBS }} + PR_AI_MAX_FILES: ${{ vars.PR_AI_MAX_FILES }} + run: node .github/scripts/pr-ai-description.mjs From 1ce444ae087442fefe6bede313cc92263d61cf49 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 4 Mar 2026 20:26:12 +0900 Subject: [PATCH 02/13] =?UTF-8?q?ci:=20PR=20AI=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=97=90=20LangSmith=20=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=8B=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/langsmith-tracer.spec.mjs | 156 ++++++ .github/scripts/langsmith-tracer.mjs | 236 +++++++++ .github/scripts/pr-ai-description.mjs | 466 ++++++++++++------ .github/workflows/pr-ai-description.yml | 5 + 4 files changed, 705 insertions(+), 158 deletions(-) create mode 100644 .github/scripts/__tests__/langsmith-tracer.spec.mjs create mode 100644 .github/scripts/langsmith-tracer.mjs diff --git a/.github/scripts/__tests__/langsmith-tracer.spec.mjs b/.github/scripts/__tests__/langsmith-tracer.spec.mjs new file mode 100644 index 0000000..f4785c7 --- /dev/null +++ b/.github/scripts/__tests__/langsmith-tracer.spec.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createLangSmithTracer, + resolveLangSmithTraceConfig, +} from '../langsmith-tracer.mjs'; + +function createFetchRecorder() { + const calls = []; + + const fetchImpl = async (url, options = {}) => { + let body = null; + + if (typeof options.body === 'string' && options.body.length > 0) { + body = JSON.parse(options.body); + } + + calls.push({ + url, + options, + body, + }); + + return { + ok: true, + status: 200, + async text() { + return ''; + }, + async json() { + return {}; + }, + }; + }; + + return { + calls, + fetchImpl, + }; +} + +test('LANGSMITH_TRACING=false 이면 tracing 설정이 비활성화된다', () => { + const config = resolveLangSmithTraceConfig({ + LANGSMITH_TRACING: 'false', + LANGSMITH_API_KEY: 'lsv2_xxx', + }); + + assert.equal(config.enabled, false); + assert.equal(config.reason, 'langsmith-tracing-disabled'); +}); + +test('API KEY가 없으면 tracing 설정이 비활성화된다', () => { + const config = resolveLangSmithTraceConfig({ + LANGSMITH_TRACING: 'true', + }); + + assert.equal(config.enabled, false); + assert.equal(config.reason, 'langsmith-api-key-missing'); +}); + +test('설정값이 없으면 endpoint/project 기본값을 사용한다', () => { + const config = resolveLangSmithTraceConfig({ + LANGSMITH_API_KEY: 'lsv2_xxx', + }); + + assert.equal(config.enabled, true); + assert.equal(config.endpoint, 'https://api.smith.langchain.com'); + assert.equal(config.projectName, 'caquick-pr-ai-description'); + assert.equal(config.workspaceId, null); +}); + +test('withRun 성공 시 /runs POST 후 PATCH가 호출된다', async () => { + const { calls, fetchImpl } = createFetchRecorder(); + const tracer = createLangSmithTracer({ + env: { + LANGSMITH_API_KEY: 'lsv2_xxx', + LANGSMITH_PROJECT: 'caquick-ci', + }, + fetchImpl, + }); + + const result = await tracer.withRun( + { + name: 'openai-summary', + runType: 'llm', + inputs: { model: 'gpt-4.1-mini' }, + mapOutput: (value) => ({ + title: value.title, + }), + }, + async () => ({ + title: '요약 제목', + longText: '생략', + }), + ); + + assert.equal(result.title, '요약 제목'); + assert.equal(calls.length, 2); + assert.equal(calls[0].url, 'https://api.smith.langchain.com/runs'); + assert.equal(calls[0].body.session_name, 'caquick-ci'); + assert.equal(calls[0].body.run_type, 'llm'); + + const runId = calls[0].body.id; + assert.ok(typeof runId === 'string' && runId.length > 0); + assert.equal(calls[1].url, `https://api.smith.langchain.com/runs/${runId}`); + assert.equal(calls[1].body.outputs.title, '요약 제목'); +}); + +test('withRun 실패 시 에러를 PATCH로 기록하고 예외를 다시 던진다', async () => { + const { calls, fetchImpl } = createFetchRecorder(); + const tracer = createLangSmithTracer({ + env: { + LANGSMITH_API_KEY: 'lsv2_xxx', + }, + fetchImpl, + }); + + await assert.rejects( + () => + tracer.withRun( + { + name: 'fail-step', + runType: 'tool', + inputs: { stage: 'patch-pr' }, + }, + async () => { + throw new Error('intentional-failure'); + }, + ), + /intentional-failure/, + ); + + assert.equal(calls.length, 2); + assert.equal(calls[1].body.outputs.constructor, Object); + assert.match(calls[1].body.error, /intentional-failure/); +}); + +test('workspace id가 있으면 x-tenant-id 헤더가 포함된다', async () => { + const { calls, fetchImpl } = createFetchRecorder(); + const tracer = createLangSmithTracer({ + env: { + LANGSMITH_API_KEY: 'lsv2_xxx', + LANGSMITH_WORKSPACE_ID: 'workspace-123', + }, + fetchImpl, + }); + + await tracer.startRun({ + name: 'root-run', + runType: 'chain', + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].options.headers['x-tenant-id'], 'workspace-123'); +}); diff --git a/.github/scripts/langsmith-tracer.mjs b/.github/scripts/langsmith-tracer.mjs new file mode 100644 index 0000000..846dbbd --- /dev/null +++ b/.github/scripts/langsmith-tracer.mjs @@ -0,0 +1,236 @@ +import { randomUUID } from 'node:crypto'; +import process from 'node:process'; + +const DEFAULT_LANGSMITH_ENDPOINT = 'https://api.smith.langchain.com'; +const DEFAULT_LANGSMITH_PROJECT = 'caquick-pr-ai-description'; +const REQUEST_TIMEOUT_MS = 10000; + +function parseBoolean(rawValue, defaultValue) { + if (rawValue === undefined || rawValue === null || rawValue === '') { + return defaultValue; + } + + const normalized = String(rawValue).trim().toLowerCase(); + + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + + return defaultValue; +} + +function toOptionalString(rawValue) { + if (typeof rawValue !== 'string') { + return null; + } + + const trimmed = rawValue.trim(); + + if (trimmed.length === 0) { + return null; + } + + return trimmed; +} + +function nowIsoString() { + return new Date().toISOString(); +} + +function toTraceError(error) { + if (!error) { + return 'unknown-error'; + } + + const message = + typeof error.message === 'string' && error.message.trim().length > 0 + ? error.message.trim() + : 'unknown-error'; + + if (typeof error.stack === 'string' && error.stack.trim().length > 0) { + return `${message}\n${error.stack.slice(0, 2000)}`; + } + + return message; +} + +export function resolveLangSmithTraceConfig(env = process.env) { + const tracingEnabled = parseBoolean(env.LANGSMITH_TRACING, true); + + if (!tracingEnabled) { + return { + enabled: false, + reason: 'langsmith-tracing-disabled', + }; + } + + const apiKey = toOptionalString(env.LANGSMITH_API_KEY); + + if (!apiKey) { + return { + enabled: false, + reason: 'langsmith-api-key-missing', + }; + } + + const endpoint = + (toOptionalString(env.LANGSMITH_ENDPOINT) ?? DEFAULT_LANGSMITH_ENDPOINT).replace( + /\/+$/, + '', + ); + const projectName = toOptionalString(env.LANGSMITH_PROJECT) ?? DEFAULT_LANGSMITH_PROJECT; + const workspaceId = toOptionalString(env.LANGSMITH_WORKSPACE_ID); + + return { + enabled: true, + reason: null, + apiKey, + endpoint, + projectName, + workspaceId: workspaceId ?? null, + }; +} + +export function createLangSmithTracer({ + env = process.env, + fetchImpl = fetch, + logger, +} = {}) { + const config = resolveLangSmithTraceConfig(env); + const log = typeof logger === 'function' ? logger : () => {}; + + async function requestLangSmith(method, path, payload) { + if (!config.enabled) { + return null; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const headers = { + 'Content-Type': 'application/json', + 'x-api-key': config.apiKey, + }; + + if (config.workspaceId) { + headers['x-tenant-id'] = config.workspaceId; + } + + try { + const response = await fetchImpl(`${config.endpoint}${path}`, { + method, + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + const rawBody = await response.text(); + throw new Error( + `langsmith-api-error:${response.status}:${path}:${rawBody.slice(0, 200)}`, + ); + } + + return null; + } catch (error) { + if (error.name === 'AbortError') { + log('warn', 'langsmith request timed out', { method, path }); + } else { + log('warn', 'langsmith request failed', { + method, + path, + error: error?.message ?? 'unknown-error', + }); + } + + return null; + } finally { + clearTimeout(timeoutId); + } + } + + async function startRun({ name, runType = 'chain', inputs = {}, parentRunId, extra } = {}) { + if (!config.enabled) { + return null; + } + + const runId = randomUUID(); + const payload = { + id: runId, + name: typeof name === 'string' && name.trim().length > 0 ? name.trim() : 'unnamed-run', + run_type: runType, + inputs, + start_time: nowIsoString(), + session_name: config.projectName, + }; + + if (typeof parentRunId === 'string' && parentRunId.trim().length > 0) { + payload.parent_run_id = parentRunId; + } + + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + payload.extra = extra; + } + + await requestLangSmith('POST', '/runs', payload); + + return { + id: runId, + name: payload.name, + runType, + }; + } + + async function endRun(run, outputs = {}) { + if (!run || typeof run.id !== 'string') { + return; + } + + await requestLangSmith('PATCH', `/runs/${run.id}`, { + outputs, + end_time: nowIsoString(), + }); + } + + async function failRun(run, error, outputs = {}) { + if (!run || typeof run.id !== 'string') { + return; + } + + await requestLangSmith('PATCH', `/runs/${run.id}`, { + outputs, + error: toTraceError(error), + end_time: nowIsoString(), + }); + } + + async function withRun(options, execute) { + const { mapOutput, ...runOptions } = options ?? {}; + const run = await startRun(runOptions); + + try { + const value = await execute(); + const outputs = typeof mapOutput === 'function' ? mapOutput(value) : value; + await endRun(run, outputs); + return value; + } catch (error) { + await failRun(run, error); + throw error; + } + } + + return { + config, + isEnabled() { + return config.enabled; + }, + reason: config.reason ?? null, + startRun, + endRun, + failRun, + withRun, + }; +} diff --git a/.github/scripts/pr-ai-description.mjs b/.github/scripts/pr-ai-description.mjs index 05f3213..8388420 100644 --- a/.github/scripts/pr-ai-description.mjs +++ b/.github/scripts/pr-ai-description.mjs @@ -17,6 +17,7 @@ import { upsertSummaryBlock, validateAiSummaryJson, } from './pr-ai-description-lib.mjs'; +import { createLangSmithTracer } from './langsmith-tracer.mjs'; const TARGET_ASSIGNEE = 'chanwoo7'; const DEFAULT_MAX_DIFF_BYTES = 102400; @@ -630,6 +631,14 @@ function uniqueStringList(values) { return result; } +function shortenSha(sha) { + if (typeof sha !== 'string') { + return 'unknown'; + } + + return sha.slice(0, 12); +} + async function run() { const githubToken = ensureEnv('GITHUB_TOKEN'); const openAiApiKey = ensureEnv('OPENAI_API_KEY'); @@ -641,197 +650,338 @@ async function run() { const maxFiles = parseIntegerEnv(process.env.PR_AI_MAX_FILES, DEFAULT_MAX_FILES); const applyTitle = parseBooleanEnv(process.env.PR_AI_APPLY_TITLE, true); const excludeGlobs = buildExcludeGlobs(process.env.PR_AI_EXCLUDE_GLOBS); + const tracer = createLangSmithTracer({ + logger: (level, message, payload) => { + if (level === 'warn') { + logWarn(message, payload); + return; + } - const payload = await readEventPayload(); - const pullRequest = payload.pull_request; + logInfo(message, payload); + }, + }); - if (!pullRequest) { - throw new Error('pull_request payload not found'); + if (tracer.isEnabled()) { + logInfo('LangSmith tracing enabled', { + endpoint: tracer.config.endpoint, + projectName: tracer.config.projectName, + }); + } else { + logInfo('LangSmith tracing disabled', { + reason: tracer.reason, + }); } - const isFork = pullRequest.head?.repo?.full_name !== payload.repository?.full_name; - - if (isFork) { - logInfo('fork PR detected. skip by policy.'); - await writeStepSummary('- Fork PR detected: skipped by policy.'); - return; - } + const workflowRun = await tracer.startRun({ + name: 'pr-ai-description', + runType: 'chain', + inputs: { + repository: process.env.GITHUB_REPOSITORY ?? 'unknown', + eventName: process.env.GITHUB_EVENT_NAME ?? 'unknown', + actor: process.env.GITHUB_ACTOR ?? 'unknown', + model: openAiModel, + maxDiffBytes, + maxFiles, + applyTitle, + }, + }); - const { owner, repo } = parseRepository(); - const githubRequest = createGitHubRequest({ githubToken }); - const prNumber = pullRequest.number; - const baseSha = pullRequest.base?.sha; - const headSha = pullRequest.head?.sha; + try { + const payload = await readEventPayload(); + const pullRequest = payload.pull_request; - if (typeof baseSha !== 'string' || typeof headSha !== 'string') { - throw new Error('base/head sha missing from payload'); - } + if (!pullRequest) { + throw new Error('pull_request payload not found'); + } - let repositoryLabels = []; + const isFork = pullRequest.head?.repo?.full_name !== payload.repository?.full_name; - try { - repositoryLabels = await fetchRepositoryLabels(githubRequest, owner, repo); - } catch (error) { - if (isPermissionError(error)) { - logWarn('failed to read repository labels due to permission issue', { - status: error.status, + if (isFork) { + logInfo('fork PR detected. skip by policy.'); + await writeStepSummary('- Fork PR detected: skipped by policy.'); + await tracer.endRun(workflowRun, { + status: 'skipped', + reason: 'fork-pr', }); - await writeStepSummary( - '- Repository labels could not be loaded (permission issue).', - ); - repositoryLabels = []; - } else { - throw error; + return; } - } - const compareResult = await tryCompareDiff({ - githubRequest, - owner, - repo, - baseSha, - headSha, - excludeGlobs, - maxFiles, - }); + const { owner, repo } = parseRepository(); + const githubRequest = createGitHubRequest({ githubToken }); + const prNumber = pullRequest.number; + const baseSha = pullRequest.base?.sha; + const headSha = pullRequest.head?.sha; - let diffSource = 'compare'; - let diffEntries = compareResult.entries; - let excludedFilesCount = compareResult.excludedFilesCount; - - if (compareResult.useFallback) { - logWarn('compare diff unavailable. fallback to git diff.', { - reason: compareResult.reason, - }); + if (typeof baseSha !== 'string' || typeof headSha !== 'string') { + throw new Error('base/head sha missing from payload'); + } - const gitResult = collectDiffFromGit({ - baseSha, - headSha, - excludeGlobs, - maxFiles, - }); + let repositoryLabels = []; - diffSource = 'git'; - diffEntries = gitResult.entries; - excludedFilesCount = gitResult.excludedFilesCount; - } + try { + repositoryLabels = await fetchRepositoryLabels(githubRequest, owner, repo); + } catch (error) { + if (isPermissionError(error)) { + logWarn('failed to read repository labels due to permission issue', { + status: error.status, + }); + await writeStepSummary( + '- Repository labels could not be loaded (permission issue).', + ); + repositoryLabels = []; + } else { + throw error; + } + } - if (diffEntries.length === 0) { - throw new Error('no-diff-entries-for-ai'); - } + const diffContext = await tracer.withRun( + { + name: 'collect-diff', + runType: 'chain', + parentRunId: workflowRun?.id, + inputs: { + baseSha: shortenSha(baseSha), + headSha: shortenSha(headSha), + maxFiles, + excludeGlobsCount: excludeGlobs.length, + }, + mapOutput: (value) => ({ + diffSource: value.diffSource, + diffEntriesCount: value.diffEntries.length, + excludedFilesCount: value.excludedFilesCount, + fallbackReason: value.fallbackReason ?? 'none', + }), + }, + async () => { + const compareResult = await tryCompareDiff({ + githubRequest, + owner, + repo, + baseSha, + headSha, + excludeGlobs, + maxFiles, + }); + + let diffSource = 'compare'; + let diffEntries = compareResult.entries; + let excludedFilesCount = compareResult.excludedFilesCount; + let fallbackReason = null; + + if (compareResult.useFallback) { + logWarn('compare diff unavailable. fallback to git diff.', { + reason: compareResult.reason, + }); + + const gitResult = collectDiffFromGit({ + baseSha, + headSha, + excludeGlobs, + maxFiles, + }); + + diffSource = 'git'; + diffEntries = gitResult.entries; + excludedFilesCount = gitResult.excludedFilesCount; + fallbackReason = compareResult.reason; + } - const maskedEntries = diffEntries.map((entry) => ({ - ...entry, - patch: maskSensitiveContent(entry.patch), - })); + return { + diffSource, + diffEntries, + excludedFilesCount, + fallbackReason, + }; + }, + ); - const limitedDiff = buildLimitedDiff(maskedEntries, maxDiffBytes); - const maskedDiff = limitedDiff.diffText; + const { diffSource, diffEntries, excludedFilesCount } = diffContext; - if (maskedDiff.trim().length === 0) { - throw new Error('masked-diff-is-empty'); - } + if (diffEntries.length === 0) { + throw new Error('no-diff-entries-for-ai'); + } - const prompt = buildOpenAiPrompt({ - pr: pullRequest, - repositoryLabels, - diffText: maskedDiff, - }); + const maskedEntries = diffEntries.map((entry) => ({ + ...entry, + patch: maskSensitiveContent(entry.patch), + })); - const aiSummary = await requestOpenAiSummary({ - openAiApiKey, - model: openAiModel, - prompt, - }); + const limitedDiff = buildLimitedDiff(maskedEntries, maxDiffBytes); + const maskedDiff = limitedDiff.diffText; - const currentAssignees = uniqueStringList( - Array.isArray(pullRequest.assignees) - ? pullRequest.assignees.map((assignee) => assignee?.login) - : [], - ); + if (maskedDiff.trim().length === 0) { + throw new Error('masked-diff-is-empty'); + } - const currentLabels = uniqueStringList( - Array.isArray(pullRequest.labels) - ? pullRequest.labels.map((label) => label?.name) - : [], - ); + const prompt = buildOpenAiPrompt({ + pr: pullRequest, + repositoryLabels, + diffText: maskedDiff, + }); - const assigneesAdded = await addAssignee({ - githubRequest, - owner, - repo, - number: prNumber, - currentAssignees, - }); + const aiSummary = await tracer.withRun( + { + name: 'generate-ai-summary', + runType: 'llm', + parentRunId: workflowRun?.id, + inputs: { + model: openAiModel, + diffSource, + diffBytes: limitedDiff.meta.finalBytes, + truncated: limitedDiff.meta.truncated, + }, + mapOutput: (summary) => ({ + title: summary.title, + labels: summary.labels, + changesCount: summary.changes.length, + checklistCount: summary.checklist.length, + risksCount: summary.risks.length, + }), + }, + async () => + requestOpenAiSummary({ + openAiApiKey, + model: openAiModel, + prompt, + }), + ); - const aiLabelCandidates = uniqueStringList(aiSummary.labels); - const { applicableLabels, unknownLabelsIgnoredCount } = filterKnownLabels( - aiLabelCandidates, - repositoryLabels, - ); + const currentAssignees = uniqueStringList( + Array.isArray(pullRequest.assignees) + ? pullRequest.assignees.map((assignee) => assignee?.login) + : [], + ); - const labelsToAdd = applicableLabels.filter( - (labelName) => !currentLabels.some((current) => current.toLowerCase() === labelName.toLowerCase()), - ); + const currentLabels = uniqueStringList( + Array.isArray(pullRequest.labels) + ? pullRequest.labels.map((label) => label?.name) + : [], + ); - const labelsAdded = await addLabels({ - githubRequest, - owner, - repo, - number: prNumber, - labelsToAdd, - }); + const updateResult = await tracer.withRun( + { + name: 'apply-pr-updates', + runType: 'tool', + parentRunId: workflowRun?.id, + inputs: { + prNumber, + applyTitle, + }, + mapOutput: (value) => ({ + assigneesAddedCount: value.assigneesAdded.length, + labelsAddedCount: value.labelsAdded.length, + unknownLabelsIgnoredCount: value.unknownLabelsIgnoredCount, + titleUpdated: value.titleUpdated, + bodyUpdated: value.bodyUpdated, + }), + }, + async () => { + const assigneesAdded = await addAssignee({ + githubRequest, + owner, + repo, + number: prNumber, + currentAssignees, + }); + + const aiLabelCandidates = uniqueStringList(aiSummary.labels); + const { applicableLabels, unknownLabelsIgnoredCount } = filterKnownLabels( + aiLabelCandidates, + repositoryLabels, + ); + + const labelsToAdd = applicableLabels.filter( + (labelName) => + !currentLabels.some((current) => current.toLowerCase() === labelName.toLowerCase()), + ); + + const labelsAdded = await addLabels({ + githubRequest, + owner, + repo, + number: prNumber, + labelsToAdd, + }); + + const block = renderSummaryBlock(aiSummary, { + diffSource, + finalBytes: limitedDiff.meta.finalBytes, + excludedFilesCount, + truncated: limitedDiff.meta.truncated, + assigneesAdded, + labelsAdded, + unknownLabelsIgnoredCount, + }); + + const updatedBody = upsertSummaryBlock(pullRequest.body ?? '', block); + + const titleShouldChange = shouldApplyTitle({ + applyTitle, + aiTitle: aiSummary.title, + existingTitle: pullRequest.title, + labelNames: currentLabels, + }); + + const nextTitle = titleShouldChange ? aiSummary.title : undefined; + const bodyUpdated = updatedBody !== (pullRequest.body ?? ''); + + if (bodyUpdated || typeof nextTitle === 'string') { + await patchPullRequest({ + githubRequest, + owner, + repo, + number: prNumber, + title: nextTitle, + body: updatedBody, + }); + } - const block = renderSummaryBlock(aiSummary, { - diffSource, - finalBytes: limitedDiff.meta.finalBytes, - excludedFilesCount, - truncated: limitedDiff.meta.truncated, - assigneesAdded, - labelsAdded, - unknownLabelsIgnoredCount, - }); + return { + assigneesAdded, + labelsAdded, + unknownLabelsIgnoredCount, + titleUpdated: typeof nextTitle === 'string', + bodyUpdated, + }; + }, + ); - const updatedBody = upsertSummaryBlock(pullRequest.body ?? '', block); + const { labelsAdded, unknownLabelsIgnoredCount } = updateResult; - const titleShouldChange = shouldApplyTitle({ - applyTitle, - aiTitle: aiSummary.title, - existingTitle: pullRequest.title, - labelNames: currentLabels, - }); + logInfo('PR AI description update completed', { + diffSource, + finalBytes: limitedDiff.meta.finalBytes, + excludedFilesCount, + truncated: limitedDiff.meta.truncated, + labelsAppliedCount: labelsAdded.length, + unknownLabelsIgnoredCount, + }); - const nextTitle = titleShouldChange ? aiSummary.title : undefined; + await writeStepSummary('## PR AI Summary Result'); + await writeStepSummary(`- Diff Source: ${diffSource}`); + await writeStepSummary(`- Diff Bytes: ${limitedDiff.meta.finalBytes}`); + await writeStepSummary(`- Excluded Files: ${excludedFilesCount}`); + await writeStepSummary(`- Truncated: ${String(limitedDiff.meta.truncated)}`); + await writeStepSummary(`- Labels Added: ${labelsAdded.join(', ') || 'none'}`); + await writeStepSummary( + `- Unknown Labels Ignored: ${unknownLabelsIgnoredCount}`, + ); - if (updatedBody !== (pullRequest.body ?? '') || typeof nextTitle === 'string') { - await patchPullRequest({ - githubRequest, - owner, - repo, - number: prNumber, - title: nextTitle, - body: updatedBody, + await tracer.endRun(workflowRun, { + status: 'success', + prNumber, + diffSource, + diffBytes: limitedDiff.meta.finalBytes, + truncated: limitedDiff.meta.truncated, + labelsAddedCount: labelsAdded.length, + unknownLabelsIgnoredCount, }); + } catch (error) { + await tracer.failRun(workflowRun, error, { + status: 'failed', + }); + throw error; } - - logInfo('PR AI description update completed', { - diffSource, - finalBytes: limitedDiff.meta.finalBytes, - excludedFilesCount, - truncated: limitedDiff.meta.truncated, - labelsAppliedCount: labelsAdded.length, - unknownLabelsIgnoredCount, - }); - - await writeStepSummary('## PR AI Summary Result'); - await writeStepSummary(`- Diff Source: ${diffSource}`); - await writeStepSummary(`- Diff Bytes: ${limitedDiff.meta.finalBytes}`); - await writeStepSummary(`- Excluded Files: ${excludedFilesCount}`); - await writeStepSummary(`- Truncated: ${String(limitedDiff.meta.truncated)}`); - await writeStepSummary(`- Labels Added: ${labelsAdded.join(', ') || 'none'}`); - await writeStepSummary( - `- Unknown Labels Ignored: ${unknownLabelsIgnoredCount}`, - ); } run().catch(async (error) => { diff --git a/.github/workflows/pr-ai-description.yml b/.github/workflows/pr-ai-description.yml index c4f96c6..ebe6151 100644 --- a/.github/workflows/pr-ai-description.yml +++ b/.github/workflows/pr-ai-description.yml @@ -42,4 +42,9 @@ jobs: PR_AI_APPLY_TITLE: ${{ vars.PR_AI_APPLY_TITLE }} PR_AI_EXCLUDE_GLOBS: ${{ vars.PR_AI_EXCLUDE_GLOBS }} PR_AI_MAX_FILES: ${{ vars.PR_AI_MAX_FILES }} + LANGSMITH_TRACING: ${{ vars.LANGSMITH_TRACING }} + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + LANGSMITH_ENDPOINT: ${{ vars.LANGSMITH_ENDPOINT }} + LANGSMITH_PROJECT: ${{ vars.LANGSMITH_PROJECT }} + LANGSMITH_WORKSPACE_ID: ${{ vars.LANGSMITH_WORKSPACE_ID }} run: node .github/scripts/pr-ai-description.mjs From 451d6da24f3f98000e85bcde8213cdec564aa519 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 4 Mar 2026 20:32:43 +0900 Subject: [PATCH 03/13] =?UTF-8?q?ci:=20PR=20AI=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=20LangSmith=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8B=B1=EC=9D=84=20SDK=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/langsmith-tracer.spec.mjs | 141 +++++++++++------- .github/scripts/langsmith-tracer.mjs | 107 ++++++------- .github/workflows/pr-ai-description.yml | 3 + package.json | 1 + yarn.lock | 110 ++++++++++++++ 5 files changed, 259 insertions(+), 103 deletions(-) diff --git a/.github/scripts/__tests__/langsmith-tracer.spec.mjs b/.github/scripts/__tests__/langsmith-tracer.spec.mjs index f4785c7..ff3dc10 100644 --- a/.github/scripts/__tests__/langsmith-tracer.spec.mjs +++ b/.github/scripts/__tests__/langsmith-tracer.spec.mjs @@ -6,37 +6,36 @@ import { resolveLangSmithTraceConfig, } from '../langsmith-tracer.mjs'; -function createFetchRecorder() { - const calls = []; - - const fetchImpl = async (url, options = {}) => { - let body = null; - - if (typeof options.body === 'string' && options.body.length > 0) { - body = JSON.parse(options.body); - } - - calls.push({ - url, - options, - body, - }); - - return { - ok: true, - status: 200, - async text() { - return ''; - }, - async json() { - return {}; - }, - }; +function createClientRecorder(options = {}) { + const createCalls = []; + const updateCalls = []; + const createErrorMessage = options.createErrorMessage; + const updateErrorMessage = options.updateErrorMessage; + + const client = { + async createRun(payload) { + createCalls.push(payload); + + if (typeof createErrorMessage === 'string') { + throw new Error(createErrorMessage); + } + }, + async updateRun(runId, payload) { + updateCalls.push({ + runId, + payload, + }); + + if (typeof updateErrorMessage === 'string') { + throw new Error(updateErrorMessage); + } + }, }; return { - calls, - fetchImpl, + client, + createCalls, + updateCalls, }; } @@ -70,14 +69,14 @@ test('설정값이 없으면 endpoint/project 기본값을 사용한다', () => assert.equal(config.workspaceId, null); }); -test('withRun 성공 시 /runs POST 후 PATCH가 호출된다', async () => { - const { calls, fetchImpl } = createFetchRecorder(); +test('withRun 성공 시 SDK createRun/updateRun이 호출된다', async () => { + const { client, createCalls, updateCalls } = createClientRecorder(); const tracer = createLangSmithTracer({ env: { LANGSMITH_API_KEY: 'lsv2_xxx', LANGSMITH_PROJECT: 'caquick-ci', }, - fetchImpl, + client, }); const result = await tracer.withRun( @@ -96,24 +95,24 @@ test('withRun 성공 시 /runs POST 후 PATCH가 호출된다', async () => { ); assert.equal(result.title, '요약 제목'); - assert.equal(calls.length, 2); - assert.equal(calls[0].url, 'https://api.smith.langchain.com/runs'); - assert.equal(calls[0].body.session_name, 'caquick-ci'); - assert.equal(calls[0].body.run_type, 'llm'); + assert.equal(createCalls.length, 1); + assert.equal(createCalls[0].project_name, 'caquick-ci'); + assert.equal(createCalls[0].run_type, 'llm'); - const runId = calls[0].body.id; + const runId = createCalls[0].id; assert.ok(typeof runId === 'string' && runId.length > 0); - assert.equal(calls[1].url, `https://api.smith.langchain.com/runs/${runId}`); - assert.equal(calls[1].body.outputs.title, '요약 제목'); + assert.equal(updateCalls.length, 1); + assert.equal(updateCalls[0].runId, runId); + assert.equal(updateCalls[0].payload.outputs.title, '요약 제목'); }); test('withRun 실패 시 에러를 PATCH로 기록하고 예외를 다시 던진다', async () => { - const { calls, fetchImpl } = createFetchRecorder(); + const { client, createCalls, updateCalls } = createClientRecorder(); const tracer = createLangSmithTracer({ env: { LANGSMITH_API_KEY: 'lsv2_xxx', }, - fetchImpl, + client, }); await assert.rejects( @@ -131,26 +130,64 @@ test('withRun 실패 시 에러를 PATCH로 기록하고 예외를 다시 던진 /intentional-failure/, ); - assert.equal(calls.length, 2); - assert.equal(calls[1].body.outputs.constructor, Object); - assert.match(calls[1].body.error, /intentional-failure/); + assert.equal(createCalls.length, 1); + assert.equal(updateCalls.length, 1); + assert.equal(updateCalls[0].payload.outputs.constructor, Object); + assert.match(updateCalls[0].payload.error, /intentional-failure/); }); -test('workspace id가 있으면 x-tenant-id 헤더가 포함된다', async () => { - const { calls, fetchImpl } = createFetchRecorder(); - const tracer = createLangSmithTracer({ +test('workspace id는 SDK Client 초기화 옵션으로 전달된다', () => { + let capturedClientConfig = null; + + createLangSmithTracer({ env: { LANGSMITH_API_KEY: 'lsv2_xxx', LANGSMITH_WORKSPACE_ID: 'workspace-123', + LANGSMITH_ENDPOINT: 'https://api.eu.smith.langchain.com', + }, + clientFactory: (clientConfig) => { + capturedClientConfig = clientConfig; + return { + async createRun() {}, + async updateRun() {}, + }; }, - fetchImpl, }); - await tracer.startRun({ - name: 'root-run', - runType: 'chain', + assert.ok(capturedClientConfig); + assert.equal(capturedClientConfig.workspaceId, 'workspace-123'); + assert.equal(capturedClientConfig.apiUrl, 'https://api.eu.smith.langchain.com'); + assert.equal(capturedClientConfig.autoBatchTracing, false); +}); + +test('SDK 호출 실패는 경고 로그만 남기고 작업을 중단하지 않는다', async () => { + const { client, createCalls, updateCalls } = createClientRecorder({ + createErrorMessage: 'create-failed', + updateErrorMessage: 'update-failed', + }); + const logs = []; + const tracer = createLangSmithTracer({ + env: { + LANGSMITH_API_KEY: 'lsv2_xxx', + }, + client, + logger: (level, message, payload) => { + logs.push({ level, message, payload }); + }, }); - assert.equal(calls.length, 1); - assert.equal(calls[0].options.headers['x-tenant-id'], 'workspace-123'); + const result = await tracer.withRun( + { + name: 'resilient-run', + runType: 'chain', + }, + async () => ({ + ok: true, + }), + ); + + assert.equal(result.ok, true); + assert.equal(createCalls.length, 1); + assert.equal(updateCalls.length, 1); + assert.ok(logs.some((entry) => entry.level === 'warn')); }); diff --git a/.github/scripts/langsmith-tracer.mjs b/.github/scripts/langsmith-tracer.mjs index 846dbbd..0341d0c 100644 --- a/.github/scripts/langsmith-tracer.mjs +++ b/.github/scripts/langsmith-tracer.mjs @@ -1,10 +1,9 @@ import { randomUUID } from 'node:crypto'; import process from 'node:process'; +import { Client } from 'langsmith'; const DEFAULT_LANGSMITH_ENDPOINT = 'https://api.smith.langchain.com'; const DEFAULT_LANGSMITH_PROJECT = 'caquick-pr-ai-description'; -const REQUEST_TIMEOUT_MS = 10000; - function parseBoolean(rawValue, defaultValue) { if (rawValue === undefined || rawValue === null || rawValue === '') { return defaultValue; @@ -97,63 +96,63 @@ export function resolveLangSmithTraceConfig(env = process.env) { export function createLangSmithTracer({ env = process.env, - fetchImpl = fetch, logger, + client, + clientFactory, } = {}) { const config = resolveLangSmithTraceConfig(env); const log = typeof logger === 'function' ? logger : () => {}; + const tracingClient = + config.enabled && + (client ?? + (typeof clientFactory === 'function' + ? clientFactory({ + apiKey: config.apiKey, + apiUrl: config.endpoint, + workspaceId: config.workspaceId ?? undefined, + autoBatchTracing: false, + }) + : new Client({ + apiKey: config.apiKey, + apiUrl: config.endpoint, + workspaceId: config.workspaceId ?? undefined, + autoBatchTracing: false, + }))); + + function toKvMap(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value; + } - async function requestLangSmith(method, path, payload) { - if (!config.enabled) { - return null; + if (value === undefined) { + return {}; } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - const headers = { - 'Content-Type': 'application/json', - 'x-api-key': config.apiKey, + return { + value, }; + } - if (config.workspaceId) { - headers['x-tenant-id'] = config.workspaceId; + async function requestLangSmith(action, execute) { + if (!config.enabled || !tracingClient) { + return null; } try { - const response = await fetchImpl(`${config.endpoint}${path}`, { - method, - headers, - body: JSON.stringify(payload), - signal: controller.signal, - }); - - if (!response.ok) { - const rawBody = await response.text(); - throw new Error( - `langsmith-api-error:${response.status}:${path}:${rawBody.slice(0, 200)}`, - ); - } - + await execute(); return null; } catch (error) { - if (error.name === 'AbortError') { - log('warn', 'langsmith request timed out', { method, path }); - } else { - log('warn', 'langsmith request failed', { - method, - path, - error: error?.message ?? 'unknown-error', - }); - } + log('warn', 'langsmith sdk request failed', { + action, + error: error?.message ?? 'unknown-error', + }); return null; - } finally { - clearTimeout(timeoutId); } } async function startRun({ name, runType = 'chain', inputs = {}, parentRunId, extra } = {}) { - if (!config.enabled) { + if (!config.enabled || !tracingClient) { return null; } @@ -162,9 +161,9 @@ export function createLangSmithTracer({ id: runId, name: typeof name === 'string' && name.trim().length > 0 ? name.trim() : 'unnamed-run', run_type: runType, - inputs, + inputs: toKvMap(inputs), start_time: nowIsoString(), - session_name: config.projectName, + project_name: config.projectName, }; if (typeof parentRunId === 'string' && parentRunId.trim().length > 0) { @@ -175,7 +174,9 @@ export function createLangSmithTracer({ payload.extra = extra; } - await requestLangSmith('POST', '/runs', payload); + await requestLangSmith('createRun', async () => { + await tracingClient.createRun(payload); + }); return { id: runId, @@ -185,25 +186,29 @@ export function createLangSmithTracer({ } async function endRun(run, outputs = {}) { - if (!run || typeof run.id !== 'string') { + if (!run || typeof run.id !== 'string' || !tracingClient) { return; } - await requestLangSmith('PATCH', `/runs/${run.id}`, { - outputs, - end_time: nowIsoString(), + await requestLangSmith('updateRun', async () => { + await tracingClient.updateRun(run.id, { + outputs: toKvMap(outputs), + end_time: nowIsoString(), + }); }); } async function failRun(run, error, outputs = {}) { - if (!run || typeof run.id !== 'string') { + if (!run || typeof run.id !== 'string' || !tracingClient) { return; } - await requestLangSmith('PATCH', `/runs/${run.id}`, { - outputs, - error: toTraceError(error), - end_time: nowIsoString(), + await requestLangSmith('updateRun', async () => { + await tracingClient.updateRun(run.id, { + outputs: toKvMap(outputs), + error: toTraceError(error), + end_time: nowIsoString(), + }); }); } diff --git a/.github/workflows/pr-ai-description.yml b/.github/workflows/pr-ai-description.yml index ebe6151..39f761e 100644 --- a/.github/workflows/pr-ai-description.yml +++ b/.github/workflows/pr-ai-description.yml @@ -30,6 +30,9 @@ jobs: with: node-version: '24.x' + - name: Install dependencies + run: yarn install --immutable + - name: Run PR AI helper unit tests run: node --test .github/scripts/__tests__/*.spec.mjs diff --git a/package.json b/package.json index 1d36702..f0754ea 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "eslint-plugin-prettier": "^5.2.2", "globals": "^16.0.0", "jest": "^29", + "langsmith": "^0.5.7", "prettier": "^3.4.2", "prisma": "^6.2.0", "source-map-support": "^0.5.21", diff --git a/yarn.lock b/yarn.lock index a0b6692..f3c9332 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3830,6 +3830,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "@types/uuid@npm:10.0.0" + checksum: 10c0/9a1404bf287164481cb9b97f6bb638f78f955be57c40c6513b7655160beb29df6f84c915aaf4089a1559c216557dc4d2f79b48d978742d3ae10b937420ddac60 + languageName: node + linkType: hard + "@types/validator@npm:^13.15.3": version: 13.15.10 resolution: "@types/validator@npm:13.15.10" @@ -5299,6 +5306,7 @@ __metadata: globals: "npm:^16.0.0" graphql: "npm:^16.12.0" jest: "npm:^29" + langsmith: "npm:^0.5.7" logform: "npm:^2.7.0" openid-client: "npm:5.7.1" passport: "npm:^0.7.0" @@ -5352,6 +5360,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^5.6.2": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 + languageName: node + linkType: hard + "change-case-all@npm:1.0.15": version: 1.0.15 resolution: "change-case-all@npm:1.0.15" @@ -5804,6 +5819,15 @@ __metadata: languageName: node linkType: hard +"console-table-printer@npm:^2.12.1": + version: 2.15.0 + resolution: "console-table-printer@npm:2.15.0" + dependencies: + simple-wcswidth: "npm:^1.1.2" + checksum: 10c0/ec63b6c7b7b7d6fe78087e5960743710f6f8e9dc239daf8ce625b305056fc39d891f5d6f7827117e47917f9f97f0e5e4352e9eb397ca5a0b381a05de6d382ea2 + languageName: node + linkType: hard + "constant-case@npm:^3.0.4": version: 3.0.4 resolution: "constant-case@npm:3.0.4" @@ -7062,6 +7086,13 @@ __metadata: languageName: node linkType: hard +"eventemitter3@npm:^4.0.4": + version: 4.0.7 + resolution: "eventemitter3@npm:4.0.7" + checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b + languageName: node + linkType: hard + "eventemitter3@npm:^5.0.1": version: 5.0.4 resolution: "eventemitter3@npm:5.0.4" @@ -10013,6 +10044,34 @@ __metadata: languageName: node linkType: hard +"langsmith@npm:^0.5.7": + version: 0.5.7 + resolution: "langsmith@npm:0.5.7" + dependencies: + "@types/uuid": "npm:^10.0.0" + chalk: "npm:^5.6.2" + console-table-printer: "npm:^2.12.1" + p-queue: "npm:^6.6.2" + semver: "npm:^7.6.3" + uuid: "npm:^10.0.0" + peerDependencies: + "@opentelemetry/api": "*" + "@opentelemetry/exporter-trace-otlp-proto": "*" + "@opentelemetry/sdk-trace-base": "*" + openai: "*" + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@opentelemetry/exporter-trace-otlp-proto": + optional: true + "@opentelemetry/sdk-trace-base": + optional: true + openai: + optional: true + checksum: 10c0/10df40f1e363a0a062bffc60f95e185bc2326c06d457a75aa5b7e16ec07294d0186208e38191f4ab766245450b9723ecfb32ef95b5ae6cf0ab285d929359fdd1 + languageName: node + linkType: hard + "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -11350,6 +11409,13 @@ __metadata: languageName: node linkType: hard +"p-finally@npm:^1.0.0": + version: 1.0.0 + resolution: "p-finally@npm:1.0.0" + checksum: 10c0/6b8552339a71fe7bd424d01d8451eea92d379a711fc62f6b2fe64cad8a472c7259a236c9a22b4733abca0b5666ad503cb497792a0478c5af31ded793d00937e7 + languageName: node + linkType: hard + "p-limit@npm:3.1.0, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": version: 3.1.0 resolution: "p-limit@npm:3.1.0" @@ -11393,6 +11459,25 @@ __metadata: languageName: node linkType: hard +"p-queue@npm:^6.6.2": + version: 6.6.2 + resolution: "p-queue@npm:6.6.2" + dependencies: + eventemitter3: "npm:^4.0.4" + p-timeout: "npm:^3.2.0" + checksum: 10c0/5739ecf5806bbeadf8e463793d5e3004d08bb3f6177bd1a44a005da8fd81bb90f80e4633e1fb6f1dfd35ee663a5c0229abe26aebb36f547ad5a858347c7b0d3e + languageName: node + linkType: hard + +"p-timeout@npm:^3.2.0": + version: 3.2.0 + resolution: "p-timeout@npm:3.2.0" + dependencies: + p-finally: "npm:^1.0.0" + checksum: 10c0/524b393711a6ba8e1d48137c5924749f29c93d70b671e6db761afa784726572ca06149c715632da8f70c090073afb2af1c05730303f915604fd38ee207b70a61 + languageName: node + linkType: hard + "p-try@npm:^2.0.0": version: 2.2.0 resolution: "p-try@npm:2.2.0" @@ -12479,6 +12564,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.3": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + languageName: node + linkType: hard + "send@npm:^1.1.0, send@npm:^1.2.0": version: 1.2.0 resolution: "send@npm:1.2.0" @@ -12734,6 +12828,13 @@ __metadata: languageName: node linkType: hard +"simple-wcswidth@npm:^1.1.2": + version: 1.1.2 + resolution: "simple-wcswidth@npm:1.1.2" + checksum: 10c0/0db23ffef39d81a018a2354d64db1d08a44123c54263e48173992c61d808aaa8b58e5651d424e8c275589671f35e9094ac6fa2bbf2c98771b1bae9e007e611dd + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -14174,6 +14275,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "uuid@npm:10.0.0" + bin: + uuid: dist/bin/uuid + checksum: 10c0/eab18c27fe4ab9fb9709a5d5f40119b45f2ec8314f8d4cf12ce27e4c6f4ffa4a6321dc7db6c515068fa373c075b49691ba969f0010bf37f44c37ca40cd6bf7fe + languageName: node + linkType: hard + "uuid@npm:^11.1.0": version: 11.1.0 resolution: "uuid@npm:11.1.0" From a5d628f3494da1fb2c56ad2fe0bbdd96f1916cee Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 4 Mar 2026 20:58:34 +0900 Subject: [PATCH 04/13] =?UTF-8?q?ci:=20PR=20AI=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/pr-ai-description.mjs | 22 ++-------------------- .github/workflows/pr-ai-description.yml | 2 -- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/.github/scripts/pr-ai-description.mjs b/.github/scripts/pr-ai-description.mjs index 8388420..d4ff186 100644 --- a/.github/scripts/pr-ai-description.mjs +++ b/.github/scripts/pr-ai-description.mjs @@ -52,24 +52,6 @@ async function writeStepSummary(line) { await fs.appendFile(stepSummaryPath, `${line}\n`, 'utf8'); } -function parseBooleanEnv(rawValue, defaultValue) { - if (rawValue === undefined || rawValue === null || rawValue === '') { - return defaultValue; - } - - const normalized = String(rawValue).trim().toLowerCase(); - - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true; - } - - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false; - } - - return defaultValue; -} - function parseIntegerEnv(rawValue, defaultValue) { if (rawValue === undefined || rawValue === null || rawValue === '') { return defaultValue; @@ -648,8 +630,8 @@ async function run() { DEFAULT_MAX_DIFF_BYTES, ); const maxFiles = parseIntegerEnv(process.env.PR_AI_MAX_FILES, DEFAULT_MAX_FILES); - const applyTitle = parseBooleanEnv(process.env.PR_AI_APPLY_TITLE, true); - const excludeGlobs = buildExcludeGlobs(process.env.PR_AI_EXCLUDE_GLOBS); + const applyTitle = true; + const excludeGlobs = buildExcludeGlobs(); const tracer = createLangSmithTracer({ logger: (level, message, payload) => { if (level === 'warn') { diff --git a/.github/workflows/pr-ai-description.yml b/.github/workflows/pr-ai-description.yml index 39f761e..f6a2482 100644 --- a/.github/workflows/pr-ai-description.yml +++ b/.github/workflows/pr-ai-description.yml @@ -42,8 +42,6 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} PR_AI_MAX_DIFF_BYTES: ${{ vars.PR_AI_MAX_DIFF_BYTES }} - PR_AI_APPLY_TITLE: ${{ vars.PR_AI_APPLY_TITLE }} - PR_AI_EXCLUDE_GLOBS: ${{ vars.PR_AI_EXCLUDE_GLOBS }} PR_AI_MAX_FILES: ${{ vars.PR_AI_MAX_FILES }} LANGSMITH_TRACING: ${{ vars.LANGSMITH_TRACING }} LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} From 3538147576e11b644e43184c6ac15adf17075c96 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 4 Mar 2026 21:58:40 +0900 Subject: [PATCH 05/13] =?UTF-8?q?ci:=20PR=20AI=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20OpenAI=20=EC=97=90=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/pr-ai-description.mjs | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/scripts/pr-ai-description.mjs b/.github/scripts/pr-ai-description.mjs index d4ff186..2190352 100644 --- a/.github/scripts/pr-ai-description.mjs +++ b/.github/scripts/pr-ai-description.mjs @@ -66,6 +66,18 @@ function parseIntegerEnv(rawValue, defaultValue) { return defaultValue; } +function truncateText(value, maxLength = 2000) { + if (typeof value !== 'string') { + return ''; + } + + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength)}...(truncated)`; +} + function ensureEnv(name) { const value = process.env[name]; @@ -459,6 +471,7 @@ async function requestOpenAiSummary({ if (!response.ok) { const rawBody = await response.text(); const error = new Error(`openai-api-error:${response.status}`); + error.status = response.status; error.response = rawBody; throw error; } @@ -967,13 +980,30 @@ async function run() { } run().catch(async (error) => { + const rawResponse = + typeof error?.response === 'string' && error.response.length > 0 + ? error.response + : null; + const safeResponse = rawResponse + ? truncateText(maskSensitiveContent(rawResponse), 4000) + : null; + logWarn('PR AI description workflow failed', { message: error?.message ?? 'unknown-error', status: error?.status, + openAiErrorResponse: safeResponse ?? undefined, }); await writeStepSummary('## PR AI Summary Failed'); await writeStepSummary(`- Error: ${error?.message ?? 'unknown-error'}`); + await writeStepSummary(`- Status: ${error?.status ?? 'unknown'}`); + + if (safeResponse) { + await writeStepSummary('- OpenAI Error Response:'); + await writeStepSummary('```json'); + await writeStepSummary(safeResponse); + await writeStepSummary('```'); + } process.exit(1); }); From f7cf032b9786ac739853070feac4b44be578a320 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 4 Mar 2026 22:03:57 +0900 Subject: [PATCH 06/13] =?UTF-8?q?ci:=20OpenAI=20strict=20json=5Fschema=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20labels=EB=A5=BC=20required=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/pr-ai-description-lib.mjs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/scripts/pr-ai-description-lib.mjs b/.github/scripts/pr-ai-description-lib.mjs index aa3c66f..e82e203 100644 --- a/.github/scripts/pr-ai-description-lib.mjs +++ b/.github/scripts/pr-ai-description-lib.mjs @@ -27,7 +27,7 @@ const IMPACT_KEYS = [ export const AI_RESPONSE_JSON_SCHEMA = { type: 'object', additionalProperties: false, - required: ['title', 'summary', 'changes', 'impact', 'checklist', 'risks'], + required: ['title', 'summary', 'changes', 'impact', 'checklist', 'risks', 'labels'], properties: { title: { type: 'string' }, summary: { type: 'string' }, @@ -546,11 +546,7 @@ export function validateAiSummaryJson(payload) { const checklist = validateStringArray(payload.checklist, 'checklist'); const risks = validateStringArray(payload.risks, 'risks'); - let labels = []; - - if (payload.labels !== undefined) { - labels = validateStringArray(payload.labels, 'labels'); - } + const labels = validateStringArray(payload.labels, 'labels'); return { title, From d00dc1765a88461ad002f7122dfe2562eb6c15fa Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Fri, 6 Mar 2026 03:17:10 +0900 Subject: [PATCH 07/13] =?UTF-8?q?ci:=20LangSmith=20generate-ai-summary=20r?= =?UTF-8?q?un=EC=97=90=20=EC=8B=A4=EC=A0=9C=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8/=EC=9D=91=EB=8B=B5=20=EA=B8=B0=EB=A1=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/pr-ai-description.mjs | 54 ++++++++++++++++----------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/.github/scripts/pr-ai-description.mjs b/.github/scripts/pr-ai-description.mjs index 2190352..36c65b7 100644 --- a/.github/scripts/pr-ai-description.mjs +++ b/.github/scripts/pr-ai-description.mjs @@ -430,7 +430,7 @@ function extractChatCompletionContent(responseData) { async function requestOpenAiSummary({ openAiApiKey, model, - prompt, + messages, }) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 45000); @@ -445,17 +445,7 @@ async function requestOpenAiSummary({ body: JSON.stringify({ model, temperature: 0.2, - messages: [ - { - role: 'system', - content: - 'You are a senior backend engineer. Return only JSON that matches the schema.', - }, - { - role: 'user', - content: prompt, - }, - ], + messages, response_format: { type: 'json_schema', json_schema: { @@ -814,30 +804,50 @@ async function run() { diffText: maskedDiff, }); + const openAiMessages = [ + { + role: 'system', + content: + 'You are a senior backend engineer. Return only JSON that matches the schema.', + }, + { + role: 'user', + content: prompt, + }, + ]; + const aiSummary = await tracer.withRun( { name: 'generate-ai-summary', runType: 'llm', parentRunId: workflowRun?.id, inputs: { - model: openAiModel, - diffSource, - diffBytes: limitedDiff.meta.finalBytes, - truncated: limitedDiff.meta.truncated, + messages: openAiMessages, + }, + extra: { + metadata: { + model: openAiModel, + diffSource, + diffBytes: limitedDiff.meta.finalBytes, + truncated: limitedDiff.meta.truncated, + }, }, mapOutput: (summary) => ({ - title: summary.title, - labels: summary.labels, - changesCount: summary.changes.length, - checklistCount: summary.checklist.length, - risksCount: summary.risks.length, + choices: [ + { + message: { + role: 'assistant', + content: JSON.stringify(summary, null, 2), + }, + }, + ], }), }, async () => requestOpenAiSummary({ openAiApiKey, model: openAiModel, - prompt, + messages: openAiMessages, }), ); From 9dcb93538436ec6899abdcd3e1fca7695d9c28e2 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 11 Mar 2026 20:48:17 +0900 Subject: [PATCH 08/13] =?UTF-8?q?ci:=20PR=20AI=20description=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=9D=84=20OpenAI=20SDK=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=ED=8E=B8,=20dr?= =?UTF-8?q?y-run=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/langsmith-tracer.spec.mjs | 193 ----- .../__tests__/pr-ai-description-lib.spec.mjs | 60 +- .../__tests__/pr-ai-description.spec.mjs | 208 +++++ .github/scripts/langsmith-tracer.mjs | 241 ------ .github/scripts/pr-ai-description-lib.mjs | 274 +++---- .github/scripts/pr-ai-description.mjs | 712 +++++++++--------- .github/scripts/pr-ai-dry-run.mjs | 439 +++++++++++ package.json | 1 + yarn.lock | 18 + 9 files changed, 1172 insertions(+), 974 deletions(-) delete mode 100644 .github/scripts/__tests__/langsmith-tracer.spec.mjs create mode 100644 .github/scripts/__tests__/pr-ai-description.spec.mjs delete mode 100644 .github/scripts/langsmith-tracer.mjs create mode 100644 .github/scripts/pr-ai-dry-run.mjs diff --git a/.github/scripts/__tests__/langsmith-tracer.spec.mjs b/.github/scripts/__tests__/langsmith-tracer.spec.mjs deleted file mode 100644 index ff3dc10..0000000 --- a/.github/scripts/__tests__/langsmith-tracer.spec.mjs +++ /dev/null @@ -1,193 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - createLangSmithTracer, - resolveLangSmithTraceConfig, -} from '../langsmith-tracer.mjs'; - -function createClientRecorder(options = {}) { - const createCalls = []; - const updateCalls = []; - const createErrorMessage = options.createErrorMessage; - const updateErrorMessage = options.updateErrorMessage; - - const client = { - async createRun(payload) { - createCalls.push(payload); - - if (typeof createErrorMessage === 'string') { - throw new Error(createErrorMessage); - } - }, - async updateRun(runId, payload) { - updateCalls.push({ - runId, - payload, - }); - - if (typeof updateErrorMessage === 'string') { - throw new Error(updateErrorMessage); - } - }, - }; - - return { - client, - createCalls, - updateCalls, - }; -} - -test('LANGSMITH_TRACING=false 이면 tracing 설정이 비활성화된다', () => { - const config = resolveLangSmithTraceConfig({ - LANGSMITH_TRACING: 'false', - LANGSMITH_API_KEY: 'lsv2_xxx', - }); - - assert.equal(config.enabled, false); - assert.equal(config.reason, 'langsmith-tracing-disabled'); -}); - -test('API KEY가 없으면 tracing 설정이 비활성화된다', () => { - const config = resolveLangSmithTraceConfig({ - LANGSMITH_TRACING: 'true', - }); - - assert.equal(config.enabled, false); - assert.equal(config.reason, 'langsmith-api-key-missing'); -}); - -test('설정값이 없으면 endpoint/project 기본값을 사용한다', () => { - const config = resolveLangSmithTraceConfig({ - LANGSMITH_API_KEY: 'lsv2_xxx', - }); - - assert.equal(config.enabled, true); - assert.equal(config.endpoint, 'https://api.smith.langchain.com'); - assert.equal(config.projectName, 'caquick-pr-ai-description'); - assert.equal(config.workspaceId, null); -}); - -test('withRun 성공 시 SDK createRun/updateRun이 호출된다', async () => { - const { client, createCalls, updateCalls } = createClientRecorder(); - const tracer = createLangSmithTracer({ - env: { - LANGSMITH_API_KEY: 'lsv2_xxx', - LANGSMITH_PROJECT: 'caquick-ci', - }, - client, - }); - - const result = await tracer.withRun( - { - name: 'openai-summary', - runType: 'llm', - inputs: { model: 'gpt-4.1-mini' }, - mapOutput: (value) => ({ - title: value.title, - }), - }, - async () => ({ - title: '요약 제목', - longText: '생략', - }), - ); - - assert.equal(result.title, '요약 제목'); - assert.equal(createCalls.length, 1); - assert.equal(createCalls[0].project_name, 'caquick-ci'); - assert.equal(createCalls[0].run_type, 'llm'); - - const runId = createCalls[0].id; - assert.ok(typeof runId === 'string' && runId.length > 0); - assert.equal(updateCalls.length, 1); - assert.equal(updateCalls[0].runId, runId); - assert.equal(updateCalls[0].payload.outputs.title, '요약 제목'); -}); - -test('withRun 실패 시 에러를 PATCH로 기록하고 예외를 다시 던진다', async () => { - const { client, createCalls, updateCalls } = createClientRecorder(); - const tracer = createLangSmithTracer({ - env: { - LANGSMITH_API_KEY: 'lsv2_xxx', - }, - client, - }); - - await assert.rejects( - () => - tracer.withRun( - { - name: 'fail-step', - runType: 'tool', - inputs: { stage: 'patch-pr' }, - }, - async () => { - throw new Error('intentional-failure'); - }, - ), - /intentional-failure/, - ); - - assert.equal(createCalls.length, 1); - assert.equal(updateCalls.length, 1); - assert.equal(updateCalls[0].payload.outputs.constructor, Object); - assert.match(updateCalls[0].payload.error, /intentional-failure/); -}); - -test('workspace id는 SDK Client 초기화 옵션으로 전달된다', () => { - let capturedClientConfig = null; - - createLangSmithTracer({ - env: { - LANGSMITH_API_KEY: 'lsv2_xxx', - LANGSMITH_WORKSPACE_ID: 'workspace-123', - LANGSMITH_ENDPOINT: 'https://api.eu.smith.langchain.com', - }, - clientFactory: (clientConfig) => { - capturedClientConfig = clientConfig; - return { - async createRun() {}, - async updateRun() {}, - }; - }, - }); - - assert.ok(capturedClientConfig); - assert.equal(capturedClientConfig.workspaceId, 'workspace-123'); - assert.equal(capturedClientConfig.apiUrl, 'https://api.eu.smith.langchain.com'); - assert.equal(capturedClientConfig.autoBatchTracing, false); -}); - -test('SDK 호출 실패는 경고 로그만 남기고 작업을 중단하지 않는다', async () => { - const { client, createCalls, updateCalls } = createClientRecorder({ - createErrorMessage: 'create-failed', - updateErrorMessage: 'update-failed', - }); - const logs = []; - const tracer = createLangSmithTracer({ - env: { - LANGSMITH_API_KEY: 'lsv2_xxx', - }, - client, - logger: (level, message, payload) => { - logs.push({ level, message, payload }); - }, - }); - - const result = await tracer.withRun( - { - name: 'resilient-run', - runType: 'chain', - }, - async () => ({ - ok: true, - }), - ); - - assert.equal(result.ok, true); - assert.equal(createCalls.length, 1); - assert.equal(updateCalls.length, 1); - assert.ok(logs.some((entry) => entry.level === 'warn')); -}); diff --git a/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs b/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs index 4eb3a2c..e7c5cf0 100644 --- a/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs +++ b/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs @@ -47,81 +47,63 @@ test('Diff 절단 시 메타가 계산되고 최종 bytes가 한도를 넘지 test('JSON schema 검증 성공/실패를 구분한다', () => { const validPayload = { - title: '사용자 조회 API 개선', + title: 'feat: 사용자 조회 API 개선', summary: '응답 필드와 예외 처리를 정리했습니다.', + summaryBullets: ['조회 조건 검증 로직 추가', '에러 응답 포맷 통일'], changes: [ { - area: 'user', + file: 'src/user/user.service.ts', description: '조회 조건 검증 로직 추가', - importance: 'medium', }, ], - impact: { - api: 'medium', - db: 'low', - security: 'low', - performance: 'low', - operations: 'low', - tests: 'medium', - }, + impact: ['API 응답 형식 변경으로 클라이언트 확인 필요'], checklist: ['resolver 통합 테스트 확인'], - risks: ['기존 캐시 키와 충돌 가능성 점검 필요'], + breakingChanges: [], + relatedIssues: [], + dependencies: [], labels: ['backend', 'api'], }; const validated = validateAiSummaryJson(validPayload); assert.equal(validated.title, validPayload.title); assert.deepEqual(validated.labels, ['backend', 'api']); + assert.deepEqual(validated.summaryBullets, validPayload.summaryBullets); + assert.equal(validated.changes[0].file, 'src/user/user.service.ts'); assert.throws( () => validateAiSummaryJson({ ...validPayload, - impact: { - ...validPayload.impact, - api: 'critical', - }, + unknown: 'x', }), - /invalid-impact-api-value/, + /invalid-root-additional-property/, ); assert.throws( () => validateAiSummaryJson({ ...validPayload, - unknown: 'x', + changes: [{ file: '', description: 'test' }], }), - /invalid-root-additional-property/, + /invalid-changes-0-file/, ); }); test('마커 블록이 있으면 교체하고 없으면 하단에 추가한다', () => { const summary = { - title: 'AI 제목', + title: 'fix: 인증 가드 수정', summary: '요약', - changes: [{ area: 'auth', description: '가드 수정', importance: 'high' }], - impact: { - api: 'low', - db: 'low', - security: 'medium', - performance: 'low', - operations: 'low', - tests: 'medium', - }, + summaryBullets: ['가드 로직 개선'], + changes: [{ file: 'src/auth/auth.guard.ts', description: '가드 수정' }], + impact: ['인증 플로우 변경으로 테스트 확인 필요'], checklist: ['테스트 실행'], - risks: ['권한 정책 확인'], + breakingChanges: [], + relatedIssues: [], + dependencies: [], labels: [], }; - const block = renderSummaryBlock(summary, { - diffSource: 'compare', - finalBytes: 120, - excludedFilesCount: 1, - truncated: false, - assigneesAdded: ['chanwoo7'], - labelsAdded: ['backend'], - unknownLabelsIgnoredCount: 0, - }); + const block = renderSummaryBlock(summary); const bodyWithoutMarker = '기존 본문'; const appended = upsertSummaryBlock(bodyWithoutMarker, block); diff --git a/.github/scripts/__tests__/pr-ai-description.spec.mjs b/.github/scripts/__tests__/pr-ai-description.spec.mjs new file mode 100644 index 0000000..28e7328 --- /dev/null +++ b/.github/scripts/__tests__/pr-ai-description.spec.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { requestOpenAiSummary } from '../pr-ai-description.mjs'; + +function createFakeOpenAiClient(createImpl) { + return { + chat: { + completions: { + create: createImpl, + }, + }, + }; +} + +function createValidSummary() { + return { + title: 'feat: 사용자 조회 API 개선', + summary: '응답 필드와 예외 처리를 정리했습니다.', + summaryBullets: ['조회 조건 검증 로직 추가', '에러 응답 포맷 통일'], + changes: [ + { + file: 'src/user/user.service.ts', + description: '조회 조건 검증 로직 추가', + }, + ], + impact: ['API 응답 형식 변경으로 클라이언트 확인 필요'], + checklist: ['resolver 통합 테스트 확인'], + breakingChanges: [], + relatedIssues: [], + dependencies: [], + labels: ['backend', 'api'], + }; +} + +test('OpenAI SDK 응답을 검증된 summary 객체로 변환한다', async () => { + let capturedArgs = null; + const openAiClient = createFakeOpenAiClient(async (...args) => { + capturedArgs = args; + + return { + choices: [ + { + message: { + content: JSON.stringify(createValidSummary()), + }, + }, + ], + }; + }); + + const summary = await requestOpenAiSummary({ + openAiClient, + model: 'gpt-4.1-mini', + messages: [{ role: 'user', content: 'test' }], + langsmithExtra: { + metadata: { + diffSource: 'compare', + }, + }, + }); + + assert.equal(summary.title, 'feat: 사용자 조회 API 개선'); + assert.equal(capturedArgs[0].response_format.type, 'json_schema'); + assert.equal(capturedArgs[0].temperature, 0.2); + assert.equal(capturedArgs[1].langsmithExtra.metadata.diffSource, 'compare'); +}); + +test('gpt-5 계열 모델에는 temperature를 포함하지 않는다', async () => { + let capturedArgs = null; + const openAiClient = createFakeOpenAiClient(async (...args) => { + capturedArgs = args; + + return { + choices: [ + { + message: { + content: JSON.stringify(createValidSummary()), + }, + }, + ], + }; + }); + + await requestOpenAiSummary({ + openAiClient, + model: 'gpt-5-mini', + messages: [{ role: 'user', content: 'test' }], + }); + + assert.equal('temperature' in capturedArgs[0], false); +}); + +test('content가 비어 있으면 openai-empty-response 에러를 던진다', async () => { + const openAiClient = createFakeOpenAiClient(async () => ({ + choices: [ + { + message: { + content: ' ', + }, + }, + ], + })); + + await assert.rejects( + () => + requestOpenAiSummary({ + openAiClient, + model: 'gpt-4.1-mini', + messages: [{ role: 'user', content: 'test' }], + }), + /openai-empty-response/, + ); +}); + +test('JSON 파싱에 실패하면 openai-json-parse-failed 에러를 던진다', async () => { + const openAiClient = createFakeOpenAiClient(async () => ({ + choices: [ + { + message: { + content: '{not-json}', + }, + }, + ], + })); + + await assert.rejects( + () => + requestOpenAiSummary({ + openAiClient, + model: 'gpt-4.1-mini', + messages: [{ role: 'user', content: 'test' }], + }), + /openai-json-parse-failed/, + ); +}); + +test('스키마 검증에 실패하면 openai-schema-validation-failed 에러를 던진다', async () => { + const invalidPayload = { + ...createValidSummary(), + labels: undefined, + }; + const openAiClient = createFakeOpenAiClient(async () => ({ + choices: [ + { + message: { + content: JSON.stringify(invalidPayload), + }, + }, + ], + })); + + await assert.rejects( + () => + requestOpenAiSummary({ + openAiClient, + model: 'gpt-4.1-mini', + messages: [{ role: 'user', content: 'test' }], + }), + /openai-schema-validation-failed/, + ); +}); + +test('OpenAI SDK status 에러를 openai-api-error로 변환한다', async () => { + const openAiClient = createFakeOpenAiClient(async () => { + const error = new Error('rate-limited'); + error.status = 429; + error.error = { + message: 'too many requests', + }; + throw error; + }); + + let thrown = null; + + try { + await requestOpenAiSummary({ + openAiClient, + model: 'gpt-4.1-mini', + messages: [{ role: 'user', content: 'test' }], + }); + } catch (error) { + thrown = error; + } + + assert.ok(thrown instanceof Error); + assert.equal(thrown.message, 'openai-api-error:429'); + assert.equal(thrown.status, 429); + assert.match(thrown.response, /too many requests/); +}); + +test('abort 계열 에러를 openai-timeout 으로 변환한다', async () => { + const openAiClient = createFakeOpenAiClient(async () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + throw error; + }); + + await assert.rejects( + () => + requestOpenAiSummary({ + openAiClient, + model: 'gpt-4.1-mini', + messages: [{ role: 'user', content: 'test' }], + }), + /openai-timeout/, + ); +}); diff --git a/.github/scripts/langsmith-tracer.mjs b/.github/scripts/langsmith-tracer.mjs deleted file mode 100644 index 0341d0c..0000000 --- a/.github/scripts/langsmith-tracer.mjs +++ /dev/null @@ -1,241 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import process from 'node:process'; -import { Client } from 'langsmith'; - -const DEFAULT_LANGSMITH_ENDPOINT = 'https://api.smith.langchain.com'; -const DEFAULT_LANGSMITH_PROJECT = 'caquick-pr-ai-description'; -function parseBoolean(rawValue, defaultValue) { - if (rawValue === undefined || rawValue === null || rawValue === '') { - return defaultValue; - } - - const normalized = String(rawValue).trim().toLowerCase(); - - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true; - } - - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false; - } - - return defaultValue; -} - -function toOptionalString(rawValue) { - if (typeof rawValue !== 'string') { - return null; - } - - const trimmed = rawValue.trim(); - - if (trimmed.length === 0) { - return null; - } - - return trimmed; -} - -function nowIsoString() { - return new Date().toISOString(); -} - -function toTraceError(error) { - if (!error) { - return 'unknown-error'; - } - - const message = - typeof error.message === 'string' && error.message.trim().length > 0 - ? error.message.trim() - : 'unknown-error'; - - if (typeof error.stack === 'string' && error.stack.trim().length > 0) { - return `${message}\n${error.stack.slice(0, 2000)}`; - } - - return message; -} - -export function resolveLangSmithTraceConfig(env = process.env) { - const tracingEnabled = parseBoolean(env.LANGSMITH_TRACING, true); - - if (!tracingEnabled) { - return { - enabled: false, - reason: 'langsmith-tracing-disabled', - }; - } - - const apiKey = toOptionalString(env.LANGSMITH_API_KEY); - - if (!apiKey) { - return { - enabled: false, - reason: 'langsmith-api-key-missing', - }; - } - - const endpoint = - (toOptionalString(env.LANGSMITH_ENDPOINT) ?? DEFAULT_LANGSMITH_ENDPOINT).replace( - /\/+$/, - '', - ); - const projectName = toOptionalString(env.LANGSMITH_PROJECT) ?? DEFAULT_LANGSMITH_PROJECT; - const workspaceId = toOptionalString(env.LANGSMITH_WORKSPACE_ID); - - return { - enabled: true, - reason: null, - apiKey, - endpoint, - projectName, - workspaceId: workspaceId ?? null, - }; -} - -export function createLangSmithTracer({ - env = process.env, - logger, - client, - clientFactory, -} = {}) { - const config = resolveLangSmithTraceConfig(env); - const log = typeof logger === 'function' ? logger : () => {}; - const tracingClient = - config.enabled && - (client ?? - (typeof clientFactory === 'function' - ? clientFactory({ - apiKey: config.apiKey, - apiUrl: config.endpoint, - workspaceId: config.workspaceId ?? undefined, - autoBatchTracing: false, - }) - : new Client({ - apiKey: config.apiKey, - apiUrl: config.endpoint, - workspaceId: config.workspaceId ?? undefined, - autoBatchTracing: false, - }))); - - function toKvMap(value) { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return value; - } - - if (value === undefined) { - return {}; - } - - return { - value, - }; - } - - async function requestLangSmith(action, execute) { - if (!config.enabled || !tracingClient) { - return null; - } - - try { - await execute(); - return null; - } catch (error) { - log('warn', 'langsmith sdk request failed', { - action, - error: error?.message ?? 'unknown-error', - }); - - return null; - } - } - - async function startRun({ name, runType = 'chain', inputs = {}, parentRunId, extra } = {}) { - if (!config.enabled || !tracingClient) { - return null; - } - - const runId = randomUUID(); - const payload = { - id: runId, - name: typeof name === 'string' && name.trim().length > 0 ? name.trim() : 'unnamed-run', - run_type: runType, - inputs: toKvMap(inputs), - start_time: nowIsoString(), - project_name: config.projectName, - }; - - if (typeof parentRunId === 'string' && parentRunId.trim().length > 0) { - payload.parent_run_id = parentRunId; - } - - if (extra && typeof extra === 'object' && !Array.isArray(extra)) { - payload.extra = extra; - } - - await requestLangSmith('createRun', async () => { - await tracingClient.createRun(payload); - }); - - return { - id: runId, - name: payload.name, - runType, - }; - } - - async function endRun(run, outputs = {}) { - if (!run || typeof run.id !== 'string' || !tracingClient) { - return; - } - - await requestLangSmith('updateRun', async () => { - await tracingClient.updateRun(run.id, { - outputs: toKvMap(outputs), - end_time: nowIsoString(), - }); - }); - } - - async function failRun(run, error, outputs = {}) { - if (!run || typeof run.id !== 'string' || !tracingClient) { - return; - } - - await requestLangSmith('updateRun', async () => { - await tracingClient.updateRun(run.id, { - outputs: toKvMap(outputs), - error: toTraceError(error), - end_time: nowIsoString(), - }); - }); - } - - async function withRun(options, execute) { - const { mapOutput, ...runOptions } = options ?? {}; - const run = await startRun(runOptions); - - try { - const value = await execute(); - const outputs = typeof mapOutput === 'function' ? mapOutput(value) : value; - await endRun(run, outputs); - return value; - } catch (error) { - await failRun(run, error); - throw error; - } - } - - return { - config, - isEnabled() { - return config.enabled; - }, - reason: config.reason ?? null, - startRun, - endRun, - failRun, - withRun, - }; -} diff --git a/.github/scripts/pr-ai-description-lib.mjs b/.github/scripts/pr-ai-description-lib.mjs index e82e203..09f4ba3 100644 --- a/.github/scripts/pr-ai-description-lib.mjs +++ b/.github/scripts/pr-ai-description-lib.mjs @@ -13,58 +13,57 @@ export const DEFAULT_EXCLUDE_GLOBS = [ '**/*.map', ]; -const IMPORTANCE_VALUES = new Set(['low', 'medium', 'high']); -const IMPACT_VALUES = new Set(['low', 'medium', 'high']); -const IMPACT_KEYS = [ - 'api', - 'db', - 'security', - 'performance', - 'operations', - 'tests', -]; - export const AI_RESPONSE_JSON_SCHEMA = { type: 'object', additionalProperties: false, - required: ['title', 'summary', 'changes', 'impact', 'checklist', 'risks', 'labels'], + required: [ + 'title', + 'summary', + 'summaryBullets', + 'changes', + 'impact', + 'checklist', + 'breakingChanges', + 'relatedIssues', + 'dependencies', + 'labels', + ], properties: { title: { type: 'string' }, summary: { type: 'string' }, + summaryBullets: { + type: 'array', + items: { type: 'string' }, + }, changes: { type: 'array', items: { type: 'object', additionalProperties: false, - required: ['area', 'description', 'importance'], + required: ['file', 'description'], properties: { - area: { type: 'string' }, + file: { type: 'string' }, description: { type: 'string' }, - importance: { - type: 'string', - enum: ['low', 'medium', 'high'], - }, }, }, }, impact: { - type: 'object', - additionalProperties: false, - required: IMPACT_KEYS, - properties: { - api: { type: 'string', enum: ['low', 'medium', 'high'] }, - db: { type: 'string', enum: ['low', 'medium', 'high'] }, - security: { type: 'string', enum: ['low', 'medium', 'high'] }, - performance: { type: 'string', enum: ['low', 'medium', 'high'] }, - operations: { type: 'string', enum: ['low', 'medium', 'high'] }, - tests: { type: 'string', enum: ['low', 'medium', 'high'] }, - }, + type: 'array', + items: { type: 'string' }, }, checklist: { type: 'array', items: { type: 'string' }, }, - risks: { + breakingChanges: { + type: 'array', + items: { type: 'string' }, + }, + relatedIssues: { + type: 'array', + items: { type: 'string' }, + }, + dependencies: { type: 'array', items: { type: 'string' }, }, @@ -451,6 +450,20 @@ function validateStringArray(value, keyName) { }); } +function validateChangeEntry(change, index) { + if (!change || typeof change !== 'object' || Array.isArray(change)) { + throw new Error(`invalid-changes-${index}-type`); + } + + const file = asNonEmptyString(change.file, `changes-${index}-file`); + const description = asNonEmptyString( + change.description, + `changes-${index}-description`, + ); + + return { file, description }; +} + export function validateAiSummaryJson(payload) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new Error('invalid-root-object'); @@ -459,10 +472,13 @@ export function validateAiSummaryJson(payload) { const rootAllowedKeys = new Set([ 'title', 'summary', + 'summaryBullets', 'changes', 'impact', 'checklist', - 'risks', + 'breakingChanges', + 'relatedIssues', + 'dependencies', 'labels', ]); @@ -474,155 +490,99 @@ export function validateAiSummaryJson(payload) { const title = asNonEmptyString(payload.title, 'title'); const summary = asNonEmptyString(payload.summary, 'summary'); + const summaryBullets = validateStringArray(payload.summaryBullets, 'summaryBullets'); if (!Array.isArray(payload.changes)) { throw new Error('invalid-changes-type'); } - const changes = payload.changes.map((change, index) => { - if (!change || typeof change !== 'object' || Array.isArray(change)) { - throw new Error(`invalid-changes-${index}-type`); - } - - const changeAllowedKeys = new Set(['area', 'description', 'importance']); - - for (const key of Object.keys(change)) { - if (!changeAllowedKeys.has(key)) { - throw new Error(`invalid-changes-${index}-additional-property:${key}`); - } - } - - const area = asNonEmptyString(change.area, `changes-${index}-area`); - const description = asNonEmptyString( - change.description, - `changes-${index}-description`, - ); - - if (typeof change.importance !== 'string') { - throw new Error(`invalid-changes-${index}-importance-type`); - } - - const importance = change.importance.trim(); - - if (!IMPORTANCE_VALUES.has(importance)) { - throw new Error(`invalid-changes-${index}-importance-value`); - } - - return { - area, - description, - importance, - }; - }); - - const impact = payload.impact; - - if (!impact || typeof impact !== 'object' || Array.isArray(impact)) { - throw new Error('invalid-impact-type'); - } - - for (const key of Object.keys(impact)) { - if (!IMPACT_KEYS.includes(key)) { - throw new Error(`invalid-impact-additional-property:${key}`); - } - } - - const normalizedImpact = {}; - - for (const key of IMPACT_KEYS) { - if (typeof impact[key] !== 'string') { - throw new Error(`invalid-impact-${key}-type`); - } - - const value = impact[key].trim(); - - if (!IMPACT_VALUES.has(value)) { - throw new Error(`invalid-impact-${key}-value`); - } - - normalizedImpact[key] = value; - } + const changes = payload.changes.map((change, index) => + validateChangeEntry(change, index), + ); + const impact = validateStringArray(payload.impact, 'impact'); const checklist = validateStringArray(payload.checklist, 'checklist'); - const risks = validateStringArray(payload.risks, 'risks'); - + const breakingChanges = validateStringArray(payload.breakingChanges, 'breakingChanges'); + const relatedIssues = validateStringArray(payload.relatedIssues, 'relatedIssues'); + const dependencies = validateStringArray(payload.dependencies, 'dependencies'); const labels = validateStringArray(payload.labels, 'labels'); return { title, summary, + summaryBullets, changes, - impact: normalizedImpact, + impact, checklist, - risks, + breakingChanges, + relatedIssues, + dependencies, labels, }; } -function joinList(values, fallbackValue) { - if (!Array.isArray(values) || values.length === 0) { - return fallbackValue; +function renderOptionalSection(heading, items) { + if (!Array.isArray(items) || items.length === 0) { + return []; } - return values.join(', '); + return [ + '', + `### ${heading}`, + ...items.map((item) => `- ${item}`), + ]; } -export function renderSummaryBlock(summary, meta) { - const changeLines = - summary.changes.length > 0 - ? summary.changes.map( - (change) => - `- [${change.importance}] ${change.area}: ${change.description}`, - ) - : ['- [low] general: 변경사항 정보가 없습니다.']; - - const checklistLines = - summary.checklist.length > 0 - ? summary.checklist.map((item) => `- ${item}`) - : ['- 없음']; - - const riskLines = - summary.risks.length > 0 - ? summary.risks.map((item) => `- ${item}`) - : ['- 없음']; +export function renderSummaryBlock(summary) { + const lines = [MARKER_START]; - return [ - MARKER_START, - '## AI PR 요약', - '', - '### 제목 제안', - summary.title, - '', - '### 요약', - summary.summary, - '', - '### 변경사항', - ...changeLines, - '', - '### 영향도', - `- API: ${summary.impact.api}`, - `- DB: ${summary.impact.db}`, - `- Security: ${summary.impact.security}`, - `- Performance: ${summary.impact.performance}`, - `- Operations: ${summary.impact.operations}`, - `- Tests: ${summary.impact.tests}`, - '', - '### 체크리스트', - ...checklistLines, - '', - '### 리스크', - ...riskLines, - '', - '### 메타', - `- Diff Source: ${meta.diffSource}`, - `- Diff Bytes: ${meta.finalBytes}`, - `- Excluded Files: ${meta.excludedFilesCount}`, - `- Truncated: ${String(meta.truncated)}`, - `- Assignees Added: ${joinList(meta.assigneesAdded, 'none')}`, - `- Labels Added: ${joinList(meta.labelsAdded, 'none')}`, - `- Unknown Labels Ignored: ${meta.unknownLabelsIgnoredCount}`, - MARKER_END, - ].join('\n'); + // Summary + lines.push('### PR Summary'); + lines.push(summary.summary); + if (summary.summaryBullets.length > 0) { + lines.push(''); + for (const bullet of summary.summaryBullets) { + lines.push(`- ${bullet}`); + } + } + + // Changes (테이블) + lines.push(''); + lines.push('### Changes'); + lines.push(`> ${summary.changes.length} files changed`); + lines.push(''); + lines.push('| File | Changes |'); + lines.push('|------|---------|'); + for (const change of summary.changes) { + const escapedDesc = change.description.replaceAll('|', '\\|'); + lines.push(`| \`${change.file}\` | ${escapedDesc} |`); + } + + // Impact + if (summary.impact.length > 0) { + lines.push(''); + lines.push('### Impact'); + for (const item of summary.impact) { + lines.push(`- ${item}`); + } + } + + // Checklist (체크박스) + if (summary.checklist.length > 0) { + lines.push(''); + lines.push('### Checklist'); + for (const item of summary.checklist) { + lines.push(`- [ ] ${item}`); + } + } + + // 선택적 섹션들 + lines.push(...renderOptionalSection('Breaking Changes', summary.breakingChanges)); + lines.push(...renderOptionalSection('Related Issues', summary.relatedIssues)); + lines.push(...renderOptionalSection('Dependencies', summary.dependencies)); + + lines.push(MARKER_END); + return lines.join('\n'); } export function upsertSummaryBlock(prBody, block) { diff --git a/.github/scripts/pr-ai-description.mjs b/.github/scripts/pr-ai-description.mjs index 36c65b7..5d4a11e 100644 --- a/.github/scripts/pr-ai-description.mjs +++ b/.github/scripts/pr-ai-description.mjs @@ -1,6 +1,12 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs/promises'; +import { resolve } from 'node:path'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { traceable } from 'langsmith/traceable'; +import { wrapOpenAI } from 'langsmith/wrappers'; +import { OpenAI } from 'openai'; import { AI_RESPONSE_JSON_SCHEMA, @@ -17,7 +23,6 @@ import { upsertSummaryBlock, validateAiSummaryJson, } from './pr-ai-description-lib.mjs'; -import { createLangSmithTracer } from './langsmith-tracer.mjs'; const TARGET_ASSIGNEE = 'chanwoo7'; const DEFAULT_MAX_DIFF_BYTES = 102400; @@ -375,7 +380,7 @@ function collectDiffFromGit({ }; } -function buildOpenAiPrompt({ +export function buildOpenAiPrompt({ pr, repositoryLabels, diffText, @@ -394,8 +399,19 @@ function buildOpenAiPrompt({ return [ '다음 Pull Request 정보를 기반으로 한국어 PR 요약 JSON을 생성하세요.', - '코드 식별자/파일 경로/에러 메시지는 원문을 유지하세요.', - 'labels는 아래 제공된 레포 라벨 목록에서만 선택하세요.', + '', + '규칙:', + '- title은 conventional commits 형식의 prefix를 포함하세요 (feat:, fix:, refactor:, docs:, style:, perf:, test:, build:, ci:, chore:, revert: 중 택1). 예: "feat: 사용자 인증 기능 추가"', + '- summary는 PR 전체를 요약하는 한국어 텍스트를 작성하세요.', + '- summaryBullets는 핵심 변경사항을 간결한 한국어 리스트 항목으로 작성하세요. 불필요하면 빈 배열로 두세요.', + '- changes는 변경된 각 파일별로 { file, description } 형태로 작성하세요. file은 파일 경로 원문, description은 해당 파일의 변경 내용을 한국어 문장으로 작성하세요.', + '- impact는 이 PR이 시스템에 미치는 영향을 자유 형식 한국어 텍스트 리스트로 작성하세요. 영향이 없으면 빈 배열로 두세요.', + '- checklist는 리뷰어가 꼭 확인해야 할 항목들을 한국어로 작성하세요. 애매하거나 추상적인 항목은 피하고, 객관적이고 명확한 행동 지침을 간결하게 작성하세요.', + '- breakingChanges는 하위 호환성을 깨는 변경사항을 작성하세요. 없으면 빈 배열로 두세요.', + '- relatedIssues는 관련 이슈/PR을 "#번호 설명" 형태로 작성하세요. 없으면 빈 배열로 두세요.', + '- dependencies는 추가/제거/업데이트된 패키지 의존성을 작성하세요. 없으면 빈 배열로 두세요.', + '- labels는 아래 제공된 레포 라벨 목록에서만 선택하세요. 최대 3개까지 선택할 수 있으나, 꼭 필요한 경우가 아니면 1-2개로 제한하는 것을 권장합니다.', + '- 코드 식별자/파일 경로/에러 메시지는 원문을 유지하세요.', '', `PR Meta:\n${JSON.stringify(prMeta, null, 2)}`, '', @@ -427,46 +443,69 @@ function extractChatCompletionContent(responseData) { return ''; } -async function requestOpenAiSummary({ - openAiApiKey, +function serializeOpenAiErrorResponse(error) { + const detail = error?.error ?? error?.response ?? error?.body; + + if (typeof detail === 'string') { + return detail; + } + + if (detail && typeof detail === 'object') { + try { + return JSON.stringify(detail); + } catch { + return undefined; + } + } + + return undefined; +} + +function buildChatCompletionRequest({ + model, + messages, +}) { + const request = { + model, + messages, + response_format: { + type: 'json_schema', + json_schema: { + name: 'pr_ai_summary', + strict: true, + schema: AI_RESPONSE_JSON_SCHEMA, + }, + }, + }; + + // GPT-5 계열은 temperature 파라미터를 허용하지 않는다. + if (!model.startsWith('gpt-5')) { + request.temperature = 0.2; + } + + return request; +} + +export async function requestOpenAiSummary({ + openAiClient, model, messages, + langsmithExtra, }) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 45000); try { - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - Authorization: `Bearer ${openAiApiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + const responseData = await openAiClient.chat.completions.create( + buildChatCompletionRequest({ model, - temperature: 0.2, messages, - response_format: { - type: 'json_schema', - json_schema: { - name: 'pr_ai_summary', - strict: true, - schema: AI_RESPONSE_JSON_SCHEMA, - }, - }, }), - signal: controller.signal, - }); - - if (!response.ok) { - const rawBody = await response.text(); - const error = new Error(`openai-api-error:${response.status}`); - error.status = response.status; - error.response = rawBody; - throw error; - } - - const responseData = await response.json(); + { + signal: controller.signal, + langsmithExtra, + }, + ); const content = extractChatCompletionContent(responseData); if (!content || content.trim().length === 0) { @@ -489,10 +528,27 @@ async function requestOpenAiSummary({ throw validationError; } } catch (error) { - if (error.name === 'AbortError') { + if ( + error?.name === 'AbortError' || + error?.name === 'APIConnectionTimeoutError' || + error?.name === 'APIUserAbortError' + ) { throw new Error('openai-timeout'); } + if (typeof error?.status === 'number') { + const apiError = new Error(`openai-api-error:${error.status}`); + apiError.status = error.status; + + const rawResponse = serializeOpenAiErrorResponse(error); + + if (typeof rawResponse === 'string' && rawResponse.length > 0) { + apiError.response = rawResponse; + } + + throw apiError; + } + throw error; } finally { clearTimeout(timeoutId); @@ -635,361 +691,329 @@ async function run() { const maxFiles = parseIntegerEnv(process.env.PR_AI_MAX_FILES, DEFAULT_MAX_FILES); const applyTitle = true; const excludeGlobs = buildExcludeGlobs(); - const tracer = createLangSmithTracer({ - logger: (level, message, payload) => { - if (level === 'warn') { - logWarn(message, payload); - return; - } - - logInfo(message, payload); - }, - }); - - if (tracer.isEnabled()) { - logInfo('LangSmith tracing enabled', { - endpoint: tracer.config.endpoint, - projectName: tracer.config.projectName, - }); - } else { - logInfo('LangSmith tracing disabled', { - reason: tracer.reason, - }); - } - - const workflowRun = await tracer.startRun({ - name: 'pr-ai-description', - runType: 'chain', - inputs: { - repository: process.env.GITHUB_REPOSITORY ?? 'unknown', - eventName: process.env.GITHUB_EVENT_NAME ?? 'unknown', - actor: process.env.GITHUB_ACTOR ?? 'unknown', - model: openAiModel, - maxDiffBytes, - maxFiles, - applyTitle, - }, - }); - - try { - const payload = await readEventPayload(); - const pullRequest = payload.pull_request; + const openAiClient = wrapOpenAI(new OpenAI({ apiKey: openAiApiKey })); - if (!pullRequest) { - throw new Error('pull_request payload not found'); - } - - const isFork = pullRequest.head?.repo?.full_name !== payload.repository?.full_name; + const runWorkflow = traceable( + async () => { + const payload = await readEventPayload(); + const pullRequest = payload.pull_request; - if (isFork) { - logInfo('fork PR detected. skip by policy.'); - await writeStepSummary('- Fork PR detected: skipped by policy.'); - await tracer.endRun(workflowRun, { - status: 'skipped', - reason: 'fork-pr', - }); - return; - } + if (!pullRequest) { + throw new Error('pull_request payload not found'); + } - const { owner, repo } = parseRepository(); - const githubRequest = createGitHubRequest({ githubToken }); - const prNumber = pullRequest.number; - const baseSha = pullRequest.base?.sha; - const headSha = pullRequest.head?.sha; + const isFork = pullRequest.head?.repo?.full_name !== payload.repository?.full_name; - if (typeof baseSha !== 'string' || typeof headSha !== 'string') { - throw new Error('base/head sha missing from payload'); - } + if (isFork) { + logInfo('fork PR detected. skip by policy.'); + await writeStepSummary('- Fork PR detected: skipped by policy.'); + return { + status: 'skipped', + reason: 'fork-pr', + }; + } - let repositoryLabels = []; + const { owner, repo } = parseRepository(); + const githubRequest = createGitHubRequest({ githubToken }); + const prNumber = pullRequest.number; + const baseSha = pullRequest.base?.sha; + const headSha = pullRequest.head?.sha; - try { - repositoryLabels = await fetchRepositoryLabels(githubRequest, owner, repo); - } catch (error) { - if (isPermissionError(error)) { - logWarn('failed to read repository labels due to permission issue', { - status: error.status, - }); - await writeStepSummary( - '- Repository labels could not be loaded (permission issue).', - ); - repositoryLabels = []; - } else { - throw error; + if (typeof baseSha !== 'string' || typeof headSha !== 'string') { + throw new Error('base/head sha missing from payload'); } - } - const diffContext = await tracer.withRun( - { - name: 'collect-diff', - runType: 'chain', - parentRunId: workflowRun?.id, - inputs: { - baseSha: shortenSha(baseSha), - headSha: shortenSha(headSha), - maxFiles, - excludeGlobsCount: excludeGlobs.length, - }, - mapOutput: (value) => ({ - diffSource: value.diffSource, - diffEntriesCount: value.diffEntries.length, - excludedFilesCount: value.excludedFilesCount, - fallbackReason: value.fallbackReason ?? 'none', - }), - }, - async () => { - const compareResult = await tryCompareDiff({ - githubRequest, - owner, - repo, - baseSha, - headSha, - excludeGlobs, - maxFiles, - }); - - let diffSource = 'compare'; - let diffEntries = compareResult.entries; - let excludedFilesCount = compareResult.excludedFilesCount; - let fallbackReason = null; - - if (compareResult.useFallback) { - logWarn('compare diff unavailable. fallback to git diff.', { - reason: compareResult.reason, + let repositoryLabels = []; + + try { + repositoryLabels = await fetchRepositoryLabels(githubRequest, owner, repo); + } catch (error) { + if (isPermissionError(error)) { + logWarn('failed to read repository labels due to permission issue', { + status: error.status, }); + await writeStepSummary( + '- Repository labels could not be loaded (permission issue).', + ); + repositoryLabels = []; + } else { + throw error; + } + } - const gitResult = collectDiffFromGit({ + const collectDiff = traceable( + async () => { + const compareResult = await tryCompareDiff({ + githubRequest, + owner, + repo, baseSha, headSha, excludeGlobs, maxFiles, }); - diffSource = 'git'; - diffEntries = gitResult.entries; - excludedFilesCount = gitResult.excludedFilesCount; - fallbackReason = compareResult.reason; - } - - return { - diffSource, - diffEntries, - excludedFilesCount, - fallbackReason, - }; - }, - ); + let diffSource = 'compare'; + let diffEntries = compareResult.entries; + let excludedFilesCount = compareResult.excludedFilesCount; + let fallbackReason = null; + + if (compareResult.useFallback) { + logWarn('compare diff unavailable. fallback to git diff.', { + reason: compareResult.reason, + }); + + const gitResult = collectDiffFromGit({ + baseSha, + headSha, + excludeGlobs, + maxFiles, + }); + + diffSource = 'git'; + diffEntries = gitResult.entries; + excludedFilesCount = gitResult.excludedFilesCount; + fallbackReason = compareResult.reason; + } + + return { + diffSource, + diffEntries, + excludedFilesCount, + fallbackReason, + }; + }, + { + name: 'collect-diff', + run_type: 'chain', + metadata: { + baseSha: shortenSha(baseSha), + headSha: shortenSha(headSha), + maxFiles, + excludeGlobsCount: excludeGlobs.length, + }, + processOutputs: (value) => ({ + diffSource: value.diffSource, + diffEntriesCount: value.diffEntries.length, + excludedFilesCount: value.excludedFilesCount, + fallbackReason: value.fallbackReason ?? 'none', + }), + }, + ); - const { diffSource, diffEntries, excludedFilesCount } = diffContext; + const diffContext = await collectDiff(); - if (diffEntries.length === 0) { - throw new Error('no-diff-entries-for-ai'); - } + const { diffSource, diffEntries, excludedFilesCount } = diffContext; - const maskedEntries = diffEntries.map((entry) => ({ - ...entry, - patch: maskSensitiveContent(entry.patch), - })); + if (diffEntries.length === 0) { + throw new Error('no-diff-entries-for-ai'); + } - const limitedDiff = buildLimitedDiff(maskedEntries, maxDiffBytes); - const maskedDiff = limitedDiff.diffText; + const maskedEntries = diffEntries.map((entry) => ({ + ...entry, + patch: maskSensitiveContent(entry.patch), + })); - if (maskedDiff.trim().length === 0) { - throw new Error('masked-diff-is-empty'); - } + const limitedDiff = buildLimitedDiff(maskedEntries, maxDiffBytes); + const maskedDiff = limitedDiff.diffText; - const prompt = buildOpenAiPrompt({ - pr: pullRequest, - repositoryLabels, - diffText: maskedDiff, - }); + if (maskedDiff.trim().length === 0) { + throw new Error('masked-diff-is-empty'); + } - const openAiMessages = [ - { - role: 'system', - content: - 'You are a senior backend engineer. Return only JSON that matches the schema.', - }, - { - role: 'user', - content: prompt, - }, - ]; + const prompt = buildOpenAiPrompt({ + pr: pullRequest, + repositoryLabels, + diffText: maskedDiff, + }); - const aiSummary = await tracer.withRun( - { - name: 'generate-ai-summary', - runType: 'llm', - parentRunId: workflowRun?.id, - inputs: { - messages: openAiMessages, + const openAiMessages = [ + { + role: 'system', + content: + 'You are a senior backend engineer. Return only JSON that matches the schema.', }, - extra: { - metadata: { + { + role: 'user', + content: prompt, + }, + ]; + + const generateAiSummary = traceable( + async () => + requestOpenAiSummary({ + openAiClient, model: openAiModel, + messages: openAiMessages, + langsmithExtra: { + metadata: { + diffSource, + diffBytes: limitedDiff.meta.finalBytes, + truncated: limitedDiff.meta.truncated, + prNumber, + }, + }, + }), + { + name: 'generate-ai-summary', + run_type: 'chain', + metadata: { diffSource, diffBytes: limitedDiff.meta.finalBytes, truncated: limitedDiff.meta.truncated, + prNumber, }, + processOutputs: (summary) => ({ + title: summary.title, + changesCount: summary.changes.length, + labels: summary.labels, + }), }, - mapOutput: (summary) => ({ - choices: [ - { - message: { - role: 'assistant', - content: JSON.stringify(summary, null, 2), - }, - }, - ], - }), - }, - async () => - requestOpenAiSummary({ - openAiApiKey, - model: openAiModel, - messages: openAiMessages, - }), - ); + ); - const currentAssignees = uniqueStringList( - Array.isArray(pullRequest.assignees) - ? pullRequest.assignees.map((assignee) => assignee?.login) - : [], - ); + const aiSummary = await generateAiSummary(); - const currentLabels = uniqueStringList( - Array.isArray(pullRequest.labels) - ? pullRequest.labels.map((label) => label?.name) - : [], - ); + const currentAssignees = uniqueStringList( + Array.isArray(pullRequest.assignees) + ? pullRequest.assignees.map((assignee) => assignee?.login) + : [], + ); - const updateResult = await tracer.withRun( - { - name: 'apply-pr-updates', - runType: 'tool', - parentRunId: workflowRun?.id, - inputs: { - prNumber, - applyTitle, - }, - mapOutput: (value) => ({ - assigneesAddedCount: value.assigneesAdded.length, - labelsAddedCount: value.labelsAdded.length, - unknownLabelsIgnoredCount: value.unknownLabelsIgnoredCount, - titleUpdated: value.titleUpdated, - bodyUpdated: value.bodyUpdated, - }), - }, - async () => { - const assigneesAdded = await addAssignee({ - githubRequest, - owner, - repo, - number: prNumber, - currentAssignees, - }); - - const aiLabelCandidates = uniqueStringList(aiSummary.labels); - const { applicableLabels, unknownLabelsIgnoredCount } = filterKnownLabels( - aiLabelCandidates, - repositoryLabels, - ); - - const labelsToAdd = applicableLabels.filter( - (labelName) => - !currentLabels.some((current) => current.toLowerCase() === labelName.toLowerCase()), - ); - - const labelsAdded = await addLabels({ - githubRequest, - owner, - repo, - number: prNumber, - labelsToAdd, - }); - - const block = renderSummaryBlock(aiSummary, { - diffSource, - finalBytes: limitedDiff.meta.finalBytes, - excludedFilesCount, - truncated: limitedDiff.meta.truncated, - assigneesAdded, - labelsAdded, - unknownLabelsIgnoredCount, - }); - - const updatedBody = upsertSummaryBlock(pullRequest.body ?? '', block); - - const titleShouldChange = shouldApplyTitle({ - applyTitle, - aiTitle: aiSummary.title, - existingTitle: pullRequest.title, - labelNames: currentLabels, - }); - - const nextTitle = titleShouldChange ? aiSummary.title : undefined; - const bodyUpdated = updatedBody !== (pullRequest.body ?? ''); - - if (bodyUpdated || typeof nextTitle === 'string') { - await patchPullRequest({ + const currentLabels = uniqueStringList( + Array.isArray(pullRequest.labels) + ? pullRequest.labels.map((label) => label?.name) + : [], + ); + + const applyPrUpdates = traceable( + async () => { + const assigneesAdded = await addAssignee({ githubRequest, owner, repo, number: prNumber, - title: nextTitle, - body: updatedBody, + currentAssignees, }); - } - return { - assigneesAdded, - labelsAdded, - unknownLabelsIgnoredCount, - titleUpdated: typeof nextTitle === 'string', - bodyUpdated, - }; - }, - ); + const aiLabelCandidates = uniqueStringList(aiSummary.labels); + const { applicableLabels, unknownLabelsIgnoredCount } = filterKnownLabels( + aiLabelCandidates, + repositoryLabels, + ); - const { labelsAdded, unknownLabelsIgnoredCount } = updateResult; + const labelsToAdd = applicableLabels.filter( + (labelName) => + !currentLabels.some((current) => current.toLowerCase() === labelName.toLowerCase()), + ); - logInfo('PR AI description update completed', { - diffSource, - finalBytes: limitedDiff.meta.finalBytes, - excludedFilesCount, - truncated: limitedDiff.meta.truncated, - labelsAppliedCount: labelsAdded.length, - unknownLabelsIgnoredCount, - }); + const labelsAdded = await addLabels({ + githubRequest, + owner, + repo, + number: prNumber, + labelsToAdd, + }); - await writeStepSummary('## PR AI Summary Result'); - await writeStepSummary(`- Diff Source: ${diffSource}`); - await writeStepSummary(`- Diff Bytes: ${limitedDiff.meta.finalBytes}`); - await writeStepSummary(`- Excluded Files: ${excludedFilesCount}`); - await writeStepSummary(`- Truncated: ${String(limitedDiff.meta.truncated)}`); - await writeStepSummary(`- Labels Added: ${labelsAdded.join(', ') || 'none'}`); - await writeStepSummary( - `- Unknown Labels Ignored: ${unknownLabelsIgnoredCount}`, - ); + const block = renderSummaryBlock(aiSummary); - await tracer.endRun(workflowRun, { - status: 'success', - prNumber, - diffSource, - diffBytes: limitedDiff.meta.finalBytes, - truncated: limitedDiff.meta.truncated, - labelsAddedCount: labelsAdded.length, - unknownLabelsIgnoredCount, - }); - } catch (error) { - await tracer.failRun(workflowRun, error, { - status: 'failed', - }); - throw error; - } + const updatedBody = upsertSummaryBlock(pullRequest.body ?? '', block); + + const titleShouldChange = shouldApplyTitle({ + applyTitle, + aiTitle: aiSummary.title, + existingTitle: pullRequest.title, + labelNames: currentLabels, + }); + + const nextTitle = titleShouldChange ? aiSummary.title : undefined; + const bodyUpdated = updatedBody !== (pullRequest.body ?? ''); + + if (bodyUpdated || typeof nextTitle === 'string') { + await patchPullRequest({ + githubRequest, + owner, + repo, + number: prNumber, + title: nextTitle, + body: updatedBody, + }); + } + + return { + assigneesAdded, + labelsAdded, + unknownLabelsIgnoredCount, + titleUpdated: typeof nextTitle === 'string', + bodyUpdated, + }; + }, + { + name: 'apply-pr-updates', + run_type: 'tool', + metadata: { + prNumber, + applyTitle, + }, + processOutputs: (value) => ({ + assigneesAddedCount: value.assigneesAdded.length, + labelsAddedCount: value.labelsAdded.length, + unknownLabelsIgnoredCount: value.unknownLabelsIgnoredCount, + titleUpdated: value.titleUpdated, + bodyUpdated: value.bodyUpdated, + }), + }, + ); + + const updateResult = await applyPrUpdates(); + + const { labelsAdded, unknownLabelsIgnoredCount } = updateResult; + + logInfo('PR AI description update completed', { + diffSource, + finalBytes: limitedDiff.meta.finalBytes, + excludedFilesCount, + truncated: limitedDiff.meta.truncated, + labelsAppliedCount: labelsAdded.length, + unknownLabelsIgnoredCount, + }); + + await writeStepSummary('## PR AI Summary Result'); + await writeStepSummary(`- Labels Added: ${labelsAdded.join(', ') || 'none'}`); + await writeStepSummary(`- Unknown Labels Ignored: ${unknownLabelsIgnoredCount}`); + + return { + status: 'success', + prNumber, + diffSource, + diffBytes: limitedDiff.meta.finalBytes, + truncated: limitedDiff.meta.truncated, + labelsAddedCount: labelsAdded.length, + unknownLabelsIgnoredCount, + }; + }, + { + name: 'pr-ai-description', + run_type: 'chain', + metadata: { + repository: process.env.GITHUB_REPOSITORY ?? 'unknown', + eventName: process.env.GITHUB_EVENT_NAME ?? 'unknown', + actor: process.env.GITHUB_ACTOR ?? 'unknown', + model: openAiModel, + maxDiffBytes, + maxFiles, + applyTitle, + }, + }, + ); + + return runWorkflow(); } -run().catch(async (error) => { +const isDirectRun = + process.argv[1] && + fileURLToPath(import.meta.url) === resolve(process.argv[1]); + +if (isDirectRun) run().catch(async (error) => { const rawResponse = typeof error?.response === 'string' && error.response.length > 0 ? error.response diff --git a/.github/scripts/pr-ai-dry-run.mjs b/.github/scripts/pr-ai-dry-run.mjs new file mode 100644 index 0000000..aaa3aed --- /dev/null +++ b/.github/scripts/pr-ai-dry-run.mjs @@ -0,0 +1,439 @@ +/** + * PR AI Dry-Run 스크립트 + * + * 기존 PR AI 파이프라인과 동일한 흐름으로 OpenAI 요약을 생성하되, + * 실제 PR 업데이트 없이 결과만 콘솔에 출력한다. + * + * 사용법: + * # 기존 PR 기반 (GitHub API로 diff 수집) + * OPENAI_API_KEY=sk-xxx node .github/scripts/pr-ai-dry-run.mjs --pr 42 + * + * # 현재 브랜치 기반 (로컬 git diff) + * OPENAI_API_KEY=sk-xxx node .github/scripts/pr-ai-dry-run.mjs + */ + +import { execFileSync } from 'node:child_process'; +import { parseArgs } from 'node:util'; +import process from 'node:process'; + +import { traceable } from 'langsmith/traceable'; +import { wrapOpenAI } from 'langsmith/wrappers'; +import { OpenAI } from 'openai'; + +import { + buildExcludeGlobs, + buildLimitedDiff, + filterDiffFiles, + filterKnownLabels, + maskSensitiveContent, + normalizeDiffEntries, + renderSummaryBlock, + toGitDiffFilePath, +} from './pr-ai-description-lib.mjs'; + +import { + buildOpenAiPrompt, + requestOpenAiSummary, +} from './pr-ai-description.mjs'; + +const DEFAULT_MAX_DIFF_BYTES = 102400; +const DEFAULT_MAX_FILES = 300; +const DEFAULT_OPENAI_MODEL = 'gpt-4.1-mini'; +const DEFAULT_BASE_BRANCH = 'main'; + +function log(message) { + console.log(`[dry-run] ${message}`); +} + +function logWarn(message) { + console.warn(`[dry-run][warn] ${message}`); +} + +function runGit(args) { + return execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function runGh(args) { + return execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function mapGitStatus(rawStatus) { + if (rawStatus.startsWith('R')) return 'renamed'; + if (rawStatus.startsWith('A')) return 'added'; + if (rawStatus.startsWith('D')) return 'removed'; + return 'modified'; +} + +function parseNameStatus(text) { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((row) => { + const parts = row.split('\t'); + if (parts.length < 2) return null; + + const rawStatus = parts[0]; + if (rawStatus.startsWith('R') && parts.length >= 3) { + return { + status: mapGitStatus(rawStatus), + previous_filename: parts[1], + filename: parts[2], + }; + } + + return { + status: mapGitStatus(rawStatus), + filename: parts[1], + }; + }) + .filter(Boolean); +} + +// -- PR 모드: GitHub API로 PR 정보 + diff 수집 -- + +function fetchPrData(prNumber) { + const raw = runGh([ + 'api', + `repos/{owner}/{repo}/pulls/${prNumber}`, + '--jq', + '.', + ]); + + return JSON.parse(raw); +} + +function fetchCompareDiff(baseSha, headSha) { + const raw = runGh([ + 'api', + `repos/{owner}/{repo}/compare/${baseSha}...${headSha}`, + '--jq', + '.files', + ]); + + return JSON.parse(raw); +} + +function fetchRepositoryLabels() { + try { + const raw = runGh([ + 'api', + 'repos/{owner}/{repo}/labels', + '--paginate', + '--jq', + '.[].name', + ]); + + return raw + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } catch { + logWarn('레포 라벨 조회 실패. 빈 목록으로 진행합니다.'); + return []; + } +} + +async function runWithPr(prNumber, options) { + log(`PR #${prNumber} 정보 조회 중...`); + const pr = fetchPrData(prNumber); + const baseSha = pr.base?.sha; + const headSha = pr.head?.sha; + + if (!baseSha || !headSha) { + throw new Error('PR에서 base/head SHA를 가져올 수 없습니다.'); + } + + log(`diff 수집 중... (${baseSha.slice(0, 8)}...${headSha.slice(0, 8)})`); + const compareFiles = fetchCompareDiff(baseSha, headSha); + + const excludeGlobs = buildExcludeGlobs(); + const { included } = filterDiffFiles(compareFiles, excludeGlobs); + + if (included.length === 0) { + throw new Error('필터링 후 diff 파일이 없습니다.'); + } + + const diffEntries = normalizeDiffEntries(included); + const repositoryLabels = fetchRepositoryLabels(); + + return generateAndPrint({ + pr, + diffEntries, + diffSource: 'compare', + + repositoryLabels, + ...options, + }); +} + +// -- 브랜치 모드: 로컬 git diff 수집 -- + +async function runWithBranch(options) { + const baseBranch = options.baseBranch; + const currentBranch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']); + + log(`현재 브랜치: ${currentBranch}, 비교 대상: ${baseBranch}`); + + const mergeBase = runGit(['merge-base', baseBranch, 'HEAD']); + const range = `${mergeBase}...HEAD`; + + log(`diff 수집 중... (${mergeBase.slice(0, 8)}...HEAD)`); + + const nameStatus = runGit([ + 'diff', + '--no-color', + '--diff-algorithm=histogram', + '--name-status', + range, + ]); + + if (!nameStatus) { + throw new Error(`${baseBranch} 대비 변경사항이 없습니다.`); + } + + const parsedFiles = parseNameStatus(nameStatus); + + if (parsedFiles.length > options.maxFiles) { + throw new Error(`파일 수 초과: ${parsedFiles.length} > ${options.maxFiles}`); + } + + const excludeGlobs = buildExcludeGlobs(); + const { included } = filterDiffFiles(parsedFiles, excludeGlobs); + + if (included.length === 0) { + throw new Error('필터링 후 diff 파일이 없습니다.'); + } + + const entries = included.map((file) => { + const patch = runGit([ + 'diff', + '--no-color', + '--diff-algorithm=histogram', + range, + '--', + toGitDiffFilePath(file.filename), + ]); + return { ...file, patch }; + }); + + const diffEntries = normalizeDiffEntries(entries); + + // 브랜치 모드에서는 PR 메타를 git 정보로 구성 + const pr = { + number: 0, + title: currentBranch, + user: { login: runGit(['config', 'user.name']) || 'local' }, + base: { ref: baseBranch }, + head: { ref: currentBranch }, + commits: parseInt(runGit(['rev-list', '--count', range]), 10) || 0, + changed_files: diffEntries.length, + additions: 0, + deletions: 0, + }; + + let repositoryLabels = []; + + try { + repositoryLabels = fetchRepositoryLabels(); + } catch { + // gh CLI 없거나 인증 안 된 경우 빈 목록으로 진행 + } + + return generateAndPrint({ + pr, + diffEntries, + diffSource: 'git', + + repositoryLabels, + ...options, + }); +} + +// -- 공통: OpenAI 호출 + 결과 출력 -- + +async function generateAndPrint({ + pr, + diffEntries, + diffSource, + repositoryLabels, + openAiClient, + openAiModel, + maxDiffBytes, +}) { + const runDryRun = traceable( + async () => { + const maskedEntries = diffEntries.map((entry) => ({ + ...entry, + patch: maskSensitiveContent(entry.patch), + })); + + const limitedDiff = buildLimitedDiff(maskedEntries, maxDiffBytes); + + if (limitedDiff.diffText.trim().length === 0) { + throw new Error('마스킹 후 diff가 비어있습니다.'); + } + + log( + `diff 준비 완료 (${limitedDiff.meta.finalBytes} bytes, ${diffEntries.length}개 파일, truncated: ${limitedDiff.meta.truncated})`, + ); + + const prompt = buildOpenAiPrompt({ + pr, + repositoryLabels, + diffText: limitedDiff.diffText, + }); + + const openAiMessages = [ + { + role: 'system', + content: 'You are a senior backend engineer. Return only JSON that matches the schema.', + }, + { + role: 'user', + content: prompt, + }, + ]; + + log(`OpenAI 호출 중... (model: ${openAiModel})`); + + const generateAiSummary = traceable( + async () => + requestOpenAiSummary({ + openAiClient, + model: openAiModel, + messages: openAiMessages, + langsmithExtra: { + metadata: { + diffSource, + diffBytes: limitedDiff.meta.finalBytes, + truncated: limitedDiff.meta.truncated, + prNumber: pr.number, + }, + }, + }), + { + name: 'generate-ai-summary', + run_type: 'chain', + metadata: { + diffSource, + diffBytes: limitedDiff.meta.finalBytes, + truncated: limitedDiff.meta.truncated, + prNumber: pr.number, + }, + processOutputs: (summary) => ({ + title: summary.title, + changesCount: summary.changes.length, + labels: summary.labels, + }), + }, + ); + + const aiSummary = await generateAiSummary(); + + log('AI 요약 생성 완료\n'); + + const { applicableLabels, unknownLabelsIgnoredCount } = filterKnownLabels( + aiSummary.labels, + repositoryLabels, + ); + const block = renderSummaryBlock(aiSummary); + + console.log('='.repeat(60)); + console.log(' AI 제목 제안'); + console.log('='.repeat(60)); + console.log(aiSummary.title); + console.log(); + console.log('='.repeat(60)); + console.log(' 적용될 라벨'); + console.log('='.repeat(60)); + console.log(applicableLabels.length > 0 ? applicableLabels.join(', ') : '(없음)'); + if (unknownLabelsIgnoredCount > 0) { + console.log(` (레포에 없는 라벨 ${unknownLabelsIgnoredCount}개 무시됨)`); + } + console.log(); + console.log('='.repeat(60)); + console.log(' PR 본문에 삽입될 AI 요약 블록'); + console.log('='.repeat(60)); + console.log(block); + + return { + status: 'success', + diffSource, + diffBytes: limitedDiff.meta.finalBytes, + truncated: limitedDiff.meta.truncated, + labelsCount: applicableLabels.length, + unknownLabelsIgnoredCount, + }; + }, + { + name: 'pr-ai-dry-run', + run_type: 'chain', + metadata: { + mode: pr.number > 0 ? 'pr' : 'branch', + prNumber: pr.number, + model: openAiModel, + maxDiffBytes, + diffSource, + }, + }, + ); + + return runDryRun(); +} + +// -- 진입점 -- + +async function main() { + const { values } = parseArgs({ + options: { + pr: { type: 'string' }, + model: { type: 'string' }, + base: { type: 'string' }, + 'max-diff-bytes': { type: 'string' }, + 'max-files': { type: 'string' }, + }, + strict: false, + }); + + const openAiApiKey = process.env.OPENAI_API_KEY; + + if (!openAiApiKey) { + console.error('[dry-run] OPENAI_API_KEY 환경변수가 필요합니다.'); + process.exit(1); + } + + const openAiClient = wrapOpenAI(new OpenAI({ apiKey: openAiApiKey })); + + const options = { + openAiClient, + openAiModel: values.model || process.env.OPENAI_MODEL || DEFAULT_OPENAI_MODEL, + maxDiffBytes: parseInt(values['max-diff-bytes'] ?? '', 10) || DEFAULT_MAX_DIFF_BYTES, + maxFiles: parseInt(values['max-files'] ?? '', 10) || DEFAULT_MAX_FILES, + baseBranch: values.base || DEFAULT_BASE_BRANCH, + }; + + if (values.pr) { + const prNumber = parseInt(values.pr, 10); + + if (Number.isNaN(prNumber) || prNumber <= 0) { + console.error(`[dry-run] 잘못된 PR 번호: ${values.pr}`); + process.exit(1); + } + + await runWithPr(prNumber, options); + } else { + await runWithBranch(options); + } +} + +main().catch((error) => { + console.error(`[dry-run] 실패: ${error.message}`); + process.exit(1); +}); diff --git a/package.json b/package.json index f0754ea..c2b3e46 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "globals": "^16.0.0", "jest": "^29", "langsmith": "^0.5.7", + "openai": "^6.27.0", "prettier": "^3.4.2", "prisma": "^6.2.0", "source-map-support": "^0.5.21", diff --git a/yarn.lock b/yarn.lock index f3c9332..02c35c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5308,6 +5308,7 @@ __metadata: jest: "npm:^29" langsmith: "npm:^0.5.7" logform: "npm:^2.7.0" + openai: "npm:^6.27.0" openid-client: "npm:5.7.1" passport: "npm:^0.7.0" passport-jwt: "npm:^4.0.1" @@ -11331,6 +11332,23 @@ __metadata: languageName: node linkType: hard +"openai@npm:^6.27.0": + version: 6.27.0 + resolution: "openai@npm:6.27.0" + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + bin: + openai: bin/cli + checksum: 10c0/88f9205eb1bd6c86c3d7fdab3e3d171c0d03c1e491903084aef35bfeb34fd33b86cb91b4d0eef9068e5bd5406f9dedb74a9a8b9c7c01e3a348a7edf117cbf78e + languageName: node + linkType: hard + "openid-client@npm:5.7.1": version: 5.7.1 resolution: "openid-client@npm:5.7.1" From d187b20e1fa43e47bd7b698eaaafc890ac8436da Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 15 Mar 2026 00:50:13 +0900 Subject: [PATCH 09/13] =?UTF-8?q?ci:=20.coderabbit.yaml=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coderabbit.yaml | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..f38df5c --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,79 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "ko-KR" + +reviews: + profile: "chill" + request_changes_workflow: false + commit_status: true + fail_commit_status: false + high_level_summary: true + high_level_summary_in_walkthrough: false + poem: false + in_progress_fortune: false + suggested_labels: false + auto_apply_labels: false + suggested_reviewers: false + + auto_review: + enabled: true + auto_incremental_review: true + drafts: false + + path_filters: + - "!src/graphql/graphql.types.ts" + - "!public/**" + - "!**/*.min.js" + - "!**/*.min.css" + + path_instructions: + - path: "src/**/*.ts" + instructions: | + NestJS feature-first 구조를 기준으로 리뷰하세요. + - Resolver는 I/O 조립만 담당해야 하고 비즈니스 로직은 Service로 이동해야 합니다. + - Service/Resolver가 PrismaService 또는 PrismaClient에 직접 접근하면 안 됩니다. Repository를 통해서만 DB 접근해야 합니다. + - export 되는 함수/클래스는 입력/출력 타입이 명확해야 하며 any 사용은 허용하지 않습니다. + - 인증/로깅/에러 처리에서 민감정보가 노출되지 않는지 확인하세요. + - 기능 변경에는 테스트가 함께 추가되었는지 확인하세요. + + - path: "src/features/**/*.graphql" + instructions: | + Schema-first GraphQL 규칙을 기준으로 리뷰하세요. + - 각 도메인은 extend type Query / Mutation 패턴을 사용해야 합니다. + - SDL 변경이 Resolver 이름, 입력/출력 타입과 일관되는지 확인하세요. + + - path: "prisma/**" + instructions: | + Prisma 스키마와 마이그레이션은 데이터 안전성을 중심으로 리뷰하세요. + - nullable/default/index/unique/관계 변경이 기존 데이터와 호환되는지 확인하세요. + - 파괴적 변경이나 운영 반영 시 위험한 변경은 명확히 지적하세요. + + - path: ".github/workflows/**/*.yml" + instructions: | + GitHub Actions는 최소 권한, 시크릿 노출 방지, fork 안전성, idempotency를 중심으로 리뷰하세요. + + - path: "test/**/*.ts" + instructions: | + 테스트는 시간/uuid/네트워크/DB 의존성을 mock 또는 stub으로 통제하는지, + 정상 흐름뿐 아니라 주요 예외/분기 케이스가 포함되는지 확인하세요. + + pre_merge_checks: + title: + mode: warning + requirements: "PR 제목은 변경 내용을 간결하게 설명해야 합니다." + description: + mode: off + +knowledge_base: + code_guidelines: + enabled: true + learnings: + scope: local + issues: + scope: local + pull_requests: + scope: local + web_search: + enabled: true + +chat: + auto_reply: false From 3cbc7f26aea0b104fe13e5ced332aa24594582cb Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 15 Mar 2026 00:51:54 +0900 Subject: [PATCH 10/13] =?UTF-8?q?ci:=20PR=20AI=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EC=8B=9C=20CodeRabbit=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/pr-ai-description-lib.spec.mjs | 30 +++++ .../__tests__/pr-ai-description.spec.mjs | 69 +++++++++++- .github/scripts/pr-ai-description-lib.mjs | 14 +++ .github/scripts/pr-ai-description.mjs | 104 +++++++++++++++--- 4 files changed, 201 insertions(+), 16 deletions(-) diff --git a/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs b/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs index e7c5cf0..8379ec7 100644 --- a/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs +++ b/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs @@ -126,6 +126,36 @@ test('마커 블록이 있으면 교체하고 없으면 하단에 추가한다', assert.doesNotMatch(replaced, /\nold\n/); }); +test('CodeRabbit summary가 이미 있으면 그 앞에 PR AI 블록을 끼워 넣는다', () => { + const summary = { + title: 'docs: PR 요약 포맷 조정', + summary: '요약', + summaryBullets: [], + changes: [{ file: '.github/scripts/pr-ai-description-lib.mjs', description: '삽입 순서 조정' }], + impact: [], + checklist: [], + breakingChanges: [], + relatedIssues: [], + dependencies: [], + labels: [], + }; + + const block = renderSummaryBlock(summary); + const body = [ + '기존 본문', + '', + '## Summary by CodeRabbit', + '기존 CodeRabbit summary', + ].join('\n'); + + const updated = upsertSummaryBlock(body, block); + + assert.match(updated, /기존 본문/); + assert.match(updated, //); + assert.match(updated, /## Summary by CodeRabbit/); + assert.ok(updated.indexOf('') < updated.indexOf('## Summary by CodeRabbit')); +}); + test('Compare API fallback 조건에서 patch 누락 1개만 있어도 fallback 된다', () => { const files = [ { filename: 'src/a.ts', status: 'modified', patch: '@@ -1 +1 @@' }, diff --git a/.github/scripts/__tests__/pr-ai-description.spec.mjs b/.github/scripts/__tests__/pr-ai-description.spec.mjs index 28e7328..b8edbff 100644 --- a/.github/scripts/__tests__/pr-ai-description.spec.mjs +++ b/.github/scripts/__tests__/pr-ai-description.spec.mjs @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { requestOpenAiSummary } from '../pr-ai-description.mjs'; +import { + buildPrUpdatePayload, + loadCurrentPullRequest, + requestOpenAiSummary, +} from '../pr-ai-description.mjs'; function createFakeOpenAiClient(createImpl) { return { @@ -202,7 +206,68 @@ test('abort 계열 에러를 openai-timeout 으로 변환한다', async () => { openAiClient, model: 'gpt-4.1-mini', messages: [{ role: 'user', content: 'test' }], - }), + }), /openai-timeout/, ); }); + +test('최신 PR body를 기준으로 PR AI 블록을 갱신해 CodeRabbit summary를 보존한다', () => { + const block = [ + '', + 'new summary', + '', + ].join('\n'); + const fallbackPullRequest = { + title: '기존 제목', + body: 'stale body', + labels: [], + }; + const currentPullRequest = { + title: '기존 제목', + body: [ + '기존 본문', + '', + '## Summary by CodeRabbit', + 'CodeRabbit summary', + ].join('\n'), + labels: [], + }; + + const result = buildPrUpdatePayload({ + currentPullRequest, + fallbackPullRequest, + block, + applyTitle: true, + aiTitle: 'feat: 새 제목', + }); + + assert.match(result.updatedBody, /기존 본문/); + assert.match(result.updatedBody, /## Summary by CodeRabbit/); + assert.ok( + result.updatedBody.indexOf('') < + result.updatedBody.indexOf('## Summary by CodeRabbit'), + ); + assert.equal(result.currentBody, currentPullRequest.body); +}); + +test('최신 PR 재조회가 실패하면 이벤트 payload를 fallback으로 사용한다', async () => { + const fallbackPullRequest = { + title: 'payload title', + body: 'payload body', + }; + + const pullRequest = await loadCurrentPullRequest({ + githubRequest: async () => { + const error = new Error('boom'); + error.status = 500; + throw error; + }, + owner: 'caquick', + repo: 'caquick-be', + number: 10, + fallbackPullRequest, + onWarn: () => {}, + }); + + assert.deepEqual(pullRequest, fallbackPullRequest); +}); diff --git a/.github/scripts/pr-ai-description-lib.mjs b/.github/scripts/pr-ai-description-lib.mjs index 09f4ba3..c4b0771 100644 --- a/.github/scripts/pr-ai-description-lib.mjs +++ b/.github/scripts/pr-ai-description-lib.mjs @@ -590,6 +590,7 @@ export function upsertSummaryBlock(prBody, block) { const escapedStart = MARKER_START.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedEnd = MARKER_END.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); const blockPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm'); + const codeRabbitSummaryPattern = /^## Summary by CodeRabbit\b/m; if (blockPattern.test(existingBody)) { return existingBody.replace(blockPattern, block); @@ -599,6 +600,19 @@ export function upsertSummaryBlock(prBody, block) { return block; } + const codeRabbitMatch = codeRabbitSummaryPattern.exec(existingBody); + + if (codeRabbitMatch && typeof codeRabbitMatch.index === 'number') { + const beforeSummary = existingBody.slice(0, codeRabbitMatch.index).trimEnd(); + const summarySection = existingBody.slice(codeRabbitMatch.index).trimStart(); + + if (beforeSummary.length === 0) { + return `${block}\n\n${summarySection}`; + } + + return `${beforeSummary}\n\n${block}\n\n${summarySection}`; + } + return `${existingBody.trimEnd()}\n\n${block}`; } diff --git a/.github/scripts/pr-ai-description.mjs b/.github/scripts/pr-ai-description.mjs index 5d4a11e..7bab239 100644 --- a/.github/scripts/pr-ai-description.mjs +++ b/.github/scripts/pr-ai-description.mjs @@ -209,6 +209,34 @@ async function fetchRepositoryLabels(githubRequest, owner, repo) { return labels; } +async function fetchPullRequest(githubRequest, owner, repo, number) { + return githubRequest('GET', `/repos/${owner}/${repo}/pulls/${number}`); +} + +export async function loadCurrentPullRequest({ + githubRequest, + owner, + repo, + number, + fallbackPullRequest, + onWarn = logWarn, +}) { + try { + const latestPullRequest = await fetchPullRequest(githubRequest, owner, repo, number); + + if (latestPullRequest && typeof latestPullRequest === 'object') { + return latestPullRequest; + } + } catch (error) { + onWarn('failed to fetch latest pull request. fallback to event payload.', { + status: error?.status, + message: error?.message ?? 'unknown-error', + }); + } + + return fallbackPullRequest; +} + async function tryCompareDiff({ githubRequest, owner, @@ -680,6 +708,56 @@ function shortenSha(sha) { return sha.slice(0, 12); } +function extractLabelNames(pullRequest) { + return uniqueStringList( + Array.isArray(pullRequest?.labels) + ? pullRequest.labels.map((label) => label?.name) + : [], + ); +} + +export function buildPrUpdatePayload({ + currentPullRequest, + fallbackPullRequest, + block, + applyTitle, + aiTitle, +}) { + const effectivePullRequest = + currentPullRequest && typeof currentPullRequest === 'object' + ? currentPullRequest + : fallbackPullRequest; + + const currentBody = + typeof effectivePullRequest?.body === 'string' ? effectivePullRequest.body : ''; + const currentTitle = + typeof effectivePullRequest?.title === 'string' + ? effectivePullRequest.title + : fallbackPullRequest?.title; + const effectiveLabelNames = extractLabelNames(effectivePullRequest); + const fallbackLabelNames = extractLabelNames(fallbackPullRequest); + const currentLabelNames = + effectiveLabelNames.length > 0 ? effectiveLabelNames : fallbackLabelNames; + + const updatedBody = upsertSummaryBlock(currentBody, block); + const titleShouldChange = shouldApplyTitle({ + applyTitle, + aiTitle, + existingTitle: currentTitle, + labelNames: currentLabelNames, + }); + const nextTitle = titleShouldChange ? aiTitle : undefined; + + return { + currentBody, + currentLabelNames, + currentTitle, + updatedBody, + nextTitle, + bodyUpdated: updatedBody !== currentBody, + }; +} + async function run() { const githubToken = ensureEnv('GITHUB_TOKEN'); const openAiApiKey = ensureEnv('OPENAI_API_KEY'); @@ -879,11 +957,7 @@ async function run() { : [], ); - const currentLabels = uniqueStringList( - Array.isArray(pullRequest.labels) - ? pullRequest.labels.map((label) => label?.name) - : [], - ); + const currentLabels = extractLabelNames(pullRequest); const applyPrUpdates = traceable( async () => { @@ -915,19 +989,21 @@ async function run() { }); const block = renderSummaryBlock(aiSummary); - - const updatedBody = upsertSummaryBlock(pullRequest.body ?? '', block); - - const titleShouldChange = shouldApplyTitle({ + const latestPullRequest = await loadCurrentPullRequest({ + githubRequest, + owner, + repo, + number: prNumber, + fallbackPullRequest: pullRequest, + }); + const { updatedBody, nextTitle, bodyUpdated } = buildPrUpdatePayload({ + currentPullRequest: latestPullRequest, + fallbackPullRequest: pullRequest, + block, applyTitle, aiTitle: aiSummary.title, - existingTitle: pullRequest.title, - labelNames: currentLabels, }); - const nextTitle = titleShouldChange ? aiSummary.title : undefined; - const bodyUpdated = updatedBody !== (pullRequest.body ?? ''); - if (bodyUpdated || typeof nextTitle === 'string') { await patchPullRequest({ githubRequest, From c8d5d731642ea9b7fd2834244357d1182aa4501a Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 16 Mar 2026 00:35:01 +0900 Subject: [PATCH 11/13] =?UTF-8?q?ci:=20PR=20AI=20=ED=94=84=EB=A1=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=EA=B0=80=EB=8F=85=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20CodeRabbit=20path=5Finstructions=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coderabbit.yaml | 13 ++++++++++++- .github/scripts/pr-ai-description.mjs | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index f38df5c..2c22155 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -51,11 +51,22 @@ reviews: instructions: | GitHub Actions는 최소 권한, 시크릿 노출 방지, fork 안전성, idempotency를 중심으로 리뷰하세요. - - path: "test/**/*.ts" + - path: "src/**/*.spec.ts" instructions: | 테스트는 시간/uuid/네트워크/DB 의존성을 mock 또는 stub으로 통제하는지, 정상 흐름뿐 아니라 주요 예외/분기 케이스가 포함되는지 확인하세요. + - path: "test/**/*.ts" + instructions: | + E2E 테스트는 실제 요청 흐름과 통합 시나리오를 검증하는지, + 테스트 환경 설정(DB, 서버 기동 등)이 올바르게 구성되어 있는지 확인하세요. + + - path: ".github/scripts/**" + instructions: | + CI 스크립트는 GitHub Actions 환경에서 실행되는 코드입니다. + - 환경 변수/시크릿 사용이 안전한지, 민감정보가 로그에 노출되지 않는지 확인하세요. + - 외부 API 호출 시 타임아웃과 에러 처리가 적절한지 확인하세요. + pre_merge_checks: title: mode: warning diff --git a/.github/scripts/pr-ai-description.mjs b/.github/scripts/pr-ai-description.mjs index 7bab239..abd4e28 100644 --- a/.github/scripts/pr-ai-description.mjs +++ b/.github/scripts/pr-ai-description.mjs @@ -430,7 +430,7 @@ export function buildOpenAiPrompt({ '', '규칙:', '- title은 conventional commits 형식의 prefix를 포함하세요 (feat:, fix:, refactor:, docs:, style:, perf:, test:, build:, ci:, chore:, revert: 중 택1). 예: "feat: 사용자 인증 기능 추가"', - '- summary는 PR 전체를 요약하는 한국어 텍스트를 작성하세요.', + '- summary는 PR 전체를 요약하는 한국어 텍스트를 작성하세요. 문장이 여러 개일 경우 마침표 뒤에 줄바꿈(\\n)을 삽입하여 문장을 분리하세요.', '- summaryBullets는 핵심 변경사항을 간결한 한국어 리스트 항목으로 작성하세요. 불필요하면 빈 배열로 두세요.', '- changes는 변경된 각 파일별로 { file, description } 형태로 작성하세요. file은 파일 경로 원문, description은 해당 파일의 변경 내용을 한국어 문장으로 작성하세요.', '- impact는 이 PR이 시스템에 미치는 영향을 자유 형식 한국어 텍스트 리스트로 작성하세요. 영향이 없으면 빈 배열로 두세요.', @@ -439,7 +439,7 @@ export function buildOpenAiPrompt({ '- relatedIssues는 관련 이슈/PR을 "#번호 설명" 형태로 작성하세요. 없으면 빈 배열로 두세요.', '- dependencies는 추가/제거/업데이트된 패키지 의존성을 작성하세요. 없으면 빈 배열로 두세요.', '- labels는 아래 제공된 레포 라벨 목록에서만 선택하세요. 최대 3개까지 선택할 수 있으나, 꼭 필요한 경우가 아니면 1-2개로 제한하는 것을 권장합니다.', - '- 코드 식별자/파일 경로/에러 메시지는 원문을 유지하세요.', + '- 코드 식별자/파일 경로/에러 메시지는 원문을 유지하고, 마크다운 백틱(`)으로 감싸세요. 예: `src/features/auth/auth.service.ts`, `package.json`, `PrismaService`', '', `PR Meta:\n${JSON.stringify(prMeta, null, 2)}`, '', From 0627432e5e7216fb45376291d5f7d4f593592bba Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 16 Mar 2026 00:41:24 +0900 Subject: [PATCH 12/13] =?UTF-8?q?ci:=20PR=20=EC=9A=94=EC=95=BD=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=EB=B3=84=20=EC=A0=9C=EB=AA=A9=EC=9D=84=20h3=EC=97=90?= =?UTF-8?q?=EC=84=9C=20h2=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/pr-ai-description-lib.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/scripts/pr-ai-description-lib.mjs b/.github/scripts/pr-ai-description-lib.mjs index c4b0771..bdf29df 100644 --- a/.github/scripts/pr-ai-description-lib.mjs +++ b/.github/scripts/pr-ai-description-lib.mjs @@ -528,7 +528,7 @@ function renderOptionalSection(heading, items) { return [ '', - `### ${heading}`, + `## ${heading}`, ...items.map((item) => `- ${item}`), ]; } @@ -537,7 +537,7 @@ export function renderSummaryBlock(summary) { const lines = [MARKER_START]; // Summary - lines.push('### PR Summary'); + lines.push('## PR Summary'); lines.push(summary.summary); if (summary.summaryBullets.length > 0) { lines.push(''); @@ -548,7 +548,7 @@ export function renderSummaryBlock(summary) { // Changes (테이블) lines.push(''); - lines.push('### Changes'); + lines.push('## Changes'); lines.push(`> ${summary.changes.length} files changed`); lines.push(''); lines.push('| File | Changes |'); @@ -561,7 +561,7 @@ export function renderSummaryBlock(summary) { // Impact if (summary.impact.length > 0) { lines.push(''); - lines.push('### Impact'); + lines.push('## Impact'); for (const item of summary.impact) { lines.push(`- ${item}`); } @@ -570,7 +570,7 @@ export function renderSummaryBlock(summary) { // Checklist (체크박스) if (summary.checklist.length > 0) { lines.push(''); - lines.push('### Checklist'); + lines.push('## Checklist'); for (const item of summary.checklist) { lines.push(`- [ ] ${item}`); } From 976aa764d1267b3335faed58a34088aa189d80df Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 16 Mar 2026 01:04:56 +0900 Subject: [PATCH 13/13] =?UTF-8?q?ci:=20PR=20AI=20=ED=94=84=EB=A1=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8/=EB=A0=8C=EB=8D=94=EB=A7=81=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0,=20CodeRabbit=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EA=B2=BD=EB=A1=9C=20=EB=B3=B4=EA=B0=95,?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/pr-ai-description-lib.mjs | 75 +++++++++++++++++------ .github/scripts/pr-ai-dry-run.mjs | 31 +++++++--- 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/.github/scripts/pr-ai-description-lib.mjs b/.github/scripts/pr-ai-description-lib.mjs index bdf29df..74ad9e3 100644 --- a/.github/scripts/pr-ai-description-lib.mjs +++ b/.github/scripts/pr-ai-description-lib.mjs @@ -250,7 +250,9 @@ export function normalizeDiffEntries(files) { filename: file.filename, status: file.status ?? 'modified', previousFilename: - typeof file.previous_filename === 'string' ? file.previous_filename : undefined, + typeof file.previous_filename === 'string' + ? file.previous_filename + : undefined, patch: typeof file.patch === 'string' && file.patch.trim().length > 0 ? file.patch @@ -260,7 +262,8 @@ export function normalizeDiffEntries(files) { export function formatDiffEntry(entry) { const sourceText = - typeof entry.previousFilename === 'string' && entry.previousFilename.length > 0 + typeof entry.previousFilename === 'string' && + entry.previousFilename.length > 0 ? ` (from ${entry.previousFilename})` : ''; @@ -359,7 +362,8 @@ function composeDiff(chunks, totalFiles, forceTruncated) { } export function buildLimitedDiff(entries, maxBytes) { - const safeMaxBytes = Number.isInteger(maxBytes) && maxBytes > 0 ? maxBytes : 102400; + const safeMaxBytes = + Number.isInteger(maxBytes) && maxBytes > 0 ? maxBytes : 102400; const chunks = entries.map((entry) => formatDiffEntry(entry)); const selected = []; @@ -373,7 +377,11 @@ export function buildLimitedDiff(entries, maxBytes) { selected.push(chunk); } - let composed = composeDiff(selected, chunks.length, selected.length < chunks.length); + let composed = composeDiff( + selected, + chunks.length, + selected.length < chunks.length, + ); while (composed.meta.finalBytes > safeMaxBytes && selected.length > 0) { selected.pop(); @@ -490,7 +498,10 @@ export function validateAiSummaryJson(payload) { const title = asNonEmptyString(payload.title, 'title'); const summary = asNonEmptyString(payload.summary, 'summary'); - const summaryBullets = validateStringArray(payload.summaryBullets, 'summaryBullets'); + const summaryBullets = validateStringArray( + payload.summaryBullets, + 'summaryBullets', + ); if (!Array.isArray(payload.changes)) { throw new Error('invalid-changes-type'); @@ -502,9 +513,18 @@ export function validateAiSummaryJson(payload) { const impact = validateStringArray(payload.impact, 'impact'); const checklist = validateStringArray(payload.checklist, 'checklist'); - const breakingChanges = validateStringArray(payload.breakingChanges, 'breakingChanges'); - const relatedIssues = validateStringArray(payload.relatedIssues, 'relatedIssues'); - const dependencies = validateStringArray(payload.dependencies, 'dependencies'); + const breakingChanges = validateStringArray( + payload.breakingChanges, + 'breakingChanges', + ); + const relatedIssues = validateStringArray( + payload.relatedIssues, + 'relatedIssues', + ); + const dependencies = validateStringArray( + payload.dependencies, + 'dependencies', + ); const labels = validateStringArray(payload.labels, 'labels'); return { @@ -526,11 +546,7 @@ function renderOptionalSection(heading, items) { return []; } - return [ - '', - `## ${heading}`, - ...items.map((item) => `- ${item}`), - ]; + return ['', `## ${heading}`, ...items.map((item) => `- ${item}`)]; } export function renderSummaryBlock(summary) { @@ -554,7 +570,10 @@ export function renderSummaryBlock(summary) { lines.push('| File | Changes |'); lines.push('|------|---------|'); for (const change of summary.changes) { - const escapedDesc = change.description.replaceAll('|', '\\|'); + const escapedDesc = change.description + .replaceAll('\r\n', '\n') + .replaceAll('\n', '
') + .replaceAll('|', '\\|'); lines.push(`| \`${change.file}\` | ${escapedDesc} |`); } @@ -577,7 +596,9 @@ export function renderSummaryBlock(summary) { } // 선택적 섹션들 - lines.push(...renderOptionalSection('Breaking Changes', summary.breakingChanges)); + lines.push( + ...renderOptionalSection('Breaking Changes', summary.breakingChanges), + ); lines.push(...renderOptionalSection('Related Issues', summary.relatedIssues)); lines.push(...renderOptionalSection('Dependencies', summary.dependencies)); @@ -589,7 +610,10 @@ export function upsertSummaryBlock(prBody, block) { const existingBody = typeof prBody === 'string' ? prBody : ''; const escapedStart = MARKER_START.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedEnd = MARKER_END.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const blockPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm'); + const blockPattern = new RegExp( + `${escapedStart}[\\s\\S]*?${escapedEnd}`, + 'm', + ); const codeRabbitSummaryPattern = /^## Summary by CodeRabbit\b/m; if (blockPattern.test(existingBody)) { @@ -603,8 +627,12 @@ export function upsertSummaryBlock(prBody, block) { const codeRabbitMatch = codeRabbitSummaryPattern.exec(existingBody); if (codeRabbitMatch && typeof codeRabbitMatch.index === 'number') { - const beforeSummary = existingBody.slice(0, codeRabbitMatch.index).trimEnd(); - const summarySection = existingBody.slice(codeRabbitMatch.index).trimStart(); + const beforeSummary = existingBody + .slice(0, codeRabbitMatch.index) + .trimEnd(); + const summarySection = existingBody + .slice(codeRabbitMatch.index) + .trimStart(); if (beforeSummary.length === 0) { return `${block}\n\n${summarySection}`; @@ -671,13 +699,20 @@ export function filterKnownLabels(aiLabels, repoLabelNames) { }; } -export function shouldApplyTitle({ applyTitle, aiTitle, existingTitle, labelNames }) { +export function shouldApplyTitle({ + applyTitle, + aiTitle, + existingTitle, + labelNames, +}) { if (!applyTitle) { return false; } const hasTitleLock = labelNames.some( - (labelName) => typeof labelName === 'string' && labelName.toLowerCase() === 'ai-title-lock', + (labelName) => + typeof labelName === 'string' && + labelName.toLowerCase() === 'ai-title-lock', ); if (hasTitleLock) { diff --git a/.github/scripts/pr-ai-dry-run.mjs b/.github/scripts/pr-ai-dry-run.mjs index aaa3aed..9179cd9 100644 --- a/.github/scripts/pr-ai-dry-run.mjs +++ b/.github/scripts/pr-ai-dry-run.mjs @@ -201,7 +201,9 @@ async function runWithBranch(options) { const parsedFiles = parseNameStatus(nameStatus); if (parsedFiles.length > options.maxFiles) { - throw new Error(`파일 수 초과: ${parsedFiles.length} > ${options.maxFiles}`); + throw new Error( + `파일 수 초과: ${parsedFiles.length} > ${options.maxFiles}`, + ); } const excludeGlobs = buildExcludeGlobs(); @@ -229,7 +231,15 @@ async function runWithBranch(options) { const pr = { number: 0, title: currentBranch, - user: { login: runGit(['config', 'user.name']) || 'local' }, + user: { + login: (() => { + try { + return runGit(['config', 'user.name']) || 'local'; + } catch { + return 'local'; + } + })(), + }, base: { ref: baseBranch }, head: { ref: currentBranch }, commits: parseInt(runGit(['rev-list', '--count', range]), 10) || 0, @@ -293,7 +303,8 @@ async function generateAndPrint({ const openAiMessages = [ { role: 'system', - content: 'You are a senior backend engineer. Return only JSON that matches the schema.', + content: + 'You are a senior backend engineer. Return only JSON that matches the schema.', }, { role: 'user', @@ -353,9 +364,13 @@ async function generateAndPrint({ console.log('='.repeat(60)); console.log(' 적용될 라벨'); console.log('='.repeat(60)); - console.log(applicableLabels.length > 0 ? applicableLabels.join(', ') : '(없음)'); + console.log( + applicableLabels.length > 0 ? applicableLabels.join(', ') : '(없음)', + ); if (unknownLabelsIgnoredCount > 0) { - console.log(` (레포에 없는 라벨 ${unknownLabelsIgnoredCount}개 무시됨)`); + console.log( + ` (레포에 없는 라벨 ${unknownLabelsIgnoredCount}개 무시됨)`, + ); } console.log(); console.log('='.repeat(60)); @@ -413,8 +428,10 @@ async function main() { const options = { openAiClient, - openAiModel: values.model || process.env.OPENAI_MODEL || DEFAULT_OPENAI_MODEL, - maxDiffBytes: parseInt(values['max-diff-bytes'] ?? '', 10) || DEFAULT_MAX_DIFF_BYTES, + openAiModel: + values.model || process.env.OPENAI_MODEL || DEFAULT_OPENAI_MODEL, + maxDiffBytes: + parseInt(values['max-diff-bytes'] ?? '', 10) || DEFAULT_MAX_DIFF_BYTES, maxFiles: parseInt(values['max-files'] ?? '', 10) || DEFAULT_MAX_FILES, baseBranch: values.base || DEFAULT_BASE_BRANCH, };