Compare commits

..

24 Commits

Author SHA1 Message Date
e6bea76eee Typo 2023-10-26 17:07:16 +03:00
414f2b6767 Fix incremental review if there are no new commits (would have performed a full review instead) 2023-10-26 16:49:55 +03:00
6541575a0e Refactor to use pull_request synchronize event 2023-10-26 16:49:54 +03:00
02570ea797 Remove previous review comment on push event 2023-10-26 16:46:54 +03:00
65bb70a1dd Added support for automatic review on push event
The new feature can be enabled via the new configuration `github_app.handle_push_event`. To avoid any unwanted side-effects, the current default of this configuration is set to `false`.

The high level flow (assuming the configuration is enabled):
1. receive push event from GitHub
2. extract branch and commits from event
3. find PR url for branch (currently does not support PRs from forks)
4. perform configured commands (e.g. `/describe`, `/review -i`)

The push event flow is guarded by a backlog queue so that multiple push events on the same branch won't trigger multiple duplicate runs of the PR-Agent commands.
Example timeline:
1. push 1 - start handling event
2. push 2 - waiting to be handled while push 1 event is still running
3. push 3 - event is dropped since handling it and handling push 2 is the same, so it is redundant
4. push 1 finished being handled
5. push 2 awakens from wait and continues handling (potentially reviewing the commits of both push 2 and push 3)

All of these options are configurable and can be enabled/disabled as per the user's desire.

Additional minor changes in this PR:
1. Created `DefaultDictWithTimeout` utility class to avoid too much boilerplate code in managing caches for outdated triggers.
2. Guard against running increment review when there are no new commits.
3. Minor styling changes for incremented review text.
2023-10-25 11:15:23 +03:00
b6cabda586 quick fix 2023-10-19 17:24:37 +03:00
abbce60f18 Merge remote-tracking branch 'origin/main' 2023-10-19 17:10:30 +03:00
5daaaf2c1d quick fix 2023-10-19 17:10:21 +03:00
e8f207691e Merge pull request #391 from Codium-ai/tr/readme
Update and Enhance DESCRIBE.md Documentation
2023-10-19 02:03:50 -07:00
b0dce4ceae describe 2023-10-19 12:02:12 +03:00
fc494296d7 Merge pull request #387 from Codium-ai/ok/json_logging_in_bitbucket
Enhancing Logging in Bitbucket, GitLab, and Google Cloud Storage Secret Provider
2023-10-19 11:59:26 +03:00
67b4069540 describe 2023-10-19 11:45:41 +03:00
e6defcc846 describe 2023-10-19 11:43:18 +03:00
096fcbbc17 describe 2023-10-19 11:40:01 +03:00
eb7add1c77 describe 2023-10-19 11:38:21 +03:00
1b6fb3ea53 Merge pull request #385 from Codium-ai/hl/fix_add_docs_in_scripts
Add Blacklist for Non-Editable File Extensions in Documentation
2023-10-19 11:21:36 +03:00
c57b70f1d4 Merge pull request #390 from Codium-ai/tr/readme
Enhancing Documentation and Updating Configuration for PR Descriptions
2023-10-19 01:04:24 -07:00
a2c3db463a use_bullet_points 2023-10-19 10:45:42 +03:00
193da1c356 update readme 2023-10-19 09:22:26 +03:00
5bc26880b3 update readme 2023-10-19 09:20:36 +03:00
21a1cc970e - update readme
- minor prompts change
2023-10-19 09:16:20 +03:00
954727ad67 Merge pull request #386 from Codium-ai/ok/fix_bitbucket_pipeline
Refactor Bitbucket Pipeline Integration and Update Documentation
2023-10-18 16:45:26 +03:00
1314898cbf Enhance logging in bitbucket_app, gitlab_webhook, and google_cloud_storage_secret_provider with JSON format and additional context 2023-10-18 16:44:03 +03:00
3673924fe9 Add docs editable blacklist of file extensions like sql, yaml... 2023-10-17 18:50:39 +03:00
25 changed files with 371 additions and 86 deletions

