From 730e7604d7f8823e9d72cf38552615b5af2ca3cf Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 4 Mar 2026 20:01:04 +0900 Subject: [PATCH 1/6] =?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 2/6] =?UTF-8?q?ci:=20PR=20AI=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=97=90=20LangSmith=20=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=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 3/6] =?UTF-8?q?ci:=20PR=20AI=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=20LangSmith=20=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=8B=B1?= =?UTF-8?q?=EC=9D=84=20SDK=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=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 4/6] =?UTF-8?q?ci:=20PR=20AI=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=84=A4?= =?UTF-8?q?=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 5/6] =?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 6/6] =?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,