diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..2c22155 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,90 @@ +# 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: "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 + 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 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..8379ec7 --- /dev/null +++ b/.github/scripts/__tests__/pr-ai-description-lib.spec.mjs @@ -0,0 +1,199 @@ +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: 'feat: 사용자 조회 API 개선', + summary: '응답 필드와 예외 처리를 정리했습니다.', + summaryBullets: ['조회 조건 검증 로직 추가', '에러 응답 포맷 통일'], + changes: [ + { + file: 'src/user/user.service.ts', + description: '조회 조건 검증 로직 추가', + }, + ], + impact: ['API 응답 형식 변경으로 클라이언트 확인 필요'], + checklist: ['resolver 통합 테스트 확인'], + 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, + unknown: 'x', + }), + /invalid-root-additional-property/, + ); + + assert.throws( + () => + validateAiSummaryJson({ + ...validPayload, + changes: [{ file: '', description: 'test' }], + }), + /invalid-changes-0-file/, + ); +}); + +test('마커 블록이 있으면 교체하고 없으면 하단에 추가한다', () => { + const summary = { + title: 'fix: 인증 가드 수정', + summary: '요약', + summaryBullets: ['가드 로직 개선'], + changes: [{ file: 'src/auth/auth.guard.ts', description: '가드 수정' }], + impact: ['인증 플로우 변경으로 테스트 확인 필요'], + checklist: ['테스트 실행'], + breakingChanges: [], + relatedIssues: [], + dependencies: [], + labels: [], + }; + + const block = renderSummaryBlock(summary); + + 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('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 @@' }, + { 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/__tests__/pr-ai-description.spec.mjs b/.github/scripts/__tests__/pr-ai-description.spec.mjs new file mode 100644 index 0000000..b8edbff --- /dev/null +++ b/.github/scripts/__tests__/pr-ai-description.spec.mjs @@ -0,0 +1,273 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildPrUpdatePayload, + loadCurrentPullRequest, + 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/, + ); +}); + +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 new file mode 100644 index 0000000..74ad9e3 --- /dev/null +++ b/.github/scripts/pr-ai-description-lib.mjs @@ -0,0 +1,742 @@ +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', +]; + +export const AI_RESPONSE_JSON_SCHEMA = { + type: 'object', + additionalProperties: false, + 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: ['file', 'description'], + properties: { + file: { type: 'string' }, + description: { type: 'string' }, + }, + }, + }, + impact: { + type: 'array', + items: { type: 'string' }, + }, + checklist: { + type: 'array', + items: { type: 'string' }, + }, + breakingChanges: { + type: 'array', + items: { type: 'string' }, + }, + relatedIssues: { + type: 'array', + items: { type: 'string' }, + }, + dependencies: { + 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; + }); +} + +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'); + } + + const rootAllowedKeys = new Set([ + 'title', + 'summary', + 'summaryBullets', + 'changes', + 'impact', + 'checklist', + 'breakingChanges', + 'relatedIssues', + 'dependencies', + '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'); + const summaryBullets = validateStringArray( + payload.summaryBullets, + 'summaryBullets', + ); + + if (!Array.isArray(payload.changes)) { + throw new Error('invalid-changes-type'); + } + + const changes = payload.changes.map((change, index) => + validateChangeEntry(change, index), + ); + + 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 labels = validateStringArray(payload.labels, 'labels'); + + return { + title, + summary, + summaryBullets, + changes, + impact, + checklist, + breakingChanges, + relatedIssues, + dependencies, + labels, + }; +} + +function renderOptionalSection(heading, items) { + if (!Array.isArray(items) || items.length === 0) { + return []; + } + + return ['', `## ${heading}`, ...items.map((item) => `- ${item}`)]; +} + +export function renderSummaryBlock(summary) { + const lines = [MARKER_START]; + + // 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('\r\n', '\n') + .replaceAll('\n', '
') + .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) { + 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 codeRabbitSummaryPattern = /^## Summary by CodeRabbit\b/m; + + if (blockPattern.test(existingBody)) { + return existingBody.replace(blockPattern, block); + } + + if (existingBody.trim().length === 0) { + 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}`; +} + +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..abd4e28 --- /dev/null +++ b/.github/scripts/pr-ai-description.mjs @@ -0,0 +1,1119 @@ +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, + 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 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 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]; + + 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 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, + 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, + }; +} + +export 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을 생성하세요.', + '', + '규칙:', + '- title은 conventional commits 형식의 prefix를 포함하세요 (feat:, fix:, refactor:, docs:, style:, perf:, test:, build:, ci:, chore:, revert: 중 택1). 예: "feat: 사용자 인증 기능 추가"', + '- summary는 PR 전체를 요약하는 한국어 텍스트를 작성하세요. 문장이 여러 개일 경우 마침표 뒤에 줄바꿈(\\n)을 삽입하여 문장을 분리하세요.', + '- summaryBullets는 핵심 변경사항을 간결한 한국어 리스트 항목으로 작성하세요. 불필요하면 빈 배열로 두세요.', + '- changes는 변경된 각 파일별로 { file, description } 형태로 작성하세요. file은 파일 경로 원문, description은 해당 파일의 변경 내용을 한국어 문장으로 작성하세요.', + '- impact는 이 PR이 시스템에 미치는 영향을 자유 형식 한국어 텍스트 리스트로 작성하세요. 영향이 없으면 빈 배열로 두세요.', + '- checklist는 리뷰어가 꼭 확인해야 할 항목들을 한국어로 작성하세요. 애매하거나 추상적인 항목은 피하고, 객관적이고 명확한 행동 지침을 간결하게 작성하세요.', + '- breakingChanges는 하위 호환성을 깨는 변경사항을 작성하세요. 없으면 빈 배열로 두세요.', + '- relatedIssues는 관련 이슈/PR을 "#번호 설명" 형태로 작성하세요. 없으면 빈 배열로 두세요.', + '- dependencies는 추가/제거/업데이트된 패키지 의존성을 작성하세요. 없으면 빈 배열로 두세요.', + '- labels는 아래 제공된 레포 라벨 목록에서만 선택하세요. 최대 3개까지 선택할 수 있으나, 꼭 필요한 경우가 아니면 1-2개로 제한하는 것을 권장합니다.', + '- 코드 식별자/파일 경로/에러 메시지는 원문을 유지하고, 마크다운 백틱(`)으로 감싸세요. 예: `src/features/auth/auth.service.ts`, `package.json`, `PrismaService`', + '', + `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 ''; +} + +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 responseData = await openAiClient.chat.completions.create( + buildChatCompletionRequest({ + model, + messages, + }), + { + signal: controller.signal, + langsmithExtra, + }, + ); + 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' || + 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); + } +} + +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; +} + +function shortenSha(sha) { + if (typeof sha !== 'string') { + return 'unknown'; + } + + 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'); + 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 = true; + const excludeGlobs = buildExcludeGlobs(); + const openAiClient = wrapOpenAI(new OpenAI({ apiKey: openAiApiKey })); + + const runWorkflow = traceable( + async () => { + 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 { + status: 'skipped', + reason: 'fork-pr', + }; + } + + 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 collectDiff = traceable( + 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; + } + + 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 diffContext = await collectDiff(); + + const { diffSource, diffEntries, excludedFilesCount } = diffContext; + + 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 openAiMessages = [ + { + role: 'system', + content: + 'You are a senior backend engineer. Return only JSON that matches the schema.', + }, + { + 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, + }), + }, + ); + + const aiSummary = await generateAiSummary(); + + const currentAssignees = uniqueStringList( + Array.isArray(pullRequest.assignees) + ? pullRequest.assignees.map((assignee) => assignee?.login) + : [], + ); + + const currentLabels = extractLabelNames(pullRequest); + + const applyPrUpdates = traceable( + 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); + 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, + }); + + 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(); +} + +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 + : 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); +}); diff --git a/.github/scripts/pr-ai-dry-run.mjs b/.github/scripts/pr-ai-dry-run.mjs new file mode 100644 index 0000000..9179cd9 --- /dev/null +++ b/.github/scripts/pr-ai-dry-run.mjs @@ -0,0 +1,456 @@ +/** + * 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: (() => { + 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, + 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/.github/workflows/pr-ai-description.yml b/.github/workflows/pr-ai-description.yml new file mode 100644 index 0000000..f6a2482 --- /dev/null +++ b/.github/workflows/pr-ai-description.yml @@ -0,0 +1,51 @@ +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: Install dependencies + run: yarn install --immutable + + - 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_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 diff --git a/package.json b/package.json index 1d36702..c2b3e46 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,8 @@ "eslint-plugin-prettier": "^5.2.2", "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 a0b6692..02c35c8 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,7 +5306,9 @@ __metadata: globals: "npm:^16.0.0" graphql: "npm:^16.12.0" 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" @@ -5352,6 +5361,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 +5820,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 +7087,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 +10045,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" @@ -11272,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" @@ -11350,6 +11427,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 +11477,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 +12582,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 +12846,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 +14293,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"