View File

@ -27,18 +27,14 @@ Under the section 'pr_description', the [configuration file](./../pr_agent/setti
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...". - `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...".
#### Markers template ### Markers template
markers enable to easily integrate user's content and auto-generated content, with a template-like mechanism. markers enable to easily integrate user's content and auto-generated content, with a template-like mechanism.
- `use_description_markers`: if set to true, the tool will use markers template. It replaces every marker of the form `pr_agent:marker_name` with the relevant content. Default is false.
For example, if the PR original description was: For example, if the PR original description was:
``` ```
User content... User content...
## PR Type:
pr_agent:pr_type
## PR Description: ## PR Description:
pr_agent:summary pr_agent:summary
@ -46,6 +42,21 @@ pr_agent:summary
## PR Walkthrough: ## PR Walkthrough:
pr_agent:walkthrough pr_agent:walkthrough
``` ```
The marker `pr_agent:pr_type` will be replaced with the PR type, `pr_agent:summary` will be replaced with the PR summary, and `pr_agent:walkthrough` will be replaced with the PR walkthrough. The marker `pr_agent:summary` will be replaced with the PR summary, and `pr_agent:walkthrough` will be replaced with the PR walkthrough.
##### Example:
```
env:
pr_description.use_description_markers: 'true'
```
<kbd><img src=./../pics/describe_markers_before.png width="768"></kbd>
==>
<kbd><img src=./../pics/describe_markers_after.png width="768"></kbd>
##### Configuration params:
- `use_description_markers`: if set to true, the tool will use markers template. It replaces every marker of the form `pr_agent:marker_name` with the relevant content. Default is false.
- `include_generated_by_header`: if set to true, the tool will add a dedicated header: 'Generated by PR Agent at ...' to any automatic content. Default is true. - `include_generated_by_header`: if set to true, the tool will add a dedicated header: 'Generated by PR Agent at ...' to any automatic content. Default is true.

View File

@ -32,3 +32,14 @@ Under the section 'pr_code_suggestions', the [configuration file](./../pr_agent/
- `rank_extended_suggestions`: if set to true, the tool will rank the suggestions, based on importance. Default is true. - `rank_extended_suggestions`: if set to true, the tool will rank the suggestions, based on importance. Default is true.
- `max_number_of_calls`: maximum number of chunks. Default is 5. - `max_number_of_calls`: maximum number of chunks. Default is 5.
- `final_clip_factor`: factor to remove suggestions with low confidence. Default is 0.9. - `final_clip_factor`: factor to remove suggestions with low confidence. Default is 0.9.
#### A note on code suggestions quality
- With current level of AI for code (GPT-4), mistakes can happen. Not all the suggestions will be perfect, and a user should not accept all of them automatically.
- Suggestions are not meant to be [simplistic](./../pr_agent/settings/pr_code_suggestions_prompts.toml#L34). Instead, they aim to give deep feedback and raise questions, ideas and thoughts to the user, who can then use his judgment, experience, and understanding of the code base.
- Recommended to use the 'extra_instructions' field to guide the model to suggestions that are more relevant to the specific needs of the project.
- Best quality will be obtained by using 'improve --extended' mode.

View File

@ -44,3 +44,14 @@ The tool will first ask the author questions about the PR, and will guide the re
<kbd><img src=./../pics/reflection_questions.png width="768"></kbd> <kbd><img src=./../pics/reflection_questions.png width="768"></kbd>
<kbd><img src=./../pics/reflection_answers.png width="768"></kbd> <kbd><img src=./../pics/reflection_answers.png width="768"></kbd>
<kbd><img src=./../pics/reflection_insights.png width="768"></kbd> <kbd><img src=./../pics/reflection_insights.png width="768"></kbd>
#### A note on code suggestions quality
- With current level of AI for code (GPT-4), mistakes can happen. Not all the suggestions will be perfect, and a user should not accept all of them automatically.
- Suggestions are not meant to be [simplistic](./../pr_agent/settings/pr_reviewer_prompts.toml#L29). Instead, they aim to give deep feedback and raise questions, ideas and thoughts to the user, who can then use his judgment, experience, and understanding of the code base.
- Recommended to use the 'extra_instructions' field to guide the model to suggestions that are more relevant to the specific needs of the project.
- Unlike the 'review' feature, which does a lot of things, the ['improve --extended'](./IMPROVE.md) feature is dedicated only to suggestions, and usually gives better results.

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -142,10 +142,15 @@ class BitbucketProvider(GitProvider):
def remove_initial_comment(self): def remove_initial_comment(self):
try: try:
for comment in self.temp_comments: for comment in self.temp_comments:
self.pr.delete(f"comments/{comment}") self.remove_comment(comment)
except Exception as e: except Exception as e:
get_logger().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 # funtion to create_inline_comment
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str): def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):

View File

@ -221,6 +221,9 @@ class CodeCommitProvider(GitProvider):
def remove_initial_comment(self): def remove_initial_comment(self):
return "" # not implemented yet 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): 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 # 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") raise NotImplementedError("CodeCommit provider does not support publishing inline comments yet")

View File

@ -396,5 +396,8 @@ class GerritProvider(GitProvider):
# shutil.rmtree(self.repo_path) # shutil.rmtree(self.repo_path)
pass pass
def remove_comment(self, comment):
pass
def get_pr_branch(self): def get_pr_branch(self):
return self.repo.head return self.repo.head

View File

@ -71,6 +71,10 @@ class GitProvider(ABC):
def remove_initial_comment(self): def remove_initial_comment(self):
pass pass
@abstractmethod
def remove_comment(self, comment):
pass
@abstractmethod @abstractmethod
def get_languages(self): def get_languages(self):
pass pass

View File

@ -50,7 +50,7 @@ class GithubProvider(GitProvider):
def get_incremental_commits(self): def get_incremental_commits(self):
self.commits = list(self.pr.get_commits()) 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: if self.previous_review:
self.incremental.commits_range = self.get_commit_range() self.incremental.commits_range = self.get_commit_range()
# Get all files changed during the commit range # Get all files changed during the commit range
@ -63,7 +63,7 @@ class GithubProvider(GitProvider):
def get_commit_range(self): def get_commit_range(self):
last_review_time = self.previous_review.created_at 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): for index in range(len(self.commits) - 1, -1, -1):
if self.commits[index].commit.author.date > last_review_time: 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_sha = self.commits[index].sha
@ -71,15 +71,21 @@ class GithubProvider(GitProvider):
else: else:
self.incremental.last_seen_commit_sha = self.commits[index].sha self.incremental.last_seen_commit_sha = self.commits[index].sha
break 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): def get_previous_review(self, *, full: bool, incremental: bool):
self.previous_review = None if not (full or incremental):
self.comments = list(self.pr.get_issue_comments()) 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): for index in range(len(self.comments) - 1, -1, -1):
if self.comments[index].body.startswith("## PR Analysis") or self.comments[index].body.startswith("## Incremental PR Review"): if any(self.comments[index].body.startswith(prefix) for prefix in prefixes):
self.previous_review = self.comments[index] return self.comments[index]
break
def get_files(self): def get_files(self):
if self.incremental.is_incremental and self.file_set: if self.incremental.is_incremental and self.file_set:
@ -218,10 +224,16 @@ class GithubProvider(GitProvider):
try: try:
for comment in getattr(self.pr, 'comments_list', []): for comment in getattr(self.pr, 'comments_list', []):
if comment.is_temporary: if comment.is_temporary:
comment.delete() self.remove_comment(comment)
except Exception as e: except Exception as e:
get_logger().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): def get_title(self):
return self.pr.title return self.pr.title

