mirror of
https://github.com/qodo-ai/pr-agent.git
synced 2025-07-04 12:50:38 +08:00
Compare commits
211 Commits
fix/repo_s
...
v0.10
Author | SHA1 | Date | |
---|---|---|---|
416a5495da | |||
a2b27dcac8 | |||
d8e4e2e8fd | |||
896a81d173 | |||
b216af8f04 | |||
388cc740b6 | |||
6214494c84 | |||
762a6981e1 | |||
b362c406bc | |||
7a342d3312 | |||
2e95988741 | |||
9478447141 | |||
082293b48c | |||
e1d92206f3 | |||
557ec72bfe | |||
6b4b16dcf9 | |||
c4899a6c54 | |||
24d82e65cb | |||
2567a6cf27 | |||
94cb6b9795 | |||
e878bbbe36 | |||
7d89b82967 | |||
c5f9bbbf92 | |||
a5e5a82952 | |||
ccbb62b50a | |||
1df36c6a44 | |||
9e5e9afe92 | |||
5e43c202dd | |||
37e6608e68 | |||
f64d5f1e2a | |||
8fdf174dec | |||
29d4f98b19 | |||
737792d83c | |||
7e5889061c | |||
755e04cf65 | |||
44d6c95714 | |||
14610d5375 | |||
f9c832d6cb | |||
c2bec614e5 | |||
49725e92f2 | |||
a1e32d8331 | |||
0293412a42 | |||
10ec0a1812 | |||
69b68b78f5 | |||
c5bc4b44ff | |||
39e5102a2e | |||
6c82bc9a3e | |||
54f41dd603 | |||
094f641fb5 | |||
a35a75eb34 | |||
5a7c118b56 | |||
cf9e0fbbc5 | |||
ef9af261ed | |||
ff79776410 | |||
ec3f2fb485 | |||
94a2a5e527 | |||
ea4bc548fc | |||
1eefd3365b | |||
db37ee819a | |||
e352c98ce8 | |||
e96b03da57 | |||
1d2aedf169 | |||
4c484f8e86 | |||
8a79114ed9 | |||
cd69f43c77 | |||
6d6d864417 | |||
b286c8ed20 | |||
7238c81f0c | |||
62412f8cd4 | |||
5d2bdadb45 | |||
06d030637c | |||
8e3fa3926a | |||
92071fcf1c | |||
fed1c160eb | |||
e37daf6987 | |||
8fc663911f | |||
bb2760ae41 | |||
3548b88463 | |||
c917e48098 | |||
e6ef123ce5 | |||
194bfe1193 | |||
e456cf36aa | |||
fe3527de3c | |||
b99c769b53 | |||
60bdfb78df | |||
c0b3c76884 | |||
e1370a8385 | |||
c623c3baf4 | |||
d0f3a4139d | |||
3ddc7e79d1 | |||
3e14edfd4e | |||
15573e2286 | |||
ce64877063 | |||
6666a128ee | |||
9fbf89670d | |||
ad1c51c536 | |||
9ab7ccd20d | |||
c907f93ab8 | |||
29a8cf8357 | |||
7b6a6c7164 | |||
cf4d007737 | |||
a751bb0ef0 | |||
26d6280a20 | |||
32a19fdab6 | |||
775ccb3f25 | |||
a1c6c57f7b | |||
73bb70fef4 | |||
dcac6c145c | |||
4bda9dfe04 | |||
66644f0224 | |||
e74bb80668 | |||
e06fb534d3 | |||
71a341855e | |||
7d949ad6e2 | |||
4b5f86fcf0 | |||
cd11f51df0 | |||
b40c0b9b23 | |||
816ddeeb9e | |||
11f01a226c | |||
b57ec301e8 | |||
71da20ea7e | |||
c895657310 | |||
eda20ccca9 | |||
aed113cd79 | |||
0ab07a46c6 | |||
5f32e28933 | |||
7538c4dd2f | |||
e3845283f8 | |||
a85921d3c5 | |||
27b64fbcaf | |||
8d50f2ae82 | |||
e97a03f522 | |||
2e3344b5b0 | |||
e1b51eace7 | |||
49e3d5ec5f | |||
afa78ed3fb | |||
72d5e4748e | |||
61d3e1ebf4 | |||
055b5ea700 | |||
3434296792 | |||
ae375c2ff0 | |||
3d5efdf4f3 | |||
9a585de364 | |||
c27dc436c4 | |||
e83747300d | |||
7374243d0b | |||
5c568bc0c5 | |||
22c196cb3b | |||
d2cc856cfc | |||
013a689b33 | |||
d772213cfc | |||
638db96311 | |||
4dffabf397 | |||
6f2bbd3baa | |||
9e41f3780c | |||
f53ec1d0cc | |||
f7666cb59a | |||
a7cb59ca8b | |||
ca0ea77415 | |||
0cf27e5fee | |||
f3bdbfc103 | |||
20e3acdd86 | |||
f965b09571 | |||
e6bea76eee | |||
414f2b6767 | |||
6541575a0e | |||
02570ea797 | |||
b8583c998d | |||
726594600b | |||
c77cc1d6ed | |||
b6c9e01a59 | |||
ec673214c8 | |||
16777a5334 | |||
65bb70a1dd | |||
1a89c7eadf | |||
07617eab5a | |||
f9e4c2b098 | |||
fa24413201 | |||
b6cabda586 | |||
abbce60f18 | |||
5daaaf2c1d | |||
e8f207691e | |||
b0dce4ceae | |||
fc494296d7 | |||
67b4069540 | |||
e6defcc846 | |||
096fcbbc17 | |||
eb7add1c77 | |||
1b6fb3ea53 | |||
c57b70f1d4 | |||
a2c3db463a | |||
193da1c356 | |||
5bc26880b3 | |||
21a1cc970e | |||
954727ad67 | |||
1314898cbf | |||
ff04d459d7 | |||
88ca501c0c | |||
fe284a8f91 | |||
d41fe0cf79 | |||
3673924fe9 | |||
d5c098de73 | |||
9f5c0daa8e | |||
bce2262d4e | |||
e6f1e0520a | |||
d8de89ae33 | |||
428c38e3d9 | |||
7ffdf8de37 | |||
83e670c5df | |||
c324d88be3 | |||
41166dc271 |
1
.github/workflows/pr-agent-review.yaml
vendored
1
.github/workflows/pr-agent-review.yaml
vendored
@ -26,5 +26,6 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
PINECONE.API_KEY: ${{ secrets.PINECONE_API_KEY }}
|
PINECONE.API_KEY: ${{ secrets.PINECONE_API_KEY }}
|
||||||
PINECONE.ENVIRONMENT: ${{ secrets.PINECONE_ENVIRONMENT }}
|
PINECONE.ENVIRONMENT: ${{ secrets.PINECONE_ENVIRONMENT }}
|
||||||
|
GITHUB_ACTION.AUTO_REVIEW: true
|
||||||
|
|
||||||
|
|
||||||
|
2
.pr_agent.toml
Normal file
2
.pr_agent.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[pr_reviewer]
|
||||||
|
enable_review_labels_effort = true
|
@ -1,18 +0,0 @@
|
|||||||
FROM python:3.10 as base
|
|
||||||
|
|
||||||
ENV OPENAI_API_KEY=${OPENAI_API_KEY} \
|
|
||||||
BITBUCKET_BEARER_TOKEN=${BITBUCKET_BEARER_TOKEN} \
|
|
||||||
BITBUCKET_PR_ID=${BITBUCKET_PR_ID} \
|
|
||||||
BITBUCKET_REPO_SLUG=${BITBUCKET_REPO_SLUG} \
|
|
||||||
BITBUCKET_WORKSPACE=${BITBUCKET_WORKSPACE}
|
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
ADD pyproject.toml .
|
|
||||||
ADD requirements.txt .
|
|
||||||
RUN pip install . && rm pyproject.toml requirements.txt
|
|
||||||
ENV PYTHONPATH=/app
|
|
||||||
ADD pr_agent pr_agent
|
|
||||||
ADD bitbucket_pipeline/entrypoint.sh /
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
157
INSTALL.md
157
INSTALL.md
@ -4,66 +4,73 @@
|
|||||||
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.
|
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.
|
2. A GitHub\GitLab\BitBucket personal access token (classic) with the repo scope.
|
||||||
|
|
||||||
There are several ways to use PR-Agent:
|
There are several ways to use PR-Agent:
|
||||||
|
|
||||||
- [Method 1: Use Docker image (no installation required)](INSTALL.md#method-1-use-docker-image-no-installation-required)
|
**Locally**
|
||||||
- [Method 2: Run from source](INSTALL.md#method-2-run-from-source)
|
- [Using Docker image (no installation required)](INSTALL.md#use-docker-image-no-installation-required)
|
||||||
- [Method 3: Run as a GitHub Action](INSTALL.md#method-3-run-as-a-github-action)
|
- [Run from source](INSTALL.md#run-from-source)
|
||||||
- [Method 4: Run as a polling server](INSTALL.md#method-4-run-as-a-polling-server)
|
|
||||||
- [Method 5: Run as a GitHub App](INSTALL.md#method-5-run-as-a-github-app)
|
**GitHub specific methods**
|
||||||
- [Method 6: Deploy as a Lambda Function](INSTALL.md#method-6---deploy-as-a-lambda-function)
|
- [Run as a GitHub Action](INSTALL.md#run-as-a-github-action)
|
||||||
- [Method 7: AWS CodeCommit](INSTALL.md#method-7---aws-codecommit-setup)
|
- [Run as a polling server](INSTALL.md#run-as-a-polling-server)
|
||||||
- [Method 8: Run a GitLab webhook server](INSTALL.md#method-8---run-a-gitlab-webhook-server)
|
- [Run as a GitHub App](INSTALL.md#run-as-a-github-app)
|
||||||
- [Method 9: Run as a Bitbucket Pipeline](INSTALL.md#method-9-run-as-a-bitbucket-pipeline)
|
- [Deploy as a Lambda Function](INSTALL.md#deploy-as-a-lambda-function)
|
||||||
|
- [AWS CodeCommit](INSTALL.md#aws-codecommit-setup)
|
||||||
|
|
||||||
|
**GitLab specific methods**
|
||||||
|
- [Run a GitLab webhook server](INSTALL.md#run-a-gitlab-webhook-server)
|
||||||
|
|
||||||
|
**BitBucket specific methods**
|
||||||
|
- [Run as a Bitbucket Pipeline](INSTALL.md#run-as-a-bitbucket-pipeline)
|
||||||
|
- [Run on a hosted app](INSTALL.md#run-on-a-hosted-bitbucket-app)
|
||||||
---
|
---
|
||||||
|
|
||||||
### Method 1: Use Docker image (no installation required)
|
### 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:
|
A list of the relevant tools can be found in the [tools guide](./docs/TOOLS_GUIDE.md).
|
||||||
|
|
||||||
1. To request a review for a PR, run the following command:
|
To invoke a tool (for example `review`), you can run directly from the Docker image. Here's how:
|
||||||
|
|
||||||
For GitHub:
|
- For GitHub:
|
||||||
```
|
```
|
||||||
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent --pr_url <pr_url> review
|
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent:latest --pr_url <pr_url> review
|
||||||
```
|
```
|
||||||
For GitLab:
|
|
||||||
|
- For GitLab:
|
||||||
```
|
```
|
||||||
docker run --rm -it -e OPENAI.KEY=<your key> -e CONFIG.GIT_PROVIDER=gitlab -e GITLAB.PERSONAL_ACCESS_TOKEN=<your token> codiumai/pr-agent --pr_url <pr_url> review
|
docker run --rm -it -e OPENAI.KEY=<your key> -e CONFIG.GIT_PROVIDER=gitlab -e GITLAB.PERSONAL_ACCESS_TOKEN=<your token> codiumai/pr-agent:latest --pr_url <pr_url> review
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: If you have a dedicated GitLab instance, you need to specify the custom url as variable:
|
||||||
|
```
|
||||||
|
docker run --rm -it -e OPENAI.KEY=<your key> -e CONFIG.GIT_PROVIDER=gitlab -e GITLAB.PERSONAL_ACCESS_TOKEN=<your token> GITLAB.URL=<your gitlab instance url> codiumai/pr-agent:latest --pr_url <pr_url> review
|
||||||
|
```
|
||||||
|
|
||||||
|
- For BitBucket:
|
||||||
|
```
|
||||||
|
docker run --rm -it -e CONFIG.GIT_PROVIDER=bitbucket -e OPENAI.KEY=$OPENAI_API_KEY -e BITBUCKET.BEARER_TOKEN=$BITBUCKET_BEARER_TOKEN codiumai/pr-agent:latest --pr_url=<pr_url> review
|
||||||
|
```
|
||||||
|
|
||||||
For other git providers, update CONFIG.GIT_PROVIDER accordingly, and check the `pr_agent/settings/.secrets_template.toml` file for the environment variables expected names and values.
|
For other git providers, update CONFIG.GIT_PROVIDER accordingly, and check the `pr_agent/settings/.secrets_template.toml` file for the environment variables expected names and values.
|
||||||
|
|
||||||
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>"
|
|
||||||
```
|
|
||||||
Note: If you want to ensure you're running a specific version of the Docker image, consider using the image's digest.
|
|
||||||
The digest is a unique identifier for a specific version of an image. You can pull and run an image using its digest by referencing it like so: repository@sha256:digest. Always ensure you're using the correct and trusted digest for your operations.
|
|
||||||
|
|
||||||
1. To request a review for a PR using a specific digest, run the following command:
|
If you want to ensure you're running a specific version of the Docker image, consider using the image's digest:
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent@sha256:71b5ee15df59c745d352d84752d01561ba64b6d51327f97d46152f0c58a5f678 --pr_url <pr_url> review
|
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent@sha256:71b5ee15df59c745d352d84752d01561ba64b6d51327f97d46152f0c58a5f678 --pr_url <pr_url> review
|
||||||
```
|
```
|
||||||
|
|
||||||
2. To ask a question about a PR using the same digest, run the following command:
|
Or you can run a [specific released versions](./RELEASE_NOTES.md) of pr-agent, for example:
|
||||||
```bash
|
```
|
||||||
docker run --rm -it -e OPENAI.KEY=<your key> -e GITHUB.USER_TOKEN=<your token> codiumai/pr-agent@sha256:71b5ee15df59c745d352d84752d01561ba64b6d51327f97d46152f0c58a5f678 --pr_url <pr_url> ask "<your question>"
|
codiumai/pr-agent@v0.9
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
### Run from source
|
||||||
|
|
||||||
1. Clone this repository:
|
1. Clone this repository:
|
||||||
|
|
||||||
@ -93,11 +100,14 @@ python3 -m pr_agent.cli --pr_url <pr_url> review
|
|||||||
python3 -m pr_agent.cli --pr_url <pr_url> ask <your question>
|
python3 -m pr_agent.cli --pr_url <pr_url> ask <your question>
|
||||||
python3 -m pr_agent.cli --pr_url <pr_url> describe
|
python3 -m pr_agent.cli --pr_url <pr_url> describe
|
||||||
python3 -m pr_agent.cli --pr_url <pr_url> improve
|
python3 -m pr_agent.cli --pr_url <pr_url> improve
|
||||||
|
python3 -m pr_agent.cli --pr_url <pr_url> add_docs
|
||||||
|
python3 -m pr_agent.cli --issue_url <issue_url> similar_issue
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Method 3: Run as a GitHub Action
|
### Run as a GitHub Action
|
||||||
|
|
||||||
You can use our pre-built Github Action Docker image to run PR-Agent as a Github Action.
|
You can use our pre-built Github Action Docker image to run PR-Agent as a Github Action.
|
||||||
|
|
||||||
@ -167,10 +177,11 @@ When you open your next PR, you should see a comment from `github-actions` bot w
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Method 4: Run as a polling server
|
### Run as a polling server
|
||||||
Request reviews by tagging your Github user on a PR
|
Request reviews by tagging your GitHub user on a PR
|
||||||
|
|
||||||
|
Follow [steps 1-3](#run-as-a-github-action) of the GitHub Action setup.
|
||||||
|
|
||||||
Follow steps 1-3 of method 2.
|
|
||||||
Run the following command to start the server:
|
Run the following command to start the server:
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -179,7 +190,7 @@ python pr_agent/servers/github_polling.py
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Method 5: Run as a GitHub App
|
### Run as a GitHub App
|
||||||
Allowing you to automate the review process on your private or public repositories.
|
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).
|
1. Create a GitHub App from the [Github Developer Portal](https://docs.github.com/en/developers/apps/creating-a-github-app).
|
||||||
@ -260,13 +271,13 @@ docker push codiumai/pr-agent:github_app # Push to your Docker repository
|
|||||||
9. Install the app by navigating to the "Install App" tab and selecting your desired repositories.
|
9. Install the app by navigating to the "Install App" tab and selecting your desired repositories.
|
||||||
|
|
||||||
> **Note:** When running PR-Agent from GitHub App, the default configuration file (configuration.toml) will be loaded.<br>
|
> **Note:** When running PR-Agent from GitHub App, the default configuration file (configuration.toml) will be loaded.<br>
|
||||||
> However, you can override the default tool parameters by uploading a local configuration file<br>
|
> However, you can override the default tool parameters by uploading a local configuration file `.pr_agent.toml`<br>
|
||||||
> For more information please check out [CONFIGURATION.md](Usage.md#working-from-github-app-pre-built-repo)
|
> For more information please check out the [USAGE GUIDE](./Usage.md#working-with-github-app)
|
||||||
---
|
---
|
||||||
|
|
||||||
### Method 6 - Deploy as a Lambda Function
|
### Deploy as a Lambda Function
|
||||||
|
|
||||||
1. Follow steps 1-5 of [Method 5](#method-5-run-as-a-github-app).
|
1. Follow steps 1-5 of [Method 5](#run-as-a-github-app).
|
||||||
2. Build a docker image that can be used as a lambda function
|
2. Build a docker image that can be used as a lambda function
|
||||||
```shell
|
```shell
|
||||||
docker buildx build --platform=linux/amd64 . -t codiumai/pr-agent:serverless -f docker/Dockerfile.lambda
|
docker buildx build --platform=linux/amd64 . -t codiumai/pr-agent:serverless -f docker/Dockerfile.lambda
|
||||||
@ -278,12 +289,12 @@ docker push codiumai/pr-agent:github_app # Push to your Docker repository
|
|||||||
```
|
```
|
||||||
4. Create a lambda function that uses the uploaded image. Set the lambda timeout to be at least 3m.
|
4. Create a lambda function that uses the uploaded image. Set the lambda timeout to be at least 3m.
|
||||||
5. Configure the lambda function to have a Function URL.
|
5. Configure the lambda function to have a Function URL.
|
||||||
6. Go back to steps 8-9 of [Method 5](#method-5-run-as-a-github-app) with the function url as your Webhook URL.
|
6. Go back to steps 8-9 of [Method 5](#run-as-a-github-app) with the function url as your Webhook URL.
|
||||||
The Webhook URL would look like `https://<LAMBDA_FUNCTION_URL>/api/v1/github_webhooks`
|
The Webhook URL would look like `https://<LAMBDA_FUNCTION_URL>/api/v1/github_webhooks`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Method 7 - AWS CodeCommit Setup
|
### AWS CodeCommit Setup
|
||||||
|
|
||||||
Not all features have been added to CodeCommit yet. As of right now, CodeCommit has been implemented to run the pr-agent CLI on the command line, using AWS credentials stored in environment variables. (More features will be added in the future.) The following is a set of instructions to have pr-agent do a review of your CodeCommit pull request from the command line:
|
Not all features have been added to CodeCommit yet. As of right now, CodeCommit has been implemented to run the pr-agent CLI on the command line, using AWS credentials stored in environment variables. (More features will be added in the future.) The following is a set of instructions to have pr-agent do a review of your CodeCommit pull request from the command line:
|
||||||
|
|
||||||
@ -353,7 +364,7 @@ PYTHONPATH="/PATH/TO/PROJECTS/pr-agent" python pr_agent/cli.py \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Method 8 - Run a GitLab webhook server
|
### Run a GitLab webhook server
|
||||||
|
|
||||||
1. From the GitLab workspace or group, create an access token. Enable the "api" scope only.
|
1. From the GitLab workspace or group, create an access token. Enable the "api" scope only.
|
||||||
2. Generate a random secret for your app, and save it for later. For example, you can use:
|
2. Generate a random secret for your app, and save it for later. For example, you can use:
|
||||||
@ -361,7 +372,7 @@ PYTHONPATH="/PATH/TO/PROJECTS/pr-agent" python pr_agent/cli.py \
|
|||||||
```
|
```
|
||||||
WEBHOOK_SECRET=$(python -c "import secrets; print(secrets.token_hex(10))")
|
WEBHOOK_SECRET=$(python -c "import secrets; print(secrets.token_hex(10))")
|
||||||
```
|
```
|
||||||
3. Follow the instructions to build the Docker image, setup a secrets file and deploy on your own server from [Method 5](#method-5-run-as-a-github-app) steps 4-7.
|
3. Follow the instructions to build the Docker image, setup a secrets file and deploy on your own server from [Method 5](#run-as-a-github-app) steps 4-7.
|
||||||
4. In the secrets file, fill in the following:
|
4. In the secrets file, fill in the following:
|
||||||
- Your OpenAI key.
|
- Your OpenAI key.
|
||||||
- In the [gitlab] section, fill in personal_access_token and shared_secret. The access token can be a personal access token, or a group or project access token.
|
- In the [gitlab] section, fill in personal_access_token and shared_secret. The access token can be a personal access token, or a group or project access token.
|
||||||
@ -372,62 +383,36 @@ In the "Trigger" section, check the ‘comments’ and ‘merge request events
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Method 9: Run as a Bitbucket Pipeline
|
### Run as a Bitbucket Pipeline
|
||||||
|
|
||||||
|
|
||||||
You can use our pre-build Bitbucket-Pipeline docker image to run as Bitbucket-Pipeline.
|
You can use the Bitbucket Pipeline system to run PR-Agent on every pull request open or update.
|
||||||
|
|
||||||
1. Add the following file in your repository bitbucket_pipelines.yml
|
1. Add the following file in your repository bitbucket_pipelines.yml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
pipelines:
|
pipelines:
|
||||||
pull-requests:
|
pull-requests:
|
||||||
'**':
|
'**':
|
||||||
- step:
|
- step:
|
||||||
name: PR Agent Pipeline
|
name: PR Agent Review
|
||||||
caches:
|
image: python:3.10
|
||||||
- pip
|
|
||||||
image: python:3.8
|
|
||||||
services:
|
services:
|
||||||
- docker
|
- docker
|
||||||
script:
|
script:
|
||||||
- git clone https://github.com/Codium-ai/pr-agent.git
|
- docker run -e CONFIG.GIT_PROVIDER=bitbucket -e OPENAI.KEY=$OPENAI_API_KEY -e BITBUCKET.BEARER_TOKEN=$BITBUCKET_BEARER_TOKEN codiumai/pr-agent:latest --pr_url=https://bitbucket.org/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG/pull-requests/$BITBUCKET_PR_ID review
|
||||||
- cd pr-agent
|
|
||||||
- docker build -t bitbucket_runner:latest -f Dockerfile.bitbucket_pipeline .
|
|
||||||
- docker run -e OPENAI_API_KEY=$OPENAI_API_KEY -e BITBUCKET_BEARER_TOKEN=$BITBUCKET_BEARER_TOKEN -e BITBUCKET_PR_ID=$BITBUCKET_PR_ID -e BITBUCKET_REPO_SLUG=$BITBUCKET_REPO_SLUG -e BITBUCKET_WORKSPACE=$BITBUCKET_WORKSPACE bitbucket_runner:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Add the following secret to your repository under Repository settings > Pipelines > Repository variables.
|
2. Add the following secure variables to your repository under Repository settings > Pipelines > Repository variables.
|
||||||
OPENAI_API_KEY: <your key>
|
OPENAI_API_KEY: <your key>
|
||||||
BITBUCKET_BEARER_TOKEN: <your token>
|
BITBUCKET_BEARER_TOKEN: <your token>
|
||||||
|
|
||||||
3. To get BITBUCKET_BEARER_TOKEN follow these steps
|
You can get a Bitbucket token for your repository by following Repository Settings -> Security -> Access Tokens.
|
||||||
So here is my step by step tutorial
|
|
||||||
i) Insert your workspace name instead of {workspace_name} and go to the following link in order to create an OAuth consumer.
|
|
||||||
|
|
||||||
https://bitbucket.org/{workspace_name}/workspace/settings/api
|
|
||||||
|
|
||||||
set callback URL to http://localhost:8976 (doesn't need to be a real server there)
|
|
||||||
select permissions: repository -> read
|
|
||||||
|
|
||||||
ii) use consumer's Key as a {client_id} and open the following URL in the browser
|
|
||||||
|
|
||||||
https://bitbucket.org/site/oauth2/authorize?client_id={client_id}&response_type=code
|
|
||||||
|
|
||||||
iii)
|
|
||||||
after you press "Grant access" in the browser it will redirect you to
|
|
||||||
|
|
||||||
http://localhost:8976?code=<CODE>
|
|
||||||
|
|
||||||
iv) use the code from the previous step and consumer's Key as a {client_id}, and consumer's Secret as {client_secret}
|
|
||||||
|
|
||||||
curl -X POST -u "{client_id}:{client_secret}" \
|
|
||||||
https://bitbucket.org/site/oauth2/access_token \
|
|
||||||
-d grant_type=authorization_code \
|
|
||||||
-d code={code} \
|
|
||||||
|
|
||||||
|
|
||||||
After completing this steps, you just to place this access token in the repository varibles.
|
### Run using CodiumAI-hosted Bitbucket app
|
||||||
|
|
||||||
|
Please contact <support@codium.ai> or visit [CodiumAI pricing page](https://www.codium.ai/pricing/) if you're interested in a hosted BitBucket app solution that provides full functionality including PR reviews and comment handling. It's based on the [bitbucket_app.py](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/git_providers/bitbucket_provider.py) implementation.
|
||||||
|
|
||||||
|
|
||||||
=======
|
=======
|
||||||
|
21
README.md
21
README.md
@ -28,16 +28,18 @@ CodiumAI `PR-Agent` is an open-source tool aiming to help developers review pull
|
|||||||
\
|
\
|
||||||
‣ **Update Changelog ([`/update_changelog`](./docs/UPDATE_CHANGELOG.md))**: Automatically updating the CHANGELOG.md file with the PR changes.
|
‣ **Update Changelog ([`/update_changelog`](./docs/UPDATE_CHANGELOG.md))**: Automatically updating the CHANGELOG.md file with the PR changes.
|
||||||
\
|
\
|
||||||
‣ **Find similar issue ([`/similar_issue`](./docs/SIMILAR_ISSUE.md))**: Automatically retrieves and presents similar issues
|
‣ **Find Similar Issue ([`/similar_issue`](./docs/SIMILAR_ISSUE.md))**: Automatically retrieves and presents similar issues
|
||||||
\
|
\
|
||||||
‣ **Add Documentation ([`/add_docs`](./docs/ADD_DOCUMENTATION.md))**: Automatically adds documentation to un-documented functions/classes in the PR.
|
‣ **Add Documentation ([`/add_docs`](./docs/ADD_DOCUMENTATION.md))**: Automatically adds documentation to un-documented functions/classes in the PR.
|
||||||
|
\
|
||||||
|
‣ **Generate Custom Labels ([`/generate_labels`](./docs/GENERATE_CUSTOM_LABELS.md))**: Automatically suggests custom labels based on the PR code changes.
|
||||||
|
|
||||||
See the [Usage Guide](./Usage.md) for instructions how to run the different tools from _CLI_, _online usage_, Or by _automatically triggering_ them when a new PR is opened.
|
See the [Installation Guide](./INSTALL.md) for instructions how to install and run the tool on different platforms.
|
||||||
|
|
||||||
|
See the [Usage Guide](./Usage.md) for instructions how to run the different tools from _CLI_, _online usage_, or by _automatically triggering_ them when a new PR is opened.
|
||||||
|
|
||||||
See the [Tools Guide](./docs/TOOLS_GUIDE.md) for detailed description of the different tools.
|
See the [Tools Guide](./docs/TOOLS_GUIDE.md) for detailed description of the different tools.
|
||||||
|
|
||||||
See the [Release notes](./RELEASE_NOTES.md) for updates on the latest changes.
|
|
||||||
|
|
||||||
<h3>Example results:</h3>
|
<h3>Example results:</h3>
|
||||||
</div>
|
</div>
|
||||||
<h4><a href="https://github.com/Codium-ai/pr-agent/pull/229#issuecomment-1687561986">/describe:</a></h4>
|
<h4><a href="https://github.com/Codium-ai/pr-agent/pull/229#issuecomment-1687561986">/describe:</a></h4>
|
||||||
@ -115,6 +117,7 @@ See the [Release notes](./RELEASE_NOTES.md) for updates on the latest changes.
|
|||||||
| | Update CHANGELOG.md | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | |
|
| | Update CHANGELOG.md | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | |
|
||||||
| | Find similar issue | :white_check_mark: | | | | | |
|
| | Find similar issue | :white_check_mark: | | | | | |
|
||||||
| | Add Documentation | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | :white_check_mark: |
|
| | Add Documentation | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | :white_check_mark: |
|
||||||
|
| | Generate Labels | :white_check_mark: | :white_check_mark: | | | | |
|
||||||
| | | | | | | |
|
| | | | | | | |
|
||||||
| USAGE | CLI | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
| USAGE | CLI | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
| | App / webhook | :white_check_mark: | :white_check_mark: | | | |
|
| | App / webhook | :white_check_mark: | :white_check_mark: | | | |
|
||||||
@ -204,6 +207,9 @@ Here are some advantages of PR-Agent:
|
|||||||
- [x] Documentation (is the PR properly documented)
|
- [x] Documentation (is the PR properly documented)
|
||||||
- [ ] ...
|
- [ ] ...
|
||||||
|
|
||||||
|
See the [Release notes](./RELEASE_NOTES.md) for updates on the latest changes.
|
||||||
|
|
||||||
|
|
||||||
## Similar Projects
|
## Similar Projects
|
||||||
|
|
||||||
- [CodiumAI - Meaningful tests for busy devs](https://github.com/Codium-ai/codiumai-vscode-release) (although various capabilities are much more advanced in the CodiumAI IDE plugins)
|
- [CodiumAI - Meaningful tests for busy devs](https://github.com/Codium-ai/codiumai-vscode-release) (although various capabilities are much more advanced in the CodiumAI IDE plugins)
|
||||||
@ -211,7 +217,12 @@ Here are some advantages of PR-Agent:
|
|||||||
- [openai-pr-reviewer](https://github.com/coderabbitai/openai-pr-reviewer)
|
- [openai-pr-reviewer](https://github.com/coderabbitai/openai-pr-reviewer)
|
||||||
- [CodeReview BOT](https://github.com/anc95/ChatGPT-CodeReview)
|
- [CodeReview BOT](https://github.com/anc95/ChatGPT-CodeReview)
|
||||||
- [AI-Maintainer](https://github.com/merwanehamadi/AI-Maintainer)
|
- [AI-Maintainer](https://github.com/merwanehamadi/AI-Maintainer)
|
||||||
|
|
||||||
|
## Data Privacy
|
||||||
|
|
||||||
|
If you use self-host PR-Agent, e.g. via CLI running on your computer, with your OpenAI API key, it is between you and OpenAI. You can read their API data privacy policy here:
|
||||||
|
https://openai.com/enterprise-privacy
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
[](https://discord.gg/kG35uSHDBc)
|
[](https://discord.gg/kG35uSHDBc)
|
||||||
|
@ -1,3 +1,50 @@
|
|||||||
|
## [Version 0.10] - 2023-11-15
|
||||||
|
- codiumai/pr-agent:0.10
|
||||||
|
- codiumai/pr-agent:0.10-github_app
|
||||||
|
- codiumai/pr-agent:0.10-bitbucket-app
|
||||||
|
- codiumai/pr-agent:0.10-gitlab_webhook
|
||||||
|
- codiumai/pr-agent:0.10-github_polling
|
||||||
|
- codiumai/pr-agent:0.10-github_action
|
||||||
|
|
||||||
|
### Added::Algo
|
||||||
|
- Review tool now works with [persistent comments](https://github.com/Codium-ai/pr-agent/pull/451) by default
|
||||||
|
- Bitbucket now publishes review suggestions with [code links](https://github.com/Codium-ai/pr-agent/pull/428)
|
||||||
|
- Enabling to limit [max number of tokens](https://github.com/Codium-ai/pr-agent/pull/437/files)
|
||||||
|
- Support ['gpt-4-1106-preview'](https://github.com/Codium-ai/pr-agent/pull/437/files) model
|
||||||
|
- Support for Google's [Vertex AI](https://github.com/Codium-ai/pr-agent/pull/436)
|
||||||
|
- Implementing [thresholds](https://github.com/Codium-ai/pr-agent/pull/423) for incremental PR reviews
|
||||||
|
- Decoupled custom labels from [PR type](https://github.com/Codium-ai/pr-agent/pull/431)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed bug in [parsing quotes](https://github.com/Codium-ai/pr-agent/pull/446) in CLI
|
||||||
|
- Preserve [user-added labels](https://github.com/Codium-ai/pr-agent/pull/433) in pull requests
|
||||||
|
- Bug fixes in GitLab and BitBucket
|
||||||
|
|
||||||
|
## [Version 0.9] - 2023-10-29
|
||||||
|
- codiumai/pr-agent:0.9
|
||||||
|
- codiumai/pr-agent:0.9-github_app
|
||||||
|
- codiumai/pr-agent:0.9-bitbucket-app
|
||||||
|
- codiumai/pr-agent:0.9-gitlab_webhook
|
||||||
|
- codiumai/pr-agent:0.9-github_polling
|
||||||
|
- codiumai/pr-agent:0.9-github_action
|
||||||
|
|
||||||
|
### Added::Algo
|
||||||
|
- New tool - [generate_labels](https://github.com/Codium-ai/pr-agent/blob/main/docs/GENERATE_CUSTOM_LABELS.md)
|
||||||
|
- New ability to use [customize labels](https://github.com/Codium-ai/pr-agent/blob/main/docs/GENERATE_CUSTOM_LABELS.md#how-to-enable-custom-labels) on the `review` and `describe` tools.
|
||||||
|
- New tool - [add_docs](https://github.com/Codium-ai/pr-agent/blob/main/docs/ADD_DOCUMENTATION.md)
|
||||||
|
- GitHub Action: Can now use a `.pr_agent.toml` file to control configuration parameters (see [Usage Guide](./Usage.md#working-with-github-action)).
|
||||||
|
- GitHub App: Added ability to trigger tools on [push events](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#github-app-automatic-tools-for-new-code-pr-push)
|
||||||
|
- Support custom domain URLs for Azure devops integration (see [link](https://github.com/Codium-ai/pr-agent/pull/381)).
|
||||||
|
- PR Description default mode is now in [bullet points](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L35).
|
||||||
|
|
||||||
|
### Added::Documentation
|
||||||
|
Significant documentation updates (see [Installation Guide](https://github.com/Codium-ai/pr-agent/blob/main/INSTALL.md), [Usage Guide](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md), and [Tools Guide](https://github.com/Codium-ai/pr-agent/blob/main/docs/TOOLS_GUIDE.md))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed support for BitBucket pipeline (see [link](https://github.com/Codium-ai/pr-agent/pull/386))
|
||||||
|
- Fixed a bug in `review -i` tool
|
||||||
|
- Added blacklist for specific file extensions in `add_docs` tool (see [link](https://github.com/Codium-ai/pr-agent/pull/385/))
|
||||||
|
|
||||||
## [Version 0.8] - 2023-09-27
|
## [Version 0.8] - 2023-09-27
|
||||||
- codiumai/pr-agent:0.8
|
- codiumai/pr-agent:0.8
|
||||||
- codiumai/pr-agent:0.8-github_app
|
- codiumai/pr-agent:0.8-github_app
|
||||||
|
96
Usage.md
96
Usage.md
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
### Introduction
|
### Introduction
|
||||||
|
|
||||||
See the **[installation guide](/INSTALL.md)** for instructions on how to setup PR-Agent. After installation, there are three basic ways to invoke CodiumAI PR-Agent:
|
After [installation](/INSTALL.md), there are three basic ways to invoke CodiumAI PR-Agent:
|
||||||
1. Locally running a CLI command
|
1. Locally running a CLI command
|
||||||
2. Online usage - by [commenting](https://github.com/Codium-ai/pr-agent/pull/229#issuecomment-1695021901) on a PR
|
2. Online usage - by [commenting](https://github.com/Codium-ai/pr-agent/pull/229#issuecomment-1695021901) on a PR
|
||||||
3. Enabling PR-Agent tools to run automatically when a new PR is opened
|
3. Enabling PR-Agent tools to run automatically when a new PR is opened
|
||||||
@ -108,19 +108,41 @@ Any configuration value in [configuration file](pr_agent/settings/configuration.
|
|||||||
|
|
||||||
|
|
||||||
### Working with GitHub App
|
### Working with GitHub App
|
||||||
When running PR-Agent from [GitHub App](INSTALL.md#method-5-run-as-a-github-app), the default configurations from a pre-built docker will be initially loaded.
|
When running PR-Agent from GitHub App, the default [configuration file](pr_agent/settings/configuration.toml) from a pre-built docker will be initially loaded.
|
||||||
|
|
||||||
|
By uploading a local `.pr_agent.toml` file, you can edit and customize any configuration parameter.
|
||||||
|
|
||||||
|
For example, if you set in `.pr_agent.toml`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[pr_reviewer]
|
||||||
|
num_code_suggestions=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Than you will overwrite the default number of code suggestions to be 1.
|
||||||
|
|
||||||
#### GitHub app automatic tools
|
#### GitHub app automatic tools
|
||||||
The [github_app](pr_agent/settings/configuration.toml#L56) section defines GitHub app specific configurations.
|
The [github_app](pr_agent/settings/configuration.toml#L76) section defines GitHub app-specific configurations.
|
||||||
An important parameter is `pr_commands`, which is a list of tools that will be **run automatically** when a new PR is opened:
|
In this section you can define configurations to control the conditions for which tools will **run automatically**.
|
||||||
|
|
||||||
|
##### GitHub app automatic tools for PR actions
|
||||||
|
The GitHub app can respond to the following actions on a PR:
|
||||||
|
1. `opened` - Opening a new PR
|
||||||
|
2. `reopened` - Reopening a closed PR
|
||||||
|
3. `ready_for_review` - Moving a PR from Draft to Open
|
||||||
|
4. `review_requested` - Specifically requesting review (in the PR reviewers list) from the `github-actions[bot]` user
|
||||||
|
|
||||||
|
The configuration parameter `handle_pr_actions` defines the list of actions for which the GitHub app will trigger the PR-Agent.
|
||||||
|
The configuration parameter `pr_commands` defines the list of tools that will be **run automatically** when one of the above action happens (e.g. a new PR is opened):
|
||||||
```
|
```
|
||||||
[github_app]
|
[github_app]
|
||||||
|
handle_pr_actions = ['opened', 'reopened', 'ready_for_review', 'review_requested']
|
||||||
pr_commands = [
|
pr_commands = [
|
||||||
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||||
"/auto_review",
|
"/auto_review",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
This means that when a new PR is opened, PR-Agent will run the `describe` and `auto_review` tools.
|
This means that when a new PR is opened/reopened or marked as ready for review, PR-Agent will run the `describe` and `auto_review` tools.
|
||||||
For the describe tool, the `add_original_user_description` and `keep_original_user_title` parameters will be set to true.
|
For the describe tool, the `add_original_user_description` and `keep_original_user_title` parameters will be set to true.
|
||||||
|
|
||||||
You can override the default tool parameters by uploading a local configuration file called `.pr_agent.toml` to the root of your repo.
|
You can override the default tool parameters by uploading a local configuration file called `.pr_agent.toml` to the root of your repo.
|
||||||
@ -135,11 +157,27 @@ When a new PR is opened, PR-Agent will run the `describe` tool with the above pa
|
|||||||
To cancel the automatic run of all the tools, set:
|
To cancel the automatic run of all the tools, set:
|
||||||
```
|
```
|
||||||
[github_app]
|
[github_app]
|
||||||
pr_commands = ""
|
handle_pr_actions = []
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### GitHub app automatic tools for new code (PR push)
|
||||||
|
In addition to running automatic tools when a PR is opened, the GitHub app can also respond to new code that is pushed to an open PR.
|
||||||
|
|
||||||
Note that a local `.pr_agent.toml` file enables you to edit and customize the default parameters of any tool, not just the ones that are run automatically.
|
The configuration toggle `handle_push_trigger` can be used to enable this feature.
|
||||||
|
The configuration parameter `push_commands` defines the list of tools that will be **run automatically** when new code is pushed to the PR.
|
||||||
|
```
|
||||||
|
[github_app]
|
||||||
|
handle_push_trigger = true
|
||||||
|
push_commands = [
|
||||||
|
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||||
|
"/auto_review -i --pr_reviewer.remove_previous_review_comment=true",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
The means that when new code is pushed to the PR, the PR-Agent will run the `describe` and incremental `auto_review` tools.
|
||||||
|
For the describe tool, the `add_original_user_description` and `keep_original_user_title` parameters will be set to true.
|
||||||
|
For the `auto_review` tool, it will run in incremental mode, and the `remove_previous_review_comment` parameter will be set to true.
|
||||||
|
|
||||||
|
Much like the configurations for `pr_commands`, you can override the default tool paramteres by uploading a local configuration file to the root of your repo.
|
||||||
|
|
||||||
#### Editing the prompts
|
#### Editing the prompts
|
||||||
The prompts for the various PR-Agent tools are defined in the `pr_agent/settings` folder.
|
The prompts for the various PR-Agent tools are defined in the `pr_agent/settings` folder.
|
||||||
@ -159,21 +197,28 @@ user="""
|
|||||||
Note that the new prompt will need to generate an output compatible with the relevant [post-process function](./pr_agent/tools/pr_description.py#L137).
|
Note that the new prompt will need to generate an output compatible with the relevant [post-process function](./pr_agent/tools/pr_description.py#L137).
|
||||||
|
|
||||||
### Working with GitHub Action
|
### Working with GitHub Action
|
||||||
You can configure settings in GitHub action by adding environment variables under the env section in `.github/workflows/pr_agent.yml` file. Some examples:
|
You can configure settings in GitHub action by adding environment variables under the env section in `.github/workflows/pr_agent.yml` file.
|
||||||
|
Specifically, start by setting the following environment variables:
|
||||||
```yaml
|
```yaml
|
||||||
env:
|
env:
|
||||||
# ... previous environment values
|
OPENAI_KEY: ${{ secrets.OPENAI_KEY }} # Make sure to add your OpenAI key to your repo secrets
|
||||||
OPENAI.ORG: "<Your organization name under your OpenAI account>"
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Make sure to add your GitHub token to your repo secrets
|
||||||
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false" # Disable tests review
|
github_action.auto_review: "true" # enable\disable auto review
|
||||||
PR_CODE_SUGGESTIONS.NUM_CODE_SUGGESTIONS: 6 # Increase number of code suggestions
|
github_action.auto_describe: "true" # enable\disable auto describe
|
||||||
github_action.auto_review: "true" # Enable auto review
|
github_action.auto_improve: "false" # enable\disable auto improve
|
||||||
github_action.auto_describe: "true" # Enable auto describe
|
|
||||||
github_action.auto_improve: "false" # Disable auto improve
|
|
||||||
```
|
```
|
||||||
specifically, `github_action.auto_review`, `github_action.auto_describe` and `github_action.auto_improve` are used to enable/disable automatic tools that run when a new PR is opened.
|
`github_action.auto_review`, `github_action.auto_describe` and `github_action.auto_improve` are used to enable/disable automatic tools that run when a new PR is opened.
|
||||||
|
|
||||||
If not set, the default option is that only the `review` tool will run automatically when a new PR is opened.
|
If not set, the default option is that only the `review` tool will run automatically when a new PR is opened.
|
||||||
|
|
||||||
|
Note that you can give additional config parameters by adding environment variables to `.github/workflows/pr_agent.yml`, or by using a `.pr_agent.toml` file in the root of your repo, similar to the GitHub App usage.
|
||||||
|
|
||||||
|
For example, you can set an environment variable: `pr_description.add_original_user_description=false`, or add a `.pr_agent.toml` file with the following content:
|
||||||
|
```
|
||||||
|
[pr_description]
|
||||||
|
add_original_user_description = false
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Changing a model
|
### Changing a model
|
||||||
|
|
||||||
See [here](pr_agent/algo/__init__.py) for the list of available models.
|
See [here](pr_agent/algo/__init__.py) for the list of available models.
|
||||||
@ -258,6 +303,23 @@ key = ...
|
|||||||
|
|
||||||
Also review the [AiHandler](pr_agent/algo/ai_handler.py) file for instruction how to set keys for other models.
|
Also review the [AiHandler](pr_agent/algo/ai_handler.py) file for instruction how to set keys for other models.
|
||||||
|
|
||||||
|
#### Vertex AI
|
||||||
|
|
||||||
|
To use Google's Vertex AI platform and its associated models (chat-bison/codechat-bison) set:
|
||||||
|
|
||||||
|
```
|
||||||
|
[config] # in configuration.toml
|
||||||
|
model = "vertex_ai/codechat-bison"
|
||||||
|
|
||||||
|
[vertexai] # in .secrets.toml
|
||||||
|
vertex_project = "my-google-cloud-project"
|
||||||
|
vertex_location = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
Your [application default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) will be used for authentication so there is no need to set explicit credentials in most environments.
|
||||||
|
|
||||||
|
If you do want to set explicit credentials then you can use the `GOOGLE_APPLICATION_CREDENTIALS` environment variable set to a path to a json credentials file.
|
||||||
|
|
||||||
### Working with large PRs
|
### Working with large PRs
|
||||||
|
|
||||||
The default mode of CodiumAI is to have a single call per tool, using GPT-4, which has a token limit of 8000 tokens.
|
The default mode of CodiumAI is to have a single call per tool, using GPT-4, which has a token limit of 8000 tokens.
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
python /app/pr_agent/servers/bitbucket_pipeline_runner.py
|
|
@ -26,19 +26,17 @@ Under the section 'pr_description', the [configuration file](./../pr_agent/setti
|
|||||||
- `keep_original_user_title`: if set to true, the tool will keep the original PR title, and won't change it. Default is false.
|
- `keep_original_user_title`: if set to true, the tool will keep the original PR title, and won't change it. Default is false.
|
||||||
|
|
||||||
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...".
|
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...".
|
||||||
|
- To enable `custom labels`, apply the configuration changes described [here](./GENERATE_CUSTOM_LABELS.md#configuration-changes)
|
||||||
#### Markers template
|
- `enable_pr_type`: if set to false, it will not show the `PR type` as a text value in the description content. Default is true.
|
||||||
|
|
||||||
|
### Markers template
|
||||||
|
|
||||||
markers enable to easily integrate user's content and auto-generated content, with a template-like mechanism.
|
markers enable to easily integrate user's content and auto-generated content, with a template-like mechanism.
|
||||||
|
|
||||||
- `use_description_markers`: if set to true, the tool will use markers template. It replaces every marker of the form `pr_agent:marker_name` with the relevant content. Default is false.
|
|
||||||
|
|
||||||
For example, if the PR original description was:
|
For example, if the PR original description was:
|
||||||
```
|
```
|
||||||
User content...
|
User content...
|
||||||
|
|
||||||
## PR Type:
|
|
||||||
pr_agent:pr_type
|
|
||||||
|
|
||||||
## PR Description:
|
## PR Description:
|
||||||
pr_agent:summary
|
pr_agent:summary
|
||||||
@ -46,6 +44,21 @@ pr_agent:summary
|
|||||||
## PR Walkthrough:
|
## PR Walkthrough:
|
||||||
pr_agent:walkthrough
|
pr_agent:walkthrough
|
||||||
```
|
```
|
||||||
The marker `pr_agent:pr_type` will be replaced with the PR type, `pr_agent:summary` will be replaced with the PR summary, and `pr_agent:walkthrough` will be replaced with the PR walkthrough.
|
The marker `pr_agent:summary` will be replaced with the PR summary, and `pr_agent:walkthrough` will be replaced with the PR walkthrough.
|
||||||
|
|
||||||
|
##### Example:
|
||||||
|
```
|
||||||
|
env:
|
||||||
|
pr_description.use_description_markers: 'true'
|
||||||
|
```
|
||||||
|
|
||||||
|
<kbd><img src=./../pics/describe_markers_before.png width="768"></kbd>
|
||||||
|
|
||||||
|
==>
|
||||||
|
|
||||||
|
<kbd><img src=./../pics/describe_markers_after.png width="768"></kbd>
|
||||||
|
|
||||||
|
##### Configuration params:
|
||||||
|
|
||||||
|
- `use_description_markers`: if set to true, the tool will use markers template. It replaces every marker of the form `pr_agent:marker_name` with the relevant content. Default is false.
|
||||||
- `include_generated_by_header`: if set to true, the tool will add a dedicated header: 'Generated by PR Agent at ...' to any automatic content. Default is true.
|
- `include_generated_by_header`: if set to true, the tool will add a dedicated header: 'Generated by PR Agent at ...' to any automatic content. Default is true.
|
||||||
|
41
docs/GENERATE_CUSTOM_LABELS.md
Normal file
41
docs/GENERATE_CUSTOM_LABELS.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Generate Custom Labels
|
||||||
|
The `generate_labels` tool scans the PR code changes, and given a list of labels and their descriptions, it automatically suggests labels that match the PR code changes.
|
||||||
|
|
||||||
|
It can be invoked manually by commenting on any PR:
|
||||||
|
```
|
||||||
|
/generate_labels
|
||||||
|
```
|
||||||
|
For example:
|
||||||
|
|
||||||
|
If we wish to add detect changes to SQL queries in a given PR, we can add the following custom label along with its description:
|
||||||
|
|
||||||
|
<kbd><img src=./../pics/custom_labels_list.png width="768"></kbd>
|
||||||
|
|
||||||
|
When running the `generate_labels` tool on a PR that includes changes in SQL queries, it will automatically suggest the custom label:
|
||||||
|
<kbd><img src=./../pics/custom_label_published.png width="768"></kbd>
|
||||||
|
|
||||||
|
### How to enable custom labels
|
||||||
|
|
||||||
|
Note that in addition to the dedicated tool `generate_labels`, the custom labels will also be used by the `review` and `describe` tools.
|
||||||
|
|
||||||
|
#### CLI
|
||||||
|
To enable custom labels, you need to apply the [configuration changes](#configuration-changes) to the [custom_labels file](./../pr_agent/settings/custom_labels.toml):
|
||||||
|
|
||||||
|
#### GitHub Action and GitHub App
|
||||||
|
To enable custom labels, you need to apply the [configuration changes](#configuration-changes) to the local `.pr_agent.toml` file in you repository.
|
||||||
|
|
||||||
|
#### Configuration changes
|
||||||
|
- Change `enable_custom_labels` to True: This will turn off the default labels and enable the custom labels provided in the custom_labels.toml file.
|
||||||
|
- Add the custom labels. It should be formatted as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
[config]
|
||||||
|
enable_custom_labels=true
|
||||||
|
|
||||||
|
[custom_labels."Custom Label Name"]
|
||||||
|
description = "Description of when AI should suggest this label"
|
||||||
|
|
||||||
|
[custom_labels."Custom Label 2"]
|
||||||
|
description = "Description of when AI should suggest this label 2"
|
||||||
|
```
|
||||||
|
|
@ -31,4 +31,15 @@ Under the section 'pr_code_suggestions', the [configuration file](./../pr_agent/
|
|||||||
- `num_code_suggestions_per_chunk`: number of code suggestions provided by the 'improve' tool, per chunk. Default is 8.
|
- `num_code_suggestions_per_chunk`: number of code suggestions provided by the 'improve' tool, per chunk. Default is 8.
|
||||||
- `rank_extended_suggestions`: if set to true, the tool will rank the suggestions, based on importance. Default is true.
|
- `rank_extended_suggestions`: if set to true, the tool will rank the suggestions, based on importance. Default is true.
|
||||||
- `max_number_of_calls`: maximum number of chunks. Default is 5.
|
- `max_number_of_calls`: maximum number of chunks. Default is 5.
|
||||||
- `final_clip_factor`: factor to remove suggestions with low confidence. Default is 0.9.
|
- `final_clip_factor`: factor to remove suggestions with low confidence. Default is 0.9.
|
||||||
|
|
||||||
|
|
||||||
|
#### A note on code suggestions quality
|
||||||
|
|
||||||
|
- With current level of AI for code (GPT-4), mistakes can happen. Not all the suggestions will be perfect, and a user should not accept all of them automatically.
|
||||||
|
|
||||||
|
- Suggestions are not meant to be [simplistic](./../pr_agent/settings/pr_code_suggestions_prompts.toml#L34). Instead, they aim to give deep feedback and raise questions, ideas and thoughts to the user, who can then use his judgment, experience, and understanding of the code base.
|
||||||
|
|
||||||
|
- Recommended to use the 'extra_instructions' field to guide the model to suggestions that are more relevant to the specific needs of the project.
|
||||||
|
|
||||||
|
- Best quality will be obtained by using 'improve --extended' mode.
|
@ -16,24 +16,46 @@ The `review` tool can also be triggered automatically every time a new PR is ope
|
|||||||
|
|
||||||
Under the section 'pr_reviewer', the [configuration file](./../pr_agent/settings/configuration.toml#L16) contains options to customize the 'review' tool:
|
Under the section 'pr_reviewer', the [configuration file](./../pr_agent/settings/configuration.toml#L16) contains options to customize the 'review' tool:
|
||||||
|
|
||||||
|
#### enable\\disable features
|
||||||
- `require_focused_review`: if set to true, the tool will add a section - 'is the PR a focused one'. Default is false.
|
- `require_focused_review`: if set to true, the tool will add a section - 'is the PR a focused one'. Default is false.
|
||||||
- `require_score_review`: if set to true, the tool will add a section that scores the PR. Default is false.
|
- `require_score_review`: if set to true, the tool will add a section that scores the PR. Default is false.
|
||||||
- `require_tests_review`: if set to true, the tool will add a section that checks if the PR contains tests. Default is true.
|
- `require_tests_review`: if set to true, the tool will add a section that checks if the PR contains tests. Default is true.
|
||||||
- `require_security_review`: if set to true, the tool will add a section that checks if the PR contains security issues. Default is true.
|
- `require_security_review`: if set to true, the tool will add a section that checks if the PR contains security issues. Default is true.
|
||||||
- `require_estimate_effort_to_review`: if set to true, the tool will add a section that estimates thed effort needed to review the PR. Default is true.
|
- `require_estimate_effort_to_review`: if set to true, the tool will add a section that estimates thed effort needed to review the PR. Default is true.
|
||||||
|
#### general options
|
||||||
- `num_code_suggestions`: number of code suggestions provided by the 'review' tool. Default is 4.
|
- `num_code_suggestions`: number of code suggestions provided by the 'review' tool. Default is 4.
|
||||||
- `inline_code_comments`: if set to true, the tool will publish the code suggestions as comments on the code diff. Default is false.
|
- `inline_code_comments`: if set to true, the tool will publish the code suggestions as comments on the code diff. Default is false.
|
||||||
- `automatic_review`: if set to false, no automatic reviews will be done. Default is true.
|
- `automatic_review`: if set to false, no automatic reviews will be done. Default is true.
|
||||||
|
- `remove_previous_review_comment`: if set to true, the tool will remove the previous review comment before adding a new one. Default is false.
|
||||||
|
- `persistent_comment`: if set to true, the review comment will be persistent, meaning that every new review request will edit the previous one. Default is true.
|
||||||
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...".
|
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...".
|
||||||
|
#### review labels
|
||||||
|
- `enable_review_labels_security`: if set to true, the tool will publish a 'possible security issue' label if it detects a security issue. Default is true.
|
||||||
|
- `enable_review_labels_effort`: if set to true, the tool will publish a 'Review effort [1-5]: x' label. Default is false.
|
||||||
|
- To enable `custom labels`, apply the configuration changes described [here](./GENERATE_CUSTOM_LABELS.md#configuration-changes)
|
||||||
#### Incremental Mode
|
#### Incremental Mode
|
||||||
For an incremental review, which only considers changes since the last PR-Agent review, this can be useful when working on the PR in an iterative manner, and you want to focus on the changes since the last review instead of reviewing the entire PR again, the following command can be used:
|
For an incremental review, which only considers changes since the last PR-Agent review, this can be useful when working on the PR in an iterative manner, and you want to focus on the changes since the last review instead of reviewing the entire PR again, the following command can be used:
|
||||||
```
|
```
|
||||||
/improve -i
|
/review -i
|
||||||
```
|
```
|
||||||
Note that the incremental mode is only available for GitHub.
|
Note that the incremental mode is only available for GitHub.
|
||||||
|
|
||||||
<kbd><img src=./../pics/incremental_review.png width="768"></kbd>
|
<kbd><img src=./../pics/incremental_review.png width="768"></kbd>
|
||||||
|
|
||||||
|
Under the section 'pr_reviewer', the [configuration file](./../pr_agent/settings/configuration.toml#L16) contains options to customize the 'review -i' tool.
|
||||||
|
These configurations can be used to control the rate at which the incremental review tool will create new review comments when invoked automatically, to prevent making too much noise in the PR.
|
||||||
|
- `minimal_commits_for_incremental_review`: Minimal number of commits since the last review that are required to create incremental review.
|
||||||
|
If there are less than the specified number of commits since the last review, the tool will not perform any action.
|
||||||
|
Default is 0 - the tool will always run, no matter how many commits since the last review.
|
||||||
|
- `minimal_minutes_for_incremental_review`: Minimal number of minutes that need to pass since the last reviewed commit to create incremental review.
|
||||||
|
If less that the specified number of minutes have passed between the last reviewed commit and running this command, the tool will not perform any action.
|
||||||
|
Default is 0 - the tool will always run, no matter how much time have passed since the last reviewed commit.
|
||||||
|
- `require_all_thresholds_for_incremental_review`: If set to true, all the previous thresholds must be met for incremental review to run. If false, only one is enough to run the tool.
|
||||||
|
For example, if `minimal_commits_for_incremental_review=2` and `minimal_minutes_for_incremental_review=2`, and we have 3 commits since the last review, but the last reviewed commit is from 1 minute ago:
|
||||||
|
When `require_all_thresholds_for_incremental_review=true` the incremental review __will not__ run, because only 1 out of 2 conditions were met (we have enough commits but the last review is too recent),
|
||||||
|
but when `require_all_thresholds_for_incremental_review=false` the incremental review __will__ run, because one condition is enough (we have 3 commits which is more than the configured 2).
|
||||||
|
Default is false - the tool will run as long as at least once conditions is met.
|
||||||
|
|
||||||
#### PR Reflection
|
#### PR Reflection
|
||||||
By invoking:
|
By invoking:
|
||||||
```
|
```
|
||||||
@ -43,4 +65,15 @@ The tool will first ask the author questions about the PR, and will guide the re
|
|||||||
|
|
||||||
<kbd><img src=./../pics/reflection_questions.png width="768"></kbd>
|
<kbd><img src=./../pics/reflection_questions.png width="768"></kbd>
|
||||||
<kbd><img src=./../pics/reflection_answers.png width="768"></kbd>
|
<kbd><img src=./../pics/reflection_answers.png width="768"></kbd>
|
||||||
<kbd><img src=./../pics/reflection_insights.png width="768"></kbd>
|
<kbd><img src=./../pics/reflection_insights.png width="768"></kbd>
|
||||||
|
|
||||||
|
|
||||||
|
#### A note on code suggestions quality
|
||||||
|
|
||||||
|
- With current level of AI for code (GPT-4), mistakes can happen. Not all the suggestions will be perfect, and a user should not accept all of them automatically.
|
||||||
|
|
||||||
|
- Suggestions are not meant to be [simplistic](./../pr_agent/settings/pr_reviewer_prompts.toml#L29). Instead, they aim to give deep feedback and raise questions, ideas and thoughts to the user, who can then use his judgment, experience, and understanding of the code base.
|
||||||
|
|
||||||
|
- Recommended to use the 'extra_instructions' field to guide the model to suggestions that are more relevant to the specific needs of the project.
|
||||||
|
|
||||||
|
- Unlike the 'review' feature, which does a lot of things, the ['improve --extended'](./IMPROVE.md) feature is dedicated only to suggestions, and usually gives better results.
|
@ -6,5 +6,6 @@
|
|||||||
- [SIMILAR_ISSUE](./SIMILAR_ISSUE.md)
|
- [SIMILAR_ISSUE](./SIMILAR_ISSUE.md)
|
||||||
- [UPDATE CHANGELOG](./UPDATE_CHANGELOG.md)
|
- [UPDATE CHANGELOG](./UPDATE_CHANGELOG.md)
|
||||||
- [ADD DOCUMENTATION](./ADD_DOCUMENTATION.md)
|
- [ADD DOCUMENTATION](./ADD_DOCUMENTATION.md)
|
||||||
|
- [GENERATE CUSTOM LABELS](./GENERATE_CUSTOM_LABELS.md)
|
||||||
|
|
||||||
See the **[installation guide](/INSTALL.md)** for instructions on how to setup PR-Agent.
|
See the **[installation guide](/INSTALL.md)** for instructions on how to setup PR-Agent.
|
BIN
pics/custom_label_published.png
Normal file
BIN
pics/custom_label_published.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 253 KiB |
BIN
pics/custom_labels_list.png
Normal file
BIN
pics/custom_labels_list.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
BIN
pics/describe_markers_after.png
Normal file
BIN
pics/describe_markers_after.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 224 KiB |
BIN
pics/describe_markers_before.png
Normal file
BIN
pics/describe_markers_before.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
@ -1,21 +1,18 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
import shlex
|
import shlex
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from pr_agent.algo.utils import update_settings_from_args
|
from pr_agent.algo.utils import update_settings_from_args
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
|
||||||
from pr_agent.git_providers.utils import apply_repo_settings
|
from pr_agent.git_providers.utils import apply_repo_settings
|
||||||
from pr_agent.tools.pr_add_docs import PRAddDocs
|
from pr_agent.tools.pr_add_docs import PRAddDocs
|
||||||
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
||||||
|
from pr_agent.tools.pr_config import PRConfig
|
||||||
from pr_agent.tools.pr_description import PRDescription
|
from pr_agent.tools.pr_description import PRDescription
|
||||||
|
from pr_agent.tools.pr_generate_labels import PRGenerateLabels
|
||||||
from pr_agent.tools.pr_information_from_user import PRInformationFromUser
|
from pr_agent.tools.pr_information_from_user import PRInformationFromUser
|
||||||
from pr_agent.tools.pr_similar_issue import PRSimilarIssue
|
|
||||||
from pr_agent.tools.pr_questions import PRQuestions
|
from pr_agent.tools.pr_questions import PRQuestions
|
||||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||||
|
from pr_agent.tools.pr_similar_issue import PRSimilarIssue
|
||||||
from pr_agent.tools.pr_update_changelog import PRUpdateChangelog
|
from pr_agent.tools.pr_update_changelog import PRUpdateChangelog
|
||||||
from pr_agent.tools.pr_config import PRConfig
|
|
||||||
|
|
||||||
command2class = {
|
command2class = {
|
||||||
"auto_review": PRReviewer,
|
"auto_review": PRReviewer,
|
||||||
@ -35,6 +32,7 @@ command2class = {
|
|||||||
"settings": PRConfig,
|
"settings": PRConfig,
|
||||||
"similar_issue": PRSimilarIssue,
|
"similar_issue": PRSimilarIssue,
|
||||||
"add_docs": PRAddDocs,
|
"add_docs": PRAddDocs,
|
||||||
|
"generate_labels": PRGenerateLabels,
|
||||||
}
|
}
|
||||||
|
|
||||||
commands = list(command2class.keys())
|
commands = list(command2class.keys())
|
||||||
@ -48,10 +46,13 @@ class PRAgent:
|
|||||||
apply_repo_settings(pr_url)
|
apply_repo_settings(pr_url)
|
||||||
|
|
||||||
# Then, apply user specific settings if exists
|
# Then, apply user specific settings if exists
|
||||||
request = request.replace("'", "\\'")
|
if isinstance(request, str):
|
||||||
lexer = shlex.shlex(request, posix=True)
|
request = request.replace("'", "\\'")
|
||||||
lexer.whitespace_split = True
|
lexer = shlex.shlex(request, posix=True)
|
||||||
action, *args = list(lexer)
|
lexer.whitespace_split = True
|
||||||
|
action, *args = list(lexer)
|
||||||
|
else:
|
||||||
|
action, *args = request
|
||||||
args = update_settings_from_args(args)
|
args = update_settings_from_args(args)
|
||||||
|
|
||||||
action = action.lstrip("/").lower()
|
action = action.lstrip("/").lower()
|
||||||
|
@ -8,9 +8,14 @@ MAX_TOKENS = {
|
|||||||
'gpt-4': 8000,
|
'gpt-4': 8000,
|
||||||
'gpt-4-0613': 8000,
|
'gpt-4-0613': 8000,
|
||||||
'gpt-4-32k': 32000,
|
'gpt-4-32k': 32000,
|
||||||
|
'gpt-4-1106-preview': 128000, # 128K, but may be limited by config.max_model_tokens
|
||||||
'claude-instant-1': 100000,
|
'claude-instant-1': 100000,
|
||||||
'claude-2': 100000,
|
'claude-2': 100000,
|
||||||
'command-nightly': 4096,
|
'command-nightly': 4096,
|
||||||
'replicate/llama-2-70b-chat:2c1608e18606fad2812020dc541930f2d0495ce32eee50074220b87300bc16e1': 4096,
|
'replicate/llama-2-70b-chat:2c1608e18606fad2812020dc541930f2d0495ce32eee50074220b87300bc16e1': 4096,
|
||||||
'meta-llama/Llama-2-7b-chat-hf': 4096
|
'meta-llama/Llama-2-7b-chat-hf': 4096,
|
||||||
|
'vertex_ai/codechat-bison': 6144,
|
||||||
|
'vertex_ai/codechat-bison-32k': 32000,
|
||||||
|
'codechat-bison': 6144,
|
||||||
|
'codechat-bison-32k': 32000,
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
@ -7,6 +6,8 @@ from litellm import acompletion
|
|||||||
from openai.error import APIError, RateLimitError, Timeout, TryAgain
|
from openai.error import APIError, RateLimitError, Timeout, TryAgain
|
||||||
from retry import retry
|
from retry import retry
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
OPENAI_RETRIES = 5
|
OPENAI_RETRIES = 5
|
||||||
|
|
||||||
|
|
||||||
@ -22,39 +23,43 @@ class AiHandler:
|
|||||||
Initializes the OpenAI API key and other settings from a configuration file.
|
Initializes the OpenAI API key and other settings from a configuration file.
|
||||||
Raises a ValueError if the OpenAI key is missing.
|
Raises a ValueError if the OpenAI key is missing.
|
||||||
"""
|
"""
|
||||||
try:
|
self.azure = False
|
||||||
|
|
||||||
|
if get_settings().get("OPENAI.KEY", None):
|
||||||
openai.api_key = get_settings().openai.key
|
openai.api_key = get_settings().openai.key
|
||||||
litellm.openai_key = get_settings().openai.key
|
litellm.openai_key = get_settings().openai.key
|
||||||
if get_settings().get("litellm.use_client"):
|
if get_settings().get("litellm.use_client"):
|
||||||
litellm_token = get_settings().get("litellm.LITELLM_TOKEN")
|
litellm_token = get_settings().get("litellm.LITELLM_TOKEN")
|
||||||
assert litellm_token, "LITELLM_TOKEN is required"
|
assert litellm_token, "LITELLM_TOKEN is required"
|
||||||
os.environ["LITELLM_TOKEN"] = litellm_token
|
os.environ["LITELLM_TOKEN"] = litellm_token
|
||||||
litellm.use_client = True
|
litellm.use_client = True
|
||||||
self.azure = False
|
if get_settings().get("OPENAI.ORG", None):
|
||||||
if get_settings().get("OPENAI.ORG", None):
|
litellm.organization = get_settings().openai.org
|
||||||
litellm.organization = get_settings().openai.org
|
if get_settings().get("OPENAI.API_TYPE", None):
|
||||||
if get_settings().get("OPENAI.API_TYPE", None):
|
if get_settings().openai.api_type == "azure":
|
||||||
if get_settings().openai.api_type == "azure":
|
self.azure = True
|
||||||
self.azure = True
|
litellm.azure_key = get_settings().openai.key
|
||||||
litellm.azure_key = get_settings().openai.key
|
if get_settings().get("OPENAI.API_VERSION", None):
|
||||||
if get_settings().get("OPENAI.API_VERSION", None):
|
litellm.api_version = get_settings().openai.api_version
|
||||||
litellm.api_version = get_settings().openai.api_version
|
if get_settings().get("OPENAI.API_BASE", None):
|
||||||
if get_settings().get("OPENAI.API_BASE", None):
|
litellm.api_base = get_settings().openai.api_base
|
||||||
litellm.api_base = get_settings().openai.api_base
|
if get_settings().get("ANTHROPIC.KEY", None):
|
||||||
if get_settings().get("ANTHROPIC.KEY", None):
|
litellm.anthropic_key = get_settings().anthropic.key
|
||||||
litellm.anthropic_key = get_settings().anthropic.key
|
if get_settings().get("COHERE.KEY", None):
|
||||||
if get_settings().get("COHERE.KEY", None):
|
litellm.cohere_key = get_settings().cohere.key
|
||||||
litellm.cohere_key = get_settings().cohere.key
|
if get_settings().get("REPLICATE.KEY", None):
|
||||||
if get_settings().get("REPLICATE.KEY", None):
|
litellm.replicate_key = get_settings().replicate.key
|
||||||
litellm.replicate_key = get_settings().replicate.key
|
if get_settings().get("REPLICATE.KEY", None):
|
||||||
if get_settings().get("REPLICATE.KEY", None):
|
litellm.replicate_key = get_settings().replicate.key
|
||||||
litellm.replicate_key = get_settings().replicate.key
|
if get_settings().get("HUGGINGFACE.KEY", None):
|
||||||
if get_settings().get("HUGGINGFACE.KEY", None):
|
litellm.huggingface_key = get_settings().huggingface.key
|
||||||
litellm.huggingface_key = get_settings().huggingface.key
|
if get_settings().get("HUGGINGFACE.API_BASE", None):
|
||||||
if get_settings().get("HUGGINGFACE.API_BASE", None):
|
litellm.api_base = get_settings().huggingface.api_base
|
||||||
litellm.api_base = get_settings().huggingface.api_base
|
if get_settings().get("VERTEXAI.VERTEX_PROJECT", None):
|
||||||
except AttributeError as e:
|
litellm.vertex_project = get_settings().vertexai.vertex_project
|
||||||
raise ValueError("OpenAI key is required") from e
|
litellm.vertex_location = get_settings().get(
|
||||||
|
"VERTEXAI.VERTEX_LOCATION", None
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def deployment_id(self):
|
def deployment_id(self):
|
||||||
@ -88,34 +93,34 @@ class AiHandler:
|
|||||||
try:
|
try:
|
||||||
deployment_id = self.deployment_id
|
deployment_id = self.deployment_id
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.debug(
|
get_logger().debug(
|
||||||
f"Generating completion with {model}"
|
f"Generating completion with {model}"
|
||||||
f"{(' from deployment ' + deployment_id) if deployment_id else ''}"
|
f"{(' from deployment ' + deployment_id) if deployment_id else ''}"
|
||||||
)
|
)
|
||||||
if self.azure:
|
if self.azure:
|
||||||
model = 'azure/' + model
|
model = 'azure/' + model
|
||||||
|
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||||
response = await acompletion(
|
response = await acompletion(
|
||||||
model=model,
|
model=model,
|
||||||
deployment_id=deployment_id,
|
deployment_id=deployment_id,
|
||||||
messages=[
|
messages=messages,
|
||||||
{"role": "system", "content": system},
|
|
||||||
{"role": "user", "content": user}
|
|
||||||
],
|
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
force_timeout=get_settings().config.ai_timeout
|
force_timeout=get_settings().config.ai_timeout
|
||||||
)
|
)
|
||||||
except (APIError, Timeout, TryAgain) as e:
|
except (APIError, Timeout, TryAgain) as e:
|
||||||
logging.error("Error during OpenAI inference: ", e)
|
get_logger().error("Error during OpenAI inference: ", e)
|
||||||
raise
|
raise
|
||||||
except (RateLimitError) as e:
|
except (RateLimitError) as e:
|
||||||
logging.error("Rate limit error during OpenAI inference: ", e)
|
get_logger().error("Rate limit error during OpenAI inference: ", e)
|
||||||
raise
|
raise
|
||||||
except (Exception) as e:
|
except (Exception) as e:
|
||||||
logging.error("Unknown error during OpenAI inference: ", e)
|
get_logger().error("Unknown error during OpenAI inference: ", e)
|
||||||
raise TryAgain from e
|
raise TryAgain from e
|
||||||
if response is None or len(response["choices"]) == 0:
|
if response is None or len(response["choices"]) == 0:
|
||||||
raise TryAgain
|
raise TryAgain
|
||||||
resp = response["choices"][0]['message']['content']
|
resp = response["choices"][0]['message']['content']
|
||||||
finish_reason = response["choices"][0]["finish_reason"]
|
finish_reason = response["choices"][0]["finish_reason"]
|
||||||
print(resp, finish_reason)
|
usage = response.get("usage")
|
||||||
|
get_logger().info("AI response", response=resp, messages=messages, finish_reason=finish_reason,
|
||||||
|
model=model, usage=usage)
|
||||||
return resp, finish_reason
|
return resp, finish_reason
|
||||||
|
@ -23,7 +23,7 @@ def filter_ignored(files):
|
|||||||
|
|
||||||
# keep filenames that _don't_ match the ignore regex
|
# keep filenames that _don't_ match the ignore regex
|
||||||
for r in compiled_patterns:
|
for r in compiled_patterns:
|
||||||
files = [f for f in files if not r.match(f.filename)]
|
files = [f for f in files if (f.filename and not r.match(f.filename))]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Could not filter file list: {e}")
|
print(f"Could not filter file list: {e}")
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
|
from pr_agent.git_providers.git_provider import EDIT_TYPE
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
def extend_patch(original_file_str, patch_str, num_lines) -> str:
|
def extend_patch(original_file_str, patch_str, num_lines) -> str:
|
||||||
@ -63,7 +65,7 @@ def extend_patch(original_file_str, patch_str, num_lines) -> str:
|
|||||||
extended_patch_lines.append(line)
|
extended_patch_lines.append(line)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.error(f"Failed to extend patch: {e}")
|
get_logger().error(f"Failed to extend patch: {e}")
|
||||||
return patch_str
|
return patch_str
|
||||||
|
|
||||||
# finish previous hunk
|
# finish previous hunk
|
||||||
@ -114,7 +116,7 @@ def omit_deletion_hunks(patch_lines) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def handle_patch_deletions(patch: str, original_file_content_str: str,
|
def handle_patch_deletions(patch: str, original_file_content_str: str,
|
||||||
new_file_content_str: str, file_name: str) -> str:
|
new_file_content_str: str, file_name: str, edit_type: EDIT_TYPE = EDIT_TYPE.UNKNOWN) -> str:
|
||||||
"""
|
"""
|
||||||
Handle entire file or deletion patches.
|
Handle entire file or deletion patches.
|
||||||
|
|
||||||
@ -131,17 +133,17 @@ def handle_patch_deletions(patch: str, original_file_content_str: str,
|
|||||||
str: The modified patch with deletion hunks omitted.
|
str: The modified patch with deletion hunks omitted.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not new_file_content_str:
|
if not new_file_content_str and edit_type != EDIT_TYPE.ADDED:
|
||||||
# logic for handling deleted files - don't show patch, just show that the file was deleted
|
# logic for handling deleted files - don't show patch, just show that the file was deleted
|
||||||
if get_settings().config.verbosity_level > 0:
|
if get_settings().config.verbosity_level > 0:
|
||||||
logging.info(f"Processing file: {file_name}, minimizing deletion file")
|
get_logger().info(f"Processing file: {file_name}, minimizing deletion file")
|
||||||
patch = None # file was deleted
|
patch = None # file was deleted
|
||||||
else:
|
else:
|
||||||
patch_lines = patch.splitlines()
|
patch_lines = patch.splitlines()
|
||||||
patch_new = omit_deletion_hunks(patch_lines)
|
patch_new = omit_deletion_hunks(patch_lines)
|
||||||
if patch != patch_new:
|
if patch != patch_new:
|
||||||
if get_settings().config.verbosity_level > 0:
|
if get_settings().config.verbosity_level > 0:
|
||||||
logging.info(f"Processing file: {file_name}, hunks were deleted")
|
get_logger().info(f"Processing file: {file_name}, hunks were deleted")
|
||||||
patch = patch_new
|
patch = patch_new
|
||||||
return patch
|
return patch
|
||||||
|
|
||||||
|
@ -1,24 +1,26 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Any, Callable, List, Tuple
|
from typing import Any, Callable, List, Tuple
|
||||||
|
|
||||||
from github import RateLimitExceededException
|
from github import RateLimitExceededException
|
||||||
|
|
||||||
from pr_agent.algo import MAX_TOKENS
|
|
||||||
from pr_agent.algo.git_patch_processing import convert_to_hunks_with_lines_numbers, extend_patch, handle_patch_deletions
|
from pr_agent.algo.git_patch_processing import convert_to_hunks_with_lines_numbers, extend_patch, handle_patch_deletions
|
||||||
from pr_agent.algo.language_handler import sort_files_by_main_languages
|
from pr_agent.algo.language_handler import sort_files_by_main_languages
|
||||||
from pr_agent.algo.file_filter import filter_ignored
|
from pr_agent.algo.file_filter import filter_ignored
|
||||||
from pr_agent.algo.token_handler import TokenHandler, get_token_encoder
|
from pr_agent.algo.token_handler import TokenHandler, get_token_encoder
|
||||||
|
from pr_agent.algo.utils import get_max_tokens
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers.git_provider import FilePatchInfo, GitProvider
|
from pr_agent.git_providers.git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
DELETED_FILES_ = "Deleted files:\n"
|
DELETED_FILES_ = "Deleted files:\n"
|
||||||
|
|
||||||
MORE_MODIFIED_FILES_ = "More modified files:\n"
|
MORE_MODIFIED_FILES_ = "Additional modified files (insufficient token budget to process):\n"
|
||||||
|
|
||||||
|
ADDED_FILES_ = "Additional added files (insufficient token budget to process):\n"
|
||||||
|
|
||||||
OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD = 1000
|
OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD = 1000
|
||||||
OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD = 600
|
OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD = 600
|
||||||
@ -51,7 +53,7 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s
|
|||||||
try:
|
try:
|
||||||
diff_files = git_provider.get_diff_files()
|
diff_files = git_provider.get_diff_files()
|
||||||
except RateLimitExceededException as e:
|
except RateLimitExceededException as e:
|
||||||
logging.error(f"Rate limit exceeded for git provider API. original message {e}")
|
get_logger().error(f"Rate limit exceeded for git provider API. original message {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
diff_files = filter_ignored(diff_files)
|
diff_files = filter_ignored(diff_files)
|
||||||
@ -64,14 +66,17 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s
|
|||||||
pr_languages, token_handler, add_line_numbers_to_hunks, patch_extra_lines=PATCH_EXTRA_LINES)
|
pr_languages, token_handler, add_line_numbers_to_hunks, patch_extra_lines=PATCH_EXTRA_LINES)
|
||||||
|
|
||||||
# if we are under the limit, return the full diff
|
# if we are under the limit, return the full diff
|
||||||
if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < MAX_TOKENS[model]:
|
if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model):
|
||||||
return "\n".join(patches_extended)
|
return "\n".join(patches_extended)
|
||||||
|
|
||||||
# if we are over the limit, start pruning
|
# if we are over the limit, start pruning
|
||||||
patches_compressed, modified_file_names, deleted_file_names = \
|
patches_compressed, modified_file_names, deleted_file_names, added_file_names = \
|
||||||
pr_generate_compressed_diff(pr_languages, token_handler, model, add_line_numbers_to_hunks)
|
pr_generate_compressed_diff(pr_languages, token_handler, model, add_line_numbers_to_hunks)
|
||||||
|
|
||||||
final_diff = "\n".join(patches_compressed)
|
final_diff = "\n".join(patches_compressed)
|
||||||
|
if added_file_names:
|
||||||
|
added_list_str = ADDED_FILES_ + "\n".join(added_file_names)
|
||||||
|
final_diff = final_diff + "\n\n" + added_list_str
|
||||||
if modified_file_names:
|
if modified_file_names:
|
||||||
modified_list_str = MORE_MODIFIED_FILES_ + "\n".join(modified_file_names)
|
modified_list_str = MORE_MODIFIED_FILES_ + "\n".join(modified_file_names)
|
||||||
final_diff = final_diff + "\n\n" + modified_list_str
|
final_diff = final_diff + "\n\n" + modified_list_str
|
||||||
@ -122,7 +127,7 @@ def pr_generate_extended_diff(pr_languages: list,
|
|||||||
|
|
||||||
|
|
||||||
def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, model: str,
|
def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, model: str,
|
||||||
convert_hunks_to_line_numbers: bool) -> Tuple[list, list, list]:
|
convert_hunks_to_line_numbers: bool) -> Tuple[list, list, list, list]:
|
||||||
"""
|
"""
|
||||||
Generate a compressed diff string for a pull request, using diff minimization techniques to reduce the number of
|
Generate a compressed diff string for a pull request, using diff minimization techniques to reduce the number of
|
||||||
tokens used.
|
tokens used.
|
||||||
@ -148,6 +153,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
patches = []
|
patches = []
|
||||||
|
added_files_list = []
|
||||||
modified_files_list = []
|
modified_files_list = []
|
||||||
deleted_files_list = []
|
deleted_files_list = []
|
||||||
# sort each one of the languages in top_langs by the number of tokens in the diff
|
# sort each one of the languages in top_langs by the number of tokens in the diff
|
||||||
@ -165,7 +171,7 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
|
|||||||
|
|
||||||
# removing delete-only hunks
|
# removing delete-only hunks
|
||||||
patch = handle_patch_deletions(patch, original_file_content_str,
|
patch = handle_patch_deletions(patch, original_file_content_str,
|
||||||
new_file_content_str, file.filename)
|
new_file_content_str, file.filename, file.edit_type)
|
||||||
if patch is None:
|
if patch is None:
|
||||||
if not deleted_files_list:
|
if not deleted_files_list:
|
||||||
total_tokens += token_handler.count_tokens(DELETED_FILES_)
|
total_tokens += token_handler.count_tokens(DELETED_FILES_)
|
||||||
@ -179,21 +185,26 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
|
|||||||
new_patch_tokens = token_handler.count_tokens(patch)
|
new_patch_tokens = token_handler.count_tokens(patch)
|
||||||
|
|
||||||
# Hard Stop, no more tokens
|
# Hard Stop, no more tokens
|
||||||
if total_tokens > MAX_TOKENS[model] - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD:
|
if total_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD:
|
||||||
logging.warning(f"File was fully skipped, no more tokens: {file.filename}.")
|
get_logger().warning(f"File was fully skipped, no more tokens: {file.filename}.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If the patch is too large, just show the file name
|
# If the patch is too large, just show the file name
|
||||||
if total_tokens + new_patch_tokens > MAX_TOKENS[model] - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD:
|
if total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD:
|
||||||
# Current logic is to skip the patch if it's too large
|
# Current logic is to skip the patch if it's too large
|
||||||
# TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens
|
# TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens
|
||||||
# until we meet the requirements
|
# until we meet the requirements
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.warning(f"Patch too large, minimizing it, {file.filename}")
|
get_logger().warning(f"Patch too large, minimizing it, {file.filename}")
|
||||||
if not modified_files_list:
|
if file.edit_type == EDIT_TYPE.ADDED:
|
||||||
total_tokens += token_handler.count_tokens(MORE_MODIFIED_FILES_)
|
if not added_files_list:
|
||||||
modified_files_list.append(file.filename)
|
total_tokens += token_handler.count_tokens(ADDED_FILES_)
|
||||||
total_tokens += token_handler.count_tokens(file.filename) + 1
|
added_files_list.append(file.filename)
|
||||||
|
else:
|
||||||
|
if not modified_files_list:
|
||||||
|
total_tokens += token_handler.count_tokens(MORE_MODIFIED_FILES_)
|
||||||
|
modified_files_list.append(file.filename)
|
||||||
|
total_tokens += token_handler.count_tokens(file.filename) + 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if patch:
|
if patch:
|
||||||
@ -204,9 +215,9 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
|
|||||||
patches.append(patch_final)
|
patches.append(patch_final)
|
||||||
total_tokens += token_handler.count_tokens(patch_final)
|
total_tokens += token_handler.count_tokens(patch_final)
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Tokens: {total_tokens}, last filename: {file.filename}")
|
get_logger().info(f"Tokens: {total_tokens}, last filename: {file.filename}")
|
||||||
|
|
||||||
return patches, modified_files_list, deleted_files_list
|
return patches, modified_files_list, deleted_files_list, added_files_list
|
||||||
|
|
||||||
|
|
||||||
async def retry_with_fallback_models(f: Callable):
|
async def retry_with_fallback_models(f: Callable):
|
||||||
@ -218,7 +229,7 @@ async def retry_with_fallback_models(f: Callable):
|
|||||||
get_settings().set("openai.deployment_id", deployment_id)
|
get_settings().set("openai.deployment_id", deployment_id)
|
||||||
return await f(model)
|
return await f(model)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(
|
get_logger().warning(
|
||||||
f"Failed to generate prediction with {model}"
|
f"Failed to generate prediction with {model}"
|
||||||
f"{(' from deployment ' + deployment_id) if deployment_id else ''}: "
|
f"{(' from deployment ' + deployment_id) if deployment_id else ''}: "
|
||||||
f"{traceback.format_exc()}"
|
f"{traceback.format_exc()}"
|
||||||
@ -271,7 +282,7 @@ def find_line_number_of_relevant_line_in_file(diff_files: List[FilePatchInfo],
|
|||||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
||||||
|
|
||||||
for file in diff_files:
|
for file in diff_files:
|
||||||
if file.filename.strip() == relevant_file:
|
if file.filename and (file.filename.strip() == relevant_file):
|
||||||
patch = file.patch
|
patch = file.patch
|
||||||
patch_lines = patch.splitlines()
|
patch_lines = patch.splitlines()
|
||||||
|
|
||||||
@ -340,7 +351,7 @@ def clip_tokens(text: str, max_tokens: int) -> str:
|
|||||||
clipped_text = text[:num_output_chars]
|
clipped_text = text[:num_output_chars]
|
||||||
return clipped_text
|
return clipped_text
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Failed to clip tokens: {e}")
|
get_logger().warning(f"Failed to clip tokens: {e}")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@ -367,7 +378,7 @@ def get_pr_multi_diffs(git_provider: GitProvider,
|
|||||||
try:
|
try:
|
||||||
diff_files = git_provider.get_diff_files()
|
diff_files = git_provider.get_diff_files()
|
||||||
except RateLimitExceededException as e:
|
except RateLimitExceededException as e:
|
||||||
logging.error(f"Rate limit exceeded for git provider API. original message {e}")
|
get_logger().error(f"Rate limit exceeded for git provider API. original message {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
diff_files = filter_ignored(diff_files)
|
diff_files = filter_ignored(diff_files)
|
||||||
@ -387,7 +398,7 @@ def get_pr_multi_diffs(git_provider: GitProvider,
|
|||||||
for file in sorted_files:
|
for file in sorted_files:
|
||||||
if call_number > max_calls:
|
if call_number > max_calls:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Reached max calls ({max_calls})")
|
get_logger().info(f"Reached max calls ({max_calls})")
|
||||||
break
|
break
|
||||||
|
|
||||||
original_file_content_str = file.base_file
|
original_file_content_str = file.base_file
|
||||||
@ -397,26 +408,26 @@ def get_pr_multi_diffs(git_provider: GitProvider,
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Remove delete-only hunks
|
# Remove delete-only hunks
|
||||||
patch = handle_patch_deletions(patch, original_file_content_str, new_file_content_str, file.filename)
|
patch = handle_patch_deletions(patch, original_file_content_str, new_file_content_str, file.filename, file.edit_type)
|
||||||
if patch is None:
|
if patch is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
patch = convert_to_hunks_with_lines_numbers(patch, file)
|
patch = convert_to_hunks_with_lines_numbers(patch, file)
|
||||||
new_patch_tokens = token_handler.count_tokens(patch)
|
new_patch_tokens = token_handler.count_tokens(patch)
|
||||||
if patch and (total_tokens + new_patch_tokens > MAX_TOKENS[model] - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD):
|
if patch and (total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD):
|
||||||
final_diff = "\n".join(patches)
|
final_diff = "\n".join(patches)
|
||||||
final_diff_list.append(final_diff)
|
final_diff_list.append(final_diff)
|
||||||
patches = []
|
patches = []
|
||||||
total_tokens = token_handler.prompt_tokens
|
total_tokens = token_handler.prompt_tokens
|
||||||
call_number += 1
|
call_number += 1
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Call number: {call_number}")
|
get_logger().info(f"Call number: {call_number}")
|
||||||
|
|
||||||
if patch:
|
if patch:
|
||||||
patches.append(patch)
|
patches.append(patch)
|
||||||
total_tokens += new_patch_tokens
|
total_tokens += new_patch_tokens
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Tokens: {total_tokens}, last filename: {file.filename}")
|
get_logger().info(f"Tokens: {total_tokens}, last filename: {file.filename}")
|
||||||
|
|
||||||
# Add the last chunk
|
# Add the last chunk
|
||||||
if patches:
|
if patches:
|
||||||
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import textwrap
|
import textwrap
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -10,7 +9,10 @@ from typing import Any, List
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from starlette_context import context
|
from starlette_context import context
|
||||||
|
|
||||||
|
from pr_agent.algo import MAX_TOKENS
|
||||||
from pr_agent.config_loader import get_settings, global_settings
|
from pr_agent.config_loader import get_settings, global_settings
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
def get_setting(key: str) -> Any:
|
def get_setting(key: str) -> Any:
|
||||||
@ -101,7 +103,8 @@ def parse_code_suggestion(code_suggestions: dict, gfm_supported: bool=True) -> s
|
|||||||
markdown_text += f" **{sub_key}:** {sub_value}\n"
|
markdown_text += f" **{sub_key}:** {sub_value}\n"
|
||||||
if not gfm_supported:
|
if not gfm_supported:
|
||||||
if "relevant line" not in sub_key.lower(): # nicer presentation
|
if "relevant line" not in sub_key.lower(): # nicer presentation
|
||||||
markdown_text = markdown_text.rstrip('\n') + "\\\n"
|
# markdown_text = markdown_text.rstrip('\n') + "\\\n" # works for gitlab
|
||||||
|
markdown_text = markdown_text.rstrip('\n') + " \n" # works for gitlab and bitbucker
|
||||||
|
|
||||||
markdown_text += "\n"
|
markdown_text += "\n"
|
||||||
return markdown_text
|
return markdown_text
|
||||||
@ -159,7 +162,7 @@ def try_fix_json(review, max_iter=10, code_suggestions=False):
|
|||||||
iter_count += 1
|
iter_count += 1
|
||||||
|
|
||||||
if not valid_json:
|
if not valid_json:
|
||||||
logging.error("Unable to decode JSON response from AI")
|
get_logger().error("Unable to decode JSON response from AI")
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
@ -230,7 +233,7 @@ def load_large_diff(filename, new_file_content_str: str, original_file_content_s
|
|||||||
diff = difflib.unified_diff(original_file_content_str.splitlines(keepends=True),
|
diff = difflib.unified_diff(original_file_content_str.splitlines(keepends=True),
|
||||||
new_file_content_str.splitlines(keepends=True))
|
new_file_content_str.splitlines(keepends=True))
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.warning(f"File was modified, but no patch was found. Manually creating patch: {filename}.")
|
get_logger().warning(f"File was modified, but no patch was found. Manually creating patch: {filename}.")
|
||||||
patch = ''.join(diff)
|
patch = ''.join(diff)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@ -262,12 +265,12 @@ def update_settings_from_args(args: List[str]) -> List[str]:
|
|||||||
vals = arg.split('=', 1)
|
vals = arg.split('=', 1)
|
||||||
if len(vals) != 2:
|
if len(vals) != 2:
|
||||||
if len(vals) > 2: # --extended is a valid argument
|
if len(vals) > 2: # --extended is a valid argument
|
||||||
logging.error(f'Invalid argument format: {arg}')
|
get_logger().error(f'Invalid argument format: {arg}')
|
||||||
other_args.append(arg)
|
other_args.append(arg)
|
||||||
continue
|
continue
|
||||||
key, value = _fix_key_value(*vals)
|
key, value = _fix_key_value(*vals)
|
||||||
get_settings().set(key, value)
|
get_settings().set(key, value)
|
||||||
logging.info(f'Updated setting {key} to: "{value}"')
|
get_logger().info(f'Updated setting {key} to: "{value}"')
|
||||||
else:
|
else:
|
||||||
other_args.append(arg)
|
other_args.append(arg)
|
||||||
return other_args
|
return other_args
|
||||||
@ -279,7 +282,7 @@ def _fix_key_value(key: str, value: str):
|
|||||||
try:
|
try:
|
||||||
value = yaml.safe_load(value)
|
value = yaml.safe_load(value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to parse YAML for config override {key}={value}", exc_info=e)
|
get_logger().error(f"Failed to parse YAML for config override {key}={value}", exc_info=e)
|
||||||
return key, value
|
return key, value
|
||||||
|
|
||||||
|
|
||||||
@ -288,19 +291,85 @@ def load_yaml(review_text: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
data = yaml.safe_load(review_text)
|
data = yaml.safe_load(review_text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to parse AI prediction: {e}")
|
get_logger().error(f"Failed to parse AI prediction: {e}")
|
||||||
data = try_fix_yaml(review_text)
|
data = try_fix_yaml(review_text)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def try_fix_yaml(review_text: str) -> dict:
|
def try_fix_yaml(review_text: str) -> dict:
|
||||||
review_text_lines = review_text.split('\n')
|
review_text_lines = review_text.split('\n')
|
||||||
|
|
||||||
|
# first fallback - try to convert 'relevant line: ...' to relevant line: |-\n ...'
|
||||||
|
review_text_lines_copy = review_text_lines.copy()
|
||||||
|
for i in range(0, len(review_text_lines_copy)):
|
||||||
|
if 'relevant line:' in review_text_lines_copy[i] and not '|-' in review_text_lines_copy[i]:
|
||||||
|
review_text_lines_copy[i] = review_text_lines_copy[i].replace('relevant line: ',
|
||||||
|
'relevant line: |-\n ')
|
||||||
|
try:
|
||||||
|
data = yaml.load('\n'.join(review_text_lines_copy), Loader=yaml.SafeLoader)
|
||||||
|
get_logger().info(f"Successfully parsed AI prediction after adding |-\n to relevant line")
|
||||||
|
return data
|
||||||
|
except:
|
||||||
|
get_logger().debug(f"Failed to parse AI prediction after adding |-\n to relevant line")
|
||||||
|
|
||||||
|
# second fallback - try to remove last lines
|
||||||
data = {}
|
data = {}
|
||||||
for i in range(1, len(review_text_lines)):
|
for i in range(1, len(review_text_lines)):
|
||||||
review_text_lines_tmp = '\n'.join(review_text_lines[:-i])
|
review_text_lines_tmp = '\n'.join(review_text_lines[:-i])
|
||||||
try:
|
try:
|
||||||
data = yaml.load(review_text_lines_tmp, Loader=yaml.SafeLoader)
|
data = yaml.load(review_text_lines_tmp, Loader=yaml.SafeLoader)
|
||||||
logging.info(f"Successfully parsed AI prediction after removing {i} lines")
|
get_logger().info(f"Successfully parsed AI prediction after removing {i} lines")
|
||||||
break
|
break
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def set_custom_labels(variables):
|
||||||
|
if not get_settings().config.enable_custom_labels:
|
||||||
|
return
|
||||||
|
|
||||||
|
labels = get_settings().custom_labels
|
||||||
|
if not labels:
|
||||||
|
# set default labels
|
||||||
|
labels = ['Bug fix', 'Tests', 'Bug fix with tests', 'Refactoring', 'Enhancement', 'Documentation', 'Other']
|
||||||
|
labels_list = "\n - ".join(labels) if labels else ""
|
||||||
|
labels_list = f" - {labels_list}" if labels_list else ""
|
||||||
|
variables["custom_labels"] = labels_list
|
||||||
|
return
|
||||||
|
final_labels = ""
|
||||||
|
for k, v in labels.items():
|
||||||
|
final_labels += f" - {k} ({v['description']})\n"
|
||||||
|
variables["custom_labels"] = final_labels
|
||||||
|
variables["custom_labels_examples"] = f" - {list(labels.keys())[0]}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_labels(current_labels: List[str] = None):
|
||||||
|
"""
|
||||||
|
Only keep labels that has been added by the user
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if current_labels is None:
|
||||||
|
current_labels = []
|
||||||
|
user_labels = []
|
||||||
|
for label in current_labels:
|
||||||
|
if label.lower() in ['bug fix', 'tests', 'refactoring', 'enhancement', 'documentation', 'other']:
|
||||||
|
continue
|
||||||
|
if get_settings().config.enable_custom_labels:
|
||||||
|
if label in get_settings().custom_labels:
|
||||||
|
continue
|
||||||
|
user_labels.append(label)
|
||||||
|
if user_labels:
|
||||||
|
get_logger().info(f"Keeping user labels: {user_labels}")
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception(f"Failed to get user labels: {e}")
|
||||||
|
return current_labels
|
||||||
|
return user_labels
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_tokens(model):
|
||||||
|
settings = get_settings()
|
||||||
|
max_tokens_model = MAX_TOKENS[model]
|
||||||
|
if settings.config.max_model_tokens:
|
||||||
|
max_tokens_model = min(settings.config.max_model_tokens, max_tokens_model)
|
||||||
|
# get_logger().debug(f"limiting max tokens to {max_tokens_model}")
|
||||||
|
return max_tokens_model
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from pr_agent.agent.pr_agent import PRAgent, commands
|
from pr_agent.agent.pr_agent import PRAgent, commands
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
|
from pr_agent.log import setup_logger
|
||||||
|
|
||||||
|
setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def run(inargs=None):
|
def run(inargs=None):
|
||||||
@ -47,13 +50,12 @@ For example: 'python cli.py --pr_url=... review --pr_reviewer.extra_instructions
|
|||||||
parser.print_help()
|
parser.print_help()
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
|
||||||
command = args.command.lower()
|
command = args.command.lower()
|
||||||
get_settings().set("CONFIG.CLI_MODE", True)
|
get_settings().set("CONFIG.CLI_MODE", True)
|
||||||
if args.issue_url:
|
if args.issue_url:
|
||||||
result = asyncio.run(PRAgent().handle_request(args.issue_url, command + " " + " ".join(args.rest)))
|
result = asyncio.run(PRAgent().handle_request(args.issue_url, [command] + args.rest))
|
||||||
else:
|
else:
|
||||||
result = asyncio.run(PRAgent().handle_request(args.pr_url, command + " " + " ".join(args.rest)))
|
result = asyncio.run(PRAgent().handle_request(args.pr_url, [command] + args.rest))
|
||||||
if not result:
|
if not result:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|
||||||
|
@ -23,8 +23,10 @@ global_settings = Dynaconf(
|
|||||||
"settings/pr_sort_code_suggestions_prompts.toml",
|
"settings/pr_sort_code_suggestions_prompts.toml",
|
||||||
"settings/pr_information_from_user_prompts.toml",
|
"settings/pr_information_from_user_prompts.toml",
|
||||||
"settings/pr_update_changelog_prompts.toml",
|
"settings/pr_update_changelog_prompts.toml",
|
||||||
|
"settings/pr_custom_labels.toml",
|
||||||
"settings/pr_add_docs.toml",
|
"settings/pr_add_docs.toml",
|
||||||
"settings_prod/.secrets.toml"
|
"settings_prod/.secrets.toml",
|
||||||
|
"settings/custom_labels.toml"
|
||||||
]]
|
]]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from ..log import get_logger
|
||||||
|
|
||||||
AZURE_DEVOPS_AVAILABLE = True
|
AZURE_DEVOPS_AVAILABLE = True
|
||||||
try:
|
try:
|
||||||
from msrest.authentication import BasicAuthentication
|
from msrest.authentication import BasicAuthentication
|
||||||
@ -55,7 +56,7 @@ class AzureDevopsProvider:
|
|||||||
path=".pr_agent.toml")
|
path=".pr_agent.toml")
|
||||||
return contents
|
return contents
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception("get repo settings error")
|
get_logger().exception("get repo settings error")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def get_files(self):
|
def get_files(self):
|
||||||
@ -110,7 +111,7 @@ class AzureDevopsProvider:
|
|||||||
|
|
||||||
new_file_content_str = new_file_content_str.content
|
new_file_content_str = new_file_content_str.content
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logging.error("Failed to retrieve new file content of %s at version %s. Error: %s", file, version, str(error))
|
get_logger().error("Failed to retrieve new file content of %s at version %s. Error: %s", file, version, str(error))
|
||||||
new_file_content_str = ""
|
new_file_content_str = ""
|
||||||
|
|
||||||
edit_type = EDIT_TYPE.MODIFIED
|
edit_type = EDIT_TYPE.MODIFIED
|
||||||
@ -131,7 +132,7 @@ class AzureDevopsProvider:
|
|||||||
include_content=True)
|
include_content=True)
|
||||||
original_file_content_str = original_file_content_str.content
|
original_file_content_str = original_file_content_str.content
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logging.error("Failed to retrieve original file content of %s at version %s. Error: %s", file, version, str(error))
|
get_logger().error("Failed to retrieve original file content of %s at version %s. Error: %s", file, version, str(error))
|
||||||
original_file_content_str = ""
|
original_file_content_str = ""
|
||||||
|
|
||||||
patch = load_large_diff(file, new_file_content_str, original_file_content_str)
|
patch = load_large_diff(file, new_file_content_str, original_file_content_str)
|
||||||
@ -166,7 +167,7 @@ class AzureDevopsProvider:
|
|||||||
pull_request_id=self.pr_num,
|
pull_request_id=self.pr_num,
|
||||||
git_pull_request_to_update=updated_pr)
|
git_pull_request_to_update=updated_pr)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Could not update pull request {self.pr_num} description: {e}")
|
get_logger().exception(f"Could not update pull request {self.pr_num} description: {e}")
|
||||||
|
|
||||||
def remove_initial_comment(self):
|
def remove_initial_comment(self):
|
||||||
return "" # not implemented yet
|
return "" # not implemented yet
|
||||||
@ -235,9 +236,6 @@ class AzureDevopsProvider:
|
|||||||
def _parse_pr_url(pr_url: str) -> Tuple[str, int]:
|
def _parse_pr_url(pr_url: str) -> Tuple[str, int]:
|
||||||
parsed_url = urlparse(pr_url)
|
parsed_url = urlparse(pr_url)
|
||||||
|
|
||||||
if 'azure.com' not in parsed_url.netloc:
|
|
||||||
raise ValueError("The provided URL is not a valid Azure DevOps URL")
|
|
||||||
|
|
||||||
path_parts = parsed_url.path.strip('/').split('/')
|
path_parts = parsed_url.path.strip('/').split('/')
|
||||||
|
|
||||||
if len(path_parts) < 6 or path_parts[4] != 'pullrequest':
|
if len(path_parts) < 6 or path_parts[4] != 'pullrequest':
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@ -7,9 +6,10 @@ import requests
|
|||||||
from atlassian.bitbucket import Cloud
|
from atlassian.bitbucket import Cloud
|
||||||
from starlette_context import context
|
from starlette_context import context
|
||||||
|
|
||||||
from ..algo.pr_processing import clip_tokens, find_line_number_of_relevant_line_in_file
|
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
|
||||||
from ..config_loader import get_settings
|
from ..config_loader import get_settings
|
||||||
from .git_provider import FilePatchInfo, GitProvider
|
from ..log import get_logger
|
||||||
|
from .git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
|
||||||
|
|
||||||
|
|
||||||
class BitbucketProvider(GitProvider):
|
class BitbucketProvider(GitProvider):
|
||||||
@ -32,8 +32,10 @@ class BitbucketProvider(GitProvider):
|
|||||||
self.repo = None
|
self.repo = None
|
||||||
self.pr_num = None
|
self.pr_num = None
|
||||||
self.pr = None
|
self.pr = None
|
||||||
|
self.pr_url = pr_url
|
||||||
self.temp_comments = []
|
self.temp_comments = []
|
||||||
self.incremental = incremental
|
self.incremental = incremental
|
||||||
|
self.diff_files = None
|
||||||
if pr_url:
|
if pr_url:
|
||||||
self.set_pr(pr_url)
|
self.set_pr(pr_url)
|
||||||
self.bitbucket_comment_api_url = self.pr._BitbucketBase__data["links"]["comments"]["href"]
|
self.bitbucket_comment_api_url = self.pr._BitbucketBase__data["links"]["comments"]["href"]
|
||||||
@ -41,9 +43,12 @@ class BitbucketProvider(GitProvider):
|
|||||||
|
|
||||||
def get_repo_settings(self):
|
def get_repo_settings(self):
|
||||||
try:
|
try:
|
||||||
contents = self.repo_obj.get_contents(
|
url = (f"https://api.bitbucket.org/2.0/repositories/{self.workspace_slug}/{self.repo_slug}/src/"
|
||||||
".pr_agent.toml", ref=self.pr.head.sha
|
f"{self.pr.destination_branch}/.pr_agent.toml")
|
||||||
).decoded_content
|
response = requests.request("GET", url, headers=self.headers)
|
||||||
|
if response.status_code == 404: # not found
|
||||||
|
return ""
|
||||||
|
contents = response.text.encode('utf-8')
|
||||||
return contents
|
return contents
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
@ -61,14 +66,14 @@ class BitbucketProvider(GitProvider):
|
|||||||
|
|
||||||
if not relevant_lines_start or relevant_lines_start == -1:
|
if not relevant_lines_start or relevant_lines_start == -1:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.exception(
|
get_logger().exception(
|
||||||
f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}"
|
f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if relevant_lines_end < relevant_lines_start:
|
if relevant_lines_end < relevant_lines_start:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.exception(
|
get_logger().exception(
|
||||||
f"Failed to publish code suggestion, "
|
f"Failed to publish code suggestion, "
|
||||||
f"relevant_lines_end is {relevant_lines_end} and "
|
f"relevant_lines_end is {relevant_lines_end} and "
|
||||||
f"relevant_lines_start is {relevant_lines_start}"
|
f"relevant_lines_start is {relevant_lines_start}"
|
||||||
@ -97,7 +102,7 @@ class BitbucketProvider(GitProvider):
|
|||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.error(f"Failed to publish code suggestion, error: {e}")
|
get_logger().error(f"Failed to publish code suggestion, error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_supported(self, capability: str) -> bool:
|
def is_supported(self, capability: str) -> bool:
|
||||||
@ -113,6 +118,9 @@ class BitbucketProvider(GitProvider):
|
|||||||
return [diff.new.path for diff in self.pr.diffstat()]
|
return [diff.new.path for diff in self.pr.diffstat()]
|
||||||
|
|
||||||
def get_diff_files(self) -> list[FilePatchInfo]:
|
def get_diff_files(self) -> list[FilePatchInfo]:
|
||||||
|
if self.diff_files:
|
||||||
|
return self.diff_files
|
||||||
|
|
||||||
diffs = self.pr.diffstat()
|
diffs = self.pr.diffstat()
|
||||||
diff_split = [
|
diff_split = [
|
||||||
"diff --git%s" % x for x in self.pr.diff().split("diff --git") if x.strip()
|
"diff --git%s" % x for x in self.pr.diff().split("diff --git") if x.strip()
|
||||||
@ -124,16 +132,56 @@ class BitbucketProvider(GitProvider):
|
|||||||
diff.old.get_data("links")
|
diff.old.get_data("links")
|
||||||
)
|
)
|
||||||
new_file_content_str = self._get_pr_file_content(diff.new.get_data("links"))
|
new_file_content_str = self._get_pr_file_content(diff.new.get_data("links"))
|
||||||
diff_files.append(
|
file_patch_canonic_structure = FilePatchInfo(
|
||||||
FilePatchInfo(
|
original_file_content_str,
|
||||||
original_file_content_str,
|
new_file_content_str,
|
||||||
new_file_content_str,
|
diff_split[index],
|
||||||
diff_split[index],
|
diff.new.path,
|
||||||
diff.new.path,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if diff.data['status'] == 'added':
|
||||||
|
file_patch_canonic_structure.edit_type = EDIT_TYPE.ADDED
|
||||||
|
elif diff.data['status'] == 'removed':
|
||||||
|
file_patch_canonic_structure.edit_type = EDIT_TYPE.DELETED
|
||||||
|
elif diff.data['status'] == 'modified':
|
||||||
|
file_patch_canonic_structure.edit_type = EDIT_TYPE.MODIFIED
|
||||||
|
elif diff.data['status'] == 'renamed':
|
||||||
|
file_patch_canonic_structure.edit_type = EDIT_TYPE.RENAMED
|
||||||
|
diff_files.append(file_patch_canonic_structure)
|
||||||
|
|
||||||
|
|
||||||
|
self.diff_files = diff_files
|
||||||
return diff_files
|
return diff_files
|
||||||
|
|
||||||
|
def get_latest_commit_url(self):
|
||||||
|
return self.pr.data['source']['commit']['links']['html']['href']
|
||||||
|
|
||||||
|
def get_comment_url(self, comment):
|
||||||
|
return comment.data['links']['html']['href']
|
||||||
|
|
||||||
|
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True):
|
||||||
|
try:
|
||||||
|
for comment in self.pr.comments():
|
||||||
|
body = comment.raw
|
||||||
|
if initial_header in body:
|
||||||
|
latest_commit_url = self.get_latest_commit_url()
|
||||||
|
comment_url = self.get_comment_url(comment)
|
||||||
|
if update_header:
|
||||||
|
updated_header = f"{initial_header}\n\n### (review updated until commit {latest_commit_url})\n"
|
||||||
|
pr_comment_updated = pr_comment.replace(initial_header, updated_header)
|
||||||
|
else:
|
||||||
|
pr_comment_updated = pr_comment
|
||||||
|
get_logger().info(f"Persistent mode- updating comment {comment_url} to latest review message")
|
||||||
|
d = {"content": {"raw": pr_comment_updated}}
|
||||||
|
response = comment._update_data(comment.put(None, data=d))
|
||||||
|
self.publish_comment(
|
||||||
|
f"**[Persistent review]({comment_url})** updated to latest commit {latest_commit_url}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception(f"Failed to update persistent review, error: {e}")
|
||||||
|
pass
|
||||||
|
self.publish_comment(pr_comment)
|
||||||
|
|
||||||
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
||||||
comment = self.pr.comment(pr_comment)
|
comment = self.pr.comment(pr_comment)
|
||||||
if is_temporary:
|
if is_temporary:
|
||||||
@ -142,17 +190,22 @@ class BitbucketProvider(GitProvider):
|
|||||||
def remove_initial_comment(self):
|
def remove_initial_comment(self):
|
||||||
try:
|
try:
|
||||||
for comment in self.temp_comments:
|
for comment in self.temp_comments:
|
||||||
self.pr.delete(f"comments/{comment}")
|
self.remove_comment(comment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to remove temp comments, error: {e}")
|
get_logger().exception(f"Failed to remove temp comments, error: {e}")
|
||||||
|
|
||||||
|
def remove_comment(self, comment):
|
||||||
|
try:
|
||||||
|
self.pr.delete(f"comments/{comment}")
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception(f"Failed to remove comment, error: {e}")
|
||||||
|
|
||||||
# funtion to create_inline_comment
|
# funtion to create_inline_comment
|
||||||
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||||
position, absolute_position = find_line_number_of_relevant_line_in_file(self.get_diff_files(), relevant_file.strip('`'), relevant_line_in_file)
|
position, absolute_position = find_line_number_of_relevant_line_in_file(self.get_diff_files(), relevant_file.strip('`'), relevant_line_in_file)
|
||||||
if position == -1:
|
if position == -1:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
||||||
subject_type = "FILE"
|
subject_type = "FILE"
|
||||||
else:
|
else:
|
||||||
subject_type = "LINE"
|
subject_type = "LINE"
|
||||||
@ -175,9 +228,29 @@ class BitbucketProvider(GitProvider):
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def generate_link_to_relevant_line_number(self, suggestion) -> str:
|
||||||
|
try:
|
||||||
|
relevant_file = suggestion['relevant file'].strip('`').strip("'")
|
||||||
|
relevant_line_str = suggestion['relevant line']
|
||||||
|
if not relevant_line_str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
diff_files = self.get_diff_files()
|
||||||
|
position, absolute_position = find_line_number_of_relevant_line_in_file \
|
||||||
|
(diff_files, relevant_file, relevant_line_str)
|
||||||
|
|
||||||
|
if absolute_position != -1 and self.pr_url:
|
||||||
|
link = f"{self.pr_url}/#L{relevant_file}T{absolute_position}"
|
||||||
|
return link
|
||||||
|
except Exception as e:
|
||||||
|
if get_settings().config.verbosity_level >= 2:
|
||||||
|
get_logger().info(f"Failed adding line link, error: {e}")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
def publish_inline_comments(self, comments: list[dict]):
|
def publish_inline_comments(self, comments: list[dict]):
|
||||||
for comment in comments:
|
for comment in comments:
|
||||||
self.publish_inline_comment(comment['body'], comment['start_line'], comment['path'])
|
self.publish_inline_comment(comment['body'], comment['position'], comment['path'])
|
||||||
|
|
||||||
def get_title(self):
|
def get_title(self):
|
||||||
return self.pr.title
|
return self.pr.title
|
||||||
@ -254,6 +327,11 @@ class BitbucketProvider(GitProvider):
|
|||||||
})
|
})
|
||||||
|
|
||||||
response = requests.request("PUT", self.bitbucket_pull_request_api_url, headers=self.headers, data=payload)
|
response = requests.request("PUT", self.bitbucket_pull_request_api_url, headers=self.headers, data=payload)
|
||||||
|
try:
|
||||||
|
if response.status_code != 200:
|
||||||
|
get_logger().info(f"Failed to update description, error code: {response.status_code}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# bitbucket does not support labels
|
# bitbucket does not support labels
|
||||||
|
@ -1,17 +1,16 @@
|
|||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from ..algo.language_handler import is_valid_file, language_extension_map
|
|
||||||
from ..algo.pr_processing import clip_tokens
|
|
||||||
from ..algo.utils import load_large_diff
|
|
||||||
from ..config_loader import get_settings
|
|
||||||
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider, IncrementalPR
|
|
||||||
from pr_agent.git_providers.codecommit_client import CodeCommitClient
|
from pr_agent.git_providers.codecommit_client import CodeCommitClient
|
||||||
|
|
||||||
|
from ..algo.language_handler import is_valid_file, language_extension_map
|
||||||
|
from ..algo.utils import load_large_diff
|
||||||
|
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
||||||
|
from ..log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class PullRequestCCMimic:
|
class PullRequestCCMimic:
|
||||||
"""
|
"""
|
||||||
@ -166,7 +165,7 @@ class CodeCommitProvider(GitProvider):
|
|||||||
|
|
||||||
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
||||||
if is_temporary:
|
if is_temporary:
|
||||||
logging.info(pr_comment)
|
get_logger().info(pr_comment)
|
||||||
return
|
return
|
||||||
|
|
||||||
pr_comment = CodeCommitProvider._remove_markdown_html(pr_comment)
|
pr_comment = CodeCommitProvider._remove_markdown_html(pr_comment)
|
||||||
@ -188,12 +187,12 @@ class CodeCommitProvider(GitProvider):
|
|||||||
for suggestion in code_suggestions:
|
for suggestion in code_suggestions:
|
||||||
# Verify that each suggestion has the required keys
|
# Verify that each suggestion has the required keys
|
||||||
if not all(key in suggestion for key in ["body", "relevant_file", "relevant_lines_start"]):
|
if not all(key in suggestion for key in ["body", "relevant_file", "relevant_lines_start"]):
|
||||||
logging.warning(f"Skipping code suggestion #{counter}: Each suggestion must have 'body', 'relevant_file', 'relevant_lines_start' keys")
|
get_logger().warning(f"Skipping code suggestion #{counter}: Each suggestion must have 'body', 'relevant_file', 'relevant_lines_start' keys")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Publish the code suggestion to CodeCommit
|
# Publish the code suggestion to CodeCommit
|
||||||
try:
|
try:
|
||||||
logging.debug(f"Code Suggestion #{counter} in file: {suggestion['relevant_file']}: {suggestion['relevant_lines_start']}")
|
get_logger().debug(f"Code Suggestion #{counter} in file: {suggestion['relevant_file']}: {suggestion['relevant_lines_start']}")
|
||||||
self.codecommit_client.publish_comment(
|
self.codecommit_client.publish_comment(
|
||||||
repo_name=self.repo_name,
|
repo_name=self.repo_name,
|
||||||
pr_number=self.pr_num,
|
pr_number=self.pr_num,
|
||||||
@ -222,6 +221,9 @@ class CodeCommitProvider(GitProvider):
|
|||||||
def remove_initial_comment(self):
|
def remove_initial_comment(self):
|
||||||
return "" # not implemented yet
|
return "" # not implemented yet
|
||||||
|
|
||||||
|
def remove_comment(self, comment):
|
||||||
|
return "" # not implemented yet
|
||||||
|
|
||||||
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||||
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/post_comment_for_compared_commit.html
|
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/post_comment_for_compared_commit.html
|
||||||
raise NotImplementedError("CodeCommit provider does not support publishing inline comments yet")
|
raise NotImplementedError("CodeCommit provider does not support publishing inline comments yet")
|
||||||
@ -296,11 +298,11 @@ class CodeCommitProvider(GitProvider):
|
|||||||
return self.codecommit_client.get_file(self.repo_name, settings_filename, self.pr.source_commit, optional=True)
|
return self.codecommit_client.get_file(self.repo_name, settings_filename, self.pr.source_commit, optional=True)
|
||||||
|
|
||||||
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
|
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
|
||||||
logging.info("CodeCommit provider does not support eyes reaction yet")
|
get_logger().info("CodeCommit provider does not support eyes reaction yet")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
||||||
logging.info("CodeCommit provider does not support removing reactions yet")
|
get_logger().info("CodeCommit provider does not support removing reactions yet")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -366,7 +368,7 @@ class CodeCommitProvider(GitProvider):
|
|||||||
# TODO: implement support for multiple targets in one CodeCommit PR
|
# TODO: implement support for multiple targets in one CodeCommit PR
|
||||||
# for now, we are only using the first target in the PR
|
# for now, we are only using the first target in the PR
|
||||||
if len(response.targets) > 1:
|
if len(response.targets) > 1:
|
||||||
logging.warning(
|
get_logger().warning(
|
||||||
"Multiple targets in one PR is not supported for CodeCommit yet. Continuing, using the first target only..."
|
"Multiple targets in one PR is not supported for CodeCommit yet. Continuing, using the first target only..."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import shutil
|
import shutil
|
||||||
@ -7,18 +6,16 @@ import subprocess
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import Counter, namedtuple
|
from collections import Counter, namedtuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import mkdtemp, NamedTemporaryFile
|
from tempfile import NamedTemporaryFile, mkdtemp
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import urllib3.util
|
import urllib3.util
|
||||||
from git import Repo
|
from git import Repo
|
||||||
|
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers.git_provider import GitProvider, FilePatchInfo, \
|
from pr_agent.git_providers.git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
||||||
EDIT_TYPE
|
|
||||||
from pr_agent.git_providers.local_git_provider import PullRequestMimic
|
from pr_agent.git_providers.local_git_provider import PullRequestMimic
|
||||||
|
from pr_agent.log import get_logger
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _call(*command, **kwargs) -> (int, str, str):
|
def _call(*command, **kwargs) -> (int, str, str):
|
||||||
@ -33,42 +30,42 @@ def _call(*command, **kwargs) -> (int, str, str):
|
|||||||
|
|
||||||
|
|
||||||
def clone(url, directory):
|
def clone(url, directory):
|
||||||
logger.info("Cloning %s to %s", url, directory)
|
get_logger().info("Cloning %s to %s", url, directory)
|
||||||
stdout = _call('git', 'clone', "--depth", "1", url, directory)
|
stdout = _call('git', 'clone', "--depth", "1", url, directory)
|
||||||
logger.info(stdout)
|
get_logger().info(stdout)
|
||||||
|
|
||||||
|
|
||||||
def fetch(url, refspec, cwd):
|
def fetch(url, refspec, cwd):
|
||||||
logger.info("Fetching %s %s", url, refspec)
|
get_logger().info("Fetching %s %s", url, refspec)
|
||||||
stdout = _call(
|
stdout = _call(
|
||||||
'git', 'fetch', '--depth', '2', url, refspec,
|
'git', 'fetch', '--depth', '2', url, refspec,
|
||||||
cwd=cwd
|
cwd=cwd
|
||||||
)
|
)
|
||||||
logger.info(stdout)
|
get_logger().info(stdout)
|
||||||
|
|
||||||
|
|
||||||
def checkout(cwd):
|
def checkout(cwd):
|
||||||
logger.info("Checking out")
|
get_logger().info("Checking out")
|
||||||
stdout = _call('git', 'checkout', "FETCH_HEAD", cwd=cwd)
|
stdout = _call('git', 'checkout', "FETCH_HEAD", cwd=cwd)
|
||||||
logger.info(stdout)
|
get_logger().info(stdout)
|
||||||
|
|
||||||
|
|
||||||
def show(*args, cwd=None):
|
def show(*args, cwd=None):
|
||||||
logger.info("Show")
|
get_logger().info("Show")
|
||||||
return _call('git', 'show', *args, cwd=cwd)
|
return _call('git', 'show', *args, cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
def diff(*args, cwd=None):
|
def diff(*args, cwd=None):
|
||||||
logger.info("Diff")
|
get_logger().info("Diff")
|
||||||
patch = _call('git', 'diff', *args, cwd=cwd)
|
patch = _call('git', 'diff', *args, cwd=cwd)
|
||||||
if not patch:
|
if not patch:
|
||||||
logger.warning("No changes found")
|
get_logger().warning("No changes found")
|
||||||
return
|
return
|
||||||
return patch
|
return patch
|
||||||
|
|
||||||
|
|
||||||
def reset_local_changes(cwd):
|
def reset_local_changes(cwd):
|
||||||
logger.info("Reset local changes")
|
get_logger().info("Reset local changes")
|
||||||
_call('git', 'checkout', "--force", cwd=cwd)
|
_call('git', 'checkout', "--force", cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
@ -399,5 +396,8 @@ class GerritProvider(GitProvider):
|
|||||||
# shutil.rmtree(self.repo_path)
|
# shutil.rmtree(self.repo_path)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def remove_comment(self, comment):
|
||||||
|
pass
|
||||||
|
|
||||||
def get_pr_branch(self):
|
def get_pr_branch(self):
|
||||||
return self.repo.head
|
return self.repo.head
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import logging
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@ -6,12 +5,15 @@ from dataclasses import dataclass
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class EDIT_TYPE(Enum):
|
class EDIT_TYPE(Enum):
|
||||||
ADDED = 1
|
ADDED = 1
|
||||||
DELETED = 2
|
DELETED = 2
|
||||||
MODIFIED = 3
|
MODIFIED = 3
|
||||||
RENAMED = 4
|
RENAMED = 4
|
||||||
|
UNKNOWN = 5
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -21,7 +23,7 @@ class FilePatchInfo:
|
|||||||
patch: str
|
patch: str
|
||||||
filename: str
|
filename: str
|
||||||
tokens: int = -1
|
tokens: int = -1
|
||||||
edit_type: EDIT_TYPE = EDIT_TYPE.MODIFIED
|
edit_type: EDIT_TYPE = EDIT_TYPE.UNKNOWN
|
||||||
old_filename: str = None
|
old_filename: str = None
|
||||||
|
|
||||||
|
|
||||||
@ -38,38 +40,10 @@ class GitProvider(ABC):
|
|||||||
def publish_description(self, pr_title: str, pr_body: str):
|
def publish_description(self, pr_title: str, pr_body: str):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def publish_inline_comments(self, comments: list[dict]):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def publish_code_suggestions(self, code_suggestions: list) -> bool:
|
def publish_code_suggestions(self, code_suggestions: list) -> bool:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def publish_labels(self, labels):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_labels(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def remove_initial_comment(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_languages(self):
|
def get_languages(self):
|
||||||
pass
|
pass
|
||||||
@ -89,16 +63,16 @@ class GitProvider(ABC):
|
|||||||
def get_pr_description(self, *, full: bool = True) -> str:
|
def get_pr_description(self, *, full: bool = True) -> str:
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.algo.pr_processing import clip_tokens
|
from pr_agent.algo.pr_processing import clip_tokens
|
||||||
max_tokens = get_settings().get("CONFIG.MAX_DESCRIPTION_TOKENS", None)
|
max_tokens_description = get_settings().get("CONFIG.MAX_DESCRIPTION_TOKENS", None)
|
||||||
description = self.get_pr_description_full() if full else self.get_user_description()
|
description = self.get_pr_description_full() if full else self.get_user_description()
|
||||||
if max_tokens:
|
if max_tokens_description:
|
||||||
return clip_tokens(description, max_tokens)
|
return clip_tokens(description, max_tokens_description)
|
||||||
return description
|
return description
|
||||||
|
|
||||||
def get_user_description(self) -> str:
|
def get_user_description(self) -> str:
|
||||||
description = (self.get_pr_description_full() or "").strip()
|
description = (self.get_pr_description_full() or "").strip()
|
||||||
# if the existing description wasn't generated by the pr-agent, just return it as-is
|
# if the existing description wasn't generated by the pr-agent, just return it as-is
|
||||||
if not description.startswith("## PR Type"):
|
if not any(description.startswith(header) for header in ("## PR Type", "## PR Description")):
|
||||||
return description
|
return description
|
||||||
# if the existing description was generated by the pr-agent, but it doesn't contain the user description,
|
# if the existing description was generated by the pr-agent, but it doesn't contain the user description,
|
||||||
# return nothing (empty string) because it means there is no user description
|
# return nothing (empty string) because it means there is no user description
|
||||||
@ -108,11 +82,54 @@ class GitProvider(ABC):
|
|||||||
return description.split("## User Description:", 1)[1].strip()
|
return description.split("## User Description:", 1)[1].strip()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_issue_comments(self):
|
def get_repo_settings(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_pr_id(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
#### comments operations ####
|
||||||
|
@abstractmethod
|
||||||
|
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool):
|
||||||
|
self.publish_comment(pr_comment)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_repo_settings(self):
|
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def publish_inline_comments(self, comments: list[dict]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def remove_initial_comment(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def remove_comment(self, comment):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_issue_comments(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_comment_url(self, comment) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
#### labels operations ####
|
||||||
|
@abstractmethod
|
||||||
|
def publish_labels(self, labels):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_labels(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -123,11 +140,12 @@ class GitProvider(ABC):
|
|||||||
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
#### commits operations ####
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_commit_messages(self):
|
def get_commit_messages(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_pr_id(self):
|
def get_latest_commit_url(self) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def get_main_pr_language(languages, files) -> str:
|
def get_main_pr_language(languages, files) -> str:
|
||||||
@ -136,7 +154,10 @@ def get_main_pr_language(languages, files) -> str:
|
|||||||
"""
|
"""
|
||||||
main_language_str = ""
|
main_language_str = ""
|
||||||
if not languages:
|
if not languages:
|
||||||
logging.info("No languages detected")
|
get_logger().info("No languages detected")
|
||||||
|
return main_language_str
|
||||||
|
if not files:
|
||||||
|
get_logger().info("No files in diff")
|
||||||
return main_language_str
|
return main_language_str
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -145,6 +166,8 @@ def get_main_pr_language(languages, files) -> str:
|
|||||||
# validate that the specific commit uses the main language
|
# validate that the specific commit uses the main language
|
||||||
extension_list = []
|
extension_list = []
|
||||||
for file in files:
|
for file in files:
|
||||||
|
if not file:
|
||||||
|
continue
|
||||||
if isinstance(file, str):
|
if isinstance(file, str):
|
||||||
file = FilePatchInfo(base_file=None, head_file=None, patch=None, filename=file)
|
file = FilePatchInfo(base_file=None, head_file=None, patch=None, filename=file)
|
||||||
extension_list.append(file.filename.rsplit('.')[-1])
|
extension_list.append(file.filename.rsplit('.')[-1])
|
||||||
@ -172,7 +195,7 @@ def get_main_pr_language(languages, files) -> str:
|
|||||||
main_language_str = top_language
|
main_language_str = top_language
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
get_logger().exception(e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return main_language_str
|
return main_language_str
|
||||||
@ -182,6 +205,13 @@ class IncrementalPR:
|
|||||||
def __init__(self, is_incremental: bool = False):
|
def __init__(self, is_incremental: bool = False):
|
||||||
self.is_incremental = is_incremental
|
self.is_incremental = is_incremental
|
||||||
self.commits_range = None
|
self.commits_range = None
|
||||||
self.first_new_commit_sha = None
|
self.first_new_commit = None
|
||||||
self.last_seen_commit_sha = None
|
self.last_seen_commit = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first_new_commit_sha(self):
|
||||||
|
return None if self.first_new_commit is None else self.first_new_commit.sha
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_seen_commit_sha(self):
|
||||||
|
return None if self.last_seen_commit is None else self.last_seen_commit.sha
|
||||||
|
@ -1,20 +1,19 @@
|
|||||||
import logging
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Tuple, Any
|
from typing import Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from github import AppAuthentication, Auth, Github, GithubException, Reaction
|
from github import AppAuthentication, Auth, Github, GithubException
|
||||||
from retry import retry
|
from retry import retry
|
||||||
from starlette_context import context
|
from starlette_context import context
|
||||||
|
|
||||||
from .git_provider import FilePatchInfo, GitProvider, IncrementalPR
|
|
||||||
from ..algo.language_handler import is_valid_file
|
from ..algo.language_handler import is_valid_file
|
||||||
|
from ..algo.pr_processing import clip_tokens, find_line_number_of_relevant_line_in_file
|
||||||
from ..algo.utils import load_large_diff
|
from ..algo.utils import load_large_diff
|
||||||
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file, clip_tokens
|
|
||||||
from ..config_loader import get_settings
|
from ..config_loader import get_settings
|
||||||
|
from ..log import get_logger
|
||||||
from ..servers.utils import RateLimitExceeded
|
from ..servers.utils import RateLimitExceeded
|
||||||
|
from .git_provider import FilePatchInfo, GitProvider, IncrementalPR, EDIT_TYPE
|
||||||
|
|
||||||
|
|
||||||
class GithubProvider(GitProvider):
|
class GithubProvider(GitProvider):
|
||||||
@ -51,36 +50,42 @@ class GithubProvider(GitProvider):
|
|||||||
def get_incremental_commits(self):
|
def get_incremental_commits(self):
|
||||||
self.commits = list(self.pr.get_commits())
|
self.commits = list(self.pr.get_commits())
|
||||||
|
|
||||||
self.get_previous_review()
|
self.previous_review = self.get_previous_review(full=True, incremental=True)
|
||||||
if self.previous_review:
|
if self.previous_review:
|
||||||
self.incremental.commits_range = self.get_commit_range()
|
self.incremental.commits_range = self.get_commit_range()
|
||||||
# Get all files changed during the commit range
|
# Get all files changed during the commit range
|
||||||
self.file_set = dict()
|
self.file_set = dict()
|
||||||
for commit in self.incremental.commits_range:
|
for commit in self.incremental.commits_range:
|
||||||
if commit.commit.message.startswith(f"Merge branch '{self._get_repo().default_branch}'"):
|
if commit.commit.message.startswith(f"Merge branch '{self._get_repo().default_branch}'"):
|
||||||
logging.info(f"Skipping merge commit {commit.commit.message}")
|
get_logger().info(f"Skipping merge commit {commit.commit.message}")
|
||||||
continue
|
continue
|
||||||
self.file_set.update({file.filename: file for file in commit.files})
|
self.file_set.update({file.filename: file for file in commit.files})
|
||||||
|
|
||||||
def get_commit_range(self):
|
def get_commit_range(self):
|
||||||
last_review_time = self.previous_review.created_at
|
last_review_time = self.previous_review.created_at
|
||||||
first_new_commit_index = 0
|
first_new_commit_index = None
|
||||||
for index in range(len(self.commits) - 1, -1, -1):
|
for index in range(len(self.commits) - 1, -1, -1):
|
||||||
if self.commits[index].commit.author.date > last_review_time:
|
if self.commits[index].commit.author.date > last_review_time:
|
||||||
self.incremental.first_new_commit_sha = self.commits[index].sha
|
self.incremental.first_new_commit = self.commits[index]
|
||||||
first_new_commit_index = index
|
first_new_commit_index = index
|
||||||
else:
|
else:
|
||||||
self.incremental.last_seen_commit_sha = self.commits[index].sha
|
self.incremental.last_seen_commit = self.commits[index]
|
||||||
break
|
break
|
||||||
return self.commits[first_new_commit_index:]
|
return self.commits[first_new_commit_index:] if first_new_commit_index is not None else []
|
||||||
|
|
||||||
def get_previous_review(self):
|
def get_previous_review(self, *, full: bool, incremental: bool):
|
||||||
self.previous_review = None
|
if not (full or incremental):
|
||||||
self.comments = list(self.pr.get_issue_comments())
|
raise ValueError("At least one of full or incremental must be True")
|
||||||
|
if not getattr(self, "comments", None):
|
||||||
|
self.comments = list(self.pr.get_issue_comments())
|
||||||
|
prefixes = []
|
||||||
|
if full:
|
||||||
|
prefixes.append("## PR Analysis")
|
||||||
|
if incremental:
|
||||||
|
prefixes.append("## Incremental PR Review")
|
||||||
for index in range(len(self.comments) - 1, -1, -1):
|
for index in range(len(self.comments) - 1, -1, -1):
|
||||||
if self.comments[index].body.startswith("## PR Analysis"):
|
if any(self.comments[index].body.startswith(prefix) for prefix in prefixes):
|
||||||
self.previous_review = self.comments[index]
|
return self.comments[index]
|
||||||
break
|
|
||||||
|
|
||||||
def get_files(self):
|
def get_files(self):
|
||||||
if self.incremental.is_incremental and self.file_set:
|
if self.incremental.is_incremental and self.file_set:
|
||||||
@ -124,22 +129,61 @@ class GithubProvider(GitProvider):
|
|||||||
if not patch:
|
if not patch:
|
||||||
patch = load_large_diff(file.filename, new_file_content_str, original_file_content_str)
|
patch = load_large_diff(file.filename, new_file_content_str, original_file_content_str)
|
||||||
|
|
||||||
diff_files.append(FilePatchInfo(original_file_content_str, new_file_content_str, patch, file.filename))
|
if file.status == 'added':
|
||||||
|
edit_type = EDIT_TYPE.ADDED
|
||||||
|
elif file.status == 'removed':
|
||||||
|
edit_type = EDIT_TYPE.DELETED
|
||||||
|
elif file.status == 'renamed':
|
||||||
|
edit_type = EDIT_TYPE.RENAMED
|
||||||
|
elif file.status == 'modified':
|
||||||
|
edit_type = EDIT_TYPE.MODIFIED
|
||||||
|
else:
|
||||||
|
get_logger().error(f"Unknown edit type: {file.status}")
|
||||||
|
edit_type = EDIT_TYPE.UNKNOWN
|
||||||
|
file_patch_canonical_structure = FilePatchInfo(original_file_content_str, new_file_content_str, patch,
|
||||||
|
file.filename, edit_type=edit_type)
|
||||||
|
diff_files.append(file_patch_canonical_structure)
|
||||||
|
|
||||||
self.diff_files = diff_files
|
self.diff_files = diff_files
|
||||||
return diff_files
|
return diff_files
|
||||||
|
|
||||||
except GithubException.RateLimitExceededException as e:
|
except GithubException.RateLimitExceededException as e:
|
||||||
logging.error(f"Rate limit exceeded for GitHub API. Original message: {e}")
|
get_logger().error(f"Rate limit exceeded for GitHub API. Original message: {e}")
|
||||||
raise RateLimitExceeded("Rate limit exceeded for GitHub API.") from e
|
raise RateLimitExceeded("Rate limit exceeded for GitHub API.") from e
|
||||||
|
|
||||||
def publish_description(self, pr_title: str, pr_body: str):
|
def publish_description(self, pr_title: str, pr_body: str):
|
||||||
self.pr.edit(title=pr_title, body=pr_body)
|
self.pr.edit(title=pr_title, body=pr_body)
|
||||||
|
|
||||||
|
def get_latest_commit_url(self) -> str:
|
||||||
|
return self.last_commit_id.html_url
|
||||||
|
|
||||||
|
def get_comment_url(self, comment) -> str:
|
||||||
|
return comment.html_url
|
||||||
|
|
||||||
|
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True):
|
||||||
|
prev_comments = list(self.pr.get_issue_comments())
|
||||||
|
for comment in prev_comments:
|
||||||
|
body = comment.body
|
||||||
|
if body.startswith(initial_header):
|
||||||
|
latest_commit_url = self.get_latest_commit_url()
|
||||||
|
comment_url = self.get_comment_url(comment)
|
||||||
|
if update_header:
|
||||||
|
updated_header = f"{initial_header}\n\n### (review updated until commit {latest_commit_url})\n"
|
||||||
|
pr_comment_updated = pr_comment.replace(initial_header, updated_header)
|
||||||
|
else:
|
||||||
|
pr_comment_updated = pr_comment
|
||||||
|
get_logger().info(f"Persistent mode- updating comment {comment_url} to latest review message")
|
||||||
|
response = comment.edit(pr_comment_updated)
|
||||||
|
self.publish_comment(
|
||||||
|
f"**[Persistent review]({comment_url})** updated to latest commit {latest_commit_url}")
|
||||||
|
return
|
||||||
|
self.publish_comment(pr_comment)
|
||||||
|
|
||||||
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
||||||
if is_temporary and not get_settings().config.publish_output_progress:
|
if is_temporary and not get_settings().config.publish_output_progress:
|
||||||
logging.debug(f"Skipping publish_comment for temporary comment: {pr_comment}")
|
get_logger().debug(f"Skipping publish_comment for temporary comment: {pr_comment}")
|
||||||
return
|
return
|
||||||
|
|
||||||
response = self.pr.create_issue_comment(pr_comment)
|
response = self.pr.create_issue_comment(pr_comment)
|
||||||
if hasattr(response, "user") and hasattr(response.user, "login"):
|
if hasattr(response, "user") and hasattr(response.user, "login"):
|
||||||
self.github_user_id = response.user.login
|
self.github_user_id = response.user.login
|
||||||
@ -156,7 +200,7 @@ class GithubProvider(GitProvider):
|
|||||||
position, absolute_position = find_line_number_of_relevant_line_in_file(self.diff_files, relevant_file.strip('`'), relevant_line_in_file)
|
position, absolute_position = find_line_number_of_relevant_line_in_file(self.diff_files, relevant_file.strip('`'), relevant_line_in_file)
|
||||||
if position == -1:
|
if position == -1:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
||||||
subject_type = "FILE"
|
subject_type = "FILE"
|
||||||
else:
|
else:
|
||||||
subject_type = "LINE"
|
subject_type = "LINE"
|
||||||
@ -179,13 +223,13 @@ class GithubProvider(GitProvider):
|
|||||||
|
|
||||||
if not relevant_lines_start or relevant_lines_start == -1:
|
if not relevant_lines_start or relevant_lines_start == -1:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.exception(
|
get_logger().exception(
|
||||||
f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}")
|
f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if relevant_lines_end < relevant_lines_start:
|
if relevant_lines_end < relevant_lines_start:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.exception(f"Failed to publish code suggestion, "
|
get_logger().exception(f"Failed to publish code suggestion, "
|
||||||
f"relevant_lines_end is {relevant_lines_end} and "
|
f"relevant_lines_end is {relevant_lines_end} and "
|
||||||
f"relevant_lines_start is {relevant_lines_start}")
|
f"relevant_lines_start is {relevant_lines_start}")
|
||||||
continue
|
continue
|
||||||
@ -212,16 +256,22 @@ class GithubProvider(GitProvider):
|
|||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.error(f"Failed to publish code suggestion, error: {e}")
|
get_logger().error(f"Failed to publish code suggestion, error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def remove_initial_comment(self):
|
def remove_initial_comment(self):
|
||||||
try:
|
try:
|
||||||
for comment in getattr(self.pr, 'comments_list', []):
|
for comment in getattr(self.pr, 'comments_list', []):
|
||||||
if comment.is_temporary:
|
if comment.is_temporary:
|
||||||
comment.delete()
|
self.remove_comment(comment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to remove initial comment, error: {e}")
|
get_logger().exception(f"Failed to remove initial comment, error: {e}")
|
||||||
|
|
||||||
|
def remove_comment(self, comment):
|
||||||
|
try:
|
||||||
|
comment.delete()
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception(f"Failed to remove comment, error: {e}")
|
||||||
|
|
||||||
def get_title(self):
|
def get_title(self):
|
||||||
return self.pr.title
|
return self.pr.title
|
||||||
@ -259,7 +309,10 @@ class GithubProvider(GitProvider):
|
|||||||
|
|
||||||
def get_repo_settings(self):
|
def get_repo_settings(self):
|
||||||
try:
|
try:
|
||||||
contents = self.repo_obj.get_contents(".pr_agent.toml", ref=self.pr.head.sha).decoded_content
|
# contents = self.repo_obj.get_contents(".pr_agent.toml", ref=self.pr.head.sha).decoded_content
|
||||||
|
|
||||||
|
# more logical to take 'pr_agent.toml' from the default branch
|
||||||
|
contents = self.repo_obj.get_contents(".pr_agent.toml").decoded_content
|
||||||
return contents
|
return contents
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
@ -269,7 +322,7 @@ class GithubProvider(GitProvider):
|
|||||||
reaction = self.pr.get_issue_comment(issue_comment_id).create_reaction("eyes")
|
reaction = self.pr.get_issue_comment(issue_comment_id).create_reaction("eyes")
|
||||||
return reaction.id
|
return reaction.id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to add eyes reaction, error: {e}")
|
get_logger().exception(f"Failed to add eyes reaction, error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
||||||
@ -277,7 +330,7 @@ class GithubProvider(GitProvider):
|
|||||||
self.pr.get_issue_comment(issue_comment_id).delete_reaction(reaction_id)
|
self.pr.get_issue_comment(issue_comment_id).delete_reaction(reaction_id)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to remove eyes reaction, error: {e}")
|
get_logger().exception(f"Failed to remove eyes reaction, error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@ -396,13 +449,13 @@ class GithubProvider(GitProvider):
|
|||||||
"PUT", f"{self.pr.issue_url}/labels", input=post_parameters
|
"PUT", f"{self.pr.issue_url}/labels", input=post_parameters
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to publish labels, error: {e}")
|
get_logger().exception(f"Failed to publish labels, error: {e}")
|
||||||
|
|
||||||
def get_labels(self):
|
def get_labels(self):
|
||||||
try:
|
try:
|
||||||
return [label.name for label in self.pr.labels]
|
return [label.name for label in self.pr.labels]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to get labels, error: {e}")
|
get_logger().exception(f"Failed to get labels, error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_commit_messages(self):
|
def get_commit_messages(self):
|
||||||
@ -444,7 +497,7 @@ class GithubProvider(GitProvider):
|
|||||||
return link
|
return link
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Failed adding line link, error: {e}")
|
get_logger().info(f"Failed adding line link, error: {e}")
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@ -12,8 +11,8 @@ from ..algo.pr_processing import clip_tokens, find_line_number_of_relevant_line_
|
|||||||
from ..algo.utils import load_large_diff
|
from ..algo.utils import load_large_diff
|
||||||
from ..config_loader import get_settings
|
from ..config_loader import get_settings
|
||||||
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
||||||
|
from ..log import get_logger
|
||||||
|
|
||||||
logger = logging.getLogger()
|
|
||||||
|
|
||||||
class DiffNotFoundError(Exception):
|
class DiffNotFoundError(Exception):
|
||||||
"""Raised when the diff for a merge request cannot be found."""
|
"""Raised when the diff for a merge request cannot be found."""
|
||||||
@ -59,7 +58,7 @@ class GitLabProvider(GitProvider):
|
|||||||
try:
|
try:
|
||||||
self.last_diff = self.mr.diffs.list(get_all=True)[-1]
|
self.last_diff = self.mr.diffs.list(get_all=True)[-1]
|
||||||
except IndexError as e:
|
except IndexError as e:
|
||||||
logger.error(f"Could not get diff for merge request {self.id_mr}")
|
get_logger().error(f"Could not get diff for merge request {self.id_mr}")
|
||||||
raise DiffNotFoundError(f"Could not get diff for merge request {self.id_mr}") from e
|
raise DiffNotFoundError(f"Could not get diff for merge request {self.id_mr}") from e
|
||||||
|
|
||||||
|
|
||||||
@ -99,7 +98,7 @@ class GitLabProvider(GitProvider):
|
|||||||
if isinstance(new_file_content_str, bytes):
|
if isinstance(new_file_content_str, bytes):
|
||||||
new_file_content_str = bytes.decode(new_file_content_str, 'utf-8')
|
new_file_content_str = bytes.decode(new_file_content_str, 'utf-8')
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
logging.warning(
|
get_logger().warning(
|
||||||
f"Cannot decode file {diff['old_path']} or {diff['new_path']} in merge request {self.id_mr}")
|
f"Cannot decode file {diff['old_path']} or {diff['new_path']} in merge request {self.id_mr}")
|
||||||
|
|
||||||
edit_type = EDIT_TYPE.MODIFIED
|
edit_type = EDIT_TYPE.MODIFIED
|
||||||
@ -135,7 +134,34 @@ class GitLabProvider(GitProvider):
|
|||||||
self.mr.description = pr_body
|
self.mr.description = pr_body
|
||||||
self.mr.save()
|
self.mr.save()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Could not update merge request {self.id_mr} description: {e}")
|
get_logger().exception(f"Could not update merge request {self.id_mr} description: {e}")
|
||||||
|
|
||||||
|
def get_latest_commit_url(self):
|
||||||
|
return self.mr.commits().next().web_url
|
||||||
|
|
||||||
|
def get_comment_url(self, comment):
|
||||||
|
return f"{self.mr.web_url}#note_{comment.id}"
|
||||||
|
|
||||||
|
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True):
|
||||||
|
try:
|
||||||
|
for comment in self.mr.notes.list(get_all=True)[::-1]:
|
||||||
|
if comment.body.startswith(initial_header):
|
||||||
|
latest_commit_url = self.get_latest_commit_url()
|
||||||
|
comment_url = self.get_comment_url(comment)
|
||||||
|
if update_header:
|
||||||
|
updated_header = f"{initial_header}\n\n### (review updated until commit {latest_commit_url})\n"
|
||||||
|
pr_comment_updated = pr_comment.replace(initial_header, updated_header)
|
||||||
|
else:
|
||||||
|
pr_comment_updated = pr_comment
|
||||||
|
get_logger().info(f"Persistent mode- updating comment {comment_url} to latest review message")
|
||||||
|
response = self.mr.notes.update(comment.id, {'body': pr_comment_updated})
|
||||||
|
self.publish_comment(
|
||||||
|
f"**[Persistent review]({comment_url})** updated to latest commit {latest_commit_url}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception(f"Failed to update persistent review, error: {e}")
|
||||||
|
pass
|
||||||
|
self.publish_comment(pr_comment)
|
||||||
|
|
||||||
def publish_comment(self, mr_comment: str, is_temporary: bool = False):
|
def publish_comment(self, mr_comment: str, is_temporary: bool = False):
|
||||||
comment = self.mr.notes.create({'body': mr_comment})
|
comment = self.mr.notes.create({'body': mr_comment})
|
||||||
@ -157,12 +183,12 @@ class GitLabProvider(GitProvider):
|
|||||||
def send_inline_comment(self,body: str,edit_type: str,found: bool,relevant_file: str,relevant_line_in_file: int,
|
def send_inline_comment(self,body: str,edit_type: str,found: bool,relevant_file: str,relevant_line_in_file: int,
|
||||||
source_line_no: int, target_file: str,target_line_no: int) -> None:
|
source_line_no: int, target_file: str,target_line_no: int) -> None:
|
||||||
if not found:
|
if not found:
|
||||||
logging.info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
||||||
else:
|
else:
|
||||||
# in order to have exact sha's we have to find correct diff for this change
|
# in order to have exact sha's we have to find correct diff for this change
|
||||||
diff = self.get_relevant_diff(relevant_file, relevant_line_in_file)
|
diff = self.get_relevant_diff(relevant_file, relevant_line_in_file)
|
||||||
if diff is None:
|
if diff is None:
|
||||||
logger.error(f"Could not get diff for merge request {self.id_mr}")
|
get_logger().error(f"Could not get diff for merge request {self.id_mr}")
|
||||||
raise DiffNotFoundError(f"Could not get diff for merge request {self.id_mr}")
|
raise DiffNotFoundError(f"Could not get diff for merge request {self.id_mr}")
|
||||||
pos_obj = {'position_type': 'text',
|
pos_obj = {'position_type': 'text',
|
||||||
'new_path': target_file.filename,
|
'new_path': target_file.filename,
|
||||||
@ -175,23 +201,23 @@ class GitLabProvider(GitProvider):
|
|||||||
else:
|
else:
|
||||||
pos_obj['new_line'] = target_line_no - 1
|
pos_obj['new_line'] = target_line_no - 1
|
||||||
pos_obj['old_line'] = source_line_no - 1
|
pos_obj['old_line'] = source_line_no - 1
|
||||||
logging.debug(f"Creating comment in {self.id_mr} with body {body} and position {pos_obj}")
|
get_logger().debug(f"Creating comment in {self.id_mr} with body {body} and position {pos_obj}")
|
||||||
self.mr.discussions.create({'body': body, 'position': pos_obj})
|
self.mr.discussions.create({'body': body, 'position': pos_obj})
|
||||||
|
|
||||||
def get_relevant_diff(self, relevant_file: str, relevant_line_in_file: int) -> Optional[dict]:
|
def get_relevant_diff(self, relevant_file: str, relevant_line_in_file: int) -> Optional[dict]:
|
||||||
changes = self.mr.changes() # Retrieve the changes for the merge request once
|
changes = self.mr.changes() # Retrieve the changes for the merge request once
|
||||||
if not changes:
|
if not changes:
|
||||||
logging.error('No changes found for the merge request.')
|
get_logger().error('No changes found for the merge request.')
|
||||||
return None
|
return None
|
||||||
all_diffs = self.mr.diffs.list(get_all=True)
|
all_diffs = self.mr.diffs.list(get_all=True)
|
||||||
if not all_diffs:
|
if not all_diffs:
|
||||||
logging.error('No diffs found for the merge request.')
|
get_logger().error('No diffs found for the merge request.')
|
||||||
return None
|
return None
|
||||||
for diff in all_diffs:
|
for diff in all_diffs:
|
||||||
for change in changes['changes']:
|
for change in changes['changes']:
|
||||||
if change['new_path'] == relevant_file and relevant_line_in_file in change['diff']:
|
if change['new_path'] == relevant_file and relevant_line_in_file in change['diff']:
|
||||||
return diff
|
return diff
|
||||||
logging.debug(
|
get_logger().debug(
|
||||||
f'No relevant diff found for {relevant_file} {relevant_line_in_file}. Falling back to last diff.')
|
f'No relevant diff found for {relevant_file} {relevant_line_in_file}. Falling back to last diff.')
|
||||||
return self.last_diff # fallback to last_diff if no relevant diff is found
|
return self.last_diff # fallback to last_diff if no relevant diff is found
|
||||||
|
|
||||||
@ -226,7 +252,7 @@ class GitLabProvider(GitProvider):
|
|||||||
self.send_inline_comment(body, edit_type, found, relevant_file, relevant_line_in_file, source_line_no,
|
self.send_inline_comment(body, edit_type, found, relevant_file, relevant_line_in_file, source_line_no,
|
||||||
target_file, target_line_no)
|
target_file, target_line_no)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Could not publish code suggestion:\nsuggestion: {suggestion}\nerror: {e}")
|
get_logger().exception(f"Could not publish code suggestion:\nsuggestion: {suggestion}\nerror: {e}")
|
||||||
|
|
||||||
# note that we publish suggestions one-by-one. so, if one fails, the rest will still be published
|
# note that we publish suggestions one-by-one. so, if one fails, the rest will still be published
|
||||||
return True
|
return True
|
||||||
@ -288,9 +314,15 @@ class GitLabProvider(GitProvider):
|
|||||||
def remove_initial_comment(self):
|
def remove_initial_comment(self):
|
||||||
try:
|
try:
|
||||||
for comment in self.temp_comments:
|
for comment in self.temp_comments:
|
||||||
comment.delete()
|
self.remove_comment(comment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to remove temp comments, error: {e}")
|
get_logger().exception(f"Failed to remove temp comments, error: {e}")
|
||||||
|
|
||||||
|
def remove_comment(self, comment):
|
||||||
|
try:
|
||||||
|
comment.delete()
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception(f"Failed to remove comment, error: {e}")
|
||||||
|
|
||||||
def get_title(self):
|
def get_title(self):
|
||||||
return self.mr.title
|
return self.mr.title
|
||||||
@ -310,7 +342,7 @@ class GitLabProvider(GitProvider):
|
|||||||
|
|
||||||
def get_repo_settings(self):
|
def get_repo_settings(self):
|
||||||
try:
|
try:
|
||||||
contents = self.gl.projects.get(self.id_project).files.get(file_path='.pr_agent.toml', ref=self.mr.source_branch)
|
contents = self.gl.projects.get(self.id_project).files.get(file_path='.pr_agent.toml', ref=self.mr.target_branch).decode()
|
||||||
return contents
|
return contents
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
@ -358,7 +390,7 @@ class GitLabProvider(GitProvider):
|
|||||||
self.mr.labels = list(set(pr_types))
|
self.mr.labels = list(set(pr_types))
|
||||||
self.mr.save()
|
self.mr.save()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to publish labels, error: {e}")
|
get_logger().exception(f"Failed to publish labels, error: {e}")
|
||||||
|
|
||||||
def publish_inline_comments(self, comments: list[dict]):
|
def publish_inline_comments(self, comments: list[dict]):
|
||||||
pass
|
pass
|
||||||
@ -410,6 +442,6 @@ class GitLabProvider(GitProvider):
|
|||||||
return link
|
return link
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Failed adding line link, error: {e}")
|
get_logger().info(f"Failed adding line link, error: {e}")
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import logging
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
@ -7,6 +6,7 @@ from git import Repo
|
|||||||
|
|
||||||
from pr_agent.config_loader import _find_repository_root, get_settings
|
from pr_agent.config_loader import _find_repository_root, get_settings
|
||||||
from pr_agent.git_providers.git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
from pr_agent.git_providers.git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class PullRequestMimic:
|
class PullRequestMimic:
|
||||||
@ -49,7 +49,7 @@ class LocalGitProvider(GitProvider):
|
|||||||
"""
|
"""
|
||||||
Prepare the repository for PR-mimic generation.
|
Prepare the repository for PR-mimic generation.
|
||||||
"""
|
"""
|
||||||
logging.debug('Preparing repository for PR-mimic generation...')
|
get_logger().debug('Preparing repository for PR-mimic generation...')
|
||||||
if self.repo.is_dirty():
|
if self.repo.is_dirty():
|
||||||
raise ValueError('The repository is not in a clean state. Please commit or stash pending changes.')
|
raise ValueError('The repository is not in a clean state. Please commit or stash pending changes.')
|
||||||
if self.target_branch_name not in self.repo.heads:
|
if self.target_branch_name not in self.repo.heads:
|
||||||
@ -140,6 +140,9 @@ class LocalGitProvider(GitProvider):
|
|||||||
def remove_initial_comment(self):
|
def remove_initial_comment(self):
|
||||||
pass # Not applicable to the local git provider, but required by the interface
|
pass # Not applicable to the local git provider, but required by the interface
|
||||||
|
|
||||||
|
def remove_comment(self, comment):
|
||||||
|
pass # Not applicable to the local git provider, but required by the interface
|
||||||
|
|
||||||
def get_languages(self):
|
def get_languages(self):
|
||||||
"""
|
"""
|
||||||
Calculate percentage of languages in repository. Used for hunk prioritisation.
|
Calculate percentage of languages in repository. Used for hunk prioritisation.
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
@ -7,6 +6,7 @@ from dynaconf import Dynaconf
|
|||||||
|
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
def apply_repo_settings(pr_url):
|
def apply_repo_settings(pr_url):
|
||||||
@ -26,10 +26,12 @@ def apply_repo_settings(pr_url):
|
|||||||
section_dict[key] = value
|
section_dict[key] = value
|
||||||
get_settings().unset(section)
|
get_settings().unset(section)
|
||||||
get_settings().set(section, section_dict, merge=False)
|
get_settings().set(section, section_dict, merge=False)
|
||||||
|
get_logger().info(f"Applying repo settings for section {section}, contents: {contents}")
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception("Failed to apply repo settings", e)
|
||||||
finally:
|
finally:
|
||||||
if repo_settings_file:
|
if repo_settings_file:
|
||||||
try:
|
try:
|
||||||
os.remove(repo_settings_file)
|
os.remove(repo_settings_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to remove temporary settings file {repo_settings_file}", e)
|
get_logger().error(f"Failed to remove temporary settings file {repo_settings_file}", e)
|
||||||
|
40
pr_agent/log/__init__.py
Normal file
40
pr_agent/log/__init__.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingFormat(str, Enum):
|
||||||
|
CONSOLE = "CONSOLE"
|
||||||
|
JSON = "JSON"
|
||||||
|
|
||||||
|
|
||||||
|
def json_format(record: dict) -> str:
|
||||||
|
return record["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger(level: str = "INFO", fmt: LoggingFormat = LoggingFormat.CONSOLE):
|
||||||
|
level: int = logging.getLevelName(level.upper())
|
||||||
|
if type(level) is not int:
|
||||||
|
level = logging.INFO
|
||||||
|
|
||||||
|
if fmt == LoggingFormat.JSON:
|
||||||
|
logger.remove(None)
|
||||||
|
logger.add(
|
||||||
|
sys.stdout,
|
||||||
|
level=level,
|
||||||
|
format="{message}",
|
||||||
|
colorize=False,
|
||||||
|
serialize=True,
|
||||||
|
)
|
||||||
|
elif fmt == LoggingFormat.CONSOLE:
|
||||||
|
logger.remove(None)
|
||||||
|
logger.add(sys.stdout, level=level, colorize=True)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(*args, **kwargs):
|
||||||
|
return logger
|
@ -1,9 +1,8 @@
|
|||||||
import ujson
|
import ujson
|
||||||
|
|
||||||
from google.cloud import storage
|
from google.cloud import storage
|
||||||
|
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers.gitlab_provider import logger
|
from pr_agent.log import get_logger
|
||||||
from pr_agent.secret_providers.secret_provider import SecretProvider
|
from pr_agent.secret_providers.secret_provider import SecretProvider
|
||||||
|
|
||||||
|
|
||||||
@ -15,7 +14,7 @@ class GoogleCloudStorageSecretProvider(SecretProvider):
|
|||||||
self.bucket_name = get_settings().google_cloud_storage.bucket_name
|
self.bucket_name = get_settings().google_cloud_storage.bucket_name
|
||||||
self.bucket = self.client.bucket(self.bucket_name)
|
self.bucket = self.client.bucket(self.bucket_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize Google Cloud Storage Secret Provider: {e}")
|
get_logger().error(f"Failed to initialize Google Cloud Storage Secret Provider: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def get_secret(self, secret_name: str) -> str:
|
def get_secret(self, secret_name: str) -> str:
|
||||||
@ -23,7 +22,7 @@ class GoogleCloudStorageSecretProvider(SecretProvider):
|
|||||||
blob = self.bucket.blob(secret_name)
|
blob = self.bucket.blob(secret_name)
|
||||||
return blob.download_as_string()
|
return blob.download_as_string()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get secret {secret_name} from Google Cloud Storage: {e}")
|
get_logger().error(f"Failed to get secret {secret_name} from Google Cloud Storage: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def store_secret(self, secret_name: str, secret_value: str):
|
def store_secret(self, secret_name: str, secret_value: str):
|
||||||
@ -31,5 +30,5 @@ class GoogleCloudStorageSecretProvider(SecretProvider):
|
|||||||
blob = self.bucket.blob(secret_name)
|
blob = self.bucket.blob(secret_name)
|
||||||
blob.upload_from_string(secret_value)
|
blob.upload_from_string(secret_value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to store secret {secret_name} in Google Cloud Storage: {e}")
|
get_logger().error(f"Failed to store secret {secret_name} in Google Cloud Storage: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import copy
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
@ -18,9 +16,10 @@ from starlette_context.middleware import RawContextMiddleware
|
|||||||
|
|
||||||
from pr_agent.agent.pr_agent import PRAgent
|
from pr_agent.agent.pr_agent import PRAgent
|
||||||
from pr_agent.config_loader import get_settings, global_settings
|
from pr_agent.config_loader import get_settings, global_settings
|
||||||
|
from pr_agent.log import LoggingFormat, get_logger, setup_logger
|
||||||
from pr_agent.secret_providers import get_secret_provider
|
from pr_agent.secret_providers import get_secret_provider
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
setup_logger(fmt=LoggingFormat.JSON)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
secret_provider = get_secret_provider()
|
secret_provider = get_secret_provider()
|
||||||
|
|
||||||
@ -49,7 +48,7 @@ async def get_bearer_token(shared_secret: str, client_key: str):
|
|||||||
bearer_token = response.json()["access_token"]
|
bearer_token = response.json()["access_token"]
|
||||||
return bearer_token
|
return bearer_token
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to get bearer token: {e}")
|
get_logger().error(f"Failed to get bearer token: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
@ -60,21 +59,23 @@ async def handle_manifest(request: Request, response: Response):
|
|||||||
manifest = manifest.replace("app_key", get_settings().bitbucket.app_key)
|
manifest = manifest.replace("app_key", get_settings().bitbucket.app_key)
|
||||||
manifest = manifest.replace("base_url", get_settings().bitbucket.base_url)
|
manifest = manifest.replace("base_url", get_settings().bitbucket.base_url)
|
||||||
except:
|
except:
|
||||||
logging.error("Failed to replace api_key in Bitbucket manifest, trying to continue")
|
get_logger().error("Failed to replace api_key in Bitbucket manifest, trying to continue")
|
||||||
manifest_obj = json.loads(manifest)
|
manifest_obj = json.loads(manifest)
|
||||||
return JSONResponse(manifest_obj)
|
return JSONResponse(manifest_obj)
|
||||||
|
|
||||||
@router.post("/webhook")
|
@router.post("/webhook")
|
||||||
async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Request):
|
async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Request):
|
||||||
print(request.headers)
|
log_context = {"server_type": "bitbucket_app"}
|
||||||
|
get_logger().debug(request.headers)
|
||||||
jwt_header = request.headers.get("authorization", None)
|
jwt_header = request.headers.get("authorization", None)
|
||||||
if jwt_header:
|
if jwt_header:
|
||||||
input_jwt = jwt_header.split(" ")[1]
|
input_jwt = jwt_header.split(" ")[1]
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
print(data)
|
get_logger().debug(data)
|
||||||
async def inner():
|
async def inner():
|
||||||
try:
|
try:
|
||||||
owner = data["data"]["repository"]["owner"]["username"]
|
owner = data["data"]["repository"]["owner"]["username"]
|
||||||
|
log_context["sender"] = owner
|
||||||
secrets = json.loads(secret_provider.get_secret(owner))
|
secrets = json.loads(secret_provider.get_secret(owner))
|
||||||
shared_secret = secrets["shared_secret"]
|
shared_secret = secrets["shared_secret"]
|
||||||
client_key = secrets["client_key"]
|
client_key = secrets["client_key"]
|
||||||
@ -86,13 +87,19 @@ async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Req
|
|||||||
agent = PRAgent()
|
agent = PRAgent()
|
||||||
if event == "pullrequest:created":
|
if event == "pullrequest:created":
|
||||||
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"]
|
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"]
|
||||||
await agent.handle_request(pr_url, "review")
|
log_context["api_url"] = pr_url
|
||||||
|
log_context["event"] = "pull_request"
|
||||||
|
with get_logger().contextualize(**log_context):
|
||||||
|
await agent.handle_request(pr_url, "review")
|
||||||
elif event == "pullrequest:comment_created":
|
elif event == "pullrequest:comment_created":
|
||||||
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"]
|
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"]
|
||||||
|
log_context["api_url"] = pr_url
|
||||||
|
log_context["event"] = "comment"
|
||||||
comment_body = data["data"]["comment"]["content"]["raw"]
|
comment_body = data["data"]["comment"]["content"]["raw"]
|
||||||
await agent.handle_request(pr_url, comment_body)
|
with get_logger().contextualize(**log_context):
|
||||||
|
await agent.handle_request(pr_url, comment_body)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to handle webhook: {e}")
|
get_logger().error(f"Failed to handle webhook: {e}")
|
||||||
background_tasks.add_task(inner)
|
background_tasks.add_task(inner)
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@ -103,9 +110,10 @@ async def handle_github_webhooks(request: Request, response: Response):
|
|||||||
@router.post("/installed")
|
@router.post("/installed")
|
||||||
async def handle_installed_webhooks(request: Request, response: Response):
|
async def handle_installed_webhooks(request: Request, response: Response):
|
||||||
try:
|
try:
|
||||||
print(request.headers)
|
get_logger().info("handle_installed_webhooks")
|
||||||
|
get_logger().info(request.headers)
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
print(data)
|
get_logger().info(data)
|
||||||
shared_secret = data["sharedSecret"]
|
shared_secret = data["sharedSecret"]
|
||||||
client_key = data["clientKey"]
|
client_key = data["clientKey"]
|
||||||
username = data["principal"]["username"]
|
username = data["principal"]["username"]
|
||||||
@ -115,13 +123,15 @@ async def handle_installed_webhooks(request: Request, response: Response):
|
|||||||
}
|
}
|
||||||
secret_provider.store_secret(username, json.dumps(secrets))
|
secret_provider.store_secret(username, json.dumps(secrets))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to register user: {e}")
|
get_logger().error(f"Failed to register user: {e}")
|
||||||
return JSONResponse({"error": "Unable to register user"}, status_code=500)
|
return JSONResponse({"error": "Unable to register user"}, status_code=500)
|
||||||
|
|
||||||
@router.post("/uninstalled")
|
@router.post("/uninstalled")
|
||||||
async def handle_uninstalled_webhooks(request: Request, response: Response):
|
async def handle_uninstalled_webhooks(request: Request, response: Response):
|
||||||
|
get_logger().info("handle_uninstalled_webhooks")
|
||||||
|
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
print(data)
|
get_logger().info(data)
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def start():
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
import os
|
|
||||||
from pr_agent.agent.pr_agent import PRAgent
|
|
||||||
from pr_agent.config_loader import get_settings
|
|
||||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def run_action():
|
|
||||||
try:
|
|
||||||
pull_request_id = os.environ.get("BITBUCKET_PR_ID", '')
|
|
||||||
slug = os.environ.get("BITBUCKET_REPO_SLUG", '')
|
|
||||||
workspace = os.environ.get("BITBUCKET_WORKSPACE", '')
|
|
||||||
bearer_token = os.environ.get('BITBUCKET_BEARER_TOKEN', None)
|
|
||||||
OPENAI_KEY = os.environ.get('OPENAI_API_KEY') or os.environ.get('OPENAI.KEY')
|
|
||||||
OPENAI_ORG = os.environ.get('OPENAI_ORG') or os.environ.get('OPENAI.ORG')
|
|
||||||
# Check if required environment variables are set
|
|
||||||
if not bearer_token:
|
|
||||||
print("BITBUCKET_BEARER_TOKEN not set")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not OPENAI_KEY:
|
|
||||||
print("OPENAI_KEY not set")
|
|
||||||
return
|
|
||||||
# Set the environment variables in the settings
|
|
||||||
get_settings().set("BITBUCKET.BEARER_TOKEN", bearer_token)
|
|
||||||
get_settings().set("OPENAI.KEY", OPENAI_KEY)
|
|
||||||
if OPENAI_ORG:
|
|
||||||
get_settings().set("OPENAI.ORG", OPENAI_ORG)
|
|
||||||
if pull_request_id and slug and workspace:
|
|
||||||
pr_url = f"https://bitbucket.org/{workspace}/{slug}/pull-requests/{pull_request_id}"
|
|
||||||
await PRReviewer(pr_url).run()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"An error occurred: {e}")
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(run_action())
|
|
@ -1,6 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
|
|
||||||
@ -12,9 +10,10 @@ from starlette_context import context
|
|||||||
from starlette_context.middleware import RawContextMiddleware
|
from starlette_context.middleware import RawContextMiddleware
|
||||||
|
|
||||||
from pr_agent.agent.pr_agent import PRAgent
|
from pr_agent.agent.pr_agent import PRAgent
|
||||||
from pr_agent.config_loader import global_settings, get_settings
|
from pr_agent.config_loader import get_settings, global_settings
|
||||||
|
from pr_agent.log import get_logger, setup_logger
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
|
setup_logger()
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@ -35,7 +34,7 @@ class Item(BaseModel):
|
|||||||
|
|
||||||
@router.post("/api/v1/gerrit/{action}")
|
@router.post("/api/v1/gerrit/{action}")
|
||||||
async def handle_gerrit_request(action: Action, item: Item):
|
async def handle_gerrit_request(action: Action, item: Item):
|
||||||
logging.debug("Received a Gerrit request")
|
get_logger().debug("Received a Gerrit request")
|
||||||
context["settings"] = copy.deepcopy(global_settings)
|
context["settings"] = copy.deepcopy(global_settings)
|
||||||
|
|
||||||
if action == Action.ask:
|
if action == Action.ask:
|
||||||
@ -54,7 +53,7 @@ async def get_body(request):
|
|||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except JSONDecodeError as e:
|
except JSONDecodeError as e:
|
||||||
logging.error("Error parsing request body", e)
|
get_logger().error("Error parsing request body", e)
|
||||||
return {}
|
return {}
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
@ -5,6 +5,8 @@ import os
|
|||||||
from pr_agent.agent.pr_agent import PRAgent
|
from pr_agent.agent.pr_agent import PRAgent
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
|
from pr_agent.git_providers.utils import apply_repo_settings
|
||||||
|
from pr_agent.log import get_logger
|
||||||
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
||||||
from pr_agent.tools.pr_description import PRDescription
|
from pr_agent.tools.pr_description import PRDescription
|
||||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||||
@ -19,7 +21,6 @@ async def run_action():
|
|||||||
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN')
|
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN')
|
||||||
get_settings().set("CONFIG.PUBLISH_OUTPUT_PROGRESS", False)
|
get_settings().set("CONFIG.PUBLISH_OUTPUT_PROGRESS", False)
|
||||||
|
|
||||||
|
|
||||||
# Check if required environment variables are set
|
# Check if required environment variables are set
|
||||||
if not GITHUB_EVENT_NAME:
|
if not GITHUB_EVENT_NAME:
|
||||||
print("GITHUB_EVENT_NAME not set")
|
print("GITHUB_EVENT_NAME not set")
|
||||||
@ -49,6 +50,15 @@ async def run_action():
|
|||||||
print(f"Failed to parse JSON: {e}")
|
print(f"Failed to parse JSON: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_logger().info("Applying repo settings")
|
||||||
|
pr_url = event_payload.get("pull_request", {}).get("html_url")
|
||||||
|
if pr_url:
|
||||||
|
apply_repo_settings(pr_url)
|
||||||
|
get_logger().info(f"enable_custom_labels: {get_settings().config.enable_custom_labels}")
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().info(f"github action: failed to apply repo settings: {e}")
|
||||||
|
|
||||||
# Handle pull request event
|
# Handle pull request event
|
||||||
if GITHUB_EVENT_NAME == "pull_request":
|
if GITHUB_EVENT_NAME == "pull_request":
|
||||||
action = event_payload.get("action")
|
action = event_payload.get("action")
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import time
|
import asyncio.locks
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
||||||
@ -16,9 +14,12 @@ from pr_agent.algo.utils import update_settings_from_args
|
|||||||
from pr_agent.config_loader import get_settings, global_settings
|
from pr_agent.config_loader import get_settings, global_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pr_agent.git_providers.utils import apply_repo_settings
|
from pr_agent.git_providers.utils import apply_repo_settings
|
||||||
from pr_agent.servers.utils import verify_signature
|
from pr_agent.git_providers.git_provider import IncrementalPR
|
||||||
|
from pr_agent.log import LoggingFormat, get_logger, setup_logger
|
||||||
|
from pr_agent.servers.utils import verify_signature, DefaultDictWithTimeout
|
||||||
|
|
||||||
|
setup_logger(fmt=LoggingFormat.JSON)
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@ -29,11 +30,11 @@ async def handle_github_webhooks(request: Request, response: Response):
|
|||||||
Verifies the request signature, parses the request body, and passes it to the handle_request function for further
|
Verifies the request signature, parses the request body, and passes it to the handle_request function for further
|
||||||
processing.
|
processing.
|
||||||
"""
|
"""
|
||||||
logging.debug("Received a GitHub webhook")
|
get_logger().debug("Received a GitHub webhook")
|
||||||
|
|
||||||
body = await get_body(request)
|
body = await get_body(request)
|
||||||
|
|
||||||
logging.debug(f'Request body:\n{body}')
|
get_logger().debug(f'Request body:\n{body}')
|
||||||
installation_id = body.get("installation", {}).get("id")
|
installation_id = body.get("installation", {}).get("id")
|
||||||
context["installation_id"] = installation_id
|
context["installation_id"] = installation_id
|
||||||
context["settings"] = copy.deepcopy(global_settings)
|
context["settings"] = copy.deepcopy(global_settings)
|
||||||
@ -45,13 +46,14 @@ async def handle_github_webhooks(request: Request, response: Response):
|
|||||||
@router.post("/api/v1/marketplace_webhooks")
|
@router.post("/api/v1/marketplace_webhooks")
|
||||||
async def handle_marketplace_webhooks(request: Request, response: Response):
|
async def handle_marketplace_webhooks(request: Request, response: Response):
|
||||||
body = await get_body(request)
|
body = await get_body(request)
|
||||||
logging.info(f'Request body:\n{body}')
|
get_logger().info(f'Request body:\n{body}')
|
||||||
|
|
||||||
|
|
||||||
async def get_body(request):
|
async def get_body(request):
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Error parsing request body", e)
|
get_logger().error("Error parsing request body", e)
|
||||||
raise HTTPException(status_code=400, detail="Error parsing request body") from e
|
raise HTTPException(status_code=400, detail="Error parsing request body") from e
|
||||||
webhook_secret = getattr(get_settings().github, 'webhook_secret', None)
|
webhook_secret = getattr(get_settings().github, 'webhook_secret', None)
|
||||||
if webhook_secret:
|
if webhook_secret:
|
||||||
@ -61,7 +63,9 @@ async def get_body(request):
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
_duplicate_requests_cache = {}
|
_duplicate_requests_cache = DefaultDictWithTimeout(ttl=get_settings().github_app.duplicate_requests_cache_ttl)
|
||||||
|
_duplicate_push_triggers = DefaultDictWithTimeout(ttl=get_settings().github_app.push_trigger_pending_tasks_ttl)
|
||||||
|
_pending_task_duplicate_push_conditions = DefaultDictWithTimeout(asyncio.locks.Condition, ttl=get_settings().github_app.push_trigger_pending_tasks_ttl)
|
||||||
|
|
||||||
|
|
||||||
async def handle_request(body: Dict[str, Any], event: str):
|
async def handle_request(body: Dict[str, Any], event: str):
|
||||||
@ -77,8 +81,8 @@ async def handle_request(body: Dict[str, Any], event: str):
|
|||||||
return {}
|
return {}
|
||||||
agent = PRAgent()
|
agent = PRAgent()
|
||||||
bot_user = get_settings().github_app.bot_user
|
bot_user = get_settings().github_app.bot_user
|
||||||
logging.info(f"action: '{action}'")
|
sender = body.get("sender", {}).get("login")
|
||||||
logging.info(f"event: '{event}'")
|
log_context = {"action": action, "event": event, "sender": sender, "server_type": "github_app"}
|
||||||
|
|
||||||
if get_settings().github_app.duplicate_requests_cache and _is_duplicate_request(body):
|
if get_settings().github_app.duplicate_requests_cache and _is_duplicate_request(body):
|
||||||
return {}
|
return {}
|
||||||
@ -88,74 +92,143 @@ async def handle_request(body: Dict[str, Any], event: str):
|
|||||||
if "comment" not in body:
|
if "comment" not in body:
|
||||||
return {}
|
return {}
|
||||||
comment_body = body.get("comment", {}).get("body")
|
comment_body = body.get("comment", {}).get("body")
|
||||||
sender = body.get("sender", {}).get("login")
|
|
||||||
if sender and bot_user in sender:
|
if sender and bot_user in sender:
|
||||||
logging.info(f"Ignoring comment from {bot_user} user")
|
get_logger().info(f"Ignoring comment from {bot_user} user")
|
||||||
return {}
|
return {}
|
||||||
logging.info(f"Processing comment from {sender} user")
|
get_logger().info(f"Processing comment from {sender} user")
|
||||||
if "issue" in body and "pull_request" in body["issue"] and "url" in body["issue"]["pull_request"]:
|
if "issue" in body and "pull_request" in body["issue"] and "url" in body["issue"]["pull_request"]:
|
||||||
api_url = body["issue"]["pull_request"]["url"]
|
api_url = body["issue"]["pull_request"]["url"]
|
||||||
elif "comment" in body and "pull_request_url" in body["comment"]:
|
elif "comment" in body and "pull_request_url" in body["comment"]:
|
||||||
api_url = body["comment"]["pull_request_url"]
|
api_url = body["comment"]["pull_request_url"]
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
logging.info(body)
|
log_context["api_url"] = api_url
|
||||||
logging.info(f"Handling comment because of event={event} and action={action}")
|
get_logger().info(body)
|
||||||
|
get_logger().info(f"Handling comment because of event={event} and action={action}")
|
||||||
comment_id = body.get("comment", {}).get("id")
|
comment_id = body.get("comment", {}).get("id")
|
||||||
provider = get_git_provider()(pr_url=api_url)
|
provider = get_git_provider()(pr_url=api_url)
|
||||||
await agent.handle_request(api_url, comment_body, notify=lambda: provider.add_eyes_reaction(comment_id))
|
with get_logger().contextualize(**log_context):
|
||||||
|
await agent.handle_request(api_url, comment_body, notify=lambda: provider.add_eyes_reaction(comment_id))
|
||||||
|
|
||||||
# handle pull_request event:
|
# handle pull_request event:
|
||||||
# automatically review opened/reopened/ready_for_review PRs as long as they're not in draft,
|
# automatically review opened/reopened/ready_for_review PRs as long as they're not in draft,
|
||||||
# as well as direct review requests from the bot
|
# as well as direct review requests from the bot
|
||||||
elif event == 'pull_request':
|
elif event == 'pull_request' and action != 'synchronize':
|
||||||
pull_request = body.get("pull_request")
|
pull_request, api_url = _check_pull_request_event(action, body, log_context, bot_user)
|
||||||
if not pull_request:
|
if not (pull_request and api_url):
|
||||||
return {}
|
|
||||||
api_url = pull_request.get("url")
|
|
||||||
if not api_url:
|
|
||||||
return {}
|
|
||||||
if pull_request.get("draft", True) or pull_request.get("state") != "open" or pull_request.get("user", {}).get("login", "") == bot_user:
|
|
||||||
return {}
|
return {}
|
||||||
if action in get_settings().github_app.handle_pr_actions:
|
if action in get_settings().github_app.handle_pr_actions:
|
||||||
if action == "review_requested":
|
if action == "review_requested":
|
||||||
if body.get("requested_reviewer", {}).get("login", "") != bot_user:
|
if body.get("requested_reviewer", {}).get("login", "") != bot_user:
|
||||||
return {}
|
return {}
|
||||||
if pull_request.get("created_at") == pull_request.get("updated_at"):
|
get_logger().info(f"Performing review for {api_url=} because of {event=} and {action=}")
|
||||||
# avoid double reviews when opening a PR for the first time
|
await _perform_commands("pr_commands", agent, body, api_url, log_context)
|
||||||
return {}
|
|
||||||
logging.info(f"Performing review because of event={event} and action={action}")
|
|
||||||
apply_repo_settings(api_url)
|
|
||||||
for command in get_settings().github_app.pr_commands:
|
|
||||||
split_command = command.split(" ")
|
|
||||||
command = split_command[0]
|
|
||||||
args = split_command[1:]
|
|
||||||
other_args = update_settings_from_args(args)
|
|
||||||
new_command = ' '.join([command] + other_args)
|
|
||||||
logging.info(body)
|
|
||||||
logging.info(f"Performing command: {new_command}")
|
|
||||||
await agent.handle_request(api_url, new_command)
|
|
||||||
|
|
||||||
logging.info("event or action does not require handling")
|
# handle pull_request event with synchronize action - "push trigger" for new commits
|
||||||
|
elif event == 'pull_request' and action == 'synchronize' and get_settings().github_app.handle_push_trigger:
|
||||||
|
pull_request, api_url = _check_pull_request_event(action, body, log_context, bot_user)
|
||||||
|
if not (pull_request and api_url):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# TODO: do we still want to get the list of commits to filter bot/merge commits?
|
||||||
|
before_sha = body.get("before")
|
||||||
|
after_sha = body.get("after")
|
||||||
|
merge_commit_sha = pull_request.get("merge_commit_sha")
|
||||||
|
if before_sha == after_sha:
|
||||||
|
return {}
|
||||||
|
if get_settings().github_app.push_trigger_ignore_merge_commits and after_sha == merge_commit_sha:
|
||||||
|
return {}
|
||||||
|
if get_settings().github_app.push_trigger_ignore_bot_commits and body.get("sender", {}).get("login", "") == bot_user:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Prevent triggering multiple times for subsequent push triggers when one is enough:
|
||||||
|
# The first push will trigger the processing, and if there's a second push in the meanwhile it will wait.
|
||||||
|
# Any more events will be discarded, because they will all trigger the exact same processing on the PR.
|
||||||
|
# We let the second event wait instead of discarding it because while the first event was being processed,
|
||||||
|
# more commits may have been pushed that led to the subsequent events,
|
||||||
|
# so we keep just one waiting as a delegate to trigger the processing for the new commits when done waiting.
|
||||||
|
current_active_tasks = _duplicate_push_triggers.setdefault(api_url, 0)
|
||||||
|
max_active_tasks = 2 if get_settings().github_app.push_trigger_pending_tasks_backlog else 1
|
||||||
|
if current_active_tasks < max_active_tasks:
|
||||||
|
# first task can enter, and second tasks too if backlog is enabled
|
||||||
|
get_logger().info(
|
||||||
|
f"Continue processing push trigger for {api_url=} because there are {current_active_tasks} active tasks"
|
||||||
|
)
|
||||||
|
_duplicate_push_triggers[api_url] += 1
|
||||||
|
else:
|
||||||
|
get_logger().info(
|
||||||
|
f"Skipping push trigger for {api_url=} because another event already triggered the same processing"
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
async with _pending_task_duplicate_push_conditions[api_url]:
|
||||||
|
if current_active_tasks == 1:
|
||||||
|
# second task waits
|
||||||
|
get_logger().info(
|
||||||
|
f"Waiting to process push trigger for {api_url=} because the first task is still in progress"
|
||||||
|
)
|
||||||
|
await _pending_task_duplicate_push_conditions[api_url].wait()
|
||||||
|
get_logger().info(f"Finished waiting to process push trigger for {api_url=} - continue with flow")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if get_settings().github_app.push_trigger_wait_for_initial_review and not get_git_provider()(api_url, incremental=IncrementalPR(True)).previous_review:
|
||||||
|
get_logger().info(f"Skipping incremental review because there was no initial review for {api_url=} yet")
|
||||||
|
return {}
|
||||||
|
get_logger().info(f"Performing incremental review for {api_url=} because of {event=} and {action=}")
|
||||||
|
await _perform_commands("push_commands", agent, body, api_url, log_context)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# release the waiting task block
|
||||||
|
async with _pending_task_duplicate_push_conditions[api_url]:
|
||||||
|
_pending_task_duplicate_push_conditions[api_url].notify(1)
|
||||||
|
_duplicate_push_triggers[api_url] -= 1
|
||||||
|
|
||||||
|
get_logger().info("event or action does not require handling")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_pull_request_event(action: str, body: dict, log_context: dict, bot_user: str) -> Tuple[Dict[str, Any], str]:
|
||||||
|
invalid_result = {}, ""
|
||||||
|
pull_request = body.get("pull_request")
|
||||||
|
if not pull_request:
|
||||||
|
return invalid_result
|
||||||
|
api_url = pull_request.get("url")
|
||||||
|
if not api_url:
|
||||||
|
return invalid_result
|
||||||
|
log_context["api_url"] = api_url
|
||||||
|
if pull_request.get("draft", True) or pull_request.get("state") != "open" or pull_request.get("user", {}).get("login", "") == bot_user:
|
||||||
|
return invalid_result
|
||||||
|
if action in ("review_requested", "synchronize") and pull_request.get("created_at") == pull_request.get("updated_at"):
|
||||||
|
# avoid double reviews when opening a PR for the first time
|
||||||
|
return invalid_result
|
||||||
|
return pull_request, api_url
|
||||||
|
|
||||||
|
|
||||||
|
async def _perform_commands(commands_conf: str, agent: PRAgent, body: dict, api_url: str, log_context: dict):
|
||||||
|
apply_repo_settings(api_url)
|
||||||
|
commands = get_settings().get(f"github_app.{commands_conf}")
|
||||||
|
for command in commands:
|
||||||
|
split_command = command.split(" ")
|
||||||
|
command = split_command[0]
|
||||||
|
args = split_command[1:]
|
||||||
|
other_args = update_settings_from_args(args)
|
||||||
|
new_command = ' '.join([command] + other_args)
|
||||||
|
get_logger().info(body)
|
||||||
|
get_logger().info(f"Performing command: {new_command}")
|
||||||
|
with get_logger().contextualize(**log_context):
|
||||||
|
await agent.handle_request(api_url, new_command)
|
||||||
|
|
||||||
|
|
||||||
def _is_duplicate_request(body: Dict[str, Any]) -> bool:
|
def _is_duplicate_request(body: Dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
In some deployments its possible to get duplicate requests if the handling is long,
|
In some deployments its possible to get duplicate requests if the handling is long,
|
||||||
This function checks if the request is duplicate and if so - ignores it.
|
This function checks if the request is duplicate and if so - ignores it.
|
||||||
"""
|
"""
|
||||||
request_hash = hash(str(body))
|
request_hash = hash(str(body))
|
||||||
logging.info(f"request_hash: {request_hash}")
|
get_logger().info(f"request_hash: {request_hash}")
|
||||||
request_time = time.monotonic()
|
is_duplicate = _duplicate_requests_cache.get(request_hash, False)
|
||||||
ttl = get_settings().github_app.duplicate_requests_cache_ttl # in seconds
|
_duplicate_requests_cache[request_hash] = True
|
||||||
to_delete = [key for key, key_time in _duplicate_requests_cache.items() if request_time - key_time > ttl]
|
|
||||||
for key in to_delete:
|
|
||||||
del _duplicate_requests_cache[key]
|
|
||||||
is_duplicate = request_hash in _duplicate_requests_cache
|
|
||||||
_duplicate_requests_cache[request_hash] = request_time
|
|
||||||
if is_duplicate:
|
if is_duplicate:
|
||||||
logging.info(f"Ignoring duplicate request {request_hash}")
|
get_logger().info(f"Ignoring duplicate request {request_hash}")
|
||||||
return is_duplicate
|
return is_duplicate
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@ -8,9 +6,10 @@ import aiohttp
|
|||||||
from pr_agent.agent.pr_agent import PRAgent
|
from pr_agent.agent.pr_agent import PRAgent
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
|
from pr_agent.log import LoggingFormat, get_logger, setup_logger
|
||||||
from pr_agent.servers.help import bot_help_text
|
from pr_agent.servers.help import bot_help_text
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
|
setup_logger(fmt=LoggingFormat.JSON)
|
||||||
NOTIFICATION_URL = "https://api.github.com/notifications"
|
NOTIFICATION_URL = "https://api.github.com/notifications"
|
||||||
|
|
||||||
|
|
||||||
@ -94,7 +93,7 @@ async def polling_loop():
|
|||||||
comment_body = comment['body'] if 'body' in comment else ''
|
comment_body = comment['body'] if 'body' in comment else ''
|
||||||
commenter_github_user = comment['user']['login'] \
|
commenter_github_user = comment['user']['login'] \
|
||||||
if 'user' in comment else ''
|
if 'user' in comment else ''
|
||||||
logging.info(f"Commenter: {commenter_github_user}\nComment: {comment_body}")
|
get_logger().info(f"Commenter: {commenter_github_user}\nComment: {comment_body}")
|
||||||
user_tag = "@" + user_id
|
user_tag = "@" + user_id
|
||||||
if user_tag not in comment_body:
|
if user_tag not in comment_body:
|
||||||
continue
|
continue
|
||||||
@ -112,7 +111,7 @@ async def polling_loop():
|
|||||||
print(f"Failed to fetch notifications. Status code: {response.status}")
|
print(f"Failed to fetch notifications. Status code: {response.status}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Exception during processing of a notification: {e}")
|
get_logger().error(f"Exception during processing of a notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import APIRouter, FastAPI, Request, status
|
from fastapi import APIRouter, FastAPI, Request, status
|
||||||
@ -14,26 +12,37 @@ from starlette_context.middleware import RawContextMiddleware
|
|||||||
|
|
||||||
from pr_agent.agent.pr_agent import PRAgent
|
from pr_agent.agent.pr_agent import PRAgent
|
||||||
from pr_agent.config_loader import get_settings, global_settings
|
from pr_agent.config_loader import get_settings, global_settings
|
||||||
|
from pr_agent.log import LoggingFormat, get_logger, setup_logger
|
||||||
from pr_agent.secret_providers import get_secret_provider
|
from pr_agent.secret_providers import get_secret_provider
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
setup_logger(fmt=LoggingFormat.JSON)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
secret_provider = get_secret_provider() if get_settings().get("CONFIG.SECRET_PROVIDER") else None
|
secret_provider = get_secret_provider() if get_settings().get("CONFIG.SECRET_PROVIDER") else None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_request(background_tasks: BackgroundTasks, url: str, body: str, log_context: dict):
|
||||||
|
log_context["action"] = body
|
||||||
|
log_context["event"] = "pull_request" if body == "/review" else "comment"
|
||||||
|
log_context["api_url"] = url
|
||||||
|
with get_logger().contextualize(**log_context):
|
||||||
|
background_tasks.add_task(PRAgent().handle_request, url, body)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook")
|
@router.post("/webhook")
|
||||||
async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
|
async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||||
|
log_context = {"server_type": "gitlab_app"}
|
||||||
if request.headers.get("X-Gitlab-Token") and secret_provider:
|
if request.headers.get("X-Gitlab-Token") and secret_provider:
|
||||||
request_token = request.headers.get("X-Gitlab-Token")
|
request_token = request.headers.get("X-Gitlab-Token")
|
||||||
secret = secret_provider.get_secret(request_token)
|
secret = secret_provider.get_secret(request_token)
|
||||||
try:
|
try:
|
||||||
secret_dict = json.loads(secret)
|
secret_dict = json.loads(secret)
|
||||||
gitlab_token = secret_dict["gitlab_token"]
|
gitlab_token = secret_dict["gitlab_token"]
|
||||||
|
log_context["sender"] = secret_dict["id"]
|
||||||
context["settings"] = copy.deepcopy(global_settings)
|
context["settings"] = copy.deepcopy(global_settings)
|
||||||
context["settings"].gitlab.personal_access_token = gitlab_token
|
context["settings"].gitlab.personal_access_token = gitlab_token
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to validate secret {request_token}: {e}")
|
get_logger().error(f"Failed to validate secret {request_token}: {e}")
|
||||||
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
|
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
|
||||||
elif get_settings().get("GITLAB.SHARED_SECRET"):
|
elif get_settings().get("GITLAB.SHARED_SECRET"):
|
||||||
secret = get_settings().get("GITLAB.SHARED_SECRET")
|
secret = get_settings().get("GITLAB.SHARED_SECRET")
|
||||||
@ -45,17 +54,17 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
|
|||||||
if not gitlab_token:
|
if not gitlab_token:
|
||||||
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
|
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=jsonable_encoder({"message": "unauthorized"}))
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
logging.info(json.dumps(data))
|
get_logger().info(json.dumps(data))
|
||||||
if data.get('object_kind') == 'merge_request' and data['object_attributes'].get('action') in ['open', 'reopen']:
|
if data.get('object_kind') == 'merge_request' and data['object_attributes'].get('action') in ['open', 'reopen']:
|
||||||
logging.info(f"A merge request has been opened: {data['object_attributes'].get('title')}")
|
get_logger().info(f"A merge request has been opened: {data['object_attributes'].get('title')}")
|
||||||
url = data['object_attributes'].get('url')
|
url = data['object_attributes'].get('url')
|
||||||
background_tasks.add_task(PRAgent().handle_request, url, "/review")
|
handle_request(background_tasks, url, "/review", log_context)
|
||||||
elif data.get('object_kind') == 'note' and data['event_type'] == 'note':
|
elif data.get('object_kind') == 'note' and data['event_type'] == 'note':
|
||||||
if 'merge_request' in data:
|
if 'merge_request' in data:
|
||||||
mr = data['merge_request']
|
mr = data['merge_request']
|
||||||
url = mr.get('url')
|
url = mr.get('url')
|
||||||
body = data.get('object_attributes', {}).get('note')
|
body = data.get('object_attributes', {}).get('note')
|
||||||
background_tasks.add_task(PRAgent().handle_request, url, body)
|
handle_request(background_tasks, url, body, log_context)
|
||||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from mangum import Mangum
|
from mangum import Mangum
|
||||||
|
from starlette.middleware import Middleware
|
||||||
|
from starlette_context.middleware import RawContextMiddleware
|
||||||
|
|
||||||
|
from pr_agent.log import setup_logger
|
||||||
from pr_agent.servers.github_app import router
|
from pr_agent.servers.github_app import router
|
||||||
|
|
||||||
logger = logging.getLogger()
|
setup_logger()
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
app = FastAPI()
|
middleware = [Middleware(RawContextMiddleware)]
|
||||||
|
app = FastAPI(middleware=middleware)
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|
||||||
handler = Mangum(app, lifespan="off")
|
handler = Mangum(app, lifespan="off")
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@ -25,3 +28,59 @@ def verify_signature(payload_body, secret_token, signature_header):
|
|||||||
class RateLimitExceeded(Exception):
|
class RateLimitExceeded(Exception):
|
||||||
"""Raised when the git provider API rate limit has been exceeded."""
|
"""Raised when the git provider API rate limit has been exceeded."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultDictWithTimeout(defaultdict):
|
||||||
|
"""A defaultdict with a time-to-live (TTL)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
default_factory: Callable[[], Any] = None,
|
||||||
|
ttl: int = None,
|
||||||
|
refresh_interval: int = 60,
|
||||||
|
update_key_time_on_get: bool = True,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
default_factory: The default factory to use for keys that are not in the dictionary.
|
||||||
|
ttl: The time-to-live (TTL) in seconds.
|
||||||
|
refresh_interval: How often to refresh the dict and delete items older than the TTL.
|
||||||
|
update_key_time_on_get: Whether to update the access time of a key also on get (or only when set).
|
||||||
|
"""
|
||||||
|
super().__init__(default_factory, *args, **kwargs)
|
||||||
|
self.__key_times = dict()
|
||||||
|
self.__ttl = ttl
|
||||||
|
self.__refresh_interval = refresh_interval
|
||||||
|
self.__update_key_time_on_get = update_key_time_on_get
|
||||||
|
self.__last_refresh = self.__time() - self.__refresh_interval
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __time():
|
||||||
|
return time.monotonic()
|
||||||
|
|
||||||
|
def __refresh(self):
|
||||||
|
if self.__ttl is None:
|
||||||
|
return
|
||||||
|
request_time = self.__time()
|
||||||
|
if request_time - self.__last_refresh > self.__refresh_interval:
|
||||||
|
return
|
||||||
|
to_delete = [key for key, key_time in self.__key_times.items() if request_time - key_time > self.__ttl]
|
||||||
|
for key in to_delete:
|
||||||
|
del self[key]
|
||||||
|
self.__last_refresh = request_time
|
||||||
|
|
||||||
|
def __getitem__(self, __key):
|
||||||
|
if self.__update_key_time_on_get:
|
||||||
|
self.__key_times[__key] = self.__time()
|
||||||
|
self.__refresh()
|
||||||
|
return super().__getitem__(__key)
|
||||||
|
|
||||||
|
def __setitem__(self, __key, __value):
|
||||||
|
self.__key_times[__key] = self.__time()
|
||||||
|
return super().__setitem__(__key, __value)
|
||||||
|
|
||||||
|
def __delitem__(self, __key):
|
||||||
|
del self.__key_times[__key]
|
||||||
|
return super().__delitem__(__key)
|
||||||
|
@ -34,7 +34,11 @@ key = "" # Optional, uncomment if you want to use Huggingface Inference API. Acq
|
|||||||
api_base = "" # the base url for your huggingface inference endpoint
|
api_base = "" # the base url for your huggingface inference endpoint
|
||||||
|
|
||||||
[ollama]
|
[ollama]
|
||||||
api_base = "" # the base url for your huggingface inference endpoint
|
api_base = "" # the base url for your local Llama 2, Code Llama, and other models inference endpoint. Acquire through https://ollama.ai/
|
||||||
|
|
||||||
|
[vertexai]
|
||||||
|
vertex_project = "" # the google cloud platform project name for your vertexai deployment
|
||||||
|
vertex_location = "" # the google cloud platform location for your vertexai deployment
|
||||||
|
|
||||||
[github]
|
[github]
|
||||||
# ---- Set the following only for deployment type == "user"
|
# ---- Set the following only for deployment type == "user"
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
[config]
|
[config]
|
||||||
model="gpt-4"
|
model="gpt-4" # "gpt-4-1106-preview"
|
||||||
fallback_models=["gpt-3.5-turbo-16k"]
|
fallback_models=["gpt-3.5-turbo-16k"]
|
||||||
git_provider="github"
|
git_provider="github"
|
||||||
publish_output=true
|
publish_output=true
|
||||||
@ -10,32 +10,49 @@ use_repo_settings_file=true
|
|||||||
ai_timeout=180
|
ai_timeout=180
|
||||||
max_description_tokens = 500
|
max_description_tokens = 500
|
||||||
max_commits_tokens = 500
|
max_commits_tokens = 500
|
||||||
|
max_model_tokens = 32000 # Limits the maximum number of tokens that can be used by any model, regardless of the model's default capabilities.
|
||||||
patch_extra_lines = 3
|
patch_extra_lines = 3
|
||||||
secret_provider="google_cloud_storage"
|
secret_provider="google_cloud_storage"
|
||||||
cli_mode=false
|
cli_mode=false
|
||||||
|
|
||||||
[pr_reviewer] # /review #
|
[pr_reviewer] # /review #
|
||||||
|
# enable/disable features
|
||||||
require_focused_review=false
|
require_focused_review=false
|
||||||
require_score_review=false
|
require_score_review=false
|
||||||
require_tests_review=true
|
require_tests_review=true
|
||||||
require_security_review=true
|
require_security_review=true
|
||||||
require_estimate_effort_to_review=true
|
require_estimate_effort_to_review=true
|
||||||
|
# general options
|
||||||
num_code_suggestions=4
|
num_code_suggestions=4
|
||||||
inline_code_comments = false
|
inline_code_comments = false
|
||||||
ask_and_reflect=false
|
ask_and_reflect=false
|
||||||
automatic_review=true
|
automatic_review=true
|
||||||
|
remove_previous_review_comment=false
|
||||||
|
persistent_comment=true
|
||||||
extra_instructions = ""
|
extra_instructions = ""
|
||||||
|
# review labels
|
||||||
|
enable_review_labels_security=true
|
||||||
|
enable_review_labels_effort=false
|
||||||
|
# specific configurations for incremental review (/review -i)
|
||||||
|
require_all_thresholds_for_incremental_review=false
|
||||||
|
minimal_commits_for_incremental_review=0
|
||||||
|
minimal_minutes_for_incremental_review=0
|
||||||
|
|
||||||
[pr_description] # /describe #
|
[pr_description] # /describe #
|
||||||
publish_labels=true
|
publish_labels=true
|
||||||
publish_description_as_comment=false
|
publish_description_as_comment=false
|
||||||
add_original_user_description=false
|
add_original_user_description=false
|
||||||
keep_original_user_title=false
|
keep_original_user_title=false
|
||||||
|
use_bullet_points=true
|
||||||
extra_instructions = ""
|
extra_instructions = ""
|
||||||
|
enable_pr_type=true
|
||||||
|
|
||||||
# markers
|
# markers
|
||||||
use_description_markers=false
|
use_description_markers=false
|
||||||
include_generated_by_header=true
|
include_generated_by_header=true
|
||||||
|
|
||||||
|
#custom_labels = ['Bug fix', 'Tests', 'Bug fix with tests', 'Refactoring', 'Enhancement', 'Documentation', 'Other']
|
||||||
|
|
||||||
[pr_questions] # /ask #
|
[pr_questions] # /ask #
|
||||||
|
|
||||||
[pr_code_suggestions] # /improve #
|
[pr_code_suggestions] # /improve #
|
||||||
@ -82,6 +99,30 @@ pr_commands = [
|
|||||||
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||||
"/auto_review",
|
"/auto_review",
|
||||||
]
|
]
|
||||||
|
# settings for "pull_request" event with "synchronize" action - used to detect and handle push triggers for new commits
|
||||||
|
handle_push_trigger = false
|
||||||
|
push_trigger_ignore_bot_commits = true
|
||||||
|
push_trigger_ignore_merge_commits = true
|
||||||
|
push_trigger_wait_for_initial_review = true
|
||||||
|
push_trigger_pending_tasks_backlog = true
|
||||||
|
push_trigger_pending_tasks_ttl = 300
|
||||||
|
push_commands = [
|
||||||
|
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||||
|
"""/auto_review -i \
|
||||||
|
--pr_reviewer.require_focused_review=false \
|
||||||
|
--pr_reviewer.require_score_review=false \
|
||||||
|
--pr_reviewer.require_tests_review=false \
|
||||||
|
--pr_reviewer.require_security_review=false \
|
||||||
|
--pr_reviewer.require_estimate_effort_to_review=false \
|
||||||
|
--pr_reviewer.num_code_suggestions=0 \
|
||||||
|
--pr_reviewer.inline_code_comments=false \
|
||||||
|
--pr_reviewer.remove_previous_review_comment=true \
|
||||||
|
--pr_reviewer.require_all_thresholds_for_incremental_review=false \
|
||||||
|
--pr_reviewer.minimal_commits_for_incremental_review=5 \
|
||||||
|
--pr_reviewer.minimal_minutes_for_incremental_review=30 \
|
||||||
|
--pr_reviewer.extra_instructions='' \
|
||||||
|
"""
|
||||||
|
]
|
||||||
|
|
||||||
[gitlab]
|
[gitlab]
|
||||||
# URL to the gitlab service
|
# URL to the gitlab service
|
||||||
@ -122,4 +163,4 @@ max_issues_to_scan = 500
|
|||||||
[pinecone]
|
[pinecone]
|
||||||
# fill and place in .secrets.toml
|
# fill and place in .secrets.toml
|
||||||
#api_key = ...
|
#api_key = ...
|
||||||
# environment = "gcp-starter"
|
# environment = "gcp-starter"
|
||||||
|
18
pr_agent/settings/custom_labels.toml
Normal file
18
pr_agent/settings/custom_labels.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[config]
|
||||||
|
enable_custom_labels=false
|
||||||
|
|
||||||
|
## template for custom labels
|
||||||
|
#[custom_labels."Bug fix"]
|
||||||
|
#description = "Fixes a bug in the code"
|
||||||
|
#[custom_labels."Tests"]
|
||||||
|
#description = "Adds or modifies tests"
|
||||||
|
#[custom_labels."Bug fix with tests"]
|
||||||
|
#description = "Fixes a bug in the code and adds or modifies tests"
|
||||||
|
#[custom_labels."Refactoring"]
|
||||||
|
#description = "Code refactoring without changing functionality"
|
||||||
|
#[custom_labels."Enhancement"]
|
||||||
|
#description = "Adds new features or functionality"
|
||||||
|
#[custom_labels."Documentation"]
|
||||||
|
#description = "Adds or modifies documentation"
|
||||||
|
#[custom_labels."Other"]
|
||||||
|
#description = "Other changes that do not fit in any of the above categories"
|
@ -433,3 +433,6 @@ reStructuredText = [".rst", ".rest", ".rest.txt", ".rst.txt", ]
|
|||||||
wisp = [".wisp", ]
|
wisp = [".wisp", ]
|
||||||
xBase = [".prg", ".prw", ]
|
xBase = [".prg", ".prw", ]
|
||||||
|
|
||||||
|
[docs_blacklist_extensions]
|
||||||
|
# Disable docs for these extensions of text files and scripts that are not programming languages of function, classes and methods
|
||||||
|
docs_blacklist = ['sql', 'txt', 'yaml', 'json', 'xml', 'md', 'rst', 'rest', 'rest.txt', 'rst.txt', 'mdpolicy', 'mdown', 'markdown', 'mdwn', 'mkd', 'mkdn', 'mkdown', 'sh']
|
71
pr_agent/settings/pr_custom_labels.toml
Normal file
71
pr_agent/settings/pr_custom_labels.toml
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
[pr_custom_labels_prompt]
|
||||||
|
system="""You are CodiumAI-PR-Reviewer, a language model designed to review git pull requests.
|
||||||
|
Your task is to label the type of the PR content.
|
||||||
|
- Make sure not to focus the new PR code (the '+' lines).
|
||||||
|
- If needed, each YAML output should be in block scalar format ('|-')
|
||||||
|
{%- if extra_instructions %}
|
||||||
|
|
||||||
|
Extra instructions from the user:
|
||||||
|
'
|
||||||
|
{{ extra_instructions }}
|
||||||
|
'
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
You must use the following YAML schema to format your answer:
|
||||||
|
```yaml
|
||||||
|
PR Type:
|
||||||
|
type: array
|
||||||
|
{%- if enable_custom_labels %}
|
||||||
|
description: Labels that are applicable to the Pull Request. Don't output the description in the parentheses. If none of the labels is relevant to the PR, output an empty array.
|
||||||
|
{%- endif %}
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
{%- if enable_custom_labels %}
|
||||||
|
{{ custom_labels }}
|
||||||
|
{%- else %}
|
||||||
|
- Bug fix
|
||||||
|
- Tests
|
||||||
|
- Refactoring
|
||||||
|
- Enhancement
|
||||||
|
- Documentation
|
||||||
|
- Other
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
```yaml
|
||||||
|
PR Type:
|
||||||
|
{%- if enable_custom_labels %}
|
||||||
|
{{ custom_labels_examples }}
|
||||||
|
{%- else %}
|
||||||
|
- Bug fix
|
||||||
|
{%- endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure to output a valid YAML. Don't repeat the prompt in the answer, and avoid outputting the 'type' and 'description' fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user="""PR Info:
|
||||||
|
Previous title: '{{title}}'
|
||||||
|
Previous description: '{{description}}'
|
||||||
|
Branch: '{{branch}}'
|
||||||
|
{%- if language %}
|
||||||
|
|
||||||
|
Main language: {{language}}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if commit_messages_str %}
|
||||||
|
|
||||||
|
Commit messages:
|
||||||
|
{{commit_messages_str}}
|
||||||
|
{%- 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 YAML, and nothing else):
|
||||||
|
```yaml
|
||||||
|
"""
|
@ -1,9 +1,10 @@
|
|||||||
[pr_description_prompt]
|
[pr_description_prompt]
|
||||||
system="""You are CodiumAI-PR-Reviewer, a language model designed to review git pull requests.
|
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.
|
Your task is to provide full description of a Pull Request (PR) content.
|
||||||
- Make sure not to focus the new PR code (the '+' lines).
|
- Make sure to focus on the new PR code (the '+' lines).
|
||||||
- Notice that the 'Previous title', 'Previous description' and 'Commit messages' sections may be partial, simplistic, non-informative or not up-to-date. Hence, compare them to the PR diff code, and use them only as a reference.
|
- Notice that the 'Previous title', 'Previous description' and 'Commit messages' sections may be partial, simplistic, non-informative or not up-to-date. Hence, compare them to the PR diff code, and use them only as a reference.
|
||||||
- If needed, each YAML output should be in block scalar format ('|-')
|
- Emphasize first the most important changes, and then the less important ones.
|
||||||
|
- If needed, each YAML output should be in block scalar format ('|-')
|
||||||
{%- if extra_instructions %}
|
{%- if extra_instructions %}
|
||||||
|
|
||||||
Extra instructions from the user:
|
Extra instructions from the user:
|
||||||
@ -18,20 +19,27 @@ PR Title:
|
|||||||
type: string
|
type: string
|
||||||
description: an informative title for the PR, describing its main theme
|
description: an informative title for the PR, describing its main theme
|
||||||
PR Type:
|
PR Type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Bug fix
|
||||||
|
- Tests
|
||||||
|
- Refactoring
|
||||||
|
- Enhancement
|
||||||
|
- Documentation
|
||||||
|
- Other
|
||||||
|
{%- if enable_custom_labels %}
|
||||||
|
PR Labels:
|
||||||
type: array
|
type: array
|
||||||
|
description: Labels that are applicable to the Pull Request. Don't output the description in the parentheses. If none of the labels is relevant to the PR, output an empty array.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- Bug fix
|
{{ custom_labels }}
|
||||||
- Tests
|
{%- endif %}
|
||||||
- Bug fix with tests
|
|
||||||
- Refactoring
|
|
||||||
- Enhancement
|
|
||||||
- Documentation
|
|
||||||
- Other
|
|
||||||
PR Description:
|
PR Description:
|
||||||
type: string
|
type: string
|
||||||
description: an informative and concise description of the PR
|
description: an informative and concise description of the PR.
|
||||||
|
{%- if use_bullet_points %} Use bullet points. {% endif %}
|
||||||
PR Main Files Walkthrough:
|
PR Main Files Walkthrough:
|
||||||
type: array
|
type: array
|
||||||
maxItems: 10
|
maxItems: 10
|
||||||
@ -44,6 +52,7 @@ PR Main Files Walkthrough:
|
|||||||
changes in file:
|
changes in file:
|
||||||
type: string
|
type: string
|
||||||
description: minimal and concise description of the changes in the relevant file
|
description: minimal and concise description of the changes in the relevant file
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
Example output:
|
Example output:
|
||||||
@ -51,7 +60,12 @@ Example output:
|
|||||||
PR Title: |-
|
PR Title: |-
|
||||||
...
|
...
|
||||||
PR Type:
|
PR Type:
|
||||||
- Bug fix
|
...
|
||||||
|
{%- if enable_custom_labels %}
|
||||||
|
PR Labels:
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- endif %}
|
||||||
PR Description: |-
|
PR Description: |-
|
||||||
...
|
...
|
||||||
PR Main Files Walkthrough:
|
PR Main Files Walkthrough:
|
||||||
|
@ -25,7 +25,7 @@ code line that already existed in the file....
|
|||||||
The review should focus on new code added in the PR (lines starting with '+'), and not on code that already existed in the file (lines starting with '-', or without prefix).
|
The review should focus on new code added in the PR (lines starting with '+'), and not on code that already existed in the file (lines starting with '-', or without prefix).
|
||||||
|
|
||||||
{%- if num_code_suggestions > 0 %}
|
{%- if num_code_suggestions > 0 %}
|
||||||
- Provide up to {{ num_code_suggestions }} code suggestions.
|
- Provide up to {{ num_code_suggestions }} code suggestions. Try to provide diverse and insightful suggestions.
|
||||||
- Focus on important suggestions like fixing code problems, issues and bugs. As a second priority, provide suggestions for meaningful code improvements, like performance, vulnerability, modularity, and best practices.
|
- Focus on important suggestions like fixing code problems, issues and bugs. As a second priority, provide suggestions for meaningful code improvements, like performance, vulnerability, modularity, and best practices.
|
||||||
- Avoid making suggestions that have already been implemented in the PR code. For example, if you want to add logs, or change a variable to const, or anything else, make sure it isn't already in the PR code.
|
- Avoid making suggestions that have already been implemented in the PR code. For example, if you want to add logs, or change a variable to const, or anything else, make sure it isn't already in the PR code.
|
||||||
- Don't suggest to add docstring, type hints, or comments.
|
- Don't suggest to add docstring, type hints, or comments.
|
||||||
@ -93,16 +93,16 @@ PR Analysis:
|
|||||||
description: >-
|
description: >-
|
||||||
Estimate, on a scale of 1-5 (inclusive), the time and effort required to review this PR by an experienced and knowledgeable developer. 1 means short and easy review , 5 means long and hard review.
|
Estimate, on a scale of 1-5 (inclusive), the time and effort required to review this PR by an experienced and knowledgeable developer. 1 means short and easy review , 5 means long and hard review.
|
||||||
Take into account the size, complexity, quality, and the needed changes of the PR code diff.
|
Take into account the size, complexity, quality, and the needed changes of the PR code diff.
|
||||||
Explain your answer shortly (1-2 sentences).
|
Explain your answer shortly (1-2 sentences). Use the format: '1, because ...'
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
PR Feedback:
|
PR Feedback:
|
||||||
General suggestions:
|
General suggestions:
|
||||||
type: string
|
type: string
|
||||||
description: |-
|
description: |-
|
||||||
General suggestions and feedback for the contributors and maintainers of
|
General suggestions and feedback for the contributors and maintainers of this PR.
|
||||||
this PR. May include important suggestions for the overall structure,
|
May include important suggestions for the overall structure,
|
||||||
primary purpose, best practices, critical bugs, and other aspects of the
|
primary purpose, best practices, critical bugs, and other aspects of the PR.
|
||||||
PR. Don't address PR title and description, or lack of tests. Explain your suggestions.
|
Don't address PR title and description, or lack of tests. Explain your suggestions.
|
||||||
{%- if num_code_suggestions > 0 %}
|
{%- if num_code_suggestions > 0 %}
|
||||||
Code feedback:
|
Code feedback:
|
||||||
type: array
|
type: array
|
||||||
@ -115,11 +115,10 @@ PR Feedback:
|
|||||||
suggestion:
|
suggestion:
|
||||||
type: string
|
type: string
|
||||||
description: |-
|
description: |-
|
||||||
a concrete suggestion for meaningfully improving the new PR code. Also
|
a concrete suggestion for meaningfully improving the new PR code.
|
||||||
describe how, specifically, the suggestion can be applied to new PR
|
Also describe how, specifically, the suggestion can be applied to new PR code.
|
||||||
code. Add tags with importance measure that matches each suggestion
|
Add tags with importance measure that matches each suggestion ('important' or 'medium').
|
||||||
('important' or 'medium'). Do not make suggestions for updating or
|
Do not make suggestions for updating or adding docstrings, renaming PR title and description, or linter like.
|
||||||
adding docstrings, renaming PR title and description, or linter like.
|
|
||||||
relevant line:
|
relevant line:
|
||||||
type: string
|
type: string
|
||||||
description: |-
|
description: |-
|
||||||
@ -131,7 +130,8 @@ PR Feedback:
|
|||||||
Security concerns:
|
Security concerns:
|
||||||
type: string
|
type: string
|
||||||
description: >-
|
description: >-
|
||||||
yes\\no question: does this PR code introduce possible vulnerabilities such as exposure of sensitive information (e.g., API keys, secrets, passwords), or security concerns like SQL injection, XSS, CSRF, and others ? If answered 'yes', explain your answer briefly.
|
does this PR code introduce possible vulnerabilities such as exposure of sensitive information (e.g., API keys, secrets, passwords), or security concerns like SQL injection, XSS, CSRF, and others ? Answer 'No' if there are no possible issues.
|
||||||
|
Answer 'Yes, because ...' if there are security concerns or issues. Explain your answer shortly.
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -143,7 +143,7 @@ PR Analysis:
|
|||||||
PR summary: |-
|
PR summary: |-
|
||||||
xxx
|
xxx
|
||||||
Type of PR: |-
|
Type of PR: |-
|
||||||
Bug fix
|
...
|
||||||
{%- if require_score %}
|
{%- if require_score %}
|
||||||
Score: 89
|
Score: 89
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
@ -153,7 +153,8 @@ PR Analysis:
|
|||||||
Focused PR: no, because ...
|
Focused PR: no, because ...
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if require_estimate_effort_to_review %}
|
{%- if require_estimate_effort_to_review %}
|
||||||
Estimated effort to review [1-5]: 3, because ...
|
Estimated effort to review [1-5]: |-
|
||||||
|
3, because ...
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
PR Feedback:
|
PR Feedback:
|
||||||
General PR suggestions: |-
|
General PR suggestions: |-
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
|
||||||
import textwrap
|
import textwrap
|
||||||
from typing import List, Dict
|
from typing import Dict
|
||||||
|
|
||||||
from jinja2 import Environment, StrictUndefined
|
from jinja2 import Environment, StrictUndefined
|
||||||
|
|
||||||
from pr_agent.algo.ai_handler import AiHandler
|
from pr_agent.algo.ai_handler import AiHandler
|
||||||
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models, get_pr_multi_diffs
|
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
|
||||||
from pr_agent.algo.token_handler import TokenHandler
|
from pr_agent.algo.token_handler import TokenHandler
|
||||||
from pr_agent.algo.utils import load_yaml
|
from pr_agent.algo.utils import load_yaml
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import BitbucketProvider, get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class PRAddDocs:
|
class PRAddDocs:
|
||||||
@ -43,34 +44,39 @@ class PRAddDocs:
|
|||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
try:
|
try:
|
||||||
logging.info('Generating code Docs for PR...')
|
get_logger().info('Generating code Docs for PR...')
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
self.git_provider.publish_comment("Generating Documentation...", is_temporary=True)
|
self.git_provider.publish_comment("Generating Documentation...", is_temporary=True)
|
||||||
|
|
||||||
logging.info('Preparing PR documentation...')
|
get_logger().info('Preparing PR documentation...')
|
||||||
await retry_with_fallback_models(self._prepare_prediction)
|
await retry_with_fallback_models(self._prepare_prediction)
|
||||||
data = self._prepare_pr_code_docs()
|
data = self._prepare_pr_code_docs()
|
||||||
if (not data) or (not 'Code Documentation' in data):
|
if (not data) or (not 'Code Documentation' in data):
|
||||||
logging.info('No code documentation found for PR.')
|
get_logger().info('No code documentation found for PR.')
|
||||||
return
|
return
|
||||||
|
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
logging.info('Pushing PR documentation...')
|
get_logger().info('Pushing PR documentation...')
|
||||||
self.git_provider.remove_initial_comment()
|
self.git_provider.remove_initial_comment()
|
||||||
logging.info('Pushing inline code documentation...')
|
get_logger().info('Pushing inline code documentation...')
|
||||||
self.push_inline_docs(data)
|
self.push_inline_docs(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to generate code documentation for PR, error: {e}")
|
get_logger().error(f"Failed to generate code documentation for PR, error: {e}")
|
||||||
|
|
||||||
async def _prepare_prediction(self, model: str):
|
async def _prepare_prediction(self, model: str):
|
||||||
logging.info('Getting PR diff...')
|
get_logger().info('Getting PR diff...')
|
||||||
|
|
||||||
|
# Disable adding docs to scripts and other non-relevant text files
|
||||||
|
from pr_agent.algo.language_handler import bad_extensions
|
||||||
|
bad_extensions += get_settings().docs_blacklist_extensions.docs_blacklist
|
||||||
|
|
||||||
self.patches_diff = get_pr_diff(self.git_provider,
|
self.patches_diff = get_pr_diff(self.git_provider,
|
||||||
self.token_handler,
|
self.token_handler,
|
||||||
model,
|
model,
|
||||||
add_line_numbers_to_hunks=True,
|
add_line_numbers_to_hunks=True,
|
||||||
disable_extra_lines=False)
|
disable_extra_lines=False)
|
||||||
|
|
||||||
logging.info('Getting AI prediction...')
|
get_logger().info('Getting AI prediction...')
|
||||||
self.prediction = await self._get_prediction(model)
|
self.prediction = await self._get_prediction(model)
|
||||||
|
|
||||||
async def _get_prediction(self, model: str):
|
async def _get_prediction(self, model: str):
|
||||||
@ -80,8 +86,8 @@ class PRAddDocs:
|
|||||||
system_prompt = environment.from_string(get_settings().pr_add_docs_prompt.system).render(variables)
|
system_prompt = environment.from_string(get_settings().pr_add_docs_prompt.system).render(variables)
|
||||||
user_prompt = environment.from_string(get_settings().pr_add_docs_prompt.user).render(variables)
|
user_prompt = environment.from_string(get_settings().pr_add_docs_prompt.user).render(variables)
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
||||||
system=system_prompt, user=user_prompt)
|
system=system_prompt, user=user_prompt)
|
||||||
|
|
||||||
@ -103,7 +109,7 @@ class PRAddDocs:
|
|||||||
for d in data['Code Documentation']:
|
for d in data['Code Documentation']:
|
||||||
try:
|
try:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"add_docs: {d}")
|
get_logger().info(f"add_docs: {d}")
|
||||||
relevant_file = d['relevant file'].strip()
|
relevant_file = d['relevant file'].strip()
|
||||||
relevant_line = int(d['relevant line']) # absolute position
|
relevant_line = int(d['relevant line']) # absolute position
|
||||||
documentation = d['documentation']
|
documentation = d['documentation']
|
||||||
@ -118,11 +124,11 @@ class PRAddDocs:
|
|||||||
'relevant_lines_end': relevant_line})
|
'relevant_lines_end': relevant_line})
|
||||||
except Exception:
|
except Exception:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Could not parse code docs: {d}")
|
get_logger().info(f"Could not parse code docs: {d}")
|
||||||
|
|
||||||
is_successful = self.git_provider.publish_code_suggestions(docs)
|
is_successful = self.git_provider.publish_code_suggestions(docs)
|
||||||
if not is_successful:
|
if not is_successful:
|
||||||
logging.info("Failed to publish code docs, trying to publish each docs separately")
|
get_logger().info("Failed to publish code docs, trying to publish each docs separately")
|
||||||
for doc_suggestion in docs:
|
for doc_suggestion in docs:
|
||||||
self.git_provider.publish_code_suggestions([doc_suggestion])
|
self.git_provider.publish_code_suggestions([doc_suggestion])
|
||||||
|
|
||||||
@ -154,7 +160,7 @@ class PRAddDocs:
|
|||||||
new_code_snippet = new_code_snippet.rstrip() + "\n" + original_initial_line
|
new_code_snippet = new_code_snippet.rstrip() + "\n" + original_initial_line
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
|
get_logger().info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
|
||||||
|
|
||||||
return new_code_snippet
|
return new_code_snippet
|
||||||
|
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
|
||||||
import textwrap
|
import textwrap
|
||||||
from typing import List, Dict
|
from typing import Dict, List
|
||||||
|
|
||||||
from jinja2 import Environment, StrictUndefined
|
from jinja2 import Environment, StrictUndefined
|
||||||
|
|
||||||
from pr_agent.algo.ai_handler import AiHandler
|
from pr_agent.algo.ai_handler import AiHandler
|
||||||
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models, get_pr_multi_diffs
|
from pr_agent.algo.pr_processing import get_pr_diff, get_pr_multi_diffs, retry_with_fallback_models
|
||||||
from pr_agent.algo.token_handler import TokenHandler
|
from pr_agent.algo.token_handler import TokenHandler
|
||||||
from pr_agent.algo.utils import load_yaml
|
from pr_agent.algo.utils import load_yaml
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import BitbucketProvider, get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class PRCodeSuggestions:
|
class PRCodeSuggestions:
|
||||||
@ -52,42 +53,42 @@ class PRCodeSuggestions:
|
|||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
try:
|
try:
|
||||||
logging.info('Generating code suggestions for PR...')
|
get_logger().info('Generating code suggestions for PR...')
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
|
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
|
||||||
|
|
||||||
logging.info('Preparing PR review...')
|
get_logger().info('Preparing PR review...')
|
||||||
if not self.is_extended:
|
if not self.is_extended:
|
||||||
await retry_with_fallback_models(self._prepare_prediction)
|
await retry_with_fallback_models(self._prepare_prediction)
|
||||||
data = self._prepare_pr_code_suggestions()
|
data = self._prepare_pr_code_suggestions()
|
||||||
else:
|
else:
|
||||||
data = await retry_with_fallback_models(self._prepare_prediction_extended)
|
data = await retry_with_fallback_models(self._prepare_prediction_extended)
|
||||||
if (not data) or (not 'Code suggestions' in data):
|
if (not data) or (not 'Code suggestions' in data):
|
||||||
logging.info('No code suggestions found for PR.')
|
get_logger().info('No code suggestions found for PR.')
|
||||||
return
|
return
|
||||||
|
|
||||||
if (not self.is_extended and get_settings().pr_code_suggestions.rank_suggestions) or \
|
if (not self.is_extended and get_settings().pr_code_suggestions.rank_suggestions) or \
|
||||||
(self.is_extended and get_settings().pr_code_suggestions.rank_extended_suggestions):
|
(self.is_extended and get_settings().pr_code_suggestions.rank_extended_suggestions):
|
||||||
logging.info('Ranking Suggestions...')
|
get_logger().info('Ranking Suggestions...')
|
||||||
data['Code suggestions'] = await self.rank_suggestions(data['Code suggestions'])
|
data['Code suggestions'] = await self.rank_suggestions(data['Code suggestions'])
|
||||||
|
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
logging.info('Pushing PR review...')
|
get_logger().info('Pushing PR review...')
|
||||||
self.git_provider.remove_initial_comment()
|
self.git_provider.remove_initial_comment()
|
||||||
logging.info('Pushing inline code suggestions...')
|
get_logger().info('Pushing inline code suggestions...')
|
||||||
self.push_inline_code_suggestions(data)
|
self.push_inline_code_suggestions(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to generate code suggestions for PR, error: {e}")
|
get_logger().error(f"Failed to generate code suggestions for PR, error: {e}")
|
||||||
|
|
||||||
async def _prepare_prediction(self, model: str):
|
async def _prepare_prediction(self, model: str):
|
||||||
logging.info('Getting PR diff...')
|
get_logger().info('Getting PR diff...')
|
||||||
self.patches_diff = get_pr_diff(self.git_provider,
|
self.patches_diff = get_pr_diff(self.git_provider,
|
||||||
self.token_handler,
|
self.token_handler,
|
||||||
model,
|
model,
|
||||||
add_line_numbers_to_hunks=True,
|
add_line_numbers_to_hunks=True,
|
||||||
disable_extra_lines=True)
|
disable_extra_lines=True)
|
||||||
|
|
||||||
logging.info('Getting AI prediction...')
|
get_logger().info('Getting AI prediction...')
|
||||||
self.prediction = await self._get_prediction(model)
|
self.prediction = await self._get_prediction(model)
|
||||||
|
|
||||||
async def _get_prediction(self, model: str):
|
async def _get_prediction(self, model: str):
|
||||||
@ -97,8 +98,8 @@ class PRCodeSuggestions:
|
|||||||
system_prompt = environment.from_string(get_settings().pr_code_suggestions_prompt.system).render(variables)
|
system_prompt = environment.from_string(get_settings().pr_code_suggestions_prompt.system).render(variables)
|
||||||
user_prompt = environment.from_string(get_settings().pr_code_suggestions_prompt.user).render(variables)
|
user_prompt = environment.from_string(get_settings().pr_code_suggestions_prompt.user).render(variables)
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
||||||
system=system_prompt, user=user_prompt)
|
system=system_prompt, user=user_prompt)
|
||||||
|
|
||||||
@ -120,7 +121,7 @@ class PRCodeSuggestions:
|
|||||||
for d in data['Code suggestions']:
|
for d in data['Code suggestions']:
|
||||||
try:
|
try:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"suggestion: {d}")
|
get_logger().info(f"suggestion: {d}")
|
||||||
relevant_file = d['relevant file'].strip()
|
relevant_file = d['relevant file'].strip()
|
||||||
relevant_lines_start = int(d['relevant lines start']) # absolute position
|
relevant_lines_start = int(d['relevant lines start']) # absolute position
|
||||||
relevant_lines_end = int(d['relevant lines end'])
|
relevant_lines_end = int(d['relevant lines end'])
|
||||||
@ -136,11 +137,11 @@ class PRCodeSuggestions:
|
|||||||
'relevant_lines_end': relevant_lines_end})
|
'relevant_lines_end': relevant_lines_end})
|
||||||
except Exception:
|
except Exception:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Could not parse suggestion: {d}")
|
get_logger().info(f"Could not parse suggestion: {d}")
|
||||||
|
|
||||||
is_successful = self.git_provider.publish_code_suggestions(code_suggestions)
|
is_successful = self.git_provider.publish_code_suggestions(code_suggestions)
|
||||||
if not is_successful:
|
if not is_successful:
|
||||||
logging.info("Failed to publish code suggestions, trying to publish each suggestion separately")
|
get_logger().info("Failed to publish code suggestions, trying to publish each suggestion separately")
|
||||||
for code_suggestion in code_suggestions:
|
for code_suggestion in code_suggestions:
|
||||||
self.git_provider.publish_code_suggestions([code_suggestion])
|
self.git_provider.publish_code_suggestions([code_suggestion])
|
||||||
|
|
||||||
@ -162,19 +163,19 @@ class PRCodeSuggestions:
|
|||||||
new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * " ").rstrip('\n')
|
new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * " ").rstrip('\n')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
|
get_logger().info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
|
||||||
|
|
||||||
return new_code_snippet
|
return new_code_snippet
|
||||||
|
|
||||||
async def _prepare_prediction_extended(self, model: str) -> dict:
|
async def _prepare_prediction_extended(self, model: str) -> dict:
|
||||||
logging.info('Getting PR diff...')
|
get_logger().info('Getting PR diff...')
|
||||||
patches_diff_list = get_pr_multi_diffs(self.git_provider, self.token_handler, model,
|
patches_diff_list = get_pr_multi_diffs(self.git_provider, self.token_handler, model,
|
||||||
max_calls=get_settings().pr_code_suggestions.max_number_of_calls)
|
max_calls=get_settings().pr_code_suggestions.max_number_of_calls)
|
||||||
|
|
||||||
logging.info('Getting multi AI predictions...')
|
get_logger().info('Getting multi AI predictions...')
|
||||||
prediction_list = []
|
prediction_list = []
|
||||||
for i, patches_diff in enumerate(patches_diff_list):
|
for i, patches_diff in enumerate(patches_diff_list):
|
||||||
logging.info(f"Processing chunk {i + 1} of {len(patches_diff_list)}")
|
get_logger().info(f"Processing chunk {i + 1} of {len(patches_diff_list)}")
|
||||||
self.patches_diff = patches_diff
|
self.patches_diff = patches_diff
|
||||||
prediction = await self._get_prediction(model)
|
prediction = await self._get_prediction(model)
|
||||||
prediction_list.append(prediction)
|
prediction_list.append(prediction)
|
||||||
@ -222,8 +223,8 @@ class PRCodeSuggestions:
|
|||||||
variables)
|
variables)
|
||||||
user_prompt = environment.from_string(get_settings().pr_sort_code_suggestions_prompt.user).render(variables)
|
user_prompt = environment.from_string(get_settings().pr_sort_code_suggestions_prompt.user).render(variables)
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
response, finish_reason = await self.ai_handler.chat_completion(model=model, system=system_prompt,
|
response, finish_reason = await self.ai_handler.chat_completion(model=model, system=system_prompt,
|
||||||
user=user_prompt)
|
user=user_prompt)
|
||||||
|
|
||||||
@ -238,7 +239,7 @@ class PRCodeSuggestions:
|
|||||||
data_sorted = data_sorted[:new_len]
|
data_sorted = data_sorted[:new_len]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if get_settings().config.verbosity_level >= 1:
|
if get_settings().config.verbosity_level >= 1:
|
||||||
logging.info(f"Could not sort suggestions, error: {e}")
|
get_logger().info(f"Could not sort suggestions, error: {e}")
|
||||||
data_sorted = suggestion_list
|
data_sorted = suggestion_list
|
||||||
|
|
||||||
return data_sorted
|
return data_sorted
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class PRConfig:
|
class PRConfig:
|
||||||
@ -19,11 +18,11 @@ class PRConfig:
|
|||||||
self.git_provider = get_git_provider()(pr_url)
|
self.git_provider = get_git_provider()(pr_url)
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
logging.info('Getting configuration settings...')
|
get_logger().info('Getting configuration settings...')
|
||||||
logging.info('Preparing configs...')
|
get_logger().info('Preparing configs...')
|
||||||
pr_comment = self._prepare_pr_configs()
|
pr_comment = self._prepare_pr_configs()
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
logging.info('Pushing configs...')
|
get_logger().info('Pushing configs...')
|
||||||
self.git_provider.publish_comment(pr_comment)
|
self.git_provider.publish_comment(pr_comment)
|
||||||
self.git_provider.remove_initial_comment()
|
self.git_provider.remove_initial_comment()
|
||||||
return ""
|
return ""
|
||||||
@ -44,5 +43,5 @@ class PRConfig:
|
|||||||
comment_str += f"\n{header.lower()}.{key.lower()} = {repr(value) if isinstance(value, str) else value}"
|
comment_str += f"\n{header.lower()}.{key.lower()} = {repr(value) if isinstance(value, str) else value}"
|
||||||
comment_str += " "
|
comment_str += " "
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"comment_str:\n{comment_str}")
|
get_logger().info(f"comment_str:\n{comment_str}")
|
||||||
return comment_str
|
return comment_str
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import logging
|
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from jinja2 import Environment, StrictUndefined
|
from jinja2 import Environment, StrictUndefined
|
||||||
@ -9,10 +7,11 @@ from jinja2 import Environment, StrictUndefined
|
|||||||
from pr_agent.algo.ai_handler import AiHandler
|
from pr_agent.algo.ai_handler import AiHandler
|
||||||
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
|
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
|
||||||
from pr_agent.algo.token_handler import TokenHandler
|
from pr_agent.algo.token_handler import TokenHandler
|
||||||
from pr_agent.algo.utils import load_yaml
|
from pr_agent.algo.utils import load_yaml, set_custom_labels, get_user_labels
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class PRDescription:
|
class PRDescription:
|
||||||
@ -41,8 +40,12 @@ class PRDescription:
|
|||||||
"description": self.git_provider.get_pr_description(full=False),
|
"description": self.git_provider.get_pr_description(full=False),
|
||||||
"language": self.main_pr_language,
|
"language": self.main_pr_language,
|
||||||
"diff": "", # empty diff for initial calculation
|
"diff": "", # empty diff for initial calculation
|
||||||
|
"use_bullet_points": get_settings().pr_description.use_bullet_points,
|
||||||
"extra_instructions": get_settings().pr_description.extra_instructions,
|
"extra_instructions": get_settings().pr_description.extra_instructions,
|
||||||
"commit_messages_str": self.git_provider.get_commit_messages()
|
"commit_messages_str": self.git_provider.get_commit_messages(),
|
||||||
|
"enable_custom_labels": get_settings().config.enable_custom_labels,
|
||||||
|
"custom_labels": "",
|
||||||
|
"custom_labels_examples": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
self.user_description = self.git_provider.get_user_description()
|
self.user_description = self.git_provider.get_user_description()
|
||||||
@ -65,13 +68,13 @@ class PRDescription:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logging.info(f"Generating a PR description {self.pr_id}")
|
get_logger().info(f"Generating a PR description {self.pr_id}")
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
self.git_provider.publish_comment("Preparing PR description...", is_temporary=True)
|
self.git_provider.publish_comment("Preparing PR description...", is_temporary=True)
|
||||||
|
|
||||||
await retry_with_fallback_models(self._prepare_prediction)
|
await retry_with_fallback_models(self._prepare_prediction)
|
||||||
|
|
||||||
logging.info(f"Preparing answer {self.pr_id}")
|
get_logger().info(f"Preparing answer {self.pr_id}")
|
||||||
if self.prediction:
|
if self.prediction:
|
||||||
self._prepare_data()
|
self._prepare_data()
|
||||||
else:
|
else:
|
||||||
@ -88,19 +91,19 @@ class PRDescription:
|
|||||||
full_markdown_description = f"## Title\n\n{pr_title}\n\n___\n{pr_body}"
|
full_markdown_description = f"## Title\n\n{pr_title}\n\n___\n{pr_body}"
|
||||||
|
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
logging.info(f"Pushing answer {self.pr_id}")
|
get_logger().info(f"Pushing answer {self.pr_id}")
|
||||||
if get_settings().pr_description.publish_description_as_comment:
|
if get_settings().pr_description.publish_description_as_comment:
|
||||||
self.git_provider.publish_comment(full_markdown_description)
|
self.git_provider.publish_comment(full_markdown_description)
|
||||||
else:
|
else:
|
||||||
self.git_provider.publish_description(pr_title, pr_body)
|
self.git_provider.publish_description(pr_title, pr_body)
|
||||||
if get_settings().pr_description.publish_labels and self.git_provider.is_supported("get_labels"):
|
if get_settings().pr_description.publish_labels and self.git_provider.is_supported("get_labels"):
|
||||||
current_labels = self.git_provider.get_labels()
|
current_labels = self.git_provider.get_labels()
|
||||||
if current_labels is None:
|
user_labels = get_user_labels(current_labels)
|
||||||
current_labels = []
|
|
||||||
self.git_provider.publish_labels(pr_labels + current_labels)
|
self.git_provider.publish_labels(pr_labels + user_labels)
|
||||||
self.git_provider.remove_initial_comment()
|
self.git_provider.remove_initial_comment()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error generating PR description {self.pr_id}: {e}")
|
get_logger().error(f"Error generating PR description {self.pr_id}: {e}")
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@ -121,9 +124,9 @@ class PRDescription:
|
|||||||
if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description:
|
if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logging.info(f"Getting PR diff {self.pr_id}")
|
get_logger().info(f"Getting PR diff {self.pr_id}")
|
||||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||||
logging.info(f"Getting AI prediction {self.pr_id}")
|
get_logger().info(f"Getting AI prediction {self.pr_id}")
|
||||||
self.prediction = await self._get_prediction(model)
|
self.prediction = await self._get_prediction(model)
|
||||||
|
|
||||||
async def _get_prediction(self, model: str) -> str:
|
async def _get_prediction(self, model: str) -> str:
|
||||||
@ -140,12 +143,13 @@ class PRDescription:
|
|||||||
variables["diff"] = self.patches_diff # update diff
|
variables["diff"] = self.patches_diff # update diff
|
||||||
|
|
||||||
environment = Environment(undefined=StrictUndefined)
|
environment = Environment(undefined=StrictUndefined)
|
||||||
|
set_custom_labels(variables)
|
||||||
system_prompt = environment.from_string(get_settings().pr_description_prompt.system).render(variables)
|
system_prompt = environment.from_string(get_settings().pr_description_prompt.system).render(variables)
|
||||||
user_prompt = environment.from_string(get_settings().pr_description_prompt.user).render(variables)
|
user_prompt = environment.from_string(get_settings().pr_description_prompt.user).render(variables)
|
||||||
|
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
|
|
||||||
response, finish_reason = await self.ai_handler.chat_completion(
|
response, finish_reason = await self.ai_handler.chat_completion(
|
||||||
model=model,
|
model=model,
|
||||||
@ -154,8 +158,10 @@ class PRDescription:
|
|||||||
user=user_prompt
|
user=user_prompt
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
if get_settings().config.verbosity_level >= 2:
|
||||||
|
get_logger().info(f"\nAI response:\n{response}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
def _prepare_data(self):
|
def _prepare_data(self):
|
||||||
# Load the AI prediction data into a dictionary
|
# Load the AI prediction data into a dictionary
|
||||||
@ -169,16 +175,20 @@ class PRDescription:
|
|||||||
pr_types = []
|
pr_types = []
|
||||||
|
|
||||||
# If the 'PR Type' key is present in the dictionary, split its value by comma and assign it to 'pr_types'
|
# If the 'PR Type' key is present in the dictionary, split its value by comma and assign it to 'pr_types'
|
||||||
if 'PR Type' in self.data:
|
if 'PR Labels' in self.data:
|
||||||
|
if type(self.data['PR Labels']) == list:
|
||||||
|
pr_types = self.data['PR Labels']
|
||||||
|
elif type(self.data['PR Labels']) == str:
|
||||||
|
pr_types = self.data['PR Labels'].split(',')
|
||||||
|
elif 'PR Type' in self.data:
|
||||||
if type(self.data['PR Type']) == list:
|
if type(self.data['PR Type']) == list:
|
||||||
pr_types = self.data['PR Type']
|
pr_types = self.data['PR Type']
|
||||||
elif type(self.data['PR Type']) == str:
|
elif type(self.data['PR Type']) == str:
|
||||||
pr_types = self.data['PR Type'].split(',')
|
pr_types = self.data['PR Type'].split(',')
|
||||||
|
|
||||||
return pr_types
|
return pr_types
|
||||||
|
|
||||||
def _prepare_pr_answer_with_markers(self) -> Tuple[str, str]:
|
def _prepare_pr_answer_with_markers(self) -> Tuple[str, str]:
|
||||||
logging.info(f"Using description marker replacements {self.pr_id}")
|
get_logger().info(f"Using description marker replacements {self.pr_id}")
|
||||||
title = self.vars["title"]
|
title = self.vars["title"]
|
||||||
body = self.user_description
|
body = self.user_description
|
||||||
if get_settings().pr_description.include_generated_by_header:
|
if get_settings().pr_description.include_generated_by_header:
|
||||||
@ -186,6 +196,11 @@ class PRDescription:
|
|||||||
else:
|
else:
|
||||||
ai_header = ""
|
ai_header = ""
|
||||||
|
|
||||||
|
ai_type = self.data.get('PR Type')
|
||||||
|
if ai_type and not re.search(r'<!--\s*pr_agent:type\s*-->', body):
|
||||||
|
pr_type = f"{ai_header}{ai_type}"
|
||||||
|
body = body.replace('pr_agent:type', pr_type)
|
||||||
|
|
||||||
ai_summary = self.data.get('PR Description')
|
ai_summary = self.data.get('PR Description')
|
||||||
if ai_summary and not re.search(r'<!--\s*pr_agent:summary\s*-->', body):
|
if ai_summary and not re.search(r'<!--\s*pr_agent:summary\s*-->', body):
|
||||||
summary = f"{ai_header}{ai_summary}"
|
summary = f"{ai_header}{ai_summary}"
|
||||||
@ -215,6 +230,11 @@ class PRDescription:
|
|||||||
|
|
||||||
# Iterate over the dictionary items and append the key and value to 'markdown_text' in a markdown format
|
# Iterate over the dictionary items and append the key and value to 'markdown_text' in a markdown format
|
||||||
markdown_text = ""
|
markdown_text = ""
|
||||||
|
# Don't display 'PR Labels'
|
||||||
|
if 'PR Labels' in self.data and self.git_provider.is_supported("get_labels"):
|
||||||
|
self.data.pop('PR Labels')
|
||||||
|
if not get_settings().pr_description.enable_pr_type:
|
||||||
|
self.data.pop('PR Type')
|
||||||
for key, value in self.data.items():
|
for key, value in self.data.items():
|
||||||
markdown_text += f"## {key}\n\n"
|
markdown_text += f"## {key}\n\n"
|
||||||
markdown_text += f"{value}\n\n"
|
markdown_text += f"{value}\n\n"
|
||||||
@ -240,7 +260,7 @@ class PRDescription:
|
|||||||
for file in value:
|
for file in value:
|
||||||
filename = file['filename'].replace("'", "`")
|
filename = file['filename'].replace("'", "`")
|
||||||
description = file['changes in file']
|
description = file['changes in file']
|
||||||
pr_body += f'`{filename}`: {description}\n'
|
pr_body += f'- `{filename}`: {description}\n'
|
||||||
if self.git_provider.is_supported("gfm_markdown"):
|
if self.git_provider.is_supported("gfm_markdown"):
|
||||||
pr_body +="</details>\n"
|
pr_body +="</details>\n"
|
||||||
else:
|
else:
|
||||||
@ -252,6 +272,6 @@ class PRDescription:
|
|||||||
pr_body += "\n___\n"
|
pr_body += "\n___\n"
|
||||||
|
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"title:\n{title}\n{pr_body}")
|
get_logger().info(f"title:\n{title}\n{pr_body}")
|
||||||
|
|
||||||
return title, pr_body
|
return title, pr_body
|
169
pr_agent/tools/pr_generate_labels.py
Normal file
169
pr_agent/tools/pr_generate_labels.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import copy
|
||||||
|
import re
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
from jinja2 import Environment, StrictUndefined
|
||||||
|
|
||||||
|
from pr_agent.algo.ai_handler import AiHandler
|
||||||
|
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
|
||||||
|
from pr_agent.algo.token_handler import TokenHandler
|
||||||
|
from pr_agent.algo.utils import load_yaml, set_custom_labels, get_user_labels
|
||||||
|
from pr_agent.config_loader import get_settings
|
||||||
|
from pr_agent.git_providers import get_git_provider
|
||||||
|
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
class PRGenerateLabels:
|
||||||
|
def __init__(self, pr_url: str, args: list = None):
|
||||||
|
"""
|
||||||
|
Initialize the PRGenerateLabels object with the necessary attributes and objects for generating labels
|
||||||
|
corresponding to the PR using an AI model.
|
||||||
|
Args:
|
||||||
|
pr_url (str): The URL of the pull request.
|
||||||
|
args (list, optional): List of arguments passed to the PRGenerateLabels class. Defaults to None.
|
||||||
|
"""
|
||||||
|
# Initialize the git provider and main PR language
|
||||||
|
self.git_provider = get_git_provider()(pr_url)
|
||||||
|
self.main_pr_language = get_main_pr_language(
|
||||||
|
self.git_provider.get_languages(), self.git_provider.get_files()
|
||||||
|
)
|
||||||
|
self.pr_id = self.git_provider.get_pr_id()
|
||||||
|
|
||||||
|
# Initialize the AI handler
|
||||||
|
self.ai_handler = AiHandler()
|
||||||
|
|
||||||
|
# Initialize the variables dictionary
|
||||||
|
self.vars = {
|
||||||
|
"title": self.git_provider.pr.title,
|
||||||
|
"branch": self.git_provider.get_pr_branch(),
|
||||||
|
"description": self.git_provider.get_pr_description(full=False),
|
||||||
|
"language": self.main_pr_language,
|
||||||
|
"diff": "", # empty diff for initial calculation
|
||||||
|
"use_bullet_points": get_settings().pr_description.use_bullet_points,
|
||||||
|
"extra_instructions": get_settings().pr_description.extra_instructions,
|
||||||
|
"commit_messages_str": self.git_provider.get_commit_messages(),
|
||||||
|
"custom_labels": "",
|
||||||
|
"custom_labels_examples": "",
|
||||||
|
"enable_custom_labels": get_settings().config.enable_custom_labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize the token handler
|
||||||
|
self.token_handler = TokenHandler(
|
||||||
|
self.git_provider.pr,
|
||||||
|
self.vars,
|
||||||
|
get_settings().pr_custom_labels_prompt.system,
|
||||||
|
get_settings().pr_custom_labels_prompt.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize patches_diff and prediction attributes
|
||||||
|
self.patches_diff = None
|
||||||
|
self.prediction = None
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""
|
||||||
|
Generates a PR labels using an AI model and publishes it to the PR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_logger().info(f"Generating a PR labels {self.pr_id}")
|
||||||
|
if get_settings().config.publish_output:
|
||||||
|
self.git_provider.publish_comment("Preparing PR labels...", is_temporary=True)
|
||||||
|
|
||||||
|
await retry_with_fallback_models(self._prepare_prediction)
|
||||||
|
|
||||||
|
get_logger().info(f"Preparing answer {self.pr_id}")
|
||||||
|
if self.prediction:
|
||||||
|
self._prepare_data()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pr_labels = self._prepare_labels()
|
||||||
|
|
||||||
|
if get_settings().config.publish_output:
|
||||||
|
get_logger().info(f"Pushing labels {self.pr_id}")
|
||||||
|
|
||||||
|
current_labels = self.git_provider.get_labels()
|
||||||
|
user_labels = get_user_labels(current_labels)
|
||||||
|
pr_labels = pr_labels + user_labels
|
||||||
|
|
||||||
|
if self.git_provider.is_supported("get_labels"):
|
||||||
|
self.git_provider.publish_labels(pr_labels)
|
||||||
|
elif pr_labels:
|
||||||
|
value = ', '.join(v for v in pr_labels)
|
||||||
|
pr_labels_text = f"## PR Labels:\n{value}\n"
|
||||||
|
self.git_provider.publish_comment(pr_labels_text, is_temporary=False)
|
||||||
|
self.git_provider.remove_initial_comment()
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().error(f"Error generating PR labels {self.pr_id}: {e}")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def _prepare_prediction(self, model: str) -> None:
|
||||||
|
"""
|
||||||
|
Prepare the AI prediction for the PR labels based on the provided model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model (str): The name of the model to be used for generating the prediction.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Any exceptions raised by the 'get_pr_diff' and '_get_prediction' functions.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
get_logger().info(f"Getting PR diff {self.pr_id}")
|
||||||
|
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||||
|
get_logger().info(f"Getting AI prediction {self.pr_id}")
|
||||||
|
self.prediction = await self._get_prediction(model)
|
||||||
|
|
||||||
|
async def _get_prediction(self, model: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate an AI prediction for the PR labels based on the provided model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model (str): The name of the model to be used for generating the prediction.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The generated AI prediction.
|
||||||
|
"""
|
||||||
|
variables = copy.deepcopy(self.vars)
|
||||||
|
variables["diff"] = self.patches_diff # update diff
|
||||||
|
|
||||||
|
environment = Environment(undefined=StrictUndefined)
|
||||||
|
set_custom_labels(variables)
|
||||||
|
system_prompt = environment.from_string(get_settings().pr_custom_labels_prompt.system).render(variables)
|
||||||
|
user_prompt = environment.from_string(get_settings().pr_custom_labels_prompt.user).render(variables)
|
||||||
|
|
||||||
|
if get_settings().config.verbosity_level >= 2:
|
||||||
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
|
|
||||||
|
response, finish_reason = await self.ai_handler.chat_completion(
|
||||||
|
model=model,
|
||||||
|
temperature=0.2,
|
||||||
|
system=system_prompt,
|
||||||
|
user=user_prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _prepare_data(self):
|
||||||
|
# Load the AI prediction data into a dictionary
|
||||||
|
self.data = load_yaml(self.prediction.strip())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_labels(self) -> List[str]:
|
||||||
|
pr_types = []
|
||||||
|
|
||||||
|
# If the 'PR Type' key is present in the dictionary, split its value by comma and assign it to 'pr_types'
|
||||||
|
if 'PR Type' in self.data:
|
||||||
|
if type(self.data['PR Type']) == list:
|
||||||
|
pr_types = self.data['PR Type']
|
||||||
|
elif type(self.data['PR Type']) == str:
|
||||||
|
pr_types = self.data['PR Type'].split(',')
|
||||||
|
|
||||||
|
return pr_types
|
@ -1,5 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
|
||||||
|
|
||||||
from jinja2 import Environment, StrictUndefined
|
from jinja2 import Environment, StrictUndefined
|
||||||
|
|
||||||
@ -9,6 +8,7 @@ from pr_agent.algo.token_handler import TokenHandler
|
|||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class PRInformationFromUser:
|
class PRInformationFromUser:
|
||||||
@ -34,22 +34,22 @@ class PRInformationFromUser:
|
|||||||
self.prediction = None
|
self.prediction = None
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
logging.info('Generating question to the user...')
|
get_logger().info('Generating question to the user...')
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
self.git_provider.publish_comment("Preparing questions...", is_temporary=True)
|
self.git_provider.publish_comment("Preparing questions...", is_temporary=True)
|
||||||
await retry_with_fallback_models(self._prepare_prediction)
|
await retry_with_fallback_models(self._prepare_prediction)
|
||||||
logging.info('Preparing questions...')
|
get_logger().info('Preparing questions...')
|
||||||
pr_comment = self._prepare_pr_answer()
|
pr_comment = self._prepare_pr_answer()
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
logging.info('Pushing questions...')
|
get_logger().info('Pushing questions...')
|
||||||
self.git_provider.publish_comment(pr_comment)
|
self.git_provider.publish_comment(pr_comment)
|
||||||
self.git_provider.remove_initial_comment()
|
self.git_provider.remove_initial_comment()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
async def _prepare_prediction(self, model):
|
async def _prepare_prediction(self, model):
|
||||||
logging.info('Getting PR diff...')
|
get_logger().info('Getting PR diff...')
|
||||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||||
logging.info('Getting AI prediction...')
|
get_logger().info('Getting AI prediction...')
|
||||||
self.prediction = await self._get_prediction(model)
|
self.prediction = await self._get_prediction(model)
|
||||||
|
|
||||||
async def _get_prediction(self, model: str):
|
async def _get_prediction(self, model: str):
|
||||||
@ -59,8 +59,8 @@ class PRInformationFromUser:
|
|||||||
system_prompt = environment.from_string(get_settings().pr_information_from_user_prompt.system).render(variables)
|
system_prompt = environment.from_string(get_settings().pr_information_from_user_prompt.system).render(variables)
|
||||||
user_prompt = environment.from_string(get_settings().pr_information_from_user_prompt.user).render(variables)
|
user_prompt = environment.from_string(get_settings().pr_information_from_user_prompt.user).render(variables)
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
||||||
system=system_prompt, user=user_prompt)
|
system=system_prompt, user=user_prompt)
|
||||||
return response
|
return response
|
||||||
@ -68,7 +68,7 @@ class PRInformationFromUser:
|
|||||||
def _prepare_pr_answer(self) -> str:
|
def _prepare_pr_answer(self) -> str:
|
||||||
model_output = self.prediction.strip()
|
model_output = self.prediction.strip()
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"answer_str:\n{model_output}")
|
get_logger().info(f"answer_str:\n{model_output}")
|
||||||
answer_str = f"{model_output}\n\n Please respond to the questions above in the following format:\n\n" +\
|
answer_str = f"{model_output}\n\n Please respond to the questions above in the following format:\n\n" +\
|
||||||
"\n>/answer\n>1) ...\n>2) ...\n>...\n"
|
"\n>/answer\n>1) ...\n>2) ...\n>...\n"
|
||||||
return answer_str
|
return answer_str
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
|
||||||
|
|
||||||
from jinja2 import Environment, StrictUndefined
|
from jinja2 import Environment, StrictUndefined
|
||||||
|
|
||||||
@ -9,6 +8,7 @@ from pr_agent.algo.token_handler import TokenHandler
|
|||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
|
|
||||||
class PRQuestions:
|
class PRQuestions:
|
||||||
@ -44,22 +44,22 @@ class PRQuestions:
|
|||||||
return question_str
|
return question_str
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
logging.info('Answering a PR question...')
|
get_logger().info('Answering a PR question...')
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
self.git_provider.publish_comment("Preparing answer...", is_temporary=True)
|
self.git_provider.publish_comment("Preparing answer...", is_temporary=True)
|
||||||
await retry_with_fallback_models(self._prepare_prediction)
|
await retry_with_fallback_models(self._prepare_prediction)
|
||||||
logging.info('Preparing answer...')
|
get_logger().info('Preparing answer...')
|
||||||
pr_comment = self._prepare_pr_answer()
|
pr_comment = self._prepare_pr_answer()
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
logging.info('Pushing answer...')
|
get_logger().info('Pushing answer...')
|
||||||
self.git_provider.publish_comment(pr_comment)
|
self.git_provider.publish_comment(pr_comment)
|
||||||
self.git_provider.remove_initial_comment()
|
self.git_provider.remove_initial_comment()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
async def _prepare_prediction(self, model: str):
|
async def _prepare_prediction(self, model: str):
|
||||||
logging.info('Getting PR diff...')
|
get_logger().info('Getting PR diff...')
|
||||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||||
logging.info('Getting AI prediction...')
|
get_logger().info('Getting AI prediction...')
|
||||||
self.prediction = await self._get_prediction(model)
|
self.prediction = await self._get_prediction(model)
|
||||||
|
|
||||||
async def _get_prediction(self, model: str):
|
async def _get_prediction(self, model: str):
|
||||||
@ -69,8 +69,8 @@ class PRQuestions:
|
|||||||
system_prompt = environment.from_string(get_settings().pr_questions_prompt.system).render(variables)
|
system_prompt = environment.from_string(get_settings().pr_questions_prompt.system).render(variables)
|
||||||
user_prompt = environment.from_string(get_settings().pr_questions_prompt.user).render(variables)
|
user_prompt = environment.from_string(get_settings().pr_questions_prompt.user).render(variables)
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
||||||
system=system_prompt, user=user_prompt)
|
system=system_prompt, user=user_prompt)
|
||||||
return response
|
return response
|
||||||
@ -79,5 +79,5 @@ class PRQuestions:
|
|||||||
answer_str = f"Question: {self.question_str}\n\n"
|
answer_str = f"Question: {self.question_str}\n\n"
|
||||||
answer_str += f"Answer:\n{self.prediction.strip()}\n\n"
|
answer_str += f"Answer:\n{self.prediction.strip()}\n\n"
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"answer_str:\n{answer_str}")
|
get_logger().info(f"answer_str:\n{answer_str}")
|
||||||
return answer_str
|
return answer_str
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import datetime
|
||||||
import logging
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
@ -9,13 +8,13 @@ from jinja2 import Environment, StrictUndefined
|
|||||||
from yaml import SafeLoader
|
from yaml import SafeLoader
|
||||||
|
|
||||||
from pr_agent.algo.ai_handler import AiHandler
|
from pr_agent.algo.ai_handler import AiHandler
|
||||||
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models, \
|
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
|
||||||
find_line_number_of_relevant_line_in_file, clip_tokens
|
|
||||||
from pr_agent.algo.token_handler import TokenHandler
|
from pr_agent.algo.token_handler import TokenHandler
|
||||||
from pr_agent.algo.utils import convert_to_markdown, try_fix_json, try_fix_yaml, load_yaml
|
from pr_agent.algo.utils import convert_to_markdown, load_yaml, try_fix_yaml, set_custom_labels, get_user_labels
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pr_agent.git_providers.git_provider import IncrementalPR, get_main_pr_language
|
from pr_agent.git_providers.git_provider import IncrementalPR, get_main_pr_language
|
||||||
|
from pr_agent.log import get_logger
|
||||||
from pr_agent.servers.help import actions_help_text, bot_help_text
|
from pr_agent.servers.help import actions_help_text, bot_help_text
|
||||||
|
|
||||||
|
|
||||||
@ -65,6 +64,8 @@ class PRReviewer:
|
|||||||
'answer_str': answer_str,
|
'answer_str': answer_str,
|
||||||
"extra_instructions": get_settings().pr_reviewer.extra_instructions,
|
"extra_instructions": get_settings().pr_reviewer.extra_instructions,
|
||||||
"commit_messages_str": self.git_provider.get_commit_messages(),
|
"commit_messages_str": self.git_provider.get_commit_messages(),
|
||||||
|
"custom_labels": "",
|
||||||
|
"enable_custom_labels": get_settings().config.enable_custom_labels,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.token_handler = TokenHandler(
|
self.token_handler = TokenHandler(
|
||||||
@ -98,29 +99,41 @@ class PRReviewer:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if self.is_auto and not get_settings().pr_reviewer.automatic_review:
|
if self.is_auto and not get_settings().pr_reviewer.automatic_review:
|
||||||
logging.info(f'Automatic review is disabled {self.pr_url}')
|
get_logger().info(f'Automatic review is disabled {self.pr_url}')
|
||||||
|
return None
|
||||||
|
if self.incremental.is_incremental and not self._can_run_incremental_review():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logging.info(f'Reviewing PR: {self.pr_url} ...')
|
get_logger().info(f'Reviewing PR: {self.pr_url} ...')
|
||||||
|
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
|
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
|
||||||
|
|
||||||
await retry_with_fallback_models(self._prepare_prediction)
|
await retry_with_fallback_models(self._prepare_prediction)
|
||||||
|
|
||||||
logging.info('Preparing PR review...')
|
get_logger().info('Preparing PR review...')
|
||||||
pr_comment = self._prepare_pr_review()
|
pr_comment = self._prepare_pr_review()
|
||||||
|
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
logging.info('Pushing PR review...')
|
get_logger().info('Pushing PR review...')
|
||||||
self.git_provider.publish_comment(pr_comment)
|
previous_review_comment = self._get_previous_review_comment()
|
||||||
self.git_provider.remove_initial_comment()
|
|
||||||
|
|
||||||
|
# publish the review
|
||||||
|
if get_settings().pr_reviewer.persistent_comment and not self.incremental.is_incremental:
|
||||||
|
self.git_provider.publish_persistent_comment(pr_comment,
|
||||||
|
initial_header="## PR Analysis",
|
||||||
|
update_header=True)
|
||||||
|
else:
|
||||||
|
self.git_provider.publish_comment(pr_comment)
|
||||||
|
|
||||||
|
self.git_provider.remove_initial_comment()
|
||||||
|
if previous_review_comment:
|
||||||
|
self._remove_previous_review_comment(previous_review_comment)
|
||||||
if get_settings().pr_reviewer.inline_code_comments:
|
if get_settings().pr_reviewer.inline_code_comments:
|
||||||
logging.info('Pushing inline code comments...')
|
get_logger().info('Pushing inline code comments...')
|
||||||
self._publish_inline_code_comments()
|
self._publish_inline_code_comments()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to review PR: {e}")
|
get_logger().error(f"Failed to review PR: {e}")
|
||||||
|
|
||||||
async def _prepare_prediction(self, model: str) -> None:
|
async def _prepare_prediction(self, model: str) -> None:
|
||||||
"""
|
"""
|
||||||
@ -132,9 +145,9 @@ class PRReviewer:
|
|||||||
Returns:
|
Returns:
|
||||||
None
|
None
|
||||||
"""
|
"""
|
||||||
logging.info('Getting PR diff...')
|
get_logger().info('Getting PR diff...')
|
||||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||||
logging.info('Getting AI prediction...')
|
get_logger().info('Getting AI prediction...')
|
||||||
self.prediction = await self._get_prediction(model)
|
self.prediction = await self._get_prediction(model)
|
||||||
|
|
||||||
async def _get_prediction(self, model: str) -> str:
|
async def _get_prediction(self, model: str) -> str:
|
||||||
@ -155,8 +168,8 @@ class PRReviewer:
|
|||||||
user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(variables)
|
user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(variables)
|
||||||
|
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
|
|
||||||
response, finish_reason = await self.ai_handler.chat_completion(
|
response, finish_reason = await self.ai_handler.chat_completion(
|
||||||
model=model,
|
model=model,
|
||||||
@ -165,6 +178,9 @@ class PRReviewer:
|
|||||||
user=user_prompt
|
user=user_prompt
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if get_settings().config.verbosity_level >= 2:
|
||||||
|
get_logger().info(f"\nAI response:\n{response}")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _prepare_pr_review(self) -> str:
|
def _prepare_pr_review(self) -> str:
|
||||||
@ -211,28 +227,20 @@ class PRReviewer:
|
|||||||
suggestion['relevant line'] = f"[{suggestion['relevant line']}]({link})"
|
suggestion['relevant line'] = f"[{suggestion['relevant line']}]({link})"
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
# try:
|
|
||||||
# relevant_file = suggestion['relevant file'].strip('`').strip("'")
|
|
||||||
# relevant_line_str = suggestion['relevant line']
|
|
||||||
# if not relevant_line_str:
|
|
||||||
# return ""
|
|
||||||
#
|
|
||||||
# position, absolute_position = find_line_number_of_relevant_line_in_file(
|
|
||||||
# self.git_provider.diff_files, relevant_file, relevant_line_str)
|
|
||||||
# if absolute_position != -1:
|
|
||||||
# suggestion[
|
|
||||||
# 'relevant line'] = f"{suggestion['relevant line']} (line {absolute_position})"
|
|
||||||
# except:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
|
|
||||||
# Add incremental review section
|
# Add incremental review section
|
||||||
if self.incremental.is_incremental:
|
if self.incremental.is_incremental:
|
||||||
last_commit_url = f"{self.git_provider.get_pr_url()}/commits/" \
|
last_commit_url = f"{self.git_provider.get_pr_url()}/commits/" \
|
||||||
f"{self.git_provider.incremental.first_new_commit_sha}"
|
f"{self.git_provider.incremental.first_new_commit_sha}"
|
||||||
|
last_commit_msg = self.incremental.commits_range[0].commit.message if self.incremental.commits_range else ""
|
||||||
|
incremental_review_markdown_text = f"Starting from commit {last_commit_url}"
|
||||||
|
if last_commit_msg:
|
||||||
|
replacement = last_commit_msg.splitlines(keepends=False)[0].replace('_', r'\_')
|
||||||
|
incremental_review_markdown_text += f" \n_({replacement})_"
|
||||||
data = OrderedDict(data)
|
data = OrderedDict(data)
|
||||||
data.update({'Incremental PR Review': {
|
data.update({'Incremental PR Review': {
|
||||||
"⏮️ Review for commits since previous PR-Agent review": f"Starting from commit {last_commit_url}"}})
|
"⏮️ Review for commits since previous PR-Agent review": incremental_review_markdown_text}})
|
||||||
data.move_to_end('Incremental PR Review', last=False)
|
data.move_to_end('Incremental PR Review', last=False)
|
||||||
|
|
||||||
markdown_text = convert_to_markdown(data, self.git_provider.is_supported("gfm_markdown"))
|
markdown_text = convert_to_markdown(data, self.git_provider.is_supported("gfm_markdown"))
|
||||||
@ -247,9 +255,12 @@ class PRReviewer:
|
|||||||
else:
|
else:
|
||||||
markdown_text += actions_help_text
|
markdown_text += actions_help_text
|
||||||
|
|
||||||
|
# Add custom labels from the review prediction (effort, security)
|
||||||
|
self.set_review_labels(data)
|
||||||
|
|
||||||
# Log markdown response if verbosity level is high
|
# Log markdown response if verbosity level is high
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"Markdown response:\n{markdown_text}")
|
get_logger().info(f"Markdown response:\n{markdown_text}")
|
||||||
|
|
||||||
if markdown_text == None or len(markdown_text) == 0:
|
if markdown_text == None or len(markdown_text) == 0:
|
||||||
markdown_text = ""
|
markdown_text = ""
|
||||||
@ -268,7 +279,7 @@ class PRReviewer:
|
|||||||
try:
|
try:
|
||||||
data = yaml.load(review_text, Loader=SafeLoader)
|
data = yaml.load(review_text, Loader=SafeLoader)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to parse AI prediction: {e}")
|
get_logger().error(f"Failed to parse AI prediction: {e}")
|
||||||
data = try_fix_yaml(review_text)
|
data = try_fix_yaml(review_text)
|
||||||
|
|
||||||
comments: List[str] = []
|
comments: List[str] = []
|
||||||
@ -277,7 +288,7 @@ class PRReviewer:
|
|||||||
relevant_line_in_file = suggestion.get('relevant line', '').strip()
|
relevant_line_in_file = suggestion.get('relevant line', '').strip()
|
||||||
content = suggestion.get('suggestion', '')
|
content = suggestion.get('suggestion', '')
|
||||||
if not relevant_file or not relevant_line_in_file or not content:
|
if not relevant_file or not relevant_line_in_file or not content:
|
||||||
logging.info("Skipping inline comment with missing file/line/content")
|
get_logger().info("Skipping inline comment with missing file/line/content")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.git_provider.is_supported("create_inline_comment"):
|
if self.git_provider.is_supported("create_inline_comment"):
|
||||||
@ -313,3 +324,82 @@ class PRReviewer:
|
|||||||
break
|
break
|
||||||
|
|
||||||
return question_str, answer_str
|
return question_str, answer_str
|
||||||
|
|
||||||
|
def _get_previous_review_comment(self):
|
||||||
|
"""
|
||||||
|
Get the previous review comment if it exists.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if get_settings().pr_reviewer.remove_previous_review_comment and hasattr(self.git_provider, "get_previous_review"):
|
||||||
|
return self.git_provider.get_previous_review(
|
||||||
|
full=not self.incremental.is_incremental,
|
||||||
|
incremental=self.incremental.is_incremental,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception(f"Failed to get previous review comment, error: {e}")
|
||||||
|
|
||||||
|
def _remove_previous_review_comment(self, comment):
|
||||||
|
"""
|
||||||
|
Remove the previous review comment if it exists.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if get_settings().pr_reviewer.remove_previous_review_comment and comment:
|
||||||
|
self.git_provider.remove_comment(comment)
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().exception(f"Failed to remove previous review comment, error: {e}")
|
||||||
|
|
||||||
|
def _can_run_incremental_review(self) -> bool:
|
||||||
|
"""Checks if we can run incremental review according the various configurations and previous review"""
|
||||||
|
# checking if running is auto mode but there are no new commits
|
||||||
|
if self.is_auto and not self.incremental.first_new_commit_sha:
|
||||||
|
get_logger().info(f"Incremental review is enabled for {self.pr_url} but there are no new commits")
|
||||||
|
return False
|
||||||
|
# checking if there are enough commits to start the review
|
||||||
|
num_new_commits = len(self.incremental.commits_range)
|
||||||
|
num_commits_threshold = get_settings().pr_reviewer.minimal_commits_for_incremental_review
|
||||||
|
not_enough_commits = num_new_commits < num_commits_threshold
|
||||||
|
# checking if the commits are not too recent to start the review
|
||||||
|
recent_commits_threshold = datetime.datetime.now() - datetime.timedelta(
|
||||||
|
minutes=get_settings().pr_reviewer.minimal_minutes_for_incremental_review
|
||||||
|
)
|
||||||
|
last_seen_commit_date = (
|
||||||
|
self.incremental.last_seen_commit.commit.author.date if self.incremental.last_seen_commit else None
|
||||||
|
)
|
||||||
|
all_commits_too_recent = (
|
||||||
|
last_seen_commit_date > recent_commits_threshold if self.incremental.last_seen_commit else False
|
||||||
|
)
|
||||||
|
# check all the thresholds or just one to start the review
|
||||||
|
condition = any if get_settings().pr_reviewer.require_all_thresholds_for_incremental_review else all
|
||||||
|
if condition((not_enough_commits, all_commits_too_recent)):
|
||||||
|
get_logger().info(
|
||||||
|
f"Incremental review is enabled for {self.pr_url} but didn't pass the threshold check to run:"
|
||||||
|
f"\n* Number of new commits = {num_new_commits} (threshold is {num_commits_threshold})"
|
||||||
|
f"\n* Last seen commit date = {last_seen_commit_date} (threshold is {recent_commits_threshold})"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_review_labels(self, data):
|
||||||
|
if (get_settings().pr_reviewer.enable_review_labels_security or
|
||||||
|
get_settings().pr_reviewer.enable_review_labels_effort):
|
||||||
|
try:
|
||||||
|
review_labels = []
|
||||||
|
if get_settings().pr_reviewer.enable_review_labels_effort:
|
||||||
|
estimated_effort = data['PR Analysis']['Estimated effort to review [1-5]']
|
||||||
|
estimated_effort_number = int(estimated_effort.split(',')[0])
|
||||||
|
if 1 <= estimated_effort_number <= 5: # 1, because ...
|
||||||
|
review_labels.append(f'Review effort [1-5]: {estimated_effort_number}')
|
||||||
|
if get_settings().pr_reviewer.enable_review_labels_security:
|
||||||
|
security_concerns = data['PR Analysis']['Security concerns'] # yes, because ...
|
||||||
|
security_concerns_bool = 'yes' in security_concerns.lower() or 'true' in security_concerns.lower()
|
||||||
|
if security_concerns_bool:
|
||||||
|
review_labels.append('Possible security concern')
|
||||||
|
|
||||||
|
if review_labels:
|
||||||
|
current_labels = self.git_provider.get_labels()
|
||||||
|
current_labels_filtered = [label for label in current_labels if
|
||||||
|
not label.lower().startswith('review effort [1-5]:') and not label.lower().startswith(
|
||||||
|
'possible security concern')]
|
||||||
|
self.git_provider.publish_labels(review_labels + current_labels_filtered)
|
||||||
|
except Exception as e:
|
||||||
|
get_logger().error(f"Failed to set review labels, error: {e}")
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import copy
|
import time
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Tuple
|
from typing import List
|
||||||
import pinecone
|
|
||||||
import openai
|
import openai
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import pinecone
|
||||||
|
from pinecone_datasets import Dataset, DatasetMetadata
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from pr_agent.algo import MAX_TOKENS
|
|
||||||
from pr_agent.algo.token_handler import TokenHandler
|
from pr_agent.algo.token_handler import TokenHandler
|
||||||
|
from pr_agent.algo.utils import get_max_tokens
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pinecone_datasets import Dataset, DatasetMetadata
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
MODEL = "text-embedding-ada-002"
|
MODEL = "text-embedding-ada-002"
|
||||||
|
|
||||||
@ -47,6 +47,13 @@ class PRSimilarIssue:
|
|||||||
|
|
||||||
# check if index exists, and if repo is already indexed
|
# check if index exists, and if repo is already indexed
|
||||||
run_from_scratch = False
|
run_from_scratch = False
|
||||||
|
if run_from_scratch: # for debugging
|
||||||
|
pinecone.init(api_key=api_key, environment=environment)
|
||||||
|
if index_name in pinecone.list_indexes():
|
||||||
|
get_logger().info('Removing index...')
|
||||||
|
pinecone.delete_index(index_name)
|
||||||
|
get_logger().info('Done')
|
||||||
|
|
||||||
upsert = True
|
upsert = True
|
||||||
pinecone.init(api_key=api_key, environment=environment)
|
pinecone.init(api_key=api_key, environment=environment)
|
||||||
if not index_name in pinecone.list_indexes():
|
if not index_name in pinecone.list_indexes():
|
||||||
@ -62,11 +69,11 @@ class PRSimilarIssue:
|
|||||||
upsert = False
|
upsert = False
|
||||||
|
|
||||||
if run_from_scratch or upsert: # index the entire repo
|
if run_from_scratch or upsert: # index the entire repo
|
||||||
logging.info('Indexing the entire repo...')
|
get_logger().info('Indexing the entire repo...')
|
||||||
|
|
||||||
logging.info('Getting issues...')
|
get_logger().info('Getting issues...')
|
||||||
issues = list(repo_obj.get_issues(state='all'))
|
issues = list(repo_obj.get_issues(state='all'))
|
||||||
logging.info('Done')
|
get_logger().info('Done')
|
||||||
self._update_index_with_issues(issues, repo_name_for_index, upsert=upsert)
|
self._update_index_with_issues(issues, repo_name_for_index, upsert=upsert)
|
||||||
else: # update index if needed
|
else: # update index if needed
|
||||||
pinecone_index = pinecone.Index(index_name=index_name)
|
pinecone_index = pinecone.Index(index_name=index_name)
|
||||||
@ -92,20 +99,20 @@ class PRSimilarIssue:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if issues_to_update:
|
if issues_to_update:
|
||||||
logging.info(f'Updating index with {counter} new issues...')
|
get_logger().info(f'Updating index with {counter} new issues...')
|
||||||
self._update_index_with_issues(issues_to_update, repo_name_for_index, upsert=True)
|
self._update_index_with_issues(issues_to_update, repo_name_for_index, upsert=True)
|
||||||
else:
|
else:
|
||||||
logging.info('No new issues to update')
|
get_logger().info('No new issues to update')
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
logging.info('Getting issue...')
|
get_logger().info('Getting issue...')
|
||||||
repo_name, original_issue_number = self.git_provider._parse_issue_url(self.issue_url.split('=')[-1])
|
repo_name, original_issue_number = self.git_provider._parse_issue_url(self.issue_url.split('=')[-1])
|
||||||
issue_main = self.git_provider.repo_obj.get_issue(original_issue_number)
|
issue_main = self.git_provider.repo_obj.get_issue(original_issue_number)
|
||||||
issue_str, comments, number = self._process_issue(issue_main)
|
issue_str, comments, number = self._process_issue(issue_main)
|
||||||
openai.api_key = get_settings().openai.key
|
openai.api_key = get_settings().openai.key
|
||||||
logging.info('Done')
|
get_logger().info('Done')
|
||||||
|
|
||||||
logging.info('Querying...')
|
get_logger().info('Querying...')
|
||||||
res = openai.Embedding.create(input=[issue_str], engine=MODEL)
|
res = openai.Embedding.create(input=[issue_str], engine=MODEL)
|
||||||
embeds = [record['embedding'] for record in res['data']]
|
embeds = [record['embedding'] for record in res['data']]
|
||||||
pinecone_index = pinecone.Index(index_name=self.index_name)
|
pinecone_index = pinecone.Index(index_name=self.index_name)
|
||||||
@ -117,7 +124,16 @@ class PRSimilarIssue:
|
|||||||
relevant_comment_number_list = []
|
relevant_comment_number_list = []
|
||||||
score_list = []
|
score_list = []
|
||||||
for r in res['matches']:
|
for r in res['matches']:
|
||||||
issue_number = int(r["id"].split('.')[0].split('_')[-1])
|
# skip example issue
|
||||||
|
if 'example_issue_' in r["id"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
issue_number = int(r["id"].split('.')[0].split('_')[-1])
|
||||||
|
except:
|
||||||
|
get_logger().debug(f"Failed to parse issue number from {r['id']}")
|
||||||
|
continue
|
||||||
|
|
||||||
if original_issue_number == issue_number:
|
if original_issue_number == issue_number:
|
||||||
continue
|
continue
|
||||||
if issue_number not in relevant_issues_number_list:
|
if issue_number not in relevant_issues_number_list:
|
||||||
@ -127,9 +143,9 @@ class PRSimilarIssue:
|
|||||||
else:
|
else:
|
||||||
relevant_comment_number_list.append(-1)
|
relevant_comment_number_list.append(-1)
|
||||||
score_list.append(str("{:.2f}".format(r['score'])))
|
score_list.append(str("{:.2f}".format(r['score'])))
|
||||||
logging.info('Done')
|
get_logger().info('Done')
|
||||||
|
|
||||||
logging.info('Publishing response...')
|
get_logger().info('Publishing response...')
|
||||||
similar_issues_str = "### Similar Issues\n___\n\n"
|
similar_issues_str = "### Similar Issues\n___\n\n"
|
||||||
for i, issue_number_similar in enumerate(relevant_issues_number_list):
|
for i, issue_number_similar in enumerate(relevant_issues_number_list):
|
||||||
issue = self.git_provider.repo_obj.get_issue(issue_number_similar)
|
issue = self.git_provider.repo_obj.get_issue(issue_number_similar)
|
||||||
@ -140,8 +156,8 @@ class PRSimilarIssue:
|
|||||||
similar_issues_str += f"{i + 1}. **[{title}]({url})** (score={score_list[i]})\n\n"
|
similar_issues_str += f"{i + 1}. **[{title}]({url})** (score={score_list[i]})\n\n"
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
response = issue_main.create_comment(similar_issues_str)
|
response = issue_main.create_comment(similar_issues_str)
|
||||||
logging.info(similar_issues_str)
|
get_logger().info(similar_issues_str)
|
||||||
logging.info('Done')
|
get_logger().info('Done')
|
||||||
|
|
||||||
def _process_issue(self, issue):
|
def _process_issue(self, issue):
|
||||||
header = issue.title
|
header = issue.title
|
||||||
@ -155,7 +171,7 @@ class PRSimilarIssue:
|
|||||||
return issue_str, comments, number
|
return issue_str, comments, number
|
||||||
|
|
||||||
def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=False):
|
def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=False):
|
||||||
logging.info('Processing issues...')
|
get_logger().info('Processing issues...')
|
||||||
corpus = Corpus()
|
corpus = Corpus()
|
||||||
example_issue_record = Record(
|
example_issue_record = Record(
|
||||||
id=f"example_issue_{repo_name_for_index}",
|
id=f"example_issue_{repo_name_for_index}",
|
||||||
@ -171,9 +187,9 @@ class PRSimilarIssue:
|
|||||||
|
|
||||||
counter += 1
|
counter += 1
|
||||||
if counter % 100 == 0:
|
if counter % 100 == 0:
|
||||||
logging.info(f"Scanned {counter} issues")
|
get_logger().info(f"Scanned {counter} issues")
|
||||||
if counter >= self.max_issues_to_scan:
|
if counter >= self.max_issues_to_scan:
|
||||||
logging.info(f"Scanned {self.max_issues_to_scan} issues, stopping")
|
get_logger().info(f"Scanned {self.max_issues_to_scan} issues, stopping")
|
||||||
break
|
break
|
||||||
|
|
||||||
issue_str, comments, number = self._process_issue(issue)
|
issue_str, comments, number = self._process_issue(issue)
|
||||||
@ -181,7 +197,7 @@ class PRSimilarIssue:
|
|||||||
username = issue.user.login
|
username = issue.user.login
|
||||||
created_at = str(issue.created_at)
|
created_at = str(issue.created_at)
|
||||||
if len(issue_str) < 8000 or \
|
if len(issue_str) < 8000 or \
|
||||||
self.token_handler.count_tokens(issue_str) < MAX_TOKENS[MODEL]: # fast reject first
|
self.token_handler.count_tokens(issue_str) < get_max_tokens(MODEL): # fast reject first
|
||||||
issue_record = Record(
|
issue_record = Record(
|
||||||
id=issue_key + "." + "issue",
|
id=issue_key + "." + "issue",
|
||||||
text=issue_str,
|
text=issue_str,
|
||||||
@ -210,9 +226,9 @@ class PRSimilarIssue:
|
|||||||
)
|
)
|
||||||
corpus.append(comment_record)
|
corpus.append(comment_record)
|
||||||
df = pd.DataFrame(corpus.dict()["documents"])
|
df = pd.DataFrame(corpus.dict()["documents"])
|
||||||
logging.info('Done')
|
get_logger().info('Done')
|
||||||
|
|
||||||
logging.info('Embedding...')
|
get_logger().info('Embedding...')
|
||||||
openai.api_key = get_settings().openai.key
|
openai.api_key = get_settings().openai.key
|
||||||
list_to_encode = list(df["text"].values)
|
list_to_encode = list(df["text"].values)
|
||||||
try:
|
try:
|
||||||
@ -220,7 +236,7 @@ class PRSimilarIssue:
|
|||||||
embeds = [record['embedding'] for record in res['data']]
|
embeds = [record['embedding'] for record in res['data']]
|
||||||
except:
|
except:
|
||||||
embeds = []
|
embeds = []
|
||||||
logging.error('Failed to embed entire list, embedding one by one...')
|
get_logger().error('Failed to embed entire list, embedding one by one...')
|
||||||
for i, text in enumerate(list_to_encode):
|
for i, text in enumerate(list_to_encode):
|
||||||
try:
|
try:
|
||||||
res = openai.Embedding.create(input=[text], engine=MODEL)
|
res = openai.Embedding.create(input=[text], engine=MODEL)
|
||||||
@ -231,21 +247,23 @@ class PRSimilarIssue:
|
|||||||
meta = DatasetMetadata.empty()
|
meta = DatasetMetadata.empty()
|
||||||
meta.dense_model.dimension = len(embeds[0])
|
meta.dense_model.dimension = len(embeds[0])
|
||||||
ds = Dataset.from_pandas(df, meta)
|
ds = Dataset.from_pandas(df, meta)
|
||||||
logging.info('Done')
|
get_logger().info('Done')
|
||||||
|
|
||||||
api_key = get_settings().pinecone.api_key
|
api_key = get_settings().pinecone.api_key
|
||||||
environment = get_settings().pinecone.environment
|
environment = get_settings().pinecone.environment
|
||||||
if not upsert:
|
if not upsert:
|
||||||
logging.info('Creating index from scratch...')
|
get_logger().info('Creating index from scratch...')
|
||||||
ds.to_pinecone_index(self.index_name, api_key=api_key, environment=environment)
|
ds.to_pinecone_index(self.index_name, api_key=api_key, environment=environment)
|
||||||
|
time.sleep(15) # wait for pinecone to finalize indexing before querying
|
||||||
else:
|
else:
|
||||||
logging.info('Upserting index...')
|
get_logger().info('Upserting index...')
|
||||||
namespace = ""
|
namespace = ""
|
||||||
batch_size: int = 100
|
batch_size: int = 100
|
||||||
concurrency: int = 10
|
concurrency: int = 10
|
||||||
pinecone.init(api_key=api_key, environment=environment)
|
pinecone.init(api_key=api_key, environment=environment)
|
||||||
ds._upsert_to_index(self.index_name, namespace, batch_size, concurrency)
|
ds._upsert_to_index(self.index_name, namespace, batch_size, concurrency)
|
||||||
logging.info('Done')
|
time.sleep(5) # wait for pinecone to finalize upserting before querying
|
||||||
|
get_logger().info('Done')
|
||||||
|
|
||||||
|
|
||||||
class IssueLevel(str, Enum):
|
class IssueLevel(str, Enum):
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
@ -10,8 +9,9 @@ from pr_agent.algo.ai_handler import AiHandler
|
|||||||
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
|
from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models
|
||||||
from pr_agent.algo.token_handler import TokenHandler
|
from pr_agent.algo.token_handler import TokenHandler
|
||||||
from pr_agent.config_loader import get_settings
|
from pr_agent.config_loader import get_settings
|
||||||
from pr_agent.git_providers import GithubProvider, get_git_provider
|
from pr_agent.git_providers import get_git_provider
|
||||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||||
|
from pr_agent.log import get_logger
|
||||||
|
|
||||||
CHANGELOG_LINES = 50
|
CHANGELOG_LINES = 50
|
||||||
|
|
||||||
@ -48,26 +48,26 @@ class PRUpdateChangelog:
|
|||||||
async def run(self):
|
async def run(self):
|
||||||
# assert type(self.git_provider) == GithubProvider, "Currently only Github is supported"
|
# assert type(self.git_provider) == GithubProvider, "Currently only Github is supported"
|
||||||
|
|
||||||
logging.info('Updating the changelog...')
|
get_logger().info('Updating the changelog...')
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True)
|
self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True)
|
||||||
await retry_with_fallback_models(self._prepare_prediction)
|
await retry_with_fallback_models(self._prepare_prediction)
|
||||||
logging.info('Preparing PR changelog updates...')
|
get_logger().info('Preparing PR changelog updates...')
|
||||||
new_file_content, answer = self._prepare_changelog_update()
|
new_file_content, answer = self._prepare_changelog_update()
|
||||||
if get_settings().config.publish_output:
|
if get_settings().config.publish_output:
|
||||||
self.git_provider.remove_initial_comment()
|
self.git_provider.remove_initial_comment()
|
||||||
logging.info('Publishing changelog updates...')
|
get_logger().info('Publishing changelog updates...')
|
||||||
if self.commit_changelog:
|
if self.commit_changelog:
|
||||||
logging.info('Pushing PR changelog updates to repo...')
|
get_logger().info('Pushing PR changelog updates to repo...')
|
||||||
self._push_changelog_update(new_file_content, answer)
|
self._push_changelog_update(new_file_content, answer)
|
||||||
else:
|
else:
|
||||||
logging.info('Publishing PR changelog as comment...')
|
get_logger().info('Publishing PR changelog as comment...')
|
||||||
self.git_provider.publish_comment(f"**Changelog updates:**\n\n{answer}")
|
self.git_provider.publish_comment(f"**Changelog updates:**\n\n{answer}")
|
||||||
|
|
||||||
async def _prepare_prediction(self, model: str):
|
async def _prepare_prediction(self, model: str):
|
||||||
logging.info('Getting PR diff...')
|
get_logger().info('Getting PR diff...')
|
||||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||||
logging.info('Getting AI prediction...')
|
get_logger().info('Getting AI prediction...')
|
||||||
self.prediction = await self._get_prediction(model)
|
self.prediction = await self._get_prediction(model)
|
||||||
|
|
||||||
async def _get_prediction(self, model: str):
|
async def _get_prediction(self, model: str):
|
||||||
@ -77,8 +77,8 @@ class PRUpdateChangelog:
|
|||||||
system_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.system).render(variables)
|
system_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.system).render(variables)
|
||||||
user_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.user).render(variables)
|
user_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.user).render(variables)
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"\nSystem prompt:\n{system_prompt}")
|
get_logger().info(f"\nSystem prompt:\n{system_prompt}")
|
||||||
logging.info(f"\nUser prompt:\n{user_prompt}")
|
get_logger().info(f"\nUser prompt:\n{user_prompt}")
|
||||||
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
response, finish_reason = await self.ai_handler.chat_completion(model=model, temperature=0.2,
|
||||||
system=system_prompt, user=user_prompt)
|
system=system_prompt, user=user_prompt)
|
||||||
|
|
||||||
@ -100,7 +100,7 @@ class PRUpdateChangelog:
|
|||||||
"\n>'/update_changelog --pr_update_changelog.push_changelog_changes=true'\n"
|
"\n>'/update_changelog --pr_update_changelog.push_changelog_changes=true'\n"
|
||||||
|
|
||||||
if get_settings().config.verbosity_level >= 2:
|
if get_settings().config.verbosity_level >= 2:
|
||||||
logging.info(f"answer:\n{answer}")
|
get_logger().info(f"answer:\n{answer}")
|
||||||
|
|
||||||
return new_file_content, answer
|
return new_file_content, answer
|
||||||
|
|
||||||
@ -149,7 +149,7 @@ Example:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.changelog_file_str = ""
|
self.changelog_file_str = ""
|
||||||
if self.commit_changelog:
|
if self.commit_changelog:
|
||||||
logging.info("No CHANGELOG.md file found in the repository. Creating one...")
|
get_logger().info("No CHANGELOG.md file found in the repository. Creating one...")
|
||||||
changelog_file = self.git_provider.repo_obj.create_file(path="CHANGELOG.md",
|
changelog_file = self.git_provider.repo_obj.create_file(path="CHANGELOG.md",
|
||||||
message='add CHANGELOG.md',
|
message='add CHANGELOG.md',
|
||||||
content="",
|
content="",
|
||||||
|
@ -13,11 +13,13 @@ atlassian-python-api==3.39.0
|
|||||||
GitPython==3.1.32
|
GitPython==3.1.32
|
||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
starlette-context==0.3.6
|
starlette-context==0.3.6
|
||||||
litellm~=0.1.574
|
litellm==0.12.5
|
||||||
boto3==1.28.25
|
boto3==1.28.25
|
||||||
google-cloud-storage==2.10.0
|
google-cloud-storage==2.10.0
|
||||||
ujson==5.8.0
|
ujson==5.8.0
|
||||||
azure-devops==7.1.0b3
|
azure-devops==7.1.0b3
|
||||||
msrest==0.7.1
|
msrest==0.7.1
|
||||||
pinecone-client
|
pinecone-client
|
||||||
pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main
|
pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main
|
||||||
|
loguru==0.7.2
|
||||||
|
google-cloud-aiplatform==1.35.0
|
||||||
|
@ -43,18 +43,6 @@ class TestHandlePatchDeletions:
|
|||||||
assert handle_patch_deletions(patch, original_file_content_str, new_file_content_str,
|
assert handle_patch_deletions(patch, original_file_content_str, new_file_content_str,
|
||||||
file_name) == patch.rstrip()
|
file_name) == patch.rstrip()
|
||||||
|
|
||||||
# Tests that handle_patch_deletions logs a message when verbosity_level is greater than 0
|
|
||||||
def test_handle_patch_deletions_happy_path_verbosity_level_greater_than_0(self, caplog):
|
|
||||||
patch = '--- a/file.py\n+++ b/file.py\n@@ -1,2 +1,2 @@\n-foo\n-bar\n+baz\n'
|
|
||||||
original_file_content_str = 'foo\nbar\n'
|
|
||||||
new_file_content_str = ''
|
|
||||||
file_name = 'file.py'
|
|
||||||
get_settings().config.verbosity_level = 1
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO):
|
|
||||||
handle_patch_deletions(patch, original_file_content_str, new_file_content_str, file_name)
|
|
||||||
assert any("Processing file" in message for message in caplog.messages)
|
|
||||||
|
|
||||||
# Tests that handle_patch_deletions returns 'File was deleted' when new_file_content_str is empty
|
# Tests that handle_patch_deletions returns 'File was deleted' when new_file_content_str is empty
|
||||||
def test_handle_patch_deletions_edge_case_new_file_content_empty(self):
|
def test_handle_patch_deletions_edge_case_new_file_content_empty(self):
|
||||||
patch = '--- a/file.py\n+++ b/file.py\n@@ -1,2 +1,2 @@\n-foo\n-bar\n'
|
patch = '--- a/file.py\n+++ b/file.py\n@@ -1,2 +1,2 @@\n-foo\n-bar\n'
|
||||||
|
31
tests/unittest/try_fix_yaml.py
Normal file
31
tests/unittest/try_fix_yaml.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
# Generated by CodiumAI
|
||||||
|
from pr_agent.algo.utils import try_fix_yaml
|
||||||
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
class TestTryFixYaml:
|
||||||
|
|
||||||
|
# The function successfully parses a valid YAML string.
|
||||||
|
def test_valid_yaml(self):
|
||||||
|
review_text = "key: value\n"
|
||||||
|
expected_output = {"key": "value"}
|
||||||
|
assert try_fix_yaml(review_text) == expected_output
|
||||||
|
|
||||||
|
# The function adds '|-' to 'relevant line:' if it is not already present and successfully parses the YAML string.
|
||||||
|
def test_add_relevant_line(self):
|
||||||
|
review_text = "relevant line: value: 3\n"
|
||||||
|
expected_output = {"relevant line": "value: 3"}
|
||||||
|
assert try_fix_yaml(review_text) == expected_output
|
||||||
|
|
||||||
|
# The function removes the last line(s) of the YAML string and successfully parses the YAML string.
|
||||||
|
def test_remove_last_line(self):
|
||||||
|
review_text = "key: value\nextra invalid line\n"
|
||||||
|
expected_output = {"key": "value"}
|
||||||
|
assert try_fix_yaml(review_text) == expected_output
|
||||||
|
|
||||||
|
# The YAML string is empty.
|
||||||
|
def test_empty_yaml_fixed(self):
|
||||||
|
review_text = ""
|
||||||
|
assert try_fix_yaml(review_text) is None
|
Reference in New Issue
Block a user