diff --git a/src/ai/anthropic.py b/src/ai/anthropic.py index 1b228f7..af0b102 100644 --- a/src/ai/anthropic.py +++ b/src/ai/anthropic.py @@ -1,5 +1,11 @@ import anthropic +from anthropic.types import APIError, APITimeoutError, APIConnectionError +from src.ai.tool import ToolError +import logger +class AnthropicError(Exception): + """Custom exception for anthropic-related errors.""" + pass async def chat_completion( system_prompt: str, @@ -8,24 +14,31 @@ async def chat_completion( ): client = anthropic.AsyncClient() - message = await client.messages.create( - model=model, - max_tokens=2000, - temperature=0, - system=system_prompt, - messages=[ - { - "role": "user", - "content": [ - { - "type": "text", - "text": prompt, - } - ], - } - ], - ) - return message.content + try: + message = await client.messages.create( + model=model, + max_tokens=2000, + temperature=0, + system=system_prompt, + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": prompt, + } + ], + } + ], + ) + return message.content + except (APIError, APITimeoutError, APIConnectionError) as e: + logger.log.error(f"Anthropic API error in chat_completion: {str(e)}") + raise AnthropicError(f"Anthropic API error: {str(e)}") + except Exception as e: + logger.log.error(f"Unexpected error in chat_completion: {str(e)}") + raise AnthropicError(f"Unexpected error: {str(e)}") async def tool_completion( @@ -44,28 +57,35 @@ async def tool_completion( else: tool_choice = {"type": "auto"} - message = await client.messages.create( - model=model, - max_tokens=2000, - temperature=0, - system=system_prompt, - messages=[ - { - "role": "user", - "content": [ - { - "type": "text", - "text": prompt, - } - ], - } - ], - tools=tools, - tool_choice=tool_choice, - ) - if message.stop_reason == "tool_use": - for response in message.content: - if response.type == "tool_use": - return response.input - else: - return message.content[0].text + try: + message = await client.messages.create( + model=model, + max_tokens=2000, + temperature=0, + system=system_prompt, + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": prompt, + } + ], + } + ], + tools=tools, + tool_choice=tool_choice, + ) + if message.stop_reason == "tool_use": + for response in message.content: + if response.type == "tool_use": + return response.input + else: + return message.content[0].text + except (APIError, APITimeoutError, APIConnectionError) as e: + logger.log.error(f"Anthropic API error in tool_completion: {str(e)}") + raise AnthropicError(f"Anthropic API error: {str(e)}") + except Exception as e: + logger.log.error(f"Unexpected error in tool_completion: {str(e)}") + raise AnthropicError(f"Unexpected error: {str(e)}") diff --git a/src/ai/assistant.py b/src/ai/assistant.py index ebef2e1..52e665f 100644 --- a/src/ai/assistant.py +++ b/src/ai/assistant.py @@ -5,7 +5,7 @@ import logger from . import prompt, schema from .anthropic import tool_completion -from .tool import TOOLS +from .tool import TOOLS, ToolError class Assistant: @@ -21,26 +21,33 @@ async def overview( self, context: code.model.PullRequestContextModel, ) -> code.review.model.OverviewModel: - completion = await tool_completion( - system_prompt=self._builder.render_template( - name="overview", - prefix="system", - persona=self._persona, - ), - prompt=self._builder.render_template( - name="overview", - prefix="user", - ), - model=self._model_name, - tools=[TOOLS["review_files"]], - tool_override="review_files", - ) - response = schema.ReviewRequestsResponseModel(**completion) + try: + completion = await tool_completion( + system_prompt=self._builder.render_template( + name="overview", + prefix="system", + persona=self._persona, + ), + prompt=self._builder.render_template( + name="overview", + prefix="user", + ), + model=self._model_name, + tools=[TOOLS["review_files"]], + tool_override="review_files", + ) + response = schema.ReviewRequestsResponseModel(**completion) - return code.review.comment.parse_overview( - response=response, - context=context, - ) + return code.review.comment.parse_overview( + response=response, + context=context, + ) + except ToolError as e: + logger.log.error(f"Error in overview: {str(e)}") + raise + except Exception as e: + logger.log.error(f"Unexpected error in overview: {str(e)}") + raise async def review_file( self, @@ -53,54 +60,68 @@ async def review_file( logger.log.debug(f"Reviewing file: {context.path}") logger.log.debug(f"Hunks: {context.patch.hunks}") - completion = await tool_completion( - system_prompt=self._builder.render_template( - name="file-review", - prefix="system", - persona=self._persona, - ), - prompt=self._builder.render_template( - name="file-review", - prefix="user", - observations=observations, - file_context=context, - ), - model=self._model_name, - tools=[TOOLS["post_feedback"]], - tool_override="post_feedback", - ) - response = schema.FileReviewResponseModel(**completion) + try: + completion = await tool_completion( + system_prompt=self._builder.render_template( + name="file-review", + prefix="system", + persona=self._persona, + ), + prompt=self._builder.render_template( + name="file-review", + prefix="user", + observations=observations, + file_context=context, + ), + model=self._model_name, + tools=[TOOLS["post_feedback"]], + tool_override="post_feedback", + ) + response = schema.FileReviewResponseModel(**completion) - return code.review.comment.extract_comments( - response=response, - file_context=context, - severity_limit=severity_limit, - ) + return code.review.comment.extract_comments( + response=response, + file_context=context, + severity_limit=severity_limit, + ) + except ToolError as e: + logger.log.error(f"Error in review_file for {context.path}: {str(e)}") + raise + except Exception as e: + logger.log.error(f"Unexpected error in review_file for {context.path}: {str(e)}") + raise async def get_feedback( self, prioritized_comments: list[code.model.GitHubCommentModel], remaining_comments: list[code.model.GitHubCommentModel], ) -> code.review.model.Feedback: - completion = await tool_completion( - system_prompt=self._builder.render_template( - name="review-summary", - prefix="system", - persona=self._persona, - ), - prompt=self._builder.render_template( - name="review-summary", - prefix="user", - prioritized_comments=prioritized_comments, - comments=remaining_comments, - ), - model=self._model_name, - tools=[TOOLS["submit_review"]], - tool_override="submit_review", - ) - response = schema.ReviewResponseModel(**completion) + try: + completion = await tool_completion( + system_prompt=self._builder.render_template( + name="review-summary", + prefix="system", + persona=self._persona, + ), + prompt=self._builder.render_template( + name="review-summary", + prefix="user", + prioritized_comments=prioritized_comments, + comments=remaining_comments, + ), + model=self._model_name, + tools=[TOOLS["submit_review"]], + tool_override="submit_review", + ) + response = schema.ReviewResponseModel(**completion) - return code.review.comment.parse_feedback( - response=response, - comments=prioritized_comments, - ) + return code.review.comment.parse_feedback( + response=response, + comments=prioritized_comments, + ) + except ToolError as e: + logger.log.error(f"Error in get_feedback: {str(e)}") + raise + except Exception as e: + logger.log.error(f"Unexpected error in get_feedback: {str(e)}") + raise diff --git a/src/ai/prompt.py b/src/ai/prompt.py index af5abec..b91c67f 100644 --- a/src/ai/prompt.py +++ b/src/ai/prompt.py @@ -6,6 +6,10 @@ import code.model import code.review.model +class PromptError(Exception): + """Custom exception for prompt-related errors.""" + pass + PROMPT_DIR = pathlib.Path(__file__).parent / "prompts" @@ -29,7 +33,12 @@ def _load_template( name: str, prefix: typing.Literal["system", "user", "persona"], ) -> jinja2.Template: - return self._templates[prefix].get_template(name) + try: + return self._templates[prefix].get_template(name) + except jinja2.TemplateNotFound: + raise PromptError(f"Template not found: {prefix}/{name}") + except jinja2.TemplateError as e: + raise PromptError(f"Error loading template {prefix}/{name}: {str(e)}") def render_template( self, @@ -37,6 +46,13 @@ def render_template( prefix: typing.Literal["system", "user", "persona"], **kwargs, ) -> str: - template = self._load_template(f"{name}.md", prefix=prefix) - overview = template.render(context=self._context, prefix=prefix, **kwargs) - return overview + try: + template = self._load_template(f"{name}.md", prefix=prefix) + overview = template.render(context=self._context, prefix=prefix, **kwargs) + return overview + except PromptError as e: + raise e + except jinja2.TemplateError as e: + raise PromptError(f"Error rendering template {prefix}/{name}.md: {str(e)}") + except Exception as e: + raise PromptError(f"Unexpected error rendering template {prefix}/{name}.md: {str(e)}") diff --git a/src/ai/tool.py b/src/ai/tool.py index c442249..08d93a2 100644 --- a/src/ai/tool.py +++ b/src/ai/tool.py @@ -1,22 +1,37 @@ import json import pathlib -from typing import Any +from typing import Any, Dict -TOOL_DIR = pathlib.Path(__file__).parent / "tools" +import logger +TOOL_DIR = pathlib.Path(__file__).parent / "tools" -def load_tool(name: str) -> dict[str, Any]: - path = TOOL_DIR / f"{name}.json" - with open(path, "r") as file: - return json.load(file) +class ToolError(Exception): + """Custom exception for tool-related errors.""" + pass +def load_tool(name: str) -> Dict[str, Any]: + try: + path = TOOL_DIR / f"{name}.json" + with open(path, "r") as file: + return json.load(file) + except FileNotFoundError: + raise ToolError(f"Tool file not found: {name}.json") + except json.JSONDecodeError: + raise ToolError(f"Invalid JSON in tool file: {name}.json") -def get_all_tools() -> dict[str, dict[str, Any]]: +def get_all_tools() -> Dict[str, Dict[str, Any]]: tools = {} for file in TOOL_DIR.glob("*.json"): tool_name = file.stem - tools[tool_name] = load_tool(tool_name) + try: + tools[tool_name] = load_tool(tool_name) + except ToolError as e: + logger.log.error(f"Error loading tool {tool_name}: {str(e)}") return tools - -TOOLS = get_all_tools() +try: + TOOLS = get_all_tools() +except Exception as e: + logger.log.critical(f"Failed to load tools: {str(e)}") + TOOLS = {} diff --git a/src/app.py b/src/app.py index 87fa135..fbb78f2 100644 --- a/src/app.py +++ b/src/app.py @@ -7,6 +7,7 @@ import ai.tool import logger from github.PullRequest import PullRequest +from github import GithubException class App: @@ -34,16 +35,20 @@ async def _review_file( if not context.patch.diff: return [], [] - # Stagger request start times to comply with rate limits - logger.log.debug(f"Waiting {delay} seconds before reviewing") - await asyncio.sleep(delay) + try: + # Stagger request start times to comply with rate limits + logger.log.debug(f"Waiting {delay} seconds before reviewing") + await asyncio.sleep(delay) - prioritized_comments, remaining_comments = await self._assistant.review_file( - observations=observations, - context=context, - ) + prioritized_comments, remaining_comments = await self._assistant.review_file( + observations=observations, + context=context, + ) - return prioritized_comments, remaining_comments + return prioritized_comments, remaining_comments + except Exception as e: + logger.log.error(f"Error reviewing file {context.path}: {str(e)}") + raise async def _review_files( self, @@ -71,41 +76,47 @@ async def _review_files( return prioritized_comments, remaining_comments async def run(self): - overview = await self._assistant.overview(self._context) - status = overview.initial_assessment.status - if status != code.review.model.Status.REVIEW_REQUIRED: - code.pull_request.submit_review( - pull_request=self._pr, - body=overview.initial_assessment.summary, + try: + overview = await self._assistant.overview(self._context) + status = overview.initial_assessment.status + if status != code.review.model.Status.REVIEW_REQUIRED: + code.pull_request.submit_review( + pull_request=self._pr, + body=overview.initial_assessment.summary, + ) + return + + observations = overview.observations + file_contexts = overview.file_contexts + logger.log.debug( + f"Files to review: \n" + f"- {"\n- ".join([context.path for context in file_contexts])}" + ) + + prioritized_comments, remaining_comments = await self._review_files( + observations=observations, + contexts=file_contexts, + ) + + feedback = await self._assistant.get_feedback( + prioritized_comments=prioritized_comments, + remaining_comments=remaining_comments, ) - return - - observations = overview.observations - file_contexts = overview.file_contexts - logger.log.debug( - f"Files to review: \n" - f"- {"\n- ".join([context.path for context in file_contexts])}" - ) - - prioritized_comments, remaining_comments = await self._review_files( - observations=observations, - contexts=file_contexts, - ) - - feedback = await self._assistant.get_feedback( - prioritized_comments=prioritized_comments, - remaining_comments=remaining_comments, - ) - logger.log.info(f"Overall Feedback: {feedback.evaluation}") - - if self._debug: - logger.log.debug("Running in debug, no review submitted") - return - - code.pull_request.submit_review( - pull_request=self._pr, - body=f"{feedback.overall_comment}\n\n" - f"{feedback.justification}\n" - f"Final Evaluation: {feedback.evaluation}", - comments=prioritized_comments, - ) + logger.log.info(f"Overall Feedback: {feedback.evaluation}") + + if self._debug: + logger.log.debug("Running in debug, no review submitted") + return + + if not code.pull_request.submit_review( + pull_request=self._pr, + body=f"{feedback.overall_comment}\n\n" + f"{feedback.justification}\n" + f"Final Evaluation: {feedback.evaluation}", + comments=prioritized_comments, + ): + code.pull_request.post_comment(self._pr, "Couldn't post review") + except GithubException as e: + logger.log.error(f"GitHub API error: {str(e)}") + except Exception as e: + logger.log.error(f"Unexpected error in run method: {str(e)}") diff --git a/src/code/model.py b/src/code/model.py index 70569c0..ffb1ed3 100644 --- a/src/code/model.py +++ b/src/code/model.py @@ -5,6 +5,10 @@ from ai.schema import Side +class InvalidSeverityError(ValueError): + pass + + class Severity(enum.IntEnum): CRITICAL = 0 MAJOR = 1 @@ -14,7 +18,10 @@ class Severity(enum.IntEnum): @classmethod def from_string(cls, s): - return cls[s.upper()] + try: + return cls[s.upper()] + except KeyError: + raise InvalidSeverityError(f"Invalid severity: {s}") class Category(enum.StrEnum): diff --git a/src/code/pull_request.py b/src/code/pull_request.py index f44c81a..8ac8445 100644 --- a/src/code/pull_request.py +++ b/src/code/pull_request.py @@ -1,4 +1,5 @@ import sys +from typing import Optional import github from github.PullRequest import PullRequest @@ -9,51 +10,67 @@ from .diff import parse_diff -def get_pr(cfg: config.GitHubConfig): +def get_pr(cfg: config.GitHubConfig) -> Optional[PullRequest]: try: gh = github.Github(cfg.token) repo = gh.get_repo(cfg.repository) pr = repo.get_pull(cfg.pr_number) + logger.log.info(f"PR retrieved: #{pr.number}") return pr + except github.GithubException as e: + logger.log.critical(f"Github API error while retrieving PR: {e}") + raise except Exception as e: - logger.log.critical(f"Couldn't retrieve pull request: {e}") - sys.exit(69) + logger.log.critical(f"Unexpected error while retrieving PR: {e}") + raise -def build_pr_context(pull_request: PullRequest) -> model.PullRequestContextModel: - files = pull_request.get_files() - context = model.PullRequestContextModel( - title=pull_request.title, - description=pull_request.body or "", - commit_messages=[ - commit.commit.message for commit in pull_request.get_commits() - ], - review_comments=[ - comment.body for comment in pull_request.get_review_comments() - ], - issue_comments=[comment.body for comment in pull_request.get_issue_comments()], - patches={ - file.filename: model.FilePatchModel( - filename=file.filename, - diff=file.patch or "", - hunks=[] if not file.patch else parse_diff(file.patch), - ) - for file in files - }, - added_files=[file.filename for file in files if file.status == "added"], - modified_files=[file.filename for file in files if file.status == "modified"], - deleted_files=[file.filename for file in files if file.status == "removed"], - ) - return context +def build_pr_context(pull_request: PullRequest) -> Optional[model.PullRequestContextModel]: + try: + files = pull_request.get_files() + context = model.PullRequestContextModel( + title=pull_request.title, + description=pull_request.body or "", + commit_messages=[ + commit.commit.message for commit in pull_request.get_commits() + ], + review_comments=[ + comment.body for comment in pull_request.get_review_comments() + ], + issue_comments=[comment.body for comment in pull_request.get_issue_comments()], + patches={ + file.filename: model.FilePatchModel( + filename=file.filename, + diff=file.patch or "", + hunks=[] if not file.patch else parse_diff(file.patch), + ) + for file in files + }, + added_files=[file.filename for file in files if file.status == "added"], + modified_files=[file.filename for file in files if file.status == "modified"], + deleted_files=[file.filename for file in files if file.status == "removed"], + ) + logger.log.info(f"PR Context built successfully: {context.title}") + return context + except github.GithubException as e: + logger.log.critical(f"GitHub API error while building PR context: {e}") + return None + except Exception as e: + logger.log.critical(f"Unexpected error while building PR context: {e}") + return None -def post_comment(pull_request: PullRequest, message: str): +def post_comment(pull_request: PullRequest, message: str) -> bool: try: - pull_request.create_issue_comment(message) + comment = pull_request.create_issue_comment(message) + logger.log.info(f"Comment posted successfully: {comment.id}") + return True except github.GithubException as e: - logger.log.warning(f"Could not post comment to GitHub: {e}") + logger.log.error(f"GitHub API error while posting comment: {e}") + return False except Exception as e: - logger.log.warning(f"Unknown problem posting comment: {e}") + logger.log.error(f"Unexpected error while posting comment: {e}") + return False def submit_review( @@ -61,10 +78,19 @@ def submit_review( body: str, comments: list[model.GitHubCommentModel] | None = None, ): - comments = [comment.model_dump(exclude_none=True) for comment in comments or []] - logger.log.debug(f"Submitting review: {comments}") - pull_request.create_review( - body=body, - comments=comments, - event="COMMENT", - ) + try: + comments = [comment.model_dump(exclude_none=True) for comment in comments or []] + logger.log.debug(f"Submitting review: {comments}") + review = pull_request.create_review( + body=body, + comments=comments, + event="COMMENT", + ) + logger.log.info(f"Review submitted successfully: {review.id}") + return True + except github.GithubException as e: + logger.log.error(f"GitHub API error while submitting review: {e}") + return False + except Exception as e: + logger.log.error(f"Unexpected error while submitting review: {e}") + return False diff --git a/src/config.py b/src/config.py index 3b75146..d04f742 100644 --- a/src/config.py +++ b/src/config.py @@ -31,18 +31,32 @@ def from_env() -> AppConfig: try: debug = bool(os.environ.get("DEBUG", False)) - github_token = os.environ["GITHUB_TOKEN"] - repository = os.environ["GITHUB_REPOSITORY"] - event_path = os.environ["GITHUB_EVENT_PATH"] + github_token = os.environ.get("GITHUB_TOKEN") + if not github_token: + raise ValueError("GITHUB_TOKEN environment variable is not set") + + repository = os.environ.get("GITHUB_REPOSITORY") + if not repository: + raise ValueError("GITHUB_REPOSITORY environment variable is not set") + + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not event_path: + raise ValueError("GITHUB_EVENT_PATH environment variable is not set") strategy = os.environ.get("LLM_STRATEGY", "anthropic") model = os.environ.get("MODEL", "claude-3-5-sonnet-20240620") persona = os.environ.get("PERSONA", "pirate") - with open(event_path, "r") as f: - event = json.load(f) + try: + with open(event_path, "r") as f: + event = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse GitHub event JSON: {e}") - pr_number = event["issue"]["number"] + try: + pr_number = event["issue"]["number"] + except KeyError: + raise ValueError("Failed to extract PR number from GitHub event") config = AppConfig( github=GitHubConfig( @@ -61,4 +75,4 @@ def from_env() -> AppConfig: return config except Exception as e: logger.log.critical(f"Failed to load environment: {e}") - sys.exit(42) + raise diff --git a/src/logger.py b/src/logger.py index a44ddd2..66a71f6 100644 --- a/src/logger.py +++ b/src/logger.py @@ -1,24 +1,35 @@ import logging import os +import sys LOG_FILE = os.environ.get("LOGFILE", "default_logfile.log") def _init_logger(): - logger = logging.getLogger(__name__) - logger.setLevel(logging.DEBUG) + try: + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.DEBUG) + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - console_handler.setFormatter(formatter) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + console_handler.setFormatter(formatter) - logger.addHandler(console_handler) + logger.addHandler(console_handler) - return logger + return logger + except Exception as e: + print(f"Error initializing logger: {e}", file=sys.stderr) + raise -log = _init_logger() +try: + log = _init_logger() +except Exception: + # Fallback to basic logging if initialization fails + logging.basicConfig(level=logging.ERROR, format="%(asctime)s - %(levelname)s - %(message)s") + log = logging.getLogger(__name__) + log.error("Failed to initialize custom logger. Using basic configuration.") diff --git a/src/main.py b/src/main.py index 8e8d3eb..7ce0647 100644 --- a/src/main.py +++ b/src/main.py @@ -1,9 +1,13 @@ import asyncio import sys import traceback +import github.GithubException +from ai.anthropic import AnthropicError import ai.assistant -import ai.prompt +from ai.prompt import PromptError +from ai.tool import ToolError +from code.model import InvalidSeverityError import code.pull_request import config import logger @@ -11,26 +15,40 @@ def main(): - cfg = config.from_env() - pr = code.pull_request.get_pr(cfg.github) - logger.log.debug(f"Pull request retrieved: #{pr.number}") - try: + cfg = config.from_env() + pr = code.pull_request.get_pr(cfg.github) context = code.pull_request.build_pr_context(pr) - logger.log.debug(f"Context built successfully: {context.title}") builder = ai.prompt.Builder(context) assistant = ai.assistant.Assistant(cfg.llm, builder) - app = App(pr, context, assistant, debug=cfg.debug) - asyncio.run(app.run()) + except github.GithubException as e: + logger.log.critical(f"Exit with GithubException: {e}") + sys.exit(69) + except InvalidSeverityError as e: + logger.log.error(f"Exit with InvalidSeverityError: {e}") + sys.exit(255) + except ToolError as e: + logger.log.error(f"Exit with ToolError: {e}") + sys.exit(7) + except AnthropicError as e: + logger.log.errror(f"Exit with AnthropicError: {e}") + sys.exit(8) + except PromptError as e: + logger.log.error(f"Exit with PromptError: {e}") + sys.exit(135) + except ValueError as e: + logger.log.error(f"Exit with ValueError: {e}") + sys.exit(123) except Exception as e: - logger.log.error(f"Problem during run: {e}") - code.pull_request.post_comment( - pr, - f"Sorry, couldn't review your code because\n" - f"```{traceback.format_exc()}```", - ) + logger.log.critical(f"Exit with Unexpected Exception: {e}") + if 'pr' in locals(): + code.pull_request.post_comment( + pr, + f"Sorry, I couldn't review your code because:\n" + f"```{traceback.format_exc()}```", + ) sys.exit(42)