View File

@ -287,10 +287,16 @@ class GitLabProvider(GitProvider):
def remove_initial_comment(self): def remove_initial_comment(self):
try: try:
for comment in self.temp_comments: for comment in self.temp_comments:
comment.delete() self.remove_comment(comment)
except Exception as e: except Exception as e:
get_logger().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): def get_title(self):
return self.mr.title return self.mr.title

View File

@ -140,6 +140,9 @@ class LocalGitProvider(GitProvider):
def remove_initial_comment(self): def remove_initial_comment(self):
pass # Not applicable to the local git provider, but required by the interface 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): def get_languages(self):
""" """
Calculate percentage of languages in repository. Used for hunk prioritisation. Calculate percentage of languages in repository. Used for hunk prioritisation.

View File

@ -1,9 +1,8 @@
import ujson import ujson
from google.cloud import storage from google.cloud import storage
from pr_agent.config_loader import get_settings 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 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_name = get_settings().google_cloud_storage.bucket_name
self.bucket = self.client.bucket(self.bucket_name) self.bucket = self.client.bucket(self.bucket_name)
except Exception as e: 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 raise e
def get_secret(self, secret_name: str) -> str: def get_secret(self, secret_name: str) -> str:
@ -23,7 +22,7 @@ class GoogleCloudStorageSecretProvider(SecretProvider):
blob = self.bucket.blob(secret_name) blob = self.bucket.blob(secret_name)
return blob.download_as_string() return blob.download_as_string()
except Exception as e: 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 "" return ""
def store_secret(self, secret_name: str, secret_value: str): def store_secret(self, secret_name: str, secret_value: str):
@ -31,5 +30,5 @@ class GoogleCloudStorageSecretProvider(SecretProvider):
blob = self.bucket.blob(secret_name) blob = self.bucket.blob(secret_name)
blob.upload_from_string(secret_value) blob.upload_from_string(secret_value)
except Exception as e: 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 raise e

