Skip to content

Commit 8bf34b1

Browse files
lkhoonyclaude
andcommitted
refactor: 복잡도 분석을 패턴 태깅 합본 댓글로 통합
복잡도 분석 결과를 별도 issue comment 가 아니라 패턴 태깅의 파일별 review comment 한 곳에 함께 게시한다. 두 분석을 같은 invocation 안에서 병렬 실행하여 두 댓글이 시점 차이로 따로 보이던 문제와 별도 디스패치로 인한 타이밍 의존성을 모두 제거한다. - complexity-analysis: callComplexityAnalysis / renderComplexitySection 만 export 하고 오케스트레이션·issue comment upsert 는 제거 - tag-patterns: 모든 raw 를 사전에 한 번 다운로드해 패턴 분석 루프와 복잡도 OpenAI 1콜이 공유, 댓글 본문에 복잡도 섹션 append - 구버전 단독 복잡도 issue comment 는 첫 동작 시 자동 정리 - webhooks/internal-dispatch: complexity-analysis 디스패치/라우팅 제거 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a221a48 commit 8bf34b1

9 files changed

Lines changed: 1021 additions & 1250 deletions

handlers/complexity-analysis.js

Lines changed: 41 additions & 211 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
11
/**
2-
* 시간/공간 복잡도 자동 분석.
3-
* PR opened/reopened/synchronize 시 호출된다.
2+
* 시간/공간 복잡도 분석 (분석 함수 + 순수 헬퍼).
3+
*
4+
* 오케스트레이션과 댓글 게시는 tag-patterns 핸들러가 담당하며, 이 모듈은
5+
* OpenAI 호출(`callComplexityAnalysis`)과 사용자 주석 처리/매칭 헬퍼,
6+
* 한 파일분 섹션 렌더러(`renderComplexitySection`)만 제공한다.
47
*
58
* 책임 분담 (plan v4):
69
* - 코드: 사용자 복잡도 주석 제거 / 추출 / matches 판정.
710
* - LLM : actualTime / actualSpace / feedback / suggestion / headerLine 만 책임.
811
*/
912

10-
import { getGitHubHeaders } from "../utils/github.js";
11-
import { hasMaintenanceLabel } from "../utils/validation.js";
12-
1313
// ── 상수 ──────────────────────────────────────────
1414

15-
const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/;
16-
const COMPLEXITY_COMMENT_MARKER = "<!-- dalestudy-complexity-analysis -->";
17-
const MAX_FILE_SIZE = 15000;
18-
const MAX_TOTAL_SIZE = 60000;
1915
const FILE_DELIMITER = "=====";
2016

2117
const COMMENT_START_PATTERN = /^(?:\/\/|#|--|;|\/\*|\*(?!\/)|"""|''')/;
@@ -244,7 +240,7 @@ export function composeSolution(modelSol, originalContent) {
244240
};
245241
}
246242

