mirror of
https://github.com/qodo-ai/pr-agent.git
synced 2025-07-07 22:30:38 +08:00
Compare commits
23 Commits
test-almog
...
v0.27
Author | SHA1 | Date | |
---|---|---|---|
7cf521c001 | |||
e71c0f1805 | |||
8182a4afc0 | |||
3817aa2868 | |||
94a8606d24 | |||
af635650f1 | |||
222f276959 | |||
9a32e94b3e | |||
7c56eee701 | |||
48b3c69c10 | |||
9d1c8312b5 | |||
64e5a87530 | |||
9a9acef0e8 | |||
3ff8f1ff11 | |||
c7f4b87d6f | |||
9db44b5f5f | |||
70a2377ac9 | |||
52a68bcd44 | |||
0a4c02c8b3 | |||
e253f18e7f | |||
d6b6191f90 | |||
de80901284 | |||
dfbd8dad5d |
@ -52,6 +52,11 @@ PR-Agent aims to help efficiently review and handle pull requests, by providing
|
||||
|
||||
## News and Updates
|
||||
|
||||
### Feb 27, 2025
|
||||
- Updated the default model to `o3-mini` for all tools. You can still use the `gpt-4o` as the default model by setting the `model` parameter in the configuration file.
|
||||
- Important updates and bug fixes for Azure DevOps, see [here](https://github.com/qodo-ai/pr-agent/pull/1583)
|
||||
- Added support for adjusting the [response language](https://qodo-merge-docs.qodo.ai/usage-guide/additional_configurations/#language-settings) of the PR-Agent tools.
|
||||
|
||||
### Feb 6, 2025
|
||||
New design for the `/improve` tool:
|
||||
|
||||
|
@ -57,6 +57,23 @@ All Qodo Merge tools have a parameter called `extra_instructions`, that enables
|
||||
/update_changelog --pr_update_changelog.extra_instructions="Make sure to update also the version ..."
|
||||
```
|
||||
|
||||
## Language Settings
|
||||
|
||||
The default response language for Qodo Merge is **U.S. English**. However, some development teams may prefer to display information in a different language. For example, your team's workflow might improve if PR descriptions and code suggestions are set to your country's native language.
|
||||
|
||||
To configure this, set the `response_language` parameter in the configuration file. This will prompt the model to respond in the specified language. Use a **standard locale code** based on [ISO 3166](https://en.wikipedia.org/wiki/ISO_3166) (country codes) and [ISO 639](https://en.wikipedia.org/wiki/ISO_639) (language codes) to define a language-country pair. See this [comprehensive list of locale codes](https://simplelocalize.io/data/locales/).
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
[config]
|
||||
response_language: "it-IT"
|
||||
```
|
||||
|
||||
This will set the response language globally for all the commands to Italian.
|
||||
|
||||
> **Important:** Note that only dynamic text generated by the AI model is translated to the configured language. Static text such as labels and table headers that are not part of the AI models response will remain in US English. In addition, the model you are using must have good support for the specified language.
|
||||
|
||||
## Working with large PRs
|
||||
|
||||
The default mode of CodiumAI is to have a single call per tool, using GPT-4, which has a token limit of 8000 tokens.
|
||||
|
@ -44,6 +44,7 @@ command2class = {
|
||||
commands = list(command2class.keys())
|
||||
|
||||
|
||||
|
||||
class PRAgent:
|
||||
def __init__(self, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler):
|
||||
self.ai_handler = ai_handler # will be initialized in run_action
|
||||
@ -72,6 +73,20 @@ class PRAgent:
|
||||
# Update settings from args
|
||||
args = update_settings_from_args(args)
|
||||
|
||||
# Append the response language in the extra instructions
|
||||
response_language = get_settings().config.get('response_language', 'en-us')
|
||||
if response_language.lower() != 'en-us':
|
||||
get_logger().info(f'User has set the response language to: {response_language}')
|
||||
for key in get_settings():
|
||||
setting = get_settings().get(key)
|
||||
if str(type(setting)) == "<class 'dynaconf.utils.boxing.DynaBox'>":
|
||||
if hasattr(setting, 'extra_instructions'):
|
||||
current_extra_instructions = setting.extra_instructions
|
||||
if current_extra_instructions:
|
||||
setting.extra_instructions = current_extra_instructions+ f"\n======\n\nIn addition, Your response MUST be written in the language corresponding to local code: {response_language}. This is crucial."
|
||||
else:
|
||||
setting.extra_instructions = f"Your response MUST be written in the language corresponding to locale code: '{response_language}'. This is crucial."
|
||||
|
||||
action = action.lstrip("/").lower()
|
||||
if action not in command2class:
|
||||
get_logger().error(f"Unknown command: {action}")
|
||||
|
@ -232,7 +232,7 @@ class LiteLLMAIHandler(BaseAiHandler):
|
||||
|
||||
# Add temperature only if model supports it
|
||||
if model not in self.no_support_temperature_models and not get_settings().config.custom_reasoning_model:
|
||||
get_logger().info(f"Adding temperature with value {temperature} to model {model}.")
|
||||
# get_logger().info(f"Adding temperature with value {temperature} to model {model}.")
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
# Add reasoning_effort if model supports it
|
||||
|
@ -250,7 +250,7 @@ def convert_to_markdown_v2(output_data: dict,
|
||||
if gfm_supported:
|
||||
if reference_link is not None and len(reference_link) > 0:
|
||||
if relevant_lines_str:
|
||||
issue_str = f"<details><summary><a href='{reference_link}'><strong>{issue_header}</strong></a>\n\n{issue_content}</summary>\n\n{relevant_lines_str}\n\n</details>"
|
||||
issue_str = f"<details><summary><a href='{reference_link}'><strong>{issue_header}</strong></a>\n\n{issue_content}\n</summary>\n\n{relevant_lines_str}\n\n</details>"
|
||||
else:
|
||||
issue_str = f"<a href='{reference_link}'><strong>{issue_header}</strong></a><br>{issue_content}"
|
||||
else:
|
||||
|
@ -183,6 +183,7 @@ class AzureDevopsProvider(GitProvider):
|
||||
return True
|
||||
|
||||
def set_pr(self, pr_url: str):
|
||||
self.pr_url = pr_url
|
||||
self.workspace_slug, self.repo_slug, self.pr_num = self._parse_pr_url(pr_url)
|
||||
self.pr = self._get_pr()
|
||||
|
||||
@ -614,8 +615,11 @@ class AzureDevopsProvider(GitProvider):
|
||||
return pr_id
|
||||
except Exception as e:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().error(f"Failed to get pr id, error: {e}")
|
||||
get_logger().info(f"Failed to get pr id, error: {e}")
|
||||
return ""
|
||||
|
||||
def publish_file_comments(self, file_comments: list) -> bool:
|
||||
pass
|
||||
|
||||
def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str:
|
||||
return self.pr_url+f"?_a=files&path={relevant_file}"
|
||||
|
@ -181,7 +181,13 @@ class GitLabProvider(GitProvider):
|
||||
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
|
||||
try:
|
||||
return self.mr.commits().next().web_url
|
||||
except StopIteration: # no commits
|
||||
return ""
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Could not get latest commit URL: {e}")
|
||||
return ""
|
||||
|
||||
def get_comment_url(self, comment):
|
||||
return f"{self.mr.web_url}#note_{comment.id}"
|
||||
|
@ -33,20 +33,16 @@ azure_devops_server = get_settings().get("azure_devops_server")
|
||||
WEBHOOK_USERNAME = azure_devops_server.get("webhook_username")
|
||||
WEBHOOK_PASSWORD = azure_devops_server.get("webhook_password")
|
||||
|
||||
def handle_request(
|
||||
background_tasks: BackgroundTasks, url: str, body: str, log_context: dict
|
||||
async def handle_request_comment( url: str, body: str, log_context: dict
|
||||
):
|
||||
log_context["action"] = body
|
||||
log_context["api_url"] = url
|
||||
|
||||
async def inner():
|
||||
try:
|
||||
with get_logger().contextualize(**log_context):
|
||||
await PRAgent().handle_request(url, body)
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to handle webhook: {e}")
|
||||
|
||||
background_tasks.add_task(inner)
|
||||
try:
|
||||
with get_logger().contextualize(**log_context):
|
||||
await PRAgent().handle_request(url, body)
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to handle webhook", artifact={"url": url, "body": body}, error=str(e))
|
||||
|
||||
|
||||
# currently only basic auth is supported with azure webhooks
|
||||
@ -68,6 +64,9 @@ async def _perform_commands_azure(commands_conf: str, agent: PRAgent, api_url: s
|
||||
get_logger().info(f"Auto feedback is disabled, skipping auto commands for PR {api_url=}", **log_context)
|
||||
return
|
||||
commands = get_settings().get(f"azure_devops_server.{commands_conf}")
|
||||
if not commands:
|
||||
return
|
||||
|
||||
get_settings().set("config.is_auto_command", True)
|
||||
for command in commands:
|
||||
try:
|
||||
@ -83,12 +82,7 @@ async def _perform_commands_azure(commands_conf: str, agent: PRAgent, api_url: s
|
||||
get_logger().error(f"Failed to perform command {command}: {e}")
|
||||
|
||||
|
||||
@router.post("/", dependencies=[Depends(authorize)])
|
||||
async def handle_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
log_context = {"server_type": "azure_devops_server"}
|
||||
data = await request.json()
|
||||
get_logger().info(json.dumps(data))
|
||||
|
||||
async def handle_request_azure(data, log_context):
|
||||
actions = []
|
||||
if data["eventType"] == "git.pullrequest.created":
|
||||
# API V1 (latest)
|
||||
@ -96,7 +90,10 @@ async def handle_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
log_context["event"] = data["eventType"]
|
||||
log_context["api_url"] = pr_url
|
||||
await _perform_commands_azure("pr_commands", PRAgent(), pr_url, log_context)
|
||||
return
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
content=jsonable_encoder({"message": "webhook triggered successfully"})
|
||||
)
|
||||
elif data["eventType"] == "ms.vss-code.git-pullrequest-comment-event" and "content" in data["resource"]["comment"]:
|
||||
if available_commands_rgx.match(data["resource"]["comment"]["content"]):
|
||||
if(data["resourceVersion"] == "2.0"):
|
||||
@ -124,7 +121,7 @@ async def handle_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
|
||||
for action in actions:
|
||||
try:
|
||||
handle_request(background_tasks, pr_url, action, log_context)
|
||||
await handle_request_comment(pr_url, action, log_context)
|
||||
except Exception as e:
|
||||
get_logger().error("Azure DevOps Trigger failed. Error:" + str(e))
|
||||
return JSONResponse(
|
||||
@ -135,6 +132,18 @@ async def handle_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
status_code=status.HTTP_202_ACCEPTED, content=jsonable_encoder({"message": "webhook triggered successfully"})
|
||||
)
|
||||
|
||||
@router.post("/", dependencies=[Depends(authorize)])
|
||||
async def handle_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
log_context = {"server_type": "azure_devops_server"}
|
||||
data = await request.json()
|
||||
# get_logger().info(json.dumps(data))
|
||||
|
||||
background_tasks.add_task(handle_request_azure, data, log_context)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_202_ACCEPTED, content=jsonable_encoder({"message": "webhook triggered successfully"})
|
||||
)
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
return {"status": "ok"}
|
||||
|
@ -71,6 +71,31 @@ def is_bot_user(data) -> bool:
|
||||
get_logger().error(f"Failed 'is_bot_user' logic: {e}")
|
||||
return False
|
||||
|
||||
def is_draft(data) -> bool:
|
||||
try:
|
||||
if 'draft' in data.get('object_attributes', {}):
|
||||
return data['object_attributes']['draft']
|
||||
|
||||
# for gitlab server version before 16
|
||||
elif 'Draft:' in data.get('object_attributes', {}).get('title'):
|
||||
return True
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed 'is_draft' logic: {e}")
|
||||
return False
|
||||
|
||||
def is_draft_ready(data) -> bool:
|
||||
try:
|
||||
if 'draft' in data.get('changes', {}):
|
||||
if data['changes']['draft']['previous'] == 'true' and data['changes']['draft']['current'] == 'false':
|
||||
return True
|
||||
|
||||
# for gitlab server version before 16
|
||||
elif 'title' in data.get('changes', {}):
|
||||
if 'Draft:' in data['changes']['title']['previous'] and 'Draft:' not in data['changes']['title']['current']:
|
||||
return True
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed 'is_draft_ready' logic: {e}")
|
||||
return False
|
||||
|
||||
def should_process_pr_logic(data) -> bool:
|
||||
try:
|
||||
@ -173,23 +198,21 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
# ignore MRs based on title, labels, source and target branches
|
||||
if not should_process_pr_logic(data):
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||
|
||||
if data['object_attributes'].get('action') in ['open', 'reopen']:
|
||||
url = data['object_attributes'].get('url')
|
||||
draft = data['object_attributes'].get('draft')
|
||||
object_attributes = data.get('object_attributes', {})
|
||||
if object_attributes.get('action') in ['open', 'reopen']:
|
||||
url = object_attributes.get('url')
|
||||
get_logger().info(f"New merge request: {url}")
|
||||
if draft:
|
||||
if is_draft(data):
|
||||
get_logger().info(f"Skipping draft MR: {url}")
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||
|
||||
await _perform_commands_gitlab("pr_commands", PRAgent(), url, log_context, data)
|
||||
|
||||
# for push event triggered merge requests
|
||||
elif data['object_attributes'].get('action') == 'update' and data['object_attributes'].get('oldrev'):
|
||||
url = data['object_attributes'].get('url')
|
||||
draft = data['object_attributes'].get('draft')
|
||||
elif object_attributes.get('action') == 'update' and object_attributes.get('oldrev'):
|
||||
url = object_attributes.get('url')
|
||||
get_logger().info(f"New merge request: {url}")
|
||||
if draft:
|
||||
if is_draft(data):
|
||||
get_logger().info(f"Skipping draft MR: {url}")
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||
|
||||
@ -202,6 +225,14 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
|
||||
get_logger().debug(f'A push event has been received: {url}')
|
||||
await _perform_commands_gitlab("push_commands", PRAgent(), url, log_context, data)
|
||||
|
||||
# for draft to ready triggered merge requests
|
||||
elif object_attributes.get('action') == 'update' and is_draft_ready(data):
|
||||
url = object_attributes.get('url')
|
||||
get_logger().info(f"Draft MR is ready: {url}")
|
||||
|
||||
# same as open MR
|
||||
await _perform_commands_gitlab("pr_commands", PRAgent(), url, log_context, data)
|
||||
|
||||
elif data.get('object_kind') == 'note' and data.get('event_type') == 'note': # comment on MR
|
||||
if 'merge_request' in data:
|
||||
|
@ -1,8 +1,13 @@
|
||||
# Important: This file contains all available configuration options.
|
||||
# Do not copy this entire file to your repository configuration.
|
||||
# Your repository configuration should only include options you wish to override from the defaults.
|
||||
|
||||
[config]
|
||||
# models
|
||||
model="gpt-4o-2024-11-20"
|
||||
fallback_models=["gpt-4o-2024-08-06"]
|
||||
model="o3-mini"
|
||||
fallback_models=["gpt-4o-2024-11-20"]
|
||||
#model_weak="gpt-4o-mini-2024-07-18" # optional, a weaker model to use for some easier tasks
|
||||
response_language="en-US" # Language locales code for PR responses in ISO 3166 and ISO 639 format (e.g., "en-US", "it-IT", "zh-CN", ...)
|
||||
# CLI
|
||||
git_provider="github"
|
||||
publish_output=true
|
||||
@ -327,3 +332,11 @@ utilize_auto_best_practices = true # public - disable usage of auto best practic
|
||||
extra_instructions = "" # public - extra instructions to the auto best practices generation prompt
|
||||
content = ""
|
||||
max_patterns = 5 # max number of patterns to be detected
|
||||
|
||||
|
||||
[azure_devops_server]
|
||||
pr_commands = [
|
||||
"/describe",
|
||||
"/review",
|
||||
"/improve",
|
||||
]
|
@ -1,250 +0,0 @@
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import uvicorn
|
||||
from fastapi import APIRouter, FastAPI, Request, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.background import BackgroundTasks
|
||||
from starlette.middleware import Middleware
|
||||
from starlette_context import context
|
||||
from starlette_context.middleware import RawContextMiddleware
|
||||
|
||||
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.utils import apply_repo_settings
|
||||
from pr_agent.log import LoggingFormat, get_logger, setup_logger
|
||||
from pr_agent.secret_providers import get_secret_provider
|
||||
|
||||
setup_logger(fmt=LoggingFormat.JSON, level="DEBUG")
|
||||
router = APIRouter()
|
||||
|
||||
secret_provider = get_secret_provider() if get_settings().get("CONFIG.SECRET_PROVIDER") else None
|
||||
|
||||
|
||||
async def handle_request(api_url: str, body: str, log_context: dict, sender_id: str):
|
||||
log_context["action"] = body
|
||||
log_context["event"] = "pull_request" if body == "/review" else "comment"
|
||||
log_context["api_url"] = api_url
|
||||
log_context["app_name"] = get_settings().get("CONFIG.APP_NAME", "Unknown")
|
||||
|
||||
with get_logger().contextualize(**log_context):
|
||||
await PRAgent().handle_request(api_url, body)
|
||||
|
||||
|
||||
async def _perform_commands_gitlab(commands_conf: str, agent: PRAgent, api_url: str,
|
||||
log_context: dict, data: dict):
|
||||
apply_repo_settings(api_url)
|
||||
if commands_conf == "pr_commands" and get_settings().config.disable_auto_feedback: # auto commands for PR, and auto feedback is disabled
|
||||
get_logger().info(f"Auto feedback is disabled, skipping auto commands for PR {api_url=}", **log_context)
|
||||
return
|
||||
if not should_process_pr_logic(data): # Here we already updated the configurations
|
||||
return
|
||||
commands = get_settings().get(f"gitlab.{commands_conf}", {})
|
||||
get_settings().set("config.is_auto_command", True)
|
||||
for command in commands:
|
||||
try:
|
||||
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(f"Performing command: {new_command}")
|
||||
with get_logger().contextualize(**log_context):
|
||||
await agent.handle_request(api_url, new_command)
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to perform command {command}: {e}")
|
||||
|
||||
|
||||
def is_bot_user(data) -> bool:
|
||||
try:
|
||||
# logic to ignore bot users (unlike Github, no direct flag for bot users in gitlab)
|
||||
sender_name = data.get("user", {}).get("name", "unknown").lower()
|
||||
bot_indicators = ['codium', 'bot_', 'bot-', '_bot', '-bot']
|
||||
if any(indicator in sender_name for indicator in bot_indicators):
|
||||
get_logger().info(f"Skipping GitLab bot user: {sender_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed 'is_bot_user' logic: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def should_process_pr_logic(data) -> bool:
|
||||
try:
|
||||
if not data.get('object_attributes', {}):
|
||||
return False
|
||||
title = data['object_attributes'].get('title')
|
||||
sender = data.get("user", {}).get("username", "")
|
||||
|
||||
# logic to ignore PRs from specific users
|
||||
ignore_pr_users = get_settings().get("CONFIG.IGNORE_PR_AUTHORS", [])
|
||||
if ignore_pr_users and sender:
|
||||
if sender in ignore_pr_users:
|
||||
get_logger().info(f"Ignoring PR from user '{sender}' due to 'config.ignore_pr_authors' settings")
|
||||
return False
|
||||
|
||||
# logic to ignore MRs for titles, labels and source, target branches.
|
||||
ignore_mr_title = get_settings().get("CONFIG.IGNORE_PR_TITLE", [])
|
||||
ignore_mr_labels = get_settings().get("CONFIG.IGNORE_PR_LABELS", [])
|
||||
ignore_mr_source_branches = get_settings().get("CONFIG.IGNORE_PR_SOURCE_BRANCHES", [])
|
||||
ignore_mr_target_branches = get_settings().get("CONFIG.IGNORE_PR_TARGET_BRANCHES", [])
|
||||
|
||||
#
|
||||
if ignore_mr_source_branches:
|
||||
source_branch = data['object_attributes'].get('source_branch')
|
||||
if any(re.search(regex, source_branch) for regex in ignore_mr_source_branches):
|
||||
get_logger().info(
|
||||
f"Ignoring MR with source branch '{source_branch}' due to gitlab.ignore_mr_source_branches settings")
|
||||
return False
|
||||
|
||||
if ignore_mr_target_branches:
|
||||
target_branch = data['object_attributes'].get('target_branch')
|
||||
if any(re.search(regex, target_branch) for regex in ignore_mr_target_branches):
|
||||
get_logger().info(
|
||||
f"Ignoring MR with target branch '{target_branch}' due to gitlab.ignore_mr_target_branches settings")
|
||||
return False
|
||||
|
||||
if ignore_mr_labels:
|
||||
labels = [label['title'] for label in data['object_attributes'].get('labels', [])]
|
||||
if any(label in ignore_mr_labels for label in labels):
|
||||
labels_str = ", ".join(labels)
|
||||
get_logger().info(f"Ignoring MR with labels '{labels_str}' due to gitlab.ignore_mr_labels settings")
|
||||
return False
|
||||
|
||||
if ignore_mr_title:
|
||||
if any(re.search(regex, title) for regex in ignore_mr_title):
|
||||
get_logger().info(f"Ignoring MR with title '{title}' due to gitlab.ignore_mr_title settings")
|
||||
return False
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed 'should_process_pr_logic': {e}")
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
start_time = datetime.now()
|
||||
request_json = await request.json()
|
||||
context["settings"] = copy.deepcopy(global_settings)
|
||||
|
||||
async def inner(data: dict):
|
||||
log_context = {"server_type": "gitlab_app"}
|
||||
get_logger().debug("Received a GitLab webhook")
|
||||
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)
|
||||
if not secret:
|
||||
get_logger().warning(f"Empty secret retrieved, request_token: {request_token}")
|
||||
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content=jsonable_encoder({"message": "unauthorized"}))
|
||||
try:
|
||||
secret_dict = json.loads(secret)
|
||||
gitlab_token = secret_dict["gitlab_token"]
|
||||
log_context["token_id"] = secret_dict.get("token_name", secret_dict.get("id", "unknown"))
|
||||
context["settings"].gitlab.personal_access_token = gitlab_token
|
||||
except Exception as 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")
|
||||
if not request.headers.get("X-Gitlab-Token") == secret:
|
||||
get_logger().error("Failed to validate secret")
|
||||
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
|
||||
else:
|
||||
get_logger().error("Failed to validate secret")
|
||||
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
|
||||
gitlab_token = get_settings().get("GITLAB.PERSONAL_ACCESS_TOKEN", None)
|
||||
if not gitlab_token:
|
||||
get_logger().error("No gitlab token found")
|
||||
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
|
||||
|
||||
get_logger().info("GitLab data", artifact=data)
|
||||
sender = data.get("user", {}).get("username", "unknown")
|
||||
sender_id = data.get("user", {}).get("id", "unknown")
|
||||
|
||||
# ignore bot users
|
||||
if is_bot_user(data):
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||
|
||||
log_context["sender"] = sender
|
||||
if data.get('object_kind') == 'merge_request':
|
||||
# ignore MRs based on title, labels, source and target branches
|
||||
if not should_process_pr_logic(data):
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||
|
||||
if data['object_attributes'].get('action') in ['open', 'reopen']:
|
||||
url = data['object_attributes'].get('url')
|
||||
draft = data['object_attributes'].get('draft')
|
||||
get_logger().info(f"New merge request: {url}")
|
||||
if draft:
|
||||
get_logger().info(f"Skipping draft MR: {url}")
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||
|
||||
await _perform_commands_gitlab("pr_commands", PRAgent(), url, log_context, data)
|
||||
|
||||
# Handle the Draft to Ready transition
|
||||
elif data['object_attributes'].get('action') == 'update':
|
||||
url = data['object_attributes'].get('url')
|
||||
old_draft_status = data['changes']['draft']['previous']
|
||||
new_draft_status = data['object_attributes'].get('draft')
|
||||
|
||||
# Check if the merge request transitioned from Draft to Ready
|
||||
if old_draft_status and not new_draft_status:
|
||||
get_logger().info(f"Merge Request transitioned from Draft to Ready: {url}")
|
||||
await _perform_commands_gitlab("pr_draft_ready_commands", PRAgent(), url, log_context, data)
|
||||
|
||||
elif data.get('object_kind') == 'note' and data.get('event_type') == 'note': # comment on MR
|
||||
if 'merge_request' in data:
|
||||
mr = data['merge_request']
|
||||
url = mr.get('url')
|
||||
|
||||
get_logger().info(f"A comment has been added to a merge request: {url}")
|
||||
body = data.get('object_attributes', {}).get('note')
|
||||
if data.get('object_attributes', {}).get('type') == 'DiffNote' and '/ask' in body: # /ask_line
|
||||
body = handle_ask_line(body, data)
|
||||
|
||||
await handle_request(url, body, log_context, sender_id)
|
||||
|
||||
background_tasks.add_task(inner, request_json)
|
||||
end_time = datetime.now()
|
||||
get_logger().info(f"Processing time: {end_time - start_time}", request=request_json)
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||
|
||||
|
||||
def handle_ask_line(body, data):
|
||||
try:
|
||||
line_range_ = data['object_attributes']['position']['line_range']
|
||||
start_line = line_range_['start']['new_line']
|
||||
end_line = line_range_['end']['new_line']
|
||||
question = body.replace('/ask', '').strip()
|
||||
path = data['object_attributes']['position']['new_path']
|
||||
side = 'RIGHT'
|
||||
comment_id = data['object_attributes']["discussion_id"]
|
||||
get_logger().info("Handling line comment")
|
||||
body = f"/ask_line --line_start={start_line} --line_end={end_line} --side={side} --file_name={path} --comment_id={comment_id} {question}"
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to handle ask line comment: {e}")
|
||||
return body
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
return {"status": "ok"}
|
||||
|
||||
gitlab_url = get_settings().get("GITLAB.URL", None)
|
||||
if not gitlab_url:
|
||||
raise ValueError("GITLAB.URL is not set")
|
||||
get_settings().config.git_provider = "gitlab"
|
||||
middleware = [Middleware(RawContextMiddleware)]
|
||||
app = FastAPI(middleware=middleware)
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
def start():
|
||||
uvicorn.run(app, host="0.0.0.0", port=3000)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
start()
|
@ -328,7 +328,10 @@ class PRDescription:
|
||||
original_prediction_dict = {"pr_files": original_prediction_loaded}
|
||||
else:
|
||||
original_prediction_dict = original_prediction_loaded
|
||||
filenames_predicted = [file['filename'].strip() for file in original_prediction_dict.get('pr_files', [])]
|
||||
if original_prediction_dict:
|
||||
filenames_predicted = [file.get('filename', '').strip() for file in original_prediction_dict.get('pr_files', [])]
|
||||
else:
|
||||
filenames_predicted = []
|
||||
|
||||
# extend the prediction with additional files not included in the original prediction
|
||||
pr_files = self.git_provider.get_diff_files()
|
||||
@ -368,8 +371,12 @@ class PRDescription:
|
||||
if counter_extra_files > 0:
|
||||
get_logger().info(f"Adding {counter_extra_files} unprocessed extra files to table prediction")
|
||||
prediction_extra_dict = load_yaml(prediction_extra, keys_fix_yaml=self.keys_fix)
|
||||
if isinstance(original_prediction_dict, dict) and isinstance(prediction_extra_dict, dict):
|
||||
original_prediction_dict["pr_files"].extend(prediction_extra_dict["pr_files"])
|
||||
if original_prediction_dict and isinstance(original_prediction_dict, dict) and \
|
||||
isinstance(prediction_extra_dict, dict) and "pr_files" in prediction_extra_dict:
|
||||
if "pr_files" in original_prediction_dict:
|
||||
original_prediction_dict["pr_files"].extend(prediction_extra_dict["pr_files"])
|
||||
else:
|
||||
original_prediction_dict["pr_files"] = prediction_extra_dict["pr_files"]
|
||||
new_yaml = yaml.dump(original_prediction_dict)
|
||||
if load_yaml(new_yaml, keys_fix_yaml=self.keys_fix):
|
||||
prediction = new_yaml
|
||||
@ -378,7 +385,7 @@ class PRDescription:
|
||||
|
||||
return prediction
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error extending uncovered files {self.pr_id}: {e}")
|
||||
get_logger().exception(f"Error extending uncovered files {self.pr_id}", artifact={"error": e})
|
||||
return original_prediction
|
||||
|
||||
|
||||
@ -683,8 +690,9 @@ class PRDescription:
|
||||
filename = filename.strip()
|
||||
link = self.git_provider.get_line_link(filename, relevant_line_start=-1)
|
||||
if (not link or not diff_plus_minus) and ('additional files' not in filename.lower()):
|
||||
get_logger().warning(f"Error getting line link for '{filename}'")
|
||||
continue
|
||||
# get_logger().warning(f"Error getting line link for '{filename}'")
|
||||
link = ""
|
||||
# continue
|
||||
|
||||
# Add file data to the PR body
|
||||
file_change_description_br = insert_br_after_x_chars(file_change_description, x=(delta - 5))
|
||||
|
Reference in New Issue
Block a user