View File

@ -65,15 +65,17 @@ async def handle_manifest(request: Request, response: Response):
@router.post("/webhook") @router.post("/webhook")
async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Request): 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) jwt_header = request.headers.get("authorization", None)
if jwt_header: if jwt_header:
input_jwt = jwt_header.split(" ")[1] input_jwt = jwt_header.split(" ")[1]
data = await request.json() data = await request.json()
print(data) get_logger().debug(data)
async def inner(): async def inner():
try: try:
owner = data["data"]["repository"]["owner"]["username"] owner = data["data"]["repository"]["owner"]["username"]
log_context["sender"] = owner
secrets = json.loads(secret_provider.get_secret(owner)) secrets = json.loads(secret_provider.get_secret(owner))
shared_secret = secrets["shared_secret"] shared_secret = secrets["shared_secret"]
client_key = secrets["client_key"] client_key = secrets["client_key"]
@ -85,11 +87,17 @@ async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Req
agent = PRAgent() agent = PRAgent()
if event == "pullrequest:created": if event == "pullrequest:created":
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"] 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": elif event == "pullrequest:comment_created":
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"] 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"] 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: except Exception as e:
get_logger().error(f"Failed to handle webhook: {e}") get_logger().error(f"Failed to handle webhook: {e}")
background_tasks.add_task(inner) background_tasks.add_task(inner)
@ -102,9 +110,10 @@ async def handle_github_webhooks(request: Request, response: Response):
@router.post("/installed") @router.post("/installed")
async def handle_installed_webhooks(request: Request, response: Response): async def handle_installed_webhooks(request: Request, response: Response):
try: try:
print(request.headers) get_logger().info("handle_installed_webhooks")
get_logger().info(request.headers)
data = await request.json() data = await request.json()
print(data) get_logger().info(data)
shared_secret = data["sharedSecret"] shared_secret = data["sharedSecret"]
client_key = data["clientKey"] client_key = data["clientKey"]
username = data["principal"]["username"] username = data["principal"]["username"]
@ -119,8 +128,10 @@ async def handle_installed_webhooks(request: Request, response: Response):
@router.post("/uninstalled") @router.post("/uninstalled")
async def handle_uninstalled_webhooks(request: Request, response: Response): async def handle_uninstalled_webhooks(request: Request, response: Response):
get_logger().info("handle_uninstalled_webhooks")
data = await request.json() data = await request.json()
print(data) get_logger().info(data)
def start(): def start():

