Compare commits
98 Commits
feature/su
...
hl/refacto
Author | SHA1 | Date | |
---|---|---|---|
dbf8142fe0 | |||
bacf6c96c2 | |||
c9d49da8f7 | |||
7b22edac60 | |||
fe98f67e08 | |||
0b1edd9716 | |||
559b160886 | |||
571b8769ac | |||
e4bd2148ce | |||
1637bd8774 | |||
ce33582d3d | |||
bc6b592fd9 | |||
24ae6b966f | |||
4cacb07ec2 | |||
2371a9b041 | |||
5b7403ae80 | |||
e979b8643d | |||
05b4f167a3 | |||
2c4245e023 | |||
d54ee252ee | |||
85eec0b98c | |||
41a988d99a | |||
448da3d481 | |||
b030299547 | |||
5bdbfda1e2 | |||
047cfb21f3 | |||
35a2497a38 | |||
99630f83c2 | |||
1757f2707c | |||
66c44d715c | |||
8f7855013a | |||
e200be4e57 | |||
d0b734bc91 | |||
399d5c5c5d | |||
1b88049cb0 | |||
0304bf05c1 | |||
94173cbb06 | |||
75447280e4 | |||
5edff8b7e4 | |||
487351d343 | |||
93311a9d9b | |||
704030230f | |||
60bce8f049 | |||
e394cb7ddb | |||
a0e4fb01af | |||
eb9190efa1 | |||
8cc37d6f59 | |||
6cc9fe3d06 | |||
0acf423450 | |||
7958786b4c | |||
719f3a9dd8 | |||
71efd84113 | |||
25e46a99fd | |||
2531849b73 | |||
19f11f99ce | |||
87f978e816 | |||
7488eb8c9e | |||
b3e79ed677 | |||
5d2fe07bf7 | |||
84bf95e9ab | |||
4f4989af8c | |||
0a4a604c28 | |||
23a249ccdb | |||
4a6bf4c55a | |||
3f75b14ba3 | |||
ae9cedd50d | |||
ae63833043 | |||
da6828ad87 | |||
ea1cd7ae45 | |||
1c1aad2806 | |||
f466d79031 | |||
e2323dfb9f | |||
e51e443adc | |||
f6d4a214ca | |||
4bb46d9faa | |||
f337d76af6 | |||
4e59693c76 | |||
4033303c1f | |||
38c8d187d2 | |||
f8ddfd2f25 | |||
4b4fda37a6 | |||
9ca6b789a7 | |||
0f73f5f906 | |||
055a8ea859 | |||
5742a9be1e | |||
914cc6639a | |||
f34cda126a | |||
dece20c984 | |||
94c1f430af | |||
9fadde388b | |||
d1b6b3bc95 | |||
f57d58ee7d | |||
77a451ada0 | |||
4b8420aa16 | |||
25bc69f70e | |||
e2faf117c5 | |||
aaff03bb60 | |||
973cb2de1c |
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@main
|
||||
env:
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI_ORG: ${{ secrets.OPENAI_ORG }} # optional
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
18
CONFIGURATION.md
Normal file
@ -0,0 +1,18 @@
|
||||
## Configuration
|
||||
|
||||
The different tools and sub-tools used by CodiumAI pr-agent are easily configurable via the configuration file: `/pr-agent/settings/configuration.toml`.
|
||||
##### Git Provider:
|
||||
You can select your git_provider with the flag `git_provider` in the `config` section
|
||||
|
||||
##### PR Reviewer:
|
||||
|
||||
You can enable/disable the different PR Reviewer abilities with the following flags (`pr_reviewer` section):
|
||||
```
|
||||
require_focused_review=true
|
||||
require_tests_review=true
|
||||
require_security_review=true
|
||||
```
|
||||
You can contol the number of suggestions returned by the PR Reviewer with the following flag:
|
||||
```inline_code_comments=3```
|
||||
And enable/disable the inline code suggestions with the following flag:
|
||||
```inline_code_comments=true```
|
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"]
|
1
Dockerfile.github_action_dockerhub
Normal file
@ -0,0 +1 @@
|
||||
FROM codiumai/pr-agent:github_action
|
180
INSTALL.md
Normal file
@ -0,0 +1,180 @@
|
||||
|
||||
## Installation
|
||||
|
||||
---
|
||||
|
||||
#### Method 1: Use Docker image (no installation required)
|
||||
|
||||
To request a review for a PR, or ask a question about a PR, you can run directly from the Docker image. Here's how:
|
||||
|
||||
1. To request a review for a PR, run the following command:
|
||||
|
||||
```
|
||||
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent --pr_url <pr_url> review
|
||||
```
|
||||
|
||||
2. To ask a question about a PR, run the following command:
|
||||
|
||||
```
|
||||
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent --pr_url <pr_url> ask "<your question>"
|
||||
```
|
||||
|
||||
Possible questions you can ask include:
|
||||
|
||||
- What is the main theme of this PR?
|
||||
- Is the PR ready for merge?
|
||||
- What are the main changes in this PR?
|
||||
- Should this PR be split into smaller parts?
|
||||
- Can you compose a rhymed song about this PR?
|
||||
|
||||
---
|
||||
|
||||
#### Method 2: Run as a GitHub Action
|
||||
|
||||
You can use our pre-built Github Action Docker image to run PR-Agent as a Github Action.
|
||||
|
||||
1. Add the following file to your repository under `.github/workflows/pr_agent.yml`:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
pull_request:
|
||||
issue_comment:
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
runs-on: ubuntu-latest
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@main
|
||||
env:
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
2. Add the following secret to your repository under `Settings > Secrets`:
|
||||
|
||||
```
|
||||
OPENAI_KEY: <your key>
|
||||
```
|
||||
|
||||
The GITHUB_TOKEN secret is automatically created by GitHub.
|
||||
|
||||
3. Merge this change to your main branch.
|
||||
When you open your next PR, you should see a comment from `github-actions` bot with a review of your PR, and instructions on how to use the rest of the tools.
|
||||
|
||||
4. You may configure PR-Agent by adding environment variables under the env section corresponding to any configurable property in the [configuration](./CONFIGURATION.md) file. Some examples:
|
||||
```yaml
|
||||
env:
|
||||
# ... previous environment values
|
||||
OPENAI.ORG: "<Your organization name under your OpenAI account>"
|
||||
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false" # Disable tests review
|
||||
PR_CODE_SUGGESTIONS.NUM_CODE_SUGGESTIONS: 6 # Increase number of code suggestions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Method 3: Run from source
|
||||
|
||||
1. Clone this repository:
|
||||
|
||||
```
|
||||
git clone https://github.com/Codium-ai/pr-agent.git
|
||||
```
|
||||
|
||||
2. Install the requirements in your favorite virtual environment:
|
||||
|
||||
```
|
||||
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.toml
|
||||
# Edit .secrets.toml file
|
||||
```
|
||||
|
||||
4. Run the appropriate Python scripts from the scripts folder:
|
||||
|
||||
```
|
||||
python pr_agent/cli.py --pr_url <pr_url> review
|
||||
python pr_agent/cli.py --pr_url <pr_url> ask <your question>
|
||||
python pr_agent/cli.py --pr_url <pr_url> describe
|
||||
python pr_agent/cli.py --pr_url <pr_url> improve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Method 4: Run as a polling server
|
||||
Request reviews by tagging your Github user on a PR
|
||||
|
||||
Follow steps 1-3 of method 2.
|
||||
Run the following command to start the server:
|
||||
|
||||
```
|
||||
python pr_agent/servers/github_polling.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Method 5: Run as a GitHub App
|
||||
Allowing you to automate the review process on your private or public repositories.
|
||||
|
||||
1. Create a GitHub App from the [Github Developer Portal](https://docs.github.com/en/developers/apps/creating-a-github-app).
|
||||
|
||||
- Set the following permissions:
|
||||
- Pull requests: Read & write
|
||||
- Issue comment: Read & write
|
||||
- Metadata: Read-only
|
||||
- Set the following events:
|
||||
- Issue comment
|
||||
- Pull request
|
||||
|
||||
2. Generate a random secret for your app, and save it for later. For example, you can use:
|
||||
|
||||
```
|
||||
WEBHOOK_SECRET=$(python -c "import secrets; print(secrets.token_hex(10))")
|
||||
```
|
||||
|
||||
3. Acquire the following pieces of information from your app's settings page:
|
||||
|
||||
- App private key (click "Generate a private key" and save the file)
|
||||
- App ID
|
||||
|
||||
4. Clone this repository:
|
||||
|
||||
```
|
||||
git clone https://github.com/Codium-ai/pr-agent.git
|
||||
```
|
||||
|
||||
5. Copy the secrets template file and fill in the following:
|
||||
- Your OpenAI key.
|
||||
- Set deployment_type to 'app'
|
||||
- Copy your app's private key to the private_key field.
|
||||
- Copy your app's ID to the app_id field.
|
||||
- Copy your app's webhook secret to the webhook_secret field.
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
docker build . -t codiumai/pr-agent:github_app --target github_app -f docker/Dockerfile
|
||||
docker push codiumai/pr-agent:github_app # Push to your Docker repository
|
||||
```
|
||||
|
||||
7. Host the app using a server, serverless function, or container environment. Alternatively, for development and
|
||||
debugging, you may use tools like smee.io to forward webhooks to your local machine.
|
||||
|
||||
8. Go back to your app's settings, and set the following:
|
||||
|
||||
- Webhook URL: The URL of your app's server or the URL of the smee.io channel.
|
||||
- Webhook secret: The secret you generated earlier.
|
||||
|
||||
9. Install the app by navigating to the "Install App" tab and selecting your desired repositories.
|
||||
|
||||
---
|
349
README.md
@ -9,285 +9,123 @@
|
||||
|
||||
[](https://github.com/Codium-ai/pr-agent/blob/main/LICENSE)
|
||||
[](https://discord.com/channels/1057273017547378788/1126104260430528613)
|
||||
|
||||
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>
|
||||
<div style="text-align:left;">
|
||||
|
||||
CodiumAI `PR-Agent` is an open-source tool aiming to help developers review PRs faster and more efficiently. It automatically analyzes the PR and can provide several types of feedback:
|
||||
|
||||
**Auto-Description**: Automatically generating PR description - name, type, summary, and code walkthrough.
|
||||
\
|
||||
**PR Review**: Feedback about the PR main theme, type, relevant tests, security issues, focused PR, and various suggestions for the PR content.
|
||||
\
|
||||
**Question Answering**: Answering free-text questions about the PR.
|
||||
\
|
||||
**Code Suggestion**: Committable code suggestions for improving the PR.
|
||||
|
||||
<h3>Example results:</h2>
|
||||
</div>
|
||||
<h4>Describe:</h4>
|
||||
<div align="center">
|
||||
<p float="center">
|
||||
<img src="./pics/describe.gif" width="800">
|
||||
</p>
|
||||
</div>
|
||||
<h4>Review:</h4>
|
||||
<div align="center">
|
||||
<p float="center">
|
||||
<img src="./pics/review.gif" width="800">
|
||||
</p>
|
||||
</div>
|
||||
<h4>Ask:</h4>
|
||||
<div align="center">
|
||||
<p float="center">
|
||||
<img src="./pics/ask.gif" width="800">
|
||||
</p>
|
||||
</div>
|
||||
<h4>Improve:</h4>
|
||||
<div align="center">
|
||||
<p float="center">
|
||||
<img src="./pics/improve.gif" width="800">
|
||||
</p>
|
||||
</div>
|
||||
<div align="left">
|
||||
|
||||
- [Live demo](#live-demo)
|
||||
- [Quickstart](#Quickstart)
|
||||
- [Overview](#overview)
|
||||
- [Quickstart](#quickstart)
|
||||
- [Usage and tools](#usage-and-tools)
|
||||
- [Configuration](#Configuration)
|
||||
- [Configuration](./CONFIGURATION.md)
|
||||
- [How it works](#how-it-works)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Similar projects](#similar-projects)
|
||||
</div>
|
||||
|
||||
## Live demo
|
||||
|
||||
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.
|
||||
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
|
||||
To set up your own PR-Agent, see the [Quickstart](#Quickstart) section
|
||||
|
||||
---
|
||||
## Overview
|
||||
`PR-Agent` offers extensive pull request functionalities across various git providers:
|
||||
| | | Github | Gitlab | Bitbucket |
|
||||
|-------|---------------------------------------------|--------|--------|-----------|
|
||||
| TOOLS | Review | ✓ | ✓ | ✓ |
|
||||
| | ⮑ Inline review | ✓ | ✓ | |
|
||||
| | Ask | ✓ | ✓ | |
|
||||
| | Auto-Description | ✓ | | |
|
||||
| | Improve Code | ✓ | | |
|
||||
| | | | | |
|
||||
| USAGE | CLI | ✓ | ✓ | ✓ |
|
||||
| | Tagging bot | ✓ | ✓ | |
|
||||
| | Actions | ✓ | | |
|
||||
| | | | | |
|
||||
| CORE | PR compression | ✓ | ✓ | ✓ |
|
||||
| | Repo language prioritization | ✓ | ✓ | ✓ |
|
||||
| | Adaptive and token-aware<br />file patch fitting | ✓ | ✓ | ✓ |
|
||||
|
||||
Examples for invoking the different tools via the [CLI](#quickstart):
|
||||
- **Review**: python cli.py --pr-url=<pr_url> review
|
||||
- **Describe**: python cli.py --pr-url=<pr_url> describe
|
||||
- **Improve**: python cli.py --pr-url=<pr_url> improve
|
||||
- **Ask**: python cli.py --pr-url=<pr_url> ask "Write me a poem about this PR"
|
||||
|
||||
"<pr_url>" is the url of the relevant PR (for example: https://github.com/Codium-ai/pr-agent/pull/50).
|
||||
|
||||
In the [configuration](./CONFIGURATION.md) file you can select your git provider (Github, Gitlab, Bitbucket), and further configure the different tools.
|
||||
|
||||
## Quickstart
|
||||
|
||||
To get started with pr-agent quickly, you first need to acquire two tokens:
|
||||
To get started with PR-Agent quickly, you first need to acquire two tokens:
|
||||
|
||||
1. An OpenAI key from [here](https://platform.openai.com/), with access to GPT-4.
|
||||
2. A GitHub personal access token (classic) with the repo scope.
|
||||
|
||||
There are several ways to use pr-agent. Let's start with the simplest one:
|
||||
There are several ways to use PR-Agent. Let's start with the simplest one:
|
||||
|
||||
---
|
||||
|
||||
#### Method 1: Use Docker image (no installation required)
|
||||
## Install
|
||||
Here are several ways to install and run PR-Agent:
|
||||
|
||||
To request a review for a PR, or ask a question about a PR, you can run directly from the Docker image. Here's how:
|
||||
|
||||
1. To request a review for a PR, run the following command:
|
||||
|
||||
```
|
||||
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent --pr_url <pr url>
|
||||
```
|
||||
|
||||
2. To ask a question about a PR, run the following command:
|
||||
|
||||
```
|
||||
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent --pr_url <pr url> --question "<your question>"
|
||||
```
|
||||
|
||||
Possible questions you can ask include:
|
||||
|
||||
- What is the main theme of this PR?
|
||||
- Is the PR ready for merge?
|
||||
- What are the main changes in this PR?
|
||||
- Should this PR be split into smaller parts?
|
||||
- Can you compose a rhymed song about this PR.
|
||||
|
||||
---
|
||||
|
||||
#### Method 2: Run from source
|
||||
|
||||
1. Clone this repository:
|
||||
|
||||
```
|
||||
git clone https://github.com/Codium-ai/pr-agent.git
|
||||
```
|
||||
|
||||
2. Install the requirements in your favorite virtual environment:
|
||||
|
||||
```
|
||||
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.toml
|
||||
# Edit .secrets.toml file
|
||||
```
|
||||
|
||||
4. Run the appropriate Python scripts from the scripts folder:
|
||||
|
||||
```
|
||||
python pr_agent/cli.py --pr_url <pr url>
|
||||
python pr_agent/cli.py --pr_url <pr url> --question "<your question>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Method 3: Method 3: Run as a polling server; request reviews by tagging your Github user on a PR
|
||||
|
||||
Follow steps 1-3 of method 2.
|
||||
Run the following command to start the server:
|
||||
|
||||
```
|
||||
python pr_agent/servers/github_polling.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Method 4: Run as a Github App, allowing you to automate the review process on your private or public repositories.
|
||||
|
||||
1. Create a GitHub App from the [Github Developer Portal](https://docs.github.com/en/developers/apps/creating-a-github-app).
|
||||
|
||||
- Set the following permissions:
|
||||
- Pull requests: Read & write
|
||||
- Issue comment: Read & write
|
||||
- Metadata: Read-only
|
||||
- Set the following events:
|
||||
- Issue comment
|
||||
- Pull request
|
||||
|
||||
2. Generate a random secret for your app, and save it for later. For example, you can use:
|
||||
|
||||
```
|
||||
WEBHOOK_SECRET=$(python -c "import secrets; print(secrets.token_hex(10))")
|
||||
```
|
||||
|
||||
3. Acquire the following pieces of information from your app's settings page:
|
||||
|
||||
- App private key (click "Generate a private key", and save the file)
|
||||
- App ID
|
||||
|
||||
4. Clone this repository:
|
||||
|
||||
```
|
||||
git clone https://github.com/Codium-ai/pr-agent.git
|
||||
```
|
||||
|
||||
5. Copy the secrets template file and fill in the following:
|
||||
- Your OpenAI key.
|
||||
- Set deployment_type to 'app'
|
||||
- Copy your app's private key to the private_key field.
|
||||
- Copy your app's ID to the app_id field.
|
||||
- Copy your app's webhook secret to the webhook_secret field.
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
docker build . -t codiumai/pr-agent:github_app --target github_app -f docker/Dockerfile
|
||||
docker push codiumai/pr-agent:github_app # Push to your Docker repository
|
||||
```
|
||||
|
||||
7. Host the app using a server, serverless function, or container environment. Alternatively, for development and
|
||||
debugging, you may use tools like smee.io to forward webhooks to your local machine.
|
||||
|
||||
8. Go back to your app's settings, set the following:
|
||||
|
||||
- Webhook URL: The URL of your app's server, or the URL of the smee.io channel.
|
||||
- Webhook secret: The secret you generated earlier.
|
||||
|
||||
9. Install the app by navigating to the "Install App" tab, and selecting your desired repositories.
|
||||
|
||||
---
|
||||
- [Method 1: Use Docker image (no installation required)](INSTALL.md#method-1-use-docker-image-no-installation-required)
|
||||
- [Method 2: Run as a Github Action](INSTALL.md#method-2-run-as-a-github-action)
|
||||
- [Method 3: Run from source](INSTALL.md#method-3-run-from-source)
|
||||
- [Method 4: Run as a polling server](INSTALL.md#method-4-run-as-a-polling-server)
|
||||
- Request reviews by tagging your Github user on a PR
|
||||
- [Method 5: Run as a Github App](INSTALL.md#method-5-run-as-a-github-app)
|
||||
- Allowing you to automate the review process on your private or public repositories
|
||||
|
||||
## Usage and Tools
|
||||
|
||||
CodiumAI pr-agent provides two types of interactions ("tools"): `"PR Reviewer"` and `"PR Q&A"`.
|
||||
**PR-Agent** provides four types of interactions ("tools"): `"PR Reviewer"`, `"PR Q&A"`, `"PR Description"` and `"PR Code Sueggestions"`.
|
||||
|
||||
- The "PR Reviewer" tool automatically analyzes PRs, and provides different types of feedbacks.
|
||||
- The "PR Q&A" tool answers free-text questions about the PR.
|
||||
|
||||
### PR Reviewer
|
||||
|
||||
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 this a focused PR
|
||||
- Are there security concerns
|
||||
- PR Feedback
|
||||
- General PR suggestions
|
||||
- Code suggestions
|
||||
|
||||
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
|
||||
- ✨ **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
|
||||
|
||||
- 💡 **General PR suggestions:** The PR is generally well-structured and the code is clean. However, it would be beneficial to add some tests to ensure the new handlers work as expected. Also, consider adding docstrings to the new functions and classes to improve code readability and maintainability.
|
||||
|
||||
- 🤖 **Code suggestions:**
|
||||
|
||||
- **relevant file:** pr_agent/algo/language_handler.py
|
||||
|
||||
**suggestion content:** Consider using a set instead of a list for 'bad_extensions' as checking membership in a set is faster than in a list. [medium]
|
||||
|
||||
- **relevant file:** pr_agent/algo/language_handler.py
|
||||
|
||||
**suggestion content:** In the 'filter_bad_extensions' function, you are splitting the filename on '.' and taking the last element to get the extension. This might not work as expected if the filename contains multiple '.' characters. Consider using 'os.path.splitext' to get the file extension more reliably. [important]
|
||||
|
||||
---
|
||||
|
||||
### PR Q&A
|
||||
|
||||
This tool answers free-text questions about the PR. This is how a typical output of the PR Q&A looks like:
|
||||
|
||||
**Question**: summarize for me the PR in 4 bullet points
|
||||
|
||||
**Answer**:
|
||||
|
||||
- The PR introduces a new feature to sort files by their main languages. It uses a mapping of programming languages to their file extensions to achieve this.
|
||||
- It also introduces a filter to exclude files with certain extensions, deemed as 'bad extensions', from the sorting process.
|
||||
- The PR modifies the `get_pr_diff` function in `pr_processing.py` to use the new sorting function. It also refactors the code to move the PR pruning logic into a separate function.
|
||||
- A new `TokenHandler` class is introduced in `token_handler.py` to handle token counting operations. This class is initialized with a PR, variables, system, and user, and provides methods to get system and user tokens and to count tokens in a patch.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
The different tools and sub-tools used by CodiumAI pr-agent are easily configurable via the configuration file: `/settings/configuration.toml`.
|
||||
|
||||
#### Enabling/disabling sub-tools:
|
||||
|
||||
You can enable/disable the different PR Reviewer sub-sections with the following flags:
|
||||
|
||||
```
|
||||
require_focused_review=true
|
||||
require_tests_review=true
|
||||
require_security_review=true
|
||||
```
|
||||
|
||||
#### Code Suggestions configuration:
|
||||
|
||||
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'),
|
||||
```
|
||||
|
||||
---
|
||||
- The "PR Reviewer" tool automatically analyzes PRs, and provides different types of feedback.
|
||||
- The "PR Ask" tool answers free-text questions about the PR.
|
||||
- The "PR Description" tool automatically sets the PR Title and body.
|
||||
- The "PR Code Suggestion" tool provide inline code suggestions for the PR that can be applied and committed.
|
||||
|
||||
## How it works
|
||||
|
||||
@ -297,14 +135,15 @@ Check out the [PR Compression strategy](./PR_COMPRESSION.md) page for more detai
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Support open-source models, as a replacement for openai models. Note that a minimal requirement for each open-source model is to have 8k+ context, and good support for generating json as an output
|
||||
- [ ] Support other Git providers, such as Gitlab and Bitbucket.
|
||||
- [ ] Support open-source models, as a replacement for openai models. (Note - a minimal requirement for each open-source model is to have 8k+ context, and good support for generating json as an output)
|
||||
- [x] Support other Git providers, such as Gitlab and Bitbucket.
|
||||
- [ ] Develop additional logics for handling large PRs, and compressing git patches
|
||||
- [ ] Dedicated tools and sub-tools for specific programming languages (Python, Javascript, Java, C++, etc)
|
||||
- [ ] Add additional context to the prompt. For example, repo (or relevant files) summarization, with tools such a [ctags](https://github.com/universal-ctags/ctags)
|
||||
- [ ] Adding more tools. Possible directions:
|
||||
- [ ] Code Quality
|
||||
- [ ] Coding Style
|
||||
- [x] PR description
|
||||
- [x] Inline code suggestions
|
||||
- [ ] Enforcing CONTRIBUTING.md guidelines
|
||||
- [ ] Performance (are there any performance issues)
|
||||
- [ ] Documentation (is the PR properly documented)
|
||||
- [ ] Rank the PR importance
|
||||
@ -314,6 +153,6 @@ Check out the [PR Compression strategy](./PR_COMPRESSION.md) page for more detai
|
||||
|
||||
- [CodiumAI - Meaningful tests for busy devs](https://github.com/Codium-ai/codiumai-vscode-release)
|
||||
- [Aider - GPT powered coding in your terminal](https://github.com/paul-gauthier/aider)
|
||||
- [GPT-Engineer](https://github.com/AntonOsika/gpt-engineer)
|
||||
- [openai-pr-reviewer](https://github.com/coderabbitai/openai-pr-reviewer)
|
||||
- [CodeReview BOT](https://github.com/anc95/ChatGPT-CodeReview)
|
||||
- [AI-Maintainer](https://github.com/merwanehamadi/AI-Maintainer)
|
||||
|
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_dockerhub'
|
2
github_action/entrypoint.sh
Normal file
@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
python /app/pr_agent/servers/github_action_runner.py
|
BIN
pics/Icon-7.png
Before Width: | Height: | Size: 100 KiB |
BIN
pics/ask.gif
Normal file
After Width: | Height: | Size: 3.8 MiB |
BIN
pics/describe.gif
Normal file
After Width: | Height: | Size: 13 MiB |
Before Width: | Height: | Size: 102 KiB |
BIN
pics/improve.gif
Normal file
After Width: | Height: | Size: 9.3 MiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
BIN
pics/main_pic_4_tools.gif
Normal file
After Width: | Height: | Size: 260 KiB |
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 316 KiB |
BIN
pics/pr_auto_description.png
Normal file
After Width: | Height: | Size: 335 KiB |
BIN
pics/pr_code_suggestions.png
Normal file
After Width: | Height: | Size: 193 KiB |
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 161 KiB |
Before Width: | Height: | Size: 267 KiB |
BIN
pics/pr_reviewer_1.png
Normal file
After Width: | Height: | Size: 185 KiB |
BIN
pics/pr_reviewer_2.png
Normal file
After Width: | Height: | Size: 162 KiB |
Before Width: | Height: | Size: 42 KiB |
BIN
pics/review.gif
Normal file
After Width: | Height: | Size: 20 MiB |
@ -1,5 +1,7 @@
|
||||
import re
|
||||
|
||||
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
||||
from pr_agent.tools.pr_description import PRDescription
|
||||
from pr_agent.tools.pr_questions import PRQuestions
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
|
||||
@ -8,17 +10,20 @@ class PRAgent:
|
||||
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)
|
||||
await reviewer.review()
|
||||
|
||||
async def handle_request(self, pr_url, request) -> bool:
|
||||
if any(cmd in request for cmd in ["/review", "/review_pr"]):
|
||||
await PRReviewer(pr_url).review()
|
||||
elif any(cmd in request for cmd in ["/describe", "/describe_pr"]):
|
||||
await PRDescription(pr_url).describe()
|
||||
elif any(cmd in request for cmd in ["/improve", "/improve_code"]):
|
||||
await PRCodeSuggestions(pr_url).suggest()
|
||||
elif any(cmd in request for cmd in ["/ask", "/ask_question"]):
|
||||
pattern = r'(/ask|/ask_question)\s*(.*)'
|
||||
matches = re.findall(pattern, request, re.IGNORECASE)
|
||||
if matches:
|
||||
question = matches[0][1]
|
||||
await PRQuestions(pr_url, question).answer()
|
||||
else:
|
||||
if "please answer" in request.lower():
|
||||
question = re.split(r'(?i)please answer', request)[1].strip()
|
||||
elif request.lower().strip().startswith("answer"):
|
||||
question = re.split(r'(?i)answer', request)[1].strip()
|
||||
else:
|
||||
question = request
|
||||
answerer = PRQuestions(pr_url, question)
|
||||
await answerer.answer()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
@ -18,7 +18,7 @@ class AiHandler:
|
||||
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
|
||||
openai.api_version = settings.openai.api_version
|
||||
if settings.get("OPENAI.API_BASE", None):
|
||||
openai.api_base = settings.openai.api_base
|
||||
except AttributeError as e:
|
||||
|
@ -13,6 +13,9 @@ def extend_patch(original_file_str, patch_str, num_lines) -> str:
|
||||
if not patch_str or num_lines == 0:
|
||||
return patch_str
|
||||
|
||||
if type(original_file_str) == bytes:
|
||||
original_file_str = original_file_str.decode('utf-8')
|
||||
|
||||
original_lines = original_file_str.splitlines()
|
||||
patch_lines = patch_str.splitlines()
|
||||
extended_patch_lines = []
|
||||
@ -105,3 +108,78 @@ def handle_patch_deletions(patch: str, original_file_content_str: str,
|
||||
logging.info(f"Processing file: {file_name}, hunks were deleted")
|
||||
patch = patch_new
|
||||
return patch
|
||||
|
||||
|
||||
def convert_to_hunks_with_lines_numbers(patch: str, file) -> str:
|
||||
# toDO: (maybe remove '-' and '+' from the beginning of the line)
|
||||
"""
|
||||
## src/file.ts
|
||||
--new hunk--
|
||||
881 line1
|
||||
882 line2
|
||||
883 line3
|
||||
884 line4
|
||||
885 line6
|
||||
886 line7
|
||||
887 + line8
|
||||
888 + line9
|
||||
889 line10
|
||||
890 line11
|
||||
...
|
||||
--old hunk--
|
||||
line1
|
||||
line2
|
||||
- line3
|
||||
- line4
|
||||
line5
|
||||
line6
|
||||
...
|
||||
|
||||
"""
|
||||
patch_with_lines_str = f"## {file.filename}\n"
|
||||
import re
|
||||
patch_lines = patch.splitlines()
|
||||
RE_HUNK_HEADER = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
||||
new_content_lines = []
|
||||
old_content_lines = []
|
||||
match = None
|
||||
start1, size1, start2, size2 = -1, -1, -1, -1
|
||||
for line in patch_lines:
|
||||
if 'no newline at end of file' in line.lower():
|
||||
continue
|
||||
|
||||
if line.startswith('@@'):
|
||||
match = RE_HUNK_HEADER.match(line)
|
||||
if match and new_content_lines: # found a new hunk, split the previous lines
|
||||
if new_content_lines:
|
||||
patch_with_lines_str += '\n--new hunk--\n'
|
||||
for i, line_new in enumerate(new_content_lines):
|
||||
patch_with_lines_str += f"{start2 + i} {line_new}\n"
|
||||
if old_content_lines:
|
||||
patch_with_lines_str += '--old hunk--\n'
|
||||
for i, line_old in enumerate(old_content_lines):
|
||||
patch_with_lines_str += f"{line_old}\n"
|
||||
new_content_lines = []
|
||||
old_content_lines = []
|
||||
start1, size1, start2, size2 = map(int, match.groups()[:4])
|
||||
elif line.startswith('+'):
|
||||
new_content_lines.append(line)
|
||||
elif line.startswith('-'):
|
||||
old_content_lines.append(line)
|
||||
else:
|
||||
new_content_lines.append(line)
|
||||
old_content_lines.append(line)
|
||||
|
||||
# finishing last hunk
|
||||
if match and new_content_lines:
|
||||
if new_content_lines:
|
||||
patch_with_lines_str += '\n--new hunk--\n'
|
||||
for i, line_new in enumerate(new_content_lines):
|
||||
patch_with_lines_str += f"{start2 + i} {line_new}\n"
|
||||
if old_content_lines:
|
||||
patch_with_lines_str += '\n--old hunk--\n'
|
||||
for i, line_old in enumerate(old_content_lines):
|
||||
patch_with_lines_str += f"{line_old}\n"
|
||||
|
||||
return patch_with_lines_str.strip()
|
||||
|
@ -4,7 +4,8 @@ import difflib
|
||||
import logging
|
||||
from typing import Any, Tuple, Union
|
||||
|
||||
from pr_agent.algo.git_patch_processing import extend_patch, handle_patch_deletions
|
||||
from pr_agent.algo.git_patch_processing import extend_patch, handle_patch_deletions, \
|
||||
convert_to_hunks_with_lines_numbers
|
||||
from pr_agent.algo.language_handler import sort_files_by_main_languages
|
||||
from pr_agent.algo.token_handler import TokenHandler
|
||||
from pr_agent.config_loader import settings
|
||||
@ -19,26 +20,33 @@ OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD = 600
|
||||
PATCH_EXTRA_LINES = 3
|
||||
|
||||
|
||||
def get_pr_diff(git_provider: Union[GithubProvider, Any], token_handler: TokenHandler) -> str:
|
||||
def get_pr_diff(git_provider: Union[GithubProvider, Any], token_handler: TokenHandler,
|
||||
add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool =False) -> str:
|
||||
"""
|
||||
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())
|
||||
if disable_extra_lines:
|
||||
global PATCH_EXTRA_LINES
|
||||
PATCH_EXTRA_LINES = 0
|
||||
|
||||
git_provider.pr.diff_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.diff_files)
|
||||
|
||||
# generate a standard diff string, with patch extension
|
||||
patches_extended, total_tokens = pr_generate_extended_diff(pr_languages, token_handler)
|
||||
patches_extended, total_tokens = pr_generate_extended_diff(pr_languages, token_handler,
|
||||
add_line_numbers_to_hunks)
|
||||
|
||||
# if we are under the limit, return the full diff
|
||||
if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < token_handler.limit:
|
||||
return "\n".join(patches_extended)
|
||||
|
||||
# if we are over the limit, start pruning
|
||||
patches_compressed, modified_file_names, deleted_file_names = pr_generate_compressed_diff(pr_languages,
|
||||
token_handler)
|
||||
patches_compressed, modified_file_names, deleted_file_names = \
|
||||
pr_generate_compressed_diff(pr_languages, token_handler, add_line_numbers_to_hunks)
|
||||
|
||||
final_diff = "\n".join(patches_compressed)
|
||||
if modified_file_names:
|
||||
modified_list_str = MORE_MODIFIED_FILES_ + "\n".join(modified_file_names)
|
||||
@ -49,7 +57,8 @@ def get_pr_diff(git_provider: Union[GithubProvider, Any], token_handler: TokenHa
|
||||
return final_diff
|
||||
|
||||
|
||||
def pr_generate_extended_diff(pr_languages: list, token_handler: TokenHandler) -> \
|
||||
def pr_generate_extended_diff(pr_languages: list, token_handler: TokenHandler,
|
||||
add_line_numbers_to_hunks: bool) -> \
|
||||
Tuple[list, int]:
|
||||
"""
|
||||
Generate a standard diff string, with patch extension
|
||||
@ -72,6 +81,9 @@ def pr_generate_extended_diff(pr_languages: list, token_handler: TokenHandler) -
|
||||
extended_patch = extend_patch(original_file_content_str, patch, num_lines=PATCH_EXTRA_LINES)
|
||||
full_extended_patch = f"## {file.filename}\n\n{extended_patch}\n"
|
||||
|
||||
if add_line_numbers_to_hunks:
|
||||
full_extended_patch = convert_to_hunks_with_lines_numbers(extended_patch, file)
|
||||
|
||||
patch_tokens = token_handler.count_tokens(full_extended_patch)
|
||||
file.tokens = patch_tokens
|
||||
total_tokens += patch_tokens
|
||||
@ -80,7 +92,8 @@ def pr_generate_extended_diff(pr_languages: list, token_handler: TokenHandler) -
|
||||
return patches_extended, total_tokens
|
||||
|
||||
|
||||
def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler) -> Tuple[list, list, list]:
|
||||
def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler,
|
||||
convert_hunks_to_line_numbers: bool) -> Tuple[list, list, list]:
|
||||
# Apply Diff Minimization techniques to reduce the number of tokens:
|
||||
# 0. Start from the largest diff patch to smaller ones
|
||||
# 1. Don't use extend context lines around diff
|
||||
@ -114,6 +127,10 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler) ->
|
||||
deleted_files_list.append(file.filename)
|
||||
total_tokens += token_handler.count_tokens(file.filename) + 1
|
||||
continue
|
||||
|
||||
if convert_hunks_to_line_numbers:
|
||||
patch = convert_to_hunks_with_lines_numbers(patch, file)
|
||||
|
||||
new_patch_tokens = token_handler.count_tokens(patch)
|
||||
|
||||
# Hard Stop, no more tokens
|
||||
@ -135,7 +152,10 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler) ->
|
||||
continue
|
||||
|
||||
if patch:
|
||||
patch_final = f"## {file.filename}\n\n{patch}\n"
|
||||
if not convert_hunks_to_line_numbers:
|
||||
patch_final = f"## {file.filename}\n\n{patch}\n"
|
||||
else:
|
||||
patch_final = patch
|
||||
patches.append(patch_final)
|
||||
total_tokens += token_handler.count_tokens(patch_final)
|
||||
if settings.config.verbosity_level >= 2:
|
||||
|
@ -11,7 +11,6 @@ def convert_to_markdown(output_data: dict) -> str:
|
||||
|
||||
emojis = {
|
||||
"Main theme": "🎯",
|
||||
"Description and title": "🔍",
|
||||
"Type of PR": "📌",
|
||||
"Relevant tests added": "🧪",
|
||||
"Unrelated changes": "⚠️",
|
||||
@ -53,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"
|
||||
|
@ -3,24 +3,60 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
||||
from pr_agent.tools.pr_description import PRDescription
|
||||
from pr_agent.tools.pr_questions import PRQuestions
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
|
||||
|
||||
def run():
|
||||
parser = argparse.ArgumentParser(description='AI based pull request analyzer')
|
||||
parser = argparse.ArgumentParser(description='AI based pull request analyzer', usage="""\
|
||||
Usage: cli.py --pr-url <URL on supported git hosting service> <command> [<args>].
|
||||
For example:
|
||||
- cli.py --pr-url xxx review
|
||||
- cli.py --pr-url xxx describe
|
||||
- cli.py --pr-url xxx improve
|
||||
- cli.py --pr-url xxx ask "write me a poem about this PR"
|
||||
|
||||
Supported commands:
|
||||
review / review_pr - Add a review that includes a summary of the PR and specific suggestions for improvement.
|
||||
ask / ask_question [question] - Ask a question about the PR.
|
||||
describe / describe_pr - Modify the PR title and description based on the PR's contents.
|
||||
improve / improve_code - Suggest improvements to the code in the PR as pull request comments ready to commit.
|
||||
""")
|
||||
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('command', type=str, help='The', choices=['review', 'review_pr',
|
||||
'ask', 'ask_question',
|
||||
'describe', 'describe_pr',
|
||||
'improve', 'improve_code'], default='review')
|
||||
parser.add_argument('rest', nargs=argparse.REMAINDER, default=[])
|
||||
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)
|
||||
command = args.command.lower()
|
||||
if command in ['ask', 'ask_question']:
|
||||
question = ' '.join(args.rest).strip()
|
||||
if len(question) == 0:
|
||||
print("Please specify a question")
|
||||
parser.print_help()
|
||||
return
|
||||
print(f"Question: {question} about PR {args.pr_url}")
|
||||
reviewer = PRQuestions(args.pr_url, question)
|
||||
asyncio.run(reviewer.answer())
|
||||
else:
|
||||
elif command in ['describe', 'describe_pr']:
|
||||
print(f"PR description: {args.pr_url}")
|
||||
reviewer = PRDescription(args.pr_url)
|
||||
asyncio.run(reviewer.describe())
|
||||
elif command in ['improve', 'improve_code']:
|
||||
print(f"PR code suggestions: {args.pr_url}")
|
||||
reviewer = PRCodeSuggestions(args.pr_url)
|
||||
asyncio.run(reviewer.suggest())
|
||||
elif command in ['review', 'review_pr']:
|
||||
print(f"Reviewing PR: {args.pr_url}")
|
||||
reviewer = PRReviewer(args.pr_url, cli_mode=True)
|
||||
asyncio.run(reviewer.review())
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@ -11,6 +11,8 @@ settings = Dynaconf(
|
||||
"settings/configuration.toml",
|
||||
"settings/pr_reviewer_prompts.toml",
|
||||
"settings/pr_questions_prompts.toml",
|
||||
"settings/pr_description_prompts.toml",
|
||||
"settings/pr_code_suggestions_prompts.toml",
|
||||
"settings_prod/.secrets.toml"
|
||||
]]
|
||||
)
|
||||
|
@ -1,10 +1,12 @@
|
||||
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
|
||||
from pr_agent.git_providers.bitbucket_provider import BitbucketProvider
|
||||
|
||||
_GIT_PROVIDERS = {
|
||||
'github': GithubProvider,
|
||||
'gitlab': GitLabProvider,
|
||||
'bitbucket': BitbucketProvider,
|
||||
}
|
||||
|
||||
def get_git_provider():
|
||||
|
110
pr_agent/git_providers/bitbucket_provider.py
Normal file
@ -0,0 +1,110 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from atlassian.bitbucket import Cloud
|
||||
|
||||
from pr_agent.config_loader import settings
|
||||
|
||||
from .git_provider import FilePatchInfo
|
||||
|
||||
class BitbucketProvider:
|
||||
def __init__(self, pr_url: Optional[str] = None):
|
||||
s = requests.Session()
|
||||
s.headers['Authorization'] = f'Bearer {settings.get("BITBUCKET.BEARER_TOKEN", None)}'
|
||||
self.bitbucket_client = Cloud(session=s)
|
||||
|
||||
self.workspace_slug = None
|
||||
self.repo_slug = None
|
||||
self.repo = None
|
||||
self.pr_num = None
|
||||
self.pr = None
|
||||
self.temp_comments = []
|
||||
if pr_url:
|
||||
self.set_pr(pr_url)
|
||||
|
||||
def set_pr(self, pr_url: str):
|
||||
self.workspace_slug, self.repo_slug, self.pr_num = self._parse_pr_url(pr_url)
|
||||
self.pr = self._get_pr()
|
||||
|
||||
def get_files(self):
|
||||
return [diff.new.path for diff in self.pr.diffstat()]
|
||||
|
||||
def get_diff_files(self) -> list[FilePatchInfo]:
|
||||
diffs = self.pr.diffstat()
|
||||
diff_split = ['diff --git%s' % x for x in self.pr.diff().split('diff --git') if x.strip()]
|
||||
|
||||
diff_files = []
|
||||
for index, diff in enumerate(diffs):
|
||||
original_file_content_str = self._get_pr_file_content(diff.old.get_data('links'))
|
||||
new_file_content_str = self._get_pr_file_content(diff.new.get_data('links'))
|
||||
diff_files.append(FilePatchInfo(original_file_content_str, new_file_content_str, diff_split[index], diff.new.path))
|
||||
return diff_files
|
||||
|
||||
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
||||
comment = self.pr.comment(pr_comment)
|
||||
if is_temporary:
|
||||
self.temp_comments.append(comment['id'])
|
||||
|
||||
def remove_initial_comment(self):
|
||||
try:
|
||||
for comment in self.temp_comments:
|
||||
self.pr.delete(f'comments/{comment}')
|
||||
except Exception as e:
|
||||
logging.exception(f"Failed to remove temp comments, error: {e}")
|
||||
|
||||
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||
pass
|
||||
|
||||
def get_title(self):
|
||||
return self.pr.title
|
||||
|
||||
def get_description(self):
|
||||
return self.pr.body
|
||||
|
||||
def get_languages(self):
|
||||
languages = {self._get_repo().get_data('language'): 0}
|
||||
return languages
|
||||
|
||||
def get_pr_branch(self):
|
||||
return self.pr.source_branch
|
||||
|
||||
def get_pr_description(self):
|
||||
return self.pr.description
|
||||
|
||||
def get_user_id(self):
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _parse_pr_url(pr_url: str) -> Tuple[str, int]:
|
||||
parsed_url = urlparse(pr_url)
|
||||
|
||||
if 'bitbucket.org' not in parsed_url.netloc:
|
||||
raise ValueError("The provided URL is not a valid GitHub URL")
|
||||
|
||||
path_parts = parsed_url.path.strip('/').split('/')
|
||||
|
||||
if len(path_parts) < 4 or path_parts[2] != 'pull-requests':
|
||||
raise ValueError("The provided URL does not appear to be a Bitbucket PR URL")
|
||||
|
||||
workspace_slug = path_parts[0]
|
||||
repo_slug = path_parts[1]
|
||||
try:
|
||||
pr_number = int(path_parts[3])
|
||||
except ValueError as e:
|
||||
raise ValueError("Unable to convert PR number to integer") from e
|
||||
|
||||
return workspace_slug, repo_slug, pr_number
|
||||
|
||||
def _get_repo(self):
|
||||
if self.repo is None:
|
||||
self.repo = self.bitbucket_client.workspaces.get(self.workspace_slug).repositories.get(self.repo_slug)
|
||||
return self.repo
|
||||
|
||||
def _get_pr(self):
|
||||
return self._get_repo().pullrequests.get(self.pr_num)
|
||||
|
||||
def _get_pr_file_content(self, remote_link: str):
|
||||
return ""
|
@ -1,6 +1,13 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
# enum EDIT_TYPE (ADDED, DELETED, MODIFIED, RENAMED)
|
||||
from enum import Enum
|
||||
class EDIT_TYPE(Enum):
|
||||
ADDED = 1
|
||||
DELETED = 2
|
||||
MODIFIED = 3
|
||||
RENAMED = 4
|
||||
|
||||
@dataclass
|
||||
class FilePatchInfo:
|
||||
@ -9,6 +16,8 @@ class FilePatchInfo:
|
||||
patch: str
|
||||
filename: str
|
||||
tokens: int = -1
|
||||
edit_type: EDIT_TYPE = EDIT_TYPE.MODIFIED
|
||||
old_filename: str = None
|
||||
|
||||
|
||||
class GitProvider(ABC):
|
||||
@ -16,10 +25,23 @@ class GitProvider(ABC):
|
||||
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 publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def publish_code_suggestion(self, body: str, relevant_file: str,
|
||||
relevant_lines_start: int, relevant_lines_end: int):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove_initial_comment(self):
|
||||
pass
|
||||
|
@ -7,10 +7,10 @@ from github import AppAuthentication, Github
|
||||
|
||||
from pr_agent.config_loader import settings
|
||||
|
||||
from .git_provider import FilePatchInfo
|
||||
from .git_provider import FilePatchInfo, GitProvider
|
||||
|
||||
|
||||
class GithubProvider:
|
||||
class GithubProvider(GitProvider):
|
||||
def __init__(self, pr_url: Optional[str] = None):
|
||||
self.installation_id = settings.get("GITHUB.INSTALLATION_ID")
|
||||
self.github_client = self._get_github_client()
|
||||
@ -18,8 +18,10 @@ class GithubProvider:
|
||||
self.pr_num = None
|
||||
self.pr = None
|
||||
self.github_user_id = None
|
||||
self.diff_files = None
|
||||
if pr_url:
|
||||
self.set_pr(pr_url)
|
||||
self.last_commit_id = list(self.pr.get_commits())[-1]
|
||||
|
||||
def set_pr(self, pr_url: str):
|
||||
self.repo, self.pr_num = self._parse_pr_url(pr_url)
|
||||
@ -35,8 +37,13 @@ class GithubProvider:
|
||||
original_file_content_str = self._get_pr_file_content(file, self.pr.base.sha)
|
||||
new_file_content_str = self._get_pr_file_content(file, self.pr.head.sha)
|
||||
diff_files.append(FilePatchInfo(original_file_content_str, new_file_content_str, file.patch, file.filename))
|
||||
self.diff_files = diff_files
|
||||
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"):
|
||||
@ -46,6 +53,76 @@ class GithubProvider:
|
||||
self.pr.comments_list = []
|
||||
self.pr.comments_list.append(response)
|
||||
|
||||
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||
self.diff_files = self.diff_files if self.diff_files else self.get_diff_files()
|
||||
position = -1
|
||||
for file in self.diff_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
|
||||
break
|
||||
elif relevant_line_in_file[0] == '+' and relevant_line_in_file[1:] in line:
|
||||
# The model often adds a '+' to the beginning of the relevant_line_in_file even if originally
|
||||
# it's a context line
|
||||
position = i
|
||||
break
|
||||
if position == -1:
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
||||
else:
|
||||
path = relevant_file.strip()
|
||||
self.pr.create_review_comment(body=body, commit_id=self.last_commit_id, path=path, position=position)
|
||||
|
||||
def publish_code_suggestion(self, body: str,
|
||||
relevant_file: str,
|
||||
relevant_lines_start: int,
|
||||
relevant_lines_end: int):
|
||||
if not relevant_lines_start or relevant_lines_start == -1:
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.exception(f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}")
|
||||
return False
|
||||
|
||||
if relevant_lines_end<relevant_lines_start:
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.exception(f"Failed to publish code suggestion, "
|
||||
f"relevant_lines_end is {relevant_lines_end} and "
|
||||
f"relevant_lines_start is {relevant_lines_start}")
|
||||
return False
|
||||
|
||||
try:
|
||||
import github.PullRequestComment
|
||||
if relevant_lines_end > relevant_lines_start:
|
||||
post_parameters = {
|
||||
"body": body,
|
||||
"commit_id": self.last_commit_id._identity,
|
||||
"path": relevant_file,
|
||||
"line": relevant_lines_end,
|
||||
"start_line": relevant_lines_start,
|
||||
"start_side": "RIGHT",
|
||||
}
|
||||
else: # API is different for single line comments
|
||||
post_parameters = {
|
||||
"body": body,
|
||||
"commit_id": self.last_commit_id._identity,
|
||||
"path": relevant_file,
|
||||
"line": relevant_lines_start,
|
||||
"side": "RIGHT",
|
||||
}
|
||||
headers, data = self.pr._requester.requestJsonAndCheck(
|
||||
"POST", f"{self.pr.url}/comments", input=post_parameters
|
||||
)
|
||||
github.PullRequestComment.PullRequestComment(
|
||||
self.pr._requester, headers, data, completed=True
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.error(f"Failed to publish code suggestion, error: {e}")
|
||||
return False
|
||||
|
||||
def remove_initial_comment(self):
|
||||
try:
|
||||
for comment in self.pr.comments_list:
|
||||
@ -146,9 +223,9 @@ class GithubProvider:
|
||||
def _get_pr(self):
|
||||
return self._get_repo().get_pull(self.pr_num)
|
||||
|
||||
def _get_pr_file_content(self, file: FilePatchInfo, sha: str):
|
||||
def _get_pr_file_content(self, file: FilePatchInfo, sha: str) -> str:
|
||||
try:
|
||||
file_content_str = self._get_repo().get_contents(file.filename, ref=sha).decoded_content.decode()
|
||||
file_content_str = str(self._get_repo().get_contents(file.filename, ref=sha).decoded_content.decode())
|
||||
except Exception:
|
||||
file_content_str = ""
|
||||
return file_content_str
|
||||
|
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -6,7 +7,7 @@ import gitlab
|
||||
|
||||
from pr_agent.config_loader import settings
|
||||
|
||||
from .git_provider import FilePatchInfo, GitProvider
|
||||
from .git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
|
||||
|
||||
|
||||
class GitLabProvider(GitProvider):
|
||||
@ -24,6 +25,7 @@ class GitLabProvider(GitProvider):
|
||||
self.id_project = None
|
||||
self.id_mr = None
|
||||
self.mr = None
|
||||
self.diff_files = None
|
||||
self.temp_comments = []
|
||||
self._set_merge_request(merge_request_url)
|
||||
|
||||
@ -35,20 +37,130 @@ class GitLabProvider(GitProvider):
|
||||
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()
|
||||
self.last_diff = self.mr.diffs.list()[-1]
|
||||
|
||||
def _get_pr_file_content(self, file_path: str, branch: str) -> str:
|
||||
return self.gl.projects.get(self.id_project).files.get(file_path, branch).decode()
|
||||
|
||||
def get_diff_files(self) -> list[FilePatchInfo]:
|
||||
diffs = self.mr.changes()['changes']
|
||||
diff_files = [FilePatchInfo("", "", diff['diff'], diff['new_path']) for diff in diffs]
|
||||
diff_files = []
|
||||
for diff in diffs:
|
||||
original_file_content_str = self._get_pr_file_content(diff['old_path'], self.mr.target_branch)
|
||||
new_file_content_str = self._get_pr_file_content(diff['new_path'], self.mr.source_branch)
|
||||
edit_type = EDIT_TYPE.MODIFIED
|
||||
if diff['new_file']:
|
||||
edit_type = EDIT_TYPE.ADDED
|
||||
elif diff['deleted_file']:
|
||||
edit_type = EDIT_TYPE.DELETED
|
||||
elif diff['renamed_file']:
|
||||
edit_type = EDIT_TYPE.RENAMED
|
||||
try:
|
||||
original_file_content_str = bytes.decode(original_file_content_str, 'utf-8')
|
||||
new_file_content_str = bytes.decode(new_file_content_str, 'utf-8')
|
||||
except UnicodeDecodeError:
|
||||
logging.warning(
|
||||
f"Cannot decode file {diff['old_path']} or {diff['new_path']} in merge request {self.id_mr}")
|
||||
diff_files.append(
|
||||
FilePatchInfo(original_file_content_str, new_file_content_str, diff['diff'], diff['new_path'],
|
||||
edit_type=edit_type,
|
||||
old_filename=None if diff['old_path'] == diff['new_path'] else diff['old_path']))
|
||||
self.diff_files = diff_files
|
||||
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 publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||
self.diff_files = self.diff_files if self.diff_files else self.get_diff_files()
|
||||
edit_type, found, source_line_no, target_file, target_line_no = self.search_line(relevant_file,
|
||||
relevant_line_in_file)
|
||||
if not found:
|
||||
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
||||
else:
|
||||
if edit_type == 'addition':
|
||||
position = target_line_no - 1
|
||||
else:
|
||||
position = source_line_no - 1
|
||||
d = self.last_diff
|
||||
pos_obj = {'position_type': 'text',
|
||||
'new_path': target_file.filename,
|
||||
'old_path': target_file.old_filename if target_file.old_filename else target_file.filename,
|
||||
'base_sha': d.base_commit_sha, 'start_sha': d.start_commit_sha, 'head_sha': d.head_commit_sha}
|
||||
if edit_type == 'deletion':
|
||||
pos_obj['old_line'] = position
|
||||
elif edit_type == 'addition':
|
||||
pos_obj['new_line'] = position
|
||||
else:
|
||||
pos_obj['new_line'] = position
|
||||
pos_obj['old_line'] = position
|
||||
self.mr.discussions.create({'body': body,
|
||||
'position': pos_obj})
|
||||
|
||||
def publish_code_suggestion(self, body: str,
|
||||
relevant_file: str,
|
||||
relevant_lines_start: int,
|
||||
relevant_lines_end: int):
|
||||
raise "not implemented yet for gitlab"
|
||||
|
||||
def search_line(self, relevant_file, relevant_line_in_file):
|
||||
RE_HUNK_HEADER = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
||||
target_file = None
|
||||
source_line_no = 0
|
||||
target_line_no = 0
|
||||
found = False
|
||||
edit_type = self.get_edit_type(relevant_line_in_file)
|
||||
for file in self.diff_files:
|
||||
if file.filename == relevant_file:
|
||||
target_file = file
|
||||
patch = file.patch
|
||||
patch_lines = patch.splitlines()
|
||||
for i, line in enumerate(patch_lines):
|
||||
if line.startswith('@@'):
|
||||
match = RE_HUNK_HEADER.match(line)
|
||||
if not match:
|
||||
continue
|
||||
start_old, size_old, start_new, size_new, _ = match.groups()
|
||||
source_line_no = int(start_old)
|
||||
target_line_no = int(start_new)
|
||||
continue
|
||||
if line.startswith('-'):
|
||||
source_line_no += 1
|
||||
elif line.startswith('+'):
|
||||
target_line_no += 1
|
||||
elif line.startswith(' '):
|
||||
source_line_no += 1
|
||||
target_line_no += 1
|
||||
if relevant_line_in_file in line:
|
||||
found = True
|
||||
edit_type = self.get_edit_type(line)
|
||||
break
|
||||
elif relevant_line_in_file[0] == '+' and relevant_line_in_file[1:] in line:
|
||||
# The model often adds a '+' to the beginning of the relevant_line_in_file even if originally
|
||||
# it's a context line
|
||||
found = True
|
||||
edit_type = self.get_edit_type(line)
|
||||
break
|
||||
return edit_type, found, source_line_no, target_file, target_line_no
|
||||
|
||||
def get_edit_type(self, relevant_line_in_file):
|
||||
edit_type = 'context'
|
||||
if relevant_line_in_file[0] == '-':
|
||||
edit_type = 'deletion'
|
||||
elif relevant_line_in_file[0] == '+':
|
||||
edit_type = 'addition'
|
||||
return edit_type
|
||||
|
||||
def remove_initial_comment(self):
|
||||
try:
|
||||
for comment in self.temp_comments:
|
||||
@ -90,3 +202,6 @@ class GitLabProvider(GitProvider):
|
||||
def _get_merge_request(self):
|
||||
mr = self.gl.projects.get(self.id_project).mergerequests.get(self.id_mr)
|
||||
return mr
|
||||
|
||||
def get_user_id(self):
|
||||
return None
|
||||
|
73
pr_agent/servers/github_action_runner.py
Normal file
@ -0,0 +1,73 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from pr_agent.config_loader import settings
|
||||
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
||||
from pr_agent.tools.pr_description import PRDescription
|
||||
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
|
||||
try:
|
||||
event_payload = json.load(open(GITHUB_EVENT_PATH, 'r'))
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
print(f"Failed to parse JSON: {e}")
|
||||
return
|
||||
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:
|
||||
body = comment_body.strip().lower()
|
||||
if any(cmd in body for cmd in ["/review", "/review_pr"]):
|
||||
await PRReviewer(pr_url).review()
|
||||
elif any(cmd in body for cmd in ["/describe", "/describe_pr"]):
|
||||
await PRDescription(pr_url).describe()
|
||||
elif any(cmd in body for cmd in ["/improve", "/improve_code"]):
|
||||
await PRCodeSuggestions(pr_url).suggest()
|
||||
elif any(cmd in body for cmd in ["/ask", "/ask_question"]):
|
||||
pattern = r'(/ask|/ask_question)\s*(.*)'
|
||||
matches = re.findall(pattern, comment_body, re.IGNORECASE)
|
||||
if matches:
|
||||
question = matches[0][1]
|
||||
await PRQuestions(pr_url, question).answer()
|
||||
else:
|
||||
print(f"Unknown command: {body}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(run_action())
|
@ -56,7 +56,7 @@ async def handle_request(body):
|
||||
api_url = pull_request.get("url", None)
|
||||
if api_url is None:
|
||||
return {}
|
||||
await agent.handle_request(api_url, "please review")
|
||||
await agent.handle_request(api_url, "/review")
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@ -8,6 +9,11 @@ import aiohttp
|
||||
from pr_agent.agent.pr_agent import PRAgent
|
||||
from pr_agent.config_loader import settings
|
||||
from pr_agent.git_providers import get_git_provider
|
||||
from pr_agent.servers.help import bot_help_text
|
||||
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
||||
from pr_agent.tools.pr_description import PRDescription
|
||||
from pr_agent.tools.pr_questions import PRQuestions
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
|
||||
NOTIFICATION_URL = "https://api.github.com/notifications"
|
||||
@ -25,6 +31,7 @@ async def polling_loop():
|
||||
last_modified = [None]
|
||||
git_provider = get_git_provider()()
|
||||
user_id = git_provider.get_user_id()
|
||||
agent = PRAgent()
|
||||
try:
|
||||
deployment_type = settings.github.deployment_type
|
||||
token = settings.github.user_token
|
||||
@ -83,8 +90,13 @@ async def polling_loop():
|
||||
if user_tag not in comment_body:
|
||||
continue
|
||||
rest_of_comment = comment_body.split(user_tag)[1].strip()
|
||||
agent = PRAgent()
|
||||
await agent.handle_request(pr_url, rest_of_comment)
|
||||
|
||||
success = await agent.handle_request(pr_url, rest_of_comment)
|
||||
if not success:
|
||||
git_provider.set_pr(pr_url)
|
||||
git_provider.publish_comment("### How to use PR-Agent\n" +
|
||||
bot_help_text(user_id))
|
||||
|
||||
elif response.status != 304:
|
||||
print(f"Failed to fetch notifications. Status code: {response.status}")
|
||||
|
||||
|
13
pr_agent/servers/help.py
Normal file
@ -0,0 +1,13 @@
|
||||
commands_text = "> /review - Request a review of the latest update to the PR.\n" \
|
||||
"> /describe - Modify the PR title and description based on the contents of the PR.\n" \
|
||||
"> /improve - Suggest improvements to the code in the PR. " \
|
||||
"These will be provided as pull request comments, ready to commit.\n" \
|
||||
"> /ask <QUESTION> - Pose a question about the PR.\n"
|
||||
|
||||
|
||||
def bot_help_text(user: str):
|
||||
return f"> Tag me in a comment '@{user}' and add one of the following commands:\n" + commands_text
|
||||
|
||||
|
||||
actions_help_text = "> To invoke the PR-Agent, add a comment using one of the following commands:\n" + \
|
||||
commands_text
|
@ -32,3 +32,6 @@ webhook_secret = "<WEBHOOK SECRET>" # Optional, may be commented out.
|
||||
# Gitlab personal access token
|
||||
personal_access_token = ""
|
||||
|
||||
[bitbucket]
|
||||
# Bitbucket personal bearer token
|
||||
bearer_token = ""
|
||||
|
@ -2,24 +2,27 @@
|
||||
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_focused_review=true
|
||||
require_tests_review=true
|
||||
require_security_review=true
|
||||
extended_code_suggestions=false
|
||||
num_code_suggestions=4
|
||||
num_code_suggestions=3
|
||||
inline_code_comments = true
|
||||
|
||||
[pr_questions]
|
||||
|
||||
[pr_code_suggestions]
|
||||
num_code_suggestions=4
|
||||
|
||||
[github]
|
||||
# The type of deployment to create. Valid values are 'app' or 'user'.
|
||||
deployment_type = "user"
|
||||
|
||||
[gitlab]
|
||||
# URL to the gitlab service
|
||||
gitlab_url = "https://gitlab.com"
|
||||
url = "https://gitlab.com"
|
||||
|
||||
# Polling (either project id or namespace/project_name) syntax can be used
|
||||
projects_to_monitor = ['org_name/repo_name']
|
||||
|
79
pr_agent/settings/pr_code_suggestions_prompts.toml
Normal file
@ -0,0 +1,79 @@
|
||||
[pr_code_suggestions_prompt]
|
||||
system="""You are a language model called CodiumAI-PR-Code-Reviewer.
|
||||
Your task is to provide meaningfull non-trivial code suggestions to improve the new code in a PR (the '+' lines).
|
||||
- Try to give 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.
|
||||
- Suggestions should refer only to the 'new hunk' code, and focus on improving the new added code lines, with '+'.
|
||||
- Provide the exact line number range (inclusive) for each issue.
|
||||
- Assume there is additional code in the relevant file that is not included in the diff.
|
||||
- Provide up to {{ num_code_suggestions }} code suggestions.
|
||||
- Make sure not to provide suggestions repeating modifications already implemented in the new PR code (the '+' lines).
|
||||
- Don't output line numbers in the 'improved code' snippets.
|
||||
|
||||
You must use the following JSON schema to format your answer:
|
||||
```json
|
||||
{
|
||||
"Code suggestions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": {{ num_code_suggestions }},
|
||||
"uniqueItems": "true",
|
||||
"items": {
|
||||
"relevant file": {
|
||||
"type": "string",
|
||||
"description": "the relevant file full path"
|
||||
},
|
||||
"suggestion content": {
|
||||
"type": "string",
|
||||
"description": "a concrete suggestion for meaningfully improving the new PR code."
|
||||
},
|
||||
"existing code": {
|
||||
"type": "string",
|
||||
"description": "a code snippet showing authentic relevant code lines from a 'new hunk' section. It must be continuous, correctly formatted and indented, and without line numbers."
|
||||
},
|
||||
"relevant lines": {
|
||||
"type": "string",
|
||||
"description": "the relevant lines in the 'new hunk' sections, in the format of 'start_line-end_line'. For example: '10-15'. They should be derived from the hunk line numbers, and correspond to the 'existing code' snippet above."
|
||||
},
|
||||
"improved code": {
|
||||
"type": "string",
|
||||
"description": "a new code snippet that can be used to replace the relevant lines in 'new hunk' code. Replacement suggestions should be complete, correctly formatted and indented, and without line numbers."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example input:
|
||||
'
|
||||
## src/file1.py
|
||||
---new_hunk---
|
||||
```
|
||||
[new hunk code, annotated with line numbers]
|
||||
```
|
||||
---old_hunk---
|
||||
```
|
||||
[old hunk code]
|
||||
```
|
||||
...
|
||||
'
|
||||
|
||||
Don't repeat the prompt in the answer, and avoid outputting the 'type' and 'description' fields.
|
||||
"""
|
||||
|
||||
user="""PR Info:
|
||||
Title: '{{title}}'
|
||||
Branch: '{{branch}}'
|
||||
Description: '{{description}}'
|
||||
{%- if language %}
|
||||
Main language: {{language}}
|
||||
{%- endif %}
|
||||
|
||||
|
||||
The PR Diff:
|
||||
```
|
||||
{{diff}}
|
||||
```
|
||||
|
||||
Response (should be a valid JSON, and nothing else):
|
||||
```json
|
||||
"""
|
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
|
||||
"""
|
@ -1,9 +1,9 @@
|
||||
[pr_questions_prompt]
|
||||
system="""You are CodiumAI-PR-Reviewer, a language model designed to review git pull requests.
|
||||
Your task is to answer questions about the new PR code (the '+' lines), and provide feedback.
|
||||
Be informative, constructive, and give examples. Try to be as specific as possible, and don't avoid answering the questions.
|
||||
Be informative, constructive, and give examples. Try to be as specific as possible.
|
||||
Don't avoid answering the questions. You must answer the questions, as best as you can, without adding unrelated content.
|
||||
Make sure not to repeat modifications already implemented in the new PR code (the '+' lines).
|
||||
Answer only the questions, and don't add unrelated content.
|
||||
"""
|
||||
|
||||
user="""PR Info:
|
||||
|
@ -3,10 +3,7 @@ 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).
|
||||
- Make sure not to provide suggestions repeating modifications already implemented in the new PR code (the '+' lines).
|
||||
|
||||
You must use the following JSON schema to format your answer:
|
||||
```json
|
||||
@ -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"]
|
||||
@ -40,48 +33,25 @@ You must use the following JSON schema to format your answer:
|
||||
"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,7 +71,6 @@ Example output:
|
||||
"PR Analysis":
|
||||
{
|
||||
"Main theme": "xxx",
|
||||
"Description and title": "Yes",
|
||||
"Type of PR": "Bug fix",
|
||||
{%- if require_tests %}
|
||||
"Relevant tests added": "No",
|
||||
@ -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",
|
||||
},
|
||||
...
|
||||
]
|
||||
|
127
pr_agent/tools/pr_code_suggestions.py
Normal file
@ -0,0 +1,127 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import textwrap
|
||||
|
||||
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, try_fix_json
|
||||
from pr_agent.config_loader import settings
|
||||
from pr_agent.git_providers import get_git_provider, GithubProvider
|
||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||
|
||||
|
||||
class PRCodeSuggestions:
|
||||
def __init__(self, pr_url: str, cli_mode=False):
|
||||
|
||||
self.git_provider = get_git_provider()(pr_url)
|
||||
self.main_language = get_main_pr_language(
|
||||
self.git_provider.get_languages(), self.git_provider.get_files()
|
||||
)
|
||||
self.ai_handler = AiHandler()
|
||||
self.patches_diff = None
|
||||
self.prediction = None
|
||||
self.cli_mode = cli_mode
|
||||
self.vars = {
|
||||
"title": self.git_provider.pr.title,
|
||||
"branch": self.git_provider.get_pr_branch(),
|
||||
"description": self.git_provider.get_pr_description(),
|
||||
"language": self.main_language,
|
||||
"diff": "", # empty diff for initial calculation
|
||||
'num_code_suggestions': settings.pr_code_suggestions.num_code_suggestions,
|
||||
}
|
||||
self.token_handler = TokenHandler(self.git_provider.pr,
|
||||
self.vars,
|
||||
settings.pr_code_suggestions_prompt.system,
|
||||
settings.pr_code_suggestions_prompt.user)
|
||||
|
||||
async def suggest(self):
|
||||
assert type(self.git_provider) == GithubProvider, "Only Github is supported for now"
|
||||
|
||||
logging.info('Generating code suggestions for PR...')
|
||||
if settings.config.publish_review:
|
||||
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
|
||||
logging.info('Getting PR diff...')
|
||||
|
||||
# we are using extended hunk with line numbers for code suggestions
|
||||
self.patches_diff = get_pr_diff(self.git_provider,
|
||||
self.token_handler,
|
||||
add_line_numbers_to_hunks=True,
|
||||
disable_extra_lines=True)
|
||||
|
||||
logging.info('Getting AI prediction...')
|
||||
self.prediction = await self._get_prediction()
|
||||
logging.info('Preparing PR review...')
|
||||
data = self._prepare_pr_code_suggestions()
|
||||
if settings.config.publish_review:
|
||||
logging.info('Pushing PR review...')
|
||||
self.git_provider.remove_initial_comment()
|
||||
logging.info('Pushing inline code comments...')
|
||||
self.push_inline_code_suggestions(data)
|
||||
|
||||
|
||||
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_code_suggestions_prompt.system).render(variables)
|
||||
user_prompt = environment.from_string(settings.pr_code_suggestions_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_code_suggestions(self) -> str:
|
||||
review = self.prediction.strip()
|
||||
data = None
|
||||
try:
|
||||
data = json.loads(review)
|
||||
except json.decoder.JSONDecodeError:
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.info(f"Could not parse json response: {review}")
|
||||
data = try_fix_json(review)
|
||||
return data
|
||||
|
||||
def push_inline_code_suggestions(self, data):
|
||||
for d in data['Code suggestions']:
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.info(f"suggestion: {d}")
|
||||
relevant_file = d['relevant file'].strip()
|
||||
relevant_lines_str = d['relevant lines'].strip()
|
||||
relevant_lines_start = int(relevant_lines_str.split('-')[0]) # absolute position
|
||||
relevant_lines_end = int(relevant_lines_str.split('-')[-1])
|
||||
content = d['suggestion content']
|
||||
existing_code_snippet = d['existing code']
|
||||
new_code_snippet = d['improved code']
|
||||
|
||||
if new_code_snippet:
|
||||
try: # dedent code snippet
|
||||
self.diff_files = self.git_provider.diff_files if self.git_provider.diff_files else self.git_provider.get_diff_files()
|
||||
original_initial_line = None
|
||||
for file in self.diff_files:
|
||||
if file.filename.strip() == relevant_file:
|
||||
original_initial_line = file.head_file.splitlines()[relevant_lines_start - 1]
|
||||
break
|
||||
if original_initial_line:
|
||||
suggested_initial_line = new_code_snippet.splitlines()[0]
|
||||
original_initial_spaces = len(original_initial_line) - len(original_initial_line.lstrip())
|
||||
suggested_initial_spaces = len(suggested_initial_line) - len(suggested_initial_line.lstrip())
|
||||
delta_spaces = original_initial_spaces - suggested_initial_spaces
|
||||
if delta_spaces > 0:
|
||||
new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * " ").rstrip('\n')
|
||||
except Exception as e:
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
|
||||
|
||||
body = f"**Suggestion:** {content}\n```suggestion\n" + new_code_snippet + "\n```"
|
||||
success = self.git_provider.publish_code_suggestion(body=body,
|
||||
relevant_file=relevant_file,
|
||||
relevant_lines_start=relevant_lines_start,
|
||||
relevant_lines_end=relevant_lines_end)
|
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('Generating a PR description...')
|
||||
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
|
@ -11,6 +11,7 @@ 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
|
||||
from pr_agent.servers.help import bot_help_text, actions_help_text
|
||||
|
||||
|
||||
class PRReviewer:
|
||||
@ -33,7 +34,6 @@ class PRReviewer:
|
||||
"require_tests": settings.pr_reviewer.require_tests_review,
|
||||
"require_security": settings.pr_reviewer.require_security_review,
|
||||
"require_focused": settings.pr_reviewer.require_focused_review,
|
||||
'extended_code_suggestions': settings.pr_reviewer.extended_code_suggestions,
|
||||
'num_code_suggestions': settings.pr_reviewer.num_code_suggestions,
|
||||
}
|
||||
self.token_handler = TokenHandler(self.git_provider.pr,
|
||||
@ -44,7 +44,7 @@ class PRReviewer:
|
||||
async def review(self):
|
||||
logging.info('Reviewing PR...')
|
||||
if settings.config.publish_review:
|
||||
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
|
||||
self.git_provider.publish_comment("Preparing review...", 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...')
|
||||
@ -55,6 +55,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):
|
||||
@ -86,21 +89,33 @@ 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()
|
||||
|
||||
if not self.cli_mode:
|
||||
markdown_text += "\n### How to use\n"
|
||||
if user and '[bot]' not in user:
|
||||
markdown_text += f"> Tag me in a comment '@{user}' to ask for a new review after you update the PR.\n"
|
||||
markdown_text += "> You can also tag me and ask any question, " \
|
||||
f"for example '@{user} is the PR ready for merge?'"
|
||||
markdown_text += bot_help_text(user)
|
||||
else:
|
||||
markdown_text += "> Add a comment that says 'review' to ask for a new review " \
|
||||
"after you update the PR.\n"
|
||||
markdown_text += "> You can also add a comment that says 'answer QUESTION', " \
|
||||
"for example 'answer is the PR ready for merge?'"
|
||||
markdown_text += actions_help_text
|
||||
|
||||
if settings.config.verbosity_level >= 2:
|
||||
logging.info(f"Markdown response:\n{markdown_text}")
|
||||
return markdown_text
|
||||
return markdown_text
|
||||
|
||||
def _publish_inline_code_comments(self):
|
||||
review = self.prediction.strip()
|
||||
try:
|
||||
data = json.loads(review)
|
||||
except json.decoder.JSONDecodeError:
|
||||
data = try_fix_json(review)
|
||||
|
||||
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']
|
||||
|
||||
self.git_provider.publish_inline_comment(content, relevant_file, relevant_line_in_file)
|
@ -9,3 +9,4 @@ uvicorn==0.22.0
|
||||
python-gitlab==3.15.0
|
||||
pytest~=7.4.0
|
||||
aiohttp~=3.8.4
|
||||
atlassian-python-api==3.39.0
|
||||
|
@ -46,7 +46,6 @@ 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
|
||||
@ -54,14 +53,12 @@ class TestConvertToMarkdown:
|
||||
'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,7 +68,6 @@ class TestConvertToMarkdown:
|
||||
}
|
||||
expected_output = """\
|
||||
- 🎯 **Main theme:** Test
|
||||
- 🔍 **Description and title:** Test description
|
||||
- 📌 **Type of PR:** Test type
|
||||
- 🧪 **Relevant tests added:** no
|
||||
- ✨ **Focused PR:** Yes
|
||||
@ -110,7 +106,6 @@ 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': {},
|
||||
|
@ -7,18 +7,16 @@ 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", "Description and title": "Yes", "Type of PR": "Bug fix"}, "PR Feedback": {"General PR suggestions": "..., `xxx`...", "Code suggestions": [{"suggestion number": 1, "relevant file": "xxx.py", "suggestion content": "xxx [important]"}, {"suggestion number": 2, "relevant file": "yyy.py", "suggestion content": "yyy [incomp...'
|
||||
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',
|
||||
'Description and title': 'Yes',
|
||||
'Type of PR': 'Bug fix'
|
||||
},
|
||||
'PR Feedback': {
|
||||
'General PR suggestions': '..., `xxx`...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'suggestion number': 1,
|
||||
'relevant file': 'xxx.py',
|
||||
'suggestion content': 'xxx [important]'
|
||||
}
|
||||
@ -28,18 +26,16 @@ class TestTryFixJson:
|
||||
assert try_fix_json(review) == expected_output
|
||||
|
||||
def test_incomplete_code_suggestions_new_line(self):
|
||||
review = '{"PR Analysis": {"Main theme": "xxx", "Description and title": "Yes", "Type of PR": "Bug fix"}, "PR Feedback": {"General PR suggestions": "..., `xxx`...", "Code suggestions": [{"suggestion number": 1, "relevant file": "xxx.py", "suggestion content": "xxx [important]"} \n\t, {"suggestion number": 2, "relevant file": "yyy.py", "suggestion content": "yyy [incomp...'
|
||||
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',
|
||||
'Description and title': 'Yes',
|
||||
'Type of PR': 'Bug fix'
|
||||
},
|
||||
'PR Feedback': {
|
||||
'General PR suggestions': '..., `xxx`...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'suggestion number': 1,
|
||||
'relevant file': 'xxx.py',
|
||||
'suggestion content': 'xxx [important]'
|
||||
}
|
||||
@ -49,18 +45,16 @@ class TestTryFixJson:
|
||||
assert try_fix_json(review) == expected_output
|
||||
|
||||
def test_incomplete_code_suggestions_many_close_brackets(self):
|
||||
review = '{"PR Analysis": {"Main theme": "xxx", "Description and title": "Yes", "Type of PR": "Bug fix"}, "PR Feedback": {"General PR suggestions": "..., `xxx`...", "Code suggestions": [{"suggestion number": 1, "relevant file": "xxx.py", "suggestion content": "xxx [important]"} \n, {"suggestion number": 2, "relevant file": "yyy.py", "suggestion content": "yyy }, [}\n ,incomp.} ,..'
|
||||
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',
|
||||
'Description and title': 'Yes',
|
||||
'Type of PR': 'Bug fix'
|
||||
},
|
||||
'PR Feedback': {
|
||||
'General PR suggestions': '..., `xxx`...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'suggestion number': 1,
|
||||
'relevant file': 'xxx.py',
|
||||
'suggestion content': 'xxx [important]'
|
||||
}
|
||||
@ -70,18 +64,16 @@ class TestTryFixJson:
|
||||
assert try_fix_json(review) == expected_output
|
||||
|
||||
def test_incomplete_code_suggestions_relevant_file(self):
|
||||
review = '{"PR Analysis": {"Main theme": "xxx", "Description and title": "Yes", "Type of PR": "Bug fix"}, "PR Feedback": {"General PR suggestions": "..., `xxx`...", "Code suggestions": [{"suggestion number": 1, "relevant file": "xxx.py", "suggestion content": "xxx [important]"}, {"suggestion number": 2, "relevant file": "yyy.p'
|
||||
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',
|
||||
'Description and title': 'Yes',
|
||||
'Type of PR': 'Bug fix'
|
||||
},
|
||||
'PR Feedback': {
|
||||
'General PR suggestions': '..., `xxx`...',
|
||||
'Code suggestions': [
|
||||
{
|
||||
'suggestion number': 1,
|
||||
'relevant file': 'xxx.py',
|
||||
'suggestion content': 'xxx [important]'
|
||||
}
|
||||
|
@ -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 = " **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,7 +56,6 @@ 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',
|
||||
@ -76,7 +67,6 @@ class TestParseCodeSuggestion:
|
||||
# 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': {
|
||||
|