Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions .github/ai-review/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import {
const API_VERSION = "2022-11-28";
const DEFAULT_MODEL = "gemini-3.6-flash";
const MAX_OPEN_FINDINGS = 20;
const OUTPUT_TOKEN_LIMITS = [8_192, 16_384];
const FINISH_REASONS = new Set([
"STOP", "MAX_TOKENS", "SAFETY", "RECITATION", "LANGUAGE", "OTHER", "BLOCKLIST",
"PROHIBITED_CONTENT", "SPII", "MALFORMED_FUNCTION_CALL", "IMAGE_SAFETY",
]);

const REVIEW_SCHEMA = {
type: "object",
Expand Down Expand Up @@ -158,23 +163,35 @@ async function callGemini(input) {
contents: [{ role: "user", parts: [{ text: JSON.stringify(input) }] }],
generationConfig: {
temperature: 0.2,
maxOutputTokens: 4096,
responseFormat: { text: { mimeType: "APPLICATION_JSON", schema: REVIEW_SCHEMA } },
},
};
const response = await request(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", "x-goog-api-key": key },
body: JSON.stringify(payload),
}, 150_000);
const candidate = response.candidates?.[0];
if (candidate?.finishReason !== "STOP") throw new Error("AI 응답이 완성되지 않았습니다.");
const text = candidate.content?.parts?.map((part) => part.text ?? "").join("");
if (!text) throw new Error("AI 응답이 비어 있습니다.");
try {
return JSON.parse(text);
} catch {
throw new Error("AI 응답이 유효한 JSON이 아닙니다.");
return completeReview(async (maxOutputTokens) => {
payload.generationConfig.maxOutputTokens = maxOutputTokens;
return request(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", "x-goog-api-key": key },
body: JSON.stringify(payload),
}, 150_000);
});
}

export async function completeReview(generate) {
for (const [index, maxOutputTokens] of OUTPUT_TOKEN_LIMITS.entries()) {
const response = await generate(maxOutputTokens);
const candidate = response?.candidates?.[0];
const reason = candidate
? (FINISH_REASONS.has(candidate.finishReason) ? candidate.finishReason : "UNKNOWN")
: "NO_CANDIDATE";
if (reason === "MAX_TOKENS" && index + 1 < OUTPUT_TOKEN_LIMITS.length) continue;
if (reason !== "STOP") throw new Error(`AI 응답이 완성되지 않았습니다 (종료 이유: ${reason}).`);
const text = candidate.content?.parts?.map((part) => part.text ?? "").join("");
if (!text) throw new Error("AI 응답이 비어 있습니다.");
try {
return JSON.parse(text);
} catch {
throw new Error("AI 응답이 유효한 JSON이 아닙니다.");
}
}
}

Expand Down
45 changes: 44 additions & 1 deletion .github/ai-review/run.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mergeReview, parseState, renderSummary } from "./core.mjs";
import { feedback, review } from "./run.mjs";
import { completeReview, feedback, review } from "./run.mjs";

const headSha = "a".repeat(40);
const baseSha = "b".repeat(40);
Expand Down Expand Up @@ -182,3 +182,46 @@ test("권한이 있는 리뷰어의 오탐 판정은 기존 댓글만 갱신한
assert.equal(parseState(patched[0].body.body).findings[0].status, "false-positive");
assert.equal(calls.filter((call) => call.method === "POST").length, 0);
});

function response(finishReason, text = "") {
return { candidates: [{ finishReason, content: { parts: [{ text }] } }] };
}

test("출력 한도에 도달하면 한 번만 더 큰 한도로 재시도한다", async () => {
const limits = [];
const review = await completeReview(async (limit) => {
limits.push(limit);
return limits.length === 1
? response("MAX_TOKENS", "미완성 응답")
: response("STOP", '{"summary":"검토 완료","findings":[]}');
});
assert.deepEqual(limits, [8_192, 16_384]);
assert.equal(review.summary, "검토 완료");
});

test("출력 한도가 다시 부족하면 응답 본문 없이 종료 이유만 알린다", async () => {
const limits = [];
await assert.rejects(
completeReview(async (limit) => {
limits.push(limit);
return response("MAX_TOKENS", "게시하면 안 되는 응답 본문");
}),
(error) => error.message.includes("MAX_TOKENS") && !error.message.includes("응답 본문"),
);
assert.deepEqual(limits, [8_192, 16_384]);
});

test("토큰 한도 외 종료는 재시도하지 않고 안전한 이유만 기록한다", async () => {
const reasons = ["SAFETY", "악의적인 임의 이유"];
for (const reason of reasons) {
let calls = 0;
await assert.rejects(
completeReview(async () => {
calls += 1;
return response(reason, "민감한 원문");
}),
(error) => !error.message.includes("민감한 원문") && !error.message.includes("악의적인"),
);
assert.equal(calls, 1);
}
});
1 change: 1 addition & 0 deletions docs/ai-review-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Phase 3 구현 범위: GitHub Actions에서 PR diff를 Gemini로 검토하고 PR
- 한 번에 최대 60개 파일, diff 60,000자, context 24,000자, finding 20개, PR당 성공한 모델 호출 20회로 제한한다. 제외 파일 수와 호출 수를 댓글에 표시한다.
- AI가 반환한 finding은 실제 검토 파일의 추가된 줄만 허용한다. severity, confidence, category, classification, file, line, 원인, 영향, 조치를 검증한다.
- API key와 원문 diff는 Actions 로그에 출력하지 않는다. 요청 실패 시 HTTP 상태만 기록한다.
- AI 응답이 출력 토큰 한도에 닿으면 한 번만 더 큰 한도로 재시도한다. 끝까지 완성되지 않으면 응답 본문 없이 허용된 종료 이유만 로그에 기록한다.

GitHub의 `pull_request_target` 보안 지침상 [PR head를 checkout하여 실행하는 것은 위험하다](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target). 이 workflow의 checkout은 기준 브랜치의 스크립트만 가져오고, PR patch는 GitHub API의 데이터로만 처리한다. Workflow나 리뷰 스크립트가 바뀌는 PR은 특히 신중히 검토한다.

Expand Down
Loading