View File

@ -1,7 +1,7 @@
import copy import copy
import os import os
import time import asyncio.locks
from typing import Any, Dict from typing import Any, Dict, List, Tuple
import uvicorn import uvicorn
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
@ -14,8 +14,9 @@ from pr_agent.algo.utils import update_settings_from_args
from pr_agent.config_loader import get_settings, global_settings from pr_agent.config_loader import get_settings, global_settings
from pr_agent.git_providers import get_git_provider from pr_agent.git_providers import get_git_provider
from pr_agent.git_providers.utils import apply_repo_settings 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.log import LoggingFormat, get_logger, setup_logger
from pr_agent.servers.utils import verify_signature from pr_agent.servers.utils import verify_signature, DefaultDictWithTimeout
setup_logger(fmt=LoggingFormat.JSON) setup_logger(fmt=LoggingFormat.JSON)
@ -47,6 +48,7 @@ async def handle_marketplace_webhooks(request: Request, response: Response):
body = await get_body(request) body = await get_body(request)
get_logger().info(f'Request body:\n{body}') get_logger().info(f'Request body:\n{body}')
async def get_body(request): async def get_body(request):
try: try:
body = await request.json() body = await request.json()
@ -61,7 +63,9 @@ async def get_body(request):
return body 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): async def handle_request(body: Dict[str, Any], event: str):
@ -109,40 +113,110 @@ async def handle_request(body: Dict[str, Any], event: str):
# handle pull_request event: # handle pull_request event:
# automatically review opened/reopened/ready_for_review PRs as long as they're not in draft, # 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 # as well as direct review requests from the bot
elif event == 'pull_request': elif event == 'pull_request' and action != 'synchronize':
pull_request = body.get("pull_request") pull_request, api_url = _check_pull_request_event(action, body, log_context, bot_user)
if not pull_request: if not (pull_request and api_url):
return {}
api_url = pull_request.get("url")
if not api_url:
return {}
log_context["api_url"] = api_url
if pull_request.get("draft", True) or pull_request.get("state") != "open" or pull_request.get("user", {}).get("login", "") == bot_user:
return {} return {}
if action in get_settings().github_app.handle_pr_actions: if action in get_settings().github_app.handle_pr_actions:
if action == "review_requested": if action == "review_requested":
if body.get("requested_reviewer", {}).get("login", "") != bot_user: if body.get("requested_reviewer", {}).get("login", "") != bot_user:
return {} return {}
if pull_request.get("created_at") == pull_request.get("updated_at"): get_logger().info(f"Performing review for {api_url=} because of {event=} and {action=}")
# avoid double reviews when opening a PR for the first time await _perform_commands(get_settings().github_app.pr_commands, agent, body, api_url, log_context)
return {}
get_logger().info(f"Performing review because of event={event} and action={action}") # handle pull_request event with synchronize action - "push trigger" for new commits
apply_repo_settings(api_url) elif event == 'pull_request' and action == 'synchronize' and get_settings().github_app.handle_push_trigger:
for command in get_settings().github_app.pr_commands: pull_request, api_url = _check_pull_request_event(action, body, log_context, bot_user)
split_command = command.split(" ") if not (pull_request and api_url):
command = split_command[0] return {}
args = split_command[1:]
other_args = update_settings_from_args(args) # TODO: do we still want to get the list of commits to filter bot/merge commits?
new_command = ' '.join([command] + other_args) before_sha = body.get("before")
get_logger().info(body) after_sha = body.get("after")
get_logger().info(f"Performing command: {new_command}") merge_commit_sha = pull_request.get("merge_commit_sha")
with get_logger().contextualize(**log_context): if before_sha == after_sha:
await agent.handle_request(api_url, new_command) 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(get_settings().github_app.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") get_logger().info("event or action does not require handling")
return {} 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: List[str], agent: PRAgent, body: dict, api_url: str, log_context: dict):
apply_repo_settings(api_url)
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: def _is_duplicate_request(body: Dict[str, Any]) -> bool:
""" """
In some deployments its possible to get duplicate requests if the handling is long, In some deployments its possible to get duplicate requests if the handling is long,
@ -150,13 +224,8 @@ def _is_duplicate_request(body: Dict[str, Any]) -> bool:
""" """
request_hash = hash(str(body)) request_hash = hash(str(body))
get_logger().info(f"request_hash: {request_hash}") get_logger().info(f"request_hash: {request_hash}")
request_time = time.monotonic() is_duplicate = _duplicate_requests_cache.get(request_hash, False)
ttl = get_settings().github_app.duplicate_requests_cache_ttl # in seconds _duplicate_requests_cache[request_hash] = True
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
if is_duplicate: if is_duplicate:
get_logger().info(f"Ignoring duplicate request {request_hash}") get_logger().info(f"Ignoring duplicate request {request_hash}")
return is_duplicate return is_duplicate