247-
async function callComplexityAnalysis(fileEntries, apiKey) {
243+
export async function callComplexityAnalysis(fileEntries, apiKey) {
248244
const userPrompt = fileEntries
249245
.map(
250246
(f) =>
@@ -356,225 +352,59 @@ function buildSolutionBody(solution) {
356352
return lines;
357353
}
358354

359-
function formatComplexityCommentBody(entries) {
355+
/**
356+
* 한 파일분 복잡도 섹션을 렌더링한다.
357+
* 패턴 태그 코멘트에 묻어가는 형태이므로 마커/푸터 없이
358+
* `### 📊 ...` 헤더부터 시작한다.
359+
*
360+
* @param {{problemName: string, solutions: Array}} entry
361+
* @returns {string} 렌더링된 섹션 (마지막에 \n 없음)
362+
*/
363+
export function renderComplexitySection(entry) {
360364
const lines = [];
361-
lines.push(COMPLEXITY_COMMENT_MARKER);
362365
lines.push("### 📊 시간/공간 복잡도 분석");
363366
lines.push("");
364367

365-
for (const { problemName, solutions } of entries) {
366-
lines.push(`### ${problemName}`);
367-
lines.push("");
368+
const solutions = entry?.solutions || [];
368369

369-
if (!solutions || solutions.length === 0) {
370-
lines.push(`> ⚠️ 분석 결과가 없습니다.`);
371-
lines.push("");
372-
continue;
373-
}
370+
if (solutions.length === 0) {
371+
lines.push(`> ⚠️ 분석 결과가 없습니다.`);
372+
return lines.join("\n");
373+
}
374374

375-
const isMulti = solutions.length > 1;
376-
const hasAnyAnnotationMissing = solutions.some(
377-
(s) => !s.hasUserAnnotation
375+
const isMulti = solutions.length > 1;
376+
const hasAnyAnnotationMissing = solutions.some((s) => !s.hasUserAnnotation);
377+
378+
if (isMulti) {
379+
lines.push(
380+
`> ℹ️ 이 파일에는 **${solutions.length}가지 풀이**가 포함되어 있어 각각 분석합니다.`
378381
);
382+
lines.push("");
379383

380-
if (isMulti) {
384+
solutions.forEach((sol, idx) => {
385+
const summaryResult = buildSummaryResult(sol);
386+
lines.push(`<details>`);
381387
lines.push(
382-
`> ℹ️ 이 파일에는 **${solutions.length}가지 풀이**가 포함되어 있어 각각 분석합니다.`
388+
`<summary>풀이 ${idx + 1}: <code>${sol.name}</code> — ${summaryResult}</summary>`
383389
);
384390
lines.push("");
385-
386-
solutions.forEach((sol, idx) => {
387-
const summaryResult = buildSummaryResult(sol);
388-
lines.push(`<details>`);
389-
lines.push(
390-
`<summary>풀이 ${idx + 1}: <code>${sol.name}</code> — ${summaryResult}</summary>`
391-
);
392-
lines.push("");
393-
lines.push(...buildSolutionBody(sol));
394-
lines.push(`</details>`);
395-
lines.push("");
396-
});
397-
} else {
398-
lines.push(...buildSolutionBody(solutions[0]));
399-
}
400-
401-
if (hasAnyAnnotationMissing) {
402-
lines.push("> 💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!");
391+
lines.push(...buildSolutionBody(sol));
392+
lines.push(`</details>`);
403393
lines.push("");
404-
}
405-
}
406-
407-
lines.push("---");
408-
lines.push("🤖 이 댓글은 GitHub App을 통해 자동으로 작성되었습니다.");
409-
410-
return lines.join("\n") + "\n";
411-
}
412-
413-
// ── 댓글 upsert ──────────────────────────────────
414-
415-
async function upsertComplexityComment(
416-
repoOwner,
417-
repoName,
418-
prNumber,
419-
body,
420-
appToken
421-
) {
422-
const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`;
423-
424-
const listResponse = await fetch(
425-
`${baseUrl}/issues/${prNumber}/comments?per_page=100`,
426-
{ headers: getGitHubHeaders(appToken) }
427-
);
428-
if (!listResponse.ok) {
429-
throw new Error(
430-
`Failed to list comments: ${listResponse.status} ${listResponse.statusText}`
431-
);
432-
}
433-
434-
const comments = await listResponse.json();
435-
const existing = comments.find(
436-
(c) =>
437-
c.user?.type === "Bot" &&
438-
c.body?.includes(COMPLEXITY_COMMENT_MARKER)
439-
);
440-
441-
const headers = {
442-
...getGitHubHeaders(appToken),
443-
"Content-Type": "application/json",
444-
};
445-
446-
if (existing) {
447-
const res = await fetch(`${baseUrl}/issues/comments/${existing.id}`, {
448-
method: "PATCH",
449-
headers,
450-
body: JSON.stringify({ body }),
451394
});
452-
if (!res.ok) {
453-
throw new Error(
454-
`Failed to update complexity comment ${existing.id}: ${res.status}`
455-
);
456-
}
457-
console.log(
458-
`[complexity] Updated comment ${existing.id} on PR #${prNumber}`
459-
);
460395
} else {
461-
const res = await fetch(`${baseUrl}/issues/${prNumber}/comments`, {
462-
method: "POST",
463-
headers,
464-
body: JSON.stringify({ body }),
465-
});
466-
if (!res.ok) {
467-
throw new Error(`Failed to post complexity comment: ${res.status}`);
468-
}
469-
console.log(`[complexity] Created complexity comment on PR #${prNumber}`);
470-
}
471-
}
472-
473-
// ── 오케스트레이션 (export) ───────────────────────
474-
475-
export async function analyzeComplexity(
476-
repoOwner,
477-
repoName,
478-
prNumber,
479-
prData,
480-
appToken,
481-
openaiApiKey
482-
) {
483-
if (prData.draft === true) {
484-
console.log(`[complexity] Skipping PR #${prNumber}: draft`);
485-
return { skipped: "draft" };
486-
}
487-
const labels = (prData.labels || []).map((l) => l.name);
488-
if (hasMaintenanceLabel(labels)) {
489-
console.log(`[complexity] Skipping PR #${prNumber}: maintenance`);
490-
return { skipped: "maintenance" };
491-
}
492-
493-
// 1) PR files
494-
const filesRes = await fetch(
495-
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`,
496-
{ headers: getGitHubHeaders(appToken) }
497-
);
498-
if (!filesRes.ok) {
499-
throw new Error(
500-
`Failed to list PR files: ${filesRes.status} ${filesRes.statusText}`
501-
);
502-
}
503-
const allFiles = await filesRes.json();
504-
505-
const solutionFiles = allFiles.filter(
506-
(f) =>
507-
(f.status === "added" || f.status === "modified") &&
508-
SOLUTION_PATH_REGEX.test(f.filename)
509-
);
510-
511-
console.log(
512-
`[complexity] PR #${prNumber}: ${allFiles.length} files, ${solutionFiles.length} solutions`
513-
);
514-
515-
if (solutionFiles.length === 0) {
516-
return { skipped: "no-solution-files" };
396+
lines.push(...buildSolutionBody(solutions[0]));
517397
}
518398

519-
// 2) 모든 솔루션 파일 다운로드
520-
const fileEntries = [];
521-
let totalSize = 0;
522-
523-
for (const file of solutionFiles) {
524-
const problemName = file.filename.split("/")[0];
525-
try {
526-
const rawRes = await fetch(file.raw_url);
527-
if (!rawRes.ok) {
528-
console.error(
529-
`[complexity] Failed to fetch ${file.filename}: ${rawRes.status}`
530-
);
531-
continue;
532-
}
533-
let content = await rawRes.text();
534-
if (content.length > MAX_FILE_SIZE) {
535-
content = content.slice(0, MAX_FILE_SIZE);
536-
}
537-
538-
if (totalSize + content.length > MAX_TOTAL_SIZE) {
539-
console.log(
540-
`[complexity] Reached MAX_TOTAL_SIZE, skipping remaining files`
541-
);
542-
break;
543-
}
544-
545-
totalSize += content.length;
546-
fileEntries.push({ problemName, content });
547-
} catch (error) {
548-
console.error(
549-
`[complexity] Failed to download ${file.filename}: ${error.message}`
550-
);
551-
}
399+
if (hasAnyAnnotationMissing) {
400+
lines.push("> 💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!");
552401
}
553402

554-
if (fileEntries.length === 0) {
555-
return { skipped: "all-downloads-failed" };
403+
// trailing 빈 줄 제거
404+
while (lines.length > 0 && lines[lines.length - 1] === "") {
405+
lines.pop();
556406
}
557407

558-
// 3) OpenAI 1회 호출로 모든 파일 분석
559-
const analysisResults = await callComplexityAnalysis(
560-
fileEntries,
561-
openaiApiKey
562-
);
563-
564-
// 4) 결과를 fileEntries 순서에 맞춰 매핑
565-
const entries = fileEntries.map((fe) => {
566-
const match = analysisResults.find(
567-
(r) => r.problemName === fe.problemName
568-
);
569-
return match || { problemName: fe.problemName, solutions: [] };
570-
});
571-
572-
// 5) 본문 빌드 + upsert
573-
const body = formatComplexityCommentBody(entries);
574-
await upsertComplexityComment(repoOwner, repoName, prNumber, body, appToken);
575-
576-
return {
577-
analyzed: entries.filter((e) => e.solutions.length > 0).length,
578-
total: fileEntries.length,
579-
};
408+
return lines.join("\n");
580409
}
410+

0 commit comments

Comments
 (0)