diff --git a/INSTALL.md b/INSTALL.md index f4247ce2..492dc25c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -40,7 +40,7 @@ For other git providers, update CONFIG.GIT_PROVIDER accordingly, and check the ` ``` docker run --rm -it -e OPENAI.KEY= -e GITHUB.USER_TOKEN= codiumai/pr-agent --pr_url ask "" ``` -Note: If you want to ensure you're running a specific version of the Docker image, consider using the image's digest. +Note: If you want to ensure you're running a specific version of the Docker image, consider using the image's digest. The digest is a unique identifier for a specific version of an image. You can pull and run an image using its digest by referencing it like so: repository@sha256:digest. Always ensure you're using the correct and trusted digest for your operations. 1. To request a review for a PR using a specific digest, run the following command: @@ -89,17 +89,17 @@ chmod 600 pr_agent/settings/.secrets.toml ``` export PYTHONPATH=[$PYTHONPATH:] -python3 -m pr_agent.cli --pr_url /review -python3 -m pr_agent.cli --pr_url /ask -python3 -m pr_agent.cli --pr_url /describe -python3 -m pr_agent.cli --pr_url /improve +python3 -m pr_agent.cli --pr_url review +python3 -m pr_agent.cli --pr_url ask +python3 -m pr_agent.cli --pr_url describe +python3 -m pr_agent.cli --pr_url improve ``` --- ### Method 3: Run as a GitHub Action -You can use our pre-built Github Action Docker image to run PR-Agent as a Github Action. +You can use our pre-built Github Action Docker image to run PR-Agent as a Github Action. 1. Add the following file to your repository under `.github/workflows/pr_agent.yml`: @@ -153,7 +153,7 @@ OPENAI_KEY: The GITHUB_TOKEN secret is automatically created by GitHub. -3. Merge this change to your main branch. +3. Merge this change to your main branch. When you open your next PR, you should see a comment from `github-actions` bot with a review of your PR, and instructions on how to use the rest of the tools. 4. You may configure PR-Agent by adding environment variables under the env section corresponding to any configurable property in the [configuration](pr_agent/settings/configuration.toml) file. Some examples: @@ -221,12 +221,12 @@ git clone https://github.com/Codium-ai/pr-agent.git - Copy your app's webhook secret to the webhook_secret field. - Set deployment_type to 'app' in [configuration.toml](./pr_agent/settings/configuration.toml) -> The .secrets.toml file is not copied to the Docker image by default, and is only used for local development. +> The .secrets.toml file is not copied to the Docker image by default, and is only used for local development. > If you want to use the .secrets.toml file in your Docker image, you can add remove it from the .dockerignore file. -> In most production environments, you would inject the secrets file as environment variables or as mounted volumes. +> In most production environments, you would inject the secrets file as environment variables or as mounted volumes. > For example, in order to inject a secrets file as a volume in a Kubernetes environment you can update your pod spec to include the following, > assuming you have a secret named `pr-agent-settings` with a key named `.secrets.toml`: -``` +``` volumes: - name: settings-volume secret: @@ -322,7 +322,7 @@ Example IAM permissions to that user to allow access to CodeCommit: "codecommit:PostComment*", "codecommit:PutCommentReaction", "codecommit:UpdatePullRequestDescription", - "codecommit:UpdatePullRequestTitle" + "codecommit:UpdatePullRequestTitle" ], "Resource": "*" } @@ -366,8 +366,8 @@ WEBHOOK_SECRET=$(python -c "import secrets; print(secrets.token_hex(10))") - Your OpenAI key. - In the [gitlab] section, fill in personal_access_token and shared_secret. The access token can be a personal access token, or a group or project access token. - Set deployment_type to 'gitlab' in [configuration.toml](./pr_agent/settings/configuration.toml) -5. Create a webhook in GitLab. Set the URL to the URL of your app's server. Set the secret token to the generated secret from step 2. -In the "Trigger" section, check the ‘comments’ and ‘merge request events’ boxes. +5. Create a webhook in GitLab. Set the URL to the URL of your app's server. Set the secret token to the generated secret from step 2. +In the "Trigger" section, check the ‘comments’ and ‘merge request events’ boxes. 6. Test your installation by opening a merge request or commenting or a merge request using one of CodiumAI's commands. diff --git a/README.md b/README.md index 6fd1f9f6..f5bc474e 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ See the [Release notes](./RELEASE_NOTES.md) for updates on the latest changes. | | Improve Code | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | :white_check_mark: | | | ⮑ Extended | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | :white_check_mark: | | | Reflect and Review | :white_check_mark: | :white_check_mark: | :white_check_mark: | | :white_check_mark: | :white_check_mark: | -| | Update CHANGELOG.md | :white_check_mark: | :white_check_mark: | :white_check_mark: | | | | +| | Update CHANGELOG.md | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | | | | Find similar issue | :white_check_mark: | | | | | | | | Add Documentation | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | :white_check_mark: | | | | | | | | | diff --git a/Usage.md b/Usage.md index 6176eaf0..867ccc44 100644 --- a/Usage.md +++ b/Usage.md @@ -29,6 +29,16 @@ In addition to general configuration options, each tool has its own configuratio The [Tools Guide](./docs/TOOLS_GUIDE.md) provides a detailed description of the different tools and their configurations. +#### Ignoring files from analysis +In some cases, you may want to exclude specific files or directories from the analysis performed by CodiumAI PR-Agent. This can be useful, for example, when you have files that are generated automatically or files that shouldn't be reviewed, like vendored code. + +To ignore files or directories, edit the **[ignore.toml](/pr_agent/settings/ignore.toml)** configuration file. This setting is also exposed the following environment variables: + + - `IGNORE.GLOB` + - `IGNORE.REGEX` + +See [dynaconf envvars documentation](https://www.dynaconf.com/envvars/). + #### git provider The [git_provider](pr_agent/settings/configuration.toml#L4) field in the configuration file determines the GIT provider that will be used by PR-Agent. Currently, the following providers are supported: ` @@ -101,7 +111,7 @@ Any configuration value in [configuration file](pr_agent/settings/configuration. When running PR-Agent from [GitHub App](INSTALL.md#method-5-run-as-a-github-app), the default configurations from a pre-built docker will be initially loaded. #### GitHub app automatic tools -The [github_app](pr_agent/settings/configuration.toml#L56) section defines GitHub app specific configurations. +The [github_app](pr_agent/settings/configuration.toml#L56) section defines GitHub app specific configurations. An important parameter is `pr_commands`, which is a list of tools that will be **run automatically** when a new PR is opened: ``` [github_app] @@ -122,11 +132,18 @@ keep_original_user_title = false ``` When a new PR is opened, PR-Agent will run the `describe` tool with the above parameters. +To cancel the automatic run of all the tools, set: +``` +[github_app] +pr_commands = "" +``` + + Note that a local `.pr_agent.toml` file enables you to edit and customize the default parameters of any tool, not just the ones that are run automatically. #### Editing the prompts The prompts for the various PR-Agent tools are defined in the `pr_agent/settings` folder. -In practice, the prompts are loaded and stored as a standard setting object. +In practice, the prompts are loaded and stored as a standard setting object. Hence, editing them is similar to editing any other configuration value - just place the relevant key in `.pr_agent.toml`file, and override the default value. For example, if you want to edit the prompts of the [describe](./pr_agent/settings/pr_description_prompts.toml) tool, you can add the following to your `.pr_agent.toml` file: @@ -151,7 +168,7 @@ You can configure settings in GitHub action by adding environment variables unde PR_CODE_SUGGESTIONS.NUM_CODE_SUGGESTIONS: 6 # Increase number of code suggestions github_action.auto_review: "true" # Enable auto review github_action.auto_describe: "true" # Enable auto describe - github_action.auto_improve: "false" # Disable auto improve + github_action.auto_improve: "false" # Disable auto improve ``` specifically, `github_action.auto_review`, `github_action.auto_describe` and `github_action.auto_improve` are used to enable/disable automatic tools that run when a new PR is opened. @@ -164,25 +181,25 @@ To use a different model than the default (GPT-4), you need to edit [configurati For models and environments not from OPENAI, you might need to provide additional keys and other parameters. See below for instructions. #### Azure -To use Azure, set in your .secrets.toml: +To use Azure, set in your .secrets.toml: ``` api_key = "" # your azure api key api_type = "azure" api_version = '2023-05-15' # Check Azure documentation for the current API version api_base = "" # The base URL for your Azure OpenAI resource. e.g. "https://.openai.azure.com" -deployment_id = "" # The deployment name you chose when you deployed the engine +openai.deployment_id = "" # The deployment name you chose when you deployed the engine ``` -and +and ``` [config] model="" # the OpenAI model you've deployed on Azure (e.g. gpt-3.5-turbo) ``` -in the configuration.toml +in the configuration.toml #### Huggingface -**Local** +**Local** You can run Huggingface models locally through either [VLLM](https://docs.litellm.ai/docs/providers/vllm) or [Ollama](https://docs.litellm.ai/docs/providers/ollama) E.g. to use a new Huggingface model locally via Ollama, set: @@ -202,7 +219,7 @@ MAX_TOKENS={ model = "ollama/llama2" [ollama] # in .secrets.toml -api_base = ... # the base url for your huggingface inference endpoint +api_base = ... # the base url for your huggingface inference endpoint ``` **Inference Endpoints** @@ -223,7 +240,7 @@ model = "huggingface/meta-llama/Llama-2-7b-chat-hf" [huggingface] # in .secrets.toml key = ... # your huggingface api key -api_base = ... # the base url for your huggingface inference endpoint +api_base = ... # the base url for your huggingface inference endpoint ``` (you can obtain a Llama2 key from [here](https://replicate.com/replicate/llama-2-70b-chat/api)) @@ -244,12 +261,12 @@ Also review the [AiHandler](pr_agent/algo/ai_handler.py) file for instruction ho ### Working with large PRs The default mode of CodiumAI is to have a single call per tool, using GPT-4, which has a token limit of 8000 tokens. -This mode provide a very good speed-quality-cost tradeoff, and can handle most PRs successfully. +This mode provide a very good speed-quality-cost tradeoff, and can handle most PRs successfully. When the PR is above the token limit, it employs a [PR Compression strategy](./PR_COMPRESSION.md). However, for very large PRs, or in case you want to emphasize quality over speed and cost, there are 2 possible solutions: 1) [Use a model](#changing-a-model) with larger context, like GPT-32K, or claude-100K. This solution will be applicable for all the tools. -2) For the `/improve` tool, there is an ['extended' mode](./docs/IMPROVE.md) (`/improve --extended`), +2) For the `/improve` tool, there is an ['extended' mode](./docs/IMPROVE.md) (`/improve --extended`), which divides the PR to chunks, and process each chunk separately. With this mode, regardless of the model, no compression will be done (but for large PRs, multiple model calls may occur) ### Appendix - additional configurations walkthrough @@ -261,6 +278,30 @@ All PR-Agent tools have a parameter called `extra_instructions`, that enables to /update_changelog --pr_update_changelog.extra_instructions="Make sure to update also the version ..." ``` +#### Patch Extra Lines +By default, around any change in your PR, git patch provides 3 lines of context above and below the change. +``` +@@ -12,5 +12,5 @@ def func1(): + code line that already existed in the file... + code line that already existed in the file... + code line that already existed in the file.... +-code line that was removed in the PR ++new code line added in the PR + code line that already existed in the file... + code line that already existed in the file... + code line that already existed in the file... +``` + +For the `review`, `describe`, `ask` and `add_docs` tools, if the token budget allows, PR-Agent tries to increase the number of lines of context, via the parameter: +``` +[config] +patch_extra_lines=3 +``` + +Increasing this number provides more context to the model, but will also increase the token budget. +If the PR is too large (see [PR Compression strategy](./PR_COMPRESSION.md)), PR-Agent automatically sets this number to 0, using the original git patch. + + #### Azure DevOps provider To use Azure DevOps provider use the following settings in configuration.toml: ``` @@ -274,4 +315,4 @@ And use the following settings (you have to replace the values) in .secrets.toml [azure_devops] org = "https://dev.azure.com/YOUR_ORGANIZATION/" pat = "YOUR_PAT_TOKEN" -``` \ No newline at end of file +``` diff --git a/docs/DESCRIBE.md b/docs/DESCRIBE.md index bb07668c..05d1608a 100644 --- a/docs/DESCRIBE.md +++ b/docs/DESCRIBE.md @@ -23,7 +23,7 @@ Under the section 'pr_description', the [configuration file](./../pr_agent/setti - `add_original_user_description`: if set to true, the tool will add the original user description to the generated description. Default is false. -- `add_original_user_title`: if set to true, the tool will keep the original PR title, and won't change it. Default is false. +- `keep_original_user_title`: if set to true, the tool will keep the original PR title, and won't change it. Default is false. - `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...". @@ -48,4 +48,4 @@ pr_agent:walkthrough ``` The marker `pr_agent:pr_type` will be replaced with the PR type, `pr_agent:summary` will be replaced with the PR summary, and `pr_agent:walkthrough` will be replaced with the PR walkthrough. -- `include_generated_by_header`: if set to true, the tool will add a dedicated header: 'Generated by PR Agent at ...' to any automatic content. Default is true. \ No newline at end of file +- `include_generated_by_header`: if set to true, the tool will add a dedicated header: 'Generated by PR Agent at ...' to any automatic content. Default is true. diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index 3d819af5..cd2bf2cc 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -1,20 +1,17 @@ -import logging -import os import shlex -import tempfile from pr_agent.algo.utils import update_settings_from_args from pr_agent.config_loader import get_settings -from pr_agent.git_providers import get_git_provider +from pr_agent.git_providers.utils import apply_repo_settings from pr_agent.tools.pr_add_docs import PRAddDocs from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions +from pr_agent.tools.pr_config import PRConfig from pr_agent.tools.pr_description import PRDescription from pr_agent.tools.pr_information_from_user import PRInformationFromUser -from pr_agent.tools.pr_similar_issue import PRSimilarIssue from pr_agent.tools.pr_questions import PRQuestions from pr_agent.tools.pr_reviewer import PRReviewer +from pr_agent.tools.pr_similar_issue import PRSimilarIssue from pr_agent.tools.pr_update_changelog import PRUpdateChangelog -from pr_agent.tools.pr_config import PRConfig command2class = { "auto_review": PRReviewer, @@ -44,22 +41,7 @@ class PRAgent: async def handle_request(self, pr_url, request, notify=None) -> bool: # First, apply repo specific settings if exists - if get_settings().config.use_repo_settings_file: - repo_settings_file = None - try: - git_provider = get_git_provider()(pr_url) - repo_settings = git_provider.get_repo_settings() - if repo_settings: - repo_settings_file = None - fd, repo_settings_file = tempfile.mkstemp(suffix='.toml') - os.write(fd, repo_settings) - get_settings().load_file(repo_settings_file) - finally: - if repo_settings_file: - try: - os.remove(repo_settings_file) - except Exception as e: - logging.error(f"Failed to remove temporary settings file {repo_settings_file}", e) + apply_repo_settings(pr_url) # Then, apply user specific settings if exists request = request.replace("'", "\\'") @@ -84,3 +66,4 @@ class PRAgent: else: return False return True + diff --git a/pr_agent/algo/ai_handler.py b/pr_agent/algo/ai_handler.py index 819ba25b..c3989563 100644 --- a/pr_agent/algo/ai_handler.py +++ b/pr_agent/algo/ai_handler.py @@ -1,4 +1,3 @@ -import logging import os import litellm @@ -7,6 +6,8 @@ from litellm import acompletion from openai.error import APIError, RateLimitError, Timeout, TryAgain from retry import retry from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger + OPENAI_RETRIES = 5 @@ -88,33 +89,34 @@ class AiHandler: try: deployment_id = self.deployment_id if get_settings().config.verbosity_level >= 2: - logging.debug( + get_logger().debug( f"Generating completion with {model}" f"{(' from deployment ' + deployment_id) if deployment_id else ''}" ) + if self.azure: + model = 'azure/' + model + messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] response = await acompletion( model=model, deployment_id=deployment_id, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": user} - ], + messages=messages, temperature=temperature, - azure=self.azure, force_timeout=get_settings().config.ai_timeout ) except (APIError, Timeout, TryAgain) as e: - logging.error("Error during OpenAI inference: ", e) + get_logger().error("Error during OpenAI inference: ", e) raise except (RateLimitError) as e: - logging.error("Rate limit error during OpenAI inference: ", e) + get_logger().error("Rate limit error during OpenAI inference: ", e) raise except (Exception) as e: - logging.error("Unknown error during OpenAI inference: ", e) + get_logger().error("Unknown error during OpenAI inference: ", e) raise TryAgain from e if response is None or len(response["choices"]) == 0: raise TryAgain resp = response["choices"][0]['message']['content'] finish_reason = response["choices"][0]["finish_reason"] - print(resp, finish_reason) + usage = response.get("usage") + get_logger().info("AI response", response=resp, messages=messages, finish_reason=finish_reason, + model=model, usage=usage) return resp, finish_reason diff --git a/pr_agent/algo/file_filter.py b/pr_agent/algo/file_filter.py new file mode 100644 index 00000000..cc466f57 --- /dev/null +++ b/pr_agent/algo/file_filter.py @@ -0,0 +1,31 @@ +import fnmatch +import re + +from pr_agent.config_loader import get_settings + +def filter_ignored(files): + """ + Filter out files that match the ignore patterns. + """ + + try: + # load regex patterns, and translate glob patterns to regex + patterns = get_settings().ignore.regex + patterns += [fnmatch.translate(glob) for glob in get_settings().ignore.glob] + + # compile all valid patterns + compiled_patterns = [] + for r in patterns: + try: + compiled_patterns.append(re.compile(r)) + except re.error: + pass + + # keep filenames that _don't_ match the ignore regex + for r in compiled_patterns: + files = [f for f in files if not r.match(f.filename)] + + except Exception as e: + print(f"Could not filter file list: {e}") + + return files diff --git a/pr_agent/algo/git_patch_processing.py b/pr_agent/algo/git_patch_processing.py index 58d05235..4c20ea48 100644 --- a/pr_agent/algo/git_patch_processing.py +++ b/pr_agent/algo/git_patch_processing.py @@ -1,8 +1,9 @@ from __future__ import annotations -import logging + import re from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger def extend_patch(original_file_str, patch_str, num_lines) -> str: @@ -63,7 +64,7 @@ def extend_patch(original_file_str, patch_str, num_lines) -> str: extended_patch_lines.append(line) except Exception as e: if get_settings().config.verbosity_level >= 2: - logging.error(f"Failed to extend patch: {e}") + get_logger().error(f"Failed to extend patch: {e}") return patch_str # finish previous hunk @@ -134,14 +135,14 @@ def handle_patch_deletions(patch: str, original_file_content_str: str, if not new_file_content_str: # logic for handling deleted files - don't show patch, just show that the file was deleted if get_settings().config.verbosity_level > 0: - logging.info(f"Processing file: {file_name}, minimizing deletion file") + get_logger().info(f"Processing file: {file_name}, minimizing deletion file") patch = None # file was deleted else: patch_lines = patch.splitlines() patch_new = omit_deletion_hunks(patch_lines) if patch != patch_new: if get_settings().config.verbosity_level > 0: - logging.info(f"Processing file: {file_name}, hunks were deleted") + get_logger().info(f"Processing file: {file_name}, hunks were deleted") patch = patch_new return patch diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index 1c34e603..63bd02eb 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -1,7 +1,6 @@ from __future__ import annotations import difflib -import logging import re import traceback from typing import Any, Callable, List, Tuple @@ -11,9 +10,11 @@ from github import RateLimitExceededException from pr_agent.algo import MAX_TOKENS from pr_agent.algo.git_patch_processing import convert_to_hunks_with_lines_numbers, extend_patch, handle_patch_deletions from pr_agent.algo.language_handler import sort_files_by_main_languages +from pr_agent.algo.file_filter import filter_ignored from pr_agent.algo.token_handler import TokenHandler, get_token_encoder from pr_agent.config_loader import get_settings from pr_agent.git_providers.git_provider import FilePatchInfo, GitProvider +from pr_agent.log import get_logger DELETED_FILES_ = "Deleted files:\n" @@ -21,7 +22,6 @@ MORE_MODIFIED_FILES_ = "More modified files:\n" OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD = 1000 OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD = 600 -PATCH_EXTRA_LINES = 3 def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: str, add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool = False) -> str: @@ -44,21 +44,24 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s """ if disable_extra_lines: - global PATCH_EXTRA_LINES PATCH_EXTRA_LINES = 0 + else: + PATCH_EXTRA_LINES = get_settings().config.patch_extra_lines try: diff_files = git_provider.get_diff_files() except RateLimitExceededException as e: - logging.error(f"Rate limit exceeded for git provider API. original message {e}") + get_logger().error(f"Rate limit exceeded for git provider API. original message {e}") raise + diff_files = filter_ignored(diff_files) + # get pr languages pr_languages = sort_files_by_main_languages(git_provider.get_languages(), diff_files) # generate a standard diff string, with patch extension - patches_extended, total_tokens, patches_extended_tokens = pr_generate_extended_diff(pr_languages, token_handler, - add_line_numbers_to_hunks) + patches_extended, total_tokens, patches_extended_tokens = pr_generate_extended_diff( + pr_languages, token_handler, add_line_numbers_to_hunks, patch_extra_lines=PATCH_EXTRA_LINES) # if we are under the limit, return the full diff if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < MAX_TOKENS[model]: @@ -80,7 +83,8 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s def pr_generate_extended_diff(pr_languages: list, token_handler: TokenHandler, - add_line_numbers_to_hunks: bool) -> Tuple[list, int, list]: + add_line_numbers_to_hunks: bool, + patch_extra_lines: int = 0) -> Tuple[list, int, list]: """ Generate a standard diff string with patch extension, while counting the number of tokens used and applying diff minimization techniques if needed. @@ -102,7 +106,7 @@ def pr_generate_extended_diff(pr_languages: list, continue # extend each patch with extra lines of context - extended_patch = extend_patch(original_file_content_str, patch, num_lines=PATCH_EXTRA_LINES) + extended_patch = extend_patch(original_file_content_str, patch, num_lines=patch_extra_lines) full_extended_patch = f"\n\n## {file.filename}\n\n{extended_patch}\n" if add_line_numbers_to_hunks: @@ -176,7 +180,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo # Hard Stop, no more tokens if total_tokens > MAX_TOKENS[model] - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD: - logging.warning(f"File was fully skipped, no more tokens: {file.filename}.") + get_logger().warning(f"File was fully skipped, no more tokens: {file.filename}.") continue # If the patch is too large, just show the file name @@ -185,7 +189,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo # TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens # until we meet the requirements if get_settings().config.verbosity_level >= 2: - logging.warning(f"Patch too large, minimizing it, {file.filename}") + get_logger().warning(f"Patch too large, minimizing it, {file.filename}") if not modified_files_list: total_tokens += token_handler.count_tokens(MORE_MODIFIED_FILES_) modified_files_list.append(file.filename) @@ -200,7 +204,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo patches.append(patch_final) total_tokens += token_handler.count_tokens(patch_final) if get_settings().config.verbosity_level >= 2: - logging.info(f"Tokens: {total_tokens}, last filename: {file.filename}") + get_logger().info(f"Tokens: {total_tokens}, last filename: {file.filename}") return patches, modified_files_list, deleted_files_list @@ -214,7 +218,7 @@ async def retry_with_fallback_models(f: Callable): get_settings().set("openai.deployment_id", deployment_id) return await f(model) except Exception as e: - logging.warning( + get_logger().warning( f"Failed to generate prediction with {model}" f"{(' from deployment ' + deployment_id) if deployment_id else ''}: " f"{traceback.format_exc()}" @@ -336,7 +340,7 @@ def clip_tokens(text: str, max_tokens: int) -> str: clipped_text = text[:num_output_chars] return clipped_text except Exception as e: - logging.warning(f"Failed to clip tokens: {e}") + get_logger().warning(f"Failed to clip tokens: {e}") return text @@ -347,25 +351,27 @@ def get_pr_multi_diffs(git_provider: GitProvider, """ Retrieves the diff files from a Git provider, sorts them by main language, and generates patches for each file. The patches are split into multiple groups based on the maximum number of tokens allowed for the given model. - + Args: git_provider (GitProvider): An object that provides access to Git provider APIs. token_handler (TokenHandler): An object that handles tokens in the context of a pull request. model (str): The name of the model. max_calls (int, optional): The maximum number of calls to retrieve diff files. Defaults to 5. - + Returns: List[str]: A list of final diff strings, split into multiple groups based on the maximum number of tokens allowed for the given model. - + Raises: RateLimitExceededException: If the rate limit for the Git provider API is exceeded. """ try: diff_files = git_provider.get_diff_files() except RateLimitExceededException as e: - logging.error(f"Rate limit exceeded for git provider API. original message {e}") + get_logger().error(f"Rate limit exceeded for git provider API. original message {e}") raise + diff_files = filter_ignored(diff_files) + # Sort files by main language pr_languages = sort_files_by_main_languages(git_provider.get_languages(), diff_files) @@ -381,7 +387,7 @@ def get_pr_multi_diffs(git_provider: GitProvider, for file in sorted_files: if call_number > max_calls: if get_settings().config.verbosity_level >= 2: - logging.info(f"Reached max calls ({max_calls})") + get_logger().info(f"Reached max calls ({max_calls})") break original_file_content_str = file.base_file @@ -404,13 +410,13 @@ def get_pr_multi_diffs(git_provider: GitProvider, total_tokens = token_handler.prompt_tokens call_number += 1 if get_settings().config.verbosity_level >= 2: - logging.info(f"Call number: {call_number}") + get_logger().info(f"Call number: {call_number}") if patch: patches.append(patch) total_tokens += new_patch_tokens if get_settings().config.verbosity_level >= 2: - logging.info(f"Tokens: {total_tokens}, last filename: {file.filename}") + get_logger().info(f"Tokens: {total_tokens}, last filename: {file.filename}") # Add the last chunk if patches: diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index c7923d16..4e88b33e 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -2,7 +2,6 @@ from __future__ import annotations import difflib import json -import logging import re import textwrap from datetime import datetime @@ -11,6 +10,7 @@ from typing import Any, List import yaml from starlette_context import context from pr_agent.config_loader import get_settings, global_settings +from pr_agent.log import get_logger def get_setting(key: str) -> Any: @@ -62,7 +62,7 @@ def convert_to_markdown(output_data: dict, gfm_supported: bool=True) -> str: markdown_text += f"- {emoji} **{key}:**\n\n" for item in value: if isinstance(item, dict) and key.lower() == 'code feedback': - markdown_text += parse_code_suggestion(item) + markdown_text += parse_code_suggestion(item, gfm_supported) elif item: markdown_text += f" - {item}\n" if key.lower() == 'code feedback': @@ -76,7 +76,7 @@ def convert_to_markdown(output_data: dict, gfm_supported: bool=True) -> str: return markdown_text -def parse_code_suggestion(code_suggestions: dict) -> str: +def parse_code_suggestion(code_suggestions: dict, gfm_supported: bool=True) -> str: """ Convert a dictionary of data into markdown format. @@ -99,6 +99,9 @@ def parse_code_suggestion(code_suggestions: dict) -> str: markdown_text += f"\n - **{sub_key}:** {sub_value}\n" else: markdown_text += f" **{sub_key}:** {sub_value}\n" + if not gfm_supported: + if "relevant line" not in sub_key.lower(): # nicer presentation + markdown_text = markdown_text.rstrip('\n') + "\\\n" markdown_text += "\n" return markdown_text @@ -156,7 +159,7 @@ def try_fix_json(review, max_iter=10, code_suggestions=False): iter_count += 1 if not valid_json: - logging.error("Unable to decode JSON response from AI") + get_logger().error("Unable to decode JSON response from AI") data = {} return data @@ -227,7 +230,7 @@ def load_large_diff(filename, new_file_content_str: str, original_file_content_s diff = difflib.unified_diff(original_file_content_str.splitlines(keepends=True), new_file_content_str.splitlines(keepends=True)) if get_settings().config.verbosity_level >= 2: - logging.warning(f"File was modified, but no patch was found. Manually creating patch: {filename}.") + get_logger().warning(f"File was modified, but no patch was found. Manually creating patch: {filename}.") patch = ''.join(diff) except Exception: pass @@ -259,12 +262,12 @@ def update_settings_from_args(args: List[str]) -> List[str]: vals = arg.split('=', 1) if len(vals) != 2: if len(vals) > 2: # --extended is a valid argument - logging.error(f'Invalid argument format: {arg}') + get_logger().error(f'Invalid argument format: {arg}') other_args.append(arg) continue key, value = _fix_key_value(*vals) get_settings().set(key, value) - logging.info(f'Updated setting {key} to: "{value}"') + get_logger().info(f'Updated setting {key} to: "{value}"') else: other_args.append(arg) return other_args @@ -276,7 +279,7 @@ def _fix_key_value(key: str, value: str): try: value = yaml.safe_load(value) except Exception as e: - logging.error(f"Failed to parse YAML for config override {key}={value}", exc_info=e) + get_logger().error(f"Failed to parse YAML for config override {key}={value}", exc_info=e) return key, value @@ -285,7 +288,7 @@ def load_yaml(review_text: str) -> dict: try: data = yaml.safe_load(review_text) except Exception as e: - logging.error(f"Failed to parse AI prediction: {e}") + get_logger().error(f"Failed to parse AI prediction: {e}") data = try_fix_yaml(review_text) return data @@ -296,7 +299,7 @@ def try_fix_yaml(review_text: str) -> dict: review_text_lines_tmp = '\n'.join(review_text_lines[:-i]) try: data = yaml.load(review_text_lines_tmp, Loader=yaml.SafeLoader) - logging.info(f"Successfully parsed AI prediction after removing {i} lines") + get_logger().info(f"Successfully parsed AI prediction after removing {i} lines") break except: pass diff --git a/pr_agent/cli.py b/pr_agent/cli.py index 07c37f5e..6728db9f 100644 --- a/pr_agent/cli.py +++ b/pr_agent/cli.py @@ -1,11 +1,12 @@ import argparse import asyncio -import logging import os from pr_agent.agent.pr_agent import PRAgent, commands from pr_agent.config_loader import get_settings +from pr_agent.log import setup_logger +setup_logger() def run(inargs=None): parser = argparse.ArgumentParser(description='AI based pull request analyzer', usage= @@ -47,7 +48,6 @@ For example: 'python cli.py --pr_url=... review --pr_reviewer.extra_instructions parser.print_help() return - logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) command = args.command.lower() get_settings().set("CONFIG.CLI_MODE", True) if args.issue_url: diff --git a/pr_agent/config_loader.py b/pr_agent/config_loader.py index 184adb82..3b0b0360 100644 --- a/pr_agent/config_loader.py +++ b/pr_agent/config_loader.py @@ -14,6 +14,7 @@ global_settings = Dynaconf( settings_files=[join(current_dir, f) for f in [ "settings/.secrets.toml", "settings/configuration.toml", + "settings/ignore.toml", "settings/language_extensions.toml", "settings/pr_reviewer_prompts.toml", "settings/pr_questions_prompts.toml", diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index e0f4760d..6a404532 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -1,10 +1,11 @@ import json -import logging from typing import Optional, Tuple from urllib.parse import urlparse import os +from ..log import get_logger + AZURE_DEVOPS_AVAILABLE = True try: from msrest.authentication import BasicAuthentication @@ -55,7 +56,7 @@ class AzureDevopsProvider: path=".pr_agent.toml") return contents except Exception as e: - logging.exception("get repo settings error") + get_logger().exception("get repo settings error") return "" def get_files(self): @@ -110,7 +111,7 @@ class AzureDevopsProvider: new_file_content_str = new_file_content_str.content except Exception as error: - logging.error("Failed to retrieve new file content of %s at version %s. Error: %s", file, version, str(error)) + get_logger().error("Failed to retrieve new file content of %s at version %s. Error: %s", file, version, str(error)) new_file_content_str = "" edit_type = EDIT_TYPE.MODIFIED @@ -131,7 +132,7 @@ class AzureDevopsProvider: include_content=True) original_file_content_str = original_file_content_str.content except Exception as error: - logging.error("Failed to retrieve original file content of %s at version %s. Error: %s", file, version, str(error)) + get_logger().error("Failed to retrieve original file content of %s at version %s. Error: %s", file, version, str(error)) original_file_content_str = "" patch = load_large_diff(file, new_file_content_str, original_file_content_str) @@ -166,7 +167,7 @@ class AzureDevopsProvider: pull_request_id=self.pr_num, git_pull_request_to_update=updated_pr) except Exception as e: - logging.exception(f"Could not update pull request {self.pr_num} description: {e}") + get_logger().exception(f"Could not update pull request {self.pr_num} description: {e}") def remove_initial_comment(self): return "" # not implemented yet @@ -235,9 +236,6 @@ class AzureDevopsProvider: def _parse_pr_url(pr_url: str) -> Tuple[str, int]: parsed_url = urlparse(pr_url) - if 'azure.com' not in parsed_url.netloc: - raise ValueError("The provided URL is not a valid Azure DevOps URL") - path_parts = parsed_url.path.strip('/').split('/') if len(path_parts) < 6 or path_parts[4] != 'pullrequest': diff --git a/pr_agent/git_providers/bitbucket_provider.py b/pr_agent/git_providers/bitbucket_provider.py index fb8d24f9..a85d9099 100644 --- a/pr_agent/git_providers/bitbucket_provider.py +++ b/pr_agent/git_providers/bitbucket_provider.py @@ -1,5 +1,4 @@ import json -import logging from typing import Optional, Tuple from urllib.parse import urlparse @@ -7,8 +6,9 @@ import requests from atlassian.bitbucket import Cloud from starlette_context import context -from ..algo.pr_processing import clip_tokens, find_line_number_of_relevant_line_in_file +from ..algo.pr_processing import find_line_number_of_relevant_line_in_file from ..config_loader import get_settings +from ..log import get_logger from .git_provider import FilePatchInfo, GitProvider @@ -63,14 +63,14 @@ class BitbucketProvider(GitProvider): if not relevant_lines_start or relevant_lines_start == -1: if get_settings().config.verbosity_level >= 2: - logging.exception( + get_logger().exception( f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}" ) continue if relevant_lines_end < relevant_lines_start: if get_settings().config.verbosity_level >= 2: - logging.exception( + get_logger().exception( f"Failed to publish code suggestion, " f"relevant_lines_end is {relevant_lines_end} and " f"relevant_lines_start is {relevant_lines_start}" @@ -99,7 +99,7 @@ class BitbucketProvider(GitProvider): return True except Exception as e: if get_settings().config.verbosity_level >= 2: - logging.error(f"Failed to publish code suggestion, error: {e}") + get_logger().error(f"Failed to publish code suggestion, error: {e}") return False def is_supported(self, capability: str) -> bool: @@ -146,7 +146,7 @@ class BitbucketProvider(GitProvider): for comment in self.temp_comments: self.pr.delete(f"comments/{comment}") except Exception as e: - logging.exception(f"Failed to remove temp comments, error: {e}") + get_logger().exception(f"Failed to remove temp comments, error: {e}") # funtion to create_inline_comment @@ -154,7 +154,7 @@ class BitbucketProvider(GitProvider): position, absolute_position = find_line_number_of_relevant_line_in_file(self.get_diff_files(), relevant_file.strip('`'), relevant_line_in_file) if position == -1: if get_settings().config.verbosity_level >= 2: - logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}") + get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}") subject_type = "FILE" else: subject_type = "LINE" diff --git a/pr_agent/git_providers/codecommit_provider.py b/pr_agent/git_providers/codecommit_provider.py index 5fa8c873..4e12f96e 100644 --- a/pr_agent/git_providers/codecommit_provider.py +++ b/pr_agent/git_providers/codecommit_provider.py @@ -1,17 +1,16 @@ -import logging import os import re from collections import Counter from typing import List, Optional, Tuple from urllib.parse import urlparse -from ..algo.language_handler import is_valid_file, language_extension_map -from ..algo.pr_processing import clip_tokens -from ..algo.utils import load_large_diff -from ..config_loader import get_settings -from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider, IncrementalPR from pr_agent.git_providers.codecommit_client import CodeCommitClient +from ..algo.language_handler import is_valid_file, language_extension_map +from ..algo.utils import load_large_diff +from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider +from ..log import get_logger + class PullRequestCCMimic: """ @@ -166,7 +165,7 @@ class CodeCommitProvider(GitProvider): def publish_comment(self, pr_comment: str, is_temporary: bool = False): if is_temporary: - logging.info(pr_comment) + get_logger().info(pr_comment) return pr_comment = CodeCommitProvider._remove_markdown_html(pr_comment) @@ -188,12 +187,12 @@ class CodeCommitProvider(GitProvider): for suggestion in code_suggestions: # Verify that each suggestion has the required keys if not all(key in suggestion for key in ["body", "relevant_file", "relevant_lines_start"]): - logging.warning(f"Skipping code suggestion #{counter}: Each suggestion must have 'body', 'relevant_file', 'relevant_lines_start' keys") + get_logger().warning(f"Skipping code suggestion #{counter}: Each suggestion must have 'body', 'relevant_file', 'relevant_lines_start' keys") continue # Publish the code suggestion to CodeCommit try: - logging.debug(f"Code Suggestion #{counter} in file: {suggestion['relevant_file']}: {suggestion['relevant_lines_start']}") + get_logger().debug(f"Code Suggestion #{counter} in file: {suggestion['relevant_file']}: {suggestion['relevant_lines_start']}") self.codecommit_client.publish_comment( repo_name=self.repo_name, pr_number=self.pr_num, @@ -296,11 +295,11 @@ class CodeCommitProvider(GitProvider): return self.codecommit_client.get_file(self.repo_name, settings_filename, self.pr.source_commit, optional=True) def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: - logging.info("CodeCommit provider does not support eyes reaction yet") + get_logger().info("CodeCommit provider does not support eyes reaction yet") return True def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: - logging.info("CodeCommit provider does not support removing reactions yet") + get_logger().info("CodeCommit provider does not support removing reactions yet") return True @staticmethod @@ -366,7 +365,7 @@ class CodeCommitProvider(GitProvider): # TODO: implement support for multiple targets in one CodeCommit PR # for now, we are only using the first target in the PR if len(response.targets) > 1: - logging.warning( + get_logger().warning( "Multiple targets in one PR is not supported for CodeCommit yet. Continuing, using the first target only..." ) diff --git a/pr_agent/git_providers/gerrit_provider.py b/pr_agent/git_providers/gerrit_provider.py index dd56803a..ae017fbd 100644 --- a/pr_agent/git_providers/gerrit_provider.py +++ b/pr_agent/git_providers/gerrit_provider.py @@ -1,5 +1,4 @@ import json -import logging import os import pathlib import shutil @@ -7,18 +6,16 @@ import subprocess import uuid from collections import Counter, namedtuple from pathlib import Path -from tempfile import mkdtemp, NamedTemporaryFile +from tempfile import NamedTemporaryFile, mkdtemp import requests import urllib3.util from git import Repo from pr_agent.config_loader import get_settings -from pr_agent.git_providers.git_provider import GitProvider, FilePatchInfo, \ - EDIT_TYPE +from pr_agent.git_providers.git_provider import EDIT_TYPE, FilePatchInfo, GitProvider from pr_agent.git_providers.local_git_provider import PullRequestMimic - -logger = logging.getLogger(__name__) +from pr_agent.log import get_logger def _call(*command, **kwargs) -> (int, str, str): @@ -33,42 +30,42 @@ def _call(*command, **kwargs) -> (int, str, str): def clone(url, directory): - logger.info("Cloning %s to %s", url, directory) + get_logger().info("Cloning %s to %s", url, directory) stdout = _call('git', 'clone', "--depth", "1", url, directory) - logger.info(stdout) + get_logger().info(stdout) def fetch(url, refspec, cwd): - logger.info("Fetching %s %s", url, refspec) + get_logger().info("Fetching %s %s", url, refspec) stdout = _call( 'git', 'fetch', '--depth', '2', url, refspec, cwd=cwd ) - logger.info(stdout) + get_logger().info(stdout) def checkout(cwd): - logger.info("Checking out") + get_logger().info("Checking out") stdout = _call('git', 'checkout', "FETCH_HEAD", cwd=cwd) - logger.info(stdout) + get_logger().info(stdout) def show(*args, cwd=None): - logger.info("Show") + get_logger().info("Show") return _call('git', 'show', *args, cwd=cwd) def diff(*args, cwd=None): - logger.info("Diff") + get_logger().info("Diff") patch = _call('git', 'diff', *args, cwd=cwd) if not patch: - logger.warning("No changes found") + get_logger().warning("No changes found") return return patch def reset_local_changes(cwd): - logger.info("Reset local changes") + get_logger().info("Reset local changes") _call('git', 'checkout', "--force", cwd=cwd) diff --git a/pr_agent/git_providers/git_provider.py b/pr_agent/git_providers/git_provider.py index a4a242f3..c6740467 100644 --- a/pr_agent/git_providers/git_provider.py +++ b/pr_agent/git_providers/git_provider.py @@ -1,4 +1,3 @@ -import logging from abc import ABC, abstractmethod from dataclasses import dataclass @@ -6,6 +5,8 @@ from dataclasses import dataclass from enum import Enum from typing import Optional +from pr_agent.log import get_logger + class EDIT_TYPE(Enum): ADDED = 1 @@ -136,7 +137,7 @@ def get_main_pr_language(languages, files) -> str: """ main_language_str = "" if not languages: - logging.info("No languages detected") + get_logger().info("No languages detected") return main_language_str try: @@ -172,7 +173,7 @@ def get_main_pr_language(languages, files) -> str: main_language_str = top_language except Exception as e: - logging.exception(e) + get_logger().exception(e) pass return main_language_str diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 4a7ca48b..e3bda3ff 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -1,20 +1,19 @@ -import logging import hashlib - from datetime import datetime -from typing import Optional, Tuple, Any +from typing import Optional, Tuple from urllib.parse import urlparse -from github import AppAuthentication, Auth, Github, GithubException, Reaction +from github import AppAuthentication, Auth, Github, GithubException from retry import retry from starlette_context import context -from .git_provider import FilePatchInfo, GitProvider, IncrementalPR from ..algo.language_handler import is_valid_file +from ..algo.pr_processing import clip_tokens, find_line_number_of_relevant_line_in_file from ..algo.utils import load_large_diff -from ..algo.pr_processing import find_line_number_of_relevant_line_in_file, clip_tokens from ..config_loader import get_settings +from ..log import get_logger from ..servers.utils import RateLimitExceeded +from .git_provider import FilePatchInfo, GitProvider, IncrementalPR class GithubProvider(GitProvider): @@ -58,7 +57,7 @@ class GithubProvider(GitProvider): self.file_set = dict() for commit in self.incremental.commits_range: if commit.commit.message.startswith(f"Merge branch '{self._get_repo().default_branch}'"): - logging.info(f"Skipping merge commit {commit.commit.message}") + get_logger().info(f"Skipping merge commit {commit.commit.message}") continue self.file_set.update({file.filename: file for file in commit.files}) @@ -130,7 +129,7 @@ class GithubProvider(GitProvider): return diff_files except GithubException.RateLimitExceededException as e: - logging.error(f"Rate limit exceeded for GitHub API. Original message: {e}") + get_logger().error(f"Rate limit exceeded for GitHub API. Original message: {e}") raise RateLimitExceeded("Rate limit exceeded for GitHub API.") from e def publish_description(self, pr_title: str, pr_body: str): @@ -138,7 +137,7 @@ class GithubProvider(GitProvider): def publish_comment(self, pr_comment: str, is_temporary: bool = False): if is_temporary and not get_settings().config.publish_output_progress: - logging.debug(f"Skipping publish_comment for temporary comment: {pr_comment}") + get_logger().debug(f"Skipping publish_comment for temporary comment: {pr_comment}") return response = self.pr.create_issue_comment(pr_comment) if hasattr(response, "user") and hasattr(response.user, "login"): @@ -156,7 +155,7 @@ class GithubProvider(GitProvider): position, absolute_position = find_line_number_of_relevant_line_in_file(self.diff_files, relevant_file.strip('`'), relevant_line_in_file) if position == -1: if get_settings().config.verbosity_level >= 2: - logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}") + get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}") subject_type = "FILE" else: subject_type = "LINE" @@ -179,13 +178,13 @@ class GithubProvider(GitProvider): if not relevant_lines_start or relevant_lines_start == -1: if get_settings().config.verbosity_level >= 2: - logging.exception( + get_logger().exception( f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}") continue if relevant_lines_end < relevant_lines_start: if get_settings().config.verbosity_level >= 2: - logging.exception(f"Failed to publish code suggestion, " + get_logger().exception(f"Failed to publish code suggestion, " f"relevant_lines_end is {relevant_lines_end} and " f"relevant_lines_start is {relevant_lines_start}") continue @@ -212,7 +211,7 @@ class GithubProvider(GitProvider): return True except Exception as e: if get_settings().config.verbosity_level >= 2: - logging.error(f"Failed to publish code suggestion, error: {e}") + get_logger().error(f"Failed to publish code suggestion, error: {e}") return False def remove_initial_comment(self): @@ -221,7 +220,7 @@ class GithubProvider(GitProvider): if comment.is_temporary: comment.delete() except Exception as e: - logging.exception(f"Failed to remove initial comment, error: {e}") + get_logger().exception(f"Failed to remove initial comment, error: {e}") def get_title(self): return self.pr.title @@ -269,7 +268,7 @@ class GithubProvider(GitProvider): reaction = self.pr.get_issue_comment(issue_comment_id).create_reaction("eyes") return reaction.id except Exception as e: - logging.exception(f"Failed to add eyes reaction, error: {e}") + get_logger().exception(f"Failed to add eyes reaction, error: {e}") return None def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: @@ -277,7 +276,7 @@ class GithubProvider(GitProvider): self.pr.get_issue_comment(issue_comment_id).delete_reaction(reaction_id) return True except Exception as e: - logging.exception(f"Failed to remove eyes reaction, error: {e}") + get_logger().exception(f"Failed to remove eyes reaction, error: {e}") return False @@ -397,13 +396,13 @@ class GithubProvider(GitProvider): "PUT", f"{self.pr.issue_url}/labels", input=post_parameters ) except Exception as e: - logging.exception(f"Failed to publish labels, error: {e}") + get_logger().exception(f"Failed to publish labels, error: {e}") def get_labels(self): try: return [label.name for label in self.pr.labels] except Exception as e: - logging.exception(f"Failed to get labels, error: {e}") + get_logger().exception(f"Failed to get labels, error: {e}") return [] def get_commit_messages(self): @@ -445,7 +444,7 @@ class GithubProvider(GitProvider): return link except Exception as e: if get_settings().config.verbosity_level >= 2: - logging.info(f"Failed adding line link, error: {e}") + get_logger().info(f"Failed adding line link, error: {e}") return "" diff --git a/pr_agent/git_providers/gitlab_provider.py b/pr_agent/git_providers/gitlab_provider.py index 33cbee2b..0d09e622 100644 --- a/pr_agent/git_providers/gitlab_provider.py +++ b/pr_agent/git_providers/gitlab_provider.py @@ -1,4 +1,4 @@ -import logging +import hashlib import re from typing import Optional, Tuple from urllib.parse import urlparse @@ -7,12 +7,12 @@ import gitlab from gitlab import GitlabGetError from ..algo.language_handler import is_valid_file -from ..algo.pr_processing import clip_tokens +from ..algo.pr_processing import clip_tokens, find_line_number_of_relevant_line_in_file from ..algo.utils import load_large_diff from ..config_loader import get_settings from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider +from ..log import get_logger -logger = logging.getLogger() class DiffNotFoundError(Exception): """Raised when the diff for a merge request cannot be found.""" @@ -58,7 +58,7 @@ class GitLabProvider(GitProvider): try: self.last_diff = self.mr.diffs.list(get_all=True)[-1] except IndexError as e: - logger.error(f"Could not get diff for merge request {self.id_mr}") + get_logger().error(f"Could not get diff for merge request {self.id_mr}") raise DiffNotFoundError(f"Could not get diff for merge request {self.id_mr}") from e @@ -98,7 +98,7 @@ class GitLabProvider(GitProvider): if isinstance(new_file_content_str, bytes): new_file_content_str = bytes.decode(new_file_content_str, 'utf-8') except UnicodeDecodeError: - logging.warning( + get_logger().warning( f"Cannot decode file {diff['old_path']} or {diff['new_path']} in merge request {self.id_mr}") edit_type = EDIT_TYPE.MODIFIED @@ -134,7 +134,7 @@ class GitLabProvider(GitProvider): self.mr.description = pr_body self.mr.save() except Exception as e: - logging.exception(f"Could not update merge request {self.id_mr} description: {e}") + get_logger().exception(f"Could not update merge request {self.id_mr} description: {e}") def publish_comment(self, mr_comment: str, is_temporary: bool = False): comment = self.mr.notes.create({'body': mr_comment}) @@ -156,12 +156,12 @@ class GitLabProvider(GitProvider): def send_inline_comment(self,body: str,edit_type: str,found: bool,relevant_file: str,relevant_line_in_file: int, source_line_no: int, target_file: str,target_line_no: int) -> None: if not found: - logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}") + get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}") else: # in order to have exact sha's we have to find correct diff for this change diff = self.get_relevant_diff(relevant_file, relevant_line_in_file) if diff is None: - logger.error(f"Could not get diff for merge request {self.id_mr}") + get_logger().error(f"Could not get diff for merge request {self.id_mr}") raise DiffNotFoundError(f"Could not get diff for merge request {self.id_mr}") pos_obj = {'position_type': 'text', 'new_path': target_file.filename, @@ -174,24 +174,23 @@ class GitLabProvider(GitProvider): else: pos_obj['new_line'] = target_line_no - 1 pos_obj['old_line'] = source_line_no - 1 - logging.debug(f"Creating comment in {self.id_mr} with body {body} and position {pos_obj}") - self.mr.discussions.create({'body': body, - 'position': pos_obj}) + get_logger().debug(f"Creating comment in {self.id_mr} with body {body} and position {pos_obj}") + self.mr.discussions.create({'body': body, 'position': pos_obj}) def get_relevant_diff(self, relevant_file: str, relevant_line_in_file: int) -> Optional[dict]: changes = self.mr.changes() # Retrieve the changes for the merge request once if not changes: - logging.error('No changes found for the merge request.') + get_logger().error('No changes found for the merge request.') return None all_diffs = self.mr.diffs.list(get_all=True) if not all_diffs: - logging.error('No diffs found for the merge request.') + get_logger().error('No diffs found for the merge request.') return None for diff in all_diffs: for change in changes['changes']: if change['new_path'] == relevant_file and relevant_line_in_file in change['diff']: return diff - logging.debug( + get_logger().debug( f'No relevant diff found for {relevant_file} {relevant_line_in_file}. Falling back to last diff.') return self.last_diff # fallback to last_diff if no relevant diff is found @@ -226,7 +225,10 @@ class GitLabProvider(GitProvider): self.send_inline_comment(body, edit_type, found, relevant_file, relevant_line_in_file, source_line_no, target_file, target_line_no) except Exception as e: - logging.exception(f"Could not publish code suggestion:\nsuggestion: {suggestion}\nerror: {e}") + get_logger().exception(f"Could not publish code suggestion:\nsuggestion: {suggestion}\nerror: {e}") + + # note that we publish suggestions one-by-one. so, if one fails, the rest will still be published + return True def search_line(self, relevant_file, relevant_line_in_file): target_file = None @@ -287,7 +289,7 @@ class GitLabProvider(GitProvider): for comment in self.temp_comments: comment.delete() except Exception as e: - logging.exception(f"Failed to remove temp comments, error: {e}") + get_logger().exception(f"Failed to remove temp comments, error: {e}") def get_title(self): return self.mr.title @@ -355,7 +357,7 @@ class GitLabProvider(GitProvider): self.mr.labels = list(set(pr_types)) self.mr.save() except Exception as e: - logging.exception(f"Failed to publish labels, error: {e}") + get_logger().exception(f"Failed to publish labels, error: {e}") def publish_inline_comments(self, comments: list[dict]): pass @@ -386,3 +388,27 @@ class GitLabProvider(GitProvider): return pr_id except: return "" + + def generate_link_to_relevant_line_number(self, suggestion) -> str: + try: + relevant_file = suggestion['relevant file'].strip('`').strip("'") + relevant_line_str = suggestion['relevant line'] + if not relevant_line_str: + return "" + + position, absolute_position = find_line_number_of_relevant_line_in_file \ + (self.diff_files, relevant_file, relevant_line_str) + + if absolute_position != -1: + # link to right file only + link = f"https://gitlab.com/codiumai/pr-agent/-/blob/{self.mr.source_branch}/{relevant_file}?ref_type=heads#L{absolute_position}" + + # # link to diff + # sha_file = hashlib.sha1(relevant_file.encode('utf-8')).hexdigest() + # link = f"{self.pr.web_url}/diffs#{sha_file}_{absolute_position}_{absolute_position}" + return link + except Exception as e: + if get_settings().config.verbosity_level >= 2: + get_logger().info(f"Failed adding line link, error: {e}") + + return "" diff --git a/pr_agent/git_providers/local_git_provider.py b/pr_agent/git_providers/local_git_provider.py index ac750371..5fa9f0be 100644 --- a/pr_agent/git_providers/local_git_provider.py +++ b/pr_agent/git_providers/local_git_provider.py @@ -1,4 +1,3 @@ -import logging from collections import Counter from pathlib import Path from typing import List @@ -7,6 +6,7 @@ from git import Repo from pr_agent.config_loader import _find_repository_root, get_settings from pr_agent.git_providers.git_provider import EDIT_TYPE, FilePatchInfo, GitProvider +from pr_agent.log import get_logger class PullRequestMimic: @@ -49,7 +49,7 @@ class LocalGitProvider(GitProvider): """ Prepare the repository for PR-mimic generation. """ - logging.debug('Preparing repository for PR-mimic generation...') + get_logger().debug('Preparing repository for PR-mimic generation...') if self.repo.is_dirty(): raise ValueError('The repository is not in a clean state. Please commit or stash pending changes.') if self.target_branch_name not in self.repo.heads: diff --git a/pr_agent/git_providers/utils.py b/pr_agent/git_providers/utils.py new file mode 100644 index 00000000..c8d80dfc --- /dev/null +++ b/pr_agent/git_providers/utils.py @@ -0,0 +1,35 @@ +import copy +import os +import tempfile + +from dynaconf import Dynaconf + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers import get_git_provider +from pr_agent.log import get_logger + + +def apply_repo_settings(pr_url): + if get_settings().config.use_repo_settings_file: + repo_settings_file = None + try: + git_provider = get_git_provider()(pr_url) + repo_settings = git_provider.get_repo_settings() + if repo_settings: + repo_settings_file = None + fd, repo_settings_file = tempfile.mkstemp(suffix='.toml') + os.write(fd, repo_settings) + new_settings = Dynaconf(settings_files=[repo_settings_file]) + for section, contents in new_settings.as_dict().items(): + section_dict = copy.deepcopy(get_settings().as_dict().get(section, {})) + for key, value in contents.items(): + section_dict[key] = value + get_settings().unset(section) + get_settings().set(section, section_dict, merge=False) + + finally: + if repo_settings_file: + try: + os.remove(repo_settings_file) + except Exception as e: + get_logger().error(f"Failed to remove temporary settings file {repo_settings_file}", e) diff --git a/pr_agent/log/__init__.py b/pr_agent/log/__init__.py new file mode 100644 index 00000000..665988ef --- /dev/null +++ b/pr_agent/log/__init__.py @@ -0,0 +1,40 @@ +import json +import logging +import sys +from enum import Enum + +from loguru import logger + + +class LoggingFormat(str, Enum): + CONSOLE = "CONSOLE" + JSON = "JSON" + + +def json_format(record: dict) -> str: + return record["message"] + + +def setup_logger(level: str = "INFO", fmt: LoggingFormat = LoggingFormat.CONSOLE): + level: int = logging.getLevelName(level.upper()) + if type(level) is not int: + level = logging.INFO + + if fmt == LoggingFormat.JSON: + logger.remove(None) + logger.add( + sys.stdout, + level=level, + format="{message}", + colorize=False, + serialize=True, + ) + elif fmt == LoggingFormat.CONSOLE: + logger.remove(None) + logger.add(sys.stdout, level=level, colorize=True) + + return logger + + +def get_logger(*args, **kwargs): + return logger diff --git a/pr_agent/servers/bitbucket_app.py b/pr_agent/servers/bitbucket_app.py index cc6491d4..00f83cfe 100644 --- a/pr_agent/servers/bitbucket_app.py +++ b/pr_agent/servers/bitbucket_app.py @@ -1,9 +1,7 @@ import copy import hashlib import json -import logging import os -import sys import time import jwt @@ -18,9 +16,10 @@ from starlette_context.middleware import RawContextMiddleware from pr_agent.agent.pr_agent import PRAgent from pr_agent.config_loader import get_settings, global_settings +from pr_agent.log import LoggingFormat, get_logger, setup_logger from pr_agent.secret_providers import get_secret_provider -logging.basicConfig(stream=sys.stdout, level=logging.INFO) +setup_logger(fmt=LoggingFormat.JSON) router = APIRouter() secret_provider = get_secret_provider() @@ -49,7 +48,7 @@ async def get_bearer_token(shared_secret: str, client_key: str): bearer_token = response.json()["access_token"] return bearer_token except Exception as e: - logging.error(f"Failed to get bearer token: {e}") + get_logger().error(f"Failed to get bearer token: {e}") raise e @router.get("/") @@ -60,7 +59,7 @@ async def handle_manifest(request: Request, response: Response): manifest = manifest.replace("app_key", get_settings().bitbucket.app_key) manifest = manifest.replace("base_url", get_settings().bitbucket.base_url) except: - logging.error("Failed to replace api_key in Bitbucket manifest, trying to continue") + get_logger().error("Failed to replace api_key in Bitbucket manifest, trying to continue") manifest_obj = json.loads(manifest) return JSONResponse(manifest_obj) @@ -92,7 +91,7 @@ async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Req comment_body = data["data"]["comment"]["content"]["raw"] await agent.handle_request(pr_url, comment_body) except Exception as e: - logging.error(f"Failed to handle webhook: {e}") + get_logger().error(f"Failed to handle webhook: {e}") background_tasks.add_task(inner) return "OK" @@ -115,7 +114,7 @@ async def handle_installed_webhooks(request: Request, response: Response): } secret_provider.store_secret(username, json.dumps(secrets)) except Exception as e: - logging.error(f"Failed to register user: {e}") + get_logger().error(f"Failed to register user: {e}") return JSONResponse({"error": "Unable to register user"}, status_code=500) @router.post("/uninstalled") diff --git a/pr_agent/servers/gerrit_server.py b/pr_agent/servers/gerrit_server.py index 04232ea9..1783f6b9 100644 --- a/pr_agent/servers/gerrit_server.py +++ b/pr_agent/servers/gerrit_server.py @@ -1,6 +1,4 @@ import copy -import logging -import sys from enum import Enum from json import JSONDecodeError @@ -12,9 +10,10 @@ from starlette_context import context from starlette_context.middleware import RawContextMiddleware from pr_agent.agent.pr_agent import PRAgent -from pr_agent.config_loader import global_settings, get_settings +from pr_agent.config_loader import get_settings, global_settings +from pr_agent.log import get_logger, setup_logger -logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) +setup_logger() router = APIRouter() @@ -35,7 +34,7 @@ class Item(BaseModel): @router.post("/api/v1/gerrit/{action}") async def handle_gerrit_request(action: Action, item: Item): - logging.debug("Received a Gerrit request") + get_logger().debug("Received a Gerrit request") context["settings"] = copy.deepcopy(global_settings) if action == Action.ask: @@ -54,7 +53,7 @@ async def get_body(request): try: body = await request.json() except JSONDecodeError as e: - logging.error("Error parsing request body", e) + get_logger().error("Error parsing request body", e) return {} return body diff --git a/pr_agent/servers/github_app.py b/pr_agent/servers/github_app.py index c9f25124..37f96e2d 100644 --- a/pr_agent/servers/github_app.py +++ b/pr_agent/servers/github_app.py @@ -1,6 +1,4 @@ import copy -import logging -import sys import os import time from typing import Any, Dict @@ -15,9 +13,12 @@ from pr_agent.agent.pr_agent import PRAgent from pr_agent.algo.utils import update_settings_from_args from pr_agent.config_loader import get_settings, global_settings from pr_agent.git_providers import get_git_provider +from pr_agent.git_providers.utils import apply_repo_settings +from pr_agent.log import LoggingFormat, get_logger, setup_logger from pr_agent.servers.utils import verify_signature -logging.basicConfig(stream=sys.stdout, level=logging.INFO) +setup_logger(fmt=LoggingFormat.JSON) + router = APIRouter() @@ -28,11 +29,11 @@ async def handle_github_webhooks(request: Request, response: Response): Verifies the request signature, parses the request body, and passes it to the handle_request function for further processing. """ - logging.debug("Received a GitHub webhook") + get_logger().debug("Received a GitHub webhook") body = await get_body(request) - logging.debug(f'Request body:\n{body}') + get_logger().debug(f'Request body:\n{body}') installation_id = body.get("installation", {}).get("id") context["installation_id"] = installation_id context["settings"] = copy.deepcopy(global_settings) @@ -44,13 +45,13 @@ async def handle_github_webhooks(request: Request, response: Response): @router.post("/api/v1/marketplace_webhooks") async def handle_marketplace_webhooks(request: Request, response: Response): body = await get_body(request) - logging.info(f'Request body:\n{body}') + get_logger().info(f'Request body:\n{body}') async def get_body(request): try: body = await request.json() except Exception as e: - logging.error("Error parsing request body", e) + get_logger().error("Error parsing request body", e) raise HTTPException(status_code=400, detail="Error parsing request body") from e webhook_secret = getattr(get_settings().github, 'webhook_secret', None) if webhook_secret: @@ -76,8 +77,8 @@ async def handle_request(body: Dict[str, Any], event: str): return {} agent = PRAgent() bot_user = get_settings().github_app.bot_user - logging.info(f"action: '{action}'") - logging.info(f"event: '{event}'") + sender = body.get("sender", {}).get("login") + log_context = {"action": action, "event": event, "sender": sender, "server_type": "github_app"} if get_settings().github_app.duplicate_requests_cache and _is_duplicate_request(body): return {} @@ -87,22 +88,23 @@ async def handle_request(body: Dict[str, Any], event: str): if "comment" not in body: return {} comment_body = body.get("comment", {}).get("body") - sender = body.get("sender", {}).get("login") if sender and bot_user in sender: - logging.info(f"Ignoring comment from {bot_user} user") + get_logger().info(f"Ignoring comment from {bot_user} user") return {} - logging.info(f"Processing comment from {sender} user") + get_logger().info(f"Processing comment from {sender} user") if "issue" in body and "pull_request" in body["issue"] and "url" in body["issue"]["pull_request"]: api_url = body["issue"]["pull_request"]["url"] elif "comment" in body and "pull_request_url" in body["comment"]: api_url = body["comment"]["pull_request_url"] else: return {} - logging.info(body) - logging.info(f"Handling comment because of event={event} and action={action}") + log_context["api_url"] = api_url + get_logger().info(body) + get_logger().info(f"Handling comment because of event={event} and action={action}") comment_id = body.get("comment", {}).get("id") provider = get_git_provider()(pr_url=api_url) - await agent.handle_request(api_url, comment_body, notify=lambda: provider.add_eyes_reaction(comment_id)) + with get_logger().contextualize(**log_context): + await agent.handle_request(api_url, comment_body, notify=lambda: provider.add_eyes_reaction(comment_id)) # handle pull_request event: # automatically review opened/reopened/ready_for_review PRs as long as they're not in draft, @@ -114,6 +116,7 @@ async def handle_request(body: Dict[str, Any], event: str): api_url = pull_request.get("url") if not api_url: return {} + log_context["api_url"] = api_url if pull_request.get("draft", True) or pull_request.get("state") != "open" or pull_request.get("user", {}).get("login", "") == bot_user: return {} if action in get_settings().github_app.handle_pr_actions: @@ -123,18 +126,20 @@ async def handle_request(body: Dict[str, Any], event: str): if pull_request.get("created_at") == pull_request.get("updated_at"): # avoid double reviews when opening a PR for the first time return {} - logging.info(f"Performing review because of event={event} and action={action}") + get_logger().info(f"Performing review because of event={event} and action={action}") + apply_repo_settings(api_url) for command in get_settings().github_app.pr_commands: split_command = command.split(" ") command = split_command[0] args = split_command[1:] other_args = update_settings_from_args(args) new_command = ' '.join([command] + other_args) - logging.info(body) - logging.info(f"Performing command: {new_command}") - await agent.handle_request(api_url, new_command) + get_logger().info(body) + get_logger().info(f"Performing command: {new_command}") + with get_logger().contextualize(**log_context): + await agent.handle_request(api_url, new_command) - logging.info("event or action does not require handling") + get_logger().info("event or action does not require handling") return {} @@ -144,7 +149,7 @@ def _is_duplicate_request(body: Dict[str, Any]) -> bool: This function checks if the request is duplicate and if so - ignores it. """ request_hash = hash(str(body)) - logging.info(f"request_hash: {request_hash}") + get_logger().info(f"request_hash: {request_hash}") request_time = time.monotonic() ttl = get_settings().github_app.duplicate_requests_cache_ttl # in seconds to_delete = [key for key, key_time in _duplicate_requests_cache.items() if request_time - key_time > ttl] @@ -153,7 +158,7 @@ def _is_duplicate_request(body: Dict[str, Any]) -> bool: is_duplicate = request_hash in _duplicate_requests_cache _duplicate_requests_cache[request_hash] = request_time if is_duplicate: - logging.info(f"Ignoring duplicate request {request_hash}") + get_logger().info(f"Ignoring duplicate request {request_hash}") return is_duplicate diff --git a/pr_agent/servers/github_polling.py b/pr_agent/servers/github_polling.py index fdd6642d..1363b941 100644 --- a/pr_agent/servers/github_polling.py +++ b/pr_agent/servers/github_polling.py @@ -1,6 +1,4 @@ import asyncio -import logging -import sys from datetime import datetime, timezone import aiohttp @@ -8,9 +6,10 @@ import aiohttp from pr_agent.agent.pr_agent import PRAgent from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider +from pr_agent.log import LoggingFormat, get_logger, setup_logger from pr_agent.servers.help import bot_help_text -logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) +setup_logger(fmt=LoggingFormat.JSON) NOTIFICATION_URL = "https://api.github.com/notifications" @@ -94,7 +93,7 @@ async def polling_loop(): comment_body = comment['body'] if 'body' in comment else '' commenter_github_user = comment['user']['login'] \ if 'user' in comment else '' - logging.info(f"Commenter: {commenter_github_user}\nComment: {comment_body}") + get_logger().info(f"Commenter: {commenter_github_user}\nComment: {comment_body}") user_tag = "@" + user_id if user_tag not in comment_body: continue @@ -112,7 +111,7 @@ async def polling_loop(): print(f"Failed to fetch notifications. Status code: {response.status}") except Exception as e: - logging.error(f"Exception during processing of a notification: {e}") + get_logger().error(f"Exception during processing of a notification: {e}") if __name__ == '__main__': diff --git a/pr_agent/servers/gitlab_webhook.py b/pr_agent/servers/gitlab_webhook.py index 8321cd60..9cddc27e 100644 --- a/pr_agent/servers/gitlab_webhook.py +++ b/pr_agent/servers/gitlab_webhook.py @@ -1,7 +1,5 @@ import copy import json -import logging -import sys import uvicorn from fastapi import APIRouter, FastAPI, Request, status @@ -14,9 +12,10 @@ from starlette_context.middleware import RawContextMiddleware from pr_agent.agent.pr_agent import PRAgent from pr_agent.config_loader import get_settings, global_settings +from pr_agent.log import get_logger, setup_logger from pr_agent.secret_providers import get_secret_provider -logging.basicConfig(stream=sys.stdout, level=logging.INFO) +setup_logger() router = APIRouter() secret_provider = get_secret_provider() if get_settings().get("CONFIG.SECRET_PROVIDER") else None @@ -33,7 +32,7 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request): context["settings"] = copy.deepcopy(global_settings) context["settings"].gitlab.personal_access_token = gitlab_token except Exception as e: - logging.error(f"Failed to validate secret {request_token}: {e}") + get_logger().error(f"Failed to validate secret {request_token}: {e}") return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"})) elif get_settings().get("GITLAB.SHARED_SECRET"): secret = get_settings().get("GITLAB.SHARED_SECRET") @@ -45,9 +44,9 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request): if not gitlab_token: return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"})) data = await request.json() - logging.info(json.dumps(data)) + get_logger().info(json.dumps(data)) if data.get('object_kind') == 'merge_request' and data['object_attributes'].get('action') in ['open', 'reopen']: - logging.info(f"A merge request has been opened: {data['object_attributes'].get('title')}") + get_logger().info(f"A merge request has been opened: {data['object_attributes'].get('title')}") url = data['object_attributes'].get('url') background_tasks.add_task(PRAgent().handle_request, url, "/review") elif data.get('object_kind') == 'note' and data['event_type'] == 'note': diff --git a/pr_agent/servers/serverless.py b/pr_agent/servers/serverless.py index 42178431..91596315 100644 --- a/pr_agent/servers/serverless.py +++ b/pr_agent/servers/serverless.py @@ -1,12 +1,10 @@ -import logging - from fastapi import FastAPI from mangum import Mangum +from pr_agent.log import setup_logger from pr_agent.servers.github_app import router -logger = logging.getLogger() -logger.setLevel(logging.DEBUG) +setup_logger() app = FastAPI() app.include_router(router) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 187ded56..9a03055f 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -10,6 +10,7 @@ use_repo_settings_file=true ai_timeout=180 max_description_tokens = 500 max_commits_tokens = 500 +patch_extra_lines = 3 secret_provider="google_cloud_storage" cli_mode=false diff --git a/pr_agent/settings/ignore.toml b/pr_agent/settings/ignore.toml new file mode 100644 index 00000000..429d3887 --- /dev/null +++ b/pr_agent/settings/ignore.toml @@ -0,0 +1,11 @@ +[ignore] + +glob = [ + # Ignore files and directories matching these glob patterns. + # See https://docs.python.org/3/library/glob.html + 'vendor/**', +] +regex = [ + # Ignore files and directories matching these regex patterns. + # See https://learnbyexample.github.io/python-regex-cheatsheet/ +] diff --git a/pr_agent/settings/pr_add_docs.toml b/pr_agent/settings/pr_add_docs.toml index b552ec86..31b7195c 100644 --- a/pr_agent/settings/pr_add_docs.toml +++ b/pr_agent/settings/pr_add_docs.toml @@ -42,7 +42,9 @@ Specific instructions: {%- if extra_instructions %} Extra instructions from the user: +' {{ extra_instructions }} +' {%- endif %} You must use the following YAML schema to format your answer: diff --git a/pr_agent/settings/pr_code_suggestions_prompts.toml b/pr_agent/settings/pr_code_suggestions_prompts.toml index 68083945..a3eb93a1 100644 --- a/pr_agent/settings/pr_code_suggestions_prompts.toml +++ b/pr_agent/settings/pr_code_suggestions_prompts.toml @@ -1,6 +1,6 @@ [pr_code_suggestions_prompt] system="""You are a language model called PR-Code-Reviewer, that specializes in suggesting code improvements for Pull Request (PR). -Your task is to provide meaningful and actionable code suggestions, to improve the new code presented in a PR. +Your task is to provide meaningful and actionable code suggestions, to improve the new code presented in a PR (the '+' lines in the diff). Example for a PR Diff input: ' @@ -31,14 +31,13 @@ __old hunk__ ' Specific instructions: -- Provide up to {{ num_code_suggestions }} code suggestions. +- Provide up to {{ num_code_suggestions }} code suggestions. Try to provide diverse and insightful suggestions. - Prioritize suggestions that address major problems, issues and bugs in the code. As a second priority, suggestions should focus on best practices, code readability, maintainability, enhancments, performance, and other aspects. - Don't suggest to add docstring, type hints, or comments. - Try to provide diverse and insightful suggestions. +- Don't suggest to add docstring, type hints, or comments. - Suggestions should refer only to code from the '__new hunk__' sections, and focus on new lines of code (lines starting with '+'). - Avoid making suggestions that have already been implemented in the PR code. For example, if you want to add logs, or change a variable to const, or anything else, make sure it isn't already in the '__new hunk__' code. - For each suggestion, make sure to take into consideration also the context, meaning the lines before and after the relevant code. +- Avoid making suggestions that have already been implemented in the PR code. For example, if you want to add logs, or change a variable to const, or anything else, make sure it isn't already in the '__new hunk__' code. +- For each suggestion, make sure to take into consideration also the context, meaning the lines before and after the relevant code. - Provide the exact line numbers range (inclusive) for each issue. - Assume there is additional relevant code, that is not included in the diff. @@ -46,7 +45,9 @@ Specific instructions: {%- if extra_instructions %} Extra instructions from the user: +' {{ extra_instructions }} +' {%- endif %} You must use the following YAML schema to format your answer: diff --git a/pr_agent/settings/pr_description_prompts.toml b/pr_agent/settings/pr_description_prompts.toml index 43dd8e3b..c2c8e654 100644 --- a/pr_agent/settings/pr_description_prompts.toml +++ b/pr_agent/settings/pr_description_prompts.toml @@ -7,7 +7,9 @@ Your task is to provide full description of the PR content. {%- if extra_instructions %} Extra instructions from the user: +' {{ extra_instructions }} +' {% endif %} You must use the following YAML schema to format your answer: diff --git a/pr_agent/settings/pr_reviewer_prompts.toml b/pr_agent/settings/pr_reviewer_prompts.toml index c0599e50..657027af 100644 --- a/pr_agent/settings/pr_reviewer_prompts.toml +++ b/pr_agent/settings/pr_reviewer_prompts.toml @@ -35,7 +35,9 @@ The review should focus on new code added in the PR (lines starting with '+'), a {%- if extra_instructions %} Extra instructions from the user: +' {{ extra_instructions }} +' {% endif %} You must use the following YAML schema to format your answer: @@ -129,8 +131,7 @@ PR Feedback: Security concerns: type: string description: >- - yes\\no question: does this PR code introduce possible security concerns or - issues, like SQL injection, XSS, CSRF, and others ? If answered 'yes',explain your answer shortly + yes\\no question: does this PR code introduce possible vulnerabilities such as exposure of sensitive information (e.g., API keys, secrets, passwords), or security concerns like SQL injection, XSS, CSRF, and others ? If answered 'yes', explain your answer briefly. {%- endif %} ``` @@ -196,7 +197,9 @@ Here are questions to better understand the PR. Use the answers to provide bette {{question_str|trim}} User answers: +' {{answer_str|trim}} +' ###### {%- endif %} diff --git a/pr_agent/settings/pr_update_changelog_prompts.toml b/pr_agent/settings/pr_update_changelog_prompts.toml index 78b6a0b5..e9133e34 100644 --- a/pr_agent/settings/pr_update_changelog_prompts.toml +++ b/pr_agent/settings/pr_update_changelog_prompts.toml @@ -8,7 +8,9 @@ Your task is to update the CHANGELOG.md file of the project, to shortly summariz {%- if extra_instructions %} Extra instructions from the user: +' {{ extra_instructions }} +' {%- endif %} """ diff --git a/pr_agent/tools/pr_add_docs.py b/pr_agent/tools/pr_add_docs.py index 4cc9102a..c6499bb5 100644 --- a/pr_agent/tools/pr_add_docs.py +++ b/pr_agent/tools/pr_add_docs.py @@ -1,16 +1,17 @@ import copy -import logging import textwrap -from typing import List, Dict +from typing import Dict + from jinja2 import Environment, StrictUndefined from pr_agent.algo.ai_handler import AiHandler -from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models, get_pr_multi_diffs +from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import load_yaml from pr_agent.config_loader import get_settings -from pr_agent.git_providers import BitbucketProvider, get_git_provider +from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language +from pr_agent.log import get_logger class PRAddDocs: @@ -43,34 +44,34 @@ class PRAddDocs: async def run(self): try: - logging.info('Generating code Docs for PR...') + get_logger().info('Generating code Docs for PR...') if get_settings().config.publish_output: self.git_provider.publish_comment("Generating Documentation...", is_temporary=True) - logging.info('Preparing PR documentation...') + get_logger().info('Preparing PR documentation...') await retry_with_fallback_models(self._prepare_prediction) data = self._prepare_pr_code_docs() if (not data) or (not 'Code Documentation' in data): - logging.info('No code documentation found for PR.') + get_logger().info('No code documentation found for PR.') return if get_settings().config.publish_output: - logging.info('Pushing PR documentation...') + get_logger().info('Pushing PR documentation...') self.git_provider.remove_initial_comment() - logging.info('Pushing inline code documentation...') + get_logger().info('Pushing inline code documentation...') self.push_inline_docs(data) except Exception as e: - logging.error(f"Failed to generate code documentation for PR, error: {e}") + get_logger().error(f"Failed to generate code documentation for PR, error: {e}") async def _prepare_prediction(self, model: str): - logging.info('Getting PR diff...') + get_logger().info('Getting PR diff...') self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model, add_line_numbers_to_hunks=True, - disable_extra_lines=True) + disable_extra_lines=False) - logging.info('Getting AI prediction...') + get_logger().info('Getting AI prediction...') self.prediction = await self._get_prediction(model) async def _get_prediction(self, model: str): @@ -80,8 +81,8 @@ class PRAddDocs: system_prompt = environment.from_string(get_settings().pr_add_docs_prompt.system).render(variables) user_prompt = environment.from_string(get_settings().pr_add_docs_prompt.user).render(variables) if get_settings().config.verbosity_level >= 2: - logging.info(f"\nSystem prompt:\n{system_prompt}") - logging.info(f"\nUser prompt:\n{user_prompt}") + get_logger().info(f"\nSystem prompt:\n{system_prompt}") + get_logger().info(f"\nUser prompt:\n{user_prompt}") response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2, system=system_prompt, user=user_prompt) @@ -103,7 +104,7 @@ class PRAddDocs: for d in data['Code Documentation']: try: if get_settings().config.verbosity_level >= 2: - logging.info(f"add_docs: {d}") + get_logger().info(f"add_docs: {d}") relevant_file = d['relevant file'].strip() relevant_line = int(d['relevant line']) # absolute position documentation = d['documentation'] @@ -118,11 +119,11 @@ class PRAddDocs: 'relevant_lines_end': relevant_line}) except Exception: if get_settings().config.verbosity_level >= 2: - logging.info(f"Could not parse code docs: {d}") + get_logger().info(f"Could not parse code docs: {d}") is_successful = self.git_provider.publish_code_suggestions(docs) if not is_successful: - logging.info("Failed to publish code docs, trying to publish each docs separately") + get_logger().info("Failed to publish code docs, trying to publish each docs separately") for doc_suggestion in docs: self.git_provider.publish_code_suggestions([doc_suggestion]) @@ -154,7 +155,7 @@ class PRAddDocs: new_code_snippet = new_code_snippet.rstrip() + "\n" + original_initial_line except Exception as e: if get_settings().config.verbosity_level >= 2: - logging.info(f"Could not dedent code snippet for file {relevant_file}, error: {e}") + get_logger().info(f"Could not dedent code snippet for file {relevant_file}, error: {e}") return new_code_snippet diff --git a/pr_agent/tools/pr_code_suggestions.py b/pr_agent/tools/pr_code_suggestions.py index 7f0b1264..9e8d7f15 100644 --- a/pr_agent/tools/pr_code_suggestions.py +++ b/pr_agent/tools/pr_code_suggestions.py @@ -1,16 +1,17 @@ import copy -import logging import textwrap -from typing import List, Dict +from typing import Dict, List + from jinja2 import Environment, StrictUndefined from pr_agent.algo.ai_handler import AiHandler -from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models, get_pr_multi_diffs +from pr_agent.algo.pr_processing import get_pr_diff, get_pr_multi_diffs, retry_with_fallback_models from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import load_yaml from pr_agent.config_loader import get_settings -from pr_agent.git_providers import BitbucketProvider, get_git_provider +from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language +from pr_agent.log import get_logger class PRCodeSuggestions: @@ -52,42 +53,42 @@ class PRCodeSuggestions: async def run(self): try: - logging.info('Generating code suggestions for PR...') + get_logger().info('Generating code suggestions for PR...') if get_settings().config.publish_output: self.git_provider.publish_comment("Preparing review...", is_temporary=True) - logging.info('Preparing PR review...') + get_logger().info('Preparing PR review...') if not self.is_extended: await retry_with_fallback_models(self._prepare_prediction) data = self._prepare_pr_code_suggestions() else: data = await retry_with_fallback_models(self._prepare_prediction_extended) if (not data) or (not 'Code suggestions' in data): - logging.info('No code suggestions found for PR.') + get_logger().info('No code suggestions found for PR.') return if (not self.is_extended and get_settings().pr_code_suggestions.rank_suggestions) or \ (self.is_extended and get_settings().pr_code_suggestions.rank_extended_suggestions): - logging.info('Ranking Suggestions...') + get_logger().info('Ranking Suggestions...') data['Code suggestions'] = await self.rank_suggestions(data['Code suggestions']) if get_settings().config.publish_output: - logging.info('Pushing PR review...') + get_logger().info('Pushing PR review...') self.git_provider.remove_initial_comment() - logging.info('Pushing inline code suggestions...') + get_logger().info('Pushing inline code suggestions...') self.push_inline_code_suggestions(data) except Exception as e: - logging.error(f"Failed to generate code suggestions for PR, error: {e}") + get_logger().error(f"Failed to generate code suggestions for PR, error: {e}") async def _prepare_prediction(self, model: str): - logging.info('Getting PR diff...') + get_logger().info('Getting PR diff...') self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model, add_line_numbers_to_hunks=True, disable_extra_lines=True) - logging.info('Getting AI prediction...') + get_logger().info('Getting AI prediction...') self.prediction = await self._get_prediction(model) async def _get_prediction(self, model: str): @@ -97,8 +98,8 @@ class PRCodeSuggestions: system_prompt = environment.from_string(get_settings().pr_code_suggestions_prompt.system).render(variables) user_prompt = environment.from_string(get_settings().pr_code_suggestions_prompt.user).render(variables) if get_settings().config.verbosity_level >= 2: - logging.info(f"\nSystem prompt:\n{system_prompt}") - logging.info(f"\nUser prompt:\n{user_prompt}") + get_logger().info(f"\nSystem prompt:\n{system_prompt}") + get_logger().info(f"\nUser prompt:\n{user_prompt}") response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2, system=system_prompt, user=user_prompt) @@ -120,7 +121,7 @@ class PRCodeSuggestions: for d in data['Code suggestions']: try: if get_settings().config.verbosity_level >= 2: - logging.info(f"suggestion: {d}") + get_logger().info(f"suggestion: {d}") relevant_file = d['relevant file'].strip() relevant_lines_start = int(d['relevant lines start']) # absolute position relevant_lines_end = int(d['relevant lines end']) @@ -136,11 +137,11 @@ class PRCodeSuggestions: 'relevant_lines_end': relevant_lines_end}) except Exception: if get_settings().config.verbosity_level >= 2: - logging.info(f"Could not parse suggestion: {d}") + get_logger().info(f"Could not parse suggestion: {d}") is_successful = self.git_provider.publish_code_suggestions(code_suggestions) if not is_successful: - logging.info("Failed to publish code suggestions, trying to publish each suggestion separately") + get_logger().info("Failed to publish code suggestions, trying to publish each suggestion separately") for code_suggestion in code_suggestions: self.git_provider.publish_code_suggestions([code_suggestion]) @@ -162,19 +163,19 @@ class PRCodeSuggestions: new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * " ").rstrip('\n') except Exception as e: if get_settings().config.verbosity_level >= 2: - logging.info(f"Could not dedent code snippet for file {relevant_file}, error: {e}") + get_logger().info(f"Could not dedent code snippet for file {relevant_file}, error: {e}") return new_code_snippet async def _prepare_prediction_extended(self, model: str) -> dict: - logging.info('Getting PR diff...') + get_logger().info('Getting PR diff...') patches_diff_list = get_pr_multi_diffs(self.git_provider, self.token_handler, model, max_calls=get_settings().pr_code_suggestions.max_number_of_calls) - logging.info('Getting multi AI predictions...') + get_logger().info('Getting multi AI predictions...') prediction_list = [] for i, patches_diff in enumerate(patches_diff_list): - logging.info(f"Processing chunk {i + 1} of {len(patches_diff_list)}") + get_logger().info(f"Processing chunk {i + 1} of {len(patches_diff_list)}") self.patches_diff = patches_diff prediction = await self._get_prediction(model) prediction_list.append(prediction) @@ -222,8 +223,8 @@ class PRCodeSuggestions: variables) user_prompt = environment.from_string(get_settings().pr_sort_code_suggestions_prompt.user).render(variables) if get_settings().config.verbosity_level >= 2: - logging.info(f"\nSystem prompt:\n{system_prompt}") - logging.info(f"\nUser prompt:\n{user_prompt}") + get_logger().info(f"\nSystem prompt:\n{system_prompt}") + get_logger().info(f"\nUser prompt:\n{user_prompt}") response, finish_reason = await self.ai_handler.chat_completion(model=model, system=system_prompt, user=user_prompt) @@ -238,7 +239,7 @@ class PRCodeSuggestions: data_sorted = data_sorted[:new_len] except Exception as e: if get_settings().config.verbosity_level >= 1: - logging.info(f"Could not sort suggestions, error: {e}") + get_logger().info(f"Could not sort suggestions, error: {e}") data_sorted = suggestion_list return data_sorted diff --git a/pr_agent/tools/pr_config.py b/pr_agent/tools/pr_config.py index 0dc35918..44ab7e23 100644 --- a/pr_agent/tools/pr_config.py +++ b/pr_agent/tools/pr_config.py @@ -1,7 +1,6 @@ -import logging - from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider +from pr_agent.log import get_logger class PRConfig: @@ -19,11 +18,11 @@ class PRConfig: self.git_provider = get_git_provider()(pr_url) async def run(self): - logging.info('Getting configuration settings...') - logging.info('Preparing configs...') + get_logger().info('Getting configuration settings...') + get_logger().info('Preparing configs...') pr_comment = self._prepare_pr_configs() if get_settings().config.publish_output: - logging.info('Pushing configs...') + get_logger().info('Pushing configs...') self.git_provider.publish_comment(pr_comment) self.git_provider.remove_initial_comment() return "" @@ -44,5 +43,5 @@ class PRConfig: comment_str += f"\n{header.lower()}.{key.lower()} = {repr(value) if isinstance(value, str) else value}" comment_str += " " if get_settings().config.verbosity_level >= 2: - logging.info(f"comment_str:\n{comment_str}") + get_logger().info(f"comment_str:\n{comment_str}") return comment_str diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index 41f0b781..d74ee665 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -1,7 +1,5 @@ import copy -import json import re -import logging from typing import List, Tuple from jinja2 import Environment, StrictUndefined @@ -13,6 +11,7 @@ from pr_agent.algo.utils import load_yaml from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language +from pr_agent.log import get_logger class PRDescription: @@ -65,13 +64,13 @@ class PRDescription: """ try: - logging.info(f"Generating a PR description {self.pr_id}") + get_logger().info(f"Generating a PR description {self.pr_id}") if get_settings().config.publish_output: self.git_provider.publish_comment("Preparing PR description...", is_temporary=True) await retry_with_fallback_models(self._prepare_prediction) - logging.info(f"Preparing answer {self.pr_id}") + get_logger().info(f"Preparing answer {self.pr_id}") if self.prediction: self._prepare_data() else: @@ -88,7 +87,7 @@ class PRDescription: full_markdown_description = f"## Title\n\n{pr_title}\n\n___\n{pr_body}" if get_settings().config.publish_output: - logging.info(f"Pushing answer {self.pr_id}") + get_logger().info(f"Pushing answer {self.pr_id}") if get_settings().pr_description.publish_description_as_comment: self.git_provider.publish_comment(full_markdown_description) else: @@ -100,7 +99,7 @@ class PRDescription: self.git_provider.publish_labels(pr_labels + current_labels) self.git_provider.remove_initial_comment() except Exception as e: - logging.error(f"Error generating PR description {self.pr_id}: {e}") + get_logger().error(f"Error generating PR description {self.pr_id}: {e}") return "" @@ -121,9 +120,9 @@ class PRDescription: if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description: return None - logging.info(f"Getting PR diff {self.pr_id}") + get_logger().info(f"Getting PR diff {self.pr_id}") self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) - logging.info(f"Getting AI prediction {self.pr_id}") + get_logger().info(f"Getting AI prediction {self.pr_id}") self.prediction = await self._get_prediction(model) async def _get_prediction(self, model: str) -> str: @@ -144,8 +143,8 @@ class PRDescription: user_prompt = environment.from_string(get_settings().pr_description_prompt.user).render(variables) if get_settings().config.verbosity_level >= 2: - logging.info(f"\nSystem prompt:\n{system_prompt}") - logging.info(f"\nUser prompt:\n{user_prompt}") + get_logger().info(f"\nSystem prompt:\n{system_prompt}") + get_logger().info(f"\nUser prompt:\n{user_prompt}") response, finish_reason = await self.ai_handler.chat_completion( model=model, @@ -178,7 +177,7 @@ class PRDescription: return pr_types def _prepare_pr_answer_with_markers(self) -> Tuple[str, str]: - logging.info(f"Using description marker replacements {self.pr_id}") + get_logger().info(f"Using description marker replacements {self.pr_id}") title = self.vars["title"] body = self.user_description if get_settings().pr_description.include_generated_by_header: @@ -252,6 +251,6 @@ class PRDescription: pr_body += "\n___\n" if get_settings().config.verbosity_level >= 2: - logging.info(f"title:\n{title}\n{pr_body}") + get_logger().info(f"title:\n{title}\n{pr_body}") return title, pr_body \ No newline at end of file diff --git a/pr_agent/tools/pr_information_from_user.py b/pr_agent/tools/pr_information_from_user.py index c049250f..059966e1 100644 --- a/pr_agent/tools/pr_information_from_user.py +++ b/pr_agent/tools/pr_information_from_user.py @@ -1,5 +1,4 @@ import copy -import logging from jinja2 import Environment, StrictUndefined @@ -9,6 +8,7 @@ from pr_agent.algo.token_handler import TokenHandler from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language +from pr_agent.log import get_logger class PRInformationFromUser: @@ -34,22 +34,22 @@ class PRInformationFromUser: self.prediction = None async def run(self): - logging.info('Generating question to the user...') + get_logger().info('Generating question to the user...') if get_settings().config.publish_output: self.git_provider.publish_comment("Preparing questions...", is_temporary=True) await retry_with_fallback_models(self._prepare_prediction) - logging.info('Preparing questions...') + get_logger().info('Preparing questions...') pr_comment = self._prepare_pr_answer() if get_settings().config.publish_output: - logging.info('Pushing questions...') + get_logger().info('Pushing questions...') self.git_provider.publish_comment(pr_comment) self.git_provider.remove_initial_comment() return "" async def _prepare_prediction(self, model): - logging.info('Getting PR diff...') + get_logger().info('Getting PR diff...') self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) - logging.info('Getting AI prediction...') + get_logger().info('Getting AI prediction...') self.prediction = await self._get_prediction(model) async def _get_prediction(self, model: str): @@ -59,8 +59,8 @@ class PRInformationFromUser: system_prompt = environment.from_string(get_settings().pr_information_from_user_prompt.system).render(variables) user_prompt = environment.from_string(get_settings().pr_information_from_user_prompt.user).render(variables) if get_settings().config.verbosity_level >= 2: - logging.info(f"\nSystem prompt:\n{system_prompt}") - logging.info(f"\nUser prompt:\n{user_prompt}") + get_logger().info(f"\nSystem prompt:\n{system_prompt}") + get_logger().info(f"\nUser prompt:\n{user_prompt}") response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2, system=system_prompt, user=user_prompt) return response @@ -68,7 +68,7 @@ class PRInformationFromUser: def _prepare_pr_answer(self) -> str: model_output = self.prediction.strip() if get_settings().config.verbosity_level >= 2: - logging.info(f"answer_str:\n{model_output}") + get_logger().info(f"answer_str:\n{model_output}") answer_str = f"{model_output}\n\n Please respond to the questions above in the following format:\n\n" +\ "\n>/answer\n>1) ...\n>2) ...\n>...\n" return answer_str diff --git a/pr_agent/tools/pr_questions.py b/pr_agent/tools/pr_questions.py index 959bebe7..7740fd4a 100644 --- a/pr_agent/tools/pr_questions.py +++ b/pr_agent/tools/pr_questions.py @@ -1,5 +1,4 @@ import copy -import logging from jinja2 import Environment, StrictUndefined @@ -9,6 +8,7 @@ from pr_agent.algo.token_handler import TokenHandler from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language +from pr_agent.log import get_logger class PRQuestions: @@ -44,22 +44,22 @@ class PRQuestions: return question_str async def run(self): - logging.info('Answering a PR question...') + get_logger().info('Answering a PR question...') if get_settings().config.publish_output: self.git_provider.publish_comment("Preparing answer...", is_temporary=True) await retry_with_fallback_models(self._prepare_prediction) - logging.info('Preparing answer...') + get_logger().info('Preparing answer...') pr_comment = self._prepare_pr_answer() if get_settings().config.publish_output: - logging.info('Pushing answer...') + get_logger().info('Pushing answer...') self.git_provider.publish_comment(pr_comment) self.git_provider.remove_initial_comment() return "" async def _prepare_prediction(self, model: str): - logging.info('Getting PR diff...') + get_logger().info('Getting PR diff...') self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) - logging.info('Getting AI prediction...') + get_logger().info('Getting AI prediction...') self.prediction = await self._get_prediction(model) async def _get_prediction(self, model: str): @@ -69,8 +69,8 @@ class PRQuestions: system_prompt = environment.from_string(get_settings().pr_questions_prompt.system).render(variables) user_prompt = environment.from_string(get_settings().pr_questions_prompt.user).render(variables) if get_settings().config.verbosity_level >= 2: - logging.info(f"\nSystem prompt:\n{system_prompt}") - logging.info(f"\nUser prompt:\n{user_prompt}") + get_logger().info(f"\nSystem prompt:\n{system_prompt}") + get_logger().info(f"\nUser prompt:\n{user_prompt}") response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2, system=system_prompt, user=user_prompt) return response @@ -79,5 +79,5 @@ class PRQuestions: answer_str = f"Question: {self.question_str}\n\n" answer_str += f"Answer:\n{self.prediction.strip()}\n\n" if get_settings().config.verbosity_level >= 2: - logging.info(f"answer_str:\n{answer_str}") + get_logger().info(f"answer_str:\n{answer_str}") return answer_str diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 01e3f276..ed99ddf6 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -1,6 +1,4 @@ import copy -import json -import logging from collections import OrderedDict from typing import List, Tuple @@ -9,13 +7,13 @@ from jinja2 import Environment, StrictUndefined from yaml import SafeLoader from pr_agent.algo.ai_handler import AiHandler -from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models, \ - find_line_number_of_relevant_line_in_file, clip_tokens +from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import convert_to_markdown, try_fix_json, try_fix_yaml, load_yaml +from pr_agent.algo.utils import convert_to_markdown, load_yaml, try_fix_yaml from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import IncrementalPR, get_main_pr_language +from pr_agent.log import get_logger from pr_agent.servers.help import actions_help_text, bot_help_text @@ -98,29 +96,29 @@ class PRReviewer: try: if self.is_auto and not get_settings().pr_reviewer.automatic_review: - logging.info(f'Automatic review is disabled {self.pr_url}') + get_logger().info(f'Automatic review is disabled {self.pr_url}') return None - logging.info(f'Reviewing PR: {self.pr_url} ...') + get_logger().info(f'Reviewing PR: {self.pr_url} ...') if get_settings().config.publish_output: self.git_provider.publish_comment("Preparing review...", is_temporary=True) await retry_with_fallback_models(self._prepare_prediction) - logging.info('Preparing PR review...') + get_logger().info('Preparing PR review...') pr_comment = self._prepare_pr_review() if get_settings().config.publish_output: - logging.info('Pushing PR review...') + get_logger().info('Pushing PR review...') self.git_provider.publish_comment(pr_comment) self.git_provider.remove_initial_comment() if get_settings().pr_reviewer.inline_code_comments: - logging.info('Pushing inline code comments...') + get_logger().info('Pushing inline code comments...') self._publish_inline_code_comments() except Exception as e: - logging.error(f"Failed to review PR: {e}") + get_logger().error(f"Failed to review PR: {e}") async def _prepare_prediction(self, model: str) -> None: """ @@ -132,9 +130,9 @@ class PRReviewer: Returns: None """ - logging.info('Getting PR diff...') + get_logger().info('Getting PR diff...') self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) - logging.info('Getting AI prediction...') + get_logger().info('Getting AI prediction...') self.prediction = await self._get_prediction(model) async def _get_prediction(self, model: str) -> str: @@ -155,8 +153,8 @@ class PRReviewer: user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(variables) if get_settings().config.verbosity_level >= 2: - logging.info(f"\nSystem prompt:\n{system_prompt}") - logging.info(f"\nUser prompt:\n{user_prompt}") + get_logger().info(f"\nSystem prompt:\n{system_prompt}") + get_logger().info(f"\nUser prompt:\n{user_prompt}") response, finish_reason = await self.ai_handler.chat_completion( model=model, @@ -209,6 +207,22 @@ class PRReviewer: link = self.git_provider.generate_link_to_relevant_line_number(suggestion) if link: suggestion['relevant line'] = f"[{suggestion['relevant line']}]({link})" + else: + pass + # try: + # relevant_file = suggestion['relevant file'].strip('`').strip("'") + # relevant_line_str = suggestion['relevant line'] + # if not relevant_line_str: + # return "" + # + # position, absolute_position = find_line_number_of_relevant_line_in_file( + # self.git_provider.diff_files, relevant_file, relevant_line_str) + # if absolute_position != -1: + # suggestion[ + # 'relevant line'] = f"{suggestion['relevant line']} (line {absolute_position})" + # except: + # pass + # Add incremental review section if self.incremental.is_incremental: @@ -225,14 +239,15 @@ class PRReviewer: # Add help text if not in CLI mode if not get_settings().get("CONFIG.CLI_MODE", False): markdown_text += "\n### How to use\n" - if user and '[bot]' not in user: + bot_user = "[bot]" if get_settings().github_app.override_deployment_type else get_settings().github_app.bot_user + if user and bot_user not in user: markdown_text += bot_help_text(user) else: markdown_text += actions_help_text # Log markdown response if verbosity level is high if get_settings().config.verbosity_level >= 2: - logging.info(f"Markdown response:\n{markdown_text}") + get_logger().info(f"Markdown response:\n{markdown_text}") if markdown_text == None or len(markdown_text) == 0: markdown_text = "" @@ -251,7 +266,7 @@ class PRReviewer: try: data = yaml.load(review_text, Loader=SafeLoader) except Exception as e: - logging.error(f"Failed to parse AI prediction: {e}") + get_logger().error(f"Failed to parse AI prediction: {e}") data = try_fix_yaml(review_text) comments: List[str] = [] @@ -260,7 +275,7 @@ class PRReviewer: relevant_line_in_file = suggestion.get('relevant line', '').strip() content = suggestion.get('suggestion', '') if not relevant_file or not relevant_line_in_file or not content: - logging.info("Skipping inline comment with missing file/line/content") + get_logger().info("Skipping inline comment with missing file/line/content") continue if self.git_provider.is_supported("create_inline_comment"): diff --git a/pr_agent/tools/pr_similar_issue.py b/pr_agent/tools/pr_similar_issue.py index 9987c08a..7cf7b31b 100644 --- a/pr_agent/tools/pr_similar_issue.py +++ b/pr_agent/tools/pr_similar_issue.py @@ -1,18 +1,17 @@ -import copy -import json -import logging from enum import Enum -from typing import List, Tuple -import pinecone +from typing import List + import openai import pandas as pd +import pinecone +from pinecone_datasets import Dataset, DatasetMetadata from pydantic import BaseModel, Field from pr_agent.algo import MAX_TOKENS from pr_agent.algo.token_handler import TokenHandler from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider -from pinecone_datasets import Dataset, DatasetMetadata +from pr_agent.log import get_logger MODEL = "text-embedding-ada-002" @@ -60,11 +59,11 @@ class PRSimilarIssue: upsert = False if run_from_scratch or upsert: # index the entire repo - logging.info('Indexing the entire repo...') + get_logger().info('Indexing the entire repo...') - logging.info('Getting issues...') + get_logger().info('Getting issues...') issues = self.git_provider.get_repo_issues(repo_obj) - logging.info('Done') + get_logger().info('Done') self._update_index_with_issues(issues, repo_name_for_index, upsert=upsert) else: # update index if needed pinecone_index = pinecone.Index(index_name=index_name) @@ -92,20 +91,20 @@ class PRSimilarIssue: break if issues_to_update: - logging.info(f'Updating index with {counter} new issues...') + get_logger().info(f'Updating index with {counter} new issues...') self._update_index_with_issues(issues_to_update, repo_name_for_index, upsert=True) else: - logging.info('No new issues to update') + get_logger().info('No new issues to update') async def run(self): - logging.info('Getting issue...') + get_logger().info('Getting issue...') workspace_slug, repo_name, original_issue_number = self.git_provider._parse_issue_url(self.issue_url.split('=')[-1]) issue_main = self.git_provider.get_issue(workspace_slug, repo_name, original_issue_number) issue_str, comments, number = self._process_issue(issue_main) openai.api_key = get_settings().openai.key - logging.info('Done') + get_logger().info('Done') - logging.info('Querying...') + get_logger().info('Querying...') res = openai.Embedding.create(input=[issue_str], engine=MODEL) embeds = [record['embedding'] for record in res['data']] pinecone_index = pinecone.Index(index_name=self.index_name) @@ -127,9 +126,9 @@ class PRSimilarIssue: else: relevant_comment_number_list.append(-1) score_list.append(str("{:.2f}".format(r['score']))) - logging.info('Done') + get_logger().info('Done') - logging.info('Publishing response...') + get_logger().info('Publishing response...') similar_issues_str = "### Similar Issues\n___\n\n" for i, issue_number_similar in enumerate(relevant_issues_number_list): issue = self.git_provider.get_issue(workspace_slug, repo_name, issue_number_similar) @@ -138,8 +137,8 @@ class PRSimilarIssue: similar_issues_str += f"{i + 1}. **[{title}]({url})** (score={score_list[i]})\n\n" if get_settings().config.publish_output: response = self.git_provider.create_issue_comment(similar_issues_str, workspace_slug, repo_name, original_issue_number) - logging.info(similar_issues_str) - logging.info('Done') + get_logger().info(similar_issues_str) + get_logger().info('Done') def _process_issue(self, issue): header = issue.title @@ -153,7 +152,7 @@ class PRSimilarIssue: return issue_str, comments, number def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=False): - logging.info('Processing issues...') + get_logger().info('Processing issues...') corpus = Corpus() example_issue_record = Record( id=str([issue.number for issue in issues_list]), @@ -171,9 +170,9 @@ class PRSimilarIssue: counter += 1 if counter % 100 == 0: - logging.info(f"Scanned {counter} issues") + get_logger().info(f"Scanned {counter} issues") if counter >= self.max_issues_to_scan: - logging.info(f"Scanned {self.max_issues_to_scan} issues, stopping") + get_logger().info(f"Scanned {self.max_issues_to_scan} issues, stopping") break issue_str, comments, number = self._process_issue(issue) @@ -210,9 +209,9 @@ class PRSimilarIssue: ) corpus.append(comment_record) df = pd.DataFrame(corpus.dict()["documents"]) - logging.info('Done') + get_logger().info('Done') - logging.info('Embedding...') + get_logger().info('Embedding...') openai.api_key = get_settings().openai.key list_to_encode = list(df["text"].values) try: @@ -220,7 +219,7 @@ class PRSimilarIssue: embeds = [record['embedding'] for record in res['data']] except: embeds = [] - logging.error('Failed to embed entire list, embedding one by one...') + get_logger().error('Failed to embed entire list, embedding one by one...') for i, text in enumerate(list_to_encode): try: res = openai.Embedding.create(input=[text], engine=MODEL) @@ -231,21 +230,21 @@ class PRSimilarIssue: meta = DatasetMetadata.empty() meta.dense_model.dimension = len(embeds[0]) ds = Dataset.from_pandas(df, meta) - logging.info('Done') + get_logger().info('Done') api_key = get_settings().github.api_key environment = get_settings().github.environment if not upsert: - logging.info('Creating index from scratch...') + get_logger().info('Creating index from scratch...') ds.to_pinecone_index(self.index_name, api_key=api_key, environment=environment) else: - logging.info('Upserting index...') + get_logger().info('Upserting index...') namespace = "" batch_size: int = 100 concurrency: int = 10 pinecone.init(api_key=api_key, environment=environment) ds._upsert_to_index(self.index_name, namespace, batch_size, concurrency) - logging.info('Done') + get_logger().info('Done') class IssueLevel(str, Enum): diff --git a/pr_agent/tools/pr_update_changelog.py b/pr_agent/tools/pr_update_changelog.py index 547ce84b..a5f24e0d 100644 --- a/pr_agent/tools/pr_update_changelog.py +++ b/pr_agent/tools/pr_update_changelog.py @@ -1,5 +1,4 @@ import copy -import logging from datetime import date from time import sleep from typing import Tuple @@ -10,8 +9,9 @@ from pr_agent.algo.ai_handler import AiHandler from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models from pr_agent.algo.token_handler import TokenHandler from pr_agent.config_loader import get_settings -from pr_agent.git_providers import GithubProvider, get_git_provider +from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language +from pr_agent.log import get_logger CHANGELOG_LINES = 50 @@ -48,26 +48,26 @@ class PRUpdateChangelog: async def run(self): # assert type(self.git_provider) == GithubProvider, "Currently only Github is supported" - logging.info('Updating the changelog...') + get_logger().info('Updating the changelog...') if get_settings().config.publish_output: self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True) await retry_with_fallback_models(self._prepare_prediction) - logging.info('Preparing PR changelog updates...') + get_logger().info('Preparing PR changelog updates...') new_file_content, answer = self._prepare_changelog_update() if get_settings().config.publish_output: self.git_provider.remove_initial_comment() - logging.info('Publishing changelog updates...') + get_logger().info('Publishing changelog updates...') if self.commit_changelog: - logging.info('Pushing PR changelog updates to repo...') + get_logger().info('Pushing PR changelog updates to repo...') self._push_changelog_update(new_file_content, answer) else: - logging.info('Publishing PR changelog as comment...') + get_logger().info('Publishing PR changelog as comment...') self.git_provider.publish_comment(f"**Changelog updates:**\n\n{answer}") async def _prepare_prediction(self, model: str): - logging.info('Getting PR diff...') + get_logger().info('Getting PR diff...') self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) - logging.info('Getting AI prediction...') + get_logger().info('Getting AI prediction...') self.prediction = await self._get_prediction(model) async def _get_prediction(self, model: str): @@ -77,8 +77,8 @@ class PRUpdateChangelog: system_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.system).render(variables) user_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.user).render(variables) if get_settings().config.verbosity_level >= 2: - logging.info(f"\nSystem prompt:\n{system_prompt}") - logging.info(f"\nUser prompt:\n{user_prompt}") + get_logger().info(f"\nSystem prompt:\n{system_prompt}") + get_logger().info(f"\nUser prompt:\n{user_prompt}") response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2, system=system_prompt, user=user_prompt) @@ -100,7 +100,7 @@ class PRUpdateChangelog: "\n>'/update_changelog --pr_update_changelog.push_changelog_changes=true'\n" if get_settings().config.verbosity_level >= 2: - logging.info(f"answer:\n{answer}") + get_logger().info(f"answer:\n{answer}") return new_file_content, answer @@ -149,7 +149,7 @@ Example: except Exception: self.changelog_file_str = "" if self.commit_changelog: - logging.info("No CHANGELOG.md file found in the repository. Creating one...") + get_logger().info("No CHANGELOG.md file found in the repository. Creating one...") changelog_file = self.git_provider.repo_obj.create_file(path="CHANGELOG.md", message='add CHANGELOG.md', content="", diff --git a/requirements.txt b/requirements.txt index 8791a115..8589b30b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,4 +20,5 @@ ujson==5.8.0 azure-devops==7.1.0b3 msrest==0.7.1 pinecone-client -pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main \ No newline at end of file +pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main +loguru==0.7.2 diff --git a/tests/unittest/test_file_filter.py b/tests/unittest/test_file_filter.py new file mode 100644 index 00000000..43e9c9b4 --- /dev/null +++ b/tests/unittest/test_file_filter.py @@ -0,0 +1,80 @@ +import pytest +from pr_agent.algo.file_filter import filter_ignored +from pr_agent.config_loader import global_settings + +class TestIgnoreFilter: + def test_no_ignores(self): + """ + Test no files are ignored when no patterns are specified. + """ + files = [ + type('', (object,), {'filename': 'file1.py'})(), + type('', (object,), {'filename': 'file2.java'})(), + type('', (object,), {'filename': 'file3.cpp'})(), + type('', (object,), {'filename': 'file4.py'})(), + type('', (object,), {'filename': 'file5.py'})() + ] + assert filter_ignored(files) == files, "Expected all files to be returned when no ignore patterns are given." + + def test_glob_ignores(self, monkeypatch): + """ + Test files are ignored when glob patterns are specified. + """ + monkeypatch.setattr(global_settings.ignore, 'glob', ['*.py']) + + files = [ + type('', (object,), {'filename': 'file1.py'})(), + type('', (object,), {'filename': 'file2.java'})(), + type('', (object,), {'filename': 'file3.cpp'})(), + type('', (object,), {'filename': 'file4.py'})(), + type('', (object,), {'filename': 'file5.py'})() + ] + expected = [ + files[1], + files[2] + ] + + filtered_files = filter_ignored(files) + assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." + + def test_regex_ignores(self, monkeypatch): + """ + Test files are ignored when regex patterns are specified. + """ + monkeypatch.setattr(global_settings.ignore, 'regex', ['^file[2-4]\..*$']) + + files = [ + type('', (object,), {'filename': 'file1.py'})(), + type('', (object,), {'filename': 'file2.java'})(), + type('', (object,), {'filename': 'file3.cpp'})(), + type('', (object,), {'filename': 'file4.py'})(), + type('', (object,), {'filename': 'file5.py'})() + ] + expected = [ + files[0], + files[4] + ] + + filtered_files = filter_ignored(files) + assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." + + def test_invalid_regex(self, monkeypatch): + """ + Test invalid patterns are quietly ignored. + """ + monkeypatch.setattr(global_settings.ignore, 'regex', ['(((||', '^file[2-4]\..*$']) + + files = [ + type('', (object,), {'filename': 'file1.py'})(), + type('', (object,), {'filename': 'file2.java'})(), + type('', (object,), {'filename': 'file3.cpp'})(), + type('', (object,), {'filename': 'file4.py'})(), + type('', (object,), {'filename': 'file5.py'})() + ] + expected = [ + files[0], + files[4] + ] + + filtered_files = filter_ignored(files) + assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." diff --git a/tests/unittest/test_handle_patch_deletions.py b/tests/unittest/test_handle_patch_deletions.py index 152ea4b2..e44c0d77 100644 --- a/tests/unittest/test_handle_patch_deletions.py +++ b/tests/unittest/test_handle_patch_deletions.py @@ -43,18 +43,6 @@ class TestHandlePatchDeletions: assert handle_patch_deletions(patch, original_file_content_str, new_file_content_str, file_name) == patch.rstrip() - # Tests that handle_patch_deletions logs a message when verbosity_level is greater than 0 - def test_handle_patch_deletions_happy_path_verbosity_level_greater_than_0(self, caplog): - patch = '--- a/file.py\n+++ b/file.py\n@@ -1,2 +1,2 @@\n-foo\n-bar\n+baz\n' - original_file_content_str = 'foo\nbar\n' - new_file_content_str = '' - file_name = 'file.py' - get_settings().config.verbosity_level = 1 - - with caplog.at_level(logging.INFO): - handle_patch_deletions(patch, original_file_content_str, new_file_content_str, file_name) - assert any("Processing file" in message for message in caplog.messages) - # Tests that handle_patch_deletions returns 'File was deleted' when new_file_content_str is empty def test_handle_patch_deletions_edge_case_new_file_content_empty(self): patch = '--- a/file.py\n+++ b/file.py\n@@ -1,2 +1,2 @@\n-foo\n-bar\n'