View File

@ -12,23 +12,33 @@ from starlette_context.middleware import RawContextMiddleware
from pr_agent.agent.pr_agent import PRAgent from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings, global_settings from pr_agent.config_loader import get_settings, global_settings
from pr_agent.log import get_logger, setup_logger from pr_agent.log import LoggingFormat, get_logger, setup_logger
from pr_agent.secret_providers import get_secret_provider from pr_agent.secret_providers import get_secret_provider
setup_logger() setup_logger(fmt=LoggingFormat.JSON)
router = APIRouter() router = APIRouter()
secret_provider = get_secret_provider() if get_settings().get("CONFIG.SECRET_PROVIDER") else None 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") @router.post("/webhook")
async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request): 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: if request.headers.get("X-Gitlab-Token") and secret_provider:
request_token = request.headers.get("X-Gitlab-Token") request_token = request.headers.get("X-Gitlab-Token")
secret = secret_provider.get_secret(request_token) secret = secret_provider.get_secret(request_token)
try: try:
secret_dict = json.loads(secret) secret_dict = json.loads(secret)
gitlab_token = secret_dict["gitlab_token"] gitlab_token = secret_dict["gitlab_token"]
log_context["sender"] = secret_dict["id"]
context["settings"] = copy.deepcopy(global_settings) context["settings"] = copy.deepcopy(global_settings)
context["settings"].gitlab.personal_access_token = gitlab_token context["settings"].gitlab.personal_access_token = gitlab_token
except Exception as e: except Exception as e:
@ -48,13 +58,13 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
if data.get('object_kind') == 'merge_request' and data['object_attributes'].get('action') in ['open', 'reopen']: if data.get('object_kind') == 'merge_request' and data['object_attributes'].get('action') in ['open', 'reopen']:
get_logger().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') url = data['object_attributes'].get('url')
background_tasks.add_task(PRAgent().handle_request, url, "/review") handle_request(background_tasks, url, "/review")
elif data.get('object_kind') == 'note' and data['event_type'] == 'note': elif data.get('object_kind') == 'note' and data['event_type'] == 'note':
if 'merge_request' in data: if 'merge_request' in data:
mr = data['merge_request'] mr = data['merge_request']
url = mr.get('url') url = mr.get('url')
body = data.get('object_attributes', {}).get('note') body = data.get('object_attributes', {}).get('note')
background_tasks.add_task(PRAgent().handle_request, url, body) handle_request(background_tasks, url, body)
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"})) return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))

View File

