mirror of
https://github.com/qodo-ai/pr-agent.git
synced 2025-07-11 00:00:38 +08:00
Compare commits
91 Commits
enhancemen
...
feature/gi
Author | SHA1 | Date | |
---|---|---|---|
ea1cd7ae45 | |||
1c1aad2806 | |||
f466d79031 | |||
e2323dfb9f | |||
e51e443adc | |||
f6d4a214ca | |||
4bb46d9faa | |||
f337d76af6 | |||
4033303c1f | |||
38c8d187d2 | |||
f8ddfd2f25 | |||
4b4fda37a6 | |||
9ca6b789a7 | |||
0f73f5f906 | |||
5742a9be1e | |||
914cc6639a | |||
f34cda126a | |||
dece20c984 | |||
94c1f430af | |||
9fadde388b | |||
d1b6b3bc95 | |||
77a451ada0 | |||
4b8420aa16 | |||
25bc69f70e | |||
e2faf117c5 | |||
aaff03bb60 | |||
cd1e62ec96 | |||
7767cae181 | |||
1bc206e7b2 | |||
52a438b3c8 | |||
b8a71b369d | |||
72af2a1f9c | |||
fd4a2bf7ff | |||
a3211d4958 | |||
86d7ed5f82 | |||
210d94f2aa | |||
b2d952cafa | |||
6eacf4791d | |||
4076f67ab8 | |||
c2639a2520 | |||
38db65831e | |||
e1b856f7e6 | |||
301622216f | |||
b63db6cef0 | |||
8fba670bda | |||
ca47833c56 | |||
567475c18c | |||
fb4badd160 | |||
9695d96799 | |||
0930f76cb7 | |||
365559405f | |||
d4adcb3c22 | |||
75167c2700 | |||
78f5f58774 | |||
81a2e5cbe2 | |||
e63a4f47ce | |||
caff65613f | |||
ee3cac9836 | |||
8b3ff7a632 | |||
7d49e080fc | |||
1a94079936 | |||
7ed12c2f8e | |||
ed8cf27b05 | |||
4b786b350e | |||
110d987514 | |||
cc5e01cec5 | |||
620bf68d25 | |||
86e5a30a36 | |||
6c10f78c31 | |||
46922d2842 | |||
55ab198bb2 | |||
0c7f048e58 | |||
efc8f755d5 | |||
aebcb3f3c6 | |||
1cedd13cf3 | |||
b7cd368cce | |||
6ef5843380 | |||
c5f2abb548 | |||
bfdff08cb8 | |||
f1380df468 | |||
2c4c7c485e | |||
f3df032f06 | |||
e15559011d | |||
2434240f08 | |||
d3936122ec | |||
c75f561701 | |||
d9bd73646c | |||
13101df811 | |||
64cb5da821 | |||
f6f4d32edb | |||
3e445c7e03 |
16
.github/workflows/review.yaml
vendored
Normal file
16
.github/workflows/review.yaml
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
on:
|
||||
pull_request:
|
||||
issue_comment:
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
runs-on: ubuntu-latest
|
||||
name: Run pr agent on every pull request
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@feature/github_action
|
||||
env:
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI_ORG: ${{ secrets.OPENAI_ORG }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
10
Dockerfile.github_action
Normal file
10
Dockerfile.github_action
Normal file
@ -0,0 +1,10 @@
|
||||
FROM python:3.10 as base
|
||||
|
||||
WORKDIR /app
|
||||
ADD requirements.txt .
|
||||
RUN pip install -r requirements.txt && rm requirements.txt
|
||||
ENV PYTHONPATH=/app
|
||||
ADD pr_agent pr_agent
|
||||
ADD github_action/entrypoint.sh /
|
||||
RUN chmod +x /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
42
PR_COMPRESSION.md
Normal file
42
PR_COMPRESSION.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Git Patch Logic
|
||||
There are two scenarios:
|
||||
1. The PR is small enough to fit in a single prompt (including system and user prompt)
|
||||
2. The PR is too large to fit in a single prompt (including system and user prompt)
|
||||
|
||||
For both scenarios, we first use the following strategy
|
||||
#### Repo language prioritization strategy
|
||||
|
||||
We prioritize the languages of the repo based on the following criteria:
|
||||
1. Exclude binary files and non code files (e.g. images, pdfs, etc)
|
||||
2. Given the main languages used in the repo
|
||||
2. We sort the PR files by the most common languages in the repo (in descending order):
|
||||
* ```[[file.py, file2.py],[file3.js, file4.jsx],[readme.md]]```
|
||||
|
||||
|
||||
## Small PR
|
||||
In this case, we can fit the entire PR in a single prompt:
|
||||
1. Exclude binary files and non code files (e.g. images, pdfs, etc)
|
||||
2. We Expand the surrounding context of each patch to 6 lines above and below the patch
|
||||
## Large PR
|
||||
|
||||
### Motivation
|
||||
Pull Requests can be very long and contain a lot of information with varying degree of relevance to the pr-agent.
|
||||
We want to be able to pack as much information as possible in a single LMM prompt, while keeping the information relevant to the pr-agent.
|
||||
|
||||
|
||||
|
||||
#### PR compression strategy
|
||||
We prioritize additions over deletions:
|
||||
- Combine all deleted files into a single list (`deleted files`)
|
||||
- File patches are a list of hunks, remove all hunks of type deletion-only from the hunks in the file patch
|
||||
#### Adaptive and token-aware file patch fitting
|
||||
We use [tiktoken](https://github.com/openai/tiktoken) to tokenize the patches after the modifications described above, and we use the following strategy to fit the patches into the prompt:
|
||||
1. Withing each language we sort the files by the number of tokens in the file (in descending order):
|
||||
* ```[[file2.py, file.py],[file4.jsx, file3.js],[readme.md]]```
|
||||
2. Iterate through the patches in the order described above
|
||||
2. Add the patches to the prompt until the prompt reaches a certain buffer from the max token length
|
||||
3. If there are still patches left, add the remaining patches as a list called `other modified files` to the prompt until the prompt reaches the max token length (hard stop), skip the rest of the patches.
|
||||
4. If we haven't reached the max token length, add the `deleted files` to the prompt until the prompt reaches the max token length (hard stop), skip the rest of the patches.
|
||||
|
||||
### Example
|
||||

|
72
README.md
72
README.md
@ -1,28 +1,35 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="./pics/Icon-7.png" alt="pr-agent_icon" width="100"/>
|
||||
<div align="center">
|
||||
|
||||
# pr-agent
|
||||
<img src="./pics/logo-dark.png#gh-dark-mode-only" width="250"/>
|
||||
<img src="./pics/logo-light.png#gh-light-mode-only" width="250"/>
|
||||
|
||||
</div>
|
||||
|
||||
[](https://github.com/Codium-ai/pr-agent/blob/main/LICENSE)
|
||||
[](https://discord.com/channels/1057273017547378788/1126104260430528613)
|
||||
|
||||
CodiumAI `pr-agent` is an open-source tool is powered by GPT-4 aming to help developers review PRs faster and more efficiently. It automatically analyzes the PR, and provides feedback and suggestions, and can answer questions.
|
||||
CodiumAI `pr-agent` is an open-source tool aiming to help developers review PRs faster and more efficiently. It automatically analyzes the PR, provides feedback and suggestions, and can answer free-text questions.
|
||||
|
||||
</div>
|
||||
|
||||
- [Live demo](#live-demo)
|
||||
- [Quickstart](#Quickstart)
|
||||
- [Usage and Tools](#usage-and-tools)
|
||||
- [Usage and tools](#usage-and-tools)
|
||||
- [Configuration](#Configuration)
|
||||
- [How it works](#how-it-works)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Similar projects](#similar-projects)
|
||||
|
||||
## Live demo
|
||||
|
||||
Experience GPT-4 powered PR review on your public Github repository with our hosted pr-agent. To try it, mention @CodiumAI-Agent in a PR comment! The agent will generate the review in response ([see details in the Usage section](#usage-and-tools)).
|
||||
Experience GPT-4 powered PR review on your public GitHub repository with our hosted pr-agent. To try it, just mention `@CodiumAI-Agent` in any PR comment! The agent will generate a PR review in response.
|
||||
|
||||

|
||||
|
||||
To set up your own pr-agent, see the [Quickstart](#Quickstart) section
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
@ -79,8 +86,8 @@ pip install -r requirements.txt
|
||||
3. Copy the secrets template file and fill in your OpenAI key and your GitHub user token:
|
||||
|
||||
```
|
||||
cp pr_agent/settings/.secrets_template.toml pr_agent/settings/.secrets
|
||||
# Edit .secrets file
|
||||
cp pr_agent/settings/.secrets_template.toml pr_agent/settings/.secrets.toml
|
||||
# Edit .secrets.toml file
|
||||
```
|
||||
|
||||
4. Run the appropriate Python scripts from the scripts folder:
|
||||
@ -140,8 +147,8 @@ git clone https://github.com/Codium-ai/pr-agent.git
|
||||
- Copy your app's webhook secret to the webhook_secret field.
|
||||
|
||||
```
|
||||
cp pr_agent/settings/.secrets_template.toml pr_agent/settings/.secrets
|
||||
# Edit .secrets file
|
||||
cp pr_agent/settings/.secrets_template.toml pr_agent/settings/.secrets.toml
|
||||
# Edit .secrets.toml file
|
||||
```
|
||||
|
||||
6. Build a Docker image for the app and optionally push it to a Docker repository. We'll use Dockerhub as an example:
|
||||
@ -176,10 +183,9 @@ Here is a quick overview of the different sub-tools of PR Reviewer:
|
||||
|
||||
- PR Analysis
|
||||
- Summarize main theme
|
||||
- PR description and title
|
||||
- PR type classification
|
||||
- Is the PR covered by relevant tests
|
||||
- Is the PR minimal and focused
|
||||
- Is this a focused PR
|
||||
- Are there security concerns
|
||||
- PR Feedback
|
||||
- General PR suggestions
|
||||
@ -192,10 +198,9 @@ This is how a typical output of the PR Reviewer looks like:
|
||||
#### PR Analysis
|
||||
|
||||
- 🎯 **Main theme:** Adding language extension handler and token handler
|
||||
- 🔍 **Description and title:** Yes
|
||||
- 📌 **Type of PR:** Enhancement
|
||||
- 🧪 **Relevant tests added:** No
|
||||
- ✨ **Minimal and focused:** Yes, the PR is focused on adding two new handlers for language extension and token counting.
|
||||
- ✨ **Focused PR:** Yes, the PR is focused on adding two new handlers for language extension and token counting.
|
||||
- 🔒 **Security concerns:** No, the PR does not introduce possible security concerns or issues.
|
||||
|
||||
#### PR Feedback
|
||||
@ -238,49 +243,16 @@ The different tools and sub-tools used by CodiumAI pr-agent are easily configura
|
||||
You can enable/disable the different PR Reviewer sub-sections with the following flags:
|
||||
|
||||
```
|
||||
require_minimal_and_focused_review=true
|
||||
require_focused_review=true
|
||||
require_tests_review=true
|
||||
require_security_review=true
|
||||
```
|
||||
|
||||
#### Code Suggestions configuration:
|
||||
## How it works
|
||||
|
||||
There are also configuration options to control different aspects of the `code suggestions` feature.
|
||||
The number of suggestions provided can be controlled by adjusting the following parameter:
|
||||

|
||||
|
||||
```
|
||||
num_code_suggestions=4
|
||||
```
|
||||
|
||||
You can also enable more verbose and informative mode of code suggestions:
|
||||
|
||||
```
|
||||
extended_code_suggestions=false
|
||||
```
|
||||
|
||||
This is a comparison of the regular and extended code suggestions modes:
|
||||
|
||||
- **relevant file:** sql.py
|
||||
- **suggestion content:** Remove hardcoded sensitive information like username and password. Use environment variables or a secure method to store these values. [important]
|
||||
|
||||
Example for extended suggestion:
|
||||
|
||||
- **relevant file:** sql.py
|
||||
- **suggestion content:** Remove hardcoded sensitive information (username and password) [important]
|
||||
- **why:** Hardcoding sensitive information is a security risk. It's better to use environment variables or a secure way to store these values.
|
||||
- **code example:**
|
||||
- **before code:**
|
||||
```
|
||||
user = "root",
|
||||
password = "Mysql@123",
|
||||
```
|
||||
- **after code:**
|
||||
```
|
||||
user = os.getenv('DB_USER'),
|
||||
password = os.getenv('DB_PASSWORD'),
|
||||
```
|
||||
|
||||
---
|
||||
Check out the [PR Compression strategy](./PR_COMPRESSION.md) page for more details on how we convert a code diff to a manageable LLM prompt
|
||||
|
||||
## Roadmap
|
||||
|
||||
|
5
action.yaml
Normal file
5
action.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: 'PR Agent'
|
||||
description: 'Summarize, review and suggest improvements for pull requests'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile.github_action'
|
2
github_action/entrypoint.sh
Normal file
2
github_action/entrypoint.sh
Normal file
@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
python /app/pr_agent/servers/github_action_runner.py
|
BIN
pics/git_patch_logic.png
Normal file
BIN
pics/git_patch_logic.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 346 KiB |
BIN
pics/logo-dark.png
Normal file
BIN
pics/logo-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
BIN
pics/logo-light.png
Normal file
BIN
pics/logo-light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
BIN
pics/pr_agent_overview.png
Normal file
BIN
pics/pr_agent_overview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 413 KiB |
@ -1,17 +1,16 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from pr_agent.tools.pr_questions import PRQuestions
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
|
||||
|
||||
class PRAgent:
|
||||
def __init__(self, installation_id: Optional[int] = None):
|
||||
self.installation_id = installation_id
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def handle_request(self, pr_url, request):
|
||||
if 'please review' in request.lower() or 'review' == request.lower().strip() or len(request) == 0:
|
||||
reviewer = PRReviewer(pr_url, self.installation_id)
|
||||
reviewer = PRReviewer(pr_url)
|
||||
await reviewer.review()
|
||||
|
||||
else:
|
||||
@ -21,5 +20,5 @@ class PRAgent:
|
||||
question = re.split(r'(?i)answer', request)[1].strip()
|
||||
else:
|
||||
question = request
|
||||
answerer = PRQuestions(pr_url, question, self.installation_id)
|
||||
answerer = PRQuestions(pr_url, question)
|
||||
await answerer.answer()
|
||||
|
@ -14,6 +14,13 @@ class AiHandler:
|
||||
openai.api_key = settings.openai.key
|
||||
if settings.get("OPENAI.ORG", None):
|
||||
openai.organization = settings.openai.org
|
||||
self.deployment_id = settings.get("OPENAI.DEPLOYMENT_ID", None)
|
||||
if settings.get("OPENAI.API_TYPE", None):
|
||||
openai.api_type = settings.openai.api_type
|
||||
if settings.get("OPENAI.API_VERSION", None):
|
||||
openai.engine = settings.openai.api_version
|
||||
if settings.get("OPENAI.API_BASE", None):
|
||||
openai.api_base = settings.openai.api_base
|
||||
except AttributeError as e:
|
||||
raise ValueError("OpenAI key is required") from e
|
||||
|
||||
@ -23,6 +30,7 @@ class AiHandler:
|
||||
try:
|
||||
response = await openai.ChatCompletion.acreate(
|
||||
model=model,
|
||||
deployment_id=self.deployment_id,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user}
|
||||
|
@ -58,7 +58,8 @@ bad_extensions = [
|
||||
'woff2',
|
||||
'xz',
|
||||
'zip',
|
||||
'zst'
|
||||
'zst',
|
||||
'snap'
|
||||
]
|
||||
|
||||
|
||||
@ -92,7 +93,7 @@ def sort_files_by_main_languages(languages: Dict, files: list):
|
||||
for ext in main_extensions:
|
||||
main_extensions_flat.extend(ext)
|
||||
|
||||
for extensions, lang in zip(main_extensions, languages_sorted_list):
|
||||
for extensions, lang in zip(main_extensions, languages_sorted_list): # noqa: B905
|
||||
tmp = []
|
||||
for file in files_filtered:
|
||||
extension_str = f".{file.filename.split('.')[-1]}"
|
||||
|
@ -24,10 +24,10 @@ def get_pr_diff(git_provider: Union[GithubProvider, Any], token_handler: TokenHa
|
||||
Returns a string with the diff of the PR.
|
||||
If needed, apply diff minimization techniques to reduce the number of tokens
|
||||
"""
|
||||
files = list(git_provider.get_diff_files())
|
||||
git_provider.pr.files = list(git_provider.get_diff_files())
|
||||
|
||||
# get pr languages
|
||||
pr_languages = sort_files_by_main_languages(git_provider.get_languages(), files)
|
||||
pr_languages = sort_files_by_main_languages(git_provider.get_languages(), git_provider.pr.files)
|
||||
|
||||
# generate a standard diff string, with patch extension
|
||||
patches_extended, total_tokens = pr_generate_extended_diff(pr_languages, token_handler)
|
||||
|
@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
|
||||
@ -8,11 +11,10 @@ def convert_to_markdown(output_data: dict) -> str:
|
||||
|
||||
emojis = {
|
||||
"Main theme": "🎯",
|
||||
"Description and title": "🔍",
|
||||
"Type of PR": "📌",
|
||||
"Relevant tests added": "🧪",
|
||||
"Unrelated changes": "⚠️",
|
||||
"Minimal and focused": "✨",
|
||||
"Focused PR": "✨",
|
||||
"Security concerns": "🔒",
|
||||
"General PR suggestions": "💡",
|
||||
"Code suggestions": "🤖"
|
||||
@ -50,10 +52,7 @@ def parse_code_suggestion(code_suggestions: dict) -> str:
|
||||
code_str_indented = textwrap.indent(code_str, ' ')
|
||||
markdown_text += f" - **{code_key}:**\n{code_str_indented}\n"
|
||||
else:
|
||||
if "suggestion number" in sub_key.lower():
|
||||
# markdown_text += f"- **suggestion {sub_value}:**\n" # prettier formatting
|
||||
pass
|
||||
elif "relevant file" in sub_key.lower():
|
||||
if "relevant file" in sub_key.lower():
|
||||
markdown_text += f"\n - **{sub_key}:** {sub_value}\n"
|
||||
else:
|
||||
markdown_text += f" **{sub_key}:** {sub_value}\n"
|
||||
@ -61,3 +60,25 @@ def parse_code_suggestion(code_suggestions: dict) -> str:
|
||||
markdown_text += "\n"
|
||||
return markdown_text
|
||||
|
||||
|
||||
def try_fix_json(review, max_iter=10):
|
||||
# Try to fix JSON if it is broken/incomplete: parse until the last valid code suggestion
|
||||
data = {}
|
||||
if review.rfind("'Code suggestions': [") > 0 or review.rfind('"Code suggestions": [') > 0:
|
||||
last_code_suggestion_ind = [m.end() for m in re.finditer(r"\}\s*,", review)][-1] - 1
|
||||
valid_json = False
|
||||
iter_count = 0
|
||||
while last_code_suggestion_ind > 0 and not valid_json and iter_count < max_iter:
|
||||
try:
|
||||
data = json.loads(review[:last_code_suggestion_ind] + "]}}")
|
||||
valid_json = True
|
||||
review = review[:last_code_suggestion_ind].strip() + "]}}"
|
||||
except json.decoder.JSONDecodeError:
|
||||
review = review[:last_code_suggestion_ind]
|
||||
# Use regular expression to find the last occurrence of "}," with any number of whitespaces or newlines
|
||||
last_code_suggestion_ind = [m.end() for m in re.finditer(r"\}\s*,", review)][-1] - 1
|
||||
iter_count += 1
|
||||
if not valid_json:
|
||||
logging.error("Unable to decode JSON response from AI")
|
||||
data = {}
|
||||
return data
|
||||
|
@ -3,6 +3,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from pr_agent.tools.pr_description import PRDescription
|
||||
from pr_agent.tools.pr_questions import PRQuestions
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
|
||||
@ -11,15 +12,20 @@ def run():
|
||||
parser = argparse.ArgumentParser(description='AI based pull request analyzer')
|
||||
parser.add_argument('--pr_url', type=str, help='The URL of the PR to review', required=True)
|
||||
parser.add_argument('--question', type=str, help='Optional question to ask', required=False)
|
||||
parser.add_argument('--pr_description', action='store_true', help='Optional question to ask', required=False)
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
||||
if args.question:
|
||||
print(f"Question: {args.question} about PR {args.pr_url}")
|
||||
reviewer = PRQuestions(args.pr_url, args.question, installation_id=None)
|
||||
reviewer = PRQuestions(args.pr_url, args.question)
|
||||
asyncio.run(reviewer.answer())
|
||||
elif args.pr_description:
|
||||
print(f"PR description: {args.pr_url}")
|
||||
reviewer = PRDescription(args.pr_url)
|
||||
asyncio.run(reviewer.describe())
|
||||
else:
|
||||
print(f"Reviewing PR: {args.pr_url}")
|
||||
reviewer = PRReviewer(args.pr_url, installation_id=None, cli_mode=True)
|
||||
reviewer = PRReviewer(args.pr_url, cli_mode=True)
|
||||
asyncio.run(reviewer.review())
|
||||
|
||||
|
||||
|
@ -5,11 +5,13 @@ from dynaconf import Dynaconf
|
||||
current_dir = dirname(abspath(__file__))
|
||||
settings = Dynaconf(
|
||||
envvar_prefix=False,
|
||||
merge_enabled=True,
|
||||
settings_files=[join(current_dir, f) for f in [
|
||||
"settings/.secrets.toml",
|
||||
"settings/configuration.toml",
|
||||
"settings/pr_reviewer_prompts.toml",
|
||||
"settings/pr_questions_prompts.toml",
|
||||
"settings/pr_description_prompts.toml",
|
||||
"settings_prod/.secrets.toml"
|
||||
]]
|
||||
)
|
||||
|
@ -1,15 +1,17 @@
|
||||
from pr_agent.config_loader import settings
|
||||
from pr_agent.git_providers.github_provider import GithubProvider
|
||||
from pr_agent.git_providers.gitlab_provider import GitLabProvider
|
||||
|
||||
_GIT_PROVIDERS = {
|
||||
'github': GithubProvider
|
||||
'github': GithubProvider,
|
||||
'gitlab': GitLabProvider,
|
||||
}
|
||||
|
||||
def get_git_provider():
|
||||
try:
|
||||
provider_id = settings.config.git_provider
|
||||
except AttributeError as e:
|
||||
raise ValueError("github_provider is a required attribute in the configuration file") from e
|
||||
raise ValueError("git_provider is a required attribute in the configuration file") from e
|
||||
if provider_id not in _GIT_PROVIDERS:
|
||||
raise ValueError(f"Unknown git provider: {provider_id}")
|
||||
return _GIT_PROVIDERS[provider_id]
|
||||
|
86
pr_agent/git_providers/git_provider.py
Normal file
86
pr_agent/git_providers/git_provider.py
Normal file
@ -0,0 +1,86 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilePatchInfo:
|
||||
base_file: str
|
||||
head_file: str
|
||||
patch: str
|
||||
filename: str
|
||||
tokens: int = -1
|
||||
|
||||
|
||||
class GitProvider(ABC):
|
||||
@abstractmethod
|
||||
def get_diff_files(self) -> list[FilePatchInfo]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
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 remove_initial_comment(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_languages(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_pr_branch(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_user_id(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_pr_description(self):
|
||||
pass
|
||||
|
||||
|
||||
def get_main_pr_language(languages, files) -> str:
|
||||
"""
|
||||
Get the main language of the commit. Return an empty string if cannot determine.
|
||||
"""
|
||||
main_language_str = ""
|
||||
try:
|
||||
top_language = max(languages, key=languages.get).lower()
|
||||
|
||||
# validate that the specific commit uses the main language
|
||||
extension_list = []
|
||||
for file in files:
|
||||
extension_list.append(file.filename.rsplit('.')[-1])
|
||||
|
||||
# get the most common extension
|
||||
most_common_extension = max(set(extension_list), key=extension_list.count)
|
||||
|
||||
# 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 == 'swift' and top_language == 'swift':
|
||||
main_language_str = top_language
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return main_language_str
|
@ -1,25 +1,18 @@
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from github import AppAuthentication, File, Github
|
||||
from github import AppAuthentication, Github
|
||||
|
||||
from pr_agent.config_loader import settings
|
||||
|
||||
@dataclass
|
||||
class FilePatchInfo:
|
||||
base_file: str
|
||||
head_file: str
|
||||
patch: str
|
||||
filename: str
|
||||
tokens: int = -1
|
||||
from .git_provider import FilePatchInfo
|
||||
|
||||
|
||||
class GithubProvider:
|
||||
def __init__(self, pr_url: Optional[str] = None, installation_id: Optional[int] = None):
|
||||
self.installation_id = installation_id
|
||||
def __init__(self, pr_url: Optional[str] = None):
|
||||
self.installation_id = settings.get("GITHUB.INSTALLATION_ID")
|
||||
self.github_client = self._get_github_client()
|
||||
self.repo = None
|
||||
self.pr_num = None
|
||||
@ -32,6 +25,11 @@ class GithubProvider:
|
||||
self.repo, self.pr_num = self._parse_pr_url(pr_url)
|
||||
self.pr = self._get_pr()
|
||||
|
||||
def get_files(self):
|
||||
if hasattr(self.pr, 'files'):
|
||||
return self.pr.files
|
||||
return self.pr.get_files()
|
||||
|
||||
def get_diff_files(self) -> list[FilePatchInfo]:
|
||||
files = self.pr.get_files()
|
||||
diff_files = []
|
||||
@ -41,6 +39,10 @@ class GithubProvider:
|
||||
diff_files.append(FilePatchInfo(original_file_content_str, new_file_content_str, file.patch, file.filename))
|
||||
return diff_files
|
||||
|
||||
def publish_description(self, pr_title: str, pr_body: str):
|
||||
self.pr.edit(title=pr_title, body=pr_body)
|
||||
# self.pr.create_issue_comment(pr_comment)
|
||||
|
||||
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
||||
response = self.pr.create_issue_comment(pr_comment)
|
||||
if hasattr(response, "user") and hasattr(response.user, "login"):
|
||||
@ -65,53 +67,15 @@ class GithubProvider:
|
||||
return self.pr.body
|
||||
|
||||
def get_languages(self):
|
||||
return self._get_repo().get_languages()
|
||||
|
||||
def get_main_pr_language(self) -> str:
|
||||
"""
|
||||
Get the main language of the commit. Return an empty string if cannot determine.
|
||||
"""
|
||||
main_language_str = ""
|
||||
try:
|
||||
languages = self.get_languages()
|
||||
top_language = max(languages, key=languages.get).lower()
|
||||
|
||||
# validate that the specific commit uses the main language
|
||||
extension_list = []
|
||||
files = self.pr.get_files()
|
||||
for file in files:
|
||||
extension_list.append(file.filename.rsplit('.')[-1])
|
||||
|
||||
# get the most common extension
|
||||
most_common_extension = max(set(extension_list), key=extension_list.count)
|
||||
|
||||
# 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 == 'swift' and top_language == 'swift':
|
||||
main_language_str = top_language
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return main_language_str
|
||||
languages = self._get_repo().get_languages()
|
||||
return languages
|
||||
|
||||
def get_pr_branch(self):
|
||||
return self.pr.head.ref
|
||||
|
||||
def get_pr_description(self):
|
||||
return self.pr.body
|
||||
|
||||
def get_user_id(self):
|
||||
if not self.github_user_id:
|
||||
try:
|
||||
|
96
pr_agent/git_providers/gitlab_provider.py
Normal file
96
pr_agent/git_providers/gitlab_provider.py
Normal file
@ -0,0 +1,96 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import gitlab
|
||||
|
||||
from pr_agent.config_loader import settings
|
||||
|
||||
from .git_provider import FilePatchInfo, GitProvider
|
||||
|
||||
|
||||
class GitLabProvider(GitProvider):
|
||||
def __init__(self, merge_request_url: Optional[str] = None):
|
||||
gitlab_url = settings.get("GITLAB.URL", None)
|
||||
if not gitlab_url:
|
||||
raise ValueError("GitLab URL is not set in the config file")
|
||||
gitlab_access_token = settings.get("GITLAB.PERSONAL_ACCESS_TOKEN", None)
|
||||
if not gitlab_access_token:
|
||||
raise ValueError("GitLab personal access token is not set in the config file")
|
||||
self.gl = gitlab.Gitlab(
|
||||
gitlab_url,
|
||||
gitlab_access_token
|
||||
)
|
||||
self.id_project = None
|
||||
self.id_mr = None
|
||||
self.mr = None
|
||||
self.temp_comments = []
|
||||
self._set_merge_request(merge_request_url)
|
||||
|
||||
@property
|
||||
def pr(self):
|
||||
'''The GitLab terminology is merge request (MR) instead of pull request (PR)'''
|
||||
return self.mr
|
||||
|
||||
def _set_merge_request(self, merge_request_url: str):
|
||||
self.id_project, self.id_mr = self._parse_merge_request_url(merge_request_url)
|
||||
self.mr = self._get_merge_request()
|
||||
|
||||
def get_diff_files(self) -> list[FilePatchInfo]:
|
||||
diffs = self.mr.changes()['changes']
|
||||
diff_files = [FilePatchInfo("", "", diff['diff'], diff['new_path']) for diff in diffs]
|
||||
return diff_files
|
||||
|
||||
def get_files(self):
|
||||
return [change['new_path'] for change in self.mr.changes()['changes']]
|
||||
|
||||
def publish_description(self, pr_title: str, pr_body: str):
|
||||
logging.exception("Not implemented yet")
|
||||
pass
|
||||
|
||||
def publish_comment(self, mr_comment: str, is_temporary: bool = False):
|
||||
comment = self.mr.notes.create({'body': mr_comment})
|
||||
if is_temporary:
|
||||
self.temp_comments.append(comment)
|
||||
|
||||
def remove_initial_comment(self):
|
||||
try:
|
||||
for comment in self.temp_comments:
|
||||
comment.delete()
|
||||
except Exception as e:
|
||||
logging.exception(f"Failed to remove temp comments, error: {e}")
|
||||
|
||||
def get_title(self):
|
||||
return self.mr.title
|
||||
|
||||
def get_description(self):
|
||||
return self.mr.description
|
||||
|
||||
def get_languages(self):
|
||||
languages = self.gl.projects.get(self.id_project).languages()
|
||||
return languages
|
||||
|
||||
def get_pr_branch(self):
|
||||
return self.mr.source_branch
|
||||
|
||||
def get_pr_description(self):
|
||||
return self.mr.description
|
||||
|
||||
def _parse_merge_request_url(self, merge_request_url: str) -> Tuple[int, int]:
|
||||
parsed_url = urlparse(merge_request_url)
|
||||
|
||||
path_parts = parsed_url.path.strip('/').split('/')
|
||||
if path_parts[-2] != 'merge_requests':
|
||||
raise ValueError("The provided URL does not appear to be a GitLab merge request URL")
|
||||
|
||||
try:
|
||||
mr_id = int(path_parts[-1])
|
||||
except ValueError as e:
|
||||
raise ValueError("Unable to convert merge request ID to integer") from e
|
||||
|
||||
# Gitlab supports access by both project numeric ID as well as 'namespace/project_name'
|
||||
return "/".join(path_parts[:2]), mr_id
|
||||
|
||||
def _get_merge_request(self):
|
||||
mr = self.gl.projects.get(self.id_project).mergerequests.get(self.id_mr)
|
||||
return mr
|
58
pr_agent/servers/github_action_runner.py
Normal file
58
pr_agent/servers/github_action_runner.py
Normal file
@ -0,0 +1,58 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from pr_agent.config_loader import settings
|
||||
from pr_agent.tools.pr_questions import PRQuestions
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
|
||||
|
||||
async def run_action():
|
||||
GITHUB_EVENT_NAME = os.environ.get('GITHUB_EVENT_NAME', None)
|
||||
if not GITHUB_EVENT_NAME:
|
||||
print("GITHUB_EVENT_NAME not set")
|
||||
return
|
||||
GITHUB_EVENT_PATH = os.environ.get('GITHUB_EVENT_PATH', None)
|
||||
if not GITHUB_EVENT_PATH:
|
||||
print("GITHUB_EVENT_PATH not set")
|
||||
return
|
||||
event_payload = json.load(open(GITHUB_EVENT_PATH, 'r'))
|
||||
RUNNER_DEBUG = os.environ.get('RUNNER_DEBUG', None)
|
||||
if not RUNNER_DEBUG:
|
||||
print("RUNNER_DEBUG not set")
|
||||
OPENAI_KEY = os.environ.get('OPENAI_KEY', None)
|
||||
if not OPENAI_KEY:
|
||||
print("OPENAI_KEY not set")
|
||||
return
|
||||
OPENAI_ORG = os.environ.get('OPENAI_ORG', None)
|
||||
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', None)
|
||||
if not GITHUB_TOKEN:
|
||||
print("GITHUB_TOKEN not set")
|
||||
return
|
||||
settings.set("OPENAI.KEY", OPENAI_KEY)
|
||||
if OPENAI_ORG:
|
||||
settings.set("OPENAI.ORG", OPENAI_ORG)
|
||||
settings.set("GITHUB.USER_TOKEN", GITHUB_TOKEN)
|
||||
settings.set("GITHUB.DEPLOYMENT_TYPE", "user")
|
||||
if GITHUB_EVENT_NAME == "pull_request":
|
||||
action = event_payload.get("action", None)
|
||||
if action in ["opened", "reopened"]:
|
||||
pr_url = event_payload.get("pull_request", {}).get("url", None)
|
||||
if pr_url:
|
||||
await PRReviewer(pr_url).review()
|
||||
|
||||
elif GITHUB_EVENT_NAME == "issue_comment":
|
||||
action = event_payload.get("action", None)
|
||||
if action in ["created", "edited"]:
|
||||
comment_body = event_payload.get("comment", {}).get("body", None)
|
||||
if comment_body:
|
||||
pr_url = event_payload.get("issue", {}).get("pull_request", {}).get("url", None)
|
||||
if pr_url:
|
||||
if comment_body.strip().lower() == "review":
|
||||
await PRReviewer(pr_url).review()
|
||||
elif comment_body.lstrip().lower().startswith("answer"):
|
||||
await PRQuestions(pr_url, comment_body).answer()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(run_action())
|
@ -35,7 +35,8 @@ async def handle_github_webhooks(request: Request, response: Response):
|
||||
async def handle_request(body):
|
||||
action = body.get("action", None)
|
||||
installation_id = body.get("installation", {}).get("id", None)
|
||||
agent = PRAgent(installation_id)
|
||||
settings.set("GITHUB.INSTALLATION_ID", installation_id)
|
||||
agent = PRAgent()
|
||||
if action == 'created':
|
||||
if "comment" not in body:
|
||||
return {}
|
||||
@ -66,8 +67,8 @@ async def root():
|
||||
|
||||
|
||||
def start():
|
||||
if settings.get("GITHUB.DEPLOYMENT_TYPE", "user") != "app":
|
||||
raise Exception("Please set deployment type to app in .secrets.toml file")
|
||||
# Override the deployment type to app
|
||||
settings.set("GITHUB.DEPLOYMENT_TYPE", "app")
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
@ -76,7 +76,8 @@ async def polling_loop():
|
||||
if comment['user']['login'] == user_id:
|
||||
continue
|
||||
comment_body = comment['body'] if 'body' in comment else ''
|
||||
commenter_github_user = comment['user']['login'] if 'user' in comment else ''
|
||||
commenter_github_user = comment['user']['login'] \
|
||||
if 'user' in comment else ''
|
||||
logging.info(f"Commenter: {commenter_github_user}\nComment: {comment_body}")
|
||||
user_tag = "@" + user_id
|
||||
if user_tag not in comment_body:
|
||||
|
64
pr_agent/servers/gitlab_polling.py
Normal file
64
pr_agent/servers/gitlab_polling.py
Normal file
@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import gitlab
|
||||
|
||||
from pr_agent.agent.pr_agent import PRAgent
|
||||
from pr_agent.config_loader import settings
|
||||
|
||||
gl = gitlab.Gitlab(
|
||||
settings.get("GITLAB.URL"),
|
||||
private_token=settings.get("GITLAB.PERSONAL_ACCESS_TOKEN")
|
||||
)
|
||||
|
||||
# Set the list of projects to monitor
|
||||
projects_to_monitor = settings.get("GITLAB.PROJECTS_TO_MONITOR")
|
||||
magic_word = settings.get("GITLAB.MAGIC_WORD")
|
||||
|
||||
# Hold the previous seen comments
|
||||
previous_comments = set()
|
||||
|
||||
|
||||
def check_comments():
|
||||
print('Polling')
|
||||
new_comments = {}
|
||||
for project in projects_to_monitor:
|
||||
project = gl.projects.get(project)
|
||||
merge_requests = project.mergerequests.list(state='opened')
|
||||
for mr in merge_requests:
|
||||
notes = mr.notes.list(get_all=True)
|
||||
for note in notes:
|
||||
if note.id not in previous_comments and note.body.startswith(magic_word):
|
||||
new_comments[note.id] = dict(
|
||||
body=note.body[len(magic_word):],
|
||||
project=project.name,
|
||||
mr=mr
|
||||
)
|
||||
previous_comments.add(note.id)
|
||||
print(f"New comment in project {project.name}, merge request {mr.title}: {note.body}")
|
||||
|
||||
return new_comments
|
||||
|
||||
|
||||
def handle_new_comments(new_comments):
|
||||
print('Handling new comments')
|
||||
agent = PRAgent()
|
||||
for _, comment in new_comments.items():
|
||||
print(f"Handling comment: {comment['body']}")
|
||||
asyncio.run(agent.handle_request(comment['mr'].web_url, comment['body']))
|
||||
|
||||
|
||||
def run():
|
||||
assert settings.get('CONFIG.GIT_PROVIDER') == 'gitlab', 'This script is only for GitLab'
|
||||
# Initial run to populate previous_comments
|
||||
check_comments()
|
||||
|
||||
# Run the check every minute
|
||||
while True:
|
||||
time.sleep(settings.get("GITLAB.POLLING_INTERVAL_SECONDS"))
|
||||
new_comments = check_comments()
|
||||
if new_comments:
|
||||
handle_new_comments(new_comments)
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
@ -1,5 +1,5 @@
|
||||
# QUICKSTART:
|
||||
# Copy this file to .secrets in the same folder.
|
||||
# Copy this file to .secrets.toml in the same folder.
|
||||
# The minimum workable settings - set openai.key to your API key.
|
||||
# Set github.deployment_type to "user" and github.user_token to your GitHub personal access token.
|
||||
# This will allow you to run the CLI scripts in the scripts/ folder and the github_polling server.
|
||||
@ -9,11 +9,13 @@
|
||||
[openai]
|
||||
key = "<API_KEY>" # Acquire through https://platform.openai.com
|
||||
org = "<ORGANIZATION>" # Optional, may be commented out.
|
||||
# Uncomment the following for Azure OpenAI
|
||||
#api_type = "azure"
|
||||
#api_version = '2023-05-15' # Check Azure documentation for the current API version
|
||||
#api_base = "<API_BASE>" # The base URL for your Azure OpenAI resource. e.g. "https://<your resource name>.openai.azure.com"
|
||||
#deployment_id = "<DEPLOYMENT_ID>" # The deployment name you chose when you deployed the engine
|
||||
|
||||
[github]
|
||||
# The type of deployment to create. Valid values are 'app' or 'user'.
|
||||
deployment_type = "user"
|
||||
|
||||
# ---- Set the following only for deployment type == "user"
|
||||
user_token = "<TOKEN>" # A GitHub personal access token with 'repo' scope.
|
||||
|
||||
@ -25,3 +27,8 @@ private_key = """\
|
||||
"""
|
||||
app_id = 123456 # The GitHub App ID, replace with your own.
|
||||
webhook_secret = "<WEBHOOK SECRET>" # Optional, may be commented out.
|
||||
|
||||
[gitlab]
|
||||
# Gitlab personal access token
|
||||
personal_access_token = ""
|
||||
|
||||
|
@ -2,14 +2,30 @@
|
||||
model="gpt-4-0613"
|
||||
git_provider="github"
|
||||
publish_review=true
|
||||
verbosity_level=0 # 0,1,2
|
||||
verbosity_level=0 # 0,1,2
|
||||
|
||||
[pr_reviewer]
|
||||
require_minimal_and_focused_review=true
|
||||
require_focused_review=true
|
||||
require_tests_review=true
|
||||
require_security_review=true
|
||||
extended_code_suggestions=false
|
||||
num_code_suggestions=4
|
||||
inline_code_comments = true
|
||||
|
||||
[pr_questions]
|
||||
|
||||
[pr_questions]
|
||||
[github]
|
||||
# The type of deployment to create. Valid values are 'app' or 'user'.
|
||||
deployment_type = "user"
|
||||
|
||||
[gitlab]
|
||||
# URL to the gitlab service
|
||||
url = "https://gitlab.com"
|
||||
|
||||
# Polling (either project id or namespace/project_name) syntax can be used
|
||||
projects_to_monitor = ['org_name/repo_name']
|
||||
|
||||
# Polling trigger
|
||||
magic_word = "AutoReview"
|
||||
|
||||
# Polling interval
|
||||
polling_interval_seconds = 30
|
||||
|
45
pr_agent/settings/pr_description_prompts.toml
Normal file
45
pr_agent/settings/pr_description_prompts.toml
Normal file
@ -0,0 +1,45 @@
|
||||
[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).
|
||||
|
||||
You must use the following JSON schema to format your answer:
|
||||
```json
|
||||
{
|
||||
"PR Title": {
|
||||
"type": "string",
|
||||
"description": "an informative title for the PR, describing its main theme"
|
||||
},
|
||||
"Type of PR": {
|
||||
"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": "string",
|
||||
"description": "a walkthrough of the PR changes. Review main files, in bullet points, and shortly describe the changes in each file (up to 10 most important files). Format: -`filename`: description of changes\n..."
|
||||
}
|
||||
}
|
||||
|
||||
Don't repeat the prompt in the answer, and avoid outputting the 'type' and 'description' fields.
|
||||
"""
|
||||
|
||||
user="""PR Info:
|
||||
Branch: '{{branch}}'
|
||||
{%- if language %}
|
||||
Main language: {{language}}
|
||||
{%- 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, and ' ' (a space) for unchanged lines.
|
||||
|
||||
Response (should be a valid JSON, and nothing else):
|
||||
```json
|
||||
"""
|
@ -3,9 +3,6 @@ system="""You are CodiumAI-PR-Reviewer, a language model designed to review git
|
||||
Your task is to provide constructive and concise feedback for the PR, and also provide meaningfull code suggestions to improve the new PR code (the '+' lines).
|
||||
- Provide up to {{ num_code_suggestions }} code suggestions.
|
||||
- Try to focus on important suggestions like fixing code problems, issues and bugs. As a second priority, provide suggestions for meaningfull code improvements, like performance, vulnerability, modularity, and best practices.
|
||||
{%- if extended_code_suggestions %}
|
||||
- For each suggestion, provide a short and concise code snippet to illustrate the existing code, and the improved code.
|
||||
{%- endif %}
|
||||
- Make sure not to provide suggestion repeating modifications already implemented in the new PR code (the '+' lines).
|
||||
|
||||
You must use the following JSON schema to format your answer:
|
||||
@ -16,10 +13,6 @@ You must use the following JSON schema to format your answer:
|
||||
"type": "string",
|
||||
"description": "a short explanation of the PR"
|
||||
},
|
||||
"Description and title": {
|
||||
"type": "string",
|
||||
"description": "yes\\no question: does this PR have a relevant description and title"
|
||||
},
|
||||
"Type of PR": {
|
||||
"type": "string",
|
||||
"enum": ["Bug fix", "Tests", "Bug fix with tests", "Refactoring", "Enhancement", "Documentation", "Other"]
|
||||
@ -30,58 +23,35 @@ You must use the following JSON schema to format your answer:
|
||||
"description": "yes\\no question: does this PR have relevant tests ?"
|
||||
},
|
||||
{%- endif %}
|
||||
{%- if require_minimal_and_focused %}
|
||||
"Minimal and focused": {
|
||||
{%- if require_focused %}
|
||||
"Focused PR": {
|
||||
"type": "string",
|
||||
"description": "is this PR as minimal and focused as possible, with all code changes centered around a single coherent theme, described in the PR description and title ?" Make sure to explain your answer"
|
||||
"description": "Is this a focused PR, in the sense that it has a clear and coherent title and description, and all PR code diff changes are properly derived from the title and description? Explain your response."
|
||||
}
|
||||
},
|
||||
{%- endif %}
|
||||
"PR Feedback": {
|
||||
"General PR suggestions": {
|
||||
"type": "string",
|
||||
"description": "important suggestions for the contributors and maintainers of this PR, may include overall structure, primary purpose and best practices. consider using specific filenames, classes and functions names. explain yourself!"
|
||||
"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. Explain your suggestions."
|
||||
},
|
||||
"Code suggestions": {
|
||||
"type": "array",
|
||||
"maxItems": {{ num_code_suggestions }},
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"suggestion number": {
|
||||
"type": "int",
|
||||
"description": "suggestion number, starting from 1"
|
||||
},
|
||||
"relevant file": {
|
||||
"type": "string",
|
||||
"description": "the relevant file name"
|
||||
"description": "the relevant file full path"
|
||||
},
|
||||
"suggestion content": {
|
||||
"type": "string",
|
||||
{%- if extended_code_suggestions %}
|
||||
"description": "a concrete suggestion for meaningfully improving the new PR code. Don't repeat previous suggestions. 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.
|
||||
{%- else %}
|
||||
"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.
|
||||
{%- endif %}
|
||||
},
|
||||
{%- if extended_code_suggestions %}
|
||||
"why": {
|
||||
"relevant line in file": {
|
||||
"type": "string",
|
||||
"description": "shortly explain why this suggestion is important"
|
||||
},
|
||||
"code example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"before code": {
|
||||
"type": "string",
|
||||
"description": "Short and concise code snippet, to illustrate the existing code"
|
||||
},
|
||||
"after code": {
|
||||
"type": "string",
|
||||
"description": "Short and concise code snippet, to illustrate the improved code"
|
||||
}
|
||||
}
|
||||
"description": "an authentic single code line from the PR git diff section, to which the suggestion applies."
|
||||
}
|
||||
{%- endif %}
|
||||
}
|
||||
},
|
||||
{%- if require_security %}
|
||||
@ -101,13 +71,12 @@ Example output:
|
||||
"PR Analysis":
|
||||
{
|
||||
"Main theme": "xxx",
|
||||
"Description and title": "Yes",
|
||||
"Type of PR": "Bug fix",
|
||||
{%- if require_tests %}
|
||||
"Relevant tests added": "No",
|
||||
{%- endif %}
|
||||
{%- if require_minimal_and_focused %}
|
||||
"Minimal and focused": "yes\\no, because ..."
|
||||
{%- if require_focused %}
|
||||
"Focused PR": "yes\\no, because ..."
|
||||
{%- endif %}
|
||||
},
|
||||
"PR Feedback":
|
||||
@ -115,17 +84,9 @@ Example output:
|
||||
"General PR suggestions": "..., `xxx`...",
|
||||
"Code suggestions": [
|
||||
{
|
||||
"suggestion number": 1,
|
||||
"relevant file": "xxx.py",
|
||||
"relevant file": "directory/xxx.py",
|
||||
"suggestion content": "xxx [important]",
|
||||
{%- if extended_code_suggestions %}
|
||||
"why": "xxx",
|
||||
"code example":
|
||||
{
|
||||
"before code": "xxx",
|
||||
"after code": "xxx"
|
||||
}
|
||||
{%- endif %}
|
||||
"relevant line in file": "xxx",
|
||||
},
|
||||
...
|
||||
]
|
||||
|
83
pr_agent/tools/pr_description.py
Normal file
83
pr_agent/tools/pr_description.py
Normal file
@ -0,0 +1,83 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
|
||||
from pr_agent.algo.ai_handler import AiHandler
|
||||
from pr_agent.algo.pr_processing import get_pr_diff
|
||||
from pr_agent.algo.token_handler import TokenHandler
|
||||
from pr_agent.algo.utils import convert_to_markdown
|
||||
from pr_agent.config_loader import settings
|
||||
from pr_agent.git_providers import get_git_provider
|
||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||
|
||||
|
||||
class PRDescription:
|
||||
def __init__(self, pr_url: str):
|
||||
self.git_provider = get_git_provider()(pr_url)
|
||||
self.main_pr_language = get_main_pr_language(
|
||||
self.git_provider.get_languages(), self.git_provider.get_files()
|
||||
)
|
||||
self.ai_handler = AiHandler()
|
||||
self.vars = {
|
||||
"title": self.git_provider.pr.title,
|
||||
"branch": self.git_provider.get_pr_branch(),
|
||||
"description": self.git_provider.get_description(),
|
||||
"language": self.main_pr_language,
|
||||
"diff": "", # empty diff for initial calculation
|
||||
}
|
||||
self.token_handler = TokenHandler(self.git_provider.pr,
|
||||
self.vars,
|
||||
settings.pr_description_prompt.system,
|
||||
settings.pr_description_prompt.user)
|
||||
self.patches_diff = None
|
||||
self.prediction = None
|
||||
|
||||
async def describe(self):
|
||||
logging.info('Answering a PR question...')
|
||||
if settings.config.publish_review:
|
||||
self.git_provider.publish_comment("Preparing pr description...", is_temporary=True)
|
||||
logging.info('Getting PR diff...')
|
||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler)
|
||||
logging.info('Getting AI prediction...')
|
||||
self.prediction = await self._get_prediction()
|
||||
logging.info('Preparing answer...')
|
||||
pr_title, pr_body = self._prepare_pr_answer()
|
||||
if settings.config.publish_review:
|
||||
logging.info('Pushing answer...')
|
||||
self.git_provider.publish_description(pr_title, pr_body)
|
||||
self.git_provider.remove_initial_comment()
|
||||
return ""
|
||||
|
||||
async def _get_prediction(self):
|
||||
variables = copy.deepcopy(self.vars)
|
||||
variables["diff"] = self.patches_diff # update diff
|
||||
environment = Environment(undefined=StrictUndefined)
|
||||
system_prompt = environment.from_string(settings.pr_description_prompt.system).render(variables)
|
||||
user_prompt = environment.from_string(settings.pr_description_prompt.user).render(variables)
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
||||
model = settings.config.model
|
||||
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_answer(self):
|
||||
data = json.loads(self.prediction)
|
||||
pr_body = ""
|
||||
# for key, value in data.items():
|
||||
# markdown_text += f"## {key}\n\n"
|
||||
# markdown_text += f"{value}\n\n"
|
||||
title = data['PR Title']
|
||||
del data['PR Title']
|
||||
for key, value in data.items():
|
||||
pr_body += f"{key}:\n"
|
||||
if 'walkthrough' in key.lower():
|
||||
pr_body += f"{value}\n"
|
||||
else:
|
||||
pr_body += f"**{value}**\n\n___\n"
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.info(f"title:\n{title}\n{pr_body}")
|
||||
return title, pr_body
|
@ -1,6 +1,5 @@
|
||||
import copy
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
|
||||
@ -9,21 +8,23 @@ from pr_agent.algo.pr_processing import get_pr_diff
|
||||
from pr_agent.algo.token_handler import TokenHandler
|
||||
from pr_agent.config_loader import settings
|
||||
from pr_agent.git_providers import get_git_provider
|
||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||
|
||||
|
||||
class PRQuestions:
|
||||
def __init__(self, pr_url: str, question_str: str, installation_id: Optional[int] = None):
|
||||
self.git_provider = get_git_provider()(pr_url, installation_id)
|
||||
self.main_pr_language = self.git_provider.get_main_pr_language()
|
||||
self.installation_id = installation_id
|
||||
def __init__(self, pr_url: str, question_str: str):
|
||||
self.git_provider = get_git_provider()(pr_url)
|
||||
self.main_pr_language = get_main_pr_language(
|
||||
self.git_provider.get_languages(), self.git_provider.get_files()
|
||||
)
|
||||
self.ai_handler = AiHandler()
|
||||
self.question_str = question_str
|
||||
self.vars = {
|
||||
"title": self.git_provider.pr.title,
|
||||
"branch": self.git_provider.get_pr_branch(),
|
||||
"description": self.git_provider.pr.body,
|
||||
"language": self.git_provider.get_main_pr_language(),
|
||||
"diff": "", # empty diff for initial calculation
|
||||
"description": self.git_provider.get_description(),
|
||||
"language": self.main_pr_language,
|
||||
"diff": "", # empty diff for initial calculation
|
||||
"questions": self.question_str,
|
||||
}
|
||||
self.token_handler = TokenHandler(self.git_provider.pr,
|
||||
|
@ -1,24 +1,25 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
|
||||
from pr_agent.algo.ai_handler import AiHandler
|
||||
from pr_agent.algo.pr_processing import get_pr_diff
|
||||
from pr_agent.algo.token_handler import TokenHandler
|
||||
from pr_agent.algo.utils import convert_to_markdown
|
||||
from pr_agent.algo.utils import convert_to_markdown, try_fix_json
|
||||
from pr_agent.config_loader import settings
|
||||
from pr_agent.git_providers import get_git_provider
|
||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||
|
||||
|
||||
class PRReviewer:
|
||||
def __init__(self, pr_url: str, installation_id: Optional[int] = None, cli_mode=False):
|
||||
def __init__(self, pr_url: str, cli_mode=False):
|
||||
|
||||
self.git_provider = get_git_provider()(pr_url, installation_id)
|
||||
self.main_language = self.git_provider.get_main_pr_language()
|
||||
self.installation_id = installation_id
|
||||
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
|
||||
@ -26,13 +27,12 @@ class PRReviewer:
|
||||
self.vars = {
|
||||
"title": self.git_provider.pr.title,
|
||||
"branch": self.git_provider.get_pr_branch(),
|
||||
"description": self.git_provider.pr.body,
|
||||
"description": self.git_provider.get_pr_description(),
|
||||
"language": self.main_language,
|
||||
"diff": "", # empty diff for initial calculation
|
||||
"require_tests": settings.pr_reviewer.require_tests_review,
|
||||
"require_security": settings.pr_reviewer.require_security_review,
|
||||
"require_minimal_and_focused": settings.pr_reviewer.require_minimal_and_focused_review,
|
||||
'extended_code_suggestions': settings.pr_reviewer.extended_code_suggestions,
|
||||
"require_focused": settings.pr_reviewer.require_focused_review,
|
||||
'num_code_suggestions': settings.pr_reviewer.num_code_suggestions,
|
||||
}
|
||||
self.token_handler = TokenHandler(self.git_provider.pr,
|
||||
@ -54,6 +54,9 @@ class PRReviewer:
|
||||
logging.info('Pushing PR review...')
|
||||
self.git_provider.publish_comment(pr_comment)
|
||||
self.git_provider.remove_initial_comment()
|
||||
if settings.pr_reviewer.inline_code_comments:
|
||||
logging.info('Pushing inline code comments...')
|
||||
self._publish_inline_code_comments()
|
||||
return ""
|
||||
|
||||
async def _get_prediction(self):
|
||||
@ -68,11 +71,7 @@ class PRReviewer:
|
||||
model = settings.config.model
|
||||
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
||||
system=system_prompt, user=user_prompt)
|
||||
try:
|
||||
json.loads(response)
|
||||
except json.decoder.JSONDecodeError:
|
||||
logging.warning("Could not decode JSON")
|
||||
response = {}
|
||||
|
||||
return response
|
||||
|
||||
def _prepare_pr_review(self) -> str:
|
||||
@ -80,8 +79,7 @@ class PRReviewer:
|
||||
try:
|
||||
data = json.loads(review)
|
||||
except json.decoder.JSONDecodeError:
|
||||
logging.error("Unable to decode JSON response from AI")
|
||||
data = {}
|
||||
data = try_fix_json(review)
|
||||
|
||||
# reordering for nicer display
|
||||
if 'PR Feedback' in data:
|
||||
@ -90,6 +88,9 @@ class PRReviewer:
|
||||
del data['PR Feedback']['Security concerns']
|
||||
data['PR Analysis']['Security concerns'] = val
|
||||
|
||||
if settings.config.git_provider == 'github' and settings.pr_reviewer.inline_code_comments:
|
||||
del data['PR Feedback']['Code suggestions']
|
||||
|
||||
markdown_text = convert_to_markdown(data)
|
||||
user = self.git_provider.get_user_id()
|
||||
|
||||
@ -108,3 +109,36 @@ class PRReviewer:
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.info(f"Markdown response:\n{markdown_text}")
|
||||
return markdown_text
|
||||
|
||||
def _publish_inline_code_comments(self):
|
||||
if settings.config.git_provider != 'github': # inline comments are currently only supported for github
|
||||
return
|
||||
|
||||
review = self.prediction.strip()
|
||||
try:
|
||||
data = json.loads(review)
|
||||
except json.decoder.JSONDecodeError:
|
||||
data = try_fix_json(review)
|
||||
|
||||
pr = self.git_provider.pr
|
||||
last_commit_id = list(pr.get_commits())[-1]
|
||||
files = list(self.git_provider.get_diff_files())
|
||||
|
||||
for d in data['PR Feedback']['Code suggestions']:
|
||||
relevant_file = d['relevant file'].strip()
|
||||
relevant_line_in_file = d['relevant line in file'].strip()
|
||||
content = d['suggestion content']
|
||||
position = -1
|
||||
for file in files:
|
||||
if file.filename.strip() == relevant_file:
|
||||
patch = file.patch
|
||||
patch_lines = patch.splitlines()
|
||||
for i, line in enumerate(patch_lines):
|
||||
if relevant_line_in_file in line:
|
||||
position = i
|
||||
if position == -1:
|
||||
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
||||
else:
|
||||
body = content
|
||||
path = relevant_file.strip()
|
||||
pr.create_review_comment(body=body, commit_id=last_commit_id, path=path, position=position)
|
@ -6,3 +6,6 @@ openai==0.27.8
|
||||
Jinja2==3.1.2
|
||||
tiktoken==0.4.0
|
||||
uvicorn==0.22.0
|
||||
python-gitlab==3.15.0
|
||||
pytest~=7.4.0
|
||||
aiohttp~=3.8.4
|
||||
|
@ -46,22 +46,19 @@ class TestConvertToMarkdown:
|
||||
def test_simple_dictionary_input(self):
|
||||
input_data = {
|
||||
'Main theme': 'Test',
|
||||
'Description and title': 'Test description',
|
||||
'Type of PR': 'Test type',
|
||||
'Relevant tests added': 'no',
|
||||
'Unrelated changes': 'n/a', # won't be included in the output
|
||||
'Minimal and focused': 'Yes',
|
||||
'Focused PR': 'Yes',
|
||||
'General PR suggestions': 'general suggestion...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'Suggestion number': 1,
|
||||
'Code example': {
|
||||
'Before': 'Code before',
|
||||
'After': 'Code after'
|
||||
}
|
||||
},
|
||||
{
|
||||
'Suggestion number': 2,
|
||||
'Code example': {
|
||||
'Before': 'Code before 2',
|
||||
'After': 'Code after 2'
|
||||
@ -71,15 +68,13 @@ class TestConvertToMarkdown:
|
||||
}
|
||||
expected_output = """\
|
||||
- 🎯 **Main theme:** Test
|
||||
- 🔍 **Description and title:** Test description
|
||||
- 📌 **Type of PR:** Test type
|
||||
- 🧪 **Relevant tests added:** no
|
||||
- ✨ **Minimal and focused:** Yes
|
||||
- ✨ **Focused PR:** Yes
|
||||
- 💡 **General PR suggestions:** general suggestion...
|
||||
|
||||
- 🤖 **Code suggestions:**
|
||||
|
||||
- **suggestion 1:**
|
||||
- **Code example:**
|
||||
- **Before:**
|
||||
```
|
||||
@ -90,7 +85,6 @@ class TestConvertToMarkdown:
|
||||
Code after
|
||||
```
|
||||
|
||||
- **suggestion 2:**
|
||||
- **Code example:**
|
||||
- **Before:**
|
||||
```
|
||||
@ -112,11 +106,10 @@ class TestConvertToMarkdown:
|
||||
def test_dictionary_input_containing_only_empty_dictionaries(self):
|
||||
input_data = {
|
||||
'Main theme': {},
|
||||
'Description and title': {},
|
||||
'Type of PR': {},
|
||||
'Relevant tests added': {},
|
||||
'Unrelated changes': {},
|
||||
'Minimal and focused': {},
|
||||
'Focused PR': {},
|
||||
'General PR suggestions': {},
|
||||
'Code suggestions': {}
|
||||
}
|
||||
|
83
tests/unit/test_fix_output.py
Normal file
83
tests/unit/test_fix_output.py
Normal file
@ -0,0 +1,83 @@
|
||||
# Generated by CodiumAI
|
||||
from pr_agent.algo.utils import try_fix_json
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
class TestTryFixJson:
|
||||
# Tests that JSON with complete 'Code suggestions' section returns expected output
|
||||
def test_incomplete_code_suggestions(self):
|
||||
review = '{"PR Analysis": {"Main theme": "xxx", "Type of PR": "Bug fix"}, "PR Feedback": {"General PR suggestions": "..., `xxx`...", "Code suggestions": [{"relevant file": "xxx.py", "suggestion content": "xxx [important]"}, {"suggestion number": 2, "relevant file": "yyy.py", "suggestion content": "yyy [incomp...'
|
||||
expected_output = {
|
||||
'PR Analysis': {
|
||||
'Main theme': 'xxx',
|
||||
'Type of PR': 'Bug fix'
|
||||
},
|
||||
'PR Feedback': {
|
||||
'General PR suggestions': '..., `xxx`...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'relevant file': 'xxx.py',
|
||||
'suggestion content': 'xxx [important]'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert try_fix_json(review) == expected_output
|
||||
|
||||
def test_incomplete_code_suggestions_new_line(self):
|
||||
review = '{"PR Analysis": {"Main theme": "xxx", "Type of PR": "Bug fix"}, "PR Feedback": {"General PR suggestions": "..., `xxx`...", "Code suggestions": [{"relevant file": "xxx.py", "suggestion content": "xxx [important]"} \n\t, {"suggestion number": 2, "relevant file": "yyy.py", "suggestion content": "yyy [incomp...'
|
||||
expected_output = {
|
||||
'PR Analysis': {
|
||||
'Main theme': 'xxx',
|
||||
'Type of PR': 'Bug fix'
|
||||
},
|
||||
'PR Feedback': {
|
||||
'General PR suggestions': '..., `xxx`...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'relevant file': 'xxx.py',
|
||||
'suggestion content': 'xxx [important]'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert try_fix_json(review) == expected_output
|
||||
|
||||
def test_incomplete_code_suggestions_many_close_brackets(self):
|
||||
review = '{"PR Analysis": {"Main theme": "xxx", "Type of PR": "Bug fix"}, "PR Feedback": {"General PR suggestions": "..., `xxx`...", "Code suggestions": [{"relevant file": "xxx.py", "suggestion content": "xxx [important]"} \n, {"suggestion number": 2, "relevant file": "yyy.py", "suggestion content": "yyy }, [}\n ,incomp.} ,..'
|
||||
expected_output = {
|
||||
'PR Analysis': {
|
||||
'Main theme': 'xxx',
|
||||
'Type of PR': 'Bug fix'
|
||||
},
|
||||
'PR Feedback': {
|
||||
'General PR suggestions': '..., `xxx`...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'relevant file': 'xxx.py',
|
||||
'suggestion content': 'xxx [important]'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert try_fix_json(review) == expected_output
|
||||
|
||||
def test_incomplete_code_suggestions_relevant_file(self):
|
||||
review = '{"PR Analysis": {"Main theme": "xxx", "Type of PR": "Bug fix"}, "PR Feedback": {"General PR suggestions": "..., `xxx`...", "Code suggestions": [{"relevant file": "xxx.py", "suggestion content": "xxx [important]"}, {"suggestion number": 2, "relevant file": "yyy.p'
|
||||
expected_output = {
|
||||
'PR Analysis': {
|
||||
'Main theme': 'xxx',
|
||||
'Type of PR': 'Bug fix'
|
||||
},
|
||||
'PR Feedback': {
|
||||
'General PR suggestions': '..., `xxx`...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'relevant file': 'xxx.py',
|
||||
'suggestion content': 'xxx [important]'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert try_fix_json(review) == expected_output
|
@ -1,15 +1,15 @@
|
||||
|
||||
# Generated by CodiumAI
|
||||
|
||||
from pr_agent.algo.language_handler import sort_files_by_main_languages
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
"""
|
||||
Code Analysis
|
||||
|
||||
Objective:
|
||||
The objective of the function is to sort a list of files by their main language, putting the files that are in the main language first and the rest of the files after. It takes in a dictionary of languages and their sizes, and a list of files.
|
||||
The objective of the function is to sort a list of files by their main language, putting the files that are in the main
|
||||
language first and the rest of the files after. It takes in a dictionary of languages and their sizes, and a list of
|
||||
files.
|
||||
|
||||
Inputs:
|
||||
- languages: a dictionary containing the languages and their sizes
|
||||
@ -33,6 +33,8 @@ Additional aspects:
|
||||
- The function uses the filter_bad_extensions function to filter out files with bad extensions
|
||||
- The function uses a rest_files dictionary to store the files that do not belong to any of the main extensions
|
||||
"""
|
||||
|
||||
|
||||
class TestSortFilesByMainLanguages:
|
||||
# Tests that files are sorted by main language, with files in main language first and the rest after
|
||||
def test_happy_path_sort_files_by_main_languages(self):
|
||||
@ -118,4 +120,4 @@ class TestSortFilesByMainLanguages:
|
||||
{'language': 'C++', 'files': [files[2], files[7]]},
|
||||
{'language': 'Other', 'files': []}
|
||||
]
|
||||
assert sort_files_by_main_languages(languages, files) == expected_output
|
||||
assert sort_files_by_main_languages(languages, files) == expected_output
|
||||
|
@ -41,14 +41,6 @@ class TestParseCodeSuggestion:
|
||||
expected_output = "\n" # modified to expect a newline character
|
||||
assert parse_code_suggestion(input_data) == expected_output
|
||||
|
||||
# Tests that function returns correct output when 'suggestion number' key has a non-integer value
|
||||
def test_non_integer_suggestion_number(self):
|
||||
input_data = {
|
||||
"Suggestion number": "one",
|
||||
"Description": "This is a suggestion"
|
||||
}
|
||||
expected_output = "- **suggestion one:**\n - **Description:** This is a suggestion\n\n"
|
||||
assert parse_code_suggestion(input_data) == expected_output
|
||||
|
||||
# Tests that function returns correct output when 'before' or 'after' key has a non-string value
|
||||
def test_non_string_before_or_after(self):
|
||||
@ -64,19 +56,17 @@ class TestParseCodeSuggestion:
|
||||
# Tests that function returns correct output when input dictionary does not have 'code example' key
|
||||
def test_no_code_example_key(self):
|
||||
code_suggestions = {
|
||||
'suggestion number': 1,
|
||||
'suggestion': 'Suggestion 1',
|
||||
'description': 'Description 1',
|
||||
'before': 'Before 1',
|
||||
'after': 'After 1'
|
||||
}
|
||||
expected_output = "- **suggestion 1:**\n - **suggestion:** Suggestion 1\n - **description:** Description 1\n - **before:** Before 1\n - **after:** After 1\n\n" # noqa: E501
|
||||
expected_output = " **suggestion:** Suggestion 1\n **description:** Description 1\n **before:** Before 1\n **after:** After 1\n\n" # noqa: E501
|
||||
assert parse_code_suggestion(code_suggestions) == expected_output
|
||||
|
||||
# Tests that function returns correct output when input dictionary has 'code example' key
|
||||
def test_with_code_example_key(self):
|
||||
code_suggestions = {
|
||||
'suggestion number': 2,
|
||||
'suggestion': 'Suggestion 2',
|
||||
'description': 'Description 2',
|
||||
'code example': {
|
||||
@ -84,5 +74,5 @@ class TestParseCodeSuggestion:
|
||||
'after': 'After 2'
|
||||
}
|
||||
}
|
||||
expected_output = "- **suggestion 2:**\n - **suggestion:** Suggestion 2\n - **description:** Description 2\n - **code example:**\n - **before:**\n ```\n Before 2\n ```\n - **after:**\n ```\n After 2\n ```\n\n" # noqa: E501
|
||||
expected_output = " **suggestion:** Suggestion 2\n **description:** Description 2\n - **code example:**\n - **before:**\n ```\n Before 2\n ```\n - **after:**\n ```\n After 2\n ```\n\n" # noqa: E501
|
||||
assert parse_code_suggestion(code_suggestions) == expected_output
|
||||
|
Reference in New Issue
Block a user