diff --git a/.github/scripts/ai_review.py b/.github/scripts/ai_review.py index d4c0f2d..b5587f6 100644 --- a/.github/scripts/ai_review.py +++ b/.github/scripts/ai_review.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Review a pull request with Gemini and an OpenRouter fallback. +"""Review a pull request with OpenAI. The workflow checks out the trusted base revision before running this file. Pull request metadata and patches are fetched through the GitHub API and are treated @@ -15,7 +15,6 @@ import sys import time import urllib.error -import urllib.parse import urllib.request from dataclasses import dataclass from pathlib import Path @@ -24,8 +23,7 @@ import review_publishing COMMENT_MARKER = "" -GEMINI_MODEL = "gemini-3.6-flash" -OPENROUTER_MODEL = "poolside/laguna-s-2.1:free" +OPENAI_MODEL = "gpt-5.6-terra" SYSTEM_PROMPT_PATH = Path(".github/prompts/ai-review.md") MAX_PR_BODY_CHARS = 10_000 @@ -34,7 +32,7 @@ MAX_PATCH_CHARS = 30_000 MAX_DIFF_CHARS = 170_000 MAX_FINDINGS_PER_KIND = 20 -MAX_OUTPUT_TOKENS = 8_192 +MAX_OUTPUT_TOKENS = 4_096 REQUEST_TIMEOUT_SECONDS = 60 RETRY_DELAYS_SECONDS = (1, 2, 4) TRANSIENT_HTTP_STATUSES = {408, 409, 425, 429} @@ -142,79 +140,83 @@ def with_retry(operation: Callable[[], T]) -> T: raise AssertionError("retry loop ended unexpectedly") -def _extract_gemini_text(response: Any) -> str: - try: - parts = response["candidates"][0]["content"]["parts"] - text = "".join(part.get("text", "") for part in parts) - except (KeyError, IndexError, TypeError) as error: - raise ReviewError("Gemini response did not contain review text") from error - if not text.strip(): - raise ReviewError("Gemini returned an empty review") - return text - - -def call_gemini(api_key: str, system_prompt: str, user_prompt: str) -> str: - model = urllib.parse.quote(GEMINI_MODEL, safe="") - url = ( - "https://generativelanguage.googleapis.com/v1beta/models/" - f"{model}:generateContent" - ) - response = request_json( - url, - method="POST", - headers={"x-goog-api-key": api_key}, - payload={ - "systemInstruction": {"parts": [{"text": system_prompt}]}, - "contents": [{"role": "user", "parts": [{"text": user_prompt}]}], - "generationConfig": { - "maxOutputTokens": MAX_OUTPUT_TOKENS, - "responseMimeType": "application/json", +REVIEW_JSON_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["summary", "blocking", "suggestions"], + "properties": { + "summary": {"type": "string"}, + "blocking": {"type": "array", "items": {"$ref": "#/$defs/finding"}}, + "suggestions": {"type": "array", "items": {"$ref": "#/$defs/finding"}}, + }, + "$defs": { + "finding": { + "type": "object", + "additionalProperties": False, + "required": [ + "title", + "file", + "line", + "reason", + "rule_reference", + "recommendation", + ], + "properties": { + "title": {"type": "string"}, + "file": {"type": "string"}, + "line": {"type": ["integer", "null"]}, + "reason": {"type": "string"}, + "rule_reference": {"type": "string"}, + "recommendation": {"type": "string"}, }, - }, - ) - return _extract_gemini_text(response) - - -def _extract_openrouter_text(response: Any) -> str: - try: - content = response["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError) as error: - raise ReviewError("OpenRouter response did not contain review text") from error - - if isinstance(content, list): - content = "".join( - part.get("text", "") for part in content if isinstance(part, dict) - ) - if not isinstance(content, str) or not content.strip(): - raise ReviewError("OpenRouter returned an empty review") - return content + } + }, +} + + +def _extract_openai_text(response: Any) -> str: + if isinstance(response, dict): + output_text = response.get("output_text") + if isinstance(output_text, str) and output_text.strip(): + return output_text + + output = response.get("output") + if isinstance(output, list): + text = "".join( + part.get("text", "") + for item in output + if isinstance(item, dict) + for part in item.get("content", []) + if isinstance(part, dict) and part.get("type") == "output_text" + ) + if text.strip(): + return text + raise ReviewError("OpenAI response did not contain review text") -def call_openrouter( - api_key: str, - system_prompt: str, - user_prompt: str, - repository: str, -) -> str: +def call_openai(api_key: str, system_prompt: str, user_prompt: str) -> str: response = request_json( - "https://openrouter.ai/api/v1/chat/completions", + "https://api.openai.com/v1/responses", method="POST", - headers={ - "Authorization": f"Bearer {api_key}", - "HTTP-Referer": f"https://github.com/{repository}", - "X-OpenRouter-Title": "WhyLog CI AI Review", - }, + headers={"Authorization": f"Bearer {api_key}"}, payload={ - "model": OPENROUTER_MODEL, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - "temperature": 0.1, - "max_tokens": MAX_OUTPUT_TOKENS, + "model": OPENAI_MODEL, + "instructions": system_prompt, + "input": user_prompt, + "max_output_tokens": MAX_OUTPUT_TOKENS, + "reasoning": {"effort": "medium"}, + "store": False, + "text": { + "format": { + "type": "json_schema", + "name": "pull_request_review", + "strict": True, + "schema": REVIEW_JSON_SCHEMA, + } + }, }, ) - return _extract_openrouter_text(response) + return _extract_openai_text(response) def _strip_code_fence(text: str) -> str: @@ -280,52 +282,15 @@ def parse_review(text: str) -> Review: ) -def _provider_failure(name: str, error: Exception) -> str: - return f"{name}: {type(error).__name__}: {error}" - - -def review_with_fallback( +def review_with_openai( system_prompt: str, user_prompt: str, - repository: str, - gemini_api_key: str, - openrouter_api_key: str, + openai_api_key: str, ) -> ProviderResult: - failures: list[str] = [] - - if gemini_api_key: - try: - raw = with_retry( - lambda: call_gemini(gemini_api_key, system_prompt, user_prompt) - ) - return ProviderResult("Google", GEMINI_MODEL, parse_review(raw)) - except (ReviewError, HttpRequestError, NetworkRequestError) as error: - failures.append(_provider_failure("Gemini", error)) - else: - failures.append("Gemini: GEMINI_API_KEY is not configured") - - if openrouter_api_key: - try: - raw = with_retry( - lambda: call_openrouter( - openrouter_api_key, - system_prompt, - user_prompt, - repository, - ) - ) - return ProviderResult( - "OpenRouter", - OPENROUTER_MODEL, - parse_review(raw), - fallback_reason=failures[-1], - ) - except (ReviewError, HttpRequestError, NetworkRequestError) as error: - failures.append(_provider_failure("OpenRouter", error)) - else: - failures.append("OpenRouter: OPENROUTER_API_KEY is not configured") - - raise ReviewError("; ".join(failures)) + if not openai_api_key: + raise ReviewError("OPENAI_API_KEY is not configured") + raw = with_retry(lambda: call_openai(openai_api_key, system_prompt, user_prompt)) + return ProviderResult("OpenAI", OPENAI_MODEL, parse_review(raw)) def _safe_read(path: Path, workspace: Path) -> str: @@ -578,12 +543,6 @@ def render_comment( inline_result: review_publishing.InlinePublishResult | None = None, document_status: str | None = None, ) -> str: - fallback = "" - if result.fallback_reason: - fallback = ( - "\n> Gemini 호출에 실패해 무료 OpenRouter 폴백을 사용했습니다: " - f"`{result.fallback_reason}`\n" - ) verdict = "❌ 차단 항목 있음" if result.review.blocking else "✅ 차단 항목 없음" publishing = "" if inline_result is not None: @@ -601,7 +560,6 @@ def render_comment( ## WhyLog AI 리뷰 **결과:** {verdict} · **모델:** {result.provider} `{result.model}` -{fallback} {result.review.summary} {publishing} @@ -625,7 +583,7 @@ def render_failure_comment(message: str) -> str: `{message}` -Gemini와 OpenRouter 설정 또는 일시 장애를 확인하세요. 리뷰가 생성되지 않으면 quality gate는 통과하지 않습니다. +OpenAI API 설정 또는 일시 장애를 확인하세요. 리뷰가 생성되지 않으면 quality gate는 통과하지 않습니다. """ @@ -720,8 +678,7 @@ def run() -> int: repository = os.environ["GITHUB_REPOSITORY"] api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com") github_token = os.environ["GITHUB_TOKEN"] - gemini_api_key = os.environ.get("GEMINI_API_KEY", "") - openrouter_api_key = os.environ.get("OPENROUTER_API_KEY", "") + openai_api_key = os.environ.get("OPENAI_API_KEY", "") push_token = os.environ.get("AI_REVIEW_PUSH_TOKEN", "") review_actor_login = os.environ.get("AI_REVIEW_ACTOR_LOGIN", "").strip() if not review_actor_login: @@ -801,12 +758,10 @@ def run() -> int: trusted_context, diff_payload, ) - result = review_with_fallback( + result = review_with_openai( system_prompt, user_prompt, - repository, - gemini_api_key, - openrouter_api_key, + openai_api_key, ) document = review_publishing.render_pr_review_document( pr_number, @@ -902,7 +857,7 @@ def run() -> int: except Exception as error: message = _redact( f"{type(error).__name__}: {error}", - (github_token, gemini_api_key, openrouter_api_key, push_token), + (github_token, openai_api_key, push_token), ) document_status = ( f"`{document_path}` artifact만 생성 · 저장소 동기화 실패: `{message}`" @@ -922,8 +877,7 @@ def run() -> int: def main() -> int: secrets = ( os.environ.get("GITHUB_TOKEN", ""), - os.environ.get("GEMINI_API_KEY", ""), - os.environ.get("OPENROUTER_API_KEY", ""), + os.environ.get("OPENAI_API_KEY", ""), os.environ.get("AI_REVIEW_PUSH_TOKEN", ""), ) try: diff --git a/.github/scripts/test_ai_review.py b/.github/scripts/test_ai_review.py index 466b1c6..88664e9 100644 --- a/.github/scripts/test_ai_review.py +++ b/.github/scripts/test_ai_review.py @@ -61,89 +61,58 @@ def test_malformed_http_json_becomes_provider_failure( ai_review.request_json("https://example.invalid") @mock.patch.object(ai_review, "request_json") - def test_gemini_request_omits_deprecated_sampling_parameters( + def test_openai_request_uses_structured_output_without_storage( self, request_json: mock.Mock ) -> None: - request_json.return_value = { - "candidates": [{"content": {"parts": [{"text": review_json()}]}}] - } + request_json.return_value = {"output_text": review_json()} - ai_review.call_gemini("key", "system", "user") + ai_review.call_openai("key", "system", "user") - generation_config = request_json.call_args.kwargs["payload"]["generationConfig"] - self.assertNotIn("temperature", generation_config) - self.assertNotIn("topP", generation_config) - self.assertNotIn("topK", generation_config) + self.assertEqual( + request_json.call_args.args[0], "https://api.openai.com/v1/responses" + ) + payload = request_json.call_args.kwargs["payload"] + self.assertEqual(payload["model"], ai_review.OPENAI_MODEL) + self.assertFalse(payload["store"]) + self.assertEqual(payload["reasoning"], {"effort": "medium"}) + self.assertEqual(payload["text"]["format"]["type"], "json_schema") + self.assertTrue(payload["text"]["format"]["strict"]) -class ProviderFallbackTest(unittest.TestCase): - @mock.patch.object(ai_review, "call_openrouter") - @mock.patch.object(ai_review, "call_gemini") - def test_primary_success_does_not_call_fallback( - self, - gemini: mock.Mock, - openrouter: mock.Mock, - ) -> None: - gemini.return_value = review_json() +class OpenAIProviderTest(unittest.TestCase): + @mock.patch.object(ai_review, "call_openai") + def test_reviews_with_openai(self, openai: mock.Mock) -> None: + openai.return_value = review_json() - result = ai_review.review_with_fallback( - "system", - "user", - "WhyLog-App/WhyLog", - "gemini-key", - "openrouter-key", - ) + result = ai_review.review_with_openai("system", "user", "openai-key") - self.assertEqual(result.model, ai_review.GEMINI_MODEL) - openrouter.assert_not_called() + self.assertEqual(result.provider, "OpenAI") + self.assertEqual(result.model, ai_review.OPENAI_MODEL) + + def test_requires_openai_api_key(self) -> None: + with self.assertRaisesRegex(ai_review.ReviewError, "OPENAI_API_KEY"): + ai_review.review_with_openai("system", "user", "") @mock.patch.object(ai_review.time, "sleep") - @mock.patch.object(ai_review, "call_openrouter") - @mock.patch.object(ai_review, "call_gemini") - def test_rate_limit_retries_then_uses_fallback( - self, - gemini: mock.Mock, - openrouter: mock.Mock, - sleep: mock.Mock, + @mock.patch.object(ai_review, "call_openai") + def test_rate_limit_retries_then_fails( + self, openai: mock.Mock, sleep: mock.Mock ) -> None: - gemini.side_effect = ai_review.HttpRequestError(429, "rate limited") - openrouter.return_value = review_json() + openai.side_effect = ai_review.HttpRequestError(429, "rate limited") - result = ai_review.review_with_fallback( - "system", - "user", - "WhyLog-App/WhyLog", - "gemini-key", - "openrouter-key", - ) + with self.assertRaisesRegex(ai_review.HttpRequestError, "HTTP 429"): + ai_review.review_with_openai("system", "user", "openai-key") - self.assertEqual(gemini.call_count, 4) + self.assertEqual(openai.call_count, 4) self.assertEqual( sleep.call_args_list, [mock.call(1), mock.call(2), mock.call(4)] ) - self.assertEqual(result.model, ai_review.OPENROUTER_MODEL) - self.assertIn("HTTP 429", result.fallback_reason or "") - - @mock.patch.object(ai_review, "call_openrouter") - @mock.patch.object(ai_review, "call_gemini") - def test_invalid_primary_json_uses_fallback_without_retry( - self, - gemini: mock.Mock, - openrouter: mock.Mock, - ) -> None: - gemini.return_value = "not-json" - openrouter.return_value = review_json() - - result = ai_review.review_with_fallback( - "system", - "user", - "WhyLog-App/WhyLog", - "gemini-key", - "openrouter-key", - ) - gemini.assert_called_once() - self.assertEqual(result.provider, "OpenRouter") + def test_rejects_response_without_output_text(self) -> None: + with self.assertRaisesRegex( + ai_review.ReviewError, "did not contain review text" + ): + ai_review._extract_openai_text({"output": []}) class ContextAndPromptTest(unittest.TestCase): @@ -243,8 +212,8 @@ def test_renders_blockers_and_suggestions_separately(self) -> None: recommendation="공통 응답을 사용", ) result = ai_review.ProviderResult( - "Google", - ai_review.GEMINI_MODEL, + "OpenAI", + ai_review.OPENAI_MODEL, ai_review.Review("요약", (finding,), (finding,)), ) @@ -257,8 +226,8 @@ def test_renders_blockers_and_suggestions_separately(self) -> None: def test_renders_inline_and_document_publish_status(self) -> None: result = ai_review.ProviderResult( - "Google", - ai_review.GEMINI_MODEL, + "OpenAI", + ai_review.OPENAI_MODEL, ai_review.Review("요약", (), ()), ) inline = ai_review.review_publishing.InlinePublishResult( @@ -363,12 +332,12 @@ def test_rejects_private_repository_for_free_tier_review(self) -> None: class EndToEndWiringTest(unittest.TestCase): @mock.patch.object(ai_review, "upsert_pr_comment") - @mock.patch.object(ai_review, "review_with_fallback") + @mock.patch.object(ai_review, "review_with_openai") @mock.patch.object(ai_review, "fetch_pr_files") def test_run_reviews_internal_public_pull_request( self, fetch_pr_files: mock.Mock, - review_with_fallback: mock.Mock, + review_with_openai: mock.Mock, upsert_pr_comment: mock.Mock, ) -> None: with tempfile.TemporaryDirectory() as directory: @@ -402,9 +371,9 @@ def test_run_reviews_internal_public_pull_request( [{"filename": "server/Test.java", "patch": "+change"}], False, ) - review_with_fallback.return_value = ai_review.ProviderResult( - "Google", - ai_review.GEMINI_MODEL, + review_with_openai.return_value = ai_review.ProviderResult( + "OpenAI", + ai_review.OPENAI_MODEL, ai_review.Review("통합 검토 완료", (), ()), ) environment = { @@ -413,8 +382,7 @@ def test_run_reviews_internal_public_pull_request( "GITHUB_REPOSITORY": "WhyLog-App/WhyLog", "GITHUB_TOKEN": "github-token", "AI_REVIEW_ACTOR_LOGIN": "whylog-dev", - "GEMINI_API_KEY": "gemini-key", - "OPENROUTER_API_KEY": "openrouter-key", + "OPENAI_API_KEY": "openai-key", "REPOSITORY_IS_PRIVATE": "false", } @@ -451,16 +419,16 @@ def test_run_reviews_internal_public_pull_request( result = ai_review.run() self.assertEqual(result, 0) - review_with_fallback.assert_called_once() + review_with_openai.assert_called_once() self.assertEqual(upsert_pr_comment.call_count, 2) @mock.patch.object(ai_review, "upsert_pr_comment") - @mock.patch.object(ai_review, "review_with_fallback") + @mock.patch.object(ai_review, "review_with_openai") @mock.patch.object(ai_review, "fetch_pr_files") def test_generated_doc_commit_reuses_verified_blocking_result( self, fetch_pr_files: mock.Mock, - review_with_fallback: mock.Mock, + review_with_openai: mock.Mock, upsert_pr_comment: mock.Mock, ) -> None: with tempfile.TemporaryDirectory() as directory: @@ -501,8 +469,8 @@ def test_generated_doc_commit_reuses_verified_blocking_result( 7, pull_request, ai_review.ProviderResult( - "Google", - ai_review.GEMINI_MODEL, + "OpenAI", + ai_review.OPENAI_MODEL, ai_review.Review("이전 검토", (blocker,), ()), ), None, @@ -516,8 +484,7 @@ def test_generated_doc_commit_reuses_verified_blocking_result( "GITHUB_REPOSITORY": "WhyLog-App/WhyLog", "GITHUB_TOKEN": "github-token", "AI_REVIEW_ACTOR_LOGIN": "whylog-dev", - "GEMINI_API_KEY": "gemini-key", - "OPENROUTER_API_KEY": "openrouter-key", + "OPENAI_API_KEY": "openai-key", "AI_REVIEW_PUSH_TOKEN": "push-token", "REPOSITORY_IS_PRIVATE": "false", } @@ -542,7 +509,7 @@ def test_generated_doc_commit_reuses_verified_blocking_result( result = ai_review.run() self.assertEqual(result, 1) - review_with_fallback.assert_not_called() + review_with_openai.assert_not_called() publish_inline.assert_not_called() upsert_pr_comment.assert_called_once() self.assertIn("이전 판단 유지", upsert_pr_comment.call_args.args[-1]) @@ -557,7 +524,7 @@ def test_run_does_not_publish_when_head_changed_during_review(self) -> None: "GITHUB_REPOSITORY": "WhyLog-App/WhyLog", "GITHUB_TOKEN": "github-token", "AI_REVIEW_ACTOR_LOGIN": "whylog-dev", - "GEMINI_API_KEY": "gemini-key", + "OPENAI_API_KEY": "openai-key", "REPOSITORY_IS_PRIVATE": "false", } pull_request = { @@ -572,8 +539,8 @@ def test_run_does_not_publish_when_head_changed_during_review(self) -> None: }, } provider_result = ai_review.ProviderResult( - "Google", - ai_review.GEMINI_MODEL, + "OpenAI", + ai_review.OPENAI_MODEL, ai_review.Review("검토 완료", (), ()), ) with ( @@ -589,7 +556,7 @@ def test_run_does_not_publish_when_head_changed_during_review(self) -> None: ai_review, "fetch_pr_files", return_value=([], False) ), mock.patch.object( - ai_review, "review_with_fallback", return_value=provider_result + ai_review, "review_with_openai", return_value=provider_result ), mock.patch.object( ai_review.review_publishing, @@ -631,7 +598,7 @@ def test_unexpected_document_sync_failure_fails_review_job(self) -> None: "GITHUB_REPOSITORY": "WhyLog-App/WhyLog", "GITHUB_TOKEN": "github-token", "AI_REVIEW_ACTOR_LOGIN": "whylog-dev", - "GEMINI_API_KEY": "gemini-key", + "OPENAI_API_KEY": "openai-key", "AI_REVIEW_PUSH_TOKEN": "push-token", "REPOSITORY_IS_PRIVATE": "false", } @@ -647,8 +614,8 @@ def test_unexpected_document_sync_failure_fails_review_job(self) -> None: }, } provider_result = ai_review.ProviderResult( - "Google", - ai_review.GEMINI_MODEL, + "OpenAI", + ai_review.OPENAI_MODEL, ai_review.Review("검토 완료", (), ()), ) inline_result = ai_review.review_publishing.InlinePublishResult( @@ -667,7 +634,7 @@ def test_unexpected_document_sync_failure_fails_review_job(self) -> None: ai_review, "fetch_pr_files", return_value=([], False) ), mock.patch.object( - ai_review, "review_with_fallback", return_value=provider_result + ai_review, "review_with_openai", return_value=provider_result ), mock.patch.object( ai_review.review_publishing, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa4f1bb..b6bc047 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -216,8 +216,7 @@ jobs: - name: Review pull request if: steps.trusted-reviewer.outputs.available == 'true' env: - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} AI_REVIEW_PUSH_TOKEN: ${{ secrets.AI_REVIEW_PUSH_TOKEN }} AI_REVIEW_ACTOR_LOGIN: whylog-dev GITHUB_TOKEN: ${{ secrets.AI_REVIEW_PUSH_TOKEN }}