@ -1,5 +1,8 @@
import hashlib import hashlib
import hmac import hmac
import time
from collections import defaultdict
from typing import Callable, Any
from fastapi import HTTPException from fastapi import HTTPException
@ -25,3 +28,59 @@ def verify_signature(payload_body, secret_token, signature_header):
class RateLimitExceeded(Exception): class RateLimitExceeded(Exception):
"""Raised when the git provider API rate limit has been exceeded.""" """Raised when the git provider API rate limit has been exceeded."""
pass 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

@ -24,6 +24,7 @@ num_code_suggestions=4
inline_code_comments = false inline_code_comments = false
ask_and_reflect=false ask_and_reflect=false
automatic_review=true automatic_review=true
remove_previous_review_comment=false
extra_instructions = "" extra_instructions = ""
[pr_description] # /describe # [pr_description] # /describe #
@ -31,6 +32,7 @@ publish_labels=true
publish_description_as_comment=false publish_description_as_comment=false
add_original_user_description=false add_original_user_description=false
keep_original_user_title=false keep_original_user_title=false
use_bullet_points=true
extra_instructions = "" extra_instructions = ""
# markers # markers
use_description_markers=false use_description_markers=false
@ -82,6 +84,27 @@ pr_commands = [
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true", "/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
"/auto_review", "/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.extra_instructions='' \
"""
]
[gitlab] [gitlab]
# URL to the gitlab service # URL to the gitlab service

View File

@ -433,3 +433,6 @@ reStructuredText = [".rst", ".rest", ".rest.txt", ".rst.txt", ]
wisp = [".wisp", ] wisp = [".wisp", ]
xBase = [".prg", ".prw", ] 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

@ -31,7 +31,8 @@ PR Type:
- Other - Other
PR Description: PR Description:
type: string type: string
description: an informative and concise description of the PR description: an informative and concise description of the PR.
{%- if use_bullet_points %} Use bullet points. {% endif %}
PR Main Files Walkthrough: PR Main Files Walkthrough:
type: array type: array
maxItems: 10 maxItems: 10

View File

@ -25,7 +25,7 @@ code line that already existed in the file....
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). 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 %} {%- if num_code_suggestions > 0 %}
- Provide up to {{ num_code_suggestions }} code suggestions. - 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. - 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. - 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. - Don't suggest to add docstring, type hints, or comments.
@ -99,10 +99,10 @@ PR Feedback:
General suggestions: General suggestions:
type: string type: string
description: |- description: |-
General suggestions and feedback for the contributors and maintainers of General suggestions and feedback for the contributors and maintainers of this PR.
this PR. May include important suggestions for the overall structure, May include important suggestions for the overall structure,
primary purpose, best practices, critical bugs, and other aspects of the primary purpose, best practices, critical bugs, and other aspects of the PR.
PR. Don't address PR title and description, or lack of tests. Explain your suggestions. Don't address PR title and description, or lack of tests. Explain your suggestions.
{%- if num_code_suggestions > 0 %} {%- if num_code_suggestions > 0 %}
Code feedback: Code feedback:
type: array type: array
@ -115,11 +115,10 @@ PR Feedback:
suggestion: suggestion:
type: string type: string
description: |- description: |-
a concrete suggestion for meaningfully improving the new PR code. Also a concrete suggestion for meaningfully improving the new PR code.
describe how, specifically, the suggestion can be applied to new PR Also describe how, specifically, the suggestion can be applied to new PR code.
code. Add tags with importance measure that matches each suggestion Add tags with importance measure that matches each suggestion ('important' or 'medium').
('important' or 'medium'). Do not make suggestions for updating or Do not make suggestions for updating or adding docstrings, renaming PR title and description, or linter like.
adding docstrings, renaming PR title and description, or linter like.
relevant line: relevant line:
type: string type: string
description: |- description: |-

View File

@ -65,6 +65,11 @@ class PRAddDocs:
async def _prepare_prediction(self, model: str): async def _prepare_prediction(self, model: str):
get_logger().info('Getting PR diff...') 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.patches_diff = get_pr_diff(self.git_provider,
self.token_handler, self.token_handler,
model, model,

View File

@ -40,6 +40,7 @@ class PRDescription:
"description": self.git_provider.get_pr_description(full=False), "description": self.git_provider.get_pr_description(full=False),
"language": self.main_pr_language, "language": self.main_pr_language,
"diff": "", # empty diff for initial calculation "diff": "", # empty diff for initial calculation
"use_bullet_points": get_settings().pr_description.use_bullet_points,
"extra_instructions": get_settings().pr_description.extra_instructions, "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()
} }
@ -185,6 +186,11 @@ class PRDescription:
else: else:
ai_header = "" ai_header = ""
ai_type = self.data.get('PR 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('PR Description') ai_summary = self.data.get('PR Description')
if ai_summary and not re.search(r'<!--\s*pr_agent:summary\s*-->', body): if ai_summary and not re.search(r'<!--\s*pr_agent:summary\s*-->', body):
summary = f"{ai_header}{ai_summary}" summary = f"{ai_header}{ai_summary}"

View File

@ -98,6 +98,9 @@ class PRReviewer:
if self.is_auto and not get_settings().pr_reviewer.automatic_review: if self.is_auto and not get_settings().pr_reviewer.automatic_review:
get_logger().info(f'Automatic review is disabled {self.pr_url}') get_logger().info(f'Automatic review is disabled {self.pr_url}')
return None return None
if self.is_auto and self.incremental.is_incremental 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 None
get_logger().info(f'Reviewing PR: {self.pr_url} ...') get_logger().info(f'Reviewing PR: {self.pr_url} ...')
@ -111,9 +114,10 @@ class PRReviewer:
if get_settings().config.publish_output: if get_settings().config.publish_output:
get_logger().info('Pushing PR review...') get_logger().info('Pushing PR review...')
previous_review_comment = self._get_previous_review_comment()
self.git_provider.publish_comment(pr_comment) self.git_provider.publish_comment(pr_comment)
self.git_provider.remove_initial_comment() self.git_provider.remove_initial_comment()
self._remove_previous_review_comment(previous_review_comment)
if get_settings().pr_reviewer.inline_code_comments: if get_settings().pr_reviewer.inline_code_comments:
get_logger().info('Pushing inline code comments...') get_logger().info('Pushing inline code comments...')
self._publish_inline_code_comments() self._publish_inline_code_comments()
@ -228,9 +232,13 @@ class PRReviewer:
if self.incremental.is_incremental: if self.incremental.is_incremental:
last_commit_url = f"{self.git_provider.get_pr_url()}/commits/" \ last_commit_url = f"{self.git_provider.get_pr_url()}/commits/" \
f"{self.git_provider.incremental.first_new_commit_sha}" 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:
incremental_review_markdown_text += f" \n_({last_commit_msg.splitlines(keepends=False)[0]})_"
data = OrderedDict(data) data = OrderedDict(data)
data.update({'Incremental PR Review': { 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) data.move_to_end('Incremental PR Review', last=False)
markdown_text = convert_to_markdown(data, self.git_provider.is_supported("gfm_markdown")) markdown_text = convert_to_markdown(data, self.git_provider.is_supported("gfm_markdown"))
@ -311,3 +319,26 @@ class PRReviewer:
break break
return question_str, answer_str 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}")

View File

@ -48,9 +48,9 @@ class PRSimilarIssue:
# check if index exists, and if repo is already indexed # check if index exists, and if repo is already indexed
run_from_scratch = False run_from_scratch = False
if run_from_scratch: # for debugging if run_from_scratch: # for debugging
if not index_name in pinecone.list_indexes(): pinecone.init(api_key=api_key, environment=environment)
if index_name in pinecone.list_indexes():
get_logger().info('Removing index...') get_logger().info('Removing index...')
pinecone.init(api_key=api_key, environment=environment)
pinecone.delete_index(index_name) pinecone.delete_index(index_name)
get_logger().info('Done') get_logger().info('Done')