mirror of
https://github.com/qodo-ai/pr-agent.git
synced 2025-07-05 21:30:40 +08:00
Compare commits
36 Commits
hl/jira_se
...
v0.26
Author | SHA1 | Date | |
---|---|---|---|
8218fa6e13 | |||
8463c4f549 | |||
014b1f20c5 | |||
2f73ab6eab | |||
16dc29a23a | |||
bd9522057f | |||
b3d4af6cbf | |||
5df9698bae | |||
e89b65ed38 | |||
6a145af159 | |||
39a375b3e4 | |||
dbd76ecde5 | |||
e95920c58c | |||
59899f0c62 | |||
5e46955d52 | |||
95d0fafa75 | |||
71c558d306 | |||
7b2c41e0d2 | |||
4aad67b563 | |||
12d603fdb4 | |||
6540e2c674 | |||
83e68f168a | |||
5e1b04980e | |||
495c1ebe5f | |||
ad71de82a9 | |||
11676943b6 | |||
b88507aa23 | |||
a5c5e6f4ae | |||
47cd361663 | |||
c84b3d04b9 | |||
7d9288bb1a | |||
93e64367d2 | |||
6c131b8406 | |||
dd89e1f2dc | |||
e8e4fb0afa | |||
3360a28b3e |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
.idea/
|
||||
.lsp/
|
||||
.vscode/
|
||||
.env
|
||||
venv/
|
||||
pr_agent/settings/.secrets.toml
|
||||
__pycache__
|
||||
@ -8,4 +9,4 @@ dist/
|
||||
*.egg-info/
|
||||
build/
|
||||
.DS_Store
|
||||
docs/.cache/
|
||||
docs/.cache/
|
41
README.md
41
README.md
@ -41,6 +41,10 @@ Qode Merge PR-Agent aims to help efficiently review and handle pull requests, by
|
||||
|
||||
## News and Updates
|
||||
|
||||
### December 25, 2024
|
||||
|
||||
The `review` tool previously included a legacy feature for providing code suggestions (controlled by '--pr_reviewer.num_code_suggestion'). This functionality has been deprecated. Use instead the [`improve`](https://qodo-merge-docs.qodo.ai/tools/improve/) tool, which offers higher quality and more actionable code suggestions.
|
||||
|
||||
### December 2, 2024
|
||||
|
||||
Open-source repositories can now freely use Qodo Merge Pro, and enjoy easy one-click installation using a marketplace [app](https://github.com/apps/qodo-merge-pro-for-open-source).
|
||||
@ -179,43 +183,6 @@ ___
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
[//]: # (<h4><a href="https://github.com/Codium-ai/pr-agent/pull/78#issuecomment-1639739496">/reflect_and_review:</a></h4>)
|
||||
|
||||
[//]: # (<div align="center">)
|
||||
|
||||
[//]: # (<p float="center">)
|
||||
|
||||
[//]: # (<img src="https://www.codium.ai/images/reflect_and_review.gif" width="800">)
|
||||
|
||||
[//]: # (</p>)
|
||||
|
||||
[//]: # (</div>)
|
||||
|
||||
[//]: # (<h4><a href="https://github.com/Codium-ai/pr-agent/pull/229#issuecomment-1695020538">/ask:</a></h4>)
|
||||
|
||||
[//]: # (<div align="center">)
|
||||
|
||||
[//]: # (<p float="center">)
|
||||
|
||||
[//]: # (<img src="https://www.codium.ai/images/ask-2.gif" width="800">)
|
||||
|
||||
[//]: # (</p>)
|
||||
|
||||
[//]: # (</div>)
|
||||
|
||||
[//]: # (<h4><a href="https://github.com/Codium-ai/pr-agent/pull/229#issuecomment-1695024952">/improve:</a></h4>)
|
||||
|
||||
[//]: # (<div align="center">)
|
||||
|
||||
[//]: # (<p float="center">)
|
||||
|
||||
[//]: # (<img src="https://www.codium.ai/images/improve-2.gif" width="800">)
|
||||
|
||||
[//]: # (</p>)
|
||||
|
||||
[//]: # (</div>)
|
||||
<div align="left">
|
||||
|
||||
|
||||
|
@ -66,7 +66,30 @@ To invoke a tool (for example `review`), you can run directly from the Docker im
|
||||
docker run --rm -it -e CONFIG.GIT_PROVIDER=bitbucket -e OPENAI.KEY=$OPENAI_API_KEY -e BITBUCKET.BEARER_TOKEN=$BITBUCKET_BEARER_TOKEN codiumai/pr-agent:latest --pr_url=<pr_url> review
|
||||
```
|
||||
|
||||
For other git providers, update CONFIG.GIT_PROVIDER accordingly, and check the `pr_agent/settings/.secrets_template.toml` file for the environment variables expected names and values.
|
||||
For other git providers, update `CONFIG.GIT_PROVIDER` accordingly and check the `pr_agent/settings/.secrets_template.toml` file for environment variables expected names and values.
|
||||
The `pr_agent` uses [Dynaconf](https://www.dynaconf.com/) to load settings from configuration files.
|
||||
|
||||
It is also possible to provide or override the configuration by setting the corresponding environment variables.
|
||||
You can define the corresponding environment variables by following this convention: `<TABLE>__<KEY>=<VALUE>` or `<TABLE>.<KEY>=<VALUE>`.
|
||||
The `<TABLE>` refers to a table/section in a configuration file and `<KEY>=<VALUE>` refers to the key/value pair of a setting in the configuration file.
|
||||
|
||||
For example, suppose you want to run `pr_agent` that connects to a self-hosted GitLab instance similar to an example above.
|
||||
You can define the environment variables in a plain text file named `.env` with the following content:
|
||||
|
||||
> Warning: Never commit the `.env` file to version control system as it might contains sensitive credentials!
|
||||
|
||||
```
|
||||
CONFIG__GIT_PROVIDER="gitlab"
|
||||
GITLAB__URL="<your url>"
|
||||
GITLAB__PERSONAL_ACCESS_TOKEN="<your token>"
|
||||
OPENAI__KEY="<your key>"
|
||||
```
|
||||
|
||||
Then, you can run `pr_agent` using Docker with the following command:
|
||||
|
||||
```shell
|
||||
docker run --rm -it --env-file .env codiumai/pr-agent:latest <tool> <tool parameter>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
@ -46,61 +46,12 @@ extra_instructions = "..."
|
||||
- The `pr_commands` lists commands that will be executed automatically when a PR is opened.
|
||||
- The `[pr_reviewer]` section contains the configurations for the `review` tool you want to edit (if any).
|
||||
|
||||
[//]: # ()
|
||||
[//]: # (### Incremental Mode)
|
||||
|
||||
[//]: # (Incremental review only considers changes since the last Qodo Merge 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.)
|
||||
|
||||
[//]: # (For invoking the incremental mode, the following command can be used:)
|
||||
|
||||
[//]: # (```)
|
||||
|
||||
[//]: # (/review -i)
|
||||
|
||||
[//]: # (```)
|
||||
|
||||
[//]: # (Note that the incremental mode is only available for GitHub.)
|
||||
|
||||
[//]: # ()
|
||||
[//]: # ({width=512})
|
||||
|
||||
[//]: # (### PR Reflection)
|
||||
|
||||
[//]: # ()
|
||||
[//]: # (By invoking:)
|
||||
|
||||
[//]: # (```)
|
||||
|
||||
[//]: # (/reflect_and_review)
|
||||
|
||||
[//]: # (```)
|
||||
|
||||
[//]: # (The tool will first ask the author questions about the PR, and will guide the review based on their answers.)
|
||||
|
||||
[//]: # ()
|
||||
[//]: # ({width=512})
|
||||
|
||||
[//]: # ()
|
||||
[//]: # ({width=512})
|
||||
|
||||
[//]: # ()
|
||||
[//]: # ({width=512})
|
||||
|
||||
|
||||
|
||||
## Configuration options
|
||||
|
||||
!!! example "General options"
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><b>num_code_suggestions</b></td>
|
||||
<td>Number of code suggestions provided by the 'review' tool. Default is 0, meaning no code suggestions will be provided by the `review` tool. Note that this is a legacy feature, that will be removed in future releases. Use the `improve` tool instead for code suggestions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>inline_code_comments</b></td>
|
||||
<td>If set to true, the tool will publish the code suggestions as comments on the code diff. Default is false. Note that you need to set `num_code_suggestions`>0 to get code suggestions </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>persistent_comment</b></td>
|
||||
<td>If set to true, the review comment will be persistent, meaning that every new review request will edit the previous one. Default is true.</td>
|
||||
@ -189,9 +140,9 @@ If enabled, the `review` tool can approve a PR when a specific comment, `/review
|
||||
!!! tip "Automation"
|
||||
When you first install Qodo Merge app, the [default mode](../usage-guide/automations_and_usage.md#github-app-automatic-tools-when-a-new-pr-is-opened) for the `review` tool is:
|
||||
```
|
||||
pr_commands = ["/review --pr_reviewer.num_code_suggestions=0", ...]
|
||||
pr_commands = ["/review", ...]
|
||||
```
|
||||
Meaning the `review` tool will run automatically on every PR, without providing code suggestions.
|
||||
Meaning the `review` tool will run automatically on every PR, without any additional configurations.
|
||||
Edit this field to enable/disable the tool, or to change the configurations used.
|
||||
|
||||
!!! tip "Possible labels from the review tool"
|
||||
@ -249,12 +200,8 @@ If enabled, the `review` tool can approve a PR when a specific comment, `/review
|
||||
maximal_review_effort = 5
|
||||
```
|
||||
|
||||
[//]: # (!!! tip "Code suggestions")
|
||||
!!! tip "Code suggestions"
|
||||
|
||||
[//]: # ()
|
||||
[//]: # ( If you set `num_code_suggestions`>0 , the `review` tool will also provide code suggestions.)
|
||||
The `review` tool previously included a legacy feature for providing code suggestions (controlled by `--pr_reviewer.num_code_suggestion`). This functionality has been deprecated and replaced by the [`improve`](./improve.md) tool, which offers higher quality and more actionable code suggestions.
|
||||
|
||||
[//]: # ( )
|
||||
[//]: # ( Notice If you are interested **only** in the code suggestions, it is recommended to use the [`improve`](./improve.md) feature instead, since it is a dedicated only to code suggestions, and usually gives better results.)
|
||||
|
||||
[//]: # ( Use the `review` tool if you want to get more comprehensive feedback, which includes code suggestions as well.)
|
||||
|
||||
|
@ -17,3 +17,4 @@ Under the section `pr_update_changelog`, the [configuration file](https://github
|
||||
|
||||
- `push_changelog_changes`: whether to push the changes to CHANGELOG.md, or just print them. Default is false (print only).
|
||||
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...
|
||||
- `add_pr_link`: whether the model should try to add a link to the PR in the changelog. Default is true.
|
@ -285,7 +285,7 @@ To control which commands will run automatically when a new PR is opened, you ca
|
||||
[azure_devops_server]
|
||||
pr_commands = [
|
||||
"/describe",
|
||||
"/review --pr_reviewer.num_code_suggestions=0",
|
||||
"/review",
|
||||
"/improve",
|
||||
]
|
||||
```
|
||||
|
@ -13,7 +13,6 @@ from pr_agent.tools.pr_config import PRConfig
|
||||
from pr_agent.tools.pr_description import PRDescription
|
||||
from pr_agent.tools.pr_generate_labels import PRGenerateLabels
|
||||
from pr_agent.tools.pr_help_message import PRHelpMessage
|
||||
from pr_agent.tools.pr_information_from_user import PRInformationFromUser
|
||||
from pr_agent.tools.pr_line_questions import PR_LineQuestions
|
||||
from pr_agent.tools.pr_questions import PRQuestions
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
@ -25,8 +24,6 @@ command2class = {
|
||||
"answer": PRReviewer,
|
||||
"review": PRReviewer,
|
||||
"review_pr": PRReviewer,
|
||||
"reflect": PRInformationFromUser,
|
||||
"reflect_and_review": PRInformationFromUser,
|
||||
"describe": PRDescription,
|
||||
"describe_pr": PRDescription,
|
||||
"improve": PRCodeSuggestions,
|
||||
@ -49,7 +46,6 @@ commands = list(command2class.keys())
|
||||
class PRAgent:
|
||||
def __init__(self, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler):
|
||||
self.ai_handler = ai_handler # will be initialized in run_action
|
||||
self.forbidden_cli_args = ['enable_auto_approval']
|
||||
|
||||
async def handle_request(self, pr_url, request, notify=None) -> bool:
|
||||
# First, apply repo specific settings if exists
|
||||
@ -64,10 +60,13 @@ class PRAgent:
|
||||
else:
|
||||
action, *args = request
|
||||
|
||||
forbidden_cli_args = ['enable_auto_approval', 'base_url', 'url', 'app_name', 'secret_provider',
|
||||
'git_provider', 'skip_keys', 'key', 'ANALYTICS_FOLDER', 'uri', 'app_id', 'webhook_secret',
|
||||
'bearer_token', 'PERSONAL_ACCESS_TOKEN', 'override_deployment_type', 'private_key', 'api_base', 'api_type', 'api_version']
|
||||
if args:
|
||||
for forbidden_arg in self.forbidden_cli_args:
|
||||
for forbidden_arg in forbidden_cli_args:
|
||||
for arg in args:
|
||||
if forbidden_arg in arg:
|
||||
if forbidden_arg.lower() in arg.lower():
|
||||
get_logger().error(
|
||||
f"CLI argument for param '{forbidden_arg}' is forbidden. Use instead a configuration file."
|
||||
)
|
||||
@ -76,12 +75,10 @@ class PRAgent:
|
||||
|
||||
action = action.lstrip("/").lower()
|
||||
if action not in command2class:
|
||||
get_logger().debug(f"Unknown command: {action}")
|
||||
get_logger().error(f"Unknown command: {action}")
|
||||
return False
|
||||
with get_logger().contextualize(command=action, pr_url=pr_url):
|
||||
get_logger().info("PR-Agent request handler started", analytics=True)
|
||||
if action == "reflect_and_review":
|
||||
get_settings().pr_reviewer.ask_and_reflect = True
|
||||
if action == "answer":
|
||||
if notify:
|
||||
notify()
|
||||
|
@ -62,13 +62,14 @@ MAX_TOKENS = {
|
||||
'bedrock/anthropic.claude-3-5-haiku-20241022-v1:0': 100000,
|
||||
'bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0': 100000,
|
||||
'bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0': 100000,
|
||||
"bedrock/us.anthropic.claude-3-5-sonnet-20241022-v2:0": 100000,
|
||||
'claude-3-5-sonnet': 100000,
|
||||
'groq/llama3-8b-8192': 8192,
|
||||
'groq/llama3-70b-8192': 8192,
|
||||
'groq/llama-3.1-8b-instant': 8192,
|
||||
'groq/llama-3.3-70b-versatile': 128000,
|
||||
'groq/mixtral-8x7b-32768': 32768,
|
||||
'groq/llama-3.1-8b-instant': 131072,
|
||||
'groq/llama-3.1-70b-versatile': 131072,
|
||||
'groq/llama-3.1-405b-reasoning': 131072,
|
||||
'groq/gemma2-9b-it': 8192,
|
||||
'ollama/llama3': 4096,
|
||||
'watsonx/meta-llama/llama-3-8b-instruct': 4096,
|
||||
"watsonx/meta-llama/llama-3-70b-instruct": 4096,
|
||||
|
@ -364,48 +364,51 @@ __old hunk__
|
||||
|
||||
|
||||
def extract_hunk_lines_from_patch(patch: str, file_name, line_start, line_end, side) -> tuple[str, str]:
|
||||
try:
|
||||
patch_with_lines_str = f"\n\n## File: '{file_name.strip()}'\n\n"
|
||||
selected_lines = ""
|
||||
patch_lines = patch.splitlines()
|
||||
RE_HUNK_HEADER = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
||||
match = None
|
||||
start1, size1, start2, size2 = -1, -1, -1, -1
|
||||
skip_hunk = False
|
||||
selected_lines_num = 0
|
||||
for line in patch_lines:
|
||||
if 'no newline at end of file' in line.lower():
|
||||
continue
|
||||
|
||||
patch_with_lines_str = f"\n\n## File: '{file_name.strip()}'\n\n"
|
||||
selected_lines = ""
|
||||
patch_lines = patch.splitlines()
|
||||
RE_HUNK_HEADER = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
||||
match = None
|
||||
start1, size1, start2, size2 = -1, -1, -1, -1
|
||||
skip_hunk = False
|
||||
selected_lines_num = 0
|
||||
for line in patch_lines:
|
||||
if 'no newline at end of file' in line.lower():
|
||||
continue
|
||||
if line.startswith('@@'):
|
||||
skip_hunk = False
|
||||
selected_lines_num = 0
|
||||
header_line = line
|
||||
|
||||
if line.startswith('@@'):
|
||||
skip_hunk = False
|
||||
selected_lines_num = 0
|
||||
header_line = line
|
||||
match = RE_HUNK_HEADER.match(line)
|
||||
|
||||
match = RE_HUNK_HEADER.match(line)
|
||||
section_header, size1, size2, start1, start2 = extract_hunk_headers(match)
|
||||
|
||||
section_header, size1, size2, start1, start2 = extract_hunk_headers(match)
|
||||
|
||||
# check if line range is in this hunk
|
||||
if side.lower() == 'left':
|
||||
# check if line range is in this hunk
|
||||
if not (start1 <= line_start <= start1 + size1):
|
||||
skip_hunk = True
|
||||
continue
|
||||
elif side.lower() == 'right':
|
||||
if not (start2 <= line_start <= start2 + size2):
|
||||
skip_hunk = True
|
||||
continue
|
||||
patch_with_lines_str += f'\n{header_line}\n'
|
||||
if side.lower() == 'left':
|
||||
# check if line range is in this hunk
|
||||
if not (start1 <= line_start <= start1 + size1):
|
||||
skip_hunk = True
|
||||
continue
|
||||
elif side.lower() == 'right':
|
||||
if not (start2 <= line_start <= start2 + size2):
|
||||
skip_hunk = True
|
||||
continue
|
||||
patch_with_lines_str += f'\n{header_line}\n'
|
||||
|
||||
elif not skip_hunk:
|
||||
if side.lower() == 'right' and line_start <= start2 + selected_lines_num <= line_end:
|
||||
selected_lines += line + '\n'
|
||||
if side.lower() == 'left' and start1 <= selected_lines_num + start1 <= line_end:
|
||||
selected_lines += line + '\n'
|
||||
patch_with_lines_str += line + '\n'
|
||||
if not line.startswith('-'): # currently we don't support /ask line for deleted lines
|
||||
selected_lines_num += 1
|
||||
elif not skip_hunk:
|
||||
if side.lower() == 'right' and line_start <= start2 + selected_lines_num <= line_end:
|
||||
selected_lines += line + '\n'
|
||||
if side.lower() == 'left' and start1 <= selected_lines_num + start1 <= line_end:
|
||||
selected_lines += line + '\n'
|
||||
patch_with_lines_str += line + '\n'
|
||||
if not line.startswith('-'): # currently we don't support /ask line for deleted lines
|
||||
selected_lines_num += 1
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to extract hunk lines from patch: {e}", artifact={"traceback": traceback.format_exc()})
|
||||
return "", ""
|
||||
|
||||
return patch_with_lines_str.rstrip(), selected_lines.rstrip()
|
||||
|
@ -316,13 +316,13 @@ def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_mod
|
||||
# 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:
|
||||
get_logger().warning(f"Patch too large, skipping it, {filename}")
|
||||
get_logger().warning(f"Patch too large, skipping it: '{filename}'")
|
||||
remaining_files_list_new.append(filename)
|
||||
continue
|
||||
|
||||
if patch:
|
||||
if not convert_hunks_to_line_numbers:
|
||||
patch_final = f"\n\n## File: '{filename.strip()}\n\n{patch.strip()}\n'"
|
||||
patch_final = f"\n\n## File: '{filename.strip()}'\n\n{patch.strip()}\n"
|
||||
else:
|
||||
patch_final = "\n\n" + patch.strip()
|
||||
patches.append(patch_final)
|
||||
|
@ -1,5 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class EDIT_TYPE(Enum):
|
||||
@ -21,4 +22,5 @@ class FilePatchInfo:
|
||||
old_filename: str = None
|
||||
num_plus_lines: int = -1
|
||||
num_minus_lines: int = -1
|
||||
language: Optional[str] = None
|
||||
ai_file_summary: str = None
|
||||
|
@ -23,6 +23,7 @@ from pydantic import BaseModel
|
||||
from starlette_context import context
|
||||
|
||||
from pr_agent.algo import MAX_TOKENS
|
||||
from pr_agent.algo.git_patch_processing import extract_hunk_lines_from_patch
|
||||
from pr_agent.algo.token_handler import TokenEncoder
|
||||
from pr_agent.algo.types import FilePatchInfo
|
||||
from pr_agent.config_loader import get_settings, global_settings
|
||||
@ -235,7 +236,7 @@ def convert_to_markdown_v2(output_data: dict,
|
||||
start_line = int(str(issue.get('start_line', 0)).strip())
|
||||
end_line = int(str(issue.get('end_line', 0)).strip())
|
||||
|
||||
relevant_lines_str = extract_relevant_lines_str(end_line, files, relevant_file, start_line)
|
||||
relevant_lines_str = extract_relevant_lines_str(end_line, files, relevant_file, start_line, dedent=True)
|
||||
if git_provider:
|
||||
reference_link = git_provider.get_line_link(relevant_file, start_line, end_line)
|
||||
else:
|
||||
@ -270,25 +271,13 @@ def convert_to_markdown_v2(output_data: dict,
|
||||
if gfm_supported:
|
||||
markdown_text += "</table>\n"
|
||||
|
||||
if 'code_feedback' in output_data:
|
||||
if gfm_supported:
|
||||
markdown_text += f"\n\n"
|
||||
markdown_text += f"<details><summary> <strong>Code feedback:</strong></summary>\n\n"
|
||||
markdown_text += "<hr>"
|
||||
else:
|
||||
markdown_text += f"\n\n### Code feedback:\n\n"
|
||||
for i, value in enumerate(output_data['code_feedback']):
|
||||
if value is None or value == '' or value == {} or value == []:
|
||||
continue
|
||||
markdown_text += parse_code_suggestion(value, i, gfm_supported)+"\n\n"
|
||||
if markdown_text.endswith('<hr>'):
|
||||
markdown_text= markdown_text[:-4]
|
||||
if gfm_supported:
|
||||
markdown_text += f"</details>"
|
||||
|
||||
return markdown_text
|
||||
|
||||
def extract_relevant_lines_str(end_line, files, relevant_file, start_line):
|
||||
|
||||
def extract_relevant_lines_str(end_line, files, relevant_file, start_line, dedent=False) -> str:
|
||||
"""
|
||||
Finds 'relevant_file' in 'files', and extracts the lines from 'start_line' to 'end_line' string from the file content.
|
||||
"""
|
||||
try:
|
||||
relevant_lines_str = ""
|
||||
if files:
|
||||
@ -296,12 +285,29 @@ def extract_relevant_lines_str(end_line, files, relevant_file, start_line):
|
||||
for file in files:
|
||||
if file.filename.strip() == relevant_file:
|
||||
if not file.head_file:
|
||||
get_logger().warning(f"No content found in file: {file.filename}")
|
||||
return ""
|
||||
relevant_file_lines = file.head_file.splitlines()
|
||||
relevant_lines_str = "\n".join(relevant_file_lines[start_line - 1:end_line])
|
||||
# as a fallback, extract relevant lines directly from patch
|
||||
patch = file.patch
|
||||
get_logger().info(f"No content found in file: '{file.filename}' for 'extract_relevant_lines_str'. Using patch instead")
|
||||
_, selected_lines = extract_hunk_lines_from_patch(patch, file.filename, start_line, end_line,side='right')
|
||||
if not selected_lines:
|
||||
get_logger().error(f"Failed to extract relevant lines from patch: {file.filename}")
|
||||
return ""
|
||||
# filter out '-' lines
|
||||
relevant_lines_str = ""
|
||||
for line in selected_lines.splitlines():
|
||||
if line.startswith('-'):
|
||||
continue
|
||||
relevant_lines_str += line[1:] + '\n'
|
||||
else:
|
||||
relevant_file_lines = file.head_file.splitlines()
|
||||
relevant_lines_str = "\n".join(relevant_file_lines[start_line - 1:end_line])
|
||||
|
||||
if dedent and relevant_lines_str:
|
||||
# Remove the longest leading string of spaces and tabs common to all lines.
|
||||
relevant_lines_str = textwrap.dedent(relevant_lines_str)
|
||||
relevant_lines_str = f"```{file.language}\n{relevant_lines_str}\n```"
|
||||
break
|
||||
|
||||
return relevant_lines_str
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to extract relevant lines: {e}")
|
||||
@ -577,27 +583,20 @@ def load_large_diff(filename, new_file_content_str: str, original_file_content_s
|
||||
"""
|
||||
Generate a patch for a modified file by comparing the original content of the file with the new content provided as
|
||||
input.
|
||||
|
||||
Args:
|
||||
new_file_content_str: The new content of the file as a string.
|
||||
original_file_content_str: The original content of the file as a string.
|
||||
|
||||
Returns:
|
||||
The generated or provided patch string.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
patch = ""
|
||||
if not original_file_content_str and not new_file_content_str:
|
||||
return ""
|
||||
|
||||
try:
|
||||
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 and show_warning:
|
||||
get_logger().warning(f"File was modified, but no patch was found. Manually creating patch: {filename}.")
|
||||
get_logger().info(f"File was modified, but no patch was found. Manually creating patch: {filename}.")
|
||||
patch = ''.join(diff)
|
||||
except Exception:
|
||||
pass
|
||||
return patch
|
||||
return patch
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to generate patch for file: {filename}")
|
||||
return ""
|
||||
|
||||
|
||||
def update_settings_from_args(args: List[str]) -> List[str]:
|
||||
|
@ -174,6 +174,24 @@ class GithubProvider(GitProvider):
|
||||
|
||||
diff_files = []
|
||||
invalid_files_names = []
|
||||
is_close_to_rate_limit = False
|
||||
|
||||
# The base.sha will point to the current state of the base branch (including parallel merges), not the original base commit when the PR was created
|
||||
# We can fix this by finding the merge base commit between the PR head and base branches
|
||||
# Note that The pr.head.sha is actually correct as is - it points to the latest commit in your PR branch.
|
||||
# This SHA isn't affected by parallel merges to the base branch since it's specific to your PR's branch.
|
||||
repo = self.repo_obj
|
||||
pr = self.pr
|
||||
try:
|
||||
compare = repo.compare(pr.base.sha, pr.head.sha) # communication with GitHub
|
||||
merge_base_commit = compare.merge_base_commit
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to get merge base commit: {e}")
|
||||
merge_base_commit = pr.base
|
||||
if merge_base_commit.sha != pr.base.sha:
|
||||
get_logger().info(
|
||||
f"Using merge base commit {merge_base_commit.sha} instead of base commit ")
|
||||
|
||||
counter_valid = 0
|
||||
for file in files:
|
||||
if not is_valid_file(file.filename):
|
||||
@ -181,48 +199,36 @@ class GithubProvider(GitProvider):
|
||||
continue
|
||||
|
||||
patch = file.patch
|
||||
|
||||
# allow only a limited number of files to be fully loaded. We can manage the rest with diffs only
|
||||
counter_valid += 1
|
||||
avoid_load = False
|
||||
if counter_valid >= MAX_FILES_ALLOWED_FULL and patch and not self.incremental.is_incremental:
|
||||
avoid_load = True
|
||||
if counter_valid == MAX_FILES_ALLOWED_FULL:
|
||||
get_logger().info(f"Too many files in PR, will avoid loading full content for rest of files")
|
||||
|
||||
if avoid_load:
|
||||
if is_close_to_rate_limit:
|
||||
new_file_content_str = ""
|
||||
original_file_content_str = ""
|
||||
else:
|
||||
new_file_content_str = self._get_pr_file_content(file, self.pr.head.sha) # communication with GitHub
|
||||
# allow only a limited number of files to be fully loaded. We can manage the rest with diffs only
|
||||
counter_valid += 1
|
||||
avoid_load = False
|
||||
if counter_valid >= MAX_FILES_ALLOWED_FULL and patch and not self.incremental.is_incremental:
|
||||
avoid_load = True
|
||||
if counter_valid == MAX_FILES_ALLOWED_FULL:
|
||||
get_logger().info(f"Too many files in PR, will avoid loading full content for rest of files")
|
||||
|
||||
if self.incremental.is_incremental and self.unreviewed_files_set:
|
||||
original_file_content_str = self._get_pr_file_content(file, self.incremental.last_seen_commit_sha)
|
||||
patch = load_large_diff(file.filename, new_file_content_str, original_file_content_str)
|
||||
self.unreviewed_files_set[file.filename] = patch
|
||||
else:
|
||||
if avoid_load:
|
||||
original_file_content_str = ""
|
||||
new_file_content_str = ""
|
||||
else:
|
||||
# The base.sha will point to the current state of the base branch (including parallel merges), not the original base commit when the PR was created
|
||||
# We can fix this by finding the merge base commit between the PR head and base branches
|
||||
# Note that The pr.head.sha is actually correct as is - it points to the latest commit in your PR branch.
|
||||
# This SHA isn't affected by parallel merges to the base branch since it's specific to your PR's branch.
|
||||
repo = self.repo_obj
|
||||
pr = self.pr
|
||||
try:
|
||||
compare = repo.compare(pr.base.sha, pr.head.sha)
|
||||
merge_base_commit = compare.merge_base_commit
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to get merge base commit: {e}")
|
||||
merge_base_commit = pr.base
|
||||
if merge_base_commit.sha != pr.base.sha:
|
||||
get_logger().info(
|
||||
f"Using merge base commit {merge_base_commit.sha} instead of base commit "
|
||||
f"{pr.base.sha} for {file.filename}")
|
||||
original_file_content_str = self._get_pr_file_content(file, merge_base_commit.sha)
|
||||
new_file_content_str = self._get_pr_file_content(file, self.pr.head.sha) # communication with GitHub
|
||||
|
||||
if not patch:
|
||||
if self.incremental.is_incremental and self.unreviewed_files_set:
|
||||
original_file_content_str = self._get_pr_file_content(file, self.incremental.last_seen_commit_sha)
|
||||
patch = load_large_diff(file.filename, new_file_content_str, original_file_content_str)
|
||||
self.unreviewed_files_set[file.filename] = patch
|
||||
else:
|
||||
if avoid_load:
|
||||
original_file_content_str = ""
|
||||
else:
|
||||
original_file_content_str = self._get_pr_file_content(file, merge_base_commit.sha)
|
||||
# original_file_content_str = self._get_pr_file_content(file, self.pr.base.sha)
|
||||
if not patch:
|
||||
patch = load_large_diff(file.filename, new_file_content_str, original_file_content_str)
|
||||
|
||||
|
||||
if file.status == 'added':
|
||||
edit_type = EDIT_TYPE.ADDED
|
||||
@ -237,9 +243,14 @@ class GithubProvider(GitProvider):
|
||||
edit_type = EDIT_TYPE.UNKNOWN
|
||||
|
||||
# count number of lines added and removed
|
||||
patch_lines = patch.splitlines(keepends=True)
|
||||
num_plus_lines = len([line for line in patch_lines if line.startswith('+')])
|
||||
num_minus_lines = len([line for line in patch_lines if line.startswith('-')])
|
||||
if hasattr(file, 'additions') and hasattr(file, 'deletions'):
|
||||
num_plus_lines = file.additions
|
||||
num_minus_lines = file.deletions
|
||||
else:
|
||||
patch_lines = patch.splitlines(keepends=True)
|
||||
num_plus_lines = len([line for line in patch_lines if line.startswith('+')])
|
||||
num_minus_lines = len([line for line in patch_lines if line.startswith('-')])
|
||||
|
||||
file_patch_canonical_structure = FilePatchInfo(original_file_content_str, new_file_content_str, patch,
|
||||
file.filename, edit_type=edit_type,
|
||||
num_plus_lines=num_plus_lines,
|
||||
|
@ -55,9 +55,6 @@ require_can_be_split_review=false
|
||||
require_security_review=true
|
||||
require_ticket_analysis_review=true
|
||||
# general options
|
||||
num_code_suggestions=0 # legacy mode. use the `improve` command instead
|
||||
inline_code_comments = false
|
||||
ask_and_reflect=false
|
||||
persistent_comment=true
|
||||
extra_instructions = ""
|
||||
final_update_message = true
|
||||
@ -91,6 +88,7 @@ publish_description_as_comment_persistent=true
|
||||
## changes walkthrough section
|
||||
enable_semantic_files_types=true
|
||||
collapsible_file_list='adaptive' # true, false, 'adaptive'
|
||||
collapsible_file_list_threshold=8
|
||||
inline_file_summary=false # false, true, 'table'
|
||||
# markers
|
||||
use_description_markers=false
|
||||
@ -99,7 +97,6 @@ include_generated_by_header=true
|
||||
enable_large_pr_handling=true
|
||||
max_ai_calls=4
|
||||
async_ai_calls=true
|
||||
mention_extra_files=true
|
||||
#custom_labels = ['Bug fix', 'Tests', 'Bug fix with tests', 'Enhancement', 'Documentation', 'Other']
|
||||
|
||||
[pr_questions] # /ask #
|
||||
@ -162,6 +159,7 @@ class_name = "" # in case there are several methods with the same name in
|
||||
[pr_update_changelog] # /update_changelog #
|
||||
push_changelog_changes=false
|
||||
extra_instructions = ""
|
||||
add_pr_link=true
|
||||
|
||||
[pr_analyze] # /analyze #
|
||||
enable_help_text=true
|
||||
|
@ -9,7 +9,7 @@ Your task is to provide a full description for the PR content - files walkthroug
|
||||
- Keep in mind that the 'Previous title', 'Previous description' and 'Commit messages' sections may be partial, simplistic, non-informative or out of date. Hence, compare them to the PR diff code, and use them only as a reference.
|
||||
- The generated title and description should prioritize the most significant changes.
|
||||
- If needed, each YAML output should be in block scalar indicator ('|-')
|
||||
- When quoting variables or names from the code, use backticks (`) instead of single quote (').
|
||||
- When quoting variables, names or file paths from the code, use backticks (`) instead of single quote (').
|
||||
|
||||
{%- if extra_instructions %}
|
||||
|
||||
@ -38,23 +38,22 @@ class PRType(str, Enum):
|
||||
{%- if enable_semantic_files_types %}
|
||||
|
||||
class FileDescription(BaseModel):
|
||||
filename: str = Field(description="The full file path of the relevant file.")
|
||||
language: str = Field(description="The programming language of the relevant file.")
|
||||
filename: str = Field(description="The full file path of the relevant file")
|
||||
language: str = Field(description="The programming language of the relevant file")
|
||||
{%- if include_file_summary_changes %}
|
||||
changes_summary: str = Field(description="concise summary of the changes in the relevant file, in bullet points (1-4 bullet points).")
|
||||
changes_title: str = Field(description="an informative title for the changes in the files, describing its main theme (5-10 words).")
|
||||
{%- endif %}
|
||||
changes_title: str = Field(description="one-line summary (5-10 words) capturing the main theme of changes in the file")
|
||||
label: str = Field(description="a single semantic label that represents a type of code changes that occurred in the File. Possible values (partial list): 'bug fix', 'tests', 'enhancement', 'documentation', 'error handling', 'configuration changes', 'dependencies', 'formatting', 'miscellaneous', ...")
|
||||
{%- endif %}
|
||||
|
||||
class PRDescription(BaseModel):
|
||||
type: List[PRType] = Field(description="one or more types that describe the PR content. Return the label member value (e.g. 'Bug fix', not 'bug_fix')")
|
||||
{%- if enable_semantic_files_types %}
|
||||
pr_files: List[FileDescription] = Field(max_items=15, description="a list of the files in the PR, and summary of their changes")
|
||||
pr_files: List[FileDescription] = Field(max_items=20, description="a list of all the files that were changed in the PR, and summary of their changes. Each file must be analyzed regardless of change size.")
|
||||
{%- endif %}
|
||||
description: str = Field(description="an informative and concise description of the PR. Use bullet points. Display first the most significant changes.")
|
||||
title: str = Field(description="an informative title for the PR, describing its main theme")
|
||||
{%- if enable_custom_labels %}
|
||||
labels: List[Label] = Field(min_items=0, description="choose the relevant custom labels that describe the PR content, and return their keys. Use the value field of the Label object to better understand the label meaning.")
|
||||
{%- endif %}
|
||||
=====
|
||||
|
||||
|
||||
@ -70,25 +69,20 @@ pr_files:
|
||||
...
|
||||
language: |
|
||||
...
|
||||
{%- if include_file_summary_changes %}
|
||||
changes_summary: |
|
||||
...
|
||||
{%- endif %}
|
||||
changes_title: |
|
||||
...
|
||||
label: |
|
||||
...
|
||||
label_key_1
|
||||
...
|
||||
{%- endif %}
|
||||
description: |
|
||||
...
|
||||
title: |
|
||||
...
|
||||
{%- if enable_custom_labels %}
|
||||
labels:
|
||||
- |
|
||||
...
|
||||
- |
|
||||
...
|
||||
{%- endif %}
|
||||
```
|
||||
|
||||
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|')
|
||||
|
@ -1,10 +1,6 @@
|
||||
[pr_review_prompt]
|
||||
system="""You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
Your task is to provide constructive and concise feedback for the PR, and also provide meaningful code suggestions.
|
||||
{%- else %}
|
||||
Your task is to provide constructive and concise feedback for the PR.
|
||||
{%- endif %}
|
||||
The review should focus on new code added in the PR code diff (lines starting with '+')
|
||||
|
||||
|
||||
@ -49,16 +45,6 @@ __new hunk__
|
||||
{%- endif %}
|
||||
- When quoting variables or names from the code, use backticks (`) instead of single quote (').
|
||||
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
|
||||
|
||||
Code suggestions guidelines:
|
||||
- Provide up to {{ num_code_suggestions }} code suggestions. Try to provide diverse and insightful suggestions.
|
||||
- Focus on important suggestions like fixing code problems, issues and bugs. As a second priority, provide suggestions for meaningful code improvements, like performance, vulnerability, modularity, and best practices.
|
||||
- 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 PR code.
|
||||
- Don't suggest to add docstring, type hints, or comments.
|
||||
- Suggestions should address the new code added in the PR diff (lines starting with '+')
|
||||
{%- endif %}
|
||||
|
||||
{%- if extra_instructions %}
|
||||
|
||||
@ -118,25 +104,9 @@ class Review(BaseModel):
|
||||
{%- if require_can_be_split_review %}
|
||||
can_be_split: List[SubPR] = Field(min_items=0, max_items=3, description="Can this PR, which contains {{ num_pr_files }} changed files in total, be divided into smaller sub-PRs with distinct tasks that can be reviewed and merged independently, regardless of the order ? Make sure that the sub-PRs are indeed independent, with no code dependencies between them, and that each sub-PR represent a meaningful independent task. Output an empty list if the PR code does not need to be split.")
|
||||
{%- endif %}
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
|
||||
class CodeSuggestion(BaseModel):
|
||||
relevant_file: str = Field(description="The full file path of the relevant file")
|
||||
language: str = Field(description="The programming language of the relevant file")
|
||||
suggestion: str = Field(description="a concrete suggestion for meaningfully improving the new PR code. Also describe how, specifically, the suggestion can be applied to new PR code. Add tags with importance measure that matches each suggestion ('important' or 'medium'). Do not make suggestions for updating or adding docstrings, renaming PR title and description, or linter like.")
|
||||
relevant_line: str = Field(description="a single code line taken from the relevant file, to which the suggestion applies. The code line should start with a '+'. Make sure to output the line exactly as it appears in the relevant file")
|
||||
{%- endif %}
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
|
||||
class PRReview(BaseModel):
|
||||
review: Review
|
||||
code_feedback: List[CodeSuggestion]
|
||||
{%- else %}
|
||||
|
||||
|
||||
class PRReview(BaseModel):
|
||||
review: Review
|
||||
{%- endif %}
|
||||
=====
|
||||
|
||||
|
||||
@ -185,18 +155,6 @@ review:
|
||||
title: ...
|
||||
- ...
|
||||
{%- endif %}
|
||||
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
code_feedback:
|
||||
- relevant_file: |
|
||||
directory/xxx.py
|
||||
language: |
|
||||
python
|
||||
suggestion: |
|
||||
xxx [important]
|
||||
relevant_line: |
|
||||
xxx
|
||||
{%- endif %}
|
||||
```
|
||||
|
||||
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|')
|
||||
|
@ -1,9 +1,14 @@
|
||||
[pr_update_changelog_prompt]
|
||||
system="""You are a language model called PR-Changelog-Updater.
|
||||
Your task is to update the CHANGELOG.md file of the project, to shortly summarize important changes introduced in this PR (the '+' lines).
|
||||
- The output should match the existing CHANGELOG.md format, style and conventions, so it will look like a natural part of the file. For example, if previous changes were summarized in a single line, you should do the same.
|
||||
- Don't repeat previous changes. Generate only new content, that is not already in the CHANGELOG.md file.
|
||||
- Be general, and avoid specific details, files, etc. The output should be minimal, no more than 3-4 short lines. Ignore non-relevant subsections.
|
||||
Your task is to add a brief summary of this PR's changes to CHANGELOG.md file of the project:
|
||||
- Follow the file's existing format and style conventions like dates, section titles, etc.
|
||||
- Only add new changes (don't repeat existing entries)
|
||||
- Be general, and avoid specific details, files, etc. The output should be minimal, no more than 3-4 short lines.
|
||||
- Write only the new content to be added to CHANGELOG.md, without any introduction or summary. The content should appear as if it's a natural part of the existing file.
|
||||
{%- if pr_link %}
|
||||
- If relevant, convert the changelog main header into a clickable link using the PR URL '{{ pr_link }}'. Format: header [*][pr_link]
|
||||
{%- endif %}
|
||||
|
||||
|
||||
{%- if extra_instructions %}
|
||||
|
||||
@ -47,16 +52,19 @@ The PR Git Diff:
|
||||
{{ diff|trim }}
|
||||
======
|
||||
|
||||
|
||||
Current date:
|
||||
```
|
||||
{{today}}
|
||||
```
|
||||
|
||||
The current CHANGELOG.md:
|
||||
|
||||
The current 'CHANGELOG.md' file
|
||||
======
|
||||
{{ changelog_file_str }}
|
||||
======
|
||||
|
||||
|
||||
Response:
|
||||
```markdown
|
||||
"""
|
||||
|
@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import re
|
||||
import traceback
|
||||
from functools import partial
|
||||
from typing import List, Tuple
|
||||
|
||||
@ -57,6 +58,7 @@ class PRDescription:
|
||||
self.ai_handler.main_pr_language = self.main_pr_language
|
||||
|
||||
# Initialize the variables dictionary
|
||||
self.COLLAPSIBLE_FILE_LIST_THRESHOLD = get_settings().pr_description.get("collapsible_file_list_threshold", 8)
|
||||
self.vars = {
|
||||
"title": self.git_provider.pr.title,
|
||||
"branch": self.git_provider.get_pr_branch(),
|
||||
@ -69,6 +71,7 @@ class PRDescription:
|
||||
"custom_labels_class": "", # will be filled if necessary in 'set_custom_labels' function
|
||||
"enable_semantic_files_types": get_settings().pr_description.enable_semantic_files_types,
|
||||
"related_tickets": "",
|
||||
"include_file_summary_changes": len(self.git_provider.get_diff_files()) <= self.COLLAPSIBLE_FILE_LIST_THRESHOLD
|
||||
}
|
||||
|
||||
self.user_description = self.git_provider.get_user_description()
|
||||
@ -85,7 +88,6 @@ class PRDescription:
|
||||
self.patches_diff = None
|
||||
self.prediction = None
|
||||
self.file_label_dict = None
|
||||
self.COLLAPSIBLE_FILE_LIST_THRESHOLD = 8
|
||||
|
||||
async def run(self):
|
||||
try:
|
||||
@ -114,6 +116,8 @@ class PRDescription:
|
||||
pr_labels, pr_file_changes = [], []
|
||||
if get_settings().pr_description.publish_labels:
|
||||
pr_labels = self._prepare_labels()
|
||||
else:
|
||||
get_logger().debug(f"Publishing labels disabled")
|
||||
|
||||
if get_settings().pr_description.use_description_markers:
|
||||
pr_title, pr_body, changes_walkthrough, pr_file_changes = self._prepare_pr_answer_with_markers()
|
||||
@ -137,6 +141,7 @@ class PRDescription:
|
||||
pr_body += show_relevant_configurations(relevant_section='pr_description')
|
||||
|
||||
if get_settings().config.publish_output:
|
||||
|
||||
# publish labels
|
||||
if get_settings().pr_description.publish_labels and pr_labels and self.git_provider.is_supported("get_labels"):
|
||||
original_labels = self.git_provider.get_pr_labels(update=True)
|
||||
@ -164,7 +169,7 @@ class PRDescription:
|
||||
self.git_provider.publish_description(pr_title, pr_body)
|
||||
|
||||
# publish final update message
|
||||
if (get_settings().pr_description.final_update_message):
|
||||
if (get_settings().pr_description.final_update_message and not get_settings().config.get('is_auto_command', False)):
|
||||
latest_commit_url = self.git_provider.get_latest_commit_url()
|
||||
if latest_commit_url:
|
||||
pr_url = self.git_provider.get_pr_url()
|
||||
@ -176,35 +181,37 @@ class PRDescription:
|
||||
get_settings().data = {"artifact": pr_body}
|
||||
return
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error generating PR description {self.pr_id}: {e}")
|
||||
get_logger().error(f"Error generating PR description {self.pr_id}: {e}",
|
||||
artifact={"traceback": traceback.format_exc()})
|
||||
|
||||
return ""
|
||||
|
||||
async def _prepare_prediction(self, model: str) -> None:
|
||||
if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description:
|
||||
get_logger().info(
|
||||
"Markers were enabled, but user description does not contain markers. skipping AI prediction")
|
||||
get_logger().info("Markers were enabled, but user description does not contain markers. skipping AI prediction")
|
||||
return None
|
||||
|
||||
large_pr_handling = get_settings().pr_description.enable_large_pr_handling and "pr_description_only_files_prompts" in get_settings()
|
||||
output = get_pr_diff(self.git_provider, self.token_handler, model, large_pr_handling=large_pr_handling,
|
||||
return_remaining_files=True)
|
||||
output = get_pr_diff(self.git_provider, self.token_handler, model, large_pr_handling=large_pr_handling, return_remaining_files=True)
|
||||
if isinstance(output, tuple):
|
||||
patches_diff, remaining_files_list = output
|
||||
else:
|
||||
patches_diff = output
|
||||
remaining_files_list = []
|
||||
|
||||
if not large_pr_handling or patches_diff:
|
||||
self.patches_diff = patches_diff
|
||||
if patches_diff:
|
||||
# generate the prediction
|
||||
get_logger().debug(f"PR diff", artifact=self.patches_diff)
|
||||
self.prediction = await self._get_prediction(model, patches_diff, prompt="pr_description_prompt")
|
||||
if (remaining_files_list and 'pr_files' in self.prediction and 'label:' in self.prediction and
|
||||
get_settings().pr_description.mention_extra_files):
|
||||
get_logger().debug(f"Extending additional files, {len(remaining_files_list)} files")
|
||||
self.prediction = await self.extend_additional_files(remaining_files_list)
|
||||
|
||||
# extend the prediction with additional files not shown
|
||||
if get_settings().pr_description.enable_semantic_files_types:
|
||||
self.prediction = await self.extend_uncovered_files(self.prediction)
|
||||
else:
|
||||
get_logger().error(f"Error getting PR diff {self.pr_id}")
|
||||
get_logger().error(f"Error getting PR diff {self.pr_id}",
|
||||
artifact={"traceback": traceback.format_exc()})
|
||||
self.prediction = None
|
||||
else:
|
||||
# get the diff in multiple patches, with the token handler only for the files prompt
|
||||
@ -289,43 +296,81 @@ class PRDescription:
|
||||
prompt="pr_description_only_description_prompts")
|
||||
prediction_headers = prediction_headers.strip().removeprefix('```yaml').strip('`').strip()
|
||||
|
||||
# manually add extra files to final prediction
|
||||
MAX_EXTRA_FILES_TO_OUTPUT = 100
|
||||
if get_settings().pr_description.mention_extra_files:
|
||||
for i, file in enumerate(remaining_files_list):
|
||||
extra_file_yaml = f"""\
|
||||
- filename: |
|
||||
{file}
|
||||
changes_summary: |
|
||||
...
|
||||
changes_title: |
|
||||
...
|
||||
label: |
|
||||
additional files (token-limit)
|
||||
"""
|
||||
files_walkthrough = files_walkthrough.strip() + "\n" + extra_file_yaml.strip()
|
||||
if i >= MAX_EXTRA_FILES_TO_OUTPUT:
|
||||
files_walkthrough += f"""\
|
||||
extra_file_yaml =
|
||||
- filename: |
|
||||
Additional {len(remaining_files_list) - MAX_EXTRA_FILES_TO_OUTPUT} files not shown
|
||||
changes_summary: |
|
||||
...
|
||||
changes_title: |
|
||||
...
|
||||
label: |
|
||||
additional files (token-limit)
|
||||
"""
|
||||
break
|
||||
# extend the tables with the files not shown
|
||||
files_walkthrough_extended = await self.extend_uncovered_files(files_walkthrough)
|
||||
|
||||
# final processing
|
||||
self.prediction = prediction_headers + "\n" + "pr_files:\n" + files_walkthrough
|
||||
self.prediction = prediction_headers + "\n" + "pr_files:\n" + files_walkthrough_extended
|
||||
if not load_yaml(self.prediction, keys_fix_yaml=self.keys_fix):
|
||||
get_logger().error(f"Error getting valid YAML in large PR handling for describe {self.pr_id}")
|
||||
if load_yaml(prediction_headers, keys_fix_yaml=self.keys_fix):
|
||||
get_logger().debug(f"Using only headers for describe {self.pr_id}")
|
||||
self.prediction = prediction_headers
|
||||
|
||||
async def extend_uncovered_files(self, original_prediction: str) -> str:
|
||||
try:
|
||||
prediction = original_prediction
|
||||
|
||||
# get the original prediction filenames
|
||||
original_prediction_loaded = load_yaml(original_prediction, keys_fix_yaml=self.keys_fix)
|
||||
if isinstance(original_prediction_loaded, list):
|
||||
original_prediction_dict = {"pr_files": original_prediction_loaded}
|
||||
else:
|
||||
original_prediction_dict = original_prediction_loaded
|
||||
filenames_predicted = [file['filename'].strip() for file in original_prediction_dict.get('pr_files', [])]
|
||||
|
||||
# extend the prediction with additional files not included in the original prediction
|
||||
pr_files = self.git_provider.get_diff_files()
|
||||
prediction_extra = "pr_files:"
|
||||
MAX_EXTRA_FILES_TO_OUTPUT = 100
|
||||
counter_extra_files = 0
|
||||
for file in pr_files:
|
||||
if file.filename in filenames_predicted:
|
||||
continue
|
||||
|
||||
# add up to MAX_EXTRA_FILES_TO_OUTPUT files
|
||||
counter_extra_files += 1
|
||||
if counter_extra_files > MAX_EXTRA_FILES_TO_OUTPUT:
|
||||
extra_file_yaml = f"""\
|
||||
- filename: |
|
||||
Additional files not shown
|
||||
changes_title: |
|
||||
...
|
||||
label: |
|
||||
additional files
|
||||
"""
|
||||
prediction_extra = prediction_extra + "\n" + extra_file_yaml.strip()
|
||||
get_logger().debug(f"Too many remaining files, clipping to {MAX_EXTRA_FILES_TO_OUTPUT}")
|
||||
break
|
||||
|
||||
extra_file_yaml = f"""\
|
||||
- filename: |
|
||||
{file.filename}
|
||||
changes_title: |
|
||||
...
|
||||
label: |
|
||||
additional files
|
||||
"""
|
||||
prediction_extra = prediction_extra + "\n" + extra_file_yaml.strip()
|
||||
|
||||
# merge the two dictionaries
|
||||
if counter_extra_files > 0:
|
||||
get_logger().info(f"Adding {counter_extra_files} unprocessed extra files to table prediction")
|
||||
prediction_extra_dict = load_yaml(prediction_extra, keys_fix_yaml=self.keys_fix)
|
||||
if isinstance(original_prediction_dict, dict) and isinstance(prediction_extra_dict, dict):
|
||||
original_prediction_dict["pr_files"].extend(prediction_extra_dict["pr_files"])
|
||||
new_yaml = yaml.dump(original_prediction_dict)
|
||||
if load_yaml(new_yaml, keys_fix_yaml=self.keys_fix):
|
||||
prediction = new_yaml
|
||||
if isinstance(original_prediction, list):
|
||||
prediction = yaml.dump(original_prediction_dict["pr_files"])
|
||||
|
||||
return prediction
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error extending uncovered files {self.pr_id}: {e}")
|
||||
return original_prediction
|
||||
|
||||
|
||||
async def extend_additional_files(self, remaining_files_list) -> str:
|
||||
prediction = self.prediction
|
||||
try:
|
||||
@ -397,31 +442,31 @@ extra_file_yaml =
|
||||
self.data['pr_files'] = self.data.pop('pr_files')
|
||||
|
||||
def _prepare_labels(self) -> List[str]:
|
||||
pr_types = []
|
||||
pr_labels = []
|
||||
|
||||
# If the 'PR Type' key is present in the dictionary, split its value by comma and assign it to 'pr_types'
|
||||
if 'labels' in self.data:
|
||||
if 'labels' in self.data and self.data['labels']:
|
||||
if type(self.data['labels']) == list:
|
||||
pr_types = self.data['labels']
|
||||
pr_labels = self.data['labels']
|
||||
elif type(self.data['labels']) == str:
|
||||
pr_types = self.data['labels'].split(',')
|
||||
elif 'type' in self.data:
|
||||
pr_labels = self.data['labels'].split(',')
|
||||
elif 'type' in self.data and self.data['type'] and get_settings().pr_description.publish_labels:
|
||||
if type(self.data['type']) == list:
|
||||
pr_types = self.data['type']
|
||||
pr_labels = self.data['type']
|
||||
elif type(self.data['type']) == str:
|
||||
pr_types = self.data['type'].split(',')
|
||||
pr_types = [label.strip() for label in pr_types]
|
||||
pr_labels = self.data['type'].split(',')
|
||||
pr_labels = [label.strip() for label in pr_labels]
|
||||
|
||||
# convert lowercase labels to original case
|
||||
try:
|
||||
if "labels_minimal_to_labels_dict" in self.variables:
|
||||
d: dict = self.variables["labels_minimal_to_labels_dict"]
|
||||
for i, label_i in enumerate(pr_types):
|
||||
for i, label_i in enumerate(pr_labels):
|
||||
if label_i in d:
|
||||
pr_types[i] = d[label_i]
|
||||
pr_labels[i] = d[label_i]
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error converting labels to original case {self.pr_id}: {e}")
|
||||
return pr_types
|
||||
return pr_labels
|
||||
|
||||
def _prepare_pr_answer_with_markers(self) -> Tuple[str, str, str, List[dict]]:
|
||||
get_logger().info(f"Using description marker replacements {self.pr_id}")
|
||||
@ -528,14 +573,18 @@ extra_file_yaml =
|
||||
return file_label_dict
|
||||
for file in self.data['pr_files']:
|
||||
try:
|
||||
required_fields = ['changes_summary', 'changes_title', 'filename', 'label']
|
||||
required_fields = ['changes_title', 'filename', 'label']
|
||||
if not all(field in file for field in required_fields):
|
||||
# can happen for example if a YAML generation was interrupted in the middle (no more tokens)
|
||||
get_logger().warning(f"Missing required fields in file label dict {self.pr_id}, skipping file",
|
||||
artifact={"file": file})
|
||||
continue
|
||||
if not file.get('changes_title'):
|
||||
get_logger().warning(f"Empty changes title or summary in file label dict {self.pr_id}, skipping file",
|
||||
artifact={"file": file})
|
||||
continue
|
||||
filename = file['filename'].replace("'", "`").replace('"', '`')
|
||||
changes_summary = file['changes_summary']
|
||||
changes_summary = file.get('changes_summary', "").strip()
|
||||
changes_title = file['changes_title'].strip()
|
||||
label = file.get('label').strip().lower()
|
||||
if label not in file_label_dict:
|
||||
@ -578,12 +627,14 @@ extra_file_yaml =
|
||||
for filename, file_changes_title, file_change_description in list_tuples:
|
||||
filename = filename.replace("'", "`").rstrip()
|
||||
filename_publish = filename.split("/")[-1]
|
||||
|
||||
file_changes_title_code = f"<code>{file_changes_title}</code>"
|
||||
file_changes_title_code_br = insert_br_after_x_chars(file_changes_title_code, x=(delta - 5)).strip()
|
||||
if len(file_changes_title_code_br) < (delta - 5):
|
||||
file_changes_title_code_br += " " * ((delta - 5) - len(file_changes_title_code_br))
|
||||
filename_publish = f"<strong>{filename_publish}</strong><dd>{file_changes_title_code_br}</dd>"
|
||||
if file_changes_title and file_changes_title.strip() != "...":
|
||||
file_changes_title_code = f"<code>{file_changes_title}</code>"
|
||||
file_changes_title_code_br = insert_br_after_x_chars(file_changes_title_code, x=(delta - 5)).strip()
|
||||
if len(file_changes_title_code_br) < (delta - 5):
|
||||
file_changes_title_code_br += " " * ((delta - 5) - len(file_changes_title_code_br))
|
||||
filename_publish = f"<strong>{filename_publish}</strong><dd>{file_changes_title_code_br}</dd>"
|
||||
else:
|
||||
filename_publish = f"<strong>{filename_publish}</strong>"
|
||||
diff_plus_minus = ""
|
||||
delta_nbsp = ""
|
||||
diff_files = self.git_provider.get_diff_files()
|
||||
@ -592,6 +643,8 @@ extra_file_yaml =
|
||||
num_plus_lines = f.num_plus_lines
|
||||
num_minus_lines = f.num_minus_lines
|
||||
diff_plus_minus += f"+{num_plus_lines}/-{num_minus_lines}"
|
||||
if len(diff_plus_minus) > 12 or diff_plus_minus == "+0/-0":
|
||||
diff_plus_minus = "[link]"
|
||||
delta_nbsp = " " * max(0, (8 - len(diff_plus_minus)))
|
||||
break
|
||||
|
||||
@ -600,9 +653,40 @@ extra_file_yaml =
|
||||
if hasattr(self.git_provider, 'get_line_link'):
|
||||
filename = filename.strip()
|
||||
link = self.git_provider.get_line_link(filename, relevant_line_start=-1)
|
||||
if (not link or not diff_plus_minus) and ('additional files' not in filename.lower()):
|
||||
get_logger().warning(f"Error getting line link for '{filename}'")
|
||||
continue
|
||||
|
||||
# Add file data to the PR body
|
||||
file_change_description_br = insert_br_after_x_chars(file_change_description, x=(delta - 5))
|
||||
pr_body += f"""
|
||||
pr_body = self.add_file_data(delta_nbsp, diff_plus_minus, file_change_description_br, filename,
|
||||
filename_publish, link, pr_body)
|
||||
|
||||
# Close the collapsible file list
|
||||
if use_collapsible_file_list:
|
||||
pr_body += """</table></details></td></tr>"""
|
||||
else:
|
||||
pr_body += """</table></td></tr>"""
|
||||
pr_body += """</tr></tbody></table>"""
|
||||
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error processing pr files to markdown {self.pr_id}: {str(e)}")
|
||||
pass
|
||||
return pr_body, pr_comments
|
||||
|
||||
def add_file_data(self, delta_nbsp, diff_plus_minus, file_change_description_br, filename, filename_publish, link,
|
||||
pr_body) -> str:
|
||||
|
||||
if not file_change_description_br:
|
||||
pr_body += f"""
|
||||
<tr>
|
||||
<td>{filename_publish}</td>
|
||||
<td><a href="{link}">{diff_plus_minus}</a>{delta_nbsp}</td>
|
||||
|
||||
</tr>
|
||||
"""
|
||||
else:
|
||||
pr_body += f"""
|
||||
<tr>
|
||||
<td>
|
||||
<details>
|
||||
@ -622,17 +706,7 @@ extra_file_yaml =
|
||||
|
||||
</tr>
|
||||
"""
|
||||
if use_collapsible_file_list:
|
||||
pr_body += """</table></details></td></tr>"""
|
||||
else:
|
||||
pr_body += """</table></td></tr>"""
|
||||
pr_body += """</tr></tbody></table>"""
|
||||
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error processing pr files to markdown {self.pr_id}: {e}")
|
||||
pass
|
||||
return pr_body, pr_comments
|
||||
|
||||
return pr_body
|
||||
|
||||
def count_chars_without_html(string):
|
||||
if '<' not in string:
|
||||
@ -641,11 +715,14 @@ def count_chars_without_html(string):
|
||||
return len(no_html_string)
|
||||
|
||||
|
||||
def insert_br_after_x_chars(text, x=70):
|
||||
def insert_br_after_x_chars(text: str, x=70):
|
||||
"""
|
||||
Insert <br> into a string after a word that increases its length above x characters.
|
||||
Use proper HTML tags for code and new lines.
|
||||
"""
|
||||
|
||||
if not text:
|
||||
return ""
|
||||
if count_chars_without_html(text) < x:
|
||||
return text
|
||||
|
||||
|
@ -1,79 +0,0 @@
|
||||
import copy
|
||||
from functools import partial
|
||||
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
|
||||
from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler
|
||||
from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler
|
||||
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 get_git_provider
|
||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
|
||||
class PRInformationFromUser:
|
||||
def __init__(self, pr_url: str, args: list = None,
|
||||
ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler):
|
||||
self.git_provider = get_git_provider()(pr_url)
|
||||
self.main_pr_language = get_main_pr_language(
|
||||
self.git_provider.get_languages(), self.git_provider.get_files()
|
||||
)
|
||||
self.ai_handler = ai_handler()
|
||||
self.ai_handler.main_pr_language = self.main_pr_language
|
||||
|
||||
self.vars = {
|
||||
"title": self.git_provider.pr.title,
|
||||
"branch": self.git_provider.get_pr_branch(),
|
||||
"description": self.git_provider.get_pr_description(),
|
||||
"language": self.main_pr_language,
|
||||
"diff": "", # empty diff for initial calculation
|
||||
"commit_messages_str": self.git_provider.get_commit_messages(),
|
||||
}
|
||||
self.token_handler = TokenHandler(self.git_provider.pr,
|
||||
self.vars,
|
||||
get_settings().pr_information_from_user_prompt.system,
|
||||
get_settings().pr_information_from_user_prompt.user)
|
||||
self.patches_diff = None
|
||||
self.prediction = None
|
||||
|
||||
async def run(self):
|
||||
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)
|
||||
get_logger().info('Preparing questions...')
|
||||
pr_comment = self._prepare_pr_answer()
|
||||
if get_settings().config.publish_output:
|
||||
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):
|
||||
get_logger().info('Getting PR diff...')
|
||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||
get_logger().info('Getting AI prediction...')
|
||||
self.prediction = await self._get_prediction(model)
|
||||
|
||||
async def _get_prediction(self, model: str):
|
||||
variables = copy.deepcopy(self.vars)
|
||||
variables["diff"] = self.patches_diff # update diff
|
||||
environment = Environment(undefined=StrictUndefined)
|
||||
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:
|
||||
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=get_settings().config.temperature, system=system_prompt, user=user_prompt)
|
||||
return response
|
||||
|
||||
def _prepare_pr_answer(self) -> str:
|
||||
model_output = self.prediction.strip()
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
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
|
@ -79,13 +79,17 @@ class PR_LineQuestions:
|
||||
line_end=line_end,
|
||||
side=side)
|
||||
if self.patch_with_lines:
|
||||
response = await retry_with_fallback_models(self._get_prediction, model_type=ModelType.WEAK)
|
||||
model_answer = await retry_with_fallback_models(self._get_prediction, model_type=ModelType.WEAK)
|
||||
# sanitize the answer so that no line will start with "/"
|
||||
model_answer_sanitized = model_answer.strip().replace("\n/", "\n /")
|
||||
if model_answer_sanitized.startswith("/"):
|
||||
model_answer_sanitized = " " + model_answer_sanitized
|
||||
|
||||
get_logger().info('Preparing answer...')
|
||||
if comment_id:
|
||||
self.git_provider.reply_to_comment_from_comment_id(comment_id, response)
|
||||
self.git_provider.reply_to_comment_from_comment_id(comment_id, model_answer_sanitized)
|
||||
else:
|
||||
self.git_provider.publish_comment(response)
|
||||
self.git_provider.publish_comment(model_answer_sanitized)
|
||||
|
||||
return ""
|
||||
|
||||
|
@ -117,6 +117,16 @@ class PRQuestions:
|
||||
return response
|
||||
|
||||
def _prepare_pr_answer(self) -> str:
|
||||
model_answer = self.prediction.strip()
|
||||
# sanitize the answer so that no line will start with "/"
|
||||
model_answer_sanitized = model_answer.replace("\n/", "\n /")
|
||||
if model_answer_sanitized.startswith("/"):
|
||||
model_answer_sanitized = " " + model_answer_sanitized
|
||||
if model_answer_sanitized != model_answer:
|
||||
get_logger().debug(f"Sanitized model answer",
|
||||
artifact={"model_answer": model_answer, "sanitized_answer": model_answer_sanitized})
|
||||
|
||||
|
||||
answer_str = f"### **Ask**❓\n{self.question_str}\n\n"
|
||||
answer_str += f"### **Answer:**\n{self.prediction.strip()}\n\n"
|
||||
answer_str += f"### **Answer:**\n{model_answer_sanitized}\n\n"
|
||||
return answer_str
|
||||
|
@ -86,7 +86,6 @@ class PRReviewer:
|
||||
"require_estimate_effort_to_review": get_settings().pr_reviewer.require_estimate_effort_to_review,
|
||||
'require_can_be_split_review': get_settings().pr_reviewer.require_can_be_split_review,
|
||||
'require_security_review': get_settings().pr_reviewer.require_security_review,
|
||||
'num_code_suggestions': get_settings().pr_reviewer.num_code_suggestions,
|
||||
'question_str': question_str,
|
||||
'answer_str': answer_str,
|
||||
"extra_instructions": get_settings().pr_reviewer.extra_instructions,
|
||||
@ -168,8 +167,6 @@ class PRReviewer:
|
||||
self.git_provider.publish_comment(pr_review)
|
||||
|
||||
self.git_provider.remove_initial_comment()
|
||||
if get_settings().pr_reviewer.inline_code_comments:
|
||||
self._publish_inline_code_comments()
|
||||
else:
|
||||
get_logger().info("Review output is not published")
|
||||
get_settings().data = {"artifact": pr_review}
|
||||
@ -235,33 +232,6 @@ class PRReviewer:
|
||||
key_issues_to_review = data['review'].pop('key_issues_to_review')
|
||||
data['review']['key_issues_to_review'] = key_issues_to_review
|
||||
|
||||
if 'code_feedback' in data:
|
||||
code_feedback = data['code_feedback']
|
||||
|
||||
# Filter out code suggestions that can be submitted as inline comments
|
||||
if get_settings().pr_reviewer.inline_code_comments:
|
||||
del data['code_feedback']
|
||||
else:
|
||||
for suggestion in code_feedback:
|
||||
if ('relevant_file' in suggestion) and (not suggestion['relevant_file'].startswith('``')):
|
||||
suggestion['relevant_file'] = f"``{suggestion['relevant_file']}``"
|
||||
|
||||
if 'relevant_line' not in suggestion:
|
||||
suggestion['relevant_line'] = ''
|
||||
|
||||
relevant_line_str = suggestion['relevant_line'].split('\n')[0]
|
||||
|
||||
# removing '+'
|
||||
suggestion['relevant_line'] = relevant_line_str.lstrip('+').strip()
|
||||
|
||||
# try to add line numbers link to code suggestions
|
||||
if hasattr(self.git_provider, 'generate_link_to_relevant_line_number'):
|
||||
link = self.git_provider.generate_link_to_relevant_line_number(suggestion)
|
||||
if link:
|
||||
suggestion['relevant_line'] = f"[{suggestion['relevant_line']}]({link})"
|
||||
else:
|
||||
pass
|
||||
|
||||
incremental_review_markdown_text = None
|
||||
# Add incremental review section
|
||||
if self.incremental.is_incremental:
|
||||
@ -292,38 +262,6 @@ class PRReviewer:
|
||||
|
||||
return markdown_text
|
||||
|
||||
def _publish_inline_code_comments(self) -> None:
|
||||
"""
|
||||
Publishes inline comments on a pull request with code suggestions generated by the AI model.
|
||||
"""
|
||||
if get_settings().pr_reviewer.num_code_suggestions == 0:
|
||||
return
|
||||
|
||||
first_key = 'review'
|
||||
last_key = 'security_concerns'
|
||||
data = load_yaml(self.prediction.strip(),
|
||||
keys_fix_yaml=["ticket_compliance_check", "estimated_effort_to_review_[1-5]:", "security_concerns:", "key_issues_to_review:",
|
||||
"relevant_file:", "relevant_line:", "suggestion:"],
|
||||
first_key=first_key, last_key=last_key)
|
||||
comments: List[str] = []
|
||||
for suggestion in data.get('code_feedback', []):
|
||||
relevant_file = suggestion.get('relevant_file', '').strip()
|
||||
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:
|
||||
get_logger().info("Skipping inline comment with missing file/line/content")
|
||||
continue
|
||||
|
||||
if self.git_provider.is_supported("create_inline_comment"):
|
||||
comment = self.git_provider.create_inline_comment(content, relevant_file, relevant_line_in_file)
|
||||
if comment:
|
||||
comments.append(comment)
|
||||
else:
|
||||
self.git_provider.publish_inline_comment(content, relevant_file, relevant_line_in_file, suggestion)
|
||||
|
||||
if comments:
|
||||
self.git_provider.publish_inline_comments(comments)
|
||||
|
||||
def _get_user_answers(self) -> Tuple[str, str]:
|
||||
"""
|
||||
Retrieves the question and answer strings from the discussion messages related to a pull request.
|
||||
|
@ -41,6 +41,7 @@ class PRUpdateChangelog:
|
||||
"description": self.git_provider.get_pr_description(),
|
||||
"language": self.main_language,
|
||||
"diff": "", # empty diff for initial calculation
|
||||
"pr_link": "",
|
||||
"changelog_file_str": self.changelog_file_str,
|
||||
"today": date.today(),
|
||||
"extra_instructions": get_settings().pr_update_changelog.extra_instructions,
|
||||
@ -102,12 +103,23 @@ class PRUpdateChangelog:
|
||||
async def _get_prediction(self, model: str):
|
||||
variables = copy.deepcopy(self.vars)
|
||||
variables["diff"] = self.patches_diff # update diff
|
||||
if get_settings().pr_update_changelog.add_pr_link:
|
||||
variables["pr_link"] = self.git_provider.get_pr_url()
|
||||
environment = Environment(undefined=StrictUndefined)
|
||||
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)
|
||||
response, finish_reason = await self.ai_handler.chat_completion(
|
||||
model=model, system=system_prompt, user=user_prompt, temperature=get_settings().config.temperature)
|
||||
|
||||
# post-process the response
|
||||
response = response.strip()
|
||||
if not response:
|
||||
return ""
|
||||
if response.startswith("```"):
|
||||
response_lines = response.splitlines()
|
||||
response_lines = response_lines[1:]
|
||||
response = "\n".join(response_lines)
|
||||
response = response.strip("`")
|
||||
return response
|
||||
|
||||
def _prepare_changelog_update(self) -> Tuple[str, str]:
|
||||
|
@ -47,13 +47,10 @@ class TestConvertToMarkdown:
|
||||
def test_simple_dictionary_input(self):
|
||||
input_data = {'review': {
|
||||
'estimated_effort_to_review_[1-5]': '1, because the changes are minimal and straightforward, focusing on a single functionality addition.\n',
|
||||
'relevant_tests': 'No\n', 'possible_issues': 'No\n', 'security_concerns': 'No\n'}, 'code_feedback': [
|
||||
{'relevant_file': '``pr_agent/git_providers/git_provider.py\n``', 'language': 'python\n',
|
||||
'suggestion': "Consider raising an exception or logging a warning when 'pr_url' attribute is not found. This can help in debugging issues related to the absence of 'pr_url' in instances where it's expected. [important]\n",
|
||||
'relevant_line': '[return ""](https://github.com/Codium-ai/pr-agent-pro/pull/102/files#diff-52d45f12b836f77ed1aef86e972e65404634ea4e2a6083fb71a9b0f9bb9e062fR199)'}]}
|
||||
'relevant_tests': 'No\n', 'possible_issues': 'No\n', 'security_concerns': 'No\n'}}
|
||||
|
||||
|
||||
expected_output = f'{PRReviewHeader.REGULAR.value} 🔍\n\nHere are some key observations to aid the review process:\n\n<table>\n<tr><td>⏱️ <strong>Estimated effort to review</strong>: 1 🔵⚪⚪⚪⚪</td></tr>\n<tr><td>🧪 <strong>No relevant tests</strong></td></tr>\n<tr><td> <strong>Possible issues</strong>: No\n</td></tr>\n<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>\n</table>\n\n\n<details><summary> <strong>Code feedback:</strong></summary>\n\n<hr><table><tr><td>relevant file</td><td>pr_agent/git_providers/git_provider.py\n</td></tr><tr><td>suggestion </td><td>\n\n<strong>\n\nConsider raising an exception or logging a warning when \'pr_url\' attribute is not found. This can help in debugging issues related to the absence of \'pr_url\' in instances where it\'s expected. [important]\n\n</strong>\n</td></tr><tr><td>relevant line</td><td><a href=\'https://github.com/Codium-ai/pr-agent-pro/pull/102/files#diff-52d45f12b836f77ed1aef86e972e65404634ea4e2a6083fb71a9b0f9bb9e062fR199\'>return ""</a></td></tr></table><hr>\n\n</details>'
|
||||
expected_output = f'{PRReviewHeader.REGULAR.value} 🔍\n\nHere are some key observations to aid the review process:\n\n<table>\n<tr><td>⏱️ <strong>Estimated effort to review</strong>: 1 🔵⚪⚪⚪⚪</td></tr>\n<tr><td>🧪 <strong>No relevant tests</strong></td></tr>\n<tr><td> <strong>Possible issues</strong>: No\n</td></tr>\n<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>\n</table>'
|
||||
|
||||
assert convert_to_markdown_v2(input_data).strip() == expected_output.strip()
|
||||
|
||||
@ -67,7 +64,7 @@ class TestConvertToMarkdown:
|
||||
assert convert_to_markdown_v2(input_data).strip() == expected_output.strip()
|
||||
|
||||
def test_dictionary_with_empty_dictionaries(self):
|
||||
input_data = {'review': {}, 'code_feedback': [{}]}
|
||||
input_data = {'review': {}}
|
||||
|
||||
expected_output = ''
|
||||
|
||||
|
Reference in New Issue
Block a user