Compare commits

...

55 Commits

Author SHA1 Message Date
416a5495da Merge pull request #453 from Codium-ai/tr/v_010
Version 0.10 Release and Workflow Update
2023-11-15 09:46:31 -08:00
a2b27dcac8 v10 2023-11-15 19:45:51 +02:00
d8e4e2e8fd Merge pull request #454 from Codium-ai/coditamar-bitbucket-doc-type
Update INSTALL.md
2023-11-15 09:44:03 -08:00
896a81d173 Update INSTALL.md 2023-11-15 15:20:50 +02:00
b216af8f04 v10 2023-11-15 14:49:18 +02:00
388cc740b6 Merge pull request #436 from rhyst/support-vertex-ai
Support Google's Vertex AI
2023-11-15 04:26:08 -08:00
6214494c84 Merge pull request #452 from Codium-ai/tr/review_extra_labels
Add Review Labels for Security and Effort Estimation
2023-11-15 04:25:03 -08:00
762a6981e1 extra_labels 2023-11-15 14:12:59 +02:00
b362c406bc Merge remote-tracking branch 'origin/main' into tr/review_extra_labels 2023-11-15 14:07:44 +02:00
7a342d3312 extra_labels 2023-11-15 14:07:32 +02:00
2e95988741 extra_labels 2023-11-15 14:04:17 +02:00
9478447141 extra_labels 2023-11-15 14:02:13 +02:00
082293b48c Merge pull request #451 from Codium-ai/tr/persistent_enhacments
Enhancement of Persistent Comments in PR Review
2023-11-15 03:55:15 -08:00
e1d92206f3 docs 2023-11-15 13:32:32 +02:00
557ec72bfe Update documentation for Vertex AI 2023-11-15 10:27:48 +00:00
6b4b16dcf9 Support Google's Vertex AI 2023-11-15 10:26:58 +00:00
c4899a6c54 bitbucket 2023-11-15 12:11:02 +02:00
24d82e65cb gitlab 2023-11-15 09:45:10 +02:00
2567a6cf27 gitlab 2023-11-15 09:40:45 +02:00
94cb6b9795 more feedback 2023-11-15 09:06:26 +02:00
e878bbbe36 Merge pull request #449 from zmeir/patch-1
Fix `get_user_description` in case `pr_description.enable_pr_type=false`
2023-11-14 22:09:59 -08:00
7d89b82967 Fix get_user_description in case pr_description.enable_pr_type=false
Fixes an issue when getting the user description after a PR-Agent description was already generated, in case the configuration setting `pr_description.enable_pr_type` was `false`.
2023-11-13 14:41:14 +02:00
c5f9bbbf92 Merge pull request #448 from Codium-ai/hl/optional_custom_labels
remove the "one or more" for custom labels
2023-11-13 13:51:18 +02:00
a5e5a82952 s 2023-11-13 13:49:16 +02:00
ccbb62b50a remove the "one or more" for custom labels 2023-11-13 13:47:06 +02:00
1df36c6a44 Merge pull request #446 from Codium-ai/tr/fix_cli_args
Handling CLI Arguments with Quotes in pr_agent
2023-11-12 17:29:38 +02:00
9e5e9afe92 Refactor CLI argument handling and request processing 2023-11-12 16:11:34 +02:00
5e43c202dd s1 2023-11-12 15:45:22 +02:00
37e6608e68 Merge pull request #444 from Codium-ai/tr/fallback_yaml
Implementing Fallback Mechanisms for YAML Parsing
2023-11-12 00:43:15 -08:00
f64d5f1e2a tests 2023-11-12 08:36:57 +02:00
8fdf174dec fallback 2023-11-10 18:44:19 +02:00
29d4f98b19 Merge pull request #441 from Codium-ai/tr/presistent_review
Add Persistent Review Feature to PR Agent
2023-11-09 05:26:51 -08:00
737792d83c publish_persistent_comment 2023-11-09 15:24:55 +02:00
7e5889061c publish_persistent_comment 2023-11-09 15:20:31 +02:00
755e04cf65 bitbucket finally works 2023-11-08 20:41:55 +02:00
44d6c95714 response 2023-11-08 20:38:18 +02:00
14610d5375 persistent
s
2023-11-08 20:16:08 +02:00
f9c832d6cb Merge pull request #439 from Codium-ai/tr/fixes_added_files
Enhancement of Patch Handling and PR Processing
2023-11-08 04:48:07 -08:00
c2bec614e5 s 2023-11-08 14:46:11 +02:00
49725e92f2 s 2023-11-08 14:41:15 +02:00
a1e32d8331 s 2023-11-08 14:36:59 +02:00
0293412a42 s 2023-11-08 14:31:08 +02:00
10ec0a1812 s 2023-11-08 14:21:03 +02:00
69b68b78f5 s 2023-11-08 14:17:59 +02:00
c5bc4b44ff fix added files 2023-11-08 12:51:30 +02:00
39e5102a2e fix added files 2023-11-08 12:47:18 +02:00
6c82bc9a3e Merge pull request #437 from Codium-ai/tr/new_gpt4
Introduce support for 'gpt-4-1106-preview' model and dynamic token limit calculation
2023-11-07 04:49:50 -08:00
54f41dd603 code 2023-11-07 14:41:15 +02:00
094f641fb5 code 2023-11-07 14:38:37 +02:00
a35a75eb34 get_max_tokens + added 'gpt-4-1106-preview' 2023-11-07 14:28:41 +02:00
5a7c118b56 Merge pull request #434 from Codium-ai/document_describe
Update DESCRIBE.md
2023-11-06 11:00:06 -08:00
cf9e0fbbc5 Update DESCRIBE.md 2023-11-06 17:55:58 +02:00
ef9af261ed Merge pull request #433 from Codium-ai/hl/user_labels
Keep user labels
2023-11-06 15:17:19 +02:00
ff79776410 Keep user labels 2023-11-06 15:14:08 +02:00
ec3f2fb485 Revert "generate labels keep user labels only"
This reverts commit 94a2a5e527.
2023-11-06 15:08:29 +02:00
29 changed files with 456 additions and 136 deletions

View File

@ -26,5 +26,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PINECONE.API_KEY: ${{ secrets.PINECONE_API_KEY }} PINECONE.API_KEY: ${{ secrets.PINECONE_API_KEY }}
PINECONE.ENVIRONMENT: ${{ secrets.PINECONE_ENVIRONMENT }} PINECONE.ENVIRONMENT: ${{ secrets.PINECONE_ENVIRONMENT }}
GITHUB_ACTION.AUTO_REVIEW: true

2
.pr_agent.toml Normal file
View File

@ -0,0 +1,2 @@
[pr_reviewer]
enable_review_labels_effort = true

View File

@ -410,9 +410,9 @@ BITBUCKET_BEARER_TOKEN: <your token>
You can get a Bitbucket token for your repository by following Repository Settings -> Security -> Access Tokens. You can get a Bitbucket token for your repository by following Repository Settings -> Security -> Access Tokens.
### Run on a hosted Bitbucket app ### Run using CodiumAI-hosted Bitbucket app
Please contact <support@codium.ai> if you're interested in a hosted BitBucket app solution that provides full functionality including PR reviews and comment handling. It's based on the [bitbucket_app.py](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/git_providers/bitbucket_provider.py) implmentation. Please contact <support@codium.ai> or visit [CodiumAI pricing page](https://www.codium.ai/pricing/) if you're interested in a hosted BitBucket app solution that provides full functionality including PR reviews and comment handling. It's based on the [bitbucket_app.py](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/git_providers/bitbucket_provider.py) implementation.
======= =======

View File

@ -1,3 +1,25 @@
## [Version 0.10] - 2023-11-15
- codiumai/pr-agent:0.10
- codiumai/pr-agent:0.10-github_app
- codiumai/pr-agent:0.10-bitbucket-app
- codiumai/pr-agent:0.10-gitlab_webhook
- codiumai/pr-agent:0.10-github_polling
- codiumai/pr-agent:0.10-github_action
### Added::Algo
- Review tool now works with [persistent comments](https://github.com/Codium-ai/pr-agent/pull/451) by default
- Bitbucket now publishes review suggestions with [code links](https://github.com/Codium-ai/pr-agent/pull/428)
- Enabling to limit [max number of tokens](https://github.com/Codium-ai/pr-agent/pull/437/files)
- Support ['gpt-4-1106-preview'](https://github.com/Codium-ai/pr-agent/pull/437/files) model
- Support for Google's [Vertex AI](https://github.com/Codium-ai/pr-agent/pull/436)
- Implementing [thresholds](https://github.com/Codium-ai/pr-agent/pull/423) for incremental PR reviews
- Decoupled custom labels from [PR type](https://github.com/Codium-ai/pr-agent/pull/431)
### Fixed
- Fixed bug in [parsing quotes](https://github.com/Codium-ai/pr-agent/pull/446) in CLI
- Preserve [user-added labels](https://github.com/Codium-ai/pr-agent/pull/433) in pull requests
- Bug fixes in GitLab and BitBucket
## [Version 0.9] - 2023-10-29 ## [Version 0.9] - 2023-10-29
- codiumai/pr-agent:0.9 - codiumai/pr-agent:0.9
- codiumai/pr-agent:0.9-github_app - codiumai/pr-agent:0.9-github_app

View File

@ -173,7 +173,7 @@ push_commands = [
"/auto_review -i --pr_reviewer.remove_previous_review_comment=true", "/auto_review -i --pr_reviewer.remove_previous_review_comment=true",
] ]
``` ```
The means that when new code is pused to the PR, the PR-Agent will run the `describe` and incremental `auto_review` tools. The means that when new code is pushed to the PR, the PR-Agent will run the `describe` and incremental `auto_review` tools.
For the describe tool, the `add_original_user_description` and `keep_original_user_title` parameters will be set to true. For the describe tool, the `add_original_user_description` and `keep_original_user_title` parameters will be set to true.
For the `auto_review` tool, it will run in incremental mode, and the `remove_previous_review_comment` parameter will be set to true. For the `auto_review` tool, it will run in incremental mode, and the `remove_previous_review_comment` parameter will be set to true.
@ -303,6 +303,23 @@ key = ...
Also review the [AiHandler](pr_agent/algo/ai_handler.py) file for instruction how to set keys for other models. Also review the [AiHandler](pr_agent/algo/ai_handler.py) file for instruction how to set keys for other models.
#### Vertex AI
To use Google's Vertex AI platform and its associated models (chat-bison/codechat-bison) set:
```
[config] # in configuration.toml
model = "vertex_ai/codechat-bison"
[vertexai] # in .secrets.toml
vertex_project = "my-google-cloud-project"
vertex_location = ""
```
Your [application default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) will be used for authentication so there is no need to set explicit credentials in most environments.
If you do want to set explicit credentials then you can use the `GOOGLE_APPLICATION_CREDENTIALS` environment variable set to a path to a json credentials file.
### Working with large PRs ### 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. The default mode of CodiumAI is to have a single call per tool, using GPT-4, which has a token limit of 8000 tokens.

View File

@ -27,6 +27,8 @@ Under the section 'pr_description', the [configuration file](./../pr_agent/setti
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...". - `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...".
- To enable `custom labels`, apply the configuration changes described [here](./GENERATE_CUSTOM_LABELS.md#configuration-changes) - To enable `custom labels`, apply the configuration changes described [here](./GENERATE_CUSTOM_LABELS.md#configuration-changes)
- `enable_pr_type`: if set to false, it will not show the `PR type` as a text value in the description content. Default is true.
### Markers template ### Markers template
markers enable to easily integrate user's content and auto-generated content, with a template-like mechanism. markers enable to easily integrate user's content and auto-generated content, with a template-like mechanism.

View File

@ -16,15 +16,22 @@ The `review` tool can also be triggered automatically every time a new PR is ope
Under the section 'pr_reviewer', the [configuration file](./../pr_agent/settings/configuration.toml#L16) contains options to customize the 'review' tool: Under the section 'pr_reviewer', the [configuration file](./../pr_agent/settings/configuration.toml#L16) contains options to customize the 'review' tool:
#### enable\\disable features
- `require_focused_review`: if set to true, the tool will add a section - 'is the PR a focused one'. Default is false. - `require_focused_review`: if set to true, the tool will add a section - 'is the PR a focused one'. Default is false.
- `require_score_review`: if set to true, the tool will add a section that scores the PR. Default is false. - `require_score_review`: if set to true, the tool will add a section that scores the PR. Default is false.
- `require_tests_review`: if set to true, the tool will add a section that checks if the PR contains tests. Default is true. - `require_tests_review`: if set to true, the tool will add a section that checks if the PR contains tests. Default is true.
- `require_security_review`: if set to true, the tool will add a section that checks if the PR contains security issues. Default is true. - `require_security_review`: if set to true, the tool will add a section that checks if the PR contains security issues. Default is true.
- `require_estimate_effort_to_review`: if set to true, the tool will add a section that estimates thed effort needed to review the PR. Default is true. - `require_estimate_effort_to_review`: if set to true, the tool will add a section that estimates thed effort needed to review the PR. Default is true.
#### general options
- `num_code_suggestions`: number of code suggestions provided by the 'review' tool. Default is 4. - `num_code_suggestions`: number of code suggestions provided by the 'review' tool. Default is 4.
- `inline_code_comments`: if set to true, the tool will publish the code suggestions as comments on the code diff. Default is false. - `inline_code_comments`: if set to true, the tool will publish the code suggestions as comments on the code diff. Default is false.
- `automatic_review`: if set to false, no automatic reviews will be done. Default is true. - `automatic_review`: if set to false, no automatic reviews will be done. Default is true.
- `remove_previous_review_comment`: if set to true, the tool will remove the previous review comment before adding a new one. Default is false.
- `persistent_comment`: if set to true, the review comment will be persistent, meaning that every new review request will edit the previous one. Default is true.
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...". - `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...".
#### review labels
- `enable_review_labels_security`: if set to true, the tool will publish a 'possible security issue' label if it detects a security issue. Default is true.
- `enable_review_labels_effort`: if set to true, the tool will publish a 'Review effort [1-5]: x' label. Default is false.
- To enable `custom labels`, apply the configuration changes described [here](./GENERATE_CUSTOM_LABELS.md#configuration-changes) - To enable `custom labels`, apply the configuration changes described [here](./GENERATE_CUSTOM_LABELS.md#configuration-changes)
#### Incremental Mode #### Incremental Mode
For an incremental review, which only considers changes since the last PR-Agent review, this can be useful when working on the PR in an iterative manner, and you want to focus on the changes since the last review instead of reviewing the entire PR again, the following command can be used: For an incremental review, which only considers changes since the last PR-Agent review, this can be useful when working on the PR in an iterative manner, and you want to focus on the changes since the last review instead of reviewing the entire PR again, the following command can be used:

View File

@ -46,10 +46,13 @@ class PRAgent:
apply_repo_settings(pr_url) apply_repo_settings(pr_url)
# Then, apply user specific settings if exists # Then, apply user specific settings if exists
request = request.replace("'", "\\'") if isinstance(request, str):
lexer = shlex.shlex(request, posix=True) request = request.replace("'", "\\'")
lexer.whitespace_split = True lexer = shlex.shlex(request, posix=True)
action, *args = list(lexer) lexer.whitespace_split = True
action, *args = list(lexer)
else:
action, *args = request
args = update_settings_from_args(args) args = update_settings_from_args(args)
action = action.lstrip("/").lower() action = action.lstrip("/").lower()

View File

@ -8,9 +8,14 @@ MAX_TOKENS = {
'gpt-4': 8000, 'gpt-4': 8000,
'gpt-4-0613': 8000, 'gpt-4-0613': 8000,
'gpt-4-32k': 32000, 'gpt-4-32k': 32000,
'gpt-4-1106-preview': 128000, # 128K, but may be limited by config.max_model_tokens
'claude-instant-1': 100000, 'claude-instant-1': 100000,
'claude-2': 100000, 'claude-2': 100000,
'command-nightly': 4096, 'command-nightly': 4096,
'replicate/llama-2-70b-chat:2c1608e18606fad2812020dc541930f2d0495ce32eee50074220b87300bc16e1': 4096, 'replicate/llama-2-70b-chat:2c1608e18606fad2812020dc541930f2d0495ce32eee50074220b87300bc16e1': 4096,
'meta-llama/Llama-2-7b-chat-hf': 4096 'meta-llama/Llama-2-7b-chat-hf': 4096,
'vertex_ai/codechat-bison': 6144,
'vertex_ai/codechat-bison-32k': 32000,
'codechat-bison': 6144,
'codechat-bison-32k': 32000,
} }

View File

@ -23,39 +23,43 @@ class AiHandler:
Initializes the OpenAI API key and other settings from a configuration file. Initializes the OpenAI API key and other settings from a configuration file.
Raises a ValueError if the OpenAI key is missing. Raises a ValueError if the OpenAI key is missing.
""" """
try: self.azure = False
if get_settings().get("OPENAI.KEY", None):
openai.api_key = get_settings().openai.key openai.api_key = get_settings().openai.key
litellm.openai_key = get_settings().openai.key litellm.openai_key = get_settings().openai.key
if get_settings().get("litellm.use_client"): if get_settings().get("litellm.use_client"):
litellm_token = get_settings().get("litellm.LITELLM_TOKEN") litellm_token = get_settings().get("litellm.LITELLM_TOKEN")
assert litellm_token, "LITELLM_TOKEN is required" assert litellm_token, "LITELLM_TOKEN is required"
os.environ["LITELLM_TOKEN"] = litellm_token os.environ["LITELLM_TOKEN"] = litellm_token
litellm.use_client = True litellm.use_client = True
self.azure = False if get_settings().get("OPENAI.ORG", None):
if get_settings().get("OPENAI.ORG", None): litellm.organization = get_settings().openai.org
litellm.organization = get_settings().openai.org if get_settings().get("OPENAI.API_TYPE", None):
if get_settings().get("OPENAI.API_TYPE", None): if get_settings().openai.api_type == "azure":
if get_settings().openai.api_type == "azure": self.azure = True
self.azure = True litellm.azure_key = get_settings().openai.key
litellm.azure_key = get_settings().openai.key if get_settings().get("OPENAI.API_VERSION", None):
if get_settings().get("OPENAI.API_VERSION", None): litellm.api_version = get_settings().openai.api_version
litellm.api_version = get_settings().openai.api_version if get_settings().get("OPENAI.API_BASE", None):
if get_settings().get("OPENAI.API_BASE", None): litellm.api_base = get_settings().openai.api_base
litellm.api_base = get_settings().openai.api_base if get_settings().get("ANTHROPIC.KEY", None):
if get_settings().get("ANTHROPIC.KEY", None): litellm.anthropic_key = get_settings().anthropic.key
litellm.anthropic_key = get_settings().anthropic.key if get_settings().get("COHERE.KEY", None):
if get_settings().get("COHERE.KEY", None): litellm.cohere_key = get_settings().cohere.key
litellm.cohere_key = get_settings().cohere.key if get_settings().get("REPLICATE.KEY", None):
if get_settings().get("REPLICATE.KEY", None): litellm.replicate_key = get_settings().replicate.key
litellm.replicate_key = get_settings().replicate.key if get_settings().get("REPLICATE.KEY", None):
if get_settings().get("REPLICATE.KEY", None): litellm.replicate_key = get_settings().replicate.key
litellm.replicate_key = get_settings().replicate.key if get_settings().get("HUGGINGFACE.KEY", None):
if get_settings().get("HUGGINGFACE.KEY", None): litellm.huggingface_key = get_settings().huggingface.key
litellm.huggingface_key = get_settings().huggingface.key if get_settings().get("HUGGINGFACE.API_BASE", None):
if get_settings().get("HUGGINGFACE.API_BASE", None): litellm.api_base = get_settings().huggingface.api_base
litellm.api_base = get_settings().huggingface.api_base if get_settings().get("VERTEXAI.VERTEX_PROJECT", None):
except AttributeError as e: litellm.vertex_project = get_settings().vertexai.vertex_project
raise ValueError("OpenAI key is required") from e litellm.vertex_location = get_settings().get(
"VERTEXAI.VERTEX_LOCATION", None
)
@property @property
def deployment_id(self): def deployment_id(self):

View File

@ -23,7 +23,7 @@ def filter_ignored(files):
# keep filenames that _don't_ match the ignore regex # keep filenames that _don't_ match the ignore regex
for r in compiled_patterns: for r in compiled_patterns:
files = [f for f in files if not r.match(f.filename)] files = [f for f in files if (f.filename and not r.match(f.filename))]
except Exception as e: except Exception as e:
print(f"Could not filter file list: {e}") print(f"Could not filter file list: {e}")

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import re import re
from pr_agent.config_loader import get_settings from pr_agent.config_loader import get_settings
from pr_agent.git_providers.git_provider import EDIT_TYPE
from pr_agent.log import get_logger from pr_agent.log import get_logger
@ -115,7 +116,7 @@ def omit_deletion_hunks(patch_lines) -> str:
def handle_patch_deletions(patch: str, original_file_content_str: str, def handle_patch_deletions(patch: str, original_file_content_str: str,
new_file_content_str: str, file_name: str) -> str: new_file_content_str: str, file_name: str, edit_type: EDIT_TYPE = EDIT_TYPE.UNKNOWN) -> str:
""" """
Handle entire file or deletion patches. Handle entire file or deletion patches.
@ -132,7 +133,7 @@ def handle_patch_deletions(patch: str, original_file_content_str: str,
str: The modified patch with deletion hunks omitted. str: The modified patch with deletion hunks omitted.
""" """
if not new_file_content_str: if not new_file_content_str and edit_type != EDIT_TYPE.ADDED:
# logic for handling deleted files - don't show patch, just show that the file was deleted # logic for handling deleted files - don't show patch, just show that the file was deleted
if get_settings().config.verbosity_level > 0: if get_settings().config.verbosity_level > 0:
get_logger().info(f"Processing file: {file_name}, minimizing deletion file") get_logger().info(f"Processing file: {file_name}, minimizing deletion file")

View File

@ -7,18 +7,20 @@ from typing import Any, Callable, List, Tuple
from github import RateLimitExceededException 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.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.language_handler import sort_files_by_main_languages
from pr_agent.algo.file_filter import filter_ignored from pr_agent.algo.file_filter import filter_ignored
from pr_agent.algo.token_handler import TokenHandler, get_token_encoder from pr_agent.algo.token_handler import TokenHandler, get_token_encoder
from pr_agent.algo.utils import get_max_tokens
from pr_agent.config_loader import get_settings from pr_agent.config_loader import get_settings
from pr_agent.git_providers.git_provider import FilePatchInfo, GitProvider from pr_agent.git_providers.git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
from pr_agent.log import get_logger from pr_agent.log import get_logger
DELETED_FILES_ = "Deleted files:\n" DELETED_FILES_ = "Deleted files:\n"
MORE_MODIFIED_FILES_ = "More modified files:\n" MORE_MODIFIED_FILES_ = "Additional modified files (insufficient token budget to process):\n"
ADDED_FILES_ = "Additional added files (insufficient token budget to process):\n"
OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD = 1000 OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD = 1000
OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD = 600 OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD = 600
@ -64,14 +66,17 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s
pr_languages, token_handler, add_line_numbers_to_hunks, patch_extra_lines=PATCH_EXTRA_LINES) 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 we are under the limit, return the full diff
if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < MAX_TOKENS[model]: if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model):
return "\n".join(patches_extended) return "\n".join(patches_extended)
# if we are over the limit, start pruning # if we are over the limit, start pruning
patches_compressed, modified_file_names, deleted_file_names = \ patches_compressed, modified_file_names, deleted_file_names, added_file_names = \
pr_generate_compressed_diff(pr_languages, token_handler, model, add_line_numbers_to_hunks) pr_generate_compressed_diff(pr_languages, token_handler, model, add_line_numbers_to_hunks)
final_diff = "\n".join(patches_compressed) final_diff = "\n".join(patches_compressed)
if added_file_names:
added_list_str = ADDED_FILES_ + "\n".join(added_file_names)
final_diff = final_diff + "\n\n" + added_list_str
if modified_file_names: if modified_file_names:
modified_list_str = MORE_MODIFIED_FILES_ + "\n".join(modified_file_names) modified_list_str = MORE_MODIFIED_FILES_ + "\n".join(modified_file_names)
final_diff = final_diff + "\n\n" + modified_list_str final_diff = final_diff + "\n\n" + modified_list_str
@ -122,7 +127,7 @@ def pr_generate_extended_diff(pr_languages: list,
def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, model: str, def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, model: str,
convert_hunks_to_line_numbers: bool) -> Tuple[list, list, list]: convert_hunks_to_line_numbers: bool) -> Tuple[list, list, list, list]:
""" """
Generate a compressed diff string for a pull request, using diff minimization techniques to reduce the number of Generate a compressed diff string for a pull request, using diff minimization techniques to reduce the number of
tokens used. tokens used.
@ -148,6 +153,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
""" """
patches = [] patches = []
added_files_list = []
modified_files_list = [] modified_files_list = []
deleted_files_list = [] deleted_files_list = []
# sort each one of the languages in top_langs by the number of tokens in the diff # sort each one of the languages in top_langs by the number of tokens in the diff
@ -165,7 +171,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
# removing delete-only hunks # removing delete-only hunks
patch = handle_patch_deletions(patch, original_file_content_str, patch = handle_patch_deletions(patch, original_file_content_str,
new_file_content_str, file.filename) new_file_content_str, file.filename, file.edit_type)
if patch is None: if patch is None:
if not deleted_files_list: if not deleted_files_list:
total_tokens += token_handler.count_tokens(DELETED_FILES_) total_tokens += token_handler.count_tokens(DELETED_FILES_)
@ -179,21 +185,26 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
new_patch_tokens = token_handler.count_tokens(patch) new_patch_tokens = token_handler.count_tokens(patch)
# Hard Stop, no more tokens # Hard Stop, no more tokens
if total_tokens > MAX_TOKENS[model] - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD: if total_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD:
get_logger().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 continue
# If the patch is too large, just show the file name # If the patch is too large, just show the file name
if total_tokens + new_patch_tokens > MAX_TOKENS[model] - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: if total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD:
# Current logic is to skip the patch if it's too large # Current logic is to skip the patch if it's too large
# TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens # TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens
# until we meet the requirements # until we meet the requirements
if get_settings().config.verbosity_level >= 2: if get_settings().config.verbosity_level >= 2:
get_logger().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: if file.edit_type == EDIT_TYPE.ADDED:
total_tokens += token_handler.count_tokens(MORE_MODIFIED_FILES_) if not added_files_list:
modified_files_list.append(file.filename) total_tokens += token_handler.count_tokens(ADDED_FILES_)
total_tokens += token_handler.count_tokens(file.filename) + 1 added_files_list.append(file.filename)
else:
if not modified_files_list:
total_tokens += token_handler.count_tokens(MORE_MODIFIED_FILES_)
modified_files_list.append(file.filename)
total_tokens += token_handler.count_tokens(file.filename) + 1
continue continue
if patch: if patch:
@ -206,7 +217,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
if get_settings().config.verbosity_level >= 2: if get_settings().config.verbosity_level >= 2:
get_logger().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 return patches, modified_files_list, deleted_files_list, added_files_list
async def retry_with_fallback_models(f: Callable): async def retry_with_fallback_models(f: Callable):
@ -271,7 +282,7 @@ def find_line_number_of_relevant_line_in_file(diff_files: List[FilePatchInfo],
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)") r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
for file in diff_files: for file in diff_files:
if file.filename.strip() == relevant_file: if file.filename and (file.filename.strip() == relevant_file):
patch = file.patch patch = file.patch
patch_lines = patch.splitlines() patch_lines = patch.splitlines()
@ -397,13 +408,13 @@ def get_pr_multi_diffs(git_provider: GitProvider,
continue continue
# Remove delete-only hunks # Remove delete-only hunks
patch = handle_patch_deletions(patch, original_file_content_str, new_file_content_str, file.filename) patch = handle_patch_deletions(patch, original_file_content_str, new_file_content_str, file.filename, file.edit_type)
if patch is None: if patch is None:
continue continue
patch = convert_to_hunks_with_lines_numbers(patch, file) patch = convert_to_hunks_with_lines_numbers(patch, file)
new_patch_tokens = token_handler.count_tokens(patch) new_patch_tokens = token_handler.count_tokens(patch)
if patch and (total_tokens + new_patch_tokens > MAX_TOKENS[model] - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD): if patch and (total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD):
final_diff = "\n".join(patches) final_diff = "\n".join(patches)
final_diff_list.append(final_diff) final_diff_list.append(final_diff)
patches = [] patches = []

View File

@ -9,6 +9,8 @@ from typing import Any, List
import yaml import yaml
from starlette_context import context from starlette_context import context
from pr_agent.algo import MAX_TOKENS
from pr_agent.config_loader import get_settings, global_settings from pr_agent.config_loader import get_settings, global_settings
from pr_agent.log import get_logger from pr_agent.log import get_logger
@ -295,6 +297,21 @@ def load_yaml(review_text: str) -> dict:
def try_fix_yaml(review_text: str) -> dict: def try_fix_yaml(review_text: str) -> dict:
review_text_lines = review_text.split('\n') review_text_lines = review_text.split('\n')
# first fallback - try to convert 'relevant line: ...' to relevant line: |-\n ...'
review_text_lines_copy = review_text_lines.copy()
for i in range(0, len(review_text_lines_copy)):
if 'relevant line:' in review_text_lines_copy[i] and not '|-' in review_text_lines_copy[i]:
review_text_lines_copy[i] = review_text_lines_copy[i].replace('relevant line: ',
'relevant line: |-\n ')
try:
data = yaml.load('\n'.join(review_text_lines_copy), Loader=yaml.SafeLoader)
get_logger().info(f"Successfully parsed AI prediction after adding |-\n to relevant line")
return data
except:
get_logger().debug(f"Failed to parse AI prediction after adding |-\n to relevant line")
# second fallback - try to remove last lines
data = {} data = {}
for i in range(1, len(review_text_lines)): for i in range(1, len(review_text_lines)):
review_text_lines_tmp = '\n'.join(review_text_lines[:-i]) review_text_lines_tmp = '\n'.join(review_text_lines[:-i])
@ -326,18 +343,33 @@ def set_custom_labels(variables):
variables["custom_labels_examples"] = f" - {list(labels.keys())[0]}" variables["custom_labels_examples"] = f" - {list(labels.keys())[0]}"
def get_user_labels(current_labels): def get_user_labels(current_labels: List[str] = None):
## Only keep labels that has been added by the user """
if current_labels is None: Only keep labels that has been added by the user
current_labels = [] """
user_labels = [] try:
for label in current_labels: if current_labels is None:
if label in ['Bug fix', 'Tests', 'Refactoring', 'Enhancement', 'Documentation', 'Other']: current_labels = []
continue user_labels = []
if get_settings().config.enable_custom_labels: for label in current_labels:
if label in get_settings().custom_labels: if label.lower() in ['bug fix', 'tests', 'refactoring', 'enhancement', 'documentation', 'other']:
continue continue
user_labels.append(label) if get_settings().config.enable_custom_labels:
if user_labels: if label in get_settings().custom_labels:
get_logger().info(f"Keeping user labels: {user_labels}") continue
user_labels.append(label)
if user_labels:
get_logger().info(f"Keeping user labels: {user_labels}")
except Exception as e:
get_logger().exception(f"Failed to get user labels: {e}")
return current_labels
return user_labels return user_labels
def get_max_tokens(model):
settings = get_settings()
max_tokens_model = MAX_TOKENS[model]
if settings.config.max_model_tokens:
max_tokens_model = min(settings.config.max_model_tokens, max_tokens_model)
# get_logger().debug(f"limiting max tokens to {max_tokens_model}")
return max_tokens_model

View File

@ -8,6 +8,8 @@ from pr_agent.log import setup_logger
setup_logger() setup_logger()
def run(inargs=None): def run(inargs=None):
parser = argparse.ArgumentParser(description='AI based pull request analyzer', usage= parser = argparse.ArgumentParser(description='AI based pull request analyzer', usage=
"""\ """\
@ -51,9 +53,9 @@ For example: 'python cli.py --pr_url=... review --pr_reviewer.extra_instructions
command = args.command.lower() command = args.command.lower()
get_settings().set("CONFIG.CLI_MODE", True) get_settings().set("CONFIG.CLI_MODE", True)
if args.issue_url: if args.issue_url:
result = asyncio.run(PRAgent().handle_request(args.issue_url, command + " " + " ".join(args.rest))) result = asyncio.run(PRAgent().handle_request(args.issue_url, [command] + args.rest))
else: else:
result = asyncio.run(PRAgent().handle_request(args.pr_url, command + " " + " ".join(args.rest))) result = asyncio.run(PRAgent().handle_request(args.pr_url, [command] + args.rest))
if not result: if not result:
parser.print_help() parser.print_help()

View File

@ -9,7 +9,7 @@ from starlette_context import context
from ..algo.pr_processing import 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 ..config_loader import get_settings
from ..log import get_logger from ..log import get_logger
from .git_provider import FilePatchInfo, GitProvider from .git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
class BitbucketProvider(GitProvider): class BitbucketProvider(GitProvider):
@ -132,17 +132,56 @@ class BitbucketProvider(GitProvider):
diff.old.get_data("links") diff.old.get_data("links")
) )
new_file_content_str = self._get_pr_file_content(diff.new.get_data("links")) new_file_content_str = self._get_pr_file_content(diff.new.get_data("links"))
diff_files.append( file_patch_canonic_structure = FilePatchInfo(
FilePatchInfo( original_file_content_str,
original_file_content_str, new_file_content_str,
new_file_content_str, diff_split[index],
diff_split[index], diff.new.path,
diff.new.path,
)
) )
if diff.data['status'] == 'added':
file_patch_canonic_structure.edit_type = EDIT_TYPE.ADDED
elif diff.data['status'] == 'removed':
file_patch_canonic_structure.edit_type = EDIT_TYPE.DELETED
elif diff.data['status'] == 'modified':
file_patch_canonic_structure.edit_type = EDIT_TYPE.MODIFIED
elif diff.data['status'] == 'renamed':
file_patch_canonic_structure.edit_type = EDIT_TYPE.RENAMED
diff_files.append(file_patch_canonic_structure)
self.diff_files = diff_files self.diff_files = diff_files
return diff_files return diff_files
def get_latest_commit_url(self):
return self.pr.data['source']['commit']['links']['html']['href']
def get_comment_url(self, comment):
return comment.data['links']['html']['href']
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True):
try:
for comment in self.pr.comments():
body = comment.raw
if initial_header in body:
latest_commit_url = self.get_latest_commit_url()
comment_url = self.get_comment_url(comment)
if update_header:
updated_header = f"{initial_header}\n\n### (review updated until commit {latest_commit_url})\n"
pr_comment_updated = pr_comment.replace(initial_header, updated_header)
else:
pr_comment_updated = pr_comment
get_logger().info(f"Persistent mode- updating comment {comment_url} to latest review message")
d = {"content": {"raw": pr_comment_updated}}
response = comment._update_data(comment.put(None, data=d))
self.publish_comment(
f"**[Persistent review]({comment_url})** updated to latest commit {latest_commit_url}")
return
except Exception as e:
get_logger().exception(f"Failed to update persistent review, error: {e}")
pass
self.publish_comment(pr_comment)
def publish_comment(self, pr_comment: str, is_temporary: bool = False): def publish_comment(self, pr_comment: str, is_temporary: bool = False):
comment = self.pr.comment(pr_comment) comment = self.pr.comment(pr_comment)
if is_temporary: if is_temporary:
@ -288,6 +327,11 @@ class BitbucketProvider(GitProvider):
}) })
response = requests.request("PUT", self.bitbucket_pull_request_api_url, headers=self.headers, data=payload) response = requests.request("PUT", self.bitbucket_pull_request_api_url, headers=self.headers, data=payload)
try:
if response.status_code != 200:
get_logger().info(f"Failed to update description, error code: {response.status_code}")
except:
pass
return response return response
# bitbucket does not support labels # bitbucket does not support labels

View File

@ -13,6 +13,7 @@ class EDIT_TYPE(Enum):
DELETED = 2 DELETED = 2
MODIFIED = 3 MODIFIED = 3
RENAMED = 4 RENAMED = 4
UNKNOWN = 5
@dataclass @dataclass
@ -22,7 +23,7 @@ class FilePatchInfo:
patch: str patch: str
filename: str filename: str
tokens: int = -1 tokens: int = -1
edit_type: EDIT_TYPE = EDIT_TYPE.MODIFIED edit_type: EDIT_TYPE = EDIT_TYPE.UNKNOWN
old_filename: str = None old_filename: str = None
@ -39,42 +40,10 @@ class GitProvider(ABC):
def publish_description(self, pr_title: str, pr_body: str): def publish_description(self, pr_title: str, pr_body: str):
pass pass
@abstractmethod
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
pass
@abstractmethod
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
pass
@abstractmethod
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
pass
@abstractmethod
def publish_inline_comments(self, comments: list[dict]):
pass
@abstractmethod @abstractmethod
def publish_code_suggestions(self, code_suggestions: list) -> bool: def publish_code_suggestions(self, code_suggestions: list) -> bool:
pass pass
@abstractmethod
def publish_labels(self, labels):
pass
@abstractmethod
def get_labels(self):
pass
@abstractmethod
def remove_initial_comment(self):
pass
@abstractmethod
def remove_comment(self, comment):
pass
@abstractmethod @abstractmethod
def get_languages(self): def get_languages(self):
pass pass
@ -94,16 +63,16 @@ class GitProvider(ABC):
def get_pr_description(self, *, full: bool = True) -> str: def get_pr_description(self, *, full: bool = True) -> str:
from pr_agent.config_loader import get_settings from pr_agent.config_loader import get_settings
from pr_agent.algo.pr_processing import clip_tokens from pr_agent.algo.pr_processing import clip_tokens
max_tokens = get_settings().get("CONFIG.MAX_DESCRIPTION_TOKENS", None) max_tokens_description = get_settings().get("CONFIG.MAX_DESCRIPTION_TOKENS", None)
description = self.get_pr_description_full() if full else self.get_user_description() description = self.get_pr_description_full() if full else self.get_user_description()
if max_tokens: if max_tokens_description:
return clip_tokens(description, max_tokens) return clip_tokens(description, max_tokens_description)
return description return description
def get_user_description(self) -> str: def get_user_description(self) -> str:
description = (self.get_pr_description_full() or "").strip() description = (self.get_pr_description_full() or "").strip()
# if the existing description wasn't generated by the pr-agent, just return it as-is # if the existing description wasn't generated by the pr-agent, just return it as-is
if not description.startswith("## PR Type"): if not any(description.startswith(header) for header in ("## PR Type", "## PR Description")):
return description return description
# if the existing description was generated by the pr-agent, but it doesn't contain the user description, # if the existing description was generated by the pr-agent, but it doesn't contain the user description,
# return nothing (empty string) because it means there is no user description # return nothing (empty string) because it means there is no user description
@ -113,11 +82,54 @@ class GitProvider(ABC):
return description.split("## User Description:", 1)[1].strip() return description.split("## User Description:", 1)[1].strip()
@abstractmethod @abstractmethod
def get_issue_comments(self): def get_repo_settings(self):
pass
def get_pr_id(self):
return ""
#### comments operations ####
@abstractmethod
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
pass
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool):
self.publish_comment(pr_comment)
@abstractmethod
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
pass pass
@abstractmethod @abstractmethod
def get_repo_settings(self): def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
pass
@abstractmethod
def publish_inline_comments(self, comments: list[dict]):
pass
@abstractmethod
def remove_initial_comment(self):
pass
@abstractmethod
def remove_comment(self, comment):
pass
@abstractmethod
def get_issue_comments(self):
pass
def get_comment_url(self, comment) -> str:
return ""
#### labels operations ####
@abstractmethod
def publish_labels(self, labels):
pass
@abstractmethod
def get_labels(self):
pass pass
@abstractmethod @abstractmethod
@ -128,11 +140,12 @@ class GitProvider(ABC):
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
pass pass
#### commits operations ####
@abstractmethod @abstractmethod
def get_commit_messages(self): def get_commit_messages(self):
pass pass
def get_pr_id(self): def get_latest_commit_url(self) -> str:
return "" return ""
def get_main_pr_language(languages, files) -> str: def get_main_pr_language(languages, files) -> str:
@ -153,6 +166,8 @@ def get_main_pr_language(languages, files) -> str:
# validate that the specific commit uses the main language # validate that the specific commit uses the main language
extension_list = [] extension_list = []
for file in files: for file in files:
if not file:
continue
if isinstance(file, str): if isinstance(file, str):
file = FilePatchInfo(base_file=None, head_file=None, patch=None, filename=file) file = FilePatchInfo(base_file=None, head_file=None, patch=None, filename=file)
extension_list.append(file.filename.rsplit('.')[-1]) extension_list.append(file.filename.rsplit('.')[-1])

View File

@ -13,7 +13,7 @@ from ..algo.utils import load_large_diff
from ..config_loader import get_settings from ..config_loader import get_settings
from ..log import get_logger from ..log import get_logger
from ..servers.utils import RateLimitExceeded from ..servers.utils import RateLimitExceeded
from .git_provider import FilePatchInfo, GitProvider, IncrementalPR from .git_provider import FilePatchInfo, GitProvider, IncrementalPR, EDIT_TYPE
class GithubProvider(GitProvider): class GithubProvider(GitProvider):
@ -129,7 +129,20 @@ class GithubProvider(GitProvider):
if not patch: if not patch:
patch = load_large_diff(file.filename, new_file_content_str, original_file_content_str) patch = load_large_diff(file.filename, new_file_content_str, original_file_content_str)
diff_files.append(FilePatchInfo(original_file_content_str, new_file_content_str, patch, file.filename)) if file.status == 'added':
edit_type = EDIT_TYPE.ADDED
elif file.status == 'removed':
edit_type = EDIT_TYPE.DELETED
elif file.status == 'renamed':
edit_type = EDIT_TYPE.RENAMED
elif file.status == 'modified':
edit_type = EDIT_TYPE.MODIFIED
else:
get_logger().error(f"Unknown edit type: {file.status}")
edit_type = EDIT_TYPE.UNKNOWN
file_patch_canonical_structure = FilePatchInfo(original_file_content_str, new_file_content_str, patch,
file.filename, edit_type=edit_type)
diff_files.append(file_patch_canonical_structure)
self.diff_files = diff_files self.diff_files = diff_files
return diff_files return diff_files
@ -141,10 +154,36 @@ class GithubProvider(GitProvider):
def publish_description(self, pr_title: str, pr_body: str): def publish_description(self, pr_title: str, pr_body: str):
self.pr.edit(title=pr_title, body=pr_body) self.pr.edit(title=pr_title, body=pr_body)
def get_latest_commit_url(self) -> str:
return self.last_commit_id.html_url
def get_comment_url(self, comment) -> str:
return comment.html_url
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True):
prev_comments = list(self.pr.get_issue_comments())
for comment in prev_comments:
body = comment.body
if body.startswith(initial_header):
latest_commit_url = self.get_latest_commit_url()
comment_url = self.get_comment_url(comment)
if update_header:
updated_header = f"{initial_header}\n\n### (review updated until commit {latest_commit_url})\n"
pr_comment_updated = pr_comment.replace(initial_header, updated_header)
else:
pr_comment_updated = pr_comment
get_logger().info(f"Persistent mode- updating comment {comment_url} to latest review message")
response = comment.edit(pr_comment_updated)
self.publish_comment(
f"**[Persistent review]({comment_url})** updated to latest commit {latest_commit_url}")
return
self.publish_comment(pr_comment)
def publish_comment(self, pr_comment: str, is_temporary: bool = False): def publish_comment(self, pr_comment: str, is_temporary: bool = False):
if is_temporary and not get_settings().config.publish_output_progress: if is_temporary and not get_settings().config.publish_output_progress:
get_logger().debug(f"Skipping publish_comment for temporary comment: {pr_comment}") get_logger().debug(f"Skipping publish_comment for temporary comment: {pr_comment}")
return return
response = self.pr.create_issue_comment(pr_comment) response = self.pr.create_issue_comment(pr_comment)
if hasattr(response, "user") and hasattr(response.user, "login"): if hasattr(response, "user") and hasattr(response.user, "login"):
self.github_user_id = response.user.login self.github_user_id = response.user.login

View File

@ -136,6 +136,33 @@ class GitLabProvider(GitProvider):
except Exception as e: except Exception as e:
get_logger().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 get_latest_commit_url(self):
return self.mr.commits().next().web_url
def get_comment_url(self, comment):
return f"{self.mr.web_url}#note_{comment.id}"
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True):
try:
for comment in self.mr.notes.list(get_all=True)[::-1]:
if comment.body.startswith(initial_header):
latest_commit_url = self.get_latest_commit_url()
comment_url = self.get_comment_url(comment)
if update_header:
updated_header = f"{initial_header}\n\n### (review updated until commit {latest_commit_url})\n"
pr_comment_updated = pr_comment.replace(initial_header, updated_header)
else:
pr_comment_updated = pr_comment
get_logger().info(f"Persistent mode- updating comment {comment_url} to latest review message")
response = self.mr.notes.update(comment.id, {'body': pr_comment_updated})
self.publish_comment(
f"**[Persistent review]({comment_url})** updated to latest commit {latest_commit_url}")
return
except Exception as e:
get_logger().exception(f"Failed to update persistent review, error: {e}")
pass
self.publish_comment(pr_comment)
def publish_comment(self, mr_comment: str, is_temporary: bool = False): def publish_comment(self, mr_comment: str, is_temporary: bool = False):
comment = self.mr.notes.create({'body': mr_comment}) comment = self.mr.notes.create({'body': mr_comment})
if is_temporary: if is_temporary:

View File

@ -36,6 +36,10 @@ api_base = "" # the base url for your huggingface inference endpoint
[ollama] [ollama]
api_base = "" # the base url for your local Llama 2, Code Llama, and other models inference endpoint. Acquire through https://ollama.ai/ api_base = "" # the base url for your local Llama 2, Code Llama, and other models inference endpoint. Acquire through https://ollama.ai/
[vertexai]
vertex_project = "" # the google cloud platform project name for your vertexai deployment
vertex_location = "" # the google cloud platform location for your vertexai deployment
[github] [github]
# ---- Set the following only for deployment type == "user" # ---- Set the following only for deployment type == "user"
user_token = "" # A GitHub personal access token with 'repo' scope. user_token = "" # A GitHub personal access token with 'repo' scope.

View File

@ -1,5 +1,5 @@
[config] [config]
model="gpt-4" model="gpt-4" # "gpt-4-1106-preview"
fallback_models=["gpt-3.5-turbo-16k"] fallback_models=["gpt-3.5-turbo-16k"]
git_provider="github" git_provider="github"
publish_output=true publish_output=true
@ -10,22 +10,29 @@ use_repo_settings_file=true
ai_timeout=180 ai_timeout=180
max_description_tokens = 500 max_description_tokens = 500
max_commits_tokens = 500 max_commits_tokens = 500
max_model_tokens = 32000 # Limits the maximum number of tokens that can be used by any model, regardless of the model's default capabilities.
patch_extra_lines = 3 patch_extra_lines = 3
secret_provider="google_cloud_storage" secret_provider="google_cloud_storage"
cli_mode=false cli_mode=false
[pr_reviewer] # /review # [pr_reviewer] # /review #
# enable/disable features
require_focused_review=false require_focused_review=false
require_score_review=false require_score_review=false
require_tests_review=true require_tests_review=true
require_security_review=true require_security_review=true
require_estimate_effort_to_review=true require_estimate_effort_to_review=true
# general options
num_code_suggestions=4 num_code_suggestions=4
inline_code_comments = false inline_code_comments = false
ask_and_reflect=false ask_and_reflect=false
automatic_review=true automatic_review=true
remove_previous_review_comment=false remove_previous_review_comment=false
persistent_comment=true
extra_instructions = "" extra_instructions = ""
# review labels
enable_review_labels_security=true
enable_review_labels_effort=false
# specific configurations for incremental review (/review -i) # specific configurations for incremental review (/review -i)
require_all_thresholds_for_incremental_review=false require_all_thresholds_for_incremental_review=false
minimal_commits_for_incremental_review=0 minimal_commits_for_incremental_review=0

View File

@ -16,7 +16,7 @@ You must use the following YAML schema to format your answer:
PR Type: PR Type:
type: array type: array
{%- if enable_custom_labels %} {%- if enable_custom_labels %}
description: One or more labels that describe the PR type. Don't output the description in the parentheses. description: Labels that are applicable to the Pull Request. Don't output the description in the parentheses. If none of the labels is relevant to the PR, output an empty array.
{%- endif %} {%- endif %}
items: items:
type: string type: string

View File

@ -30,7 +30,7 @@ PR Type:
{%- if enable_custom_labels %} {%- if enable_custom_labels %}
PR Labels: PR Labels:
type: array type: array
description: One or more labels that describe the PR labels. Don't output the description in the parentheses. description: Labels that are applicable to the Pull Request. Don't output the description in the parentheses. If none of the labels is relevant to the PR, output an empty array.
items: items:
type: string type: string
enum: enum:

View File

@ -93,7 +93,7 @@ PR Analysis:
description: >- description: >-
Estimate, on a scale of 1-5 (inclusive), the time and effort required to review this PR by an experienced and knowledgeable developer. 1 means short and easy review , 5 means long and hard review. Estimate, on a scale of 1-5 (inclusive), the time and effort required to review this PR by an experienced and knowledgeable developer. 1 means short and easy review , 5 means long and hard review.
Take into account the size, complexity, quality, and the needed changes of the PR code diff. Take into account the size, complexity, quality, and the needed changes of the PR code diff.
Explain your answer shortly (1-2 sentences). Explain your answer shortly (1-2 sentences). Use the format: '1, because ...'
{%- endif %} {%- endif %}
PR Feedback: PR Feedback:
General suggestions: General suggestions:
@ -130,7 +130,8 @@ PR Feedback:
Security concerns: Security concerns:
type: string type: string
description: >- description: >-
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. 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 ? Answer 'No' if there are no possible issues.
Answer 'Yes, because ...' if there are security concerns or issues. Explain your answer shortly.
{%- endif %} {%- endif %}
``` ```
@ -152,7 +153,8 @@ PR Analysis:
Focused PR: no, because ... Focused PR: no, because ...
{%- endif %} {%- endif %}
{%- if require_estimate_effort_to_review %} {%- if require_estimate_effort_to_review %}
Estimated effort to review [1-5]: 3, because ... Estimated effort to review [1-5]: |-
3, because ...
{%- endif %} {%- endif %}
PR Feedback: PR Feedback:
General PR suggestions: |- General PR suggestions: |-

View File

@ -158,6 +158,9 @@ class PRDescription:
user=user_prompt user=user_prompt
) )
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"\nAI response:\n{response}")
return response return response
def _prepare_data(self): def _prepare_data(self):

View File

@ -10,7 +10,7 @@ from yaml import SafeLoader
from pr_agent.algo.ai_handler import AiHandler 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.pr_processing import get_pr_diff, retry_with_fallback_models
from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import convert_to_markdown, load_yaml, try_fix_yaml, set_custom_labels from pr_agent.algo.utils import convert_to_markdown, load_yaml, try_fix_yaml, set_custom_labels, get_user_labels
from pr_agent.config_loader import get_settings from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider 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.git_providers.git_provider import IncrementalPR, get_main_pr_language
@ -117,7 +117,15 @@ class PRReviewer:
if get_settings().config.publish_output: if get_settings().config.publish_output:
get_logger().info('Pushing PR review...') get_logger().info('Pushing PR review...')
previous_review_comment = self._get_previous_review_comment() previous_review_comment = self._get_previous_review_comment()
self.git_provider.publish_comment(pr_comment)
# publish the review
if get_settings().pr_reviewer.persistent_comment and not self.incremental.is_incremental:
self.git_provider.publish_persistent_comment(pr_comment,
initial_header="## PR Analysis",
update_header=True)
else:
self.git_provider.publish_comment(pr_comment)
self.git_provider.remove_initial_comment() self.git_provider.remove_initial_comment()
if previous_review_comment: if previous_review_comment:
self._remove_previous_review_comment(previous_review_comment) self._remove_previous_review_comment(previous_review_comment)
@ -156,7 +164,6 @@ class PRReviewer:
variables["diff"] = self.patches_diff # update diff variables["diff"] = self.patches_diff # update diff
environment = Environment(undefined=StrictUndefined) environment = Environment(undefined=StrictUndefined)
# set_custom_labels(variables)
system_prompt = environment.from_string(get_settings().pr_review_prompt.system).render(variables) system_prompt = environment.from_string(get_settings().pr_review_prompt.system).render(variables)
user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(variables) user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(variables)
@ -171,6 +178,9 @@ class PRReviewer:
user=user_prompt user=user_prompt
) )
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"\nAI response:\n{response}")
return response return response
def _prepare_pr_review(self) -> str: def _prepare_pr_review(self) -> str:
@ -245,6 +255,9 @@ class PRReviewer:
else: else:
markdown_text += actions_help_text markdown_text += actions_help_text
# Add custom labels from the review prediction (effort, security)
self.set_review_labels(data)
# Log markdown response if verbosity level is high # Log markdown response if verbosity level is high
if get_settings().config.verbosity_level >= 2: if get_settings().config.verbosity_level >= 2:
get_logger().info(f"Markdown response:\n{markdown_text}") get_logger().info(f"Markdown response:\n{markdown_text}")
@ -365,3 +378,28 @@ class PRReviewer:
) )
return False return False
return True return True
def set_review_labels(self, data):
if (get_settings().pr_reviewer.enable_review_labels_security or
get_settings().pr_reviewer.enable_review_labels_effort):
try:
review_labels = []
if get_settings().pr_reviewer.enable_review_labels_effort:
estimated_effort = data['PR Analysis']['Estimated effort to review [1-5]']
estimated_effort_number = int(estimated_effort.split(',')[0])
if 1 <= estimated_effort_number <= 5: # 1, because ...
review_labels.append(f'Review effort [1-5]: {estimated_effort_number}')
if get_settings().pr_reviewer.enable_review_labels_security:
security_concerns = data['PR Analysis']['Security concerns'] # yes, because ...
security_concerns_bool = 'yes' in security_concerns.lower() or 'true' in security_concerns.lower()
if security_concerns_bool:
review_labels.append('Possible security concern')
if review_labels:
current_labels = self.git_provider.get_labels()
current_labels_filtered = [label for label in current_labels if
not label.lower().startswith('review effort [1-5]:') and not label.lower().startswith(
'possible security concern')]
self.git_provider.publish_labels(review_labels + current_labels_filtered)
except Exception as e:
get_logger().error(f"Failed to set review labels, error: {e}")

View File

@ -8,8 +8,8 @@ import pinecone
from pinecone_datasets import Dataset, DatasetMetadata from pinecone_datasets import Dataset, DatasetMetadata
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pr_agent.algo import MAX_TOKENS
from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import get_max_tokens
from pr_agent.config_loader import get_settings from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider from pr_agent.git_providers import get_git_provider
from pr_agent.log import get_logger from pr_agent.log import get_logger
@ -197,7 +197,7 @@ class PRSimilarIssue:
username = issue.user.login username = issue.user.login
created_at = str(issue.created_at) created_at = str(issue.created_at)
if len(issue_str) < 8000 or \ if len(issue_str) < 8000 or \
self.token_handler.count_tokens(issue_str) < MAX_TOKENS[MODEL]: # fast reject first self.token_handler.count_tokens(issue_str) < get_max_tokens(MODEL): # fast reject first
issue_record = Record( issue_record = Record(
id=issue_key + "." + "issue", id=issue_key + "." + "issue",
text=issue_str, text=issue_str,

View File

@ -13,7 +13,7 @@ atlassian-python-api==3.39.0
GitPython==3.1.32 GitPython==3.1.32
PyYAML==6.0 PyYAML==6.0
starlette-context==0.3.6 starlette-context==0.3.6
litellm~=0.1.574 litellm==0.12.5
boto3==1.28.25 boto3==1.28.25
google-cloud-storage==2.10.0 google-cloud-storage==2.10.0
ujson==5.8.0 ujson==5.8.0
@ -22,3 +22,4 @@ msrest==0.7.1
pinecone-client pinecone-client
pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main
loguru==0.7.2 loguru==0.7.2
google-cloud-aiplatform==1.35.0

View File

@ -0,0 +1,31 @@
# Generated by CodiumAI
from pr_agent.algo.utils import try_fix_yaml
import pytest
class TestTryFixYaml:
# The function successfully parses a valid YAML string.
def test_valid_yaml(self):
review_text = "key: value\n"
expected_output = {"key": "value"}
assert try_fix_yaml(review_text) == expected_output
# The function adds '|-' to 'relevant line:' if it is not already present and successfully parses the YAML string.
def test_add_relevant_line(self):
review_text = "relevant line: value: 3\n"
expected_output = {"relevant line": "value: 3"}
assert try_fix_yaml(review_text) == expected_output
# The function removes the last line(s) of the YAML string and successfully parses the YAML string.
def test_remove_last_line(self):
review_text = "key: value\nextra invalid line\n"
expected_output = {"key": "value"}
assert try_fix_yaml(review_text) == expected_output
# The YAML string is empty.
def test_empty_yaml_fixed(self):
review_text = ""
assert try_fix_yaml(review_text) is None