Merge remote-tracking branch 'upstream/main' into abstract-BaseAiHandler

This commit is contained in:
Brian Pham
2023-12-09 16:47:13 +00:00
104 changed files with 3813 additions and 1068 deletions

View File

@ -1,19 +1,18 @@
import logging
import os
import shlex
import tempfile
from pr_agent.algo.utils import update_settings_from_args
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.utils import apply_repo_settings
from pr_agent.tools.pr_add_docs import PRAddDocs
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
from pr_agent.tools.pr_config import PRConfig
from pr_agent.tools.pr_description import PRDescription
from pr_agent.tools.pr_generate_labels import PRGenerateLabels
from pr_agent.tools.pr_information_from_user import PRInformationFromUser
from pr_agent.tools.pr_similar_issue import PRSimilarIssue
from pr_agent.tools.pr_questions import PRQuestions
from pr_agent.tools.pr_reviewer import PRReviewer
from pr_agent.tools.pr_similar_issue import PRSimilarIssue
from pr_agent.tools.pr_update_changelog import PRUpdateChangelog
from pr_agent.tools.pr_config import PRConfig
command2class = {
"auto_review": PRReviewer,
@ -32,6 +31,8 @@ command2class = {
"config": PRConfig,
"settings": PRConfig,
"similar_issue": PRSimilarIssue,
"add_docs": PRAddDocs,
"generate_labels": PRGenerateLabels,
}
commands = list(command2class.keys())
@ -42,28 +43,16 @@ class PRAgent:
async def handle_request(self, pr_url, request, notify=None) -> bool:
# First, apply repo specific settings if exists
if get_settings().config.use_repo_settings_file:
repo_settings_file = None
try:
git_provider = get_git_provider()(pr_url)
repo_settings = git_provider.get_repo_settings()
if repo_settings:
repo_settings_file = None
fd, repo_settings_file = tempfile.mkstemp(suffix='.toml')
os.write(fd, repo_settings)
get_settings().load_file(repo_settings_file)
finally:
if repo_settings_file:
try:
os.remove(repo_settings_file)
except Exception as e:
logging.error(f"Failed to remove temporary settings file {repo_settings_file}", e)
apply_repo_settings(pr_url)
# Then, apply user specific settings if exists
request = request.replace("'", "\\'")
lexer = shlex.shlex(request, posix=True)
lexer.whitespace_split = True
action, *args = list(lexer)
if isinstance(request, str):
request = request.replace("'", "\\'")
lexer = shlex.shlex(request, posix=True)
lexer.whitespace_split = True
action, *args = list(lexer)
else:
action, *args = request
args = update_settings_from_args(args)
action = action.lstrip("/").lower()
@ -82,3 +71,4 @@ class PRAgent:
else:
return False
return True

View File

@ -8,9 +8,17 @@ MAX_TOKENS = {
'gpt-4': 8000,
'gpt-4-0613': 8000,
'gpt-4-32k': 32000,
'gpt-4-1106-preview': 128000, # 128K, but may be limited by config.max_model_tokens
'claude-instant-1': 100000,
'claude-2': 100000,
'command-nightly': 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,
'anthropic.claude-v2': 100000,
'anthropic.claude-instant-v1': 100000,
'anthropic.claude-v1': 100000,
}

View File

@ -1,6 +1,6 @@
import logging
import os
import boto3
import litellm
import openai
from litellm import acompletion
@ -8,6 +8,8 @@ from openai.error import APIError, RateLimitError, Timeout, TryAgain
from retry import retry
from pr_agent.config_loader import get_settings
from pr_agent.algo.base_ai_handler import BaseAiHandler
from pr_agent.log import get_logger
OPENAI_RETRIES = 5
@ -23,39 +25,50 @@ class AiHandler(BaseAiHandler):
Initializes the OpenAI API key and other settings from a configuration file.
Raises a ValueError if the OpenAI key is missing.
"""
try:
self.azure = False
self.aws_bedrock_client = None
if get_settings().get("OPENAI.KEY", None):
openai.api_key = get_settings().openai.key
litellm.openai_key = get_settings().openai.key
if get_settings().get("litellm.use_client"):
litellm_token = get_settings().get("litellm.LITELLM_TOKEN")
assert litellm_token, "LITELLM_TOKEN is required"
os.environ["LITELLM_TOKEN"] = litellm_token
litellm.use_client = True
self.azure = False
if get_settings().get("OPENAI.ORG", None):
litellm.organization = get_settings().openai.org
if get_settings().get("OPENAI.API_TYPE", None):
if get_settings().openai.api_type == "azure":
self.azure = True
litellm.azure_key = get_settings().openai.key
if get_settings().get("OPENAI.API_VERSION", None):
litellm.api_version = get_settings().openai.api_version
if get_settings().get("OPENAI.API_BASE", None):
litellm.api_base = get_settings().openai.api_base
if get_settings().get("ANTHROPIC.KEY", None):
litellm.anthropic_key = get_settings().anthropic.key
if get_settings().get("COHERE.KEY", None):
litellm.cohere_key = get_settings().cohere.key
if get_settings().get("REPLICATE.KEY", None):
litellm.replicate_key = get_settings().replicate.key
if get_settings().get("REPLICATE.KEY", None):
litellm.replicate_key = get_settings().replicate.key
if get_settings().get("HUGGINGFACE.KEY", None):
litellm.huggingface_key = get_settings().huggingface.key
if get_settings().get("HUGGINGFACE.API_BASE", None):
litellm.api_base = get_settings().huggingface.api_base
except AttributeError as e:
raise ValueError("OpenAI key is required") from e
if get_settings().get("litellm.use_client"):
litellm_token = get_settings().get("litellm.LITELLM_TOKEN")
assert litellm_token, "LITELLM_TOKEN is required"
os.environ["LITELLM_TOKEN"] = litellm_token
litellm.use_client = True
if get_settings().get("OPENAI.ORG", None):
litellm.organization = get_settings().openai.org
if get_settings().get("OPENAI.API_TYPE", None):
if get_settings().openai.api_type == "azure":
self.azure = True
litellm.azure_key = get_settings().openai.key
if get_settings().get("OPENAI.API_VERSION", None):
litellm.api_version = get_settings().openai.api_version
if get_settings().get("OPENAI.API_BASE", None):
litellm.api_base = get_settings().openai.api_base
if get_settings().get("ANTHROPIC.KEY", None):
litellm.anthropic_key = get_settings().anthropic.key
if get_settings().get("COHERE.KEY", None):
litellm.cohere_key = get_settings().cohere.key
if get_settings().get("REPLICATE.KEY", None):
litellm.replicate_key = get_settings().replicate.key
if get_settings().get("REPLICATE.KEY", None):
litellm.replicate_key = get_settings().replicate.key
if get_settings().get("HUGGINGFACE.KEY", None):
litellm.huggingface_key = get_settings().huggingface.key
if get_settings().get("HUGGINGFACE.API_BASE", None):
litellm.api_base = get_settings().huggingface.api_base
if get_settings().get("VERTEXAI.VERTEX_PROJECT", None):
litellm.vertex_project = get_settings().vertexai.vertex_project
litellm.vertex_location = get_settings().get(
"VERTEXAI.VERTEX_LOCATION", None
)
if get_settings().get("AWS.BEDROCK_REGION", None):
litellm.AmazonAnthropicConfig.max_tokens_to_sample = 2000
self.aws_bedrock_client = boto3.client(
service_name="bedrock-runtime",
region_name=get_settings().aws.bedrock_region,
)
@property
def deployment_id(self):
@ -89,33 +102,37 @@ class AiHandler(BaseAiHandler):
try:
deployment_id = self.deployment_id
if get_settings().config.verbosity_level >= 2:
logging.debug(
get_logger().debug(
f"Generating completion with {model}"
f"{(' from deployment ' + deployment_id) if deployment_id else ''}"
)
response = await acompletion(
model=model,
deployment_id=deployment_id,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user}
],
temperature=temperature,
azure=self.azure,
force_timeout=get_settings().config.ai_timeout
)
if self.azure:
model = 'azure/' + model
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
kwargs = {
"model": model,
"deployment_id": deployment_id,
"messages": messages,
"temperature": temperature,
"force_timeout": get_settings().config.ai_timeout,
}
if self.aws_bedrock_client:
kwargs["aws_bedrock_client"] = self.aws_bedrock_client
response = await acompletion(**kwargs)
except (APIError, Timeout, TryAgain) as e:
logging.error("Error during OpenAI inference: ", e)
get_logger().error("Error during OpenAI inference: ", e)
raise
except (RateLimitError) as e:
logging.error("Rate limit error during OpenAI inference: ", e)
get_logger().error("Rate limit error during OpenAI inference: ", e)
raise
except (Exception) as e:
logging.error("Unknown error during OpenAI inference: ", e)
get_logger().error("Unknown error during OpenAI inference: ", e)
raise TryAgain from e
if response is None or len(response["choices"]) == 0:
raise TryAgain
resp = response["choices"][0]['message']['content']
finish_reason = response["choices"][0]["finish_reason"]
print(resp, finish_reason)
usage = response.get("usage")
get_logger().info("AI response", response=resp, messages=messages, finish_reason=finish_reason,
model=model, usage=usage)
return resp, finish_reason

View File

@ -0,0 +1,36 @@
import fnmatch
import re
from pr_agent.config_loader import get_settings
def filter_ignored(files):
"""
Filter out files that match the ignore patterns.
"""
try:
# load regex patterns, and translate glob patterns to regex
patterns = get_settings().ignore.regex
if isinstance(patterns, str):
patterns = [patterns]
glob_setting = get_settings().ignore.glob
if isinstance(glob_setting, str): # --ignore.glob=[.*utils.py], --ignore.glob=.*utils.py
glob_setting = glob_setting.strip('[]').split(",")
patterns += [fnmatch.translate(glob) for glob in glob_setting]
# compile all valid patterns
compiled_patterns = []
for r in patterns:
try:
compiled_patterns.append(re.compile(r))
except re.error:
pass
# keep filenames that _don't_ match the ignore regex
for r in compiled_patterns:
files = [f for f in files if (f.filename and not r.match(f.filename))]
except Exception as e:
print(f"Could not filter file list: {e}")
return files

View File

@ -1,8 +1,10 @@
from __future__ import annotations
import logging
import re
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
def extend_patch(original_file_str, patch_str, num_lines) -> str:
@ -63,7 +65,7 @@ def extend_patch(original_file_str, patch_str, num_lines) -> str:
extended_patch_lines.append(line)
except Exception as e:
if get_settings().config.verbosity_level >= 2:
logging.error(f"Failed to extend patch: {e}")
get_logger().error(f"Failed to extend patch: {e}")
return patch_str
# finish previous hunk
@ -114,7 +116,7 @@ def omit_deletion_hunks(patch_lines) -> 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.
@ -131,17 +133,17 @@ def handle_patch_deletions(patch: str, original_file_content_str: str,
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
if get_settings().config.verbosity_level > 0:
logging.info(f"Processing file: {file_name}, minimizing deletion file")
get_logger().info(f"Processing file: {file_name}, minimizing deletion file")
patch = None # file was deleted
else:
patch_lines = patch.splitlines()
patch_new = omit_deletion_hunks(patch_lines)
if patch != patch_new:
if get_settings().config.verbosity_level > 0:
logging.info(f"Processing file: {file_name}, hunks were deleted")
get_logger().info(f"Processing file: {file_name}, hunks were deleted")
patch = patch_new
return patch

View File

@ -3,8 +3,7 @@ from typing import Dict
from pr_agent.config_loader import get_settings
language_extension_map_org = get_settings().language_extension_map_org
language_extension_map = {k.lower(): v for k, v in language_extension_map_org.items()}
# Bad Extensions, source: https://github.com/EleutherAI/github-downloader/blob/345e7c4cbb9e0dc8a0615fd995a08bf9d73b3fe6/download_repo_text.py # noqa: E501
bad_extensions = get_settings().bad_extensions.default
@ -29,6 +28,8 @@ def sort_files_by_main_languages(languages: Dict, files: list):
# languages_sorted = sorted(languages, key=lambda x: x[1], reverse=True)
# get all extensions for the languages
main_extensions = []
language_extension_map_org = get_settings().language_extension_map_org
language_extension_map = {k.lower(): v for k, v in language_extension_map_org.items()}
for language in languages_sorted_list:
if language.lower() in language_extension_map:
main_extensions.append(language_extension_map[language.lower()])

View File

@ -1,27 +1,29 @@
from __future__ import annotations
import difflib
import logging
import re
import traceback
from typing import Any, Callable, List, Tuple
from github import RateLimitExceededException
from pr_agent.algo import MAX_TOKENS
from pr_agent.algo.git_patch_processing import convert_to_hunks_with_lines_numbers, extend_patch, handle_patch_deletions
from pr_agent.algo.language_handler import sort_files_by_main_languages
from pr_agent.algo.token_handler import TokenHandler, get_token_encoder
from pr_agent.algo.file_filter import filter_ignored
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.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
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_HARD_THRESHOLD = 600
PATCH_EXTRA_LINES = 3
def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: str,
add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool = False) -> str:
@ -44,31 +46,37 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s
"""
if disable_extra_lines:
global PATCH_EXTRA_LINES
PATCH_EXTRA_LINES = 0
else:
PATCH_EXTRA_LINES = get_settings().config.patch_extra_lines
try:
diff_files = git_provider.get_diff_files()
except RateLimitExceededException as e:
logging.error(f"Rate limit exceeded for git provider API. original message {e}")
get_logger().error(f"Rate limit exceeded for git provider API. original message {e}")
raise
diff_files = filter_ignored(diff_files)
# get pr languages
pr_languages = sort_files_by_main_languages(git_provider.get_languages(), diff_files)
# generate a standard diff string, with patch extension
patches_extended, total_tokens, patches_extended_tokens = pr_generate_extended_diff(pr_languages, token_handler,
add_line_numbers_to_hunks)
patches_extended, total_tokens, patches_extended_tokens = pr_generate_extended_diff(
pr_languages, token_handler, add_line_numbers_to_hunks, patch_extra_lines=PATCH_EXTRA_LINES)
# if we are under the limit, return the full diff
if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < MAX_TOKENS[model]:
if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model):
return "\n".join(patches_extended)
# 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)
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:
modified_list_str = MORE_MODIFIED_FILES_ + "\n".join(modified_file_names)
final_diff = final_diff + "\n\n" + modified_list_str
@ -80,7 +88,8 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s
def pr_generate_extended_diff(pr_languages: list,
token_handler: TokenHandler,
add_line_numbers_to_hunks: bool) -> Tuple[list, int, list]:
add_line_numbers_to_hunks: bool,
patch_extra_lines: int = 0) -> Tuple[list, int, list]:
"""
Generate a standard diff string with patch extension, while counting the number of tokens used and applying diff
minimization techniques if needed.
@ -102,7 +111,7 @@ def pr_generate_extended_diff(pr_languages: list,
continue
# extend each patch with extra lines of context
extended_patch = extend_patch(original_file_content_str, patch, num_lines=PATCH_EXTRA_LINES)
extended_patch = extend_patch(original_file_content_str, patch, num_lines=patch_extra_lines)
full_extended_patch = f"\n\n## {file.filename}\n\n{extended_patch}\n"
if add_line_numbers_to_hunks:
@ -118,7 +127,7 @@ def pr_generate_extended_diff(pr_languages: list,
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
tokens used.
@ -144,6 +153,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
"""
patches = []
added_files_list = []
modified_files_list = []
deleted_files_list = []
# sort each one of the languages in top_langs by the number of tokens in the diff
@ -161,7 +171,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
# removing delete-only hunks
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 not deleted_files_list:
total_tokens += token_handler.count_tokens(DELETED_FILES_)
@ -175,21 +185,26 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
new_patch_tokens = token_handler.count_tokens(patch)
# Hard Stop, no more tokens
if total_tokens > MAX_TOKENS[model] - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD:
logging.warning(f"File was fully skipped, no more tokens: {file.filename}.")
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}.")
continue
# 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
# TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens
# until we meet the requirements
if get_settings().config.verbosity_level >= 2:
logging.warning(f"Patch too large, minimizing it, {file.filename}")
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
get_logger().warning(f"Patch too large, minimizing it, {file.filename}")
if file.edit_type == EDIT_TYPE.ADDED:
if not added_files_list:
total_tokens += token_handler.count_tokens(ADDED_FILES_)
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
if patch:
@ -200,9 +215,9 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
patches.append(patch_final)
total_tokens += token_handler.count_tokens(patch_final)
if get_settings().config.verbosity_level >= 2:
logging.info(f"Tokens: {total_tokens}, last filename: {file.filename}")
get_logger().info(f"Tokens: {total_tokens}, last filename: {file.filename}")
return patches, modified_files_list, deleted_files_list
return patches, modified_files_list, deleted_files_list, added_files_list
async def retry_with_fallback_models(f: Callable):
@ -214,7 +229,7 @@ async def retry_with_fallback_models(f: Callable):
get_settings().set("openai.deployment_id", deployment_id)
return await f(model)
except Exception as e:
logging.warning(
get_logger().warning(
f"Failed to generate prediction with {model}"
f"{(' from deployment ' + deployment_id) if deployment_id else ''}: "
f"{traceback.format_exc()}"
@ -267,7 +282,7 @@ def find_line_number_of_relevant_line_in_file(diff_files: List[FilePatchInfo],
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
for file in diff_files:
if file.filename.strip() == relevant_file:
if file.filename and (file.filename.strip() == relevant_file):
patch = file.patch
patch_lines = patch.splitlines()
@ -311,35 +326,6 @@ def find_line_number_of_relevant_line_in_file(diff_files: List[FilePatchInfo],
return position, absolute_position
def clip_tokens(text: str, max_tokens: int) -> str:
"""
Clip the number of tokens in a string to a maximum number of tokens.
Args:
text (str): The string to clip.
max_tokens (int): The maximum number of tokens allowed in the string.
Returns:
str: The clipped string.
"""
if not text:
return text
try:
encoder = get_token_encoder()
num_input_tokens = len(encoder.encode(text))
if num_input_tokens <= max_tokens:
return text
num_chars = len(text)
chars_per_token = num_chars / num_input_tokens
num_output_chars = int(chars_per_token * max_tokens)
clipped_text = text[:num_output_chars]
return clipped_text
except Exception as e:
logging.warning(f"Failed to clip tokens: {e}")
return text
def get_pr_multi_diffs(git_provider: GitProvider,
token_handler: TokenHandler,
model: str,
@ -347,25 +333,27 @@ def get_pr_multi_diffs(git_provider: GitProvider,
"""
Retrieves the diff files from a Git provider, sorts them by main language, and generates patches for each file.
The patches are split into multiple groups based on the maximum number of tokens allowed for the given model.
Args:
git_provider (GitProvider): An object that provides access to Git provider APIs.
token_handler (TokenHandler): An object that handles tokens in the context of a pull request.
model (str): The name of the model.
max_calls (int, optional): The maximum number of calls to retrieve diff files. Defaults to 5.
Returns:
List[str]: A list of final diff strings, split into multiple groups based on the maximum number of tokens allowed for the given model.
Raises:
RateLimitExceededException: If the rate limit for the Git provider API is exceeded.
"""
try:
diff_files = git_provider.get_diff_files()
except RateLimitExceededException as e:
logging.error(f"Rate limit exceeded for git provider API. original message {e}")
get_logger().error(f"Rate limit exceeded for git provider API. original message {e}")
raise
diff_files = filter_ignored(diff_files)
# Sort files by main language
pr_languages = sort_files_by_main_languages(git_provider.get_languages(), diff_files)
@ -381,7 +369,7 @@ def get_pr_multi_diffs(git_provider: GitProvider,
for file in sorted_files:
if call_number > max_calls:
if get_settings().config.verbosity_level >= 2:
logging.info(f"Reached max calls ({max_calls})")
get_logger().info(f"Reached max calls ({max_calls})")
break
original_file_content_str = file.base_file
@ -391,26 +379,26 @@ def get_pr_multi_diffs(git_provider: GitProvider,
continue
# 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:
continue
patch = convert_to_hunks_with_lines_numbers(patch, file)
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_list.append(final_diff)
patches = []
total_tokens = token_handler.prompt_tokens
call_number += 1
if get_settings().config.verbosity_level >= 2:
logging.info(f"Call number: {call_number}")
get_logger().info(f"Call number: {call_number}")
if patch:
patches.append(patch)
total_tokens += new_patch_tokens
if get_settings().config.verbosity_level >= 2:
logging.info(f"Tokens: {total_tokens}, last filename: {file.filename}")
get_logger().info(f"Tokens: {total_tokens}, last filename: {file.filename}")
# Add the last chunk
if patches:

View File

@ -2,7 +2,6 @@ from __future__ import annotations
import difflib
import json
import logging
import re
import textwrap
from datetime import datetime
@ -10,7 +9,11 @@ from typing import Any, List
import yaml
from starlette_context import context
from pr_agent.algo import MAX_TOKENS
from pr_agent.algo.token_handler import get_token_encoder
from pr_agent.config_loader import get_settings, global_settings
from pr_agent.log import get_logger
def get_setting(key: str) -> Any:
@ -55,14 +58,15 @@ def convert_to_markdown(output_data: dict, gfm_supported: bool=True) -> str:
emoji = emojis.get(key, "")
if key.lower() == 'code feedback':
if gfm_supported:
markdown_text += f"\n\n- **<details><summary> { emoji } Code feedback:**</summary>\n\n"
markdown_text += f"\n\n- "
markdown_text += f"<details><summary> { emoji } Code feedback:</summary>\n\n"
else:
markdown_text += f"\n\n- **{emoji} Code feedback:**\n\n"
else:
markdown_text += f"- {emoji} **{key}:**\n\n"
for item in value:
if isinstance(item, dict) and key.lower() == 'code feedback':
markdown_text += parse_code_suggestion(item)
markdown_text += parse_code_suggestion(item, gfm_supported)
elif item:
markdown_text += f" - {item}\n"
if key.lower() == 'code feedback':
@ -76,7 +80,7 @@ def convert_to_markdown(output_data: dict, gfm_supported: bool=True) -> str:
return markdown_text
def parse_code_suggestion(code_suggestions: dict) -> str:
def parse_code_suggestion(code_suggestions: dict, gfm_supported: bool=True) -> str:
"""
Convert a dictionary of data into markdown format.
@ -96,9 +100,13 @@ def parse_code_suggestion(code_suggestions: dict) -> str:
markdown_text += f" - **{code_key}:**\n{code_str_indented}\n"
else:
if "relevant file" in sub_key.lower():
markdown_text += f"\n - **{sub_key}:** {sub_value}\n"
markdown_text += f"\n - **{sub_key}:** {sub_value} \n"
else:
markdown_text += f" **{sub_key}:** {sub_value}\n"
markdown_text += f" **{sub_key}:** {sub_value} \n"
if not gfm_supported:
if "relevant line" not in sub_key.lower(): # nicer presentation
# markdown_text = markdown_text.rstrip('\n') + "\\\n" # works for gitlab
markdown_text = markdown_text.rstrip('\n') + " \n" # works for gitlab and bitbucker
markdown_text += "\n"
return markdown_text
@ -156,7 +164,7 @@ def try_fix_json(review, max_iter=10, code_suggestions=False):
iter_count += 1
if not valid_json:
logging.error("Unable to decode JSON response from AI")
get_logger().error("Unable to decode JSON response from AI")
data = {}
return data
@ -227,7 +235,7 @@ def load_large_diff(filename, new_file_content_str: str, original_file_content_s
diff = difflib.unified_diff(original_file_content_str.splitlines(keepends=True),
new_file_content_str.splitlines(keepends=True))
if get_settings().config.verbosity_level >= 2:
logging.warning(f"File was modified, but no patch was found. Manually creating patch: {filename}.")
get_logger().warning(f"File was modified, but no patch was found. Manually creating patch: {filename}.")
patch = ''.join(diff)
except Exception:
pass
@ -259,12 +267,12 @@ def update_settings_from_args(args: List[str]) -> List[str]:
vals = arg.split('=', 1)
if len(vals) != 2:
if len(vals) > 2: # --extended is a valid argument
logging.error(f'Invalid argument format: {arg}')
get_logger().error(f'Invalid argument format: {arg}')
other_args.append(arg)
continue
key, value = _fix_key_value(*vals)
get_settings().set(key, value)
logging.info(f'Updated setting {key} to: "{value}"')
get_logger().info(f'Updated setting {key} to: "{value}"')
else:
other_args.append(arg)
return other_args
@ -276,28 +284,142 @@ def _fix_key_value(key: str, value: str):
try:
value = yaml.safe_load(value)
except Exception as e:
logging.error(f"Failed to parse YAML for config override {key}={value}", exc_info=e)
get_logger().debug(f"Failed to parse YAML for config override {key}={value}", exc_info=e)
return key, value
def load_yaml(review_text: str) -> dict:
review_text = review_text.removeprefix('```yaml').rstrip('`')
def load_yaml(response_text: str) -> dict:
response_text = response_text.removeprefix('```yaml').rstrip('`')
try:
data = yaml.safe_load(review_text)
data = yaml.safe_load(response_text)
except Exception as e:
logging.error(f"Failed to parse AI prediction: {e}")
data = try_fix_yaml(review_text)
get_logger().error(f"Failed to parse AI prediction: {e}")
data = try_fix_yaml(response_text)
return data
def try_fix_yaml(review_text: str) -> dict:
review_text_lines = review_text.split('\n')
def try_fix_yaml(response_text: str) -> dict:
response_text_lines = response_text.split('\n')
keys = ['relevant line:', 'suggestion content:', 'relevant file:']
# first fallback - try to convert 'relevant line: ...' to relevant line: |-\n ...'
response_text_lines_copy = response_text_lines.copy()
for i in range(0, len(response_text_lines_copy)):
for key in keys:
if key in response_text_lines_copy[i] and not '|-' in response_text_lines_copy[i]:
response_text_lines_copy[i] = response_text_lines_copy[i].replace(f'{key}',
f'{key} |-\n ')
try:
data = yaml.safe_load('\n'.join(response_text_lines_copy))
get_logger().info(f"Successfully parsed AI prediction after adding |-\n")
return data
except:
get_logger().info(f"Failed to parse AI prediction after adding |-\n")
# second fallback - try to remove last lines
data = {}
for i in range(1, len(review_text_lines)):
review_text_lines_tmp = '\n'.join(review_text_lines[:-i])
for i in range(1, len(response_text_lines)):
response_text_lines_tmp = '\n'.join(response_text_lines[:-i])
try:
data = yaml.load(review_text_lines_tmp, Loader=yaml.SafeLoader)
logging.info(f"Successfully parsed AI prediction after removing {i} lines")
data = yaml.safe_load(response_text_lines_tmp,)
get_logger().info(f"Successfully parsed AI prediction after removing {i} lines")
break
except:
pass
return data
# thrid fallback - try to remove leading and trailing curly brackets
response_text_copy = response_text.strip().rstrip().removeprefix('{').removesuffix('}')
try:
data = yaml.safe_load(response_text_copy,)
get_logger().info(f"Successfully parsed AI prediction after removing curly brackets")
return data
except:
pass
def set_custom_labels(variables):
if not get_settings().config.enable_custom_labels:
return
labels = get_settings().custom_labels
if not labels:
# set default labels
labels = ['Bug fix', 'Tests', 'Bug fix with tests', 'Enhancement', 'Documentation', 'Other']
labels_list = "\n - ".join(labels) if labels else ""
labels_list = f" - {labels_list}" if labels_list else ""
variables["custom_labels"] = labels_list
return
#final_labels = ""
#for k, v in labels.items():
# final_labels += f" - {k} ({v['description']})\n"
#variables["custom_labels"] = final_labels
#variables["custom_labels_examples"] = f" - {list(labels.keys())[0]}"
variables["custom_labels_class"] = "class Label(str, Enum):"
for k, v in labels.items():
description = v['description'].strip('\n').replace('\n', '\\n')
variables["custom_labels_class"] += f"\n {k.lower().replace(' ', '_')} = '{k}' # {description}"
def get_user_labels(current_labels: List[str] = None):
"""
Only keep labels that has been added by the user
"""
try:
if current_labels is None:
current_labels = []
user_labels = []
for label in current_labels:
if label.lower() in ['bug fix', 'tests', 'enhancement', 'documentation', 'other']:
continue
if get_settings().config.enable_custom_labels:
if label in get_settings().custom_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
def get_max_tokens(model):
settings = get_settings()
if model in MAX_TOKENS:
max_tokens_model = MAX_TOKENS[model]
else:
raise Exception(f"MAX_TOKENS must be set for model {model} in ./pr_agent/algo/__init__.py")
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
def clip_tokens(text: str, max_tokens: int, add_three_dots=True) -> str:
"""
Clip the number of tokens in a string to a maximum number of tokens.
Args:
text (str): The string to clip.
max_tokens (int): The maximum number of tokens allowed in the string.
add_three_dots (bool, optional): A boolean indicating whether to add three dots at the end of the clipped
Returns:
str: The clipped string.
"""
if not text:
return text
try:
encoder = get_token_encoder()
num_input_tokens = len(encoder.encode(text))
if num_input_tokens <= max_tokens:
return text
num_chars = len(text)
chars_per_token = num_chars / num_input_tokens
num_output_chars = int(chars_per_token * max_tokens)
clipped_text = text[:num_output_chars]
if add_three_dots:
clipped_text += "...(truncated)"
return clipped_text
except Exception as e:
get_logger().warning(f"Failed to clip tokens: {e}")
return text

View File

@ -1,10 +1,13 @@
import argparse
import asyncio
import logging
import os
from pr_agent.agent.pr_agent import PRAgent, commands
from pr_agent.config_loader import get_settings
from pr_agent.log import setup_logger
setup_logger()
def run(inargs=None):
@ -20,18 +23,22 @@ For example:
- cli.py --issue_url=... similar_issue
Supported commands:
-review / review_pr - Add a review that includes a summary of the PR and specific suggestions for improvement.
- review / review_pr - Add a review that includes a summary of the PR and specific suggestions for improvement.
-ask / ask_question [question] - Ask a question about the PR.
- ask / ask_question [question] - Ask a question about the PR.
-describe / describe_pr - Modify the PR title and description based on the PR's contents.
- describe / describe_pr - Modify the PR title and description based on the PR's contents.
-improve / improve_code - Suggest improvements to the code in the PR as pull request comments ready to commit.
- improve / improve_code - Suggest improvements to the code in the PR as pull request comments ready to commit.
Extended mode ('improve --extended') employs several calls, and provides a more thorough feedback
-reflect - Ask the PR author questions about the PR.
- reflect - Ask the PR author questions about the PR.
-update_changelog - Update the changelog based on the PR's contents.
- update_changelog - Update the changelog based on the PR's contents.
- add_docs
- generate_labels
Configuration:
@ -47,13 +54,12 @@ For example: 'python cli.py --pr_url=... review --pr_reviewer.extra_instructions
parser.print_help()
return
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
command = args.command.lower()
get_settings().set("CONFIG.CLI_MODE", True)
if args.issue_url:
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:
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:
parser.print_help()

View File

@ -14,6 +14,7 @@ global_settings = Dynaconf(
settings_files=[join(current_dir, f) for f in [
"settings/.secrets.toml",
"settings/configuration.toml",
"settings/ignore.toml",
"settings/language_extensions.toml",
"settings/pr_reviewer_prompts.toml",
"settings/pr_questions_prompts.toml",
@ -22,7 +23,10 @@ global_settings = Dynaconf(
"settings/pr_sort_code_suggestions_prompts.toml",
"settings/pr_information_from_user_prompts.toml",
"settings/pr_update_changelog_prompts.toml",
"settings_prod/.secrets.toml"
"settings/pr_custom_labels.toml",
"settings/pr_add_docs.toml",
"settings_prod/.secrets.toml",
"settings/custom_labels.toml"
]]
)

View File

@ -1,5 +1,6 @@
from pr_agent.config_loader import get_settings
from pr_agent.git_providers.bitbucket_provider import BitbucketProvider
from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider
from pr_agent.git_providers.codecommit_provider import CodeCommitProvider
from pr_agent.git_providers.github_provider import GithubProvider
from pr_agent.git_providers.gitlab_provider import GitLabProvider
@ -12,6 +13,7 @@ _GIT_PROVIDERS = {
'github': GithubProvider,
'gitlab': GitLabProvider,
'bitbucket': BitbucketProvider,
'bitbucket_server': BitbucketServerProvider,
'azure': AzureDevopsProvider,
'codecommit': CodeCommitProvider,
'local' : LocalGitProvider,

View File

@ -1,10 +1,11 @@
import json
import logging
from typing import Optional, Tuple
from urllib.parse import urlparse
import os
from ..log import get_logger
AZURE_DEVOPS_AVAILABLE = True
try:
from msrest.authentication import BasicAuthentication
@ -13,9 +14,8 @@ try:
except ImportError:
AZURE_DEVOPS_AVAILABLE = False
from ..algo.pr_processing import clip_tokens
from ..config_loader import get_settings
from ..algo.utils import load_large_diff
from ..algo.utils import load_large_diff, clip_tokens
from ..algo.language_handler import is_valid_file
from .git_provider import EDIT_TYPE, FilePatchInfo
@ -55,7 +55,7 @@ class AzureDevopsProvider:
path=".pr_agent.toml")
return contents
except Exception as e:
logging.exception("get repo settings error")
get_logger().exception("get repo settings error")
return ""
def get_files(self):
@ -100,14 +100,18 @@ class AzureDevopsProvider:
continue
version = GitVersionDescriptor(version=head_sha.commit_id, version_type='commit')
new_file_content_str = self.azure_devops_client.get_item(repository_id=self.repo_slug,
path=file,
project=self.workspace_slug,
version_descriptor=version,
download=False,
include_content=True)
try:
new_file_content_str = self.azure_devops_client.get_item(repository_id=self.repo_slug,
path=file,
project=self.workspace_slug,
version_descriptor=version,
download=False,
include_content=True)
new_file_content_str = new_file_content_str.content
new_file_content_str = new_file_content_str.content
except Exception as error:
get_logger().error("Failed to retrieve new file content of %s at version %s. Error: %s", file, version, str(error))
new_file_content_str = ""
edit_type = EDIT_TYPE.MODIFIED
if diff_types[file] == 'add':
@ -118,13 +122,17 @@ class AzureDevopsProvider:
edit_type = EDIT_TYPE.RENAMED
version = GitVersionDescriptor(version=base_sha.commit_id, version_type='commit')
original_file_content_str = self.azure_devops_client.get_item(repository_id=self.repo_slug,
try:
original_file_content_str = self.azure_devops_client.get_item(repository_id=self.repo_slug,
path=file,
project=self.workspace_slug,
version_descriptor=version,
download=False,
include_content=True)
original_file_content_str = original_file_content_str.content
original_file_content_str = original_file_content_str.content
except Exception as error:
get_logger().error("Failed to retrieve original file content of %s at version %s. Error: %s", file, version, str(error))
original_file_content_str = ""
patch = load_large_diff(file, new_file_content_str, original_file_content_str)
@ -158,7 +166,7 @@ class AzureDevopsProvider:
pull_request_id=self.pr_num,
git_pull_request_to_update=updated_pr)
except Exception as e:
logging.exception(f"Could not update pull request {self.pr_num} description: {e}")
get_logger().exception(f"Could not update pull request {self.pr_num} description: {e}")
def remove_initial_comment(self):
return "" # not implemented yet
@ -227,9 +235,6 @@ class AzureDevopsProvider:
def _parse_pr_url(pr_url: str) -> Tuple[str, int]:
parsed_url = urlparse(pr_url)
if 'azure.com' not in parsed_url.netloc:
raise ValueError("The provided URL is not a valid Azure DevOps URL")
path_parts = parsed_url.path.strip('/').split('/')
if len(path_parts) < 6 or path_parts[4] != 'pullrequest':

View File

@ -1,5 +1,4 @@
import json
import logging
from typing import Optional, Tuple
from urllib.parse import urlparse
@ -7,9 +6,10 @@ import requests
from atlassian.bitbucket import Cloud
from starlette_context import context
from ..algo.pr_processing import clip_tokens, find_line_number_of_relevant_line_in_file
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
from ..config_loader import get_settings
from .git_provider import FilePatchInfo, GitProvider
from ..log import get_logger
from .git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
class BitbucketProvider(GitProvider):
@ -32,8 +32,10 @@ class BitbucketProvider(GitProvider):
self.repo = None
self.pr_num = None
self.pr = None
self.pr_url = pr_url
self.temp_comments = []
self.incremental = incremental
self.diff_files = None
if pr_url:
self.set_pr(pr_url)
self.bitbucket_comment_api_url = self.pr._BitbucketBase__data["links"]["comments"]["href"]
@ -41,9 +43,12 @@ class BitbucketProvider(GitProvider):
def get_repo_settings(self):
try:
contents = self.repo_obj.get_contents(
".pr_agent.toml", ref=self.pr.head.sha
).decoded_content
url = (f"https://api.bitbucket.org/2.0/repositories/{self.workspace_slug}/{self.repo_slug}/src/"
f"{self.pr.destination_branch}/.pr_agent.toml")
response = requests.request("GET", url, headers=self.headers)
if response.status_code == 404: # not found
return ""
contents = response.text.encode('utf-8')
return contents
except Exception:
return ""
@ -61,14 +66,14 @@ class BitbucketProvider(GitProvider):
if not relevant_lines_start or relevant_lines_start == -1:
if get_settings().config.verbosity_level >= 2:
logging.exception(
get_logger().exception(
f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}"
)
continue
if relevant_lines_end < relevant_lines_start:
if get_settings().config.verbosity_level >= 2:
logging.exception(
get_logger().exception(
f"Failed to publish code suggestion, "
f"relevant_lines_end is {relevant_lines_end} and "
f"relevant_lines_start is {relevant_lines_start}"
@ -97,7 +102,7 @@ class BitbucketProvider(GitProvider):
return True
except Exception as e:
if get_settings().config.verbosity_level >= 2:
logging.error(f"Failed to publish code suggestion, error: {e}")
get_logger().error(f"Failed to publish code suggestion, error: {e}")
return False
def is_supported(self, capability: str) -> bool:
@ -113,6 +118,9 @@ class BitbucketProvider(GitProvider):
return [diff.new.path for diff in self.pr.diffstat()]
def get_diff_files(self) -> list[FilePatchInfo]:
if self.diff_files:
return self.diff_files
diffs = self.pr.diffstat()
diff_split = [
"diff --git%s" % x for x in self.pr.diff().split("diff --git") if x.strip()
@ -124,16 +132,56 @@ class BitbucketProvider(GitProvider):
diff.old.get_data("links")
)
new_file_content_str = self._get_pr_file_content(diff.new.get_data("links"))
diff_files.append(
FilePatchInfo(
original_file_content_str,
new_file_content_str,
diff_split[index],
diff.new.path,
)
file_patch_canonic_structure = FilePatchInfo(
original_file_content_str,
new_file_content_str,
diff_split[index],
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
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):
comment = self.pr.comment(pr_comment)
if is_temporary:
@ -142,17 +190,22 @@ class BitbucketProvider(GitProvider):
def remove_initial_comment(self):
try:
for comment in self.temp_comments:
self.pr.delete(f"comments/{comment}")
self.remove_comment(comment)
except Exception as e:
logging.exception(f"Failed to remove temp comments, error: {e}")
get_logger().exception(f"Failed to remove temp comments, error: {e}")
def remove_comment(self, comment):
try:
self.pr.delete(f"comments/{comment}")
except Exception as e:
get_logger().exception(f"Failed to remove comment, error: {e}")
# funtion to create_inline_comment
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
position, absolute_position = find_line_number_of_relevant_line_in_file(self.get_diff_files(), relevant_file.strip('`'), relevant_line_in_file)
if position == -1:
if get_settings().config.verbosity_level >= 2:
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
subject_type = "FILE"
else:
subject_type = "LINE"
@ -175,9 +228,44 @@ class BitbucketProvider(GitProvider):
)
return response
def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str:
if relevant_line_start == -1:
link = f"{self.pr_url}/#L{relevant_file}"
else:
link = f"{self.pr_url}/#L{relevant_file}T{relevant_line_start}"
return link
def generate_link_to_relevant_line_number(self, suggestion) -> str:
try:
relevant_file = suggestion['relevant file'].strip('`').strip("'")
relevant_line_str = suggestion['relevant line']
if not relevant_line_str:
return ""
diff_files = self.get_diff_files()
position, absolute_position = find_line_number_of_relevant_line_in_file \
(diff_files, relevant_file, relevant_line_str)
if absolute_position != -1 and self.pr_url:
link = f"{self.pr_url}/#L{relevant_file}T{absolute_position}"
return link
except Exception as e:
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"Failed adding line link, error: {e}")
return ""
def publish_inline_comments(self, comments: list[dict]):
for comment in comments:
self.publish_inline_comment(comment['body'], comment['start_line'], comment['path'])
if 'position' in comment:
self.publish_inline_comment(comment['body'], comment['position'], comment['path'])
elif 'start_line' in comment: # multi-line comment
# note that bitbucket does not seem to support range - only a comment on a single line - https://community.developer.atlassian.com/t/api-post-endpoint-for-inline-pull-request-comments/60452
self.publish_inline_comment(comment['body'], comment['start_line'], comment['path'])
elif 'line' in comment: # single-line comment
self.publish_inline_comment(comment['body'], comment['line'], comment['path'])
else:
get_logger().error(f"Could not publish inline comment {comment}")
def get_title(self):
return self.pr.title
@ -254,6 +342,11 @@ class BitbucketProvider(GitProvider):
})
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
# bitbucket does not support labels

View File

@ -0,0 +1,351 @@
import json
from typing import Optional, Tuple
from urllib.parse import urlparse
import requests
from atlassian.bitbucket import Bitbucket
from starlette_context import context
from .git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
from ..algo.utils import load_large_diff
from ..config_loader import get_settings
from ..log import get_logger
class BitbucketServerProvider(GitProvider):
def __init__(
self, pr_url: Optional[str] = None, incremental: Optional[bool] = False
):
s = requests.Session()
try:
bearer = context.get("bitbucket_bearer_token", None)
s.headers["Authorization"] = f"Bearer {bearer}"
except Exception:
s.headers[
"Authorization"
] = f'Bearer {get_settings().get("BITBUCKET_SERVER.BEARER_TOKEN", None)}'
s.headers["Content-Type"] = "application/json"
self.headers = s.headers
self.bitbucket_server_url = None
self.workspace_slug = None
self.repo_slug = None
self.repo = None
self.pr_num = None
self.pr = None
self.pr_url = pr_url
self.temp_comments = []
self.incremental = incremental
self.diff_files = None
self.bitbucket_pull_request_api_url = pr_url
self.bitbucket_server_url = self._parse_bitbucket_server(url=pr_url)
self.bitbucket_client = Bitbucket(url=self.bitbucket_server_url,
token=get_settings().get("BITBUCKET_SERVER.BEARER_TOKEN", None))
if pr_url:
self.set_pr(pr_url)
def get_repo_settings(self):
try:
url = (f"{self.bitbucket_server_url}/projects/{self.workspace_slug}/repos/{self.repo_slug}/src/"
f"{self.pr.destination_branch}/.pr_agent.toml")
response = requests.request("GET", url, headers=self.headers)
if response.status_code == 404: # not found
return ""
contents = response.text.encode('utf-8')
return contents
except Exception:
return ""
def publish_code_suggestions(self, code_suggestions: list) -> bool:
"""
Publishes code suggestions as comments on the PR.
"""
post_parameters_list = []
for suggestion in code_suggestions:
body = suggestion["body"]
relevant_file = suggestion["relevant_file"]
relevant_lines_start = suggestion["relevant_lines_start"]
relevant_lines_end = suggestion["relevant_lines_end"]
if not relevant_lines_start or relevant_lines_start == -1:
if get_settings().config.verbosity_level >= 2:
get_logger().exception(
f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}"
)
continue
if relevant_lines_end < relevant_lines_start:
if get_settings().config.verbosity_level >= 2:
get_logger().exception(
f"Failed to publish code suggestion, "
f"relevant_lines_end is {relevant_lines_end} and "
f"relevant_lines_start is {relevant_lines_start}"
)
continue
if relevant_lines_end > relevant_lines_start:
post_parameters = {
"body": body,
"path": relevant_file,
"line": relevant_lines_end,
"start_line": relevant_lines_start,
"start_side": "RIGHT",
}
else: # API is different for single line comments
post_parameters = {
"body": body,
"path": relevant_file,
"line": relevant_lines_start,
"side": "RIGHT",
}
post_parameters_list.append(post_parameters)
try:
self.publish_inline_comments(post_parameters_list)
return True
except Exception as e:
if get_settings().config.verbosity_level >= 2:
get_logger().error(f"Failed to publish code suggestion, error: {e}")
return False
def is_supported(self, capability: str) -> bool:
if capability in ['get_issue_comments', 'get_labels', 'gfm_markdown']:
return False
return True
def set_pr(self, pr_url: str):
self.workspace_slug, self.repo_slug, self.pr_num = self._parse_pr_url(pr_url)
self.pr = self._get_pr()
def get_file(self, path: str, commit_id: str):
file_content = ""
try:
file_content = self.bitbucket_client.get_content_of_file(self.workspace_slug,
self.repo_slug,
path,
commit_id)
except requests.HTTPError as e:
get_logger().debug(f"File {path} not found at commit id: {commit_id}")
return file_content
def get_files(self):
changes = self.bitbucket_client.get_pull_requests_changes(self.workspace_slug, self.repo_slug, self.pr_num)
diffstat = [change["path"]['toString'] for change in changes]
return diffstat
def get_diff_files(self) -> list[FilePatchInfo]:
if self.diff_files:
return self.diff_files
commits_in_pr = self.bitbucket_client.get_pull_requests_commits(
self.workspace_slug,
self.repo_slug,
self.pr_num
)
commit_list = list(commits_in_pr)
base_sha, head_sha = commit_list[0]['parents'][0]['id'], commit_list[-1]['id']
diff_files = []
original_file_content_str = ""
new_file_content_str = ""
changes = self.bitbucket_client.get_pull_requests_changes(self.workspace_slug, self.repo_slug, self.pr_num)
for change in changes:
file_path = change['path']['toString']
match change['type']:
case 'ADD':
edit_type = EDIT_TYPE.ADDED
new_file_content_str = self.get_file(file_path, head_sha)
if isinstance(new_file_content_str, (bytes, bytearray)):
new_file_content_str = new_file_content_str.decode("utf-8")
original_file_content_str = ""
case 'DELETE':
edit_type = EDIT_TYPE.DELETED
new_file_content_str = ""
original_file_content_str = self.get_file(file_path, base_sha)
if isinstance(original_file_content_str, (bytes, bytearray)):
original_file_content_str = original_file_content_str.decode("utf-8")
case 'RENAME':
edit_type = EDIT_TYPE.RENAMED
case _:
edit_type = EDIT_TYPE.MODIFIED
original_file_content_str = self.get_file(file_path, base_sha)
if isinstance(original_file_content_str, (bytes, bytearray)):
original_file_content_str = original_file_content_str.decode("utf-8")
new_file_content_str = self.get_file(file_path, head_sha)
if isinstance(new_file_content_str, (bytes, bytearray)):
new_file_content_str = new_file_content_str.decode("utf-8")
patch = load_large_diff(file_path, new_file_content_str, original_file_content_str)
diff_files.append(
FilePatchInfo(
original_file_content_str,
new_file_content_str,
patch,
file_path,
edit_type=edit_type,
)
)
self.diff_files = diff_files
return diff_files
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
if not is_temporary:
self.bitbucket_client.add_pull_request_comment(self.workspace_slug, self.repo_slug, self.pr_num, pr_comment)
def remove_initial_comment(self):
try:
for comment in self.temp_comments:
self.remove_comment(comment)
except ValueError as e:
get_logger().exception(f"Failed to remove temp comments, error: {e}")
def remove_comment(self, comment):
pass
# funtion to create_inline_comment
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
position, absolute_position = find_line_number_of_relevant_line_in_file(
self.get_diff_files(),
relevant_file.strip('`'),
relevant_line_in_file
)
if position == -1:
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
subject_type = "FILE"
else:
subject_type = "LINE"
path = relevant_file.strip()
return dict(body=body, path=path, position=absolute_position) if subject_type == "LINE" else {}
def publish_inline_comment(self, comment: str, from_line: int, file: str):
payload = {
"text": comment,
"severity": "NORMAL",
"anchor": {
"diffType": "EFFECTIVE",
"path": file,
"lineType": "ADDED",
"line": from_line,
"fileType": "TO"
}
}
response = requests.post(url=self._get_pr_comments_url(), json=payload, headers=self.headers)
return response
def generate_link_to_relevant_line_number(self, suggestion) -> str:
try:
relevant_file = suggestion['relevant file'].strip('`').strip("'")
relevant_line_str = suggestion['relevant line']
if not relevant_line_str:
return ""
diff_files = self.get_diff_files()
position, absolute_position = find_line_number_of_relevant_line_in_file \
(diff_files, relevant_file, relevant_line_str)
if absolute_position != -1 and self.pr_url:
link = f"{self.pr_url}/#L{relevant_file}T{absolute_position}"
return link
except Exception as e:
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"Failed adding line link, error: {e}")
return ""
def publish_inline_comments(self, comments: list[dict]):
for comment in comments:
self.publish_inline_comment(comment['body'], comment['position'], comment['path'])
def get_title(self):
return self.pr.title
def get_languages(self):
return {"yaml": 0} # devops LOL
def get_pr_branch(self):
return self.pr.fromRef['displayId']
def get_pr_description_full(self):
return self.pr.description
def get_user_id(self):
return 0
def get_issue_comments(self):
raise NotImplementedError(
"Bitbucket provider does not support issue comments yet"
)
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
return True
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
return True
@staticmethod
def _parse_bitbucket_server(url: str) -> str:
parsed_url = urlparse(url)
return f"{parsed_url.scheme}://{parsed_url.netloc}"
@staticmethod
def _parse_pr_url(pr_url: str) -> Tuple[str, str, int]:
parsed_url = urlparse(pr_url)
path_parts = parsed_url.path.strip("/").split("/")
if len(path_parts) < 6 or path_parts[4] != "pull-requests":
raise ValueError(
"The provided URL does not appear to be a Bitbucket PR URL"
)
workspace_slug = path_parts[1]
repo_slug = path_parts[3]
try:
pr_number = int(path_parts[5])
except ValueError as e:
raise ValueError("Unable to convert PR number to integer") from e
return workspace_slug, repo_slug, pr_number
def _get_repo(self):
if self.repo is None:
self.repo = self.bitbucket_client.get_repo(self.workspace_slug, self.repo_slug)
return self.repo
def _get_pr(self):
pr = self.bitbucket_client.get_pull_request(self.workspace_slug, self.repo_slug, pull_request_id=self.pr_num)
return type('new_dict', (object,), pr)
def _get_pr_file_content(self, remote_link: str):
return ""
def get_commit_messages(self):
def get_commit_messages(self):
raise NotImplementedError("Get commit messages function not implemented yet.")
# bitbucket does not support labels
def publish_description(self, pr_title: str, description: str):
payload = json.dumps({
"description": description,
"title": pr_title
})
response = requests.put(url=self.bitbucket_pull_request_api_url, headers=self.headers, data=payload)
return response
# bitbucket does not support labels
def publish_labels(self, pr_types: list):
pass
# bitbucket does not support labels
def get_labels(self):
pass
def _get_pr_comments_url(self):
return f"{self.bitbucket_server_url}/rest/api/latest/projects/{self.workspace_slug}/repos/{self.repo_slug}/pull-requests/{self.pr_num}/comments"

View File

@ -1,17 +1,16 @@
import logging
import os
import re
from collections import Counter
from typing import List, Optional, Tuple
from urllib.parse import urlparse
from ..algo.language_handler import is_valid_file, language_extension_map
from ..algo.pr_processing import clip_tokens
from ..algo.utils import load_large_diff
from ..config_loader import get_settings
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider, IncrementalPR
from pr_agent.git_providers.codecommit_client import CodeCommitClient
from ..algo.utils import load_large_diff
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
from ..config_loader import get_settings
from ..log import get_logger
class PullRequestCCMimic:
"""
@ -62,6 +61,7 @@ class CodeCommitProvider(GitProvider):
self.pr = None
self.diff_files = None
self.git_files = None
self.pr_url = pr_url
if pr_url:
self.set_pr(pr_url)
@ -166,7 +166,7 @@ class CodeCommitProvider(GitProvider):
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
if is_temporary:
logging.info(pr_comment)
get_logger().info(pr_comment)
return
pr_comment = CodeCommitProvider._remove_markdown_html(pr_comment)
@ -188,12 +188,12 @@ class CodeCommitProvider(GitProvider):
for suggestion in code_suggestions:
# Verify that each suggestion has the required keys
if not all(key in suggestion for key in ["body", "relevant_file", "relevant_lines_start"]):
logging.warning(f"Skipping code suggestion #{counter}: Each suggestion must have 'body', 'relevant_file', 'relevant_lines_start' keys")
get_logger().warning(f"Skipping code suggestion #{counter}: Each suggestion must have 'body', 'relevant_file', 'relevant_lines_start' keys")
continue
# Publish the code suggestion to CodeCommit
try:
logging.debug(f"Code Suggestion #{counter} in file: {suggestion['relevant_file']}: {suggestion['relevant_lines_start']}")
get_logger().debug(f"Code Suggestion #{counter} in file: {suggestion['relevant_file']}: {suggestion['relevant_lines_start']}")
self.codecommit_client.publish_comment(
repo_name=self.repo_name,
pr_number=self.pr_num,
@ -222,6 +222,9 @@ class CodeCommitProvider(GitProvider):
def remove_initial_comment(self):
return "" # not implemented yet
def remove_comment(self, comment):
return "" # not implemented yet
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/post_comment_for_compared_commit.html
raise NotImplementedError("CodeCommit provider does not support publishing inline comments yet")
@ -267,6 +270,8 @@ class CodeCommitProvider(GitProvider):
# where each dictionary item is a language name.
# We build that language->extension dictionary here in main_extensions_flat.
main_extensions_flat = {}
language_extension_map_org = get_settings().language_extension_map_org
language_extension_map = {k.lower(): v for k, v in language_extension_map_org.items()}
for language, extensions in language_extension_map.items():
for ext in extensions:
main_extensions_flat[ext] = language
@ -296,11 +301,11 @@ class CodeCommitProvider(GitProvider):
return self.codecommit_client.get_file(self.repo_name, settings_filename, self.pr.source_commit, optional=True)
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
logging.info("CodeCommit provider does not support eyes reaction yet")
get_logger().info("CodeCommit provider does not support eyes reaction yet")
return True
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
logging.info("CodeCommit provider does not support removing reactions yet")
get_logger().info("CodeCommit provider does not support removing reactions yet")
return True
@staticmethod
@ -366,7 +371,7 @@ class CodeCommitProvider(GitProvider):
# TODO: implement support for multiple targets in one CodeCommit PR
# for now, we are only using the first target in the PR
if len(response.targets) > 1:
logging.warning(
get_logger().warning(
"Multiple targets in one PR is not supported for CodeCommit yet. Continuing, using the first target only..."
)

View File

@ -1,5 +1,4 @@
import json
import logging
import os
import pathlib
import shutil
@ -7,18 +6,16 @@ import subprocess
import uuid
from collections import Counter, namedtuple
from pathlib import Path
from tempfile import mkdtemp, NamedTemporaryFile
from tempfile import NamedTemporaryFile, mkdtemp
import requests
import urllib3.util
from git import Repo
from pr_agent.config_loader import get_settings
from pr_agent.git_providers.git_provider import GitProvider, FilePatchInfo, \
EDIT_TYPE
from pr_agent.git_providers.git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
from pr_agent.git_providers.local_git_provider import PullRequestMimic
logger = logging.getLogger(__name__)
from pr_agent.log import get_logger
def _call(*command, **kwargs) -> (int, str, str):
@ -33,42 +30,42 @@ def _call(*command, **kwargs) -> (int, str, str):
def clone(url, directory):
logger.info("Cloning %s to %s", url, directory)
get_logger().info("Cloning %s to %s", url, directory)
stdout = _call('git', 'clone', "--depth", "1", url, directory)
logger.info(stdout)
get_logger().info(stdout)
def fetch(url, refspec, cwd):
logger.info("Fetching %s %s", url, refspec)
get_logger().info("Fetching %s %s", url, refspec)
stdout = _call(
'git', 'fetch', '--depth', '2', url, refspec,
cwd=cwd
)
logger.info(stdout)
get_logger().info(stdout)
def checkout(cwd):
logger.info("Checking out")
get_logger().info("Checking out")
stdout = _call('git', 'checkout', "FETCH_HEAD", cwd=cwd)
logger.info(stdout)
get_logger().info(stdout)
def show(*args, cwd=None):
logger.info("Show")
get_logger().info("Show")
return _call('git', 'show', *args, cwd=cwd)
def diff(*args, cwd=None):
logger.info("Diff")
get_logger().info("Diff")
patch = _call('git', 'diff', *args, cwd=cwd)
if not patch:
logger.warning("No changes found")
get_logger().warning("No changes found")
return
return patch
def reset_local_changes(cwd):
logger.info("Reset local changes")
get_logger().info("Reset local changes")
_call('git', 'checkout', "--force", cwd=cwd)
@ -195,7 +192,7 @@ class GerritProvider(GitProvider):
)
self.repo = Repo(self.repo_path)
assert self.repo
self.pr_url = base_url
self.pr = PullRequestMimic(self.get_pr_title(), self.get_diff_files())
def get_pr_title(self):
@ -399,5 +396,8 @@ class GerritProvider(GitProvider):
# shutil.rmtree(self.repo_path)
pass
def remove_comment(self, comment):
pass
def get_pr_branch(self):
return self.repo.head

View File

@ -1,4 +1,3 @@
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
@ -6,12 +5,16 @@ from dataclasses import dataclass
from enum import Enum
from typing import Optional
from pr_agent.config_loader import get_settings
from pr_agent.log import get_logger
class EDIT_TYPE(Enum):
ADDED = 1
DELETED = 2
MODIFIED = 3
RENAMED = 4
UNKNOWN = 5
@dataclass
@ -21,8 +24,10 @@ class FilePatchInfo:
patch: str
filename: str
tokens: int = -1
edit_type: EDIT_TYPE = EDIT_TYPE.MODIFIED
edit_type: EDIT_TYPE = EDIT_TYPE.UNKNOWN
old_filename: str = None
num_plus_lines: int = -1
num_minus_lines: int = -1
class GitProvider(ABC):
@ -38,38 +43,10 @@ class GitProvider(ABC):
def publish_description(self, pr_title: str, pr_body: str):
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
def publish_code_suggestions(self, code_suggestions: list) -> bool:
pass
@abstractmethod
def publish_labels(self, labels):
pass
@abstractmethod
def get_labels(self):
pass
@abstractmethod
def remove_initial_comment(self):
pass
@abstractmethod
def get_languages(self):
pass
@ -88,17 +65,17 @@ class GitProvider(ABC):
def get_pr_description(self, *, full: bool = True) -> str:
from pr_agent.config_loader import get_settings
from pr_agent.algo.pr_processing import clip_tokens
max_tokens = get_settings().get("CONFIG.MAX_DESCRIPTION_TOKENS", None)
from pr_agent.algo.utils import clip_tokens
max_tokens_description = get_settings().get("CONFIG.MAX_DESCRIPTION_TOKENS", None)
description = self.get_pr_description_full() if full else self.get_user_description()
if max_tokens:
return clip_tokens(description, max_tokens)
if max_tokens_description:
return clip_tokens(description, max_tokens_description)
return description
def get_user_description(self) -> str:
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 not description.startswith("## PR Type"):
if not any(description.startswith(header) for header in ("## PR Type", "## PR Description")):
return 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
@ -108,11 +85,57 @@ class GitProvider(ABC):
return description.split("## User Description:", 1)[1].strip()
@abstractmethod
def get_issue_comments(self):
def get_repo_settings(self):
pass
def get_pr_id(self):
return ""
def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str:
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
@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
@abstractmethod
@ -123,11 +146,12 @@ class GitProvider(ABC):
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
pass
#### commits operations ####
@abstractmethod
def get_commit_messages(self):
pass
def get_pr_id(self):
def get_latest_commit_url(self) -> str:
return ""
def get_main_pr_language(languages, files) -> str:
@ -136,7 +160,10 @@ def get_main_pr_language(languages, files) -> str:
"""
main_language_str = ""
if not languages:
logging.info("No languages detected")
get_logger().info("No languages detected")
return main_language_str
if not files:
get_logger().info("No files in diff")
return main_language_str
try:
@ -145,34 +172,52 @@ def get_main_pr_language(languages, files) -> str:
# validate that the specific commit uses the main language
extension_list = []
for file in files:
if not file:
continue
if isinstance(file, str):
file = FilePatchInfo(base_file=None, head_file=None, patch=None, filename=file)
extension_list.append(file.filename.rsplit('.')[-1])
# get the most common extension
most_common_extension = max(set(extension_list), key=extension_list.count)
most_common_extension = '.' + max(set(extension_list), key=extension_list.count)
try:
language_extension_map_org = get_settings().language_extension_map_org
language_extension_map = {k.lower(): v for k, v in language_extension_map_org.items()}
# look for a match. TBD: add more languages, do this systematically
if most_common_extension == 'py' and top_language == 'python' or \
most_common_extension == 'js' and top_language == 'javascript' or \
most_common_extension == 'ts' and top_language == 'typescript' or \
most_common_extension == 'go' and top_language == 'go' or \
most_common_extension == 'java' and top_language == 'java' or \
most_common_extension == 'c' and top_language == 'c' or \
most_common_extension == 'cpp' and top_language == 'c++' or \
most_common_extension == 'cs' and top_language == 'c#' or \
most_common_extension == 'swift' and top_language == 'swift' or \
most_common_extension == 'php' and top_language == 'php' or \
most_common_extension == 'rb' and top_language == 'ruby' or \
most_common_extension == 'rs' and top_language == 'rust' or \
most_common_extension == 'scala' and top_language == 'scala' or \
most_common_extension == 'kt' and top_language == 'kotlin' or \
most_common_extension == 'pl' and top_language == 'perl' or \
most_common_extension == top_language:
main_language_str = top_language
if top_language in language_extension_map and most_common_extension in language_extension_map[top_language]:
main_language_str = top_language
else:
for language, extensions in language_extension_map.items():
if most_common_extension in extensions:
main_language_str = language
break
except Exception as e:
get_logger().exception(f"Failed to get main language: {e}")
pass
## old approach:
# most_common_extension = max(set(extension_list), key=extension_list.count)
# if most_common_extension == 'py' and top_language == 'python' or \
# most_common_extension == 'js' and top_language == 'javascript' or \
# most_common_extension == 'ts' and top_language == 'typescript' or \
# most_common_extension == 'tsx' and top_language == 'typescript' or \
# most_common_extension == 'go' and top_language == 'go' or \
# most_common_extension == 'java' and top_language == 'java' or \
# most_common_extension == 'c' and top_language == 'c' or \
# most_common_extension == 'cpp' and top_language == 'c++' or \
# most_common_extension == 'cs' and top_language == 'c#' or \
# most_common_extension == 'swift' and top_language == 'swift' or \
# most_common_extension == 'php' and top_language == 'php' or \
# most_common_extension == 'rb' and top_language == 'ruby' or \
# most_common_extension == 'rs' and top_language == 'rust' or \
# most_common_extension == 'scala' and top_language == 'scala' or \
# most_common_extension == 'kt' and top_language == 'kotlin' or \
# most_common_extension == 'pl' and top_language == 'perl' or \
# most_common_extension == top_language:
# main_language_str = top_language
except Exception as e:
logging.exception(e)
get_logger().exception(e)
pass
return main_language_str
@ -182,6 +227,13 @@ class IncrementalPR:
def __init__(self, is_incremental: bool = False):
self.is_incremental = is_incremental
self.commits_range = None
self.first_new_commit_sha = None
self.last_seen_commit_sha = None
self.first_new_commit = None
self.last_seen_commit = None
@property
def first_new_commit_sha(self):
return None if self.first_new_commit is None else self.first_new_commit.sha
@property
def last_seen_commit_sha(self):
return None if self.last_seen_commit is None else self.last_seen_commit.sha

View File

@ -1,20 +1,19 @@
import logging
import hashlib
from datetime import datetime
from typing import Optional, Tuple, Any
from typing import Optional, Tuple
from urllib.parse import urlparse
from github import AppAuthentication, Auth, Github, GithubException, Reaction
from github import AppAuthentication, Auth, Github, GithubException
from retry import retry
from starlette_context import context
from .git_provider import FilePatchInfo, GitProvider, IncrementalPR
from ..algo.language_handler import is_valid_file
from ..algo.utils import load_large_diff
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file, clip_tokens
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
from ..algo.utils import load_large_diff, clip_tokens
from ..config_loader import get_settings
from ..log import get_logger
from ..servers.utils import RateLimitExceeded
from .git_provider import FilePatchInfo, GitProvider, IncrementalPR, EDIT_TYPE
class GithubProvider(GitProvider):
@ -35,6 +34,7 @@ class GithubProvider(GitProvider):
if pr_url and 'pull' in pr_url:
self.set_pr(pr_url)
self.last_commit_id = list(self.pr.get_commits())[-1]
self.pr_url = self.get_pr_url() # pr_url for github actions can be as api.github.com, so we need to get the url from the pr object
def is_supported(self, capability: str) -> bool:
return True
@ -51,36 +51,44 @@ class GithubProvider(GitProvider):
def get_incremental_commits(self):
self.commits = list(self.pr.get_commits())
self.get_previous_review()
self.previous_review = self.get_previous_review(full=True, incremental=True)
if self.previous_review:
self.incremental.commits_range = self.get_commit_range()
# Get all files changed during the commit range
self.file_set = dict()
for commit in self.incremental.commits_range:
if commit.commit.message.startswith(f"Merge branch '{self._get_repo().default_branch}'"):
logging.info(f"Skipping merge commit {commit.commit.message}")
get_logger().info(f"Skipping merge commit {commit.commit.message}")
continue
self.file_set.update({file.filename: file for file in commit.files})
else:
raise ValueError("No previous review found")
def get_commit_range(self):
last_review_time = self.previous_review.created_at
first_new_commit_index = 0
first_new_commit_index = None
for index in range(len(self.commits) - 1, -1, -1):
if self.commits[index].commit.author.date > last_review_time:
self.incremental.first_new_commit_sha = self.commits[index].sha
self.incremental.first_new_commit = self.commits[index]
first_new_commit_index = index
else:
self.incremental.last_seen_commit_sha = self.commits[index].sha
self.incremental.last_seen_commit = self.commits[index]
break
return self.commits[first_new_commit_index:]
return self.commits[first_new_commit_index:] if first_new_commit_index is not None else []
def get_previous_review(self):
self.previous_review = None
self.comments = list(self.pr.get_issue_comments())
def get_previous_review(self, *, full: bool, incremental: bool):
if not (full or incremental):
raise ValueError("At least one of full or incremental must be True")
if not getattr(self, "comments", None):
self.comments = list(self.pr.get_issue_comments())
prefixes = []
if full:
prefixes.append("## PR Analysis")
if incremental:
prefixes.append("## Incremental PR Review")
for index in range(len(self.comments) - 1, -1, -1):
if self.comments[index].body.startswith("## PR Analysis"):
self.previous_review = self.comments[index]
break
if any(self.comments[index].body.startswith(prefix) for prefix in prefixes):
return self.comments[index]
def get_files(self):
if self.incremental.is_incremental and self.file_set:
@ -124,22 +132,68 @@ class GithubProvider(GitProvider):
if not patch:
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
# 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('-')])
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,
num_minus_lines=num_minus_lines,)
diff_files.append(file_patch_canonical_structure)
self.diff_files = diff_files
return diff_files
except GithubException.RateLimitExceededException as e:
logging.error(f"Rate limit exceeded for GitHub API. Original message: {e}")
get_logger().error(f"Rate limit exceeded for GitHub API. Original message: {e}")
raise RateLimitExceeded("Rate limit exceeded for GitHub API.") from e
def publish_description(self, pr_title: str, pr_body: str):
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):
if is_temporary and not get_settings().config.publish_output_progress:
logging.debug(f"Skipping publish_comment for temporary comment: {pr_comment}")
get_logger().debug(f"Skipping publish_comment for temporary comment: {pr_comment}")
return
response = self.pr.create_issue_comment(pr_comment)
if hasattr(response, "user") and hasattr(response.user, "login"):
self.github_user_id = response.user.login
@ -156,7 +210,7 @@ class GithubProvider(GitProvider):
position, absolute_position = find_line_number_of_relevant_line_in_file(self.diff_files, relevant_file.strip('`'), relevant_line_in_file)
if position == -1:
if get_settings().config.verbosity_level >= 2:
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
subject_type = "FILE"
else:
subject_type = "LINE"
@ -179,13 +233,13 @@ class GithubProvider(GitProvider):
if not relevant_lines_start or relevant_lines_start == -1:
if get_settings().config.verbosity_level >= 2:
logging.exception(
get_logger().exception(
f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}")
continue
if relevant_lines_end < relevant_lines_start:
if get_settings().config.verbosity_level >= 2:
logging.exception(f"Failed to publish code suggestion, "
get_logger().exception(f"Failed to publish code suggestion, "
f"relevant_lines_end is {relevant_lines_end} and "
f"relevant_lines_start is {relevant_lines_start}")
continue
@ -212,16 +266,22 @@ class GithubProvider(GitProvider):
return True
except Exception as e:
if get_settings().config.verbosity_level >= 2:
logging.error(f"Failed to publish code suggestion, error: {e}")
get_logger().error(f"Failed to publish code suggestion, error: {e}")
return False
def remove_initial_comment(self):
try:
for comment in getattr(self.pr, 'comments_list', []):
if comment.is_temporary:
comment.delete()
self.remove_comment(comment)
except Exception as e:
logging.exception(f"Failed to remove initial comment, error: {e}")
get_logger().exception(f"Failed to remove initial comment, error: {e}")
def remove_comment(self, comment):
try:
comment.delete()
except Exception as e:
get_logger().exception(f"Failed to remove comment, error: {e}")
def get_title(self):
return self.pr.title
@ -259,7 +319,10 @@ class GithubProvider(GitProvider):
def get_repo_settings(self):
try:
contents = self.repo_obj.get_contents(".pr_agent.toml", ref=self.pr.head.sha).decoded_content
# contents = self.repo_obj.get_contents(".pr_agent.toml", ref=self.pr.head.sha).decoded_content
# more logical to take 'pr_agent.toml' from the default branch
contents = self.repo_obj.get_contents(".pr_agent.toml").decoded_content
return contents
except Exception:
return ""
@ -269,7 +332,7 @@ class GithubProvider(GitProvider):
reaction = self.pr.get_issue_comment(issue_comment_id).create_reaction("eyes")
return reaction.id
except Exception as e:
logging.exception(f"Failed to add eyes reaction, error: {e}")
get_logger().exception(f"Failed to add eyes reaction, error: {e}")
return None
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
@ -277,7 +340,7 @@ class GithubProvider(GitProvider):
self.pr.get_issue_comment(issue_comment_id).delete_reaction(reaction_id)
return True
except Exception as e:
logging.exception(f"Failed to remove eyes reaction, error: {e}")
get_logger().exception(f"Failed to remove eyes reaction, error: {e}")
return False
@ -352,7 +415,7 @@ class GithubProvider(GitProvider):
raise ValueError("GitHub app installation ID is required when using GitHub app deployment")
auth = AppAuthentication(app_id=app_id, private_key=private_key,
installation_id=self.installation_id)
return Github(app_auth=auth)
return Github(app_auth=auth, base_url=get_settings().github.base_url)
if deployment_type == 'user':
try:
@ -361,7 +424,7 @@ class GithubProvider(GitProvider):
raise ValueError(
"GitHub token is required when using user deployment. See: "
"https://github.com/Codium-ai/pr-agent#method-2-run-from-source") from e
return Github(auth=Auth.Token(token))
return Github(auth=Auth.Token(token), base_url=get_settings().github.base_url)
def _get_repo(self):
if hasattr(self, 'repo_obj') and \
@ -386,7 +449,7 @@ class GithubProvider(GitProvider):
def publish_labels(self, pr_types):
try:
label_color_map = {"Bug fix": "1d76db", "Tests": "e99695", "Bug fix with tests": "c5def5",
"Refactoring": "bfdadc", "Enhancement": "bfd4f2", "Documentation": "d4c5f9",
"Enhancement": "bfd4f2", "Documentation": "d4c5f9",
"Other": "d1bcf9"}
post_parameters = []
for p in pr_types:
@ -396,13 +459,13 @@ class GithubProvider(GitProvider):
"PUT", f"{self.pr.issue_url}/labels", input=post_parameters
)
except Exception as e:
logging.exception(f"Failed to publish labels, error: {e}")
get_logger().exception(f"Failed to publish labels, error: {e}")
def get_labels(self):
try:
return [label.name for label in self.pr.labels]
except Exception as e:
logging.exception(f"Failed to get labels, error: {e}")
get_logger().exception(f"Failed to get labels, error: {e}")
return []
def get_commit_messages(self):
@ -444,10 +507,21 @@ class GithubProvider(GitProvider):
return link
except Exception as e:
if get_settings().config.verbosity_level >= 2:
logging.info(f"Failed adding line link, error: {e}")
get_logger().info(f"Failed adding line link, error: {e}")
return ""
def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str:
sha_file = hashlib.sha256(relevant_file.encode('utf-8')).hexdigest()
if relevant_line_start == -1:
link = f"https://github.com/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}"
elif relevant_line_end:
link = f"https://github.com/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{relevant_line_start}-R{relevant_line_end}"
else:
link = f"https://github.com/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{relevant_line_start}"
return link
def get_pr_id(self):
try:
pr_id = f"{self.repo}/{self.pr_num}"

View File

@ -1,4 +1,4 @@
import logging
import hashlib
import re
from typing import Optional, Tuple
from urllib.parse import urlparse
@ -7,12 +7,12 @@ import gitlab
from gitlab import GitlabGetError
from ..algo.language_handler import is_valid_file
from ..algo.pr_processing import clip_tokens
from ..algo.utils import load_large_diff
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
from ..algo.utils import load_large_diff, clip_tokens
from ..config_loader import get_settings
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
from ..log import get_logger
logger = logging.getLogger()
class DiffNotFoundError(Exception):
"""Raised when the diff for a merge request cannot be found."""
@ -37,13 +37,14 @@ class GitLabProvider(GitProvider):
self.diff_files = None
self.git_files = None
self.temp_comments = []
self.pr_url = merge_request_url
self._set_merge_request(merge_request_url)
self.RE_HUNK_HEADER = re.compile(
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
self.incremental = incremental
def is_supported(self, capability: str) -> bool:
if capability in ['get_issue_comments', 'create_inline_comment', 'publish_inline_comments', 'gfm_markdown']:
if capability in ['get_issue_comments', 'create_inline_comment', 'publish_inline_comments']: # gfm_markdown is supported in gitlab !
return False
return True
@ -58,7 +59,7 @@ class GitLabProvider(GitProvider):
try:
self.last_diff = self.mr.diffs.list(get_all=True)[-1]
except IndexError as e:
logger.error(f"Could not get diff for merge request {self.id_mr}")
get_logger().error(f"Could not get diff for merge request {self.id_mr}")
raise DiffNotFoundError(f"Could not get diff for merge request {self.id_mr}") from e
@ -98,7 +99,7 @@ class GitLabProvider(GitProvider):
if isinstance(new_file_content_str, bytes):
new_file_content_str = bytes.decode(new_file_content_str, 'utf-8')
except UnicodeDecodeError:
logging.warning(
get_logger().warning(
f"Cannot decode file {diff['old_path']} or {diff['new_path']} in merge request {self.id_mr}")
edit_type = EDIT_TYPE.MODIFIED
@ -114,12 +115,20 @@ class GitLabProvider(GitProvider):
if not patch:
patch = load_large_diff(filename, new_file_content_str, original_file_content_str)
# 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('-')])
diff_files.append(
FilePatchInfo(original_file_content_str, new_file_content_str,
patch=patch,
filename=filename,
edit_type=edit_type,
old_filename=None if diff['old_path'] == diff['new_path'] else diff['old_path']))
old_filename=None if diff['old_path'] == diff['new_path'] else diff['old_path'],
num_plus_lines=num_plus_lines,
num_minus_lines=num_minus_lines, ))
self.diff_files = diff_files
return diff_files
@ -134,7 +143,34 @@ class GitLabProvider(GitProvider):
self.mr.description = pr_body
self.mr.save()
except Exception as e:
logging.exception(f"Could not update merge request {self.id_mr} description: {e}")
get_logger().exception(f"Could not update merge request {self.id_mr} description: {e}")
def 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):
comment = self.mr.notes.create({'body': mr_comment})
@ -156,12 +192,12 @@ class GitLabProvider(GitProvider):
def send_inline_comment(self,body: str,edit_type: str,found: bool,relevant_file: str,relevant_line_in_file: int,
source_line_no: int, target_file: str,target_line_no: int) -> None:
if not found:
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
else:
# in order to have exact sha's we have to find correct diff for this change
diff = self.get_relevant_diff(relevant_file, relevant_line_in_file)
if diff is None:
logger.error(f"Could not get diff for merge request {self.id_mr}")
get_logger().error(f"Could not get diff for merge request {self.id_mr}")
raise DiffNotFoundError(f"Could not get diff for merge request {self.id_mr}")
pos_obj = {'position_type': 'text',
'new_path': target_file.filename,
@ -174,24 +210,23 @@ class GitLabProvider(GitProvider):
else:
pos_obj['new_line'] = target_line_no - 1
pos_obj['old_line'] = source_line_no - 1
logging.debug(f"Creating comment in {self.id_mr} with body {body} and position {pos_obj}")
self.mr.discussions.create({'body': body,
'position': pos_obj})
get_logger().debug(f"Creating comment in {self.id_mr} with body {body} and position {pos_obj}")
self.mr.discussions.create({'body': body, 'position': pos_obj})
def get_relevant_diff(self, relevant_file: str, relevant_line_in_file: int) -> Optional[dict]:
changes = self.mr.changes() # Retrieve the changes for the merge request once
if not changes:
logging.error('No changes found for the merge request.')
get_logger().error('No changes found for the merge request.')
return None
all_diffs = self.mr.diffs.list(get_all=True)
if not all_diffs:
logging.error('No diffs found for the merge request.')
get_logger().error('No diffs found for the merge request.')
return None
for diff in all_diffs:
for change in changes['changes']:
if change['new_path'] == relevant_file and relevant_line_in_file in change['diff']:
return diff
logging.debug(
get_logger().debug(
f'No relevant diff found for {relevant_file} {relevant_line_in_file}. Falling back to last diff.')
return self.last_diff # fallback to last_diff if no relevant diff is found
@ -226,7 +261,10 @@ class GitLabProvider(GitProvider):
self.send_inline_comment(body, edit_type, found, relevant_file, relevant_line_in_file, source_line_no,
target_file, target_line_no)
except Exception as e:
logging.exception(f"Could not publish code suggestion:\nsuggestion: {suggestion}\nerror: {e}")
get_logger().exception(f"Could not publish code suggestion:\nsuggestion: {suggestion}\nerror: {e}")
# note that we publish suggestions one-by-one. so, if one fails, the rest will still be published
return True
def search_line(self, relevant_file, relevant_line_in_file):
target_file = None
@ -285,9 +323,15 @@ class GitLabProvider(GitProvider):
def remove_initial_comment(self):
try:
for comment in self.temp_comments:
comment.delete()
self.remove_comment(comment)
except Exception as e:
logging.exception(f"Failed to remove temp comments, error: {e}")
get_logger().exception(f"Failed to remove temp comments, error: {e}")
def remove_comment(self, comment):
try:
comment.delete()
except Exception as e:
get_logger().exception(f"Failed to remove comment, error: {e}")
def get_title(self):
return self.mr.title
@ -307,7 +351,7 @@ class GitLabProvider(GitProvider):
def get_repo_settings(self):
try:
contents = self.gl.projects.get(self.id_project).files.get(file_path='.pr_agent.toml', ref=self.mr.source_branch)
contents = self.gl.projects.get(self.id_project).files.get(file_path='.pr_agent.toml', ref=self.mr.target_branch).decode()
return contents
except Exception:
return ""
@ -355,7 +399,7 @@ class GitLabProvider(GitProvider):
self.mr.labels = list(set(pr_types))
self.mr.save()
except Exception as e:
logging.exception(f"Failed to publish labels, error: {e}")
get_logger().exception(f"Failed to publish labels, error: {e}")
def publish_inline_comments(self, comments: list[dict]):
pass
@ -386,3 +430,37 @@ class GitLabProvider(GitProvider):
return pr_id
except:
return ""
def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str:
if relevant_line_start == -1:
link = f"https://gitlab.com/codiumai/pr-agent/-/blob/{self.mr.source_branch}/{relevant_file}?ref_type=heads"
elif relevant_line_end:
link = f"https://gitlab.com/codiumai/pr-agent/-/blob/{self.mr.source_branch}/{relevant_file}?ref_type=heads#L{relevant_line_start}-L{relevant_line_end}"
else:
link = f"https://gitlab.com/codiumai/pr-agent/-/blob/{self.mr.source_branch}/{relevant_file}?ref_type=heads#L{relevant_line_start}"
return link
def generate_link_to_relevant_line_number(self, suggestion) -> str:
try:
relevant_file = suggestion['relevant file'].strip('`').strip("'")
relevant_line_str = suggestion['relevant line']
if not relevant_line_str:
return ""
position, absolute_position = find_line_number_of_relevant_line_in_file \
(self.diff_files, relevant_file, relevant_line_str)
if absolute_position != -1:
# link to right file only
link = f"https://gitlab.com/codiumai/pr-agent/-/blob/{self.mr.source_branch}/{relevant_file}?ref_type=heads#L{absolute_position}"
# # link to diff
# sha_file = hashlib.sha1(relevant_file.encode('utf-8')).hexdigest()
# link = f"{self.pr.web_url}/diffs#{sha_file}_{absolute_position}_{absolute_position}"
return link
except Exception as e:
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"Failed adding line link, error: {e}")
return ""

View File

@ -1,4 +1,3 @@
import logging
from collections import Counter
from pathlib import Path
from typing import List
@ -7,6 +6,7 @@ from git import Repo
from pr_agent.config_loader import _find_repository_root, get_settings
from pr_agent.git_providers.git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
from pr_agent.log import get_logger
class PullRequestMimic:
@ -49,7 +49,7 @@ class LocalGitProvider(GitProvider):
"""
Prepare the repository for PR-mimic generation.
"""
logging.debug('Preparing repository for PR-mimic generation...')
get_logger().debug('Preparing repository for PR-mimic generation...')
if self.repo.is_dirty():
raise ValueError('The repository is not in a clean state. Please commit or stash pending changes.')
if self.target_branch_name not in self.repo.heads:
@ -140,6 +140,9 @@ class LocalGitProvider(GitProvider):
def remove_initial_comment(self):
pass # Not applicable to the local git provider, but required by the interface
def remove_comment(self, comment):
pass # Not applicable to the local git provider, but required by the interface
def get_languages(self):
"""
Calculate percentage of languages in repository. Used for hunk prioritisation.

View File

@ -0,0 +1,37 @@
import copy
import os
import tempfile
from dynaconf import Dynaconf
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.log import get_logger
def apply_repo_settings(pr_url):
if get_settings().config.use_repo_settings_file:
repo_settings_file = None
try:
git_provider = get_git_provider()(pr_url)
repo_settings = git_provider.get_repo_settings()
if repo_settings:
repo_settings_file = None
fd, repo_settings_file = tempfile.mkstemp(suffix='.toml')
os.write(fd, repo_settings)
new_settings = Dynaconf(settings_files=[repo_settings_file])
for section, contents in new_settings.as_dict().items():
section_dict = copy.deepcopy(get_settings().as_dict().get(section, {}))
for key, value in contents.items():
section_dict[key] = value
get_settings().unset(section)
get_settings().set(section, section_dict, merge=False)
get_logger().info(f"Applying repo settings for section {section}, contents: {contents}")
except Exception as e:
get_logger().exception("Failed to apply repo settings", e)
finally:
if repo_settings_file:
try:
os.remove(repo_settings_file)
except Exception as e:
get_logger().error(f"Failed to remove temporary settings file {repo_settings_file}", e)

40
pr_agent/log/__init__.py Normal file
View File

@ -0,0 +1,40 @@
import json
import logging
import sys
from enum import Enum
from loguru import logger
class LoggingFormat(str, Enum):
CONSOLE = "CONSOLE"
JSON = "JSON"
def json_format(record: dict) -> str:
return record["message"]
def setup_logger(level: str = "INFO", fmt: LoggingFormat = LoggingFormat.CONSOLE):
level: int = logging.getLevelName(level.upper())
if type(level) is not int:
level = logging.INFO
if fmt == LoggingFormat.JSON:
logger.remove(None)
logger.add(
sys.stdout,
level=level,
format="{message}",
colorize=False,
serialize=True,
)
elif fmt == LoggingFormat.CONSOLE:
logger.remove(None)
logger.add(sys.stdout, level=level, colorize=True)
return logger
def get_logger(*args, **kwargs):
return logger

View File

@ -1,9 +1,8 @@
import ujson
from google.cloud import storage
from pr_agent.config_loader import get_settings
from pr_agent.git_providers.gitlab_provider import logger
from pr_agent.log import get_logger
from pr_agent.secret_providers.secret_provider import SecretProvider
@ -15,7 +14,7 @@ class GoogleCloudStorageSecretProvider(SecretProvider):
self.bucket_name = get_settings().google_cloud_storage.bucket_name
self.bucket = self.client.bucket(self.bucket_name)
except Exception as e:
logger.error(f"Failed to initialize Google Cloud Storage Secret Provider: {e}")
get_logger().error(f"Failed to initialize Google Cloud Storage Secret Provider: {e}")
raise e
def get_secret(self, secret_name: str) -> str:
@ -23,7 +22,7 @@ class GoogleCloudStorageSecretProvider(SecretProvider):
blob = self.bucket.blob(secret_name)
return blob.download_as_string()
except Exception as e:
logger.error(f"Failed to get secret {secret_name} from Google Cloud Storage: {e}")
get_logger().error(f"Failed to get secret {secret_name} from Google Cloud Storage: {e}")
return ""
def store_secret(self, secret_name: str, secret_value: str):
@ -31,5 +30,5 @@ class GoogleCloudStorageSecretProvider(SecretProvider):
blob = self.bucket.blob(secret_name)
blob.upload_from_string(secret_value)
except Exception as e:
logger.error(f"Failed to store secret {secret_name} in Google Cloud Storage: {e}")
get_logger().error(f"Failed to store secret {secret_name} in Google Cloud Storage: {e}")
raise e

View File

@ -1,9 +1,7 @@
import copy
import hashlib
import json
import logging
import os
import sys
import time
import jwt
@ -18,9 +16,10 @@ from starlette_context.middleware import RawContextMiddleware
from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings, global_settings
from pr_agent.log import LoggingFormat, get_logger, setup_logger
from pr_agent.secret_providers import get_secret_provider
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
setup_logger(fmt=LoggingFormat.JSON)
router = APIRouter()
secret_provider = get_secret_provider()
@ -49,7 +48,7 @@ async def get_bearer_token(shared_secret: str, client_key: str):
bearer_token = response.json()["access_token"]
return bearer_token
except Exception as e:
logging.error(f"Failed to get bearer token: {e}")
get_logger().error(f"Failed to get bearer token: {e}")
raise e
@router.get("/")
@ -60,21 +59,23 @@ async def handle_manifest(request: Request, response: Response):
manifest = manifest.replace("app_key", get_settings().bitbucket.app_key)
manifest = manifest.replace("base_url", get_settings().bitbucket.base_url)
except:
logging.error("Failed to replace api_key in Bitbucket manifest, trying to continue")
get_logger().error("Failed to replace api_key in Bitbucket manifest, trying to continue")
manifest_obj = json.loads(manifest)
return JSONResponse(manifest_obj)
@router.post("/webhook")
async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Request):
print(request.headers)
log_context = {"server_type": "bitbucket_app"}
get_logger().debug(request.headers)
jwt_header = request.headers.get("authorization", None)
if jwt_header:
input_jwt = jwt_header.split(" ")[1]
data = await request.json()
print(data)
get_logger().debug(data)
async def inner():
try:
owner = data["data"]["repository"]["owner"]["username"]
log_context["sender"] = owner
secrets = json.loads(secret_provider.get_secret(owner))
shared_secret = secrets["shared_secret"]
client_key = secrets["client_key"]
@ -86,13 +87,19 @@ async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Req
agent = PRAgent()
if event == "pullrequest:created":
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"]
await agent.handle_request(pr_url, "review")
log_context["api_url"] = pr_url
log_context["event"] = "pull_request"
with get_logger().contextualize(**log_context):
await agent.handle_request(pr_url, "review")
elif event == "pullrequest:comment_created":
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"]
log_context["api_url"] = pr_url
log_context["event"] = "comment"
comment_body = data["data"]["comment"]["content"]["raw"]
await agent.handle_request(pr_url, comment_body)
with get_logger().contextualize(**log_context):
await agent.handle_request(pr_url, comment_body)
except Exception as e:
logging.error(f"Failed to handle webhook: {e}")
get_logger().error(f"Failed to handle webhook: {e}")
background_tasks.add_task(inner)
return "OK"
@ -103,9 +110,10 @@ async def handle_github_webhooks(request: Request, response: Response):
@router.post("/installed")
async def handle_installed_webhooks(request: Request, response: Response):
try:
print(request.headers)
get_logger().info("handle_installed_webhooks")
get_logger().info(request.headers)
data = await request.json()
print(data)
get_logger().info(data)
shared_secret = data["sharedSecret"]
client_key = data["clientKey"]
username = data["principal"]["username"]
@ -115,13 +123,15 @@ async def handle_installed_webhooks(request: Request, response: Response):
}
secret_provider.store_secret(username, json.dumps(secrets))
except Exception as e:
logging.error(f"Failed to register user: {e}")
get_logger().error(f"Failed to register user: {e}")
return JSONResponse({"error": "Unable to register user"}, status_code=500)
@router.post("/uninstalled")
async def handle_uninstalled_webhooks(request: Request, response: Response):
get_logger().info("handle_uninstalled_webhooks")
data = await request.json()
print(data)
get_logger().info(data)
def start():

View File

@ -1,34 +0,0 @@
import os
from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings
from pr_agent.tools.pr_reviewer import PRReviewer
import asyncio
async def run_action():
try:
pull_request_id = os.environ.get("BITBUCKET_PR_ID", '')
slug = os.environ.get("BITBUCKET_REPO_SLUG", '')
workspace = os.environ.get("BITBUCKET_WORKSPACE", '')
bearer_token = os.environ.get('BITBUCKET_BEARER_TOKEN', None)
OPENAI_KEY = os.environ.get('OPENAI_API_KEY') or os.environ.get('OPENAI.KEY')
OPENAI_ORG = os.environ.get('OPENAI_ORG') or os.environ.get('OPENAI.ORG')
# Check if required environment variables are set
if not bearer_token:
print("BITBUCKET_BEARER_TOKEN not set")
return
if not OPENAI_KEY:
print("OPENAI_KEY not set")
return
# Set the environment variables in the settings
get_settings().set("BITBUCKET.BEARER_TOKEN", bearer_token)
get_settings().set("OPENAI.KEY", OPENAI_KEY)
if OPENAI_ORG:
get_settings().set("OPENAI.ORG", OPENAI_ORG)
if pull_request_id and slug and workspace:
pr_url = f"https://bitbucket.org/{workspace}/{slug}/pull-requests/{pull_request_id}"
await PRReviewer(pr_url).run()
except Exception as e:
print(f"An error occurred: {e}")
if __name__ == "__main__":
asyncio.run(run_action())

View File

@ -0,0 +1,64 @@
import json
import uvicorn
from fastapi import APIRouter, FastAPI
from fastapi.encoders import jsonable_encoder
from starlette import status
from starlette.background import BackgroundTasks
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette_context.middleware import RawContextMiddleware
from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings
from pr_agent.log import get_logger
router = APIRouter()
def handle_request(background_tasks: BackgroundTasks, url: str, body: str, log_context: dict):
log_context["action"] = body
log_context["event"] = "pull_request" if body == "review" else "comment"
log_context["api_url"] = url
with get_logger().contextualize(**log_context):
background_tasks.add_task(PRAgent().handle_request, url, body)
@router.post("/webhook")
async def handle_webhook(background_tasks: BackgroundTasks, request: Request):
log_context = {"server_type": "bitbucket_server"}
data = await request.json()
get_logger().info(json.dumps(data))
pr_id = data['pullRequest']['id']
repository_name = data['pullRequest']['toRef']['repository']['slug']
project_name = data['pullRequest']['toRef']['repository']['project']['key']
bitbucket_server = get_settings().get("BITBUCKET_SERVER.URL")
pr_url = f"{bitbucket_server}/projects/{project_name}/repos/{repository_name}/pull-requests/{pr_id}"
log_context["api_url"] = pr_url
log_context["event"] = "pull_request"
handle_request(background_tasks, pr_url, "review", log_context)
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
@router.get("/")
async def root():
return {"status": "ok"}
def start():
bitbucket_server_url = get_settings().get("BITBUCKET_SERVER.URL", None)
if not bitbucket_server_url:
raise ValueError("BITBUCKET_SERVER.URL is not set")
get_settings().config.git_provider = "bitbucket_server"
middleware = [Middleware(RawContextMiddleware)]
app = FastAPI(middleware=middleware)
app.include_router(router)
uvicorn.run(app, host="0.0.0.0", port=3000)
if __name__ == '__main__':
start()

View File

@ -1,6 +1,4 @@
import copy
import logging
import sys
from enum import Enum
from json import JSONDecodeError
@ -12,9 +10,10 @@ from starlette_context import context
from starlette_context.middleware import RawContextMiddleware
from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import global_settings, get_settings
from pr_agent.config_loader import get_settings, global_settings
from pr_agent.log import get_logger, setup_logger
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
setup_logger()
router = APIRouter()
@ -35,7 +34,7 @@ class Item(BaseModel):
@router.post("/api/v1/gerrit/{action}")
async def handle_gerrit_request(action: Action, item: Item):
logging.debug("Received a Gerrit request")
get_logger().debug("Received a Gerrit request")
context["settings"] = copy.deepcopy(global_settings)
if action == Action.ask:
@ -54,7 +53,7 @@ async def get_body(request):
try:
body = await request.json()
except JSONDecodeError as e:
logging.error("Error parsing request body", e)
get_logger().error("Error parsing request body", e)
return {}
return body

View File

@ -1,15 +1,34 @@
import asyncio
import json
import os
from typing import Union
from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.utils import apply_repo_settings
from pr_agent.log import get_logger
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
from pr_agent.tools.pr_description import PRDescription
from pr_agent.tools.pr_reviewer import PRReviewer
def is_true(value: Union[str, bool]) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() == 'true'
return False
def get_setting_or_env(key: str, default: Union[str, bool] = None) -> Union[str, bool]:
try:
value = get_settings().get(key, default)
except AttributeError: # TBD still need to debug why this happens on GitHub Actions
value = os.getenv(key, None) or os.getenv(key.upper(), None) or os.getenv(key.lower(), None) or default
return value
async def run_action():
# Get environment variables
GITHUB_EVENT_NAME = os.environ.get('GITHUB_EVENT_NAME')
@ -19,7 +38,6 @@ async def run_action():
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN')
get_settings().set("CONFIG.PUBLISH_OUTPUT_PROGRESS", False)
# Check if required environment variables are set
if not GITHUB_EVENT_NAME:
print("GITHUB_EVENT_NAME not set")
@ -49,20 +67,29 @@ async def run_action():
print(f"Failed to parse JSON: {e}")
return
try:
get_logger().info("Applying repo settings")
pr_url = event_payload.get("pull_request", {}).get("html_url")
if pr_url:
apply_repo_settings(pr_url)
get_logger().info(f"enable_custom_labels: {get_settings().config.enable_custom_labels}")
except Exception as e:
get_logger().info(f"github action: failed to apply repo settings: {e}")
# Handle pull request event
if GITHUB_EVENT_NAME == "pull_request":
action = event_payload.get("action")
if action in ["opened", "reopened"]:
pr_url = event_payload.get("pull_request", {}).get("url")
if pr_url:
auto_review = os.environ.get('github_action.auto_review', None)
if auto_review is None or (isinstance(auto_review, str) and auto_review.lower() == 'true'):
auto_review = get_setting_or_env("GITHUB_ACTION.AUTO_REVIEW", None)
if auto_review is None or is_true(auto_review):
await PRReviewer(pr_url).run()
auto_describe = os.environ.get('github_action.auto_describe', None)
if isinstance(auto_describe, str) and auto_describe.lower() == 'true':
auto_describe = get_setting_or_env("GITHUB_ACTION.AUTO_DESCRIBE", None)
if is_true(auto_describe):
await PRDescription(pr_url).run()
auto_improve = os.environ.get('github_action.auto_improve', None)
if isinstance(auto_improve, str) and auto_improve.lower() == 'true':
auto_improve = get_setting_or_env("GITHUB_ACTION.AUTO_IMPROVE", None)
if is_true(auto_improve):
await PRCodeSuggestions(pr_url).run()
# Handle issue comment event
@ -89,4 +116,4 @@ async def run_action():
if __name__ == '__main__':
asyncio.run(run_action())
asyncio.run(run_action())

View File

@ -1,9 +1,7 @@
import copy
import logging
import sys
import os
import time
from typing import Any, Dict
import asyncio.locks
from typing import Any, Dict, List, Tuple
import uvicorn
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
@ -15,9 +13,13 @@ from pr_agent.agent.pr_agent import PRAgent
from pr_agent.algo.utils import update_settings_from_args
from pr_agent.config_loader import get_settings, global_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.servers.utils import verify_signature
from pr_agent.git_providers.utils import apply_repo_settings
from pr_agent.git_providers.git_provider import IncrementalPR
from pr_agent.log import LoggingFormat, get_logger, setup_logger
from pr_agent.servers.utils import verify_signature, DefaultDictWithTimeout
setup_logger(fmt=LoggingFormat.JSON)
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
router = APIRouter()
@ -28,11 +30,11 @@ async def handle_github_webhooks(request: Request, response: Response):
Verifies the request signature, parses the request body, and passes it to the handle_request function for further
processing.
"""
logging.debug("Received a GitHub webhook")
get_logger().debug("Received a GitHub webhook")
body = await get_body(request)
logging.debug(f'Request body:\n{body}')
get_logger().debug(f'Request body:\n{body}')
installation_id = body.get("installation", {}).get("id")
context["installation_id"] = installation_id
context["settings"] = copy.deepcopy(global_settings)
@ -44,13 +46,14 @@ async def handle_github_webhooks(request: Request, response: Response):
@router.post("/api/v1/marketplace_webhooks")
async def handle_marketplace_webhooks(request: Request, response: Response):
body = await get_body(request)
logging.info(f'Request body:\n{body}')
get_logger().info(f'Request body:\n{body}')
async def get_body(request):
try:
body = await request.json()
except Exception as e:
logging.error("Error parsing request body", e)
get_logger().error("Error parsing request body", e)
raise HTTPException(status_code=400, detail="Error parsing request body") from e
webhook_secret = getattr(get_settings().github, 'webhook_secret', None)
if webhook_secret:
@ -60,7 +63,9 @@ async def get_body(request):
return body
_duplicate_requests_cache = {}
_duplicate_requests_cache = DefaultDictWithTimeout(ttl=get_settings().github_app.duplicate_requests_cache_ttl)
_duplicate_push_triggers = DefaultDictWithTimeout(ttl=get_settings().github_app.push_trigger_pending_tasks_ttl)
_pending_task_duplicate_push_conditions = DefaultDictWithTimeout(asyncio.locks.Condition, ttl=get_settings().github_app.push_trigger_pending_tasks_ttl)
async def handle_request(body: Dict[str, Any], event: str):
@ -76,8 +81,8 @@ async def handle_request(body: Dict[str, Any], event: str):
return {}
agent = PRAgent()
bot_user = get_settings().github_app.bot_user
logging.info(f"action: '{action}'")
logging.info(f"event: '{event}'")
sender = body.get("sender", {}).get("login")
log_context = {"action": action, "event": event, "sender": sender, "server_type": "github_app"}
if get_settings().github_app.duplicate_requests_cache and _is_duplicate_request(body):
return {}
@ -87,73 +92,143 @@ async def handle_request(body: Dict[str, Any], event: str):
if "comment" not in body:
return {}
comment_body = body.get("comment", {}).get("body")
sender = body.get("sender", {}).get("login")
if sender and bot_user in sender:
logging.info(f"Ignoring comment from {bot_user} user")
get_logger().info(f"Ignoring comment from {bot_user} user")
return {}
logging.info(f"Processing comment from {sender} user")
get_logger().info(f"Processing comment from {sender} user")
if "issue" in body and "pull_request" in body["issue"] and "url" in body["issue"]["pull_request"]:
api_url = body["issue"]["pull_request"]["url"]
elif "comment" in body and "pull_request_url" in body["comment"]:
api_url = body["comment"]["pull_request_url"]
else:
return {}
logging.info(body)
logging.info(f"Handling comment because of event={event} and action={action}")
log_context["api_url"] = api_url
get_logger().info(body)
get_logger().info(f"Handling comment because of event={event} and action={action}")
comment_id = body.get("comment", {}).get("id")
provider = get_git_provider()(pr_url=api_url)
await agent.handle_request(api_url, comment_body, notify=lambda: provider.add_eyes_reaction(comment_id))
with get_logger().contextualize(**log_context):
await agent.handle_request(api_url, comment_body, notify=lambda: provider.add_eyes_reaction(comment_id))
# handle pull_request event:
# automatically review opened/reopened/ready_for_review PRs as long as they're not in draft,
# as well as direct review requests from the bot
elif event == 'pull_request':
pull_request = body.get("pull_request")
if not pull_request:
return {}
api_url = pull_request.get("url")
if not api_url:
return {}
if pull_request.get("draft", True) or pull_request.get("state") != "open" or pull_request.get("user", {}).get("login", "") == bot_user:
elif event == 'pull_request' and action != 'synchronize':
pull_request, api_url = _check_pull_request_event(action, body, log_context, bot_user)
if not (pull_request and api_url):
return {}
if action in get_settings().github_app.handle_pr_actions:
if action == "review_requested":
if body.get("requested_reviewer", {}).get("login", "") != bot_user:
return {}
if pull_request.get("created_at") == pull_request.get("updated_at"):
# avoid double reviews when opening a PR for the first time
return {}
logging.info(f"Performing review because of event={event} and action={action}")
for command in get_settings().github_app.pr_commands:
split_command = command.split(" ")
command = split_command[0]
args = split_command[1:]
other_args = update_settings_from_args(args)
new_command = ' '.join([command] + other_args)
logging.info(body)
logging.info(f"Performing command: {new_command}")
await agent.handle_request(api_url, new_command)
get_logger().info(f"Performing review for {api_url=} because of {event=} and {action=}")
await _perform_commands("pr_commands", agent, body, api_url, log_context)
logging.info("event or action does not require handling")
# handle pull_request event with synchronize action - "push trigger" for new commits
elif event == 'pull_request' and action == 'synchronize' and get_settings().github_app.handle_push_trigger:
pull_request, api_url = _check_pull_request_event(action, body, log_context, bot_user)
if not (pull_request and api_url):
return {}
# TODO: do we still want to get the list of commits to filter bot/merge commits?
before_sha = body.get("before")
after_sha = body.get("after")
merge_commit_sha = pull_request.get("merge_commit_sha")
if before_sha == after_sha:
return {}
if get_settings().github_app.push_trigger_ignore_merge_commits and after_sha == merge_commit_sha:
return {}
if get_settings().github_app.push_trigger_ignore_bot_commits and body.get("sender", {}).get("login", "") == bot_user:
return {}
# Prevent triggering multiple times for subsequent push triggers when one is enough:
# The first push will trigger the processing, and if there's a second push in the meanwhile it will wait.
# Any more events will be discarded, because they will all trigger the exact same processing on the PR.
# We let the second event wait instead of discarding it because while the first event was being processed,
# more commits may have been pushed that led to the subsequent events,
# so we keep just one waiting as a delegate to trigger the processing for the new commits when done waiting.
current_active_tasks = _duplicate_push_triggers.setdefault(api_url, 0)
max_active_tasks = 2 if get_settings().github_app.push_trigger_pending_tasks_backlog else 1
if current_active_tasks < max_active_tasks:
# first task can enter, and second tasks too if backlog is enabled
get_logger().info(
f"Continue processing push trigger for {api_url=} because there are {current_active_tasks} active tasks"
)
_duplicate_push_triggers[api_url] += 1
else:
get_logger().info(
f"Skipping push trigger for {api_url=} because another event already triggered the same processing"
)
return {}
async with _pending_task_duplicate_push_conditions[api_url]:
if current_active_tasks == 1:
# second task waits
get_logger().info(
f"Waiting to process push trigger for {api_url=} because the first task is still in progress"
)
await _pending_task_duplicate_push_conditions[api_url].wait()
get_logger().info(f"Finished waiting to process push trigger for {api_url=} - continue with flow")
try:
if get_settings().github_app.push_trigger_wait_for_initial_review and not get_git_provider()(api_url, incremental=IncrementalPR(True)).previous_review:
get_logger().info(f"Skipping incremental review because there was no initial review for {api_url=} yet")
return {}
get_logger().info(f"Performing incremental review for {api_url=} because of {event=} and {action=}")
await _perform_commands("push_commands", agent, body, api_url, log_context)
finally:
# release the waiting task block
async with _pending_task_duplicate_push_conditions[api_url]:
_pending_task_duplicate_push_conditions[api_url].notify(1)
_duplicate_push_triggers[api_url] -= 1
get_logger().info("event or action does not require handling")
return {}
def _check_pull_request_event(action: str, body: dict, log_context: dict, bot_user: str) -> Tuple[Dict[str, Any], str]:
invalid_result = {}, ""
pull_request = body.get("pull_request")
if not pull_request:
return invalid_result
api_url = pull_request.get("url")
if not api_url:
return invalid_result
log_context["api_url"] = api_url
if pull_request.get("draft", True) or pull_request.get("state") != "open" or pull_request.get("user", {}).get("login", "") == bot_user:
return invalid_result
if action in ("review_requested", "synchronize") and pull_request.get("created_at") == pull_request.get("updated_at"):
# avoid double reviews when opening a PR for the first time
return invalid_result
return pull_request, api_url
async def _perform_commands(commands_conf: str, agent: PRAgent, body: dict, api_url: str, log_context: dict):
apply_repo_settings(api_url)
commands = get_settings().get(f"github_app.{commands_conf}")
for command in commands:
split_command = command.split(" ")
command = split_command[0]
args = split_command[1:]
other_args = update_settings_from_args(args)
new_command = ' '.join([command] + other_args)
get_logger().info(body)
get_logger().info(f"Performing command: {new_command}")
with get_logger().contextualize(**log_context):
await agent.handle_request(api_url, new_command)
def _is_duplicate_request(body: Dict[str, Any]) -> bool:
"""
In some deployments its possible to get duplicate requests if the handling is long,
This function checks if the request is duplicate and if so - ignores it.
"""
request_hash = hash(str(body))
logging.info(f"request_hash: {request_hash}")
request_time = time.monotonic()
ttl = get_settings().github_app.duplicate_requests_cache_ttl # in seconds
to_delete = [key for key, key_time in _duplicate_requests_cache.items() if request_time - key_time > ttl]
for key in to_delete:
del _duplicate_requests_cache[key]
is_duplicate = request_hash in _duplicate_requests_cache
_duplicate_requests_cache[request_hash] = request_time
get_logger().info(f"request_hash: {request_hash}")
is_duplicate = _duplicate_requests_cache.get(request_hash, False)
_duplicate_requests_cache[request_hash] = True
if is_duplicate:
logging.info(f"Ignoring duplicate request {request_hash}")
get_logger().info(f"Ignoring duplicate request {request_hash}")
return is_duplicate

View File

@ -1,6 +1,4 @@
import asyncio
import logging
import sys
from datetime import datetime, timezone
import aiohttp
@ -8,9 +6,10 @@ import aiohttp
from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.log import LoggingFormat, get_logger, setup_logger
from pr_agent.servers.help import bot_help_text
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
setup_logger(fmt=LoggingFormat.JSON)
NOTIFICATION_URL = "https://api.github.com/notifications"
@ -94,7 +93,7 @@ async def polling_loop():
comment_body = comment['body'] if 'body' in comment else ''
commenter_github_user = comment['user']['login'] \
if 'user' in comment else ''
logging.info(f"Commenter: {commenter_github_user}\nComment: {comment_body}")
get_logger().info(f"Commenter: {commenter_github_user}\nComment: {comment_body}")
user_tag = "@" + user_id
if user_tag not in comment_body:
continue
@ -112,7 +111,7 @@ async def polling_loop():
print(f"Failed to fetch notifications. Status code: {response.status}")
except Exception as e:
logging.error(f"Exception during processing of a notification: {e}")
get_logger().error(f"Exception during processing of a notification: {e}")
if __name__ == '__main__':

View File

@ -1,7 +1,5 @@
import copy
import json
import logging
import sys
import uvicorn
from fastapi import APIRouter, FastAPI, Request, status
@ -14,26 +12,37 @@ from starlette_context.middleware import RawContextMiddleware
from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings, global_settings
from pr_agent.log import LoggingFormat, get_logger, setup_logger
from pr_agent.secret_providers import get_secret_provider
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
setup_logger(fmt=LoggingFormat.JSON)
router = APIRouter()
secret_provider = get_secret_provider() if get_settings().get("CONFIG.SECRET_PROVIDER") else None
def handle_request(background_tasks: BackgroundTasks, url: str, body: str, log_context: dict):
log_context["action"] = body
log_context["event"] = "pull_request" if body == "/review" else "comment"
log_context["api_url"] = url
with get_logger().contextualize(**log_context):
background_tasks.add_task(PRAgent().handle_request, url, body)
@router.post("/webhook")
async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
log_context = {"server_type": "gitlab_app"}
if request.headers.get("X-Gitlab-Token") and secret_provider:
request_token = request.headers.get("X-Gitlab-Token")
secret = secret_provider.get_secret(request_token)
try:
secret_dict = json.loads(secret)
gitlab_token = secret_dict["gitlab_token"]
log_context["sender"] = secret_dict.get("token_name", secret_dict.get("id", "unknown"))
context["settings"] = copy.deepcopy(global_settings)
context["settings"].gitlab.personal_access_token = gitlab_token
except Exception as e:
logging.error(f"Failed to validate secret {request_token}: {e}")
get_logger().error(f"Failed to validate secret {request_token}: {e}")
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
elif get_settings().get("GITLAB.SHARED_SECRET"):
secret = get_settings().get("GITLAB.SHARED_SECRET")
@ -45,17 +54,17 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
if not gitlab_token:
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
data = await request.json()
logging.info(json.dumps(data))
get_logger().info(json.dumps(data))
if data.get('object_kind') == 'merge_request' and data['object_attributes'].get('action') in ['open', 'reopen']:
logging.info(f"A merge request has been opened: {data['object_attributes'].get('title')}")
get_logger().info(f"A merge request has been opened: {data['object_attributes'].get('title')}")
url = data['object_attributes'].get('url')
background_tasks.add_task(PRAgent().handle_request, url, "/review")
handle_request(background_tasks, url, "/review", log_context)
elif data.get('object_kind') == 'note' and data['event_type'] == 'note':
if 'merge_request' in data:
mr = data['merge_request']
url = mr.get('url')
body = data.get('object_attributes', {}).get('note')
background_tasks.add_task(PRAgent().handle_request, url, body)
handle_request(background_tasks, url, body, log_context)
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))

View File

@ -1,17 +1,19 @@
commands_text = "> **/review [-i]**: Request a review of your Pull Request. For an incremental review, which only " \
"considers changes since the last review, include the '-i' option.\n" \
"> **/describe**: Modify the PR title and description based on the contents of the PR.\n" \
"> **/improve [--extended]**: Suggest improvements to the code in the PR. Extended mode employs several calls, and provides a more thorough feedback. \n" \
"> **/ask \\<QUESTION\\>**: Pose a question about the PR.\n" \
"> **/update_changelog**: Update the changelog based on the PR's contents.\n\n" \
">To edit any configuration parameter from **configuration.toml**, add --config_path=new_value\n" \
">For example: /review --pr_reviewer.extra_instructions=\"focus on the file: ...\" \n" \
">To list the possible configuration parameters, use the **/config** command.\n" \
commands_text = "> **/review**: Request a review of your Pull Request. \n" \
"> **/describe**: Update the PR title and description based on the contents of the PR. \n" \
"> **/improve [--extended]**: Suggest code improvements. Extended mode provides a higher quality feedback. \n" \
"> **/ask \\<QUESTION\\>**: Ask a question about the PR. \n" \
"> **/update_changelog**: Update the changelog based on the PR's contents. \n" \
"> **/add_docs**: Generate docstring for new components introduced in the PR. \n" \
"> **/generate_labels**: Generate labels for the PR based on the PR's contents. \n" \
"> see the [tools guide](https://github.com/Codium-ai/pr-agent/blob/main/docs/TOOLS_GUIDE.md) for more details.\n\n" \
">To edit any configuration parameter from the [configuration.toml](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml), add --config_path=new_value. \n" \
">For example: /review --pr_reviewer.extra_instructions=\"focus on the file: ...\" \n" \
">To list the possible configuration parameters, add a **/config** comment. \n" \
def bot_help_text(user: str):
return f"> Tag me in a comment '@{user}' and add one of the following commands:\n" + commands_text
return f"> Tag me in a comment '@{user}' and add one of the following commands: \n" + commands_text
actions_help_text = "> To invoke the PR-Agent, add a comment using one of the following commands:\n" + \
actions_help_text = "> To invoke the PR-Agent, add a comment using one of the following commands: \n" + \
commands_text

View File

@ -1,14 +1,13 @@
import logging
from fastapi import FastAPI
from mangum import Mangum
from starlette.middleware import Middleware
from starlette_context.middleware import RawContextMiddleware
from pr_agent.servers.github_app import router
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
app = FastAPI()
middleware = [Middleware(RawContextMiddleware)]
app = FastAPI(middleware=middleware)
app.include_router(router)
handler = Mangum(app, lifespan="off")

View File

@ -1,5 +1,8 @@
import hashlib
import hmac
import time
from collections import defaultdict
from typing import Callable, Any
from fastapi import HTTPException
@ -25,3 +28,59 @@ def verify_signature(payload_body, secret_token, signature_header):
class RateLimitExceeded(Exception):
"""Raised when the git provider API rate limit has been exceeded."""
pass
class DefaultDictWithTimeout(defaultdict):
"""A defaultdict with a time-to-live (TTL)."""
def __init__(
self,
default_factory: Callable[[], Any] = None,
ttl: int = None,
refresh_interval: int = 60,
update_key_time_on_get: bool = True,
*args,
**kwargs,
):
"""
Args:
default_factory: The default factory to use for keys that are not in the dictionary.
ttl: The time-to-live (TTL) in seconds.
refresh_interval: How often to refresh the dict and delete items older than the TTL.
update_key_time_on_get: Whether to update the access time of a key also on get (or only when set).
"""
super().__init__(default_factory, *args, **kwargs)
self.__key_times = dict()
self.__ttl = ttl
self.__refresh_interval = refresh_interval
self.__update_key_time_on_get = update_key_time_on_get
self.__last_refresh = self.__time() - self.__refresh_interval
@staticmethod
def __time():
return time.monotonic()
def __refresh(self):
if self.__ttl is None:
return
request_time = self.__time()
if request_time - self.__last_refresh > self.__refresh_interval:
return
to_delete = [key for key, key_time in self.__key_times.items() if request_time - key_time > self.__ttl]
for key in to_delete:
del self[key]
self.__last_refresh = request_time
def __getitem__(self, __key):
if self.__update_key_time_on_get:
self.__key_times[__key] = self.__time()
self.__refresh()
return super().__getitem__(__key)
def __setitem__(self, __key, __value):
self.__key_times[__key] = self.__time()
return super().__setitem__(__key, __value)
def __delitem__(self, __key):
del self.__key_times[__key]
return super().__delitem__(__key)

View File

@ -34,7 +34,14 @@ key = "" # Optional, uncomment if you want to use Huggingface Inference API. Acq
api_base = "" # the base url for your huggingface inference endpoint
[ollama]
api_base = "" # the base url for your huggingface inference endpoint
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
[aws]
bedrock_region = "" # the AWS region to call Bedrock APIs
[github]
# ---- Set the following only for deployment type == "user"

View File

@ -1,5 +1,5 @@
[config]
model="gpt-4"
model="gpt-4" # "gpt-4-1106-preview"
fallback_models=["gpt-3.5-turbo-16k"]
git_provider="github"
publish_output=true
@ -10,35 +10,58 @@ use_repo_settings_file=true
ai_timeout=180
max_description_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
secret_provider="google_cloud_storage"
cli_mode=false
[pr_reviewer] # /review #
# enable/disable features
require_focused_review=false
require_score_review=false
require_tests_review=true
require_security_review=true
require_estimate_effort_to_review=true
# general options
num_code_suggestions=4
inline_code_comments = false
ask_and_reflect=false
automatic_review=true
remove_previous_review_comment=false
persistent_comment=true
extra_instructions = ""
# review labels
enable_review_labels_security=true
enable_review_labels_effort=false
# specific configurations for incremental review (/review -i)
require_all_thresholds_for_incremental_review=false
minimal_commits_for_incremental_review=0
minimal_minutes_for_incremental_review=0
[pr_description] # /describe #
publish_labels=true
publish_description_as_comment=false
add_original_user_description=false
keep_original_user_title=false
use_bullet_points=true
extra_instructions = ""
enable_pr_type=true
enable_file_walkthrough=false
enable_semantic_files_types=true
final_update_message = true
# markers
use_description_markers=false
include_generated_by_header=true
#custom_labels = ['Bug fix', 'Tests', 'Bug fix with tests', 'Enhancement', 'Documentation', 'Other']
[pr_questions] # /ask #
[pr_code_suggestions] # /improve #
num_code_suggestions=4
summarize = false
extra_instructions = ""
rank_suggestions = false
# params for '/improve --extended' mode
@ -47,6 +70,10 @@ rank_extended_suggestions = true
max_number_of_calls = 5
final_clip_factor = 0.9
[pr_add_docs] # /add_docs #
extra_instructions = ""
docs_style = "Sphinx Style" # "Google Style with Args, Returns, Attributes...etc", "Numpy Style", "Sphinx Style", "PEP257", "reStructuredText"
[pr_update_changelog] # /update_changelog #
push_changelog_changes=false
extra_instructions = ""
@ -57,6 +84,7 @@ extra_instructions = ""
# The type of deployment to create. Valid values are 'app' or 'user'.
deployment_type = "user"
ratelimit_retries = 5
base_url = "https://api.github.com"
[github_action]
# auto_review = true # set as env var in .github/workflows/pr-agent.yaml
@ -77,6 +105,30 @@ pr_commands = [
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
"/auto_review",
]
# settings for "pull_request" event with "synchronize" action - used to detect and handle push triggers for new commits
handle_push_trigger = false
push_trigger_ignore_bot_commits = true
push_trigger_ignore_merge_commits = true
push_trigger_wait_for_initial_review = true
push_trigger_pending_tasks_backlog = true
push_trigger_pending_tasks_ttl = 300
push_commands = [
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
"""/auto_review -i \
--pr_reviewer.require_focused_review=false \
--pr_reviewer.require_score_review=false \
--pr_reviewer.require_tests_review=false \
--pr_reviewer.require_security_review=false \
--pr_reviewer.require_estimate_effort_to_review=false \
--pr_reviewer.num_code_suggestions=0 \
--pr_reviewer.inline_code_comments=false \
--pr_reviewer.remove_previous_review_comment=true \
--pr_reviewer.require_all_thresholds_for_incremental_review=false \
--pr_reviewer.minimal_commits_for_incremental_review=5 \
--pr_reviewer.minimal_minutes_for_incremental_review=30 \
--pr_reviewer.extra_instructions='' \
"""
]
[gitlab]
# URL to the gitlab service
@ -117,4 +169,4 @@ max_issues_to_scan = 500
[pinecone]
# fill and place in .secrets.toml
#api_key = ...
# environment = "gcp-starter"
# environment = "gcp-starter"

View File

@ -0,0 +1,16 @@
[config]
enable_custom_labels=false
## template for custom labels
#[custom_labels."Bug fix"]
#description = """Fixes a bug in the code"""
#[custom_labels."Tests"]
#description = """Adds or modifies tests"""
#[custom_labels."Bug fix with tests"]
#description = """Fixes a bug in the code and adds or modifies tests"""
#[custom_labels."Enhancement"]
#description = """Adds new features or modifies existing ones"""
#[custom_labels."Documentation"]
#description = """Adds or modifies documentation"""
#[custom_labels."Other"]
#description = """Other changes that do not fit in any of the above categories"""

View File

@ -0,0 +1,11 @@
[ignore]
glob = [
# Ignore files and directories matching these glob patterns.
# See https://docs.python.org/3/library/glob.html
'vendor/**',
]
regex = [
# Ignore files and directories matching these regex patterns.
# See https://learnbyexample.github.io/python-regex-cheatsheet/
]

View File

@ -433,3 +433,6 @@ reStructuredText = [".rst", ".rest", ".rest.txt", ".rst.txt", ]
wisp = [".wisp", ]
xBase = [".prg", ".prw", ]
[docs_blacklist_extensions]
# Disable docs for these extensions of text files and scripts that are not programming languages of function, classes and methods
docs_blacklist = ['sql', 'txt', 'yaml', 'json', 'xml', 'md', 'rst', 'rest', 'rest.txt', 'rst.txt', 'mdpolicy', 'mdown', 'markdown', 'mdwn', 'mkd', 'mkdn', 'mkdown', 'sh']

View File

@ -0,0 +1,126 @@
[pr_add_docs_prompt]
system="""You are PR-Doc, a language model that specializes in generating documentation for code components in a Pull Request (PR).
Your task is to generate {{ docs_for_language }} for code components in the PR Diff.
Example for the PR Diff format:
======
## src/file1.py
@@ -12,3 +12,4 @@ def func1():
__new hunk__
12 code line1 that remained unchanged in the PR
14 +new code line1 added in the PR
15 +new code line2 added in the PR
16 code line2 that remained unchanged in the PR
__old hunk__
code line1 that remained unchanged in the PR
-code line that was removed in the PR
code line2 that remained unchanged in the PR
@@ ... @@ def func2():
__new hunk__
...
__old hunk__
...
## src/file2.py
...
======
Specific instructions:
- Try to identify edited/added code components (classes/functions/methods...) that are undocumented, and generate {{ docs_for_language }} for each one.
- If there are documented (any type of {{ language }} documentation) code components in the PR, Don't generate {{ docs_for_language }} for them.
- Ignore code components that don't appear fully in the '__new hunk__' section. For example, you must see the component header and body.
- Make sure the {{ docs_for_language }} starts and ends with standart {{ language }} {{ docs_for_language }} signs.
- The {{ docs_for_language }} should be in standard format.
- Provide the exact line number (inclusive) where the {{ docs_for_language }} should be added.
{%- if extra_instructions %}
Extra instructions from the user:
======
{{ extra_instructions }}
======
{%- endif %}
You must use the following YAML schema to format your answer:
```yaml
Code Documentation:
type: array
uniqueItems: true
items:
relevant file:
type: string
description: the relevant file full path
relevant line:
type: integer
description: |-
The relevant line number from a '__new hunk__' section where the {{ docs_for_language }} should be added.
doc placement:
type: string
enum:
- before
- after
description: |-
The {{ docs_for_language }} placement relative to the relevant line (code component).
documentation:
type: string
description: |-
The {{ docs_for_language }} content. It should be complete, correctly formatted and indented, and without line numbers.
```
Example output:
```yaml
Code Documentation:
- relevant file: |-
src/file1.py
relevant lines: 12
doc placement: after
documentation: |-
\"\"\"
This is a python docstring for func1.
\"\"\"
- ...
...
```
Each YAML output MUST be after a newline, indented, with block scalar indicator ('|-').
Don't repeat the prompt in the answer, and avoid outputting the 'type' and 'description' fields.
"""
user="""PR Info:
Title: '{{ title }}'
Branch: '{{ branch }}'
{%- if description %}
Description:
======
{{ description|trim }}
======
{%- endif %}
{%- if language %}
Main PR language: '{{language}}'
{%- endif %}
The PR Diff:
======
{{ diff|trim }}
======
Response (should be a valid YAML, and nothing else):
```yaml
"""

View File

@ -1,22 +1,21 @@
[pr_code_suggestions_prompt]
system="""You are a language model called PR-Code-Reviewer, that specializes in suggesting code improvements for Pull Request (PR).
Your task is to provide meaningful and actionable code suggestions, to improve the new code presented in a PR.
system="""You are PR-Reviewer, a language model that specializes in suggesting code improvements for a Pull Request (PR).
Your task is to provide meaningful and actionable code suggestions, to improve the new code presented in a PR diff (lines starting with '+').
Example for a PR Diff input:
'
Example for the PR Diff format:
======
## src/file1.py
@@ -12,3 +12,5 @@ def func1():
@@ -12,3 +12,4 @@ def func1():
__new hunk__
12 code line that already existed in the file...
13 code line that already existed in the file....
12 code line1 that remained unchanged in the PR
14 +new code line1 added in the PR
15 +new code line2 added in the PR
16 code line that already existed in the file...
16 code line2 that remained unchanged in the PR
__old hunk__
code line that already existed in the file...
code line1 that remained unchanged in the PR
-code line that was removed in the PR
code line that already existed in the file...
code line2 that remained unchanged in the PR
@@ ... @@ def func2():
@ -28,27 +27,29 @@ __old hunk__
## src/file2.py
...
'
======
Specific instructions:
- Provide up to {{ num_code_suggestions }} code suggestions.
- Prioritize suggestions that address major problems, issues and bugs in the code.
As a second priority, suggestions should focus on best practices, code readability, maintainability, enhancments, performance, and other aspects.
Don't suggest to add docstring, type hints, or comments.
Try to provide diverse and insightful suggestions.
- Provide up to {{ num_code_suggestions }} code suggestions. Try to provide diverse and insightful suggestions.
- Prioritize suggestions that address major problems, issues and bugs in the code. As a second priority, suggestions should focus on best practices, code readability, maintainability, enhancments, performance, and other aspects.
- Don't suggest to add docstring, type hints, or comments.
- Suggestions should refer only to code from the '__new hunk__' sections, and focus on new lines of code (lines starting with '+').
Avoid making suggestions that have already been implemented in the PR code. For example, if you want to add logs, or change a variable to const, or anything else, make sure it isn't already in the '__new hunk__' code.
For each suggestion, make sure to take into consideration also the context, meaning the lines before and after the relevant code.
- Provide the exact line numbers range (inclusive) for each issue.
- Avoid making suggestions that have already been implemented in the PR code. For example, if you want to add logs, or change a variable to const, or anything else, make sure it isn't already in the '__new hunk__' code.
- For each suggestion, make sure to take into consideration also the context, meaning the lines before and after the relevant code.
- Provide the exact line numbers range (inclusive) for each suggestion.
- Assume there is additional relevant code, that is not included in the diff.
{%- if extra_instructions %}
Extra instructions from the user:
======
{{ extra_instructions }}
======
{%- endif %}
You must use the following YAML schema to format your answer:
```yaml
Code suggestions:
@ -89,16 +90,19 @@ Code suggestions:
Example output:
```yaml
Code suggestions:
- relevant file: |-
src/file1.py
suggestion content: |-
Add a docstring to func1()
existing code: |-
def func1():
relevant lines start: 12
relevant lines end: 12
improved code: |-
...
- relevant file: |-
src/file1.py
suggestion content: |-
Add a docstring to func1()
existing code: |-
def func1():
relevant lines start: |-
12
relevant lines end: |-
12
improved code: |-
...
...
```
@ -112,18 +116,25 @@ Title: '{{title}}'
Branch: '{{branch}}'
Description: '{{description}}'
{%- if description %}
Description:
======
{{ description|trim }}
======
{%- endif %}
{%- if language %}
Main language: {{language}}
Main PR language: '{{ language }}'
{%- endif %}
The PR Diff:
```
{{- diff|trim }}
```
======
{{ diff|trim }}
======
Response (should be a valid YAML, and nothing else):
```yaml

View File

@ -0,0 +1,86 @@
[pr_custom_labels_prompt]
system="""You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
Your task is to provide labels that describe the PR content.
{%- if enable_custom_labels %}
Thoroughly read the labels name and the provided description, and decide whether the label is relevant to the PR.
{%- endif %}
{%- if extra_instructions %}
Extra instructions from the user:
======
{{ extra_instructions }}
======
{% endif %}
The output must be a YAML object equivalent to type $Labels, according to the following Pydantic definitions:
======
{%- if enable_custom_labels %}
{{ custom_labels_class }}
{%- else %}
class Label(str, Enum):
bug_fix = "Bug fix"
tests = "Tests"
enhancement = "Enhancement"
documentation = "Documentation"
other = "Other"
{%- endif %}
class Labels(BaseModel):
labels: List[Label] = Field(min_items=0, description="custom labels that describe the PR. Return the label value, not the name.")
======
Example output:
```yaml
labels:
- ...
- ...
```
Answer should be a valid YAML, and nothing else.
"""
user="""PR Info:
Previous title: '{{title}}'
Branch: '{{ branch }}'
{%- if description %}
Description:
======
{{ description|trim }}
======
{%- endif %}
{%- if language %}
Main PR language: '{{ language }}'
{%- endif %}
{%- if commit_messages_str %}
Commit messages:
======
{{ commit_messages_str|trim }}
======
{%- endif %}
The PR Git Diff:
======
{{ diff|trim }}
======
Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions, and ' ' (a space) for unchanged lines.
Response (should be a valid YAML, and nothing else):
```yaml
"""

View File

@ -1,86 +1,133 @@
[pr_description_prompt]
system="""You are CodiumAI-PR-Reviewer, a language model designed to review git pull requests.
Your task is to provide full description of the PR content.
- Make sure not to focus the new PR code (the '+' lines).
- Notice that the 'Previous title', 'Previous description' and 'Commit messages' sections may be partial, simplistic, non-informative or not up-to-date. Hence, compare them to the PR diff code, and use them only as a reference.
- If needed, each YAML output should be in block scalar format ('|-')
system="""You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
Your task is to provide a full description for the PR content - title, type, description, and main files walkthrough.
- Focus on the new PR code (lines starting with '+').
- 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 ('|-')
{%- if extra_instructions %}
Extra instructions from the user:
=====
{{ extra_instructions }}
=====
{% endif %}
You must use the following YAML schema to format your answer:
```yaml
PR Title:
type: string
description: an informative title for the PR, describing its main theme
PR Type:
type: array
items:
type: string
enum:
- Bug fix
- Tests
- Bug fix with tests
- Refactoring
- Enhancement
- Documentation
- Other
PR Description:
type: string
description: an informative and concise description of the PR
PR Main Files Walkthrough:
type: array
maxItems: 10
description: |-
a walkthrough of the PR changes. Review main files, and shortly describe the changes in each file (up to 10 most important files).
items:
filename:
type: string
description: the relevant file full path
changes in file:
type: string
description: minimal and concise description of the changes in the relevant file
The output must be a YAML object equivalent to type $PRDescription, according to the following Pydantic definitions:
=====
class PRType(str, Enum):
bug_fix = "Bug fix"
tests = "Tests"
enhancement = "Enhancement"
documentation = "Documentation"
other = "Other"
{%- if enable_custom_labels %}
{{ custom_labels_class }}
{%- endif %}
{%- if enable_file_walkthrough %}
class FileWalkthrough(BaseModel):
filename: str = Field(description="the relevant file full path")
changes_in_file: str = Field(description="minimal and concise summary of the changes in the relevant file")
{%- endif %}
{%- if enable_semantic_files_types %}
Class FileDescription(BaseModel):
filename: str = Field(description="the relevant file full path")
changes_summary: str = Field(description="minimal and concise summary of the changes in the relevant 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):
title: str = Field(description="an informative title for the PR, describing its main theme")
type: List[PRType] = Field(description="one or more types that describe the PR type. Return the label value, not the name.")
description: str = Field(description="an informative and concise description of the PR. {%- if use_bullet_points %} Use bullet points.{% endif %}")
{%- if enable_custom_labels %}
labels: List[Label] = Field(min_items=0, description="custom labels that describe the PR. Return the label value, not the name.")
{%- endif %}
{%- if enable_file_walkthrough %}
main_files_walkthrough: List[FileWalkthrough] = Field(max_items=10)
{%- endif %}
{%- if enable_semantic_files_types %}
pr_files[List[FileDescription]] = Field(max_items=15")
{%- endif %}
=====
Example output:
```yaml
PR Title: |-
title: |-
...
PR Type:
- Bug fix
PR Description: |-
type:
- ...
- ...
{%- if enable_custom_labels %}
labels:
- ...
- ...
{%- endif %}
description: |-
...
PR Main Files Walkthrough:
- ...
- ...
{%- if enable_file_walkthrough %}
main_files_walkthrough:
- ...
- ...
{%- endif %}
{%- if enable_semantic_files_types %}
pr_files:
- filename: |
...
changes_summary: |
...
label: |
...
...
{%- endif %}
```
Make sure to output a valid YAML. Don't repeat the prompt in the answer, and avoid outputting the 'type' and 'description' fields.
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|-')
"""
user="""PR Info:
Previous title: '{{title}}'
Previous description: '{{description}}'
{%- if description %}
Previous description:
=====
{{ description|trim }}
=====
{%- endif %}
Branch: '{{branch}}'
{%- if language %}
Main language: {{language}}
Main PR language: '{{ language }}'
{%- endif %}
{%- if commit_messages_str %}
Commit messages:
{{commit_messages_str}}
=====
{{ commit_messages_str|trim }}
=====
{%- endif %}
The PR Git Diff:
```
{{diff}}
```
The PR Diff:
=====
{{ diff|trim }}
=====
Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions, and ' ' (a space) for unchanged lines.
Response (should be a valid YAML, and nothing else):
```yaml
"""

View File

@ -1,5 +1,5 @@
[pr_information_from_user_prompt]
system="""You are CodiumAI-PR-Reviewer, a language model designed to review git pull requests.
system="""You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
Given the PR Info and the PR Git Diff, generate 3 short questions about the PR code for the PR author.
The goal of the questions is to help the language model understand the PR better, so the questions should be insightful, informative, non-trivial, and relevant to the PR.
You should prefer asking yes\\no questions, or multiple choice questions. Also add at least one open-ended question, but make sure they are not too difficult, and can be answered in a sentence or two.
@ -16,22 +16,36 @@ Questions to better understand the PR:
user="""PR Info:
Title: '{{title}}'
Branch: '{{branch}}'
Description: '{{description}}'
{%- if description %}
Description:
======
{{ description|trim }}
======
{%- endif %}
{%- if language %}
Main language: {{language}}
Main PR language: '{{ language }}'
{%- endif %}
{%- if commit_messages_str %}
Commit messages:
{{commit_messages_str}}
======
{{ commit_messages_str|trim }}
======
{%- endif %}
The PR Git Diff:
```
{{diff}}
```
======
{{ diff|trim }}
======
Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions, and ' ' (a space) for unchanged lines

View File

@ -1,36 +1,42 @@
[pr_questions_prompt]
system="""You are CodiumAI-PR-Reviewer, a language model designed to review git pull requests.
Your task is to answer questions about the new PR code (the '+' lines), and provide feedback.
system="""You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
Your goal is to answer questions\\tasks about the new PR code (lines starting with '+'), and provide feedback.
Be informative, constructive, and give examples. Try to be as specific as possible.
Don't avoid answering the questions. You must answer the questions, as best as you can, without adding unrelated content.
Make sure not to repeat modifications already implemented in the new PR code (the '+' lines).
Don't avoid answering the questions. You must answer the questions, as best as you can, without adding any unrelated content.
"""
user="""PR Info:
Title: '{{title}}'
Branch: '{{branch}}'
Description: '{{description}}'
{%- if language %}
Main language: {{language}}
{%- endif %}
{%- if commit_messages_str %}
Commit messages:
{{commit_messages_str}}
Title: '{{title}}'
Branch: '{{branch}}'
{%- if description %}
Description:
======
{{ description|trim }}
======
{%- endif %}
{%- if language %}
Main PR language: '{{ language }}'
{%- endif %}
The PR Git Diff:
```
{{diff}}
```
======
{{ diff|trim }}
======
Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions, and ' ' (a space) for unchanged lines
The PR Questions:
```
{{ questions }}
```
======
{{ questions|trim }}
======
Response:
Response to the PR Questions:
"""

View File

@ -1,18 +1,19 @@
[pr_review_prompt]
system="""You are PR-Reviewer, a language model designed to review git pull requests.
system="""You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
Your task is to provide constructive and concise feedback for the PR, and also provide meaningful code suggestions.
The review should focus on new code added in the PR diff (lines starting with '+')
Example PR Diff input:
'
Example PR Diff:
======
## src/file1.py
@@ -12,5 +12,5 @@ def func1():
code line that already existed in the file...
code line that already existed in the file....
code line 1 that remained unchanged in the PR
code line 2 that remained unchanged in the PR
-code line that was removed in the PR
+new code line added in the PR
code line that already existed in the file...
code line that already existed in the file...
+code line added in the PR
code line 3 that remained unchanged in the PR
@@ ... @@ def func2():
...
@ -20,24 +21,28 @@ code line that already existed in the file....
## src/file2.py
...
'
The review should focus on new code added in the PR (lines starting with '+'), and not on code that already existed in the file (lines starting with '-', or without prefix).
======
{%- if num_code_suggestions > 0 %}
- Provide up to {{ num_code_suggestions }} code suggestions.
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 focus on improving the new code added in the PR (lines starting with '+')
- Suggestions should focus on the new code added in the PR diff (lines starting with '+')
{%- endif %}
{%- if extra_instructions %}
Extra instructions from the user:
======
{{ extra_instructions }}
======
{% endif %}
You must use the following YAML schema to format your answer:
```yaml
PR Analysis:
@ -52,7 +57,6 @@ PR Analysis:
enum:
- Bug fix
- Tests
- Refactoring
- Enhancement
- Documentation
- Other
@ -91,16 +95,16 @@ PR Analysis:
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.
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 %}
PR Feedback:
General suggestions:
type: string
description: |-
General suggestions and feedback for the contributors and maintainers of
this PR. May include important suggestions for the overall structure,
primary purpose, best practices, critical bugs, and other aspects of the
PR. Don't address PR title and description, or lack of tests. Explain your suggestions.
General suggestions and feedback for the contributors and maintainers of this PR.
May include important suggestions for the overall structure,
primary purpose, best practices, critical bugs, and other aspects of the PR.
Don't address PR title and description, or lack of tests. Explain your suggestions.
{%- if num_code_suggestions > 0 %}
Code feedback:
type: array
@ -113,11 +117,10 @@ PR Feedback:
suggestion:
type: string
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.
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:
type: string
description: |-
@ -129,8 +132,8 @@ PR Feedback:
Security concerns:
type: string
description: >-
yes\\no question: does this PR code introduce possible security concerns or
issues, like SQL injection, XSS, CSRF, and others ? If answered 'yes',explain your answer shortly
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 %}
```
@ -142,7 +145,7 @@ PR Analysis:
PR summary: |-
xxx
Type of PR: |-
Bug fix
...
{%- if require_score %}
Score: 89
{%- endif %}
@ -152,7 +155,8 @@ PR Analysis:
Focused PR: no, because ...
{%- endif %}
{%- if require_estimate_effort_to_review %}
Estimated effort to review [1-5]: 3, because ...
Estimated effort to review [1-5]: |-
3, because ...
{%- endif %}
PR Feedback:
General PR suggestions: |-
@ -177,34 +181,50 @@ Don't repeat the prompt in the answer, and avoid outputting the 'type' and 'desc
"""
user="""PR Info:
Title: '{{title}}'
Branch: '{{branch}}'
Description: '{{description}}'
{%- if description %}
Description:
======
{{ description|trim }}
======
{%- endif %}
{%- if language %}
Main language: {{language}}
Main PR language: '{{ language }}'
{%- endif %}
{%- if commit_messages_str %}
Commit messages:
======
{{commit_messages_str}}
======
{%- endif %}
{%- if question_str %}
######
=====
Here are questions to better understand the PR. Use the answers to provide better feedback.
{{question_str|trim}}
{{ question_str|trim }}
User answers:
{{answer_str|trim}}
######
'
{{ answer_str|trim }}
'
=====
{%- endif %}
The PR Git Diff:
```
{{diff}}
```
Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions. Focus on the '+' lines.
The PR Diff:
======
{{ diff|trim }}
======
Response (should be a valid YAML, and nothing else):
```yaml

View File

@ -2,10 +2,10 @@
system="""
"""
user="""You are given a list of code suggestions to improve a PR:
user="""You are given a list of code suggestions to improve a Git Pull Request (PR):
======
{{ suggestion_str|trim }}
======
Your task is to sort the code suggestions by their order of importance, and return a list with sorting order.
The sorting order is a list of pairs, where each pair contains the index of the suggestion in the original list.

View File

@ -1,5 +1,5 @@
[pr_update_changelog_prompt]
system="""You are a language model called CodiumAI-PR-Changlog-summarizer.
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.
@ -8,28 +8,44 @@ Your task is to update the CHANGELOG.md file of the project, to shortly summariz
{%- if extra_instructions %}
Extra instructions from the user:
{{ extra_instructions }}
======
{{ extra_instructions|trim }}
======
{%- endif %}
"""
user="""PR Info:
Title: '{{title}}'
Branch: '{{branch}}'
Description: '{{description}}'
{%- if description %}
Description:
======
{{ description|trim }}
======
{%- endif %}
{%- if language %}
Main language: {{language}}
Main PR language: '{{ language }}'
{%- endif %}
{%- if commit_messages_str %}
Commit messages:
{{commit_messages_str}}
======
{{ commit_messages_str|trim }}
======
{%- endif %}
The PR Diff:
```
{{diff}}
```
The PR Git Diff:
======
{{ diff|trim }}
======
Current date:
```
@ -37,9 +53,10 @@ Current date:
```
The current CHANGELOG.md:
```
======
{{ changelog_file_str }}
```
======
Response:
"""

View File

@ -0,0 +1,179 @@
import copy
import textwrap
from typing import Dict
from jinja2 import Environment, StrictUndefined
from pr_agent.algo.ai_handler import AiHandler
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import load_yaml
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.git_provider import get_main_pr_language
from pr_agent.log import get_logger
class PRAddDocs:
def __init__(self, pr_url: str, cli_mode=False, args: list = None):
self.git_provider = get_git_provider()(pr_url)
self.main_language = get_main_pr_language(
self.git_provider.get_languages(), self.git_provider.get_files()
)
self.ai_handler = AiHandler()
self.patches_diff = None
self.prediction = None
self.cli_mode = cli_mode
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_language,
"diff": "", # empty diff for initial calculation
"extra_instructions": get_settings().pr_add_docs.extra_instructions,
"commit_messages_str": self.git_provider.get_commit_messages(),
'docs_for_language': get_docs_for_language(self.main_language,
get_settings().pr_add_docs.docs_style),
}
self.token_handler = TokenHandler(self.git_provider.pr,
self.vars,
get_settings().pr_add_docs_prompt.system,
get_settings().pr_add_docs_prompt.user)
async def run(self):
try:
get_logger().info('Generating code Docs for PR...')
if get_settings().config.publish_output:
self.git_provider.publish_comment("Generating Documentation...", is_temporary=True)
get_logger().info('Preparing PR documentation...')
await retry_with_fallback_models(self._prepare_prediction)
data = self._prepare_pr_code_docs()
if (not data) or (not 'Code Documentation' in data):
get_logger().info('No code documentation found for PR.')
return
if get_settings().config.publish_output:
get_logger().info('Pushing PR documentation...')
self.git_provider.remove_initial_comment()
get_logger().info('Pushing inline code documentation...')
self.push_inline_docs(data)
except Exception as e:
get_logger().error(f"Failed to generate code documentation for PR, error: {e}")
async def _prepare_prediction(self, model: str):
get_logger().info('Getting PR diff...')
# Disable adding docs to scripts and other non-relevant text files
from pr_agent.algo.language_handler import bad_extensions
bad_extensions += get_settings().docs_blacklist_extensions.docs_blacklist
self.patches_diff = get_pr_diff(self.git_provider,
self.token_handler,
model,
add_line_numbers_to_hunks=True,
disable_extra_lines=False)
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_add_docs_prompt.system).render(variables)
user_prompt = environment.from_string(get_settings().pr_add_docs_prompt.user).render(variables)
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
get_logger().info(f"\nUser prompt:\n{user_prompt}")
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
system=system_prompt, user=user_prompt)
return response
def _prepare_pr_code_docs(self) -> Dict:
docs = self.prediction.strip()
data = load_yaml(docs)
if isinstance(data, list):
data = {'Code Documentation': data}
return data
def push_inline_docs(self, data):
docs = []
if not data['Code Documentation']:
return self.git_provider.publish_comment('No code documentation found to improve this PR.')
for d in data['Code Documentation']:
try:
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"add_docs: {d}")
relevant_file = d['relevant file'].strip()
relevant_line = int(d['relevant line']) # absolute position
documentation = d['documentation']
doc_placement = d['doc placement'].strip()
if documentation:
new_code_snippet = self.dedent_code(relevant_file, relevant_line, documentation, doc_placement,
add_original_line=True)
body = f"**Suggestion:** Proposed documentation\n```suggestion\n" + new_code_snippet + "\n```"
docs.append({'body': body, 'relevant_file': relevant_file,
'relevant_lines_start': relevant_line,
'relevant_lines_end': relevant_line})
except Exception:
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"Could not parse code docs: {d}")
is_successful = self.git_provider.publish_code_suggestions(docs)
if not is_successful:
get_logger().info("Failed to publish code docs, trying to publish each docs separately")
for doc_suggestion in docs:
self.git_provider.publish_code_suggestions([doc_suggestion])
def dedent_code(self, relevant_file, relevant_lines_start, new_code_snippet, doc_placement='after',
add_original_line=False):
try: # dedent code snippet
self.diff_files = self.git_provider.diff_files if self.git_provider.diff_files \
else self.git_provider.get_diff_files()
original_initial_line = None
for file in self.diff_files:
if file.filename.strip() == relevant_file:
original_initial_line = file.head_file.splitlines()[relevant_lines_start - 1]
break
if original_initial_line:
if doc_placement == 'after':
line = file.head_file.splitlines()[relevant_lines_start]
else:
line = original_initial_line
suggested_initial_line = new_code_snippet.splitlines()[0]
original_initial_spaces = len(line) - len(line.lstrip())
suggested_initial_spaces = len(suggested_initial_line) - len(suggested_initial_line.lstrip())
delta_spaces = original_initial_spaces - suggested_initial_spaces
if delta_spaces > 0:
new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * " ").rstrip('\n')
if add_original_line:
if doc_placement == 'after':
new_code_snippet = original_initial_line + "\n" + new_code_snippet
else:
new_code_snippet = new_code_snippet.rstrip() + "\n" + original_initial_line
except Exception as e:
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
return new_code_snippet
def get_docs_for_language(language, style):
language = language.lower()
if language == 'java':
return "Javadocs"
elif language in ['python', 'lisp', 'clojure']:
return f"Docstring ({style})"
elif language in ['javascript', 'typescript']:
return "JSdocs"
elif language == 'c++':
return "Doxygen"
else:
return "Docs"

View File

@ -1,16 +1,16 @@
import copy
import logging
import textwrap
from typing import List, Dict
from typing import Dict, List
from jinja2 import Environment, StrictUndefined
from pr_agent.algo.ai_handler import BaseAiHandler, AiHandler
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models, get_pr_multi_diffs
from pr_agent.algo.pr_processing import get_pr_diff, get_pr_multi_diffs, retry_with_fallback_models
from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import load_yaml
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import BitbucketProvider, get_git_provider
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.git_provider import get_main_pr_language
from pr_agent.log import get_logger
class PRCodeSuggestions:
@ -52,42 +52,46 @@ class PRCodeSuggestions:
async def run(self):
try:
logging.info('Generating code suggestions for PR...')
get_logger().info('Generating code suggestions for PR...')
if get_settings().config.publish_output:
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
self.git_provider.publish_comment("Preparing suggestions...", is_temporary=True)
logging.info('Preparing PR review...')
get_logger().info('Preparing PR code suggestions...')
if not self.is_extended:
await retry_with_fallback_models(self._prepare_prediction)
data = self._prepare_pr_code_suggestions()
else:
data = await retry_with_fallback_models(self._prepare_prediction_extended)
if (not data) or (not 'Code suggestions' in data):
logging.info('No code suggestions found for PR.')
get_logger().info('No code suggestions found for PR.')
return
if (not self.is_extended and get_settings().pr_code_suggestions.rank_suggestions) or \
(self.is_extended and get_settings().pr_code_suggestions.rank_extended_suggestions):
logging.info('Ranking Suggestions...')
get_logger().info('Ranking Suggestions...')
data['Code suggestions'] = await self.rank_suggestions(data['Code suggestions'])
if get_settings().config.publish_output:
logging.info('Pushing PR review...')
get_logger().info('Pushing PR code suggestions...')
self.git_provider.remove_initial_comment()
logging.info('Pushing inline code suggestions...')
self.push_inline_code_suggestions(data)
if get_settings().pr_code_suggestions.summarize:
get_logger().info('Pushing summarize code suggestions...')
self.publish_summarizes_suggestions(data)
else:
get_logger().info('Pushing inline code suggestions...')
self.push_inline_code_suggestions(data)
except Exception as e:
logging.error(f"Failed to generate code suggestions for PR, error: {e}")
get_logger().error(f"Failed to generate code suggestions for PR, error: {e}")
async def _prepare_prediction(self, model: str):
logging.info('Getting PR diff...')
get_logger().info('Getting PR diff...')
self.patches_diff = get_pr_diff(self.git_provider,
self.token_handler,
model,
add_line_numbers_to_hunks=True,
disable_extra_lines=True)
logging.info('Getting AI prediction...')
get_logger().info('Getting AI prediction...')
self.prediction = await self._get_prediction(model)
async def _get_prediction(self, model: str):
@ -97,8 +101,8 @@ class PRCodeSuggestions:
system_prompt = environment.from_string(get_settings().pr_code_suggestions_prompt.system).render(variables)
user_prompt = environment.from_string(get_settings().pr_code_suggestions_prompt.user).render(variables)
if get_settings().config.verbosity_level >= 2:
logging.info(f"\nSystem prompt:\n{system_prompt}")
logging.info(f"\nUser prompt:\n{user_prompt}")
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
get_logger().info(f"\nUser prompt:\n{user_prompt}")
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
system=system_prompt, user=user_prompt)
@ -115,12 +119,13 @@ class PRCodeSuggestions:
code_suggestions = []
if not data['Code suggestions']:
get_logger().info('No suggestions found to improve this PR.')
return self.git_provider.publish_comment('No suggestions found to improve this PR.')
for d in data['Code suggestions']:
try:
if get_settings().config.verbosity_level >= 2:
logging.info(f"suggestion: {d}")
get_logger().info(f"suggestion: {d}")
relevant_file = d['relevant file'].strip()
relevant_lines_start = int(d['relevant lines start']) # absolute position
relevant_lines_end = int(d['relevant lines end'])
@ -136,11 +141,11 @@ class PRCodeSuggestions:
'relevant_lines_end': relevant_lines_end})
except Exception:
if get_settings().config.verbosity_level >= 2:
logging.info(f"Could not parse suggestion: {d}")
get_logger().info(f"Could not parse suggestion: {d}")
is_successful = self.git_provider.publish_code_suggestions(code_suggestions)
if not is_successful:
logging.info("Failed to publish code suggestions, trying to publish each suggestion separately")
get_logger().info("Failed to publish code suggestions, trying to publish each suggestion separately")
for code_suggestion in code_suggestions:
self.git_provider.publish_code_suggestions([code_suggestion])
@ -162,19 +167,19 @@ class PRCodeSuggestions:
new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * " ").rstrip('\n')
except Exception as e:
if get_settings().config.verbosity_level >= 2:
logging.info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
get_logger().info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
return new_code_snippet
async def _prepare_prediction_extended(self, model: str) -> dict:
logging.info('Getting PR diff...')
get_logger().info('Getting PR diff...')
patches_diff_list = get_pr_multi_diffs(self.git_provider, self.token_handler, model,
max_calls=get_settings().pr_code_suggestions.max_number_of_calls)
logging.info('Getting multi AI predictions...')
get_logger().info('Getting multi AI predictions...')
prediction_list = []
for i, patches_diff in enumerate(patches_diff_list):
logging.info(f"Processing chunk {i + 1} of {len(patches_diff_list)}")
get_logger().info(f"Processing chunk {i + 1} of {len(patches_diff_list)}")
self.patches_diff = patches_diff
prediction = await self._get_prediction(model)
prediction_list.append(prediction)
@ -222,8 +227,8 @@ class PRCodeSuggestions:
variables)
user_prompt = environment.from_string(get_settings().pr_sort_code_suggestions_prompt.user).render(variables)
if get_settings().config.verbosity_level >= 2:
logging.info(f"\nSystem prompt:\n{system_prompt}")
logging.info(f"\nUser prompt:\n{user_prompt}")
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
get_logger().info(f"\nUser prompt:\n{user_prompt}")
response, finish_reason = await self.ai_handler.chat_completion(model=model, system=system_prompt,
user=user_prompt)
@ -238,9 +243,46 @@ class PRCodeSuggestions:
data_sorted = data_sorted[:new_len]
except Exception as e:
if get_settings().config.verbosity_level >= 1:
logging.info(f"Could not sort suggestions, error: {e}")
get_logger().info(f"Could not sort suggestions, error: {e}")
data_sorted = suggestion_list
return data_sorted
def publish_summarizes_suggestions(self, data: Dict):
try:
data_markdown = "## PR Code Suggestions\n\n"
language_extension_map_org = get_settings().language_extension_map_org
extension_to_language = {}
for language, extensions in language_extension_map_org.items():
for ext in extensions:
extension_to_language[ext] = language
for s in data['Code suggestions']:
try:
extension_s = s['relevant file'].rsplit('.')[-1]
code_snippet_link = self.git_provider.get_line_link(s['relevant file'], s['relevant lines start'],
s['relevant lines end'])
data_markdown += f"\n💡 Suggestion:\n\n**{s['suggestion content']}**\n\n"
if code_snippet_link:
data_markdown += f" File: [{s['relevant file']} ({s['relevant lines start']}-{s['relevant lines end']})]({code_snippet_link})\n\n"
else:
data_markdown += f"File: {s['relevant file']} ({s['relevant lines start']}-{s['relevant lines end']})\n\n"
if self.git_provider.is_supported("gfm_markdown"):
data_markdown += "<details> <summary> Example code:</summary>\n\n"
data_markdown += f"___\n\n"
language_name = "python"
if extension_s and (extension_s in extension_to_language):
language_name = extension_to_language[extension_s]
data_markdown += f"Existing code:\n```{language_name}\n{s['existing code']}\n```\n"
data_markdown += f"Improved code:\n```{language_name}\n{s['improved code']}\n```\n"
if self.git_provider.is_supported("gfm_markdown"):
data_markdown += "</details>\n"
data_markdown += "\n___\n\n"
except Exception as e:
get_logger().error(f"Could not parse suggestion: {s}, error: {e}")
self.git_provider.publish_comment(data_markdown)
except Exception as e:
get_logger().info(f"Failed to publish summarized code suggestions, error: {e}")

View File

@ -1,7 +1,6 @@
import logging
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.log import get_logger
class PRConfig:
@ -19,11 +18,11 @@ class PRConfig:
self.git_provider = get_git_provider()(pr_url)
async def run(self):
logging.info('Getting configuration settings...')
logging.info('Preparing configs...')
get_logger().info('Getting configuration settings...')
get_logger().info('Preparing configs...')
pr_comment = self._prepare_pr_configs()
if get_settings().config.publish_output:
logging.info('Pushing configs...')
get_logger().info('Pushing configs...')
self.git_provider.publish_comment(pr_comment)
self.git_provider.remove_initial_comment()
return ""
@ -44,5 +43,5 @@ class PRConfig:
comment_str += f"\n{header.lower()}.{key.lower()} = {repr(value) if isinstance(value, str) else value}"
comment_str += " "
if get_settings().config.verbosity_level >= 2:
logging.info(f"comment_str:\n{comment_str}")
get_logger().info(f"comment_str:\n{comment_str}")
return comment_str

View File

@ -1,7 +1,5 @@
import copy
import json
import re
import logging
from typing import List, Tuple
from jinja2 import Environment, StrictUndefined
@ -9,10 +7,11 @@ from jinja2 import Environment, StrictUndefined
from pr_agent.algo.ai_handler import BaseAiHandler, AiHandler
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import load_yaml
from pr_agent.algo.utils import load_yaml, set_custom_labels, get_user_labels
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.git_provider import get_main_pr_language
from pr_agent.log import get_logger
class PRDescription:
@ -31,6 +30,11 @@ class PRDescription:
)
self.pr_id = self.git_provider.get_pr_id()
if get_settings().pr_description.enable_semantic_files_types and not self.git_provider.is_supported(
"gfm_markdown"):
get_logger().debug(f"Disabling semantic files types for {self.pr_id}")
get_settings().pr_description.enable_semantic_files_types = False
# Initialize the AI handler
self.ai_handler = ai_handler
@ -41,8 +45,13 @@ class PRDescription:
"description": self.git_provider.get_pr_description(full=False),
"language": self.main_pr_language,
"diff": "", # empty diff for initial calculation
"use_bullet_points": get_settings().pr_description.use_bullet_points,
"extra_instructions": get_settings().pr_description.extra_instructions,
"commit_messages_str": self.git_provider.get_commit_messages()
"commit_messages_str": self.git_provider.get_commit_messages(),
"enable_custom_labels": get_settings().config.enable_custom_labels,
"custom_labels_class": "", # will be filled if necessary in 'set_custom_labels' function
"enable_file_walkthrough": get_settings().pr_description.enable_file_walkthrough,
"enable_semantic_files_types": get_settings().pr_description.enable_semantic_files_types,
}
self.user_description = self.git_provider.get_user_description()
@ -65,18 +74,21 @@ class PRDescription:
"""
try:
logging.info(f"Generating a PR description {self.pr_id}")
get_logger().info(f"Generating a PR description {self.pr_id}")
if get_settings().config.publish_output:
self.git_provider.publish_comment("Preparing PR description...", is_temporary=True)
await retry_with_fallback_models(self._prepare_prediction)
logging.info(f"Preparing answer {self.pr_id}")
get_logger().info(f"Preparing answer {self.pr_id}")
if self.prediction:
self._prepare_data()
else:
return None
if get_settings().pr_description.enable_semantic_files_types:
self._prepare_file_labels()
pr_labels = []
if get_settings().pr_description.publish_labels:
pr_labels = self._prepare_labels()
@ -88,19 +100,25 @@ class PRDescription:
full_markdown_description = f"## Title\n\n{pr_title}\n\n___\n{pr_body}"
if get_settings().config.publish_output:
logging.info(f"Pushing answer {self.pr_id}")
get_logger().info(f"Pushing answer {self.pr_id}")
if get_settings().pr_description.publish_description_as_comment:
self.git_provider.publish_comment(full_markdown_description)
else:
self.git_provider.publish_description(pr_title, pr_body)
if get_settings().pr_description.publish_labels and self.git_provider.is_supported("get_labels"):
current_labels = self.git_provider.get_labels()
if current_labels is None:
current_labels = []
self.git_provider.publish_labels(pr_labels + current_labels)
user_labels = get_user_labels(current_labels)
self.git_provider.publish_labels(pr_labels + user_labels)
if (get_settings().pr_description.final_update_message and
hasattr(self.git_provider, 'pr_url') and self.git_provider.pr_url):
latest_commit_url = self.git_provider.get_latest_commit_url()
if latest_commit_url:
self.git_provider.publish_comment(
f"**[PR Description]({self.git_provider.pr_url})** updated to latest commit ({latest_commit_url})")
self.git_provider.remove_initial_comment()
except Exception as e:
logging.error(f"Error generating PR description {self.pr_id}: {e}")
get_logger().error(f"Error generating PR description {self.pr_id}: {e}")
return ""
@ -121,9 +139,9 @@ class PRDescription:
if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description:
return None
logging.info(f"Getting PR diff {self.pr_id}")
get_logger().info(f"Getting PR diff {self.pr_id}")
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
logging.info(f"Getting AI prediction {self.pr_id}")
get_logger().info(f"Getting AI prediction {self.pr_id}")
self.prediction = await self._get_prediction(model)
async def _get_prediction(self, model: str) -> str:
@ -140,12 +158,13 @@ class PRDescription:
variables["diff"] = self.patches_diff # update diff
environment = Environment(undefined=StrictUndefined)
set_custom_labels(variables)
system_prompt = environment.from_string(get_settings().pr_description_prompt.system).render(variables)
user_prompt = environment.from_string(get_settings().pr_description_prompt.user).render(variables)
if get_settings().config.verbosity_level >= 2:
logging.info(f"\nSystem prompt:\n{system_prompt}")
logging.info(f"\nUser prompt:\n{user_prompt}")
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
get_logger().info(f"\nUser prompt:\n{user_prompt}")
response, finish_reason = await self.ai_handler.chat_completion(
model=model,
@ -154,8 +173,10 @@ class PRDescription:
user=user_prompt
)
return response
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"\nAI response:\n{response}")
return response
def _prepare_data(self):
# Load the AI prediction data into a dictionary
@ -169,16 +190,20 @@ class PRDescription:
pr_types = []
# If the 'PR Type' key is present in the dictionary, split its value by comma and assign it to 'pr_types'
if 'PR Type' in self.data:
if type(self.data['PR Type']) == list:
pr_types = self.data['PR Type']
elif type(self.data['PR Type']) == str:
pr_types = self.data['PR Type'].split(',')
if 'labels' in self.data:
if type(self.data['labels']) == list:
pr_types = self.data['labels']
elif type(self.data['labels']) == str:
pr_types = self.data['labels'].split(',')
elif 'type' in self.data:
if type(self.data['type']) == list:
pr_types = self.data['type']
elif type(self.data['type']) == str:
pr_types = self.data['type'].split(',')
return pr_types
def _prepare_pr_answer_with_markers(self) -> Tuple[str, str]:
logging.info(f"Using description marker replacements {self.pr_id}")
get_logger().info(f"Using description marker replacements {self.pr_id}")
title = self.vars["title"]
body = self.user_description
if get_settings().pr_description.include_generated_by_header:
@ -186,7 +211,12 @@ class PRDescription:
else:
ai_header = ""
ai_summary = self.data.get('PR Description')
ai_type = self.data.get('type')
if ai_type and not re.search(r'<!--\s*pr_agent:type\s*-->', body):
pr_type = f"{ai_header}{ai_type}"
body = body.replace('pr_agent:type', pr_type)
ai_summary = self.data.get('description')
if ai_summary and not re.search(r'<!--\s*pr_agent:summary\s*-->', body):
summary = f"{ai_header}{ai_summary}"
body = body.replace('pr_agent:summary', summary)
@ -215,12 +245,17 @@ class PRDescription:
# Iterate over the dictionary items and append the key and value to 'markdown_text' in a markdown format
markdown_text = ""
# Don't display 'PR Labels'
if 'labels' in self.data and self.git_provider.is_supported("get_labels"):
self.data.pop('labels')
if not get_settings().pr_description.enable_pr_type:
self.data.pop('type')
for key, value in self.data.items():
markdown_text += f"## {key}\n\n"
markdown_text += f"{value}\n\n"
# Remove the 'PR Title' key from the dictionary
ai_title = self.data.pop('PR Title', self.vars["title"])
ai_title = self.data.pop('title', self.vars["title"])
if get_settings().pr_description.keep_original_user_title:
# Assign the original PR title to the 'title' variable
title = self.vars["title"]
@ -232,26 +267,131 @@ class PRDescription:
# except for the items containing the word 'walkthrough'
pr_body = ""
for idx, (key, value) in enumerate(self.data.items()):
pr_body += f"## {key}:\n"
if key == 'pr_files':
value = self.file_label_dict
key_publish = "PR changes walkthrough"
else:
key_publish = key.rstrip(':').replace("_", " ").capitalize()
pr_body += f"## {key_publish}\n"
if 'walkthrough' in key.lower():
# for filename, description in value.items():
if self.git_provider.is_supported("gfm_markdown"):
pr_body += "<details> <summary>files:</summary>\n\n"
for file in value:
filename = file['filename'].replace("'", "`")
description = file['changes in file']
pr_body += f'`{filename}`: {description}\n'
description = file['changes_in_file']
pr_body += f'- `{filename}`: {description}\n'
if self.git_provider.is_supported("gfm_markdown"):
pr_body +="</details>\n"
pr_body += "</details>\n"
elif 'pr_files' in key.lower():
pr_body = self.process_pr_files_prediction(pr_body, value)
else:
# if the value is a list, join its items by comma
if type(value) == list:
if isinstance(value, list):
value = ', '.join(v for v in value)
pr_body += f"{value}\n"
if idx < len(self.data) - 1:
pr_body += "\n___\n"
if get_settings().config.verbosity_level >= 2:
logging.info(f"title:\n{title}\n{pr_body}")
get_logger().info(f"title:\n{title}\n{pr_body}")
return title, pr_body
return title, pr_body
def _prepare_file_labels(self):
self.file_label_dict = {}
for file in self.data['pr_files']:
try:
filename = file['filename'].replace("'", "`").replace('"', '`')
changes_summary = file['changes_summary']
label = file['label']
if label not in self.file_label_dict:
self.file_label_dict[label] = []
self.file_label_dict[label].append((filename, changes_summary))
except Exception as e:
get_logger().error(f"Error preparing file label dict {self.pr_id}: {e}")
pass
def process_pr_files_prediction(self, pr_body, value):
if not self.git_provider.is_supported("gfm_markdown"):
get_logger().info(f"Disabling semantic files types for {self.pr_id} since gfm_markdown is not supported")
return pr_body
try:
pr_body += "<table>"
header = f"Relevant files"
delta = 65
header += "&nbsp; " * delta
pr_body += f"""<thead><tr><th></th><th>{header}</th></tr></thead>"""
pr_body += """<tbody>"""
for semantic_label in value.keys():
s_label = semantic_label.strip("'").strip('"')
pr_body += f"""<tr><td><strong>{s_label.capitalize()}</strong></td>"""
list_tuples = value[semantic_label]
pr_body += f"""<td><details><summary>{len(list_tuples)} files</summary><table>"""
for filename, file_change_description in list_tuples:
filename = filename.replace("'", "`")
filename_publish = filename.split("/")[-1]
filename_publish = f"{filename_publish}"
if len(filename_publish) < (delta - 5):
filename_publish += "&nbsp; " * ((delta - 5) - len(filename_publish))
diff_plus_minus = ""
diff_files = self.git_provider.diff_files
for f in diff_files:
if f.filename.lower() == filename.lower():
num_plus_lines = f.num_plus_lines
num_minus_lines = f.num_minus_lines
diff_plus_minus += f"+{num_plus_lines}/-{num_minus_lines}"
break
# try to add line numbers link to code suggestions
link = ""
if hasattr(self.git_provider, 'get_line_link'):
filename = filename.strip()
link = self.git_provider.get_line_link(filename, relevant_line_start=-1)
file_change_description = self._insert_br_after_x_chars(file_change_description, x=(delta - 5))
pr_body += f"""
<tr>
<td>
<details>
<summary><strong>{filename_publish}</strong></summary>
<ul>
{filename}<br><br>
<strong>{file_change_description}</strong>
</ul>
</details>
</td>
<td><a href="{link}"> {diff_plus_minus}</a></td>
</tr>
"""
pr_body += """</table></details></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
def _insert_br_after_x_chars(self, text, x=70):
"""
Insert <br> into a string after a word that increases its length above x characters.
"""
if len(text) < x:
return text
words = text.split(' ')
new_text = ""
current_length = 0
for word in words:
# Check if adding this word exceeds x characters
if current_length + len(word) > x:
new_text += "<br>" # Insert line break
current_length = 0 # Reset counter
# Add the word to the new text
new_text += word + " "
current_length += len(word) + 1 # Add 1 for the space
return new_text.strip() # Remove trailing space

View File

@ -0,0 +1,171 @@
import copy
import re
from typing import List, Tuple
from jinja2 import Environment, StrictUndefined
from pr_agent.algo.ai_handler import AiHandler
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import load_yaml, set_custom_labels, get_user_labels
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 PRGenerateLabels:
def __init__(self, pr_url: str, args: list = None):
"""
Initialize the PRGenerateLabels object with the necessary attributes and objects for generating labels
corresponding to the PR using an AI model.
Args:
pr_url (str): The URL of the pull request.
args (list, optional): List of arguments passed to the PRGenerateLabels class. Defaults to None.
"""
# Initialize the git provider and main PR language
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.pr_id = self.git_provider.get_pr_id()
# Initialize the AI handler
self.ai_handler = AiHandler()
# Initialize the variables dictionary
self.vars = {
"title": self.git_provider.pr.title,
"branch": self.git_provider.get_pr_branch(),
"description": self.git_provider.get_pr_description(full=False),
"language": self.main_pr_language,
"diff": "", # empty diff for initial calculation
"use_bullet_points": get_settings().pr_description.use_bullet_points,
"extra_instructions": get_settings().pr_description.extra_instructions,
"commit_messages_str": self.git_provider.get_commit_messages(),
"enable_custom_labels": get_settings().config.enable_custom_labels,
"custom_labels_class": "", # will be filled if necessary in 'set_custom_labels' function
}
# Initialize the token handler
self.token_handler = TokenHandler(
self.git_provider.pr,
self.vars,
get_settings().pr_custom_labels_prompt.system,
get_settings().pr_custom_labels_prompt.user,
)
# Initialize patches_diff and prediction attributes
self.patches_diff = None
self.prediction = None
async def run(self):
"""
Generates a PR labels using an AI model and publishes it to the PR.
"""
try:
get_logger().info(f"Generating a PR labels {self.pr_id}")
if get_settings().config.publish_output:
self.git_provider.publish_comment("Preparing PR labels...", is_temporary=True)
await retry_with_fallback_models(self._prepare_prediction)
get_logger().info(f"Preparing answer {self.pr_id}")
if self.prediction:
self._prepare_data()
else:
return None
pr_labels = self._prepare_labels()
if get_settings().config.publish_output:
get_logger().info(f"Pushing labels {self.pr_id}")
current_labels = self.git_provider.get_labels()
user_labels = get_user_labels(current_labels)
pr_labels = pr_labels + user_labels
if self.git_provider.is_supported("get_labels"):
self.git_provider.publish_labels(pr_labels)
elif pr_labels:
value = ', '.join(v for v in pr_labels)
pr_labels_text = f"## PR Labels:\n{value}\n"
self.git_provider.publish_comment(pr_labels_text, is_temporary=False)
self.git_provider.remove_initial_comment()
except Exception as e:
get_logger().error(f"Error generating PR labels {self.pr_id}: {e}")
return ""
async def _prepare_prediction(self, model: str) -> None:
"""
Prepare the AI prediction for the PR labels based on the provided model.
Args:
model (str): The name of the model to be used for generating the prediction.
Returns:
None
Raises:
Any exceptions raised by the 'get_pr_diff' and '_get_prediction' functions.
"""
get_logger().info(f"Getting PR diff {self.pr_id}")
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
get_logger().info(f"Getting AI prediction {self.pr_id}")
self.prediction = await self._get_prediction(model)
async def _get_prediction(self, model: str) -> str:
"""
Generate an AI prediction for the PR labels based on the provided model.
Args:
model (str): The name of the model to be used for generating the prediction.
Returns:
str: The generated AI prediction.
"""
variables = copy.deepcopy(self.vars)
variables["diff"] = self.patches_diff # update diff
environment = Environment(undefined=StrictUndefined)
set_custom_labels(variables)
system_prompt = environment.from_string(get_settings().pr_custom_labels_prompt.system).render(variables)
user_prompt = environment.from_string(get_settings().pr_custom_labels_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=0.2,
system=system_prompt,
user=user_prompt
)
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"\nAI response:\n{response}")
return response
def _prepare_data(self):
# Load the AI prediction data into a dictionary
self.data = load_yaml(self.prediction.strip())
def _prepare_labels(self) -> List[str]:
pr_types = []
# If the 'labels' key is present in the dictionary, split its value by comma and assign it to 'pr_types'
if 'labels' in self.data:
if type(self.data['labels']) == list:
pr_types = self.data['labels']
elif type(self.data['labels']) == str:
pr_types = self.data['labels'].split(',')
return pr_types

View File

@ -1,5 +1,4 @@
import copy
import logging
from jinja2 import Environment, StrictUndefined
@ -9,6 +8,7 @@ from pr_agent.algo.token_handler import TokenHandler
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.git_provider import get_main_pr_language
from pr_agent.log import get_logger
class PRInformationFromUser:
@ -34,22 +34,22 @@ class PRInformationFromUser:
self.prediction = None
async def run(self):
logging.info('Generating question to the user...')
get_logger().info('Generating question to the user...')
if get_settings().config.publish_output:
self.git_provider.publish_comment("Preparing questions...", is_temporary=True)
await retry_with_fallback_models(self._prepare_prediction)
logging.info('Preparing questions...')
get_logger().info('Preparing questions...')
pr_comment = self._prepare_pr_answer()
if get_settings().config.publish_output:
logging.info('Pushing questions...')
get_logger().info('Pushing questions...')
self.git_provider.publish_comment(pr_comment)
self.git_provider.remove_initial_comment()
return ""
async def _prepare_prediction(self, model):
logging.info('Getting PR diff...')
get_logger().info('Getting PR diff...')
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
logging.info('Getting AI prediction...')
get_logger().info('Getting AI prediction...')
self.prediction = await self._get_prediction(model)
async def _get_prediction(self, model: str):
@ -59,8 +59,8 @@ class PRInformationFromUser:
system_prompt = environment.from_string(get_settings().pr_information_from_user_prompt.system).render(variables)
user_prompt = environment.from_string(get_settings().pr_information_from_user_prompt.user).render(variables)
if get_settings().config.verbosity_level >= 2:
logging.info(f"\nSystem prompt:\n{system_prompt}")
logging.info(f"\nUser prompt:\n{user_prompt}")
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
get_logger().info(f"\nUser prompt:\n{user_prompt}")
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
system=system_prompt, user=user_prompt)
return response
@ -68,7 +68,7 @@ class PRInformationFromUser:
def _prepare_pr_answer(self) -> str:
model_output = self.prediction.strip()
if get_settings().config.verbosity_level >= 2:
logging.info(f"answer_str:\n{model_output}")
get_logger().info(f"answer_str:\n{model_output}")
answer_str = f"{model_output}\n\n Please respond to the questions above in the following format:\n\n" +\
"\n>/answer\n>1) ...\n>2) ...\n>...\n"
return answer_str

View File

@ -1,5 +1,4 @@
import copy
import logging
from jinja2 import Environment, StrictUndefined
@ -9,6 +8,7 @@ from pr_agent.algo.token_handler import TokenHandler
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.git_provider import get_main_pr_language
from pr_agent.log import get_logger
class PRQuestions:
@ -44,22 +44,22 @@ class PRQuestions:
return question_str
async def run(self):
logging.info('Answering a PR question...')
get_logger().info('Answering a PR question...')
if get_settings().config.publish_output:
self.git_provider.publish_comment("Preparing answer...", is_temporary=True)
await retry_with_fallback_models(self._prepare_prediction)
logging.info('Preparing answer...')
get_logger().info('Preparing answer...')
pr_comment = self._prepare_pr_answer()
if get_settings().config.publish_output:
logging.info('Pushing answer...')
get_logger().info('Pushing answer...')
self.git_provider.publish_comment(pr_comment)
self.git_provider.remove_initial_comment()
return ""
async def _prepare_prediction(self, model: str):
logging.info('Getting PR diff...')
get_logger().info('Getting PR diff...')
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
logging.info('Getting AI prediction...')
get_logger().info('Getting AI prediction...')
self.prediction = await self._get_prediction(model)
async def _get_prediction(self, model: str):
@ -69,8 +69,8 @@ class PRQuestions:
system_prompt = environment.from_string(get_settings().pr_questions_prompt.system).render(variables)
user_prompt = environment.from_string(get_settings().pr_questions_prompt.user).render(variables)
if get_settings().config.verbosity_level >= 2:
logging.info(f"\nSystem prompt:\n{system_prompt}")
logging.info(f"\nUser prompt:\n{user_prompt}")
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
get_logger().info(f"\nUser prompt:\n{user_prompt}")
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
system=system_prompt, user=user_prompt)
return response
@ -79,5 +79,5 @@ class PRQuestions:
answer_str = f"Question: {self.question_str}\n\n"
answer_str += f"Answer:\n{self.prediction.strip()}\n\n"
if get_settings().config.verbosity_level >= 2:
logging.info(f"answer_str:\n{answer_str}")
get_logger().info(f"answer_str:\n{answer_str}")
return answer_str

View File

@ -1,6 +1,5 @@
import copy
import json
import logging
import datetime
from collections import OrderedDict
from typing import List, Tuple
@ -11,10 +10,11 @@ from yaml import SafeLoader
from pr_agent.algo.ai_handler import BaseAiHandler, AiHandler
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import convert_to_markdown, try_fix_json, try_fix_yaml, load_yaml
from pr_agent.algo.utils import convert_to_markdown, load_yaml, try_fix_yaml, set_custom_labels, get_user_labels
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.git_provider import IncrementalPR, get_main_pr_language
from pr_agent.log import get_logger
from pr_agent.servers.help import actions_help_text, bot_help_text
@ -64,6 +64,8 @@ class PRReviewer:
'answer_str': answer_str,
"extra_instructions": get_settings().pr_reviewer.extra_instructions,
"commit_messages_str": self.git_provider.get_commit_messages(),
"custom_labels": "",
"enable_custom_labels": get_settings().config.enable_custom_labels,
}
self.token_handler = TokenHandler(
@ -97,29 +99,41 @@ class PRReviewer:
try:
if self.is_auto and not get_settings().pr_reviewer.automatic_review:
logging.info(f'Automatic review is disabled {self.pr_url}')
get_logger().info(f'Automatic review is disabled {self.pr_url}')
return None
if self.incremental.is_incremental and not self._can_run_incremental_review():
return None
logging.info(f'Reviewing PR: {self.pr_url} ...')
get_logger().info(f'Reviewing PR: {self.pr_url} ...')
if get_settings().config.publish_output:
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
await retry_with_fallback_models(self._prepare_prediction)
logging.info('Preparing PR review...')
get_logger().info('Preparing PR review...')
pr_comment = self._prepare_pr_review()
if get_settings().config.publish_output:
logging.info('Pushing PR review...')
self.git_provider.publish_comment(pr_comment)
self.git_provider.remove_initial_comment()
get_logger().info('Pushing PR review...')
previous_review_comment = self._get_previous_review_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()
if previous_review_comment:
self._remove_previous_review_comment(previous_review_comment)
if get_settings().pr_reviewer.inline_code_comments:
logging.info('Pushing inline code comments...')
get_logger().info('Pushing inline code comments...')
self._publish_inline_code_comments()
except Exception as e:
logging.error(f"Failed to review PR: {e}")
get_logger().error(f"Failed to review PR: {e}")
async def _prepare_prediction(self, model: str) -> None:
"""
@ -131,9 +145,9 @@ class PRReviewer:
Returns:
None
"""
logging.info('Getting PR diff...')
get_logger().info('Getting PR diff...')
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
logging.info('Getting AI prediction...')
get_logger().info('Getting AI prediction...')
self.prediction = await self._get_prediction(model)
async def _get_prediction(self, model: str) -> str:
@ -154,8 +168,8 @@ class PRReviewer:
user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(variables)
if get_settings().config.verbosity_level >= 2:
logging.info(f"\nSystem prompt:\n{system_prompt}")
logging.info(f"\nUser prompt:\n{user_prompt}")
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
get_logger().info(f"\nUser prompt:\n{user_prompt}")
response, finish_reason = await self.ai_handler.chat_completion(
model=model,
@ -164,6 +178,9 @@ class PRReviewer:
user=user_prompt
)
if get_settings().config.verbosity_level >= 2:
get_logger().info(f"\nAI response:\n{response}")
return response
def _prepare_pr_review(self) -> str:
@ -208,14 +225,22 @@ class PRReviewer:
link = self.git_provider.generate_link_to_relevant_line_number(suggestion)
if link:
suggestion['relevant line'] = f"[{suggestion['relevant line']}]({link})"
else:
pass
# Add incremental review section
if self.incremental.is_incremental:
last_commit_url = f"{self.git_provider.get_pr_url()}/commits/" \
f"{self.git_provider.incremental.first_new_commit_sha}"
last_commit_msg = self.incremental.commits_range[0].commit.message if self.incremental.commits_range else ""
incremental_review_markdown_text = f"Starting from commit {last_commit_url}"
if last_commit_msg:
replacement = last_commit_msg.splitlines(keepends=False)[0].replace('_', r'\_')
incremental_review_markdown_text += f" \n_({replacement})_"
data = OrderedDict(data)
data.update({'Incremental PR Review': {
"⏮️ Review for commits since previous PR-Agent review": f"Starting from commit {last_commit_url}"}})
"⏮️ Review for commits since previous PR-Agent review": incremental_review_markdown_text}})
data.move_to_end('Incremental PR Review', last=False)
markdown_text = convert_to_markdown(data, self.git_provider.is_supported("gfm_markdown"))
@ -224,14 +249,22 @@ class PRReviewer:
# Add help text if not in CLI mode
if not get_settings().get("CONFIG.CLI_MODE", False):
markdown_text += "\n### How to use\n"
if user and '[bot]' not in user:
if self.git_provider.is_supported("gfm_markdown"):
markdown_text += "\n <details> <summary> Instructions</summary>\n\n"
bot_user = "[bot]" if get_settings().github_app.override_deployment_type else get_settings().github_app.bot_user
if user and bot_user not in user:
markdown_text += bot_help_text(user)
else:
markdown_text += actions_help_text
if self.git_provider.is_supported("gfm_markdown"):
markdown_text += "\n</details>\n"
# Add custom labels from the review prediction (effort, security)
self.set_review_labels(data)
# Log markdown response if verbosity level is high
if get_settings().config.verbosity_level >= 2:
logging.info(f"Markdown response:\n{markdown_text}")
get_logger().info(f"Markdown response:\n{markdown_text}")
if markdown_text == None or len(markdown_text) == 0:
markdown_text = ""
@ -245,21 +278,14 @@ class PRReviewer:
if get_settings().pr_reviewer.num_code_suggestions == 0:
return
review_text = self.prediction.strip()
review_text = review_text.removeprefix('```yaml').rstrip('`')
try:
data = yaml.load(review_text, Loader=SafeLoader)
except Exception as e:
logging.error(f"Failed to parse AI prediction: {e}")
data = try_fix_yaml(review_text)
data = load_yaml(self.prediction.strip())
comments: List[str] = []
for suggestion in data.get('PR Feedback', {}).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:
logging.info("Skipping inline comment with missing file/line/content")
get_logger().info("Skipping inline comment with missing file/line/content")
continue
if self.git_provider.is_supported("create_inline_comment"):
@ -295,3 +321,83 @@ class PRReviewer:
break
return question_str, answer_str
def _get_previous_review_comment(self):
"""
Get the previous review comment if it exists.
"""
try:
if get_settings().pr_reviewer.remove_previous_review_comment and hasattr(self.git_provider, "get_previous_review"):
return self.git_provider.get_previous_review(
full=not self.incremental.is_incremental,
incremental=self.incremental.is_incremental,
)
except Exception as e:
get_logger().exception(f"Failed to get previous review comment, error: {e}")
def _remove_previous_review_comment(self, comment):
"""
Remove the previous review comment if it exists.
"""
try:
if get_settings().pr_reviewer.remove_previous_review_comment and comment:
self.git_provider.remove_comment(comment)
except Exception as e:
get_logger().exception(f"Failed to remove previous review comment, error: {e}")
def _can_run_incremental_review(self) -> bool:
"""Checks if we can run incremental review according the various configurations and previous review"""
# checking if running is auto mode but there are no new commits
if self.is_auto and not self.incremental.first_new_commit_sha:
get_logger().info(f"Incremental review is enabled for {self.pr_url} but there are no new commits")
return False
# checking if there are enough commits to start the review
num_new_commits = len(self.incremental.commits_range)
num_commits_threshold = get_settings().pr_reviewer.minimal_commits_for_incremental_review
not_enough_commits = num_new_commits < num_commits_threshold
# checking if the commits are not too recent to start the review
recent_commits_threshold = datetime.datetime.now() - datetime.timedelta(
minutes=get_settings().pr_reviewer.minimal_minutes_for_incremental_review
)
last_seen_commit_date = (
self.incremental.last_seen_commit.commit.author.date if self.incremental.last_seen_commit else None
)
all_commits_too_recent = (
last_seen_commit_date > recent_commits_threshold if self.incremental.last_seen_commit else False
)
# check all the thresholds or just one to start the review
condition = any if get_settings().pr_reviewer.require_all_thresholds_for_incremental_review else all
if condition((not_enough_commits, all_commits_too_recent)):
get_logger().info(
f"Incremental review is enabled for {self.pr_url} but didn't pass the threshold check to run:"
f"\n* Number of new commits = {num_new_commits} (threshold is {num_commits_threshold})"
f"\n* Last seen commit date = {last_seen_commit_date} (threshold is {recent_commits_threshold})"
)
return False
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')
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')]
if current_labels or review_labels:
get_logger().info(f"Setting review labels: {review_labels + current_labels_filtered}")
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

@ -1,18 +1,19 @@
import copy
import json
import logging
import time
from enum import Enum
from typing import List, Tuple
import pinecone
from typing import List
import openai
import pandas as pd
import pinecone
from pinecone_datasets import Dataset, DatasetMetadata
from pydantic import BaseModel, Field
from pr_agent.algo import MAX_TOKENS
from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import get_max_tokens
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import get_git_provider
from pinecone_datasets import Dataset, DatasetMetadata
from pr_agent.log import get_logger
MODEL = "text-embedding-ada-002"
@ -47,6 +48,13 @@ class PRSimilarIssue:
# check if index exists, and if repo is already indexed
run_from_scratch = False
if run_from_scratch: # for debugging
pinecone.init(api_key=api_key, environment=environment)
if index_name in pinecone.list_indexes():
get_logger().info('Removing index...')
pinecone.delete_index(index_name)
get_logger().info('Done')
upsert = True
pinecone.init(api_key=api_key, environment=environment)
if not index_name in pinecone.list_indexes():
@ -62,11 +70,11 @@ class PRSimilarIssue:
upsert = False
if run_from_scratch or upsert: # index the entire repo
logging.info('Indexing the entire repo...')
get_logger().info('Indexing the entire repo...')
logging.info('Getting issues...')
get_logger().info('Getting issues...')
issues = list(repo_obj.get_issues(state='all'))
logging.info('Done')
get_logger().info('Done')
self._update_index_with_issues(issues, repo_name_for_index, upsert=upsert)
else: # update index if needed
pinecone_index = pinecone.Index(index_name=index_name)
@ -92,20 +100,20 @@ class PRSimilarIssue:
break
if issues_to_update:
logging.info(f'Updating index with {counter} new issues...')
get_logger().info(f'Updating index with {counter} new issues...')
self._update_index_with_issues(issues_to_update, repo_name_for_index, upsert=True)
else:
logging.info('No new issues to update')
get_logger().info('No new issues to update')
async def run(self):
logging.info('Getting issue...')
get_logger().info('Getting issue...')
repo_name, original_issue_number = self.git_provider._parse_issue_url(self.issue_url.split('=')[-1])
issue_main = self.git_provider.repo_obj.get_issue(original_issue_number)
issue_str, comments, number = self._process_issue(issue_main)
openai.api_key = get_settings().openai.key
logging.info('Done')
get_logger().info('Done')
logging.info('Querying...')
get_logger().info('Querying...')
res = openai.Embedding.create(input=[issue_str], engine=MODEL)
embeds = [record['embedding'] for record in res['data']]
pinecone_index = pinecone.Index(index_name=self.index_name)
@ -117,7 +125,16 @@ class PRSimilarIssue:
relevant_comment_number_list = []
score_list = []
for r in res['matches']:
issue_number = int(r["id"].split('.')[0].split('_')[-1])
# skip example issue
if 'example_issue_' in r["id"]:
continue
try:
issue_number = int(r["id"].split('.')[0].split('_')[-1])
except:
get_logger().debug(f"Failed to parse issue number from {r['id']}")
continue
if original_issue_number == issue_number:
continue
if issue_number not in relevant_issues_number_list:
@ -127,9 +144,9 @@ class PRSimilarIssue:
else:
relevant_comment_number_list.append(-1)
score_list.append(str("{:.2f}".format(r['score'])))
logging.info('Done')
get_logger().info('Done')
logging.info('Publishing response...')
get_logger().info('Publishing response...')
similar_issues_str = "### Similar Issues\n___\n\n"
for i, issue_number_similar in enumerate(relevant_issues_number_list):
issue = self.git_provider.repo_obj.get_issue(issue_number_similar)
@ -140,8 +157,8 @@ class PRSimilarIssue:
similar_issues_str += f"{i + 1}. **[{title}]({url})** (score={score_list[i]})\n\n"
if get_settings().config.publish_output:
response = issue_main.create_comment(similar_issues_str)
logging.info(similar_issues_str)
logging.info('Done')
get_logger().info(similar_issues_str)
get_logger().info('Done')
def _process_issue(self, issue):
header = issue.title
@ -155,7 +172,7 @@ class PRSimilarIssue:
return issue_str, comments, number
def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=False):
logging.info('Processing issues...')
get_logger().info('Processing issues...')
corpus = Corpus()
example_issue_record = Record(
id=f"example_issue_{repo_name_for_index}",
@ -171,9 +188,9 @@ class PRSimilarIssue:
counter += 1
if counter % 100 == 0:
logging.info(f"Scanned {counter} issues")
get_logger().info(f"Scanned {counter} issues")
if counter >= self.max_issues_to_scan:
logging.info(f"Scanned {self.max_issues_to_scan} issues, stopping")
get_logger().info(f"Scanned {self.max_issues_to_scan} issues, stopping")
break
issue_str, comments, number = self._process_issue(issue)
@ -181,7 +198,7 @@ class PRSimilarIssue:
username = issue.user.login
created_at = str(issue.created_at)
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(
id=issue_key + "." + "issue",
text=issue_str,
@ -210,9 +227,9 @@ class PRSimilarIssue:
)
corpus.append(comment_record)
df = pd.DataFrame(corpus.dict()["documents"])
logging.info('Done')
get_logger().info('Done')
logging.info('Embedding...')
get_logger().info('Embedding...')
openai.api_key = get_settings().openai.key
list_to_encode = list(df["text"].values)
try:
@ -220,7 +237,7 @@ class PRSimilarIssue:
embeds = [record['embedding'] for record in res['data']]
except:
embeds = []
logging.error('Failed to embed entire list, embedding one by one...')
get_logger().error('Failed to embed entire list, embedding one by one...')
for i, text in enumerate(list_to_encode):
try:
res = openai.Embedding.create(input=[text], engine=MODEL)
@ -231,21 +248,23 @@ class PRSimilarIssue:
meta = DatasetMetadata.empty()
meta.dense_model.dimension = len(embeds[0])
ds = Dataset.from_pandas(df, meta)
logging.info('Done')
get_logger().info('Done')
api_key = get_settings().pinecone.api_key
environment = get_settings().pinecone.environment
if not upsert:
logging.info('Creating index from scratch...')
get_logger().info('Creating index from scratch...')
ds.to_pinecone_index(self.index_name, api_key=api_key, environment=environment)
time.sleep(15) # wait for pinecone to finalize indexing before querying
else:
logging.info('Upserting index...')
get_logger().info('Upserting index...')
namespace = ""
batch_size: int = 100
concurrency: int = 10
pinecone.init(api_key=api_key, environment=environment)
ds._upsert_to_index(self.index_name, namespace, batch_size, concurrency)
logging.info('Done')
time.sleep(5) # wait for pinecone to finalize upserting before querying
get_logger().info('Done')
class IssueLevel(str, Enum):

View File

@ -1,5 +1,4 @@
import copy
import logging
from datetime import date
from time import sleep
from typing import Tuple
@ -10,8 +9,9 @@ from pr_agent.algo.ai_handler import BaseAiHandler, AiHandler
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
from pr_agent.algo.token_handler import TokenHandler
from pr_agent.config_loader import get_settings
from pr_agent.git_providers import GithubProvider, get_git_provider
from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.git_provider import get_main_pr_language
from pr_agent.log import get_logger
CHANGELOG_LINES = 50
@ -48,26 +48,26 @@ class PRUpdateChangelog:
async def run(self):
# assert type(self.git_provider) == GithubProvider, "Currently only Github is supported"
logging.info('Updating the changelog...')
get_logger().info('Updating the changelog...')
if get_settings().config.publish_output:
self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True)
await retry_with_fallback_models(self._prepare_prediction)
logging.info('Preparing PR changelog updates...')
get_logger().info('Preparing PR changelog updates...')
new_file_content, answer = self._prepare_changelog_update()
if get_settings().config.publish_output:
self.git_provider.remove_initial_comment()
logging.info('Publishing changelog updates...')
get_logger().info('Publishing changelog updates...')
if self.commit_changelog:
logging.info('Pushing PR changelog updates to repo...')
get_logger().info('Pushing PR changelog updates to repo...')
self._push_changelog_update(new_file_content, answer)
else:
logging.info('Publishing PR changelog as comment...')
get_logger().info('Publishing PR changelog as comment...')
self.git_provider.publish_comment(f"**Changelog updates:**\n\n{answer}")
async def _prepare_prediction(self, model: str):
logging.info('Getting PR diff...')
get_logger().info('Getting PR diff...')
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
logging.info('Getting AI prediction...')
get_logger().info('Getting AI prediction...')
self.prediction = await self._get_prediction(model)
async def _get_prediction(self, model: str):
@ -77,8 +77,8 @@ class PRUpdateChangelog:
system_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.system).render(variables)
user_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.user).render(variables)
if get_settings().config.verbosity_level >= 2:
logging.info(f"\nSystem prompt:\n{system_prompt}")
logging.info(f"\nUser prompt:\n{user_prompt}")
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
get_logger().info(f"\nUser prompt:\n{user_prompt}")
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
system=system_prompt, user=user_prompt)
@ -100,7 +100,7 @@ class PRUpdateChangelog:
"\n>'/update_changelog --pr_update_changelog.push_changelog_changes=true'\n"
if get_settings().config.verbosity_level >= 2:
logging.info(f"answer:\n{answer}")
get_logger().info(f"answer:\n{answer}")
return new_file_content, answer
@ -149,7 +149,7 @@ Example:
except Exception:
self.changelog_file_str = ""
if self.commit_changelog:
logging.info("No CHANGELOG.md file found in the repository. Creating one...")
get_logger().info("No CHANGELOG.md file found in the repository. Creating one...")
changelog_file = self.git_provider.repo_obj.create_file(path="CHANGELOG.md",
message='add CHANGELOG.md',
content="",