mirror of
https://github.com/qodo-ai/pr-agent.git
synced 2025-07-06 13:50:44 +08:00
Compare commits
280 Commits
v0.12
...
ok/azure_o
Author | SHA1 | Date | |
---|---|---|---|
96bccb3156 | |||
234a4f352d | |||
eed23a7aaa | |||
248c6b13be | |||
7e07347b99 | |||
aa9dbf7111 | |||
0709d5f663 | |||
c372c71514 | |||
b3fd05c465 | |||
68fab0bab9 | |||
f1bd67b7e9 | |||
5500d35856 | |||
5880221d00 | |||
c031653f68 | |||
5949a794c2 | |||
49dda61642 | |||
488caf2a90 | |||
9a0288250d | |||
917bdd5cb8 | |||
aabe96c7ff | |||
db796416d9 | |||
35315c070f | |||
7d081aa1d1 | |||
e589dcb489 | |||
60bd57538a | |||
a6cdd72966 | |||
a86a3f52f0 | |||
2340f95488 | |||
dd4dc4b761 | |||
6a51a646ee | |||
8d498cd70c | |||
d5e72c2183 | |||
b09b936b15 | |||
6f22e5f557 | |||
dbe772e708 | |||
9c7ac125e1 | |||
a8c5ac10b6 | |||
d54c5574a1 | |||
047c370683 | |||
07f507c442 | |||
39538c5356 | |||
0c654b3b64 | |||
92d3627de0 | |||
4316d00941 | |||
edc9d8944e | |||
910c56c851 | |||
ab29cf2b30 | |||
540433b82c | |||
60a37158b1 | |||
4921c26432 | |||
34fe2721fb | |||
8bdc90c0f7 | |||
77831c793d | |||
5a2b5d97a0 | |||
86c7c495f2 | |||
4b8fe84f88 | |||
8843f7bc8b | |||
64feef585a | |||
ffe4512b7d | |||
a5cb35418e | |||
8594c93186 | |||
116dd75a14 | |||
85cdf05ca8 | |||
7c9a389abf | |||
18472492bc | |||
9002dccf6b | |||
3ad53a34cd | |||
29714f9bd7 | |||
f921b5e9b9 | |||
5f9969f30c | |||
0430f68d39 | |||
7f7045fd8a | |||
2dfddd8cea | |||
bc88e0492f | |||
a15d4f7a94 | |||
4258ce165b | |||
39e2b02933 | |||
1275cf0123 | |||
5ab69af5a7 | |||
118c9addaa | |||
dad3d3429f | |||
984a2888ae | |||
8252b98bf5 | |||
34e421f79b | |||
877796b539 | |||
df3a463668 | |||
3bcf085f61 | |||
f3a712683a | |||
51ce484bab | |||
214f65902c | |||
4d8c38e5e1 | |||
2242f73661 | |||
2f3171e422 | |||
90599f53d4 | |||
b878f64793 | |||
4242e157ab | |||
6df2a6769e | |||
dadb760aae | |||
85492f20fa | |||
8b76eb1014 | |||
adc5709b29 | |||
b884920ef2 | |||
4ebac16ff7 | |||
ee59d34e39 | |||
e3dba12fea | |||
42bcda1eb8 | |||
1b214114ec | |||
764aa8d679 | |||
4d0f691b64 | |||
048d90623f | |||
7c87c9d3a5 | |||
8ffdaf00c1 | |||
a94b5c6682 | |||
fc7b267c9a | |||
e291bd352e | |||
f08ce53de3 | |||
34797fe809 | |||
f3c1c61c2e | |||
0f1614bedc | |||
a41a427b58 | |||
b1dfd905c4 | |||
dd5386e07e | |||
275f0d6a05 | |||
0e3417b4ab | |||
d0a86ab684 | |||
1348a67cd2 | |||
7b98db20d5 | |||
a4467a5773 | |||
4a0b12c036 | |||
6eca495801 | |||
5c8160444a | |||
82ba285395 | |||
2be0339a27 | |||
3e7f83ffdb | |||
8d6c6a35db | |||
34e89e45bd | |||
c3ff5c46a6 | |||
345f923569 | |||
0f815876e5 | |||
d47a840179 | |||
3770704db7 | |||
9cc147dda0 | |||
787b82d888 | |||
20483d63b7 | |||
36aa22bd18 | |||
d9775e6b8c | |||
28e8707c1b | |||
687ece1e86 | |||
0515b80247 | |||
ed5856493c | |||
e9382b18b6 | |||
a70e5c9e1f | |||
9991fde864 | |||
163132dd6d | |||
8b27559510 | |||
688cb374f6 | |||
e821ba2a5b | |||
d371bff0ba | |||
7b15101051 | |||
5918943959 | |||
cd8a40c7a6 | |||
2b12042a85 | |||
9e3b79b21b | |||
c6cb0524b4 | |||
481a4fe7a1 | |||
de4af313ba | |||
b402bd5591 | |||
cb3ebd9169 | |||
c98e736e3b | |||
40fbd55da4 | |||
3eef0a4ebd | |||
6712c0a7f8 | |||
cfe794947d | |||
24dd57e5b7 | |||
8ed98c8a4f | |||
fff52e9e26 | |||
4947c6b841 | |||
433b8d24b8 | |||
bd88c66717 | |||
d2ad8b1dbd | |||
1053fa84f6 | |||
b833d63468 | |||
70b83bac78 | |||
54a989d30f | |||
480a890741 | |||
2f327c26e8 | |||
9ff62dce08 | |||
e8c2ec034d | |||
bbd0d62c85 | |||
8fa058ff7f | |||
34378384da | |||
95344c7083 | |||
bc38fad4db | |||
076d8e7187 | |||
22d0c275d7 | |||
a168defd28 | |||
b7a522ed69 | |||
86d4a31eef | |||
9a54be5414 | |||
d0958022a0 | |||
ec2aab805d | |||
47060ddcac | |||
60d6fecd37 | |||
bdbb101183 | |||
8a677e07a2 | |||
3f42bb6793 | |||
3420c6cf79 | |||
159e2f7dd6 | |||
67fde2c17e | |||
0e08520c0c | |||
6c500413f1 | |||
73d2b1565d | |||
a40643bbba | |||
d93a24bbf7 | |||
79dac3f419 | |||
c75413fac5 | |||
4e386153ea | |||
8800bad658 | |||
cfb576d1ae | |||
7c9b65ba65 | |||
ba854c228b | |||
d8ea2731ea | |||
078e87139a | |||
bf11033349 | |||
01fbebfc5e | |||
b660badd09 | |||
796e203c01 | |||
6837e43114 | |||
555151602f | |||
f74b35fb6f | |||
f8e1bd3d4c | |||
e01025706a | |||
5af9e8e749 | |||
24c575737c | |||
4175e8c467 | |||
77e7463395 | |||
2e1462580f | |||
fa077dc516 | |||
a3f4c44632 | |||
4447110118 | |||
c2088b7752 | |||
ddb89a7474 | |||
e4f177908b | |||
b077873c3d | |||
a7ce2b11b4 | |||
ef1b0ce3e3 | |||
ea3a34be08 | |||
9a26ed7c43 | |||
5d81f31ccc | |||
92d2f484d2 | |||
91e77787c0 | |||
034c654bac | |||
94e2f00c06 | |||
2bc398f74e | |||
1c9bd3e9a8 | |||
8a04a4f481 | |||
3e96812c5d | |||
b190b1879e | |||
9d3e6620c1 | |||
4636c8a1fa | |||
3773303bfd | |||
a126ef64fc | |||
c1c7b3b6da | |||
2b6e8c3f09 | |||
d4e78118fe | |||
cce3c70369 | |||
32e8ba331a | |||
3f2a7869dd | |||
2ee329674f | |||
e104bd7a3f | |||
3e128869dc | |||
cf2ed9d483 | |||
e1b0e4a40a | |||
1013f7586b | |||
023f2eb77c | |||
cb8ff2b318 | |||
d04d8b616a | |||
2816cd2c4b | |||
2112defa51 | |||
9579be028d |
@ -1,5 +1,6 @@
|
||||
[pr_reviewer]
|
||||
enable_review_labels_effort = true
|
||||
enable_auto_approval = true
|
||||
|
||||
|
||||
[pr_code_suggestions]
|
||||
|
38
INSTALL.md
38
INSTALL.md
@ -14,7 +14,6 @@ There are several ways to use PR-Agent:
|
||||
|
||||
**GitHub specific methods**
|
||||
- [Run as a GitHub Action](INSTALL.md#run-as-a-github-action)
|
||||
- [Run as a polling server](INSTALL.md#run-as-a-polling-server)
|
||||
- [Run as a GitHub App](INSTALL.md#run-as-a-github-app)
|
||||
- [Deploy as a Lambda Function](INSTALL.md#deploy-as-a-lambda-function)
|
||||
- [AWS CodeCommit](INSTALL.md#aws-codecommit-setup)
|
||||
@ -124,7 +123,16 @@ You can use our pre-built Github Action Docker image to run PR-Agent as a Github
|
||||
```yaml
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- review_requested
|
||||
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
runs-on: ubuntu-latest
|
||||
@ -145,7 +153,16 @@ jobs:
|
||||
```yaml
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- review_requested
|
||||
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
@ -186,19 +203,6 @@ When you open your next PR, you should see a comment from `github-actions` bot w
|
||||
|
||||
---
|
||||
|
||||
### Run as a polling server
|
||||
Request reviews by tagging your GitHub user on a PR
|
||||
|
||||
Follow [steps 1-3](#run-as-a-github-action) of the GitHub Action setup.
|
||||
|
||||
Run the following command to start the server:
|
||||
|
||||
```
|
||||
python pr_agent/servers/github_polling.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Run as a GitHub App
|
||||
Allowing you to automate the review process on your private or public repositories.
|
||||
|
||||
@ -383,14 +387,14 @@ PYTHONPATH="/PATH/TO/PROJECTS/pr-agent" python pr_agent/cli.py \
|
||||
```
|
||||
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](#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. Be sure to set the target to `gitlab_webhook` instead of `github_app` when building the Docker image.
|
||||
4. In the secrets file, fill in the following:
|
||||
- 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.
|
||||
- Set deployment_type to 'gitlab' in [configuration.toml](./pr_agent/settings/configuration.toml)
|
||||
5. Create a webhook in GitLab. Set the URL to the URL of your app's server. Set the secret token to the generated secret from step 2.
|
||||
5. Create a webhook in GitLab. Set the URL to the URL of your app's server with the path `/webhook` (e.g. `http://pr-agent.example.com:3000/webhook`). Set the secret token to the generated secret from step 2.
|
||||
In the "Trigger" section, check the ‘comments’ and ‘merge request events’ boxes.
|
||||
6. Test your installation by opening a merge request or commenting or a merge request using one of CodiumAI's commands.
|
||||
6. Test your installation by opening a merge request or commenting on a merge request using one of CodiumAI's commands.
|
||||
|
||||
|
||||
|
||||
|
185
README.md
185
README.md
@ -31,119 +31,133 @@ Making pull requests less painful with an AI agent
|
||||
- [Why use PR-Agent?](#why-use-pr-agent)
|
||||
|
||||
## News and Updates
|
||||
### Jan 28, 2024
|
||||
- 💎 Test - A new tool, [`/test component_name`](https://github.com/Codium-ai/pr-agent/blob/main/docs/TEST.md), was added to PR-Agent Pro. The tool will generate tests for a selected component, based on the PR code changes.
|
||||
- 💎 Analyze - The [`/analyze`](https://github.com/Codium-ai/pr-agent/blob/main/docs/Analyze.md) tool was updated and simplified. It now presents a summary of the code components that were changed in the PR.
|
||||
### Jan 21, 2024
|
||||
- 💎 Custom suggestions - A new tool, `/custom_suggestions`, was added to PR-Agent Pro. The tool will propose only suggestions that follow specific guidelines defined by the user.
|
||||
See [here](https://github.com/Codium-ai/pr-agent/blob/main/docs/CUSTOM_SUGGESTIONS.md) for more details.
|
||||
|
||||
### Jan 17, 2024
|
||||
- 💎 Inline file summary - The `describe` tool has a new option `--pr_description.inline_file_summary`, which allows to add a summary of each file changes to the Diffview page. See [here](https://github.com/Codium-ai/pr-agent/blob/main/docs/DESCRIBE.md#inline-file-summary-)
|
||||
- The `improve` tool can now present suggestions in a nice collapsible format, which significantly reduces the PR footprint. See [here](https://github.com/Codium-ai/pr-agent/blob/main/docs/IMPROVE.md#summarized-vs-commitable-code-suggestions) for more details.
|
||||
- To accompany the improved interface of the `improve` tool, we change the [default automation settings](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L116) of our GithupApp to:
|
||||
```
|
||||
pr_commands = [
|
||||
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||
"/review --pr_reviewer.num_code_suggestions=0",
|
||||
"/improve --pr_code_suggestions.summarize=true",
|
||||
]
|
||||
```
|
||||
Meaning that by default, for each PR the `describe`, `review`, and `improve` tools will be triggered automatically, and the `improve` tool will present the suggestions in a single comment.
|
||||
You can of course overwrite these defaults by adding a `.pr_agent.toml` file to your repo. See [here](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#working-with-github-app).
|
||||
### Feb 29, 2024
|
||||
- You can now use the repo's [wiki page](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#configuration-options) to set configurations for PR-Agent 💎
|
||||
|
||||
### Jan 10, 2024
|
||||
[LanceDB](https://lancedb.com/) is now supported as a locally hosted VectorDB for the `similar_issue` tool. See [here](./docs/SIMILAR_ISSUE.md) for more details.
|
||||
<kbd><img src="https://codium.ai/images/pr_agent/wiki_configuration.png" width="512"></kbd>
|
||||
|
||||
|
||||
### Feb 21, 2024
|
||||
- Added a new command, `/help`, to easily provide a list of available tools and their descriptions, and run them interactively.
|
||||
|
||||
<kbd>
|
||||
|
||||
<img src="https://www.codium.ai/images/pr_agent/help.png" width="512">
|
||||
|
||||
</kbd>
|
||||
|
||||
### Feb 18, 2024
|
||||
- Introducing the `CI Feedback` tool 💎. The tool automatically triggers when a PR has a failed check. It analyzes the failed check, and provides summarized logs and analysis. Note that this feature requires read access to GitHub 'checks' and 'actions'. See [here](./docs/CI_FEEDBACK.md) for more details.
|
||||
- New ability - you can run `/ask` on specific lines of code in the PR from the PR's diff view. See [here](./docs/ASK.md#ask-lines) for more details.
|
||||
- Introducing support for [Azure DevOps Webhooks](./Usage.md#azure-devops-webhook), as well as bug fixes and improved support for several ADO commands.
|
||||
|
||||
|
||||
## Overview
|
||||
<div style="text-align:left;">
|
||||
|
||||
CodiumAI PR-Agent is an open-source tool to help efficiently review and handle pull requests. It automatically analyzes the pull request and can provide several types of commands:
|
||||
CodiumAI PR-Agent is an open-source tool to help efficiently review and handle pull requests.
|
||||
|
||||
| | | GitHub | Gitlab | Bitbucket |
|
||||
|-------|------------------------------------------------------------------------------------------------------------------------------------------|:------:|:------:|:---------:|
|
||||
| TOOLS | Review | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | ⮑ Incremental | :white_check_mark: | | |
|
||||
| | ⮑ [SOC2 Compliance](https://github.com/Codium-ai/pr-agent/blob/main/docs/REVIEW.md#soc2-ticket-compliance-) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Describe | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | ⮑ [Inline File Summary](https://github.com/Codium-ai/pr-agent/blob/main/docs/DESCRIBE.md#inline-file-summary-) 💎 | :white_check_mark: | | |
|
||||
| | Improve | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | ⮑ Extended | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Ask | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [Custom Suggestions](https://github.com/Codium-ai/pr-agent/blob/main/docs/CUSTOM_SUGGESTIONS.md) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [Test](https://github.com/Codium-ai/pr-agent/blob/main/docs/TEST.md) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Reflect and Review | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Update CHANGELOG.md | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Find Similar Issue | :white_check_mark: | | |
|
||||
| | [Add PR Documentation](https://github.com/Codium-ai/pr-agent/blob/main/docs/ADD_DOCUMENTATION.md) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [Custom Labels](https://github.com/Codium-ai/pr-agent/blob/main/docs/DESCRIBE.md#handle-custom-labels-from-the-repos-labels-page-gem) 💎 | :white_check_mark: | :white_check_mark: | |
|
||||
| | [Analyze](https://github.com/Codium-ai/pr-agent/blob/main/docs/Analyze.md) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | | | | |
|
||||
| USAGE | CLI | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | App / webhook | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Tagging bot | :white_check_mark: | | |
|
||||
| | Actions | :white_check_mark: | | :white_check_mark: |
|
||||
| | | | | |
|
||||
| CORE | PR compression | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Repo language prioritization | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Adaptive and token-aware<br />file patch fitting | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Multiple models support | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [Static code analysis](https://github.com/Codium-ai/pr-agent/blob/main/docs/Analyze.md) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [Global configuration](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#global-configuration-file-) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
- See the [Installation Guide](./INSTALL.md) for instructions on installing and running the tool on different git platforms.
|
||||
|
||||
- See the [Usage Guide](./Usage.md) for instructions on running the PR-Agent commands via different interfaces, including _CLI_, _online usage_, or by _automatically triggering_ them when a new PR is opened.
|
||||
|
||||
- See the [Tools Guide](./docs/TOOLS_GUIDE.md) for a detailed description of the different tools (tools are run via the commands).
|
||||
|
||||
Supported commands per platform:
|
||||
|
||||
| | | GitHub | Gitlab | Bitbucket | Azure DevOps |
|
||||
|-------|-----------------------------------------------------------------------------------------------------------------------------------------|:--------------------:|:--------------------:|:--------------------:|:--------------------:|
|
||||
| TOOLS | Review | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | ⮑ Incremental | :white_check_mark: | | | |
|
||||
| | ⮑ [SOC2 Compliance](https://github.com/Codium-ai/pr-agent/blob/main/docs/REVIEW.md#soc2-ticket-compliance-) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Describe | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | ⮑ [Inline File Summary](https://github.com/Codium-ai/pr-agent/blob/main/docs/DESCRIBE.md#inline-file-summary-) 💎 | :white_check_mark: | | | |
|
||||
| | Improve | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | ⮑ Extended | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Ask | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | ⮑ [Ask on code lines](./docs/ASK.md#ask-lines) | :white_check_mark: | :white_check_mark: | | |
|
||||
| | [Custom Suggestions](https://github.com/Codium-ai/pr-agent/blob/main/docs/CUSTOM_SUGGESTIONS.md) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [Test](https://github.com/Codium-ai/pr-agent/blob/main/docs/TEST.md) 💎 | :white_check_mark: | :white_check_mark: | | :white_check_mark: |
|
||||
| | Reflect and Review | :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: | | | |
|
||||
| | [Add PR Documentation](https://github.com/Codium-ai/pr-agent/blob/main/docs/ADD_DOCUMENTATION.md) 💎 | :white_check_mark: | :white_check_mark: | | :white_check_mark: |
|
||||
| | [Custom Labels](https://github.com/Codium-ai/pr-agent/blob/main/docs/DESCRIBE.md#handle-custom-labels-from-the-repos-labels-page-gem) 💎 | :white_check_mark: | :white_check_mark: | | :white_check_mark: |
|
||||
| | [Analyze](https://github.com/Codium-ai/pr-agent/blob/main/docs/Analyze.md) 💎 | :white_check_mark: | :white_check_mark: | | :white_check_mark: |
|
||||
| | [CI Feedback](https://github.com/Codium-ai/pr-agent/blob/main/docs/CI_FEEDBACK.md) 💎 | :white_check_mark: | | | |
|
||||
| | | | | | |
|
||||
| USAGE | CLI | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | App / webhook | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Tagging bot | :white_check_mark: | | | |
|
||||
| | Actions | :white_check_mark: | | :white_check_mark: | |
|
||||
| | | | | | |
|
||||
| CORE | PR compression | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Repo language prioritization | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Adaptive and token-aware file patch fitting | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Multiple models support | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [Static code analysis](https://github.com/Codium-ai/pr-agent/blob/main/docs/Analyze.md) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [Global and wiki configurations](./Usage.md#configuration) 💎 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | [PR Actions](https://www.codium.ai/images/pr_agent/pr-actions.mp4) 💎 | :white_check_mark: | | | |
|
||||
- 💎 means this feature is available only in [PR-Agent Pro](https://www.codium.ai/pricing/)
|
||||
- Support for additional git providers is described in [here](./docs/Full_environments.md)
|
||||
___
|
||||
|
||||
‣ **Auto Description ([`/describe`](./docs/DESCRIBE.md))**: Automatically generating PR description - title, type, summary, code walkthrough and labels.
|
||||
\
|
||||
‣ **Auto Review ([`/review`](./docs/REVIEW.md))**: Adjustable feedback about the PR main theme, type, relevant tests, security issues, score, and various suggestions for the PR content.
|
||||
‣ **Auto Review ([`/review`](./docs/REVIEW.md))**: Adjustable feedback about the PR, possible issues, security concerns, review effort and more.
|
||||
\
|
||||
‣ **Question Answering ([`/ask ...`](./docs/ASK.md))**: Answering free-text questions about the PR.
|
||||
\
|
||||
‣ **Code Suggestions ([`/improve`](./docs/IMPROVE.md))**: Committable code suggestions for improving the PR.
|
||||
‣ **Code Suggestions ([`/improve`](./docs/IMPROVE.md))**: Code suggestions for improving the PR.
|
||||
\
|
||||
‣ **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.
|
||||
\
|
||||
‣ **Add Documentation 💎 ([`/add_docs`](./docs/ADD_DOCUMENTATION.md))**: Automatically adds documentation to methods/functions/classes that changed in the PR.
|
||||
‣ **Add Documentation 💎 ([`/add_docs`](./docs/ADD_DOCUMENTATION.md))**: Generates documentation to methods/functions/classes that changed in the PR.
|
||||
\
|
||||
‣ **Generate Custom Labels 💎 ([`/generate_labels`](./docs/GENERATE_CUSTOM_LABELS.md))**: Automatically suggests custom labels based on the PR code changes.
|
||||
‣ **Generate Custom Labels 💎 ([`/generate_labels`](./docs/GENERATE_CUSTOM_LABELS.md))**: Generates custom labels for the PR, based on specific guidelines defined by the user.
|
||||
\
|
||||
‣ **Analyze 💎 ([`/analyze`](./docs/Analyze.md))**: Automatically analyzes the PR, and presents changes walkthrough for each component.
|
||||
‣ **Analyze 💎 ([`/analyze`](./docs/Analyze.md))**: Identify code components that changed in the PR, and enables to interactively generate tests, docs, and code suggestions for each component.
|
||||
\
|
||||
‣ **Custom Suggestions 💎 ([`/custom_suggestions`](./docs/CUSTOM_SUGGESTIONS.md))**: Automatically generates custom suggestions for improving the PR code, based on specific guidelines defined by the user.
|
||||
\
|
||||
‣ **Generate Tests 💎 ([`/test component_name`](./docs/TEST.md))**: Automatically generates unit tests for a selected component, based on the PR code changes.
|
||||
|
||||
See the [Installation Guide](./INSTALL.md) for instructions on installing and running the tool on different git platforms.
|
||||
|
||||
See the [Usage Guide](./Usage.md) for running the PR-Agent commands via different interfaces, including _CLI_, _online usage_, or by _automatically triggering_ them when a new PR is opened.
|
||||
|
||||
See the [Tools Guide](./docs/TOOLS_GUIDE.md) for a detailed description of the different tools (tools are run via the commands).
|
||||
|
||||
\
|
||||
‣ **CI Feedback 💎 ([`/checks ci_job`](./docs/CI_FEEDBACK.md))**: Automatically generates feedback and analysis for a failed CI job.
|
||||
___
|
||||
|
||||
## Example results
|
||||
</div>
|
||||
<h4><a href="https://github.com/Codium-ai/pr-agent/pull/530">/describe</a></h4>
|
||||
<div align="center">
|
||||
<p float="center">
|
||||
<img src="https://www.codium.ai/images/pr_agent/describe_new_short_main.png" width="800">
|
||||
<img src="https://www.codium.ai/images/pr_agent/describe_new_short_main.png" width="512">
|
||||
</p>
|
||||
</div>
|
||||
<hr>
|
||||
<h4><a href="https://github.com/Codium-ai/pr-agent/pull/472#discussion_r1435819374">/improve</a></h4>
|
||||
|
||||
<h4><a href="https://github.com/Codium-ai/pr-agent/pull/732#issuecomment-1975099151">/review</a></h4>
|
||||
<div align="center">
|
||||
<p float="center">
|
||||
<kbd>
|
||||
<img src="https://www.codium.ai/images/pr_agent/improve_short_main.png" width="768">
|
||||
<img src="https://www.codium.ai/images/pr_agent/review_new_short_main.png" width="512">
|
||||
</kbd>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<h4><a href="https://github.com/Codium-ai/pr-agent/pull/732#issuecomment-1975099159">/improve</a></h4>
|
||||
<div align="center">
|
||||
<p float="center">
|
||||
<kbd>
|
||||
<img src="https://www.codium.ai/images/pr_agent/improve_new_short_main.png" width="512">
|
||||
</kbd>
|
||||
</p>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<h4><a href="https://github.com/Codium-ai/pr-agent/pull/530">/generate_labels</a></h4>
|
||||
<div align="center">
|
||||
<p float="center">
|
||||
@ -218,31 +232,34 @@ To use your own version of PR-Agent, you first need to acquire two tokens:
|
||||
|
||||
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)
|
||||
- [Method 2: Run from source](INSTALL.md#method-2-run-from-source)
|
||||
- [Method 3: Run as a GitHub Action](INSTALL.md#method-3-run-as-a-github-action)
|
||||
- [Method 4: Run as a polling server](INSTALL.md#method-4-run-as-a-polling-server)
|
||||
- Request reviews by tagging your GitHub user on a PR
|
||||
- [Method 5: Run as a GitHub App](INSTALL.md#method-5-run-as-a-github-app)
|
||||
- Allowing you to automate the review process on your private or public repositories
|
||||
- [Method 6: Deploy as a Lambda Function](INSTALL.md#method-6---deploy-as-a-lambda-function)
|
||||
- [Method 7: AWS CodeCommit](INSTALL.md#method-7---aws-codecommit-setup)
|
||||
- [Method 8: Run a GitLab webhook server](INSTALL.md#method-8---run-a-gitlab-webhook-server)
|
||||
- [Method 9: Run as a Bitbucket Pipeline](INSTALL.md#method-9-run-as-a-bitbucket-pipeline)
|
||||
**Locally**
|
||||
- [Use Docker image (no installation required)](./INSTALL.md#use-docker-image-no-installation-required)
|
||||
- [Run from source](./INSTALL.md#run-from-source)
|
||||
|
||||
**GitHub specific methods**
|
||||
- [Run as a GitHub Action](./INSTALL.md#run-as-a-github-action)
|
||||
- [Run as a GitHub App](./INSTALL.md#run-as-a-github-app)
|
||||
|
||||
**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)
|
||||
|
||||
## PR-Agent Pro 💎
|
||||
[PR-Agent Pro](https://www.codium.ai/pricing/) is a hosted version of PR-Agent, provided by CodiumAI. It is available for a monthly fee, and provides the following benefits:
|
||||
1. **Fully managed** - We take care of everything for you - hosting, models, regular updates, and more. Installation is as simple as signing up and adding the PR-Agent app to your GitHub\BitBucket repo.
|
||||
2. **Improved privacy** - No data will be stored or used to train models. PR-Agent Pro will employ zero data retention, and will use an OpenAI account with zero data retention.
|
||||
3. **Improved support** - PR-Agent Pro users will receive priority support, and will be able to request new features and capabilities.
|
||||
4. **Extra features** -In addition to the benefits listed above, PR-Agent Pro will emphasize more customization, and the usage of static code analysis, in addition to LLM logic, to improve results. It has the following additional features:
|
||||
- [**SOC2 compliance check**](https://github.com/Codium-ai/pr-agent/blob/main/docs/REVIEW.md#soc2-ticket-compliance-)
|
||||
4. **Extra features** -In addition to the benefits listed above, PR-Agent Pro will emphasize more customization, and the usage of static code analysis, in addition to LLM logic, to improve results. It has the following additional tools and features:
|
||||
- [**Analyze PR components**](https://github.com/Codium-ai/pr-agent/blob/main/docs/Analyze.md)
|
||||
- [**Custom Code Suggestions**](https://github.com/Codium-ai/pr-agent/blob/main/docs/CUSTOM_SUGGESTIONS.md)
|
||||
- [**Tests**](https://github.com/Codium-ai/pr-agent/blob/main/docs/TEST.md)
|
||||
- [**PR documentation**](https://github.com/Codium-ai/pr-agent/blob/main/docs/ADD_DOCUMENTATION.md)
|
||||
- [**SOC2 compliance check**](https://github.com/Codium-ai/pr-agent/blob/main/docs/REVIEW.md#soc2-ticket-compliance-)
|
||||
- [**Custom labels**](https://github.com/Codium-ai/pr-agent/blob/main/docs/DESCRIBE.md#handle-custom-labels-from-the-repos-labels-page-gem)
|
||||
- [**Global configuration**](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#global-configuration-file-)
|
||||
- [**Analyze PR components**](https://github.com/Codium-ai/pr-agent/blob/main/docs/Analyze.md)
|
||||
- **Custom Code Suggestions** [WIP]
|
||||
- **Chat on Specific Code Lines** [WIP]
|
||||
|
||||
|
||||
|
||||
## How it works
|
||||
|
248
Usage.md
248
Usage.md
@ -2,14 +2,25 @@
|
||||
|
||||
### Table of Contents
|
||||
- [Introduction](#introduction)
|
||||
- [Local Repo (CLI)](#working-from-a-local-repo-cli)
|
||||
- [Online Usage](#online-usage)
|
||||
- [GitHub App](#working-with-github-app)
|
||||
- [GitHub Action](#working-with-github-action)
|
||||
- [BitBucket App](#working-with-bitbucket-self-hosted-app)
|
||||
- [Configuration Options](#configuration-options)
|
||||
- [Managing Mail Notifications](#managing-mail-notifications)
|
||||
- [Usage Types](#usage-types)
|
||||
- [Local Repo (CLI)](#working-from-a-local-repo-cli)
|
||||
- [Online Usage](#online-usage)
|
||||
- [GitHub App](#working-with-github-app)
|
||||
- [GitHub Action](#working-with-github-action)
|
||||
- [GitLab Webhook](#working-with-gitlab-webhook)
|
||||
- [BitBucket App](#working-with-bitbucket-self-hosted-app)
|
||||
- [Azure DevOps Provider](#azure-devops-provider)
|
||||
- [Additional Configurations Walkthrough](#appendix---additional-configurations-walkthrough)
|
||||
- [Ignoring files from analysis](#ignoring-files-from-analysis)
|
||||
- [Extra instructions](#extra-instructions)
|
||||
- [Working with large PRs](#working-with-large-prs)
|
||||
- [Changing a model](#changing-a-model)
|
||||
- [Patch Extra Lines](#patch-extra-lines)
|
||||
- [Editing the prompts](#editing-the-prompts)
|
||||
|
||||
### Introduction
|
||||
## Introduction
|
||||
|
||||
After [installation](/INSTALL.md), there are three basic ways to invoke CodiumAI PR-Agent:
|
||||
1. Locally running a CLI command
|
||||
@ -22,14 +33,46 @@ For online usage, you will need to setup either a [GitHub App](INSTALL.md#method
|
||||
GitHub App and GitHub Action also enable to run PR-Agent specific tool automatically when a new PR is opened.
|
||||
|
||||
|
||||
#### The configuration file
|
||||
- The different tools and sub-tools used by CodiumAI PR-Agent are adjustable via the **[configuration file](pr_agent/settings/configuration.toml)**.
|
||||
### git provider
|
||||
The [git_provider](pr_agent/settings/configuration.toml#L4) field in the configuration file determines the GIT provider that will be used by PR-Agent. Currently, the following providers are supported:
|
||||
`
|
||||
"github", "gitlab", "bitbucket", "azure", "codecommit", "local", "gerrit"
|
||||
`
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The different tools and sub-tools used by CodiumAI PR-Agent are adjustable via the **[configuration file](pr_agent/settings/configuration.toml)**.
|
||||
|
||||
In addition to general configuration options, each tool has its own configurations. For example, the `review` tool will use parameters from the [pr_reviewer](/pr_agent/settings/configuration.toml#L16) section in the configuration file.
|
||||
See the [Tools Guide](./docs/TOOLS_GUIDE.md) for a detailed description of the different tools and their configurations.
|
||||
|
||||
- The [Tools Guide](./docs/TOOLS_GUIDE.md) provides a detailed description of the different tools and their configurations.
|
||||
There are three ways to set persistent configurations:
|
||||
1. Wiki configuration page 💎
|
||||
2. Local configuration file
|
||||
3. Global configuration file 💎
|
||||
|
||||
In terms of precedence, wiki configurations will override local configurations, and local configurations will override global configurations.
|
||||
|
||||
- By uploading a local `.pr_agent.toml` file to the root of the repo's main branch, you can edit and customize any configuration parameter. Note that you need to upload `.pr_agent.toml` prior to creating a PR, in order for the configuration to take effect.
|
||||
### Wiki configuration file 💎
|
||||
|
||||
Specifically for GitHub, with PR-Agent-Pro you can set configurations by creating a page called `.pr_agent.toml` in the [wiki](https://github.com/Codium-ai/pr-agent/wiki/pr_agent.toml) of the repo.
|
||||
The advantage of this method is that it allows to set configurations without needing to commit new content to the repo - just edit the wiki page and **save**.
|
||||
|
||||
<kbd><img src="https://codium.ai/images/pr_agent/wiki_configuration.png" width="512"></kbd>
|
||||
|
||||
Click [here](https://codium.ai/images/pr_agent/wiki_configuration_pr_agent.mp4) to see a short instructional video. We recommend surrounding the configuration content with triple-quotes, to allow better presentation when displayed in the wiki as markdown.
|
||||
An example content:
|
||||
|
||||
\`\`\`<br>
|
||||
[pr_description] # /describe #<br>
|
||||
keep_original_user_title=false<br>
|
||||
\`\`\`
|
||||
|
||||
PR-Agent will know to remove the triple-quotes when reading the configuration content.
|
||||
|
||||
### Local configuration file
|
||||
|
||||
By uploading a local `.pr_agent.toml` file to the root of the repo's main branch, you can edit and customize any configuration parameter. Note that you need to upload `.pr_agent.toml` prior to creating a PR, in order for the configuration to take effect.
|
||||
|
||||
For example, if you set in `.pr_agent.toml`:
|
||||
|
||||
@ -45,7 +88,7 @@ extra_instructions="""\
|
||||
Then you can give a list of extra instructions to the `review` tool.
|
||||
|
||||
|
||||
#### Global configuration file 💎
|
||||
### Global configuration file 💎
|
||||
|
||||
If you create a repo called `pr-agent-settings` in your **organization**, it's configuration file `.pr_agent.toml` will be used as a global configuration file for any other repo that belongs to the same organization.
|
||||
Parameters from a local `.pr_agent.toml` file, in a specific repo, will override the global configuration parameters.
|
||||
@ -54,28 +97,19 @@ For example, in the GitHub organization `Codium-ai`:
|
||||
- The repo [`https://github.com/Codium-ai/pr-agent-settings`](https://github.com/Codium-ai/pr-agent-settings/blob/main/.pr_agent.toml) contains a `.pr_agent.toml` file that serves as a global configuration file for all the repos in the GitHub organization `Codium-ai`.
|
||||
- The repo [`https://github.com/Codium-ai/pr-agent`](https://github.com/Codium-ai/pr-agent/blob/main/.pr_agent.toml) inherits the global configuration file from `pr-agent-settings`.
|
||||
|
||||
#### Ignoring files from analysis
|
||||
In some cases, you may want to exclude specific files or directories from the analysis performed by CodiumAI PR-Agent. This can be useful, for example, when you have files that are generated automatically or files that shouldn't be reviewed, like vendored code.
|
||||
|
||||
To ignore files or directories, edit the **[ignore.toml](/pr_agent/settings/ignore.toml)** configuration file. This setting also exposes the following environment variables:
|
||||
## Managing mail notifications
|
||||
Unfortunately, it is not possible in GitHub to disable mail notifications from a specific user.
|
||||
If you are subscribed to notifications for a repo with PR-Agent, we recommend turning off notifications for PR comments, to avoid lengthy emails:
|
||||
|
||||
- `IGNORE.GLOB`
|
||||
- `IGNORE.REGEX`
|
||||
<kbd><img src="https://codium.ai/images/pr_agent/notifications.png" width="512"></kbd>
|
||||
|
||||
For example, to ignore python files in a PR with online usage, comment on a PR:
|
||||
`/review --ignore.glob=['*.py']`
|
||||
As an alternative, you can filter in your mail provider the notifications specifically from the PR-Agent bot:
|
||||
https://www.quora.com/How-can-you-filter-emails-for-specific-people-in-Gmail#:~:text=On%20the%20Filters%20and%20Blocked,the%20body%20of%20the%20email
|
||||
|
||||
To ignore python files in all PRs, set in a configuration file:
|
||||
```
|
||||
[ignore]
|
||||
glob = ['*.py']
|
||||
```
|
||||
<kbd><img src="https://codium.ai/images/pr_agent/filter_mail_notifications.png" width="512"></kbd>
|
||||
|
||||
#### git provider
|
||||
The [git_provider](pr_agent/settings/configuration.toml#L4) field in the configuration file determines the GIT provider that will be used by PR-Agent. Currently, the following providers are supported:
|
||||
`
|
||||
"github", "gitlab", "azure", "codecommit", "local", "gerrit"
|
||||
`
|
||||
## Usage Types
|
||||
|
||||
### Working from a local repo (CLI)
|
||||
When running from your local repo (CLI), your local configuration file will be used.
|
||||
@ -100,7 +134,7 @@ python -m pr_agent.cli --pr_url=<pr_url> /review --pr_reviewer.extra_instructio
|
||||
(2) You can print results locally, without publishing them, by setting in `configuration.toml`:
|
||||
```
|
||||
[config]
|
||||
publish_output=true
|
||||
publish_output=false
|
||||
verbosity_level=2
|
||||
```
|
||||
This is useful for debugging or experimenting with different tools.
|
||||
@ -128,23 +162,11 @@ Any configuration value in [configuration file](pr_agent/settings/configuration.
|
||||
|
||||
|
||||
### Working with GitHub App
|
||||
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 to the root of the repo's main branch, you can edit and customize any configuration parameter. Note that you need to upload `.pr_agent.toml` prior to creating a PR, in order for the configuration to take effect.
|
||||
#### GitHub app automatic tools when a new PR is opened
|
||||
|
||||
For example, if you set in `.pr_agent.toml`:
|
||||
|
||||
```
|
||||
[pr_reviewer]
|
||||
num_code_suggestions=1
|
||||
```
|
||||
|
||||
Then you will overwrite the default number of code suggestions to 1.
|
||||
|
||||
#### GitHub app automatic tools
|
||||
The [github_app](pr_agent/settings/configuration.toml#L108) section defines GitHub app specific configurations.
|
||||
|
||||
##### GitHub app automatic tools for PR actions
|
||||
The configuration parameter `pr_commands` defines the list of tools that will be **run automatically** when a new PR is opened.
|
||||
```
|
||||
[github_app]
|
||||
@ -157,7 +179,7 @@ pr_commands = [
|
||||
This means that when a new PR is opened/reopened or marked as ready for review, PR-Agent will run the `describe`, `review` and `improve` tools.
|
||||
For the `describe` tool, for example, 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 using one the three options for a [configuration file](#configuration-options): **wiki**, **local**, or **global**.
|
||||
For example, if your local `.pr_agent.toml` file contains:
|
||||
```
|
||||
[pr_description]
|
||||
@ -172,7 +194,15 @@ To cancel the automatic run of all the tools, set:
|
||||
handle_pr_actions = []
|
||||
```
|
||||
|
||||
##### GitHub app automatic tools for push actions (commits to an open PR)
|
||||
You can also disable automatic runs for PRs with specific titles, by setting the `ignore_pr_titles` parameter with the relevant regex. For example:
|
||||
```
|
||||
[github_app]
|
||||
ignore_pr_title = ["^[Auto]", ".*ignore.*"]
|
||||
```
|
||||
will ignore PRs with titles that start with "Auto" or contain the word "ignore".
|
||||
|
||||
#### GitHub app automatic tools for push actions (commits to an open PR)
|
||||
|
||||
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.
|
||||
|
||||
The configuration toggle `handle_push_trigger` can be used to enable this feature.
|
||||
@ -182,31 +212,10 @@ The configuration parameter `push_commands` defines the list of tools that will
|
||||
handle_push_trigger = true
|
||||
push_commands = [
|
||||
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||
"/review -i --pr_reviewer.remove_previous_review_comment=true",
|
||||
"/review --pr_reviewer.num_code_suggestions=0",
|
||||
]
|
||||
```
|
||||
This means that when new code is pushed to the PR, the PR-Agent will run the `describe` and incremental `review` tools.
|
||||
For the `describe` tool, the `add_original_user_description` and `keep_original_user_title` parameters will be set to true.
|
||||
For the `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 parameters by uploading a local configuration file to the root of your repo.
|
||||
|
||||
#### Editing the prompts
|
||||
The prompts for the various PR-Agent tools are defined in the `pr_agent/settings` folder.
|
||||
In practice, the prompts are loaded and stored as a standard setting object.
|
||||
Hence, editing them is similar to editing any other configuration value - just place the relevant key in `.pr_agent.toml`file, and override the default value.
|
||||
|
||||
For example, if you want to edit the prompts of the [describe](./pr_agent/settings/pr_description_prompts.toml) tool, you can add the following to your `.pr_agent.toml` file:
|
||||
```
|
||||
[pr_description_prompt]
|
||||
system="""
|
||||
...
|
||||
"""
|
||||
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).
|
||||
This means that when new code is pushed to the PR, the PR-Agent will run the `describe` and `review` tools, with the specified parameters.
|
||||
|
||||
### Working with GitHub Action
|
||||
`GitHub Action` is a different way to trigger PR-Agent tools, and uses a different configuration mechanism than `GitHub App`.
|
||||
@ -231,6 +240,17 @@ For example, you can set an environment variable: `pr_description.add_original_u
|
||||
add_original_user_description = false
|
||||
```
|
||||
|
||||
### Working with GitLab Webhook
|
||||
After setting up a GitLab webhook, to control which commands will run automatically when a new PR is opened, you can set the `pr_commands` parameter in the configuration file, similar to the GitHub App:
|
||||
```
|
||||
[gitlab]
|
||||
pr_commands = [
|
||||
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||
"/review --pr_reviewer.num_code_suggestions=0",
|
||||
"/improve",
|
||||
]
|
||||
```
|
||||
|
||||
### Working with BitBucket Self-Hosted App
|
||||
Similar to GitHub app, when running PR-Agent from BitBucket App, the default [configuration file](pr_agent/settings/configuration.toml) from a pre-built docker will be initially loaded.
|
||||
|
||||
@ -261,8 +281,71 @@ If not set, the default option is that only the `review` tool will run automatic
|
||||
Note that due to limitations of the bitbucket platform, the `auto_describe` tool will be able to publish a PR description only as a comment.
|
||||
In addition, some subsections like `PR changes walkthrough` will not appear, since they require the usage of collapsible sections, which are not supported by bitbucket.
|
||||
|
||||
### Appendix - additional configurations walkthrough
|
||||
### Azure DevOps provider
|
||||
|
||||
To use Azure DevOps provider use the following settings in configuration.toml:
|
||||
```
|
||||
[config]
|
||||
git_provider="azure"
|
||||
use_repo_settings_file=false
|
||||
```
|
||||
|
||||
Azure DevOps provider supports [PAT token](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows) or [DefaultAzureCredential](https://learn.microsoft.com/en-us/azure/developer/python/sdk/authentication-overview#authentication-in-server-environments) authentication.
|
||||
PAT is faster to create, but has build in experation date, and will use the user identity for API calls.
|
||||
Using DefaultAzureCredential you can use managed identity or Service principle, which are more secure and will create seperate ADO user identity (via AAD) to the agent.
|
||||
|
||||
If PAT was choosen, you can assign the value in .secrets.toml.
|
||||
If DefaultAzureCredential was choosen, you can assigned the additional env vars like AZURE_CLIENT_SECRET directly,
|
||||
or use managed identity/az cli (for local develpment) without any additional configuration.
|
||||
in any case, 'org' value must be assigned in .secrets.toml:
|
||||
```
|
||||
[azure_devops]
|
||||
org = "https://dev.azure.com/YOUR_ORGANIZATION/"
|
||||
# pat = "YOUR_PAT_TOKEN" needed only if using PAT for authentication
|
||||
```
|
||||
|
||||
##### Azure DevOps Webhook
|
||||
To trigger from an Azure webhook, you need to manually [add a webhook](https://learn.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops).
|
||||
Use the "Pull request created" type to trigger a review, or "Pull request commented on" to trigger any supported comment with /<command> <args> comment on the relevant PR. Note that for the "Pull request commented on" trigger, only API v2.0 is supported.
|
||||
|
||||
To control which commands will run automatically when a new PR is opened, you can set the `pr_commands` parameter in the configuration file, similar to the GitHub App:
|
||||
```
|
||||
[azure_devops_server]
|
||||
pr_commands = [
|
||||
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||
"/review --pr_reviewer.num_code_suggestions=0",
|
||||
"/improve",
|
||||
]
|
||||
```
|
||||
|
||||
For webhook security, create a sporadic username/password pair and configure the webhook username and password on both the server and Azure DevOps webhook. These will be sent as basic Auth data by the webhook with each request:
|
||||
```
|
||||
[azure_devops_server]
|
||||
webhook_username = "<basic auth user>"
|
||||
webhook_password = "<basic auth password>"
|
||||
```
|
||||
> :warning: **Ensure that the webhook endpoint is only accessible over HTTPS** to mitigate the risk of credential interception when using basic authentication.
|
||||
|
||||
|
||||
## Appendix - additional configurations walkthrough
|
||||
|
||||
|
||||
#### Ignoring files from analysis
|
||||
In some cases, you may want to exclude specific files or directories from the analysis performed by CodiumAI PR-Agent. This can be useful, for example, when you have files that are generated automatically or files that shouldn't be reviewed, like vendored code.
|
||||
|
||||
To ignore files or directories, edit the **[ignore.toml](/pr_agent/settings/ignore.toml)** configuration file. This setting also exposes the following environment variables:
|
||||
|
||||
- `IGNORE.GLOB`
|
||||
- `IGNORE.REGEX`
|
||||
|
||||
For example, to ignore python files in a PR with online usage, comment on a PR:
|
||||
`/review --ignore.glob=['*.py']`
|
||||
|
||||
To ignore python files in all PRs, set in a configuration file:
|
||||
```
|
||||
[ignore]
|
||||
glob = ['*.py']
|
||||
```
|
||||
|
||||
#### Extra instructions
|
||||
All PR-Agent tools have a parameter called `extra_instructions`, that enables to add free-text extra instructions. Example usage:
|
||||
@ -291,11 +374,12 @@ For models and environments not from OPENAI, you might need to provide additiona
|
||||
##### Azure
|
||||
To use Azure, set in your `.secrets.toml` (working from CLI), or in the GitHub `Settings > Secrets and variables` (working from GitHub App or GitHub Action):
|
||||
```
|
||||
api_key = "" # your azure api key
|
||||
[openai]
|
||||
key = "" # your azure api key
|
||||
api_type = "azure"
|
||||
api_version = '2023-05-15' # Check Azure documentation for the current API version
|
||||
api_base = "" # The base URL for your Azure OpenAI resource. e.g. "https://<your resource name>.openai.azure.com"
|
||||
openai.deployment_id = "" # The deployment name you chose when you deployed the engine
|
||||
deployment_id = "" # The deployment name you chose when you deployed the engine
|
||||
```
|
||||
|
||||
and set in your configuration file:
|
||||
@ -427,17 +511,19 @@ Increasing this number provides more context to the model, but will also increas
|
||||
If the PR is too large (see [PR Compression strategy](./PR_COMPRESSION.md)), PR-Agent automatically sets this number to 0, using the original git patch.
|
||||
|
||||
|
||||
#### Azure DevOps provider
|
||||
To use Azure DevOps provider use the following settings in configuration.toml:
|
||||
```
|
||||
[config]
|
||||
git_provider="azure"
|
||||
use_repo_settings_file=false
|
||||
```
|
||||
#### Editing the prompts
|
||||
The prompts for the various PR-Agent tools are defined in the `pr_agent/settings` folder.
|
||||
In practice, the prompts are loaded and stored as a standard setting object.
|
||||
Hence, editing them is similar to editing any other configuration value - just place the relevant key in `.pr_agent.toml`file, and override the default value.
|
||||
|
||||
And use the following settings (you have to replace the values) in .secrets.toml:
|
||||
For example, if you want to edit the prompts of the [describe](./pr_agent/settings/pr_description_prompts.toml) tool, you can add the following to your `.pr_agent.toml` file:
|
||||
```
|
||||
[azure_devops]
|
||||
org = "https://dev.azure.com/YOUR_ORGANIZATION/"
|
||||
pat = "YOUR_PAT_TOKEN"
|
||||
[pr_description_prompt]
|
||||
system="""
|
||||
...
|
||||
"""
|
||||
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).
|
||||
|
@ -26,6 +26,10 @@ FROM base as gitlab_webhook
|
||||
ADD pr_agent pr_agent
|
||||
CMD ["python", "pr_agent/servers/gitlab_webhook.py"]
|
||||
|
||||
FROM base as azure_devops_webhook
|
||||
ADD pr_agent pr_agent
|
||||
CMD ["python", "pr_agent/servers/azuredevops_server_webhook.py"]
|
||||
|
||||
FROM base as test
|
||||
ADD requirements-dev.txt .
|
||||
RUN pip install -r requirements-dev.txt && rm requirements-dev.txt
|
||||
|
13
docs/ASK.md
13
docs/ASK.md
@ -7,9 +7,18 @@ It can be invoked manually by commenting on any PR:
|
||||
```
|
||||
For example:
|
||||
___
|
||||
<kbd><img src=https://codium.ai/images/pr_agent/ask_comment.png width="768"></kbd>
|
||||
<kbd><img src="https://codium.ai/images/pr_agent/ask_comment.png" width="768"></kbd>
|
||||
___
|
||||
<kbd><img src=https://codium.ai/images/pr_agent/ask.png width="768"></kbd>
|
||||
<kbd><img src="https://codium.ai/images/pr_agent/ask.png" width="768"></kbd>
|
||||
___
|
||||
|
||||
## Ask lines
|
||||
You can run `/ask` on specific lines of code in the PR from the PR's diff view. The tool will answer questions based on the code changes in the selected lines.
|
||||
- Click on the '+' sign next to the line number to select the line.
|
||||
- To select multiple lines, click on the '+' sign of the first line and then hold and drag to select the rest of the lines.
|
||||
- write `/ask "..."` in the comment box and press `Add single comment` button.
|
||||
|
||||
<kbd><img src="https://codium.ai/images/pr_agent/Ask_line.png" width="768"></kbd>
|
||||
|
||||
|
||||
Note that the tool does not have "memory" of previous questions, and answers each question independently.
|
31
docs/CI_FEEDBACK.md
Normal file
31
docs/CI_FEEDBACK.md
Normal file
@ -0,0 +1,31 @@
|
||||
# CI Feedback Tool
|
||||
|
||||
The CI feedback tool (`/checks)` automatically triggers when a PR has a failed check.
|
||||
The tool analyzes the failed checks and provides several feedbacks:
|
||||
- Failed stage
|
||||
- Failed test name
|
||||
- Failure summary
|
||||
- Relevant error logs
|
||||
|
||||
<kbd>
|
||||
<img src="https://www.codium.ai/images/pr_agent/failed_check1.png" width="768">
|
||||
</kbd>
|
||||
|
||||
→
|
||||
<kbd>
|
||||
<img src="https://www.codium.ai/images/pr_agent/failed_check2.png" width="768">
|
||||
</kbd>
|
||||
|
||||
___
|
||||
|
||||
In addition to being automatically triggered, the tool can also be invoked manually by commenting on a PR:
|
||||
```
|
||||
/checks "https://github.com/{repo_name}/actions/runs/{run_number}/job/{job_number}"
|
||||
```
|
||||
where `{repo_name}` is the name of the repository, `{run_number}` is the run number of the failed check, and `{job_number}` is the job number of the failed check.
|
||||
|
||||
### Configuration options
|
||||
- `enable_auto_checks_feedback` - if set to true, the tool will automatically provide feedback when a check is failed. Default is true.
|
||||
- `excluded_checks_list` - a list of checks to exclude from the feedback, for example: ["check1", "check2"]. Default is an empty list.
|
||||
- `persistent_comment` - if set to true, the tool will overwrite a previous checks comment with the new feedback. Default is true.
|
||||
- `enable_help_text=true` - if set to true, the tool will provide a help message when a user comments "/checks" on a PR. Default is true.
|
@ -34,9 +34,9 @@ To edit [configurations](./../pr_agent/settings/configuration.toml#L46) related
|
||||
|
||||
- `publish_description_as_comment`: if set to true, the tool will publish the description as a comment to the PR. If false, it will overwrite the origianl description. Default is false.
|
||||
|
||||
- `add_original_user_description`: if set to true, the tool will add the original user description to the generated description. Default is false.
|
||||
- `add_original_user_description`: if set to true, the tool will add the original user description to the generated description. Default is true.
|
||||
|
||||
- `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 true.
|
||||
|
||||
- `extra_instructions`: Optional extra instructions to the tool. For example: "focus on the changes in the file X. Ignore change in ...".
|
||||
|
||||
@ -52,15 +52,23 @@ To edit [configurations](./../pr_agent/settings/configuration.toml#L46) related
|
||||
### Inline file summary 💎
|
||||
> This feature is available only in PR-Agent Pro
|
||||
|
||||
This will enable you to quickly understand the changes in each file, while reviewing the code changes (diff view).
|
||||
To enable inline file summary, set `pr_description.inline_file_summary` in the configuration file, possible values are:
|
||||
This feature will enable you to quickly understand the changes in each file while reviewing the code changes (diff view).
|
||||
|
||||
To add the walkthrough table to the "Files changed" tab, you can click on the checkbox that appears PR Description status message below the main PR Description:
|
||||
|
||||
<kbd><img src=https://codium.ai/images/pr_agent/add_table_checkbox.png width="512"></kbd>
|
||||
|
||||
If you prefer to have the file summaries appear in the "Files changed" tab on every PR, change the `pr_description.inline_file_summary` parameter in the configuration file, possible values are:
|
||||
|
||||
- `'table'`: File changes walkthrough table will be displayed on the top of the "Files changed" tab, in addition to the "Conversation" tab.
|
||||
<kbd><img src=https://codium.ai/images/pr_agent/diffview-table.png width="1024"></kbd>
|
||||
<kbd><img src=https://codium.ai/images/pr_agent/diffview-table.png width="768"></kbd>
|
||||
|
||||
- `true`: A collapsable file comment with changes title and a changes summary for each file in the PR.
|
||||
<kbd><img src=https://codium.ai/images/pr_agent/diffview_changes.png width="1024"></kbd>
|
||||
<kbd><img src=https://codium.ai/images/pr_agent/diffview_changes.png width="768"></kbd>
|
||||
|
||||
- `false` (`default`): File changes walkthrough will be added only to the "Conversation" tab.
|
||||
|
||||
*Note that this feature is currently available only for GitHub.
|
||||
Note that this feature is currently available only for GitHub.
|
||||
|
||||
|
||||
### Handle custom labels from the Repo's labels page :gem:
|
||||
@ -81,7 +89,7 @@ The description should be comprehensive and detailed, indicating when to add the
|
||||
|
||||
### Markers template
|
||||
To enable markers, set `pr_description.use_description_markers=true`.
|
||||
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.
|
||||
|
||||
For example, if the PR original description was:
|
||||
```
|
||||
|
@ -15,7 +15,7 @@
|
||||
| | Generate Custom Labels 💎 | :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: | | | :white_check_mark: |
|
||||
| | Tagging bot | :white_check_mark: | | | | |
|
||||
| | Actions | :white_check_mark: | | | | |
|
||||
| | Web server | | | | | | :white_check_mark: |
|
||||
@ -24,4 +24,4 @@
|
||||
| | Repo language prioritization | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Adaptive and token-aware<br />file patch fitting | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Multiple models support | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| | Incremental PR Review | :white_check_mark: | | | | | |
|
||||
| | Incremental PR Review | :white_check_mark: | | | | | |
|
||||
|
@ -3,7 +3,6 @@
|
||||
## Table of Contents
|
||||
- [Overview](#overview)
|
||||
- [Configuration options](#configuration-options)
|
||||
- [Summarize mode](#summarize-mode)
|
||||
- [Usage Tips](#usage-tips)
|
||||
- [Extra instructions](#extra-instructions)
|
||||
- [PR footprint - regular vs summarize mode](#pr-footprint---regular-vs-summarize-mode)
|
||||
@ -16,7 +15,7 @@ The tool can be triggered automatically every time a new PR is [opened](https://
|
||||
/improve
|
||||
```
|
||||
|
||||
### Summarized vs commitable code suggestions
|
||||
### Summarized vs committable code suggestions
|
||||
|
||||
The code suggestions can be presented as a single comment (via `pr_code_suggestions.summarize=true`):
|
||||
___
|
||||
@ -37,6 +36,14 @@ An extended mode, which does not involve PR Compression and provides more compre
|
||||
```
|
||||
/improve --extended
|
||||
```
|
||||
|
||||
or by setting:
|
||||
```
|
||||
[pr_code_suggestions]
|
||||
auto_extended_mode=true
|
||||
```
|
||||
(True by default).
|
||||
|
||||
Note that the extended mode divides the PR code changes into chunks, up to the token limits, where each chunk is handled separately (might use multiple calls to GPT-4 for large PRs).
|
||||
Hence, the total number of suggestions is proportional to the number of chunks, i.e., the size of the PR.
|
||||
|
||||
@ -54,7 +61,7 @@ To edit [configurations](./../pr_agent/settings/configuration.toml#L66) related
|
||||
- `summarize`: if set to true, the tool will display the suggestions in a single comment. Default is false.
|
||||
- `enable_help_text`: if set to true, the tool will display a help text in the comment. Default is true.
|
||||
#### params for '/improve --extended' mode
|
||||
- `auto_extended_mode`: enable extended mode automatically (no need for the `--extended` option). Default is false.
|
||||
- `auto_extended_mode`: enable extended mode automatically (no need for the `--extended` option). Default is true.
|
||||
- `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.
|
||||
- `max_number_of_calls`: maximum number of chunks. Default is 5.
|
||||
@ -85,6 +92,6 @@ Use triple quotes to write multi-line instructions. Use bullet points to make th
|
||||
|
||||
- While the current AI for code is getting better and better (GPT-4), it's not flawless. 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.
|
||||
- Recommended to use the `exra_instructions` field to guide the model to suggestions that are more relevant to the specific needs of the project.
|
||||
- Consider also trying the [Custom Suggestions Tool](https://github.com/Codium-ai/pr-agent/blob/main/docs/CUSTOM_SUGGESTIONS.md) 💎, that will **only** propose suggestions that follow specific guidelines defined by user.
|
||||
|
||||
|
@ -11,6 +11,7 @@
|
||||
- [Automation](#automation)
|
||||
- [Auto-labels](#auto-labels)
|
||||
- [Extra instructions](#extra-instructions)
|
||||
- [Auto-approval](#auto-approval-1)
|
||||
|
||||
## Overview
|
||||
The `review` tool scans the PR code changes, and automatically generates a PR review.
|
||||
@ -33,7 +34,7 @@ To edit [configurations](./../pr_agent/settings/configuration.toml#L19) related
|
||||
```
|
||||
|
||||
#### 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. For manual comments, default is 4. For [PR-Agent app](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L142) auto tools, default is 0, meaning no code suggestions will be provided by the review tool, unless you manually edit `pr_commands`.
|
||||
- `inline_code_comments`: if set to true, the tool will publish the code suggestions as comments on the code diff. 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 ...".
|
||||
@ -41,7 +42,6 @@ To edit [configurations](./../pr_agent/settings/configuration.toml#L19) related
|
||||
- `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_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_estimate_effort_to_review`: if set to true, the tool will add a section that estimates the effort needed to review the PR. Default is true.
|
||||
#### SOC2 ticket compliance 💎
|
||||
This sub-tool checks if the PR description properly contains a ticket to a project management system (e.g., Jira, Asana, Trello, etc.), as required by SOC2 compliance. If not, it will add a label to the PR: "Missing SOC2 ticket".
|
||||
@ -50,6 +50,9 @@ This sub-tool checks if the PR description properly contains a ticket to a proje
|
||||
#### Adding PR 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 true.
|
||||
#### Auto-approval
|
||||
- `enable_auto_approval`: if set to true, the tool will approve the PR when invoked with the 'auto_approve' command. Default is false. This flag can be changed only from configuration file.
|
||||
- `maximal_review_effort`: maximal effort level for auto-approval. If the PR's estimated review effort is above this threshold, the auto-approval will not run. Default is 5.
|
||||
|
||||
### Incremental Mode
|
||||
Incremental review 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.
|
||||
@ -67,14 +70,13 @@ These configurations can be used to control the rate at which the incremental re
|
||||
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.
|
||||
If less than 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.
|
||||
- `remove_previous_review_comment`: if set to true, the tool will remove the previous review comment before adding a new one. Default is false.
|
||||
|
||||
### PR Reflection
|
||||
By invoking:
|
||||
@ -97,28 +99,30 @@ ___
|
||||
3) [Automation](#automation)
|
||||
4) [Auto-labels](#auto-labels)
|
||||
5) [Extra instructions](#extra-instructions)
|
||||
6) [Auto-approval](#auto-approval)
|
||||
|
||||
### General guidelines
|
||||
The `review` tool provides a collection of possible feedbacks about a PR.
|
||||
It is recommended to review the [Configuration options](#configuration-options) section, and choose the relevant options for your use case.
|
||||
|
||||
Some of the feature that are disabled by default are quite useful, and should be considered for enabling. For example:
|
||||
Some of the features that are disabled by default are quite useful, and should be considered for enabling. For example:
|
||||
`require_score_review`, `require_soc2_ticket`, and more.
|
||||
|
||||
On the other hand, if you find one of the enabled features to be irrelevant for your use case, disable it. No default configuration can fit all use cases.
|
||||
|
||||
### Code suggestions
|
||||
The `review` tool provides several type of feedbacks, one of them is code suggestions.
|
||||
If you are interested **only** in the code suggestions, it is recommended to use the [`improve`](./IMPROVE.md) feature instead, since it dedicated only to code suggestions, and usually gives better results.
|
||||
Use the `review` tool if you want to get a more comprehensive feedback, which includes code suggestions as well.
|
||||
If you set `num_code_suggestions`>0 , the `review` tool will also provide code suggestions.
|
||||
|
||||
Notice If you are interested **only** in the code suggestions, it is recommended to use the [`improve`](./IMPROVE.md) feature instead, since it is a dedicated only to code suggestions, and usually gives better results.
|
||||
Use the `review` tool if you want to get more comprehensive feedback, which includes code suggestions as well.
|
||||
|
||||
### Automation
|
||||
- When you first install the app, the [default mode](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#github-app-automatic-tools) for the `review` tool is:
|
||||
```
|
||||
pr_commands = ["/review", ...]
|
||||
```
|
||||
meaning the `review` tool will run automatically on every PR, with the default configuration.
|
||||
Edit this field to enable/disable the tool, or to change the used configurations
|
||||
Meaning the `review` tool will run automatically on every PR, with the default configuration.
|
||||
Edit this field to enable/disable the tool, or to change the used configurations.
|
||||
|
||||
### Auto-labels
|
||||
The `review` tool can auto-generate two specific types of labels for a PR:
|
||||
@ -128,7 +132,7 @@ The `review` tool can auto-generate two specific types of labels for a PR:
|
||||
Both modes are useful, and we recommended to enable them.
|
||||
|
||||
### Extra instructions
|
||||
Extra instruction are important.
|
||||
Extra instructions are important.
|
||||
The `review` tool can be configured with extra instructions, which can be used to guide the model to a feedback tailored to the needs of your project.
|
||||
|
||||
Be specific, clear, and concise in the instructions. With extra instructions, you are the prompter. Specify the relevant sub-tool, and the relevant aspects of the PR that you want to emphasize.
|
||||
@ -146,3 +150,27 @@ In the code feedback section, emphasize the following:
|
||||
```
|
||||
Use triple quotes to write multi-line instructions. Use bullet points to make the instructions more readable.
|
||||
|
||||
|
||||
### Auto-approval
|
||||
PR-Agent can approve a PR when a specific comment is invoked.
|
||||
|
||||
To ensure safety, the auto-approval feature is disabled by default. To enable auto-approval, you need to actively set in a pre-defined configuration file the following:
|
||||
```
|
||||
[pr_reviewer]
|
||||
enable_auto_approval = true
|
||||
```
|
||||
(this specific flag cannot be set with a command line argument, only in the configuration file, committed to the repository)
|
||||
|
||||
|
||||
After enabling, by commenting on a PR:
|
||||
```
|
||||
/review auto_approve
|
||||
```
|
||||
PR-Agent will automatically approve the PR, and add a comment with the approval.
|
||||
|
||||
|
||||
You can also enable auto-approval only if the PR meets certain requirements, such as that the `estimated_review_effort` label is equal or below a certain threshold, by adjusting the flag:
|
||||
```
|
||||
[pr_reviewer]
|
||||
maximal_review_effort = 5
|
||||
```
|
||||
|
@ -5,9 +5,11 @@
|
||||
- [ASK](./ASK.md)
|
||||
- [SIMILAR_ISSUE](./SIMILAR_ISSUE.md)
|
||||
- [UPDATE CHANGELOG](./UPDATE_CHANGELOG.md)
|
||||
- [CUSTOM SUGGESTIONS](./CUSTOM_SUGGESTIONS.md) 💎
|
||||
- [ADD DOCUMENTATION](./ADD_DOCUMENTATION.md) 💎
|
||||
- [GENERATE CUSTOM LABELS](./GENERATE_CUSTOM_LABELS.md) 💎
|
||||
- [Analyze](./Analyze.md) 💎
|
||||
- [Test](./TEST.md) 💎
|
||||
- [CI Feedback](./CI_FEEDBACK.md) 💎
|
||||
|
||||
See the **[installation guide](/INSTALL.md)** for instructions on setting up PR-Agent.
|
||||
|
@ -13,7 +13,9 @@ 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_generate_labels import PRGenerateLabels
|
||||
from pr_agent.tools.pr_help_message import PRHelpMessage
|
||||
from pr_agent.tools.pr_information_from_user import PRInformationFromUser
|
||||
from pr_agent.tools.pr_line_questions import PR_LineQuestions
|
||||
from pr_agent.tools.pr_questions import PRQuestions
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
from pr_agent.tools.pr_similar_issue import PRSimilarIssue
|
||||
@ -32,9 +34,11 @@ command2class = {
|
||||
"improve_code": PRCodeSuggestions,
|
||||
"ask": PRQuestions,
|
||||
"ask_question": PRQuestions,
|
||||
"ask_line": PR_LineQuestions,
|
||||
"update_changelog": PRUpdateChangelog,
|
||||
"config": PRConfig,
|
||||
"settings": PRConfig,
|
||||
"help": PRHelpMessage,
|
||||
"similar_issue": PRSimilarIssue,
|
||||
"add_docs": PRAddDocs,
|
||||
"generate_labels": PRGenerateLabels,
|
||||
@ -45,6 +49,7 @@ commands = list(command2class.keys())
|
||||
class PRAgent:
|
||||
def __init__(self, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler):
|
||||
self.ai_handler = ai_handler # will be initialized in run_action
|
||||
self.forbidden_cli_args = ['enable_auto_approval']
|
||||
|
||||
async def handle_request(self, pr_url, request, notify=None) -> bool:
|
||||
# First, apply repo specific settings if exists
|
||||
@ -58,23 +63,32 @@ class PRAgent:
|
||||
action, *args = list(lexer)
|
||||
else:
|
||||
action, *args = request
|
||||
|
||||
if args:
|
||||
for forbidden_arg in self.forbidden_cli_args:
|
||||
for arg in args:
|
||||
if forbidden_arg in arg:
|
||||
get_logger().error(f"CLI argument for param '{forbidden_arg}' is forbidden. Use instead a configuration file.")
|
||||
return False
|
||||
args = update_settings_from_args(args)
|
||||
|
||||
action = action.lstrip("/").lower()
|
||||
if action == "reflect_and_review":
|
||||
get_settings().pr_reviewer.ask_and_reflect = True
|
||||
if action == "answer":
|
||||
if notify:
|
||||
notify()
|
||||
await PRReviewer(pr_url, is_answer=True, args=args, ai_handler=self.ai_handler).run()
|
||||
elif action == "auto_review":
|
||||
await PRReviewer(pr_url, is_auto=True, args=args, ai_handler=self.ai_handler).run()
|
||||
elif action in command2class:
|
||||
if notify:
|
||||
notify()
|
||||
|
||||
await command2class[action](pr_url, ai_handler=self.ai_handler, args=args).run()
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
with get_logger().contextualize(command=action):
|
||||
get_logger().info("PR-Agent request handler started", analytics=True)
|
||||
if action == "reflect_and_review":
|
||||
get_settings().pr_reviewer.ask_and_reflect = True
|
||||
if action == "answer":
|
||||
if notify:
|
||||
notify()
|
||||
await PRReviewer(pr_url, is_answer=True, args=args, ai_handler=self.ai_handler).run()
|
||||
elif action == "auto_review":
|
||||
await PRReviewer(pr_url, is_auto=True, args=args, ai_handler=self.ai_handler).run()
|
||||
elif action in command2class:
|
||||
if notify:
|
||||
notify()
|
||||
|
||||
await command2class[action](pr_url, ai_handler=self.ai_handler, args=args).run()
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
@ -9,6 +9,7 @@ MAX_TOKENS = {
|
||||
'gpt-4-0613': 8000,
|
||||
'gpt-4-32k': 32000,
|
||||
'gpt-4-1106-preview': 128000, # 128K, but may be limited by config.max_model_tokens
|
||||
'gpt-4-0125-preview': 128000, # 128K, but may be limited by config.max_model_tokens
|
||||
'claude-instant-1': 100000,
|
||||
'claude-2': 100000,
|
||||
'command-nightly': 4096,
|
||||
|
@ -100,6 +100,7 @@ class LiteLLMAIHandler(BaseAiHandler):
|
||||
TryAgain: If there is an attribute error during OpenAI inference.
|
||||
"""
|
||||
try:
|
||||
resp, finish_reason = None, None
|
||||
deployment_id = self.deployment_id
|
||||
if self.azure:
|
||||
model = 'azure/' + model
|
||||
@ -113,6 +114,8 @@ class LiteLLMAIHandler(BaseAiHandler):
|
||||
}
|
||||
if self.aws_bedrock_client:
|
||||
kwargs["aws_bedrock_client"] = self.aws_bedrock_client
|
||||
|
||||
get_logger().debug("Prompts", artifact={"system": system, "user": user})
|
||||
response = await acompletion(**kwargs)
|
||||
except (APIError, Timeout, TryAgain) as e:
|
||||
get_logger().error("Error during OpenAI inference: ", e)
|
||||
@ -125,9 +128,11 @@ class LiteLLMAIHandler(BaseAiHandler):
|
||||
raise TryAgain from e
|
||||
if response is None or len(response["choices"]) == 0:
|
||||
raise TryAgain
|
||||
resp = response["choices"][0]['message']['content']
|
||||
finish_reason = response["choices"][0]["finish_reason"]
|
||||
usage = response.get("usage")
|
||||
get_logger().info("AI response", response=resp, messages=messages, finish_reason=finish_reason,
|
||||
model=model, usage=usage)
|
||||
else:
|
||||
resp = response["choices"][0]['message']['content']
|
||||
finish_reason = response["choices"][0]["finish_reason"]
|
||||
# usage = response.get("usage")
|
||||
get_logger().debug(f"\nAI response:\n{resp}")
|
||||
get_logger().debug("Full_response", artifact=response)
|
||||
|
||||
return resp, finish_reason
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import re
|
||||
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.git_providers.git_provider import EDIT_TYPE
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
|
||||
@ -245,3 +245,59 @@ __old hunk__
|
||||
patch_with_lines_str += f"{line_old}\n"
|
||||
|
||||
return patch_with_lines_str.rstrip()
|
||||
|
||||
|
||||
def extract_hunk_lines_from_patch(patch: str, file_name, line_start, line_end, side) -> tuple[str, str]:
|
||||
|
||||
patch_with_lines_str = f"\n\n## file: '{file_name.strip()}'\n\n"
|
||||
selected_lines = ""
|
||||
patch_lines = patch.splitlines()
|
||||
RE_HUNK_HEADER = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
||||
match = None
|
||||
start1, size1, start2, size2 = -1, -1, -1, -1
|
||||
skip_hunk = False
|
||||
selected_lines_num = 0
|
||||
for line in patch_lines:
|
||||
if 'no newline at end of file' in line.lower():
|
||||
continue
|
||||
|
||||
if line.startswith('@@'):
|
||||
skip_hunk = False
|
||||
selected_lines_num = 0
|
||||
header_line = line
|
||||
|
||||
match = RE_HUNK_HEADER.match(line)
|
||||
|
||||
res = list(match.groups())
|
||||
for i in range(len(res)):
|
||||
if res[i] is None:
|
||||
res[i] = 0
|
||||
try:
|
||||
start1, size1, start2, size2 = map(int, res[:4])
|
||||
except: # '@@ -0,0 +1 @@' case
|
||||
start1, size1, size2 = map(int, res[:3])
|
||||
start2 = 0
|
||||
|
||||
# check if line range is in this hunk
|
||||
if side.lower() == 'left':
|
||||
# check if line range is in this hunk
|
||||
if not (start1 <= line_start <= start1 + size1):
|
||||
skip_hunk = True
|
||||
continue
|
||||
elif side.lower() == 'right':
|
||||
if not (start2 <= line_start <= start2 + size2):
|
||||
skip_hunk = True
|
||||
continue
|
||||
patch_with_lines_str += f'\n{header_line}\n'
|
||||
|
||||
elif not skip_hunk:
|
||||
if side.lower() == 'right' and line_start <= start2 + selected_lines_num <= line_end:
|
||||
selected_lines += line + '\n'
|
||||
if side.lower() == 'left' and start1 <= selected_lines_num + start1 <= line_end:
|
||||
selected_lines += line + '\n'
|
||||
patch_with_lines_str += line + '\n'
|
||||
if not line.startswith('-'): # currently we don't support /ask line for deleted lines
|
||||
selected_lines_num += 1
|
||||
|
||||
return patch_with_lines_str.rstrip(), selected_lines.rstrip()
|
@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import traceback
|
||||
from typing import Any, Callable, List, Tuple
|
||||
from typing import Callable, List, Tuple
|
||||
|
||||
from github import RateLimitExceededException
|
||||
|
||||
@ -11,9 +9,10 @@ from pr_agent.algo.git_patch_processing import convert_to_hunks_with_lines_numbe
|
||||
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.token_handler import TokenHandler
|
||||
from pr_agent.algo.utils import get_max_tokens
|
||||
from pr_agent.algo.utils import get_max_tokens, ModelType
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.git_providers.git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
|
||||
from pr_agent.git_providers.git_provider import GitProvider
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
DELETED_FILES_ = "Deleted files:\n"
|
||||
@ -51,15 +50,29 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s
|
||||
PATCH_EXTRA_LINES = get_settings().config.patch_extra_lines
|
||||
|
||||
try:
|
||||
diff_files = git_provider.get_diff_files()
|
||||
diff_files_original = git_provider.get_diff_files()
|
||||
except RateLimitExceededException as e:
|
||||
get_logger().error(f"Rate limit exceeded for git provider API. original message {e}")
|
||||
raise
|
||||
|
||||
diff_files = filter_ignored(diff_files)
|
||||
diff_files = filter_ignored(diff_files_original)
|
||||
if diff_files != diff_files_original:
|
||||
try:
|
||||
get_logger().info(f"Filtered out {len(diff_files_original) - len(diff_files)} files")
|
||||
new_names = set([a.filename for a in diff_files])
|
||||
orig_names = set([a.filename for a in diff_files_original])
|
||||
get_logger().info(f"Filtered out files: {orig_names - new_names}")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
# get pr languages
|
||||
pr_languages = sort_files_by_main_languages(git_provider.get_languages(), diff_files)
|
||||
if pr_languages:
|
||||
try:
|
||||
get_logger().info(f"PR main language: {pr_languages[0]['language']}")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# generate a standard diff string, with patch extension
|
||||
patches_extended, total_tokens, patches_extended_tokens = pr_generate_extended_diff(
|
||||
@ -67,9 +80,13 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s
|
||||
|
||||
# if we are under the limit, return the full diff
|
||||
if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model):
|
||||
get_logger().info(f"Tokens: {total_tokens}, total tokens under limit: {get_max_tokens(model)}, "
|
||||
f"returning full diff.")
|
||||
return "\n".join(patches_extended)
|
||||
|
||||
# if we are over the limit, start pruning
|
||||
get_logger().info(f"Tokens: {total_tokens}, total tokens over limit: {get_max_tokens(model)}, "
|
||||
f"pruning diff.")
|
||||
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)
|
||||
|
||||
@ -83,6 +100,11 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, model: s
|
||||
if deleted_file_names:
|
||||
deleted_list_str = DELETED_FILES_ + "\n".join(deleted_file_names)
|
||||
final_diff = final_diff + "\n\n" + deleted_list_str
|
||||
try:
|
||||
get_logger().debug(f"After pruning, added_list_str: {added_list_str}, modified_list_str: {modified_list_str}, "
|
||||
f"deleted_list_str: {deleted_list_str}")
|
||||
except Exception as e:
|
||||
pass
|
||||
return final_diff
|
||||
|
||||
|
||||
@ -220,20 +242,19 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo
|
||||
return patches, modified_files_list, deleted_files_list, added_files_list
|
||||
|
||||
|
||||
async def retry_with_fallback_models(f: Callable):
|
||||
all_models = _get_all_models()
|
||||
async def retry_with_fallback_models(f: Callable, model_type: ModelType = ModelType.REGULAR):
|
||||
all_models = _get_all_models(model_type)
|
||||
all_deployments = _get_all_deployments(all_models)
|
||||
# try each (model, deployment_id) pair until one is successful, otherwise raise exception
|
||||
for i, (model, deployment_id) in enumerate(zip(all_models, all_deployments)):
|
||||
try:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().debug(
|
||||
f"Generating prediction with {model}"
|
||||
f"{(' from deployment ' + deployment_id) if deployment_id else ''}"
|
||||
)
|
||||
get_logger().debug(
|
||||
f"Generating prediction with {model}"
|
||||
f"{(' from deployment ' + deployment_id) if deployment_id else ''}"
|
||||
)
|
||||
get_settings().set("openai.deployment_id", deployment_id)
|
||||
return await f(model)
|
||||
except Exception as e:
|
||||
except:
|
||||
get_logger().warning(
|
||||
f"Failed to generate prediction with {model}"
|
||||
f"{(' from deployment ' + deployment_id) if deployment_id else ''}: "
|
||||
@ -243,8 +264,11 @@ async def retry_with_fallback_models(f: Callable):
|
||||
raise # Re-raise the last exception
|
||||
|
||||
|
||||
def _get_all_models() -> List[str]:
|
||||
model = get_settings().config.model
|
||||
def _get_all_models(model_type: ModelType = ModelType.REGULAR) -> List[str]:
|
||||
if model_type == ModelType.TURBO:
|
||||
model = get_settings().config.model_turbo
|
||||
else:
|
||||
model = get_settings().config.model
|
||||
fallback_models = get_settings().config.fallback_models
|
||||
if not isinstance(fallback_models, list):
|
||||
fallback_models = [m.strip() for m in fallback_models.split(",")]
|
||||
@ -267,78 +291,6 @@ def _get_all_deployments(all_models: List[str]) -> List[str]:
|
||||
return all_deployments
|
||||
|
||||
|
||||
def find_line_number_of_relevant_line_in_file(diff_files: List[FilePatchInfo],
|
||||
relevant_file: str,
|
||||
relevant_line_in_file: str,
|
||||
absolute_position: int = None) -> Tuple[int, int]:
|
||||
position = -1
|
||||
if absolute_position is None:
|
||||
absolute_position = -1
|
||||
re_hunk_header = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
||||
|
||||
for file in diff_files:
|
||||
if file.filename and (file.filename.strip() == relevant_file):
|
||||
patch = file.patch
|
||||
patch_lines = patch.splitlines()
|
||||
delta = 0
|
||||
start1, size1, start2, size2 = 0, 0, 0, 0
|
||||
if absolute_position != -1: # matching absolute to relative
|
||||
for i, line in enumerate(patch_lines):
|
||||
# new hunk
|
||||
if line.startswith('@@'):
|
||||
delta = 0
|
||||
match = re_hunk_header.match(line)
|
||||
start1, size1, start2, size2 = map(int, match.groups()[:4])
|
||||
elif not line.startswith('-'):
|
||||
delta += 1
|
||||
|
||||
#
|
||||
absolute_position_curr = start2 + delta - 1
|
||||
|
||||
if absolute_position_curr == absolute_position:
|
||||
position = i
|
||||
break
|
||||
else:
|
||||
# try to find the line in the patch using difflib, with some margin of error
|
||||
matches_difflib: list[str | Any] = difflib.get_close_matches(relevant_line_in_file,
|
||||
patch_lines, n=3, cutoff=0.93)
|
||||
if len(matches_difflib) == 1 and matches_difflib[0].startswith('+'):
|
||||
relevant_line_in_file = matches_difflib[0]
|
||||
|
||||
|
||||
for i, line in enumerate(patch_lines):
|
||||
if line.startswith('@@'):
|
||||
delta = 0
|
||||
match = re_hunk_header.match(line)
|
||||
start1, size1, start2, size2 = map(int, match.groups()[:4])
|
||||
elif not line.startswith('-'):
|
||||
delta += 1
|
||||
|
||||
if relevant_line_in_file in line and line[0] != '-':
|
||||
position = i
|
||||
absolute_position = start2 + delta - 1
|
||||
break
|
||||
|
||||
if position == -1 and relevant_line_in_file[0] == '+':
|
||||
no_plus_line = relevant_line_in_file[1:].lstrip()
|
||||
for i, line in enumerate(patch_lines):
|
||||
if line.startswith('@@'):
|
||||
delta = 0
|
||||
match = re_hunk_header.match(line)
|
||||
start1, size1, start2, size2 = map(int, match.groups()[:4])
|
||||
elif not line.startswith('-'):
|
||||
delta += 1
|
||||
|
||||
if no_plus_line in line and line[0] != '-':
|
||||
# The model might add a '+' to the beginning of the relevant_line_in_file even if originally
|
||||
# it's a context line
|
||||
position = i
|
||||
absolute_position = start2 + delta - 1
|
||||
break
|
||||
return position, absolute_position
|
||||
|
||||
|
||||
def get_pr_multi_diffs(git_provider: GitProvider,
|
||||
token_handler: TokenHandler,
|
||||
model: str,
|
||||
@ -405,6 +357,11 @@ def get_pr_multi_diffs(git_provider: GitProvider,
|
||||
|
||||
patch = convert_to_hunks_with_lines_numbers(patch, file)
|
||||
new_patch_tokens = token_handler.count_tokens(patch)
|
||||
|
||||
if patch and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD:
|
||||
get_logger().warning(f"Patch too large, skipping: {file.filename}")
|
||||
continue
|
||||
|
||||
if patch and (total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD):
|
||||
final_diff = "\n".join(patches)
|
||||
final_diff_list.append(final_diff)
|
||||
|
23
pr_agent/algo/types.py
Normal file
23
pr_agent/algo/types.py
Normal file
@ -0,0 +1,23 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EDIT_TYPE(Enum):
|
||||
ADDED = 1
|
||||
DELETED = 2
|
||||
MODIFIED = 3
|
||||
RENAMED = 4
|
||||
UNKNOWN = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilePatchInfo:
|
||||
base_file: str
|
||||
head_file: str
|
||||
patch: str
|
||||
filename: str
|
||||
tokens: int = -1
|
||||
edit_type: EDIT_TYPE = EDIT_TYPE.UNKNOWN
|
||||
old_filename: str = None
|
||||
num_plus_lines: int = -1
|
||||
num_minus_lines: int = -1
|
@ -5,7 +5,8 @@ import json
|
||||
import re
|
||||
import textwrap
|
||||
from datetime import datetime
|
||||
from typing import Any, List
|
||||
from enum import Enum
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import yaml
|
||||
from starlette_context import context
|
||||
@ -13,8 +14,12 @@ from starlette_context import context
|
||||
from pr_agent.algo import MAX_TOKENS
|
||||
from pr_agent.algo.token_handler import get_token_encoder
|
||||
from pr_agent.config_loader import get_settings, global_settings
|
||||
from pr_agent.algo.types import FilePatchInfo
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
class ModelType(str, Enum):
|
||||
REGULAR = "regular"
|
||||
TURBO = "turbo"
|
||||
|
||||
def get_setting(key: str) -> Any:
|
||||
try:
|
||||
@ -23,6 +28,25 @@ def get_setting(key: str) -> Any:
|
||||
except Exception:
|
||||
return global_settings.get(key, None)
|
||||
|
||||
|
||||
def emphasize_header(text: str) -> str:
|
||||
try:
|
||||
# Finding the position of the first occurrence of ": "
|
||||
colon_position = text.find(": ")
|
||||
|
||||
# Splitting the string and wrapping the first part in <strong> tags
|
||||
if colon_position != -1:
|
||||
# Everything before the colon (inclusive) is wrapped in <strong> tags
|
||||
transformed_string = "<strong>" + text[:colon_position + 1] + "</strong>" + text[colon_position + 1:]
|
||||
else:
|
||||
# If there's no ": ", return the original string
|
||||
transformed_string = text
|
||||
|
||||
return transformed_string
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to emphasize header: {e}")
|
||||
return text
|
||||
|
||||
def convert_to_markdown(output_data: dict, gfm_supported: bool=True) -> str:
|
||||
"""
|
||||
Convert a dictionary of data into markdown format.
|
||||
@ -31,86 +55,107 @@ def convert_to_markdown(output_data: dict, gfm_supported: bool=True) -> str:
|
||||
Returns:
|
||||
str: The markdown formatted text generated from the input dictionary.
|
||||
"""
|
||||
markdown_text = ""
|
||||
|
||||
emojis = {
|
||||
"Main theme": "🎯",
|
||||
"PR summary": "📝",
|
||||
"Type of PR": "📌",
|
||||
"Possible issues": "🔍",
|
||||
"Score": "🏅",
|
||||
"Relevant tests added": "🧪",
|
||||
"Unrelated changes": "⚠️",
|
||||
"Relevant tests": "🧪",
|
||||
"Focused PR": "✨",
|
||||
"Security concerns": "🔒",
|
||||
"General suggestions": "💡",
|
||||
"Insights from user's answers": "📝",
|
||||
"Code feedback": "🤖",
|
||||
"Estimated effort to review [1-5]": "⏱️",
|
||||
}
|
||||
markdown_text = ""
|
||||
markdown_text += f"## PR Review\n\n"
|
||||
if gfm_supported:
|
||||
markdown_text += "<table>\n<tr>\n"
|
||||
# markdown_text += """<td> Feedback </td> <td></td></tr>"""
|
||||
|
||||
for key, value in output_data.items():
|
||||
if not output_data or not output_data.get('review', {}):
|
||||
return ""
|
||||
|
||||
for key, value in output_data['review'].items():
|
||||
if value is None or value == '' or value == {} or value == []:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
markdown_text += f"## {key}\n\n"
|
||||
markdown_text += convert_to_markdown(value, gfm_supported)
|
||||
elif isinstance(value, list):
|
||||
emoji = emojis.get(key, "")
|
||||
if key.lower() == 'code feedback':
|
||||
if gfm_supported:
|
||||
markdown_text += f"\n\n"
|
||||
markdown_text += f"<details><summary> <strong>{ emoji } Code feedback:</strong></summary>"
|
||||
key_nice = key.replace('_', ' ').capitalize()
|
||||
emoji = emojis.get(key_nice, "")
|
||||
if gfm_supported:
|
||||
if 'Estimated effort to review' in key_nice:
|
||||
key_nice = 'Estimated effort to review [1-5]'
|
||||
if 'security concerns' in key_nice.lower():
|
||||
value = emphasize_header(value.strip())
|
||||
markdown_text += f"<tr><td> {emoji} <strong>{key_nice}</strong></td><td>\n\n{value}\n\n</td></tr>\n"
|
||||
elif 'possible issues' in key_nice.lower():
|
||||
value = value.strip()
|
||||
issues = value.split('\n- ')
|
||||
number_of_issues = len(issues)
|
||||
if number_of_issues > 1:
|
||||
markdown_text += f"<tr><td rowspan={number_of_issues}> {emoji} <strong>{key_nice}</strong></td>\n"
|
||||
for i, issue in enumerate(issues):
|
||||
issue = issue.strip('-').strip()
|
||||
issue = emphasize_header(issue.strip())
|
||||
if i == 0:
|
||||
markdown_text += f"<td>\n\n{issue}</td></tr>\n"
|
||||
else:
|
||||
markdown_text += f"<tr>\n<td>\n\n{issue}</td></tr>\n"
|
||||
else:
|
||||
markdown_text += f"\n\n**{emoji} Code feedback:**\n\n"
|
||||
value = emphasize_header(value.strip('-').strip())
|
||||
markdown_text += f"<tr><td> {emoji} <strong>{key_nice}</strong></td><td>\n\n{value}\n\n</td></tr>\n"
|
||||
else:
|
||||
markdown_text += f"- {emoji} **{key}:**\n\n"
|
||||
for i, item in enumerate(value):
|
||||
if isinstance(item, dict) and key.lower() == 'code feedback':
|
||||
markdown_text += parse_code_suggestion(item, i, gfm_supported)
|
||||
elif item:
|
||||
markdown_text += f" - {item}\n"
|
||||
if key.lower() == 'code feedback':
|
||||
if gfm_supported:
|
||||
markdown_text += "</details>\n\n"
|
||||
else:
|
||||
markdown_text += "\n\n"
|
||||
elif value != 'n/a':
|
||||
emoji = emojis.get(key, "")
|
||||
if key.lower() == 'general suggestions':
|
||||
if gfm_supported:
|
||||
markdown_text += f"\n\n<strong>{emoji} General suggestions:</strong> {value}\n"
|
||||
else:
|
||||
markdown_text += f"{emoji} **General suggestions:** {value}\n"
|
||||
markdown_text += f"<tr><td> {emoji} <strong>{key_nice}</strong></td><td>\n\n{value}\n\n</td></tr>\n"
|
||||
else:
|
||||
if len(value.split()) > 1:
|
||||
markdown_text += f"{emoji} **{key_nice}:**\n\n {value}\n\n"
|
||||
else:
|
||||
markdown_text += f"- {emoji} **{key}:** {value}\n"
|
||||
markdown_text += f"{emoji} **{key_nice}:** {value}\n\n"
|
||||
if gfm_supported:
|
||||
markdown_text += "</table>\n"
|
||||
|
||||
if 'code_feedback' in output_data:
|
||||
if gfm_supported:
|
||||
markdown_text += f"\n\n"
|
||||
markdown_text += f"<details><summary> <strong>Code feedback:</strong></summary>\n\n"
|
||||
markdown_text += "<hr>"
|
||||
else:
|
||||
markdown_text += f"\n\n** Code feedback:**\n\n"
|
||||
for i, value in enumerate(output_data['code_feedback']):
|
||||
if value is None or value == '' or value == {} or value == []:
|
||||
continue
|
||||
markdown_text += parse_code_suggestion(value, i, gfm_supported)+"\n\n"
|
||||
if markdown_text.endswith('<hr>'):
|
||||
markdown_text = markdown_text[:-4]
|
||||
if gfm_supported:
|
||||
markdown_text += f"</details>"
|
||||
#print(markdown_text)
|
||||
|
||||
|
||||
return markdown_text
|
||||
|
||||
|
||||
def parse_code_suggestion(code_suggestions: dict, i: int = 0, gfm_supported: bool = True) -> str:
|
||||
def parse_code_suggestion(code_suggestion: dict, i: int = 0, gfm_supported: bool = True) -> str:
|
||||
"""
|
||||
Convert a dictionary of data into markdown format.
|
||||
|
||||
Args:
|
||||
code_suggestions (dict): A dictionary containing data to be converted to markdown format.
|
||||
code_suggestion (dict): A dictionary containing data to be converted to markdown format.
|
||||
|
||||
Returns:
|
||||
str: A string containing the markdown formatted text generated from the input dictionary.
|
||||
"""
|
||||
markdown_text = ""
|
||||
if gfm_supported and 'relevant line' in code_suggestions:
|
||||
if i == 0:
|
||||
markdown_text += "<hr>"
|
||||
if gfm_supported and 'relevant_line' in code_suggestion:
|
||||
markdown_text += '<table>'
|
||||
for sub_key, sub_value in code_suggestions.items():
|
||||
for sub_key, sub_value in code_suggestion.items():
|
||||
try:
|
||||
if sub_key.lower() == 'relevant file':
|
||||
if sub_key.lower() == 'relevant_file':
|
||||
relevant_file = sub_value.strip('`').strip('"').strip("'")
|
||||
markdown_text += f"<tr><td>{sub_key}</td><td>{relevant_file}</td></tr>"
|
||||
markdown_text += f"<tr><td>relevant file</td><td>{relevant_file}</td></tr>"
|
||||
# continue
|
||||
elif sub_key.lower() == 'suggestion':
|
||||
markdown_text += (f"<tr><td>{sub_key} </td>"
|
||||
f"<td><br>\n\n**{sub_value.strip()}**\n<br></td></tr>")
|
||||
elif sub_key.lower() == 'relevant line':
|
||||
f"<td>\n\n<strong>\n\n{sub_value.strip()}\n\n</strong>\n</td></tr>")
|
||||
elif sub_key.lower() == 'relevant_line':
|
||||
markdown_text += f"<tr><td>relevant line</td>"
|
||||
sub_value_list = sub_value.split('](')
|
||||
relevant_line = sub_value_list[0].lstrip('`').lstrip('[')
|
||||
@ -126,7 +171,11 @@ def parse_code_suggestion(code_suggestions: dict, i: int = 0, gfm_supported: boo
|
||||
markdown_text += '</table>'
|
||||
markdown_text += "<hr>"
|
||||
else:
|
||||
for sub_key, sub_value in code_suggestions.items():
|
||||
for sub_key, sub_value in code_suggestion.items():
|
||||
if isinstance(sub_key, str):
|
||||
sub_key = sub_key.rstrip()
|
||||
if isinstance(sub_value,str):
|
||||
sub_value = sub_value.rstrip()
|
||||
if isinstance(sub_value, dict): # "code example"
|
||||
markdown_text += f" - **{sub_key}:**\n"
|
||||
for code_key, code_value in sub_value.items(): # 'before' and 'after' code
|
||||
@ -134,14 +183,13 @@ def parse_code_suggestion(code_suggestions: dict, i: int = 0, gfm_supported: boo
|
||||
code_str_indented = textwrap.indent(code_str, ' ')
|
||||
markdown_text += f" - **{code_key}:**\n{code_str_indented}\n"
|
||||
else:
|
||||
if "relevant file" in sub_key.lower():
|
||||
if "relevant_file" in sub_key.lower():
|
||||
markdown_text += f"\n - **{sub_key}:** {sub_value} \n"
|
||||
else:
|
||||
markdown_text += f" **{sub_key}:** {sub_value} \n"
|
||||
if not gfm_supported:
|
||||
if "relevant line" not in sub_key.lower(): # nicer presentation
|
||||
# markdown_text = markdown_text.rstrip('\n') + "\\\n" # works for gitlab
|
||||
markdown_text = markdown_text.rstrip('\n') + " \n" # works for gitlab and bitbucker
|
||||
if "relevant_line" not in sub_key.lower(): # nicer presentation
|
||||
# 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"
|
||||
return markdown_text
|
||||
@ -365,9 +413,9 @@ def try_fix_yaml(response_text: str, keys_fix_yaml: List[str] = []) -> dict:
|
||||
pass
|
||||
|
||||
# third fallback - try to remove leading and trailing curly brackets
|
||||
response_text_copy = response_text.strip().rstrip().removeprefix('{').removesuffix('}')
|
||||
response_text_copy = response_text.strip().rstrip().removeprefix('{').removesuffix('}').rstrip(':\n')
|
||||
try:
|
||||
data = yaml.safe_load(response_text_copy,)
|
||||
data = yaml.safe_load(response_text_copy)
|
||||
get_logger().info(f"Successfully parsed AI prediction after removing curly brackets")
|
||||
return data
|
||||
except:
|
||||
@ -378,7 +426,7 @@ def try_fix_yaml(response_text: str, keys_fix_yaml: List[str] = []) -> dict:
|
||||
for i in range(1, len(response_text_lines)):
|
||||
response_text_lines_tmp = '\n'.join(response_text_lines[:-i])
|
||||
try:
|
||||
data = yaml.safe_load(response_text_lines_tmp,)
|
||||
data = yaml.safe_load(response_text_lines_tmp)
|
||||
get_logger().info(f"Successfully parsed AI prediction after removing {i} lines")
|
||||
return data
|
||||
except:
|
||||
@ -426,7 +474,7 @@ def get_user_labels(current_labels: List[str] = None):
|
||||
continue
|
||||
user_labels.append(label)
|
||||
if user_labels:
|
||||
get_logger().info(f"Keeping user labels: {user_labels}")
|
||||
get_logger().debug(f"Keeping user labels: {user_labels}")
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to get user labels: {e}")
|
||||
return current_labels
|
||||
@ -483,4 +531,76 @@ def replace_code_tags(text):
|
||||
parts = text.split('`')
|
||||
for i in range(1, len(parts), 2):
|
||||
parts[i] = '<code>' + parts[i] + '</code>'
|
||||
return ''.join(parts)
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
def find_line_number_of_relevant_line_in_file(diff_files: List[FilePatchInfo],
|
||||
relevant_file: str,
|
||||
relevant_line_in_file: str,
|
||||
absolute_position: int = None) -> Tuple[int, int]:
|
||||
position = -1
|
||||
if absolute_position is None:
|
||||
absolute_position = -1
|
||||
re_hunk_header = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)")
|
||||
|
||||
for file in diff_files:
|
||||
if file.filename and (file.filename.strip() == relevant_file):
|
||||
patch = file.patch
|
||||
patch_lines = patch.splitlines()
|
||||
delta = 0
|
||||
start1, size1, start2, size2 = 0, 0, 0, 0
|
||||
if absolute_position != -1: # matching absolute to relative
|
||||
for i, line in enumerate(patch_lines):
|
||||
# new hunk
|
||||
if line.startswith('@@'):
|
||||
delta = 0
|
||||
match = re_hunk_header.match(line)
|
||||
start1, size1, start2, size2 = map(int, match.groups()[:4])
|
||||
elif not line.startswith('-'):
|
||||
delta += 1
|
||||
|
||||
#
|
||||
absolute_position_curr = start2 + delta - 1
|
||||
|
||||
if absolute_position_curr == absolute_position:
|
||||
position = i
|
||||
break
|
||||
else:
|
||||
# try to find the line in the patch using difflib, with some margin of error
|
||||
matches_difflib: list[str | Any] = difflib.get_close_matches(relevant_line_in_file,
|
||||
patch_lines, n=3, cutoff=0.93)
|
||||
if len(matches_difflib) == 1 and matches_difflib[0].startswith('+'):
|
||||
relevant_line_in_file = matches_difflib[0]
|
||||
|
||||
|
||||
for i, line in enumerate(patch_lines):
|
||||
if line.startswith('@@'):
|
||||
delta = 0
|
||||
match = re_hunk_header.match(line)
|
||||
start1, size1, start2, size2 = map(int, match.groups()[:4])
|
||||
elif not line.startswith('-'):
|
||||
delta += 1
|
||||
|
||||
if relevant_line_in_file in line and line[0] != '-':
|
||||
position = i
|
||||
absolute_position = start2 + delta - 1
|
||||
break
|
||||
|
||||
if position == -1 and relevant_line_in_file[0] == '+':
|
||||
no_plus_line = relevant_line_in_file[1:].lstrip()
|
||||
for i, line in enumerate(patch_lines):
|
||||
if line.startswith('@@'):
|
||||
delta = 0
|
||||
match = re_hunk_header.match(line)
|
||||
start1, size1, start2, size2 = map(int, match.groups()[:4])
|
||||
elif not line.startswith('-'):
|
||||
delta += 1
|
||||
|
||||
if no_plus_line in line and line[0] != '-':
|
||||
# The model might add a '+' to the beginning of the relevant_line_in_file even if originally
|
||||
# it's a context line
|
||||
position = i
|
||||
absolute_position = start2 + delta - 1
|
||||
break
|
||||
return position, absolute_position
|
||||
|
@ -6,7 +6,8 @@ from pr_agent.agent.pr_agent import PRAgent, commands
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.log import setup_logger
|
||||
|
||||
setup_logger()
|
||||
log_level = os.environ.get("LOG_LEVEL", "INFO")
|
||||
setup_logger(log_level)
|
||||
|
||||
|
||||
|
||||
|
@ -18,6 +18,7 @@ global_settings = Dynaconf(
|
||||
"settings/language_extensions.toml",
|
||||
"settings/pr_reviewer_prompts.toml",
|
||||
"settings/pr_questions_prompts.toml",
|
||||
"settings/pr_line_questions_prompts.toml",
|
||||
"settings/pr_description_prompts.toml",
|
||||
"settings/pr_code_suggestions_prompts.toml",
|
||||
"settings/pr_sort_code_suggestions_prompts.toml",
|
||||
|
@ -4,11 +4,14 @@ from urllib.parse import urlparse
|
||||
|
||||
from ..log import get_logger
|
||||
from ..algo.language_handler import is_valid_file
|
||||
from ..algo.utils import clip_tokens, load_large_diff
|
||||
from ..algo.utils import clip_tokens, find_line_number_of_relevant_line_in_file, load_large_diff
|
||||
from ..config_loader import get_settings
|
||||
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
||||
from .git_provider import GitProvider
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
|
||||
AZURE_DEVOPS_AVAILABLE = True
|
||||
ADO_APP_CLIENT_DEFAULT_ID = "499b84ac-1321-427f-aa17-267ca6975798/.default"
|
||||
MAX_PR_DESCRIPTION_AZURE_LENGTH = 4000-1
|
||||
|
||||
try:
|
||||
# noinspection PyUnresolvedReferences
|
||||
@ -16,6 +19,8 @@ try:
|
||||
# noinspection PyUnresolvedReferences
|
||||
from azure.devops.connection import Connection
|
||||
# noinspection PyUnresolvedReferences
|
||||
from azure.identity import DefaultAzureCredential
|
||||
# noinspection PyUnresolvedReferences
|
||||
from azure.devops.v7_1.git.models import (
|
||||
Comment,
|
||||
CommentThread,
|
||||
@ -37,7 +42,7 @@ class AzureDevopsProvider(GitProvider):
|
||||
)
|
||||
|
||||
self.azure_devops_client = self._get_azure_devops_client()
|
||||
|
||||
self.diff_files = None
|
||||
self.workspace_slug = None
|
||||
self.repo_slug = None
|
||||
self.repo = None
|
||||
@ -123,6 +128,19 @@ class AzureDevopsProvider(GitProvider):
|
||||
def get_pr_description_full(self) -> str:
|
||||
return self.pr.description
|
||||
|
||||
def edit_comment(self, comment, body: str):
|
||||
try:
|
||||
self.azure_devops_client.update_comment(
|
||||
repository_id=self.repo_slug,
|
||||
pull_request_id=self.pr_num,
|
||||
thread_id=comment["thread_id"],
|
||||
comment_id=comment["comment_id"],
|
||||
comment=Comment(content=body),
|
||||
project=self.workspace_slug,
|
||||
)
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to edit comment, error: {e}")
|
||||
|
||||
def remove_comment(self, comment):
|
||||
try:
|
||||
self.azure_devops_client.delete_comment(
|
||||
@ -162,8 +180,6 @@ class AzureDevopsProvider(GitProvider):
|
||||
def is_supported(self, capability: str) -> bool:
|
||||
if capability in [
|
||||
"get_issue_comments",
|
||||
"create_inline_comment",
|
||||
"publish_inline_comments",
|
||||
]:
|
||||
return False
|
||||
return True
|
||||
@ -182,7 +198,7 @@ class AzureDevopsProvider(GitProvider):
|
||||
include_content=True,
|
||||
path=".pr_agent.toml",
|
||||
)
|
||||
return contents
|
||||
return list(contents)[0]
|
||||
except Exception as e:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().error(f"Failed to get repo settings, error: {e}")
|
||||
@ -207,6 +223,10 @@ class AzureDevopsProvider(GitProvider):
|
||||
|
||||
def get_diff_files(self) -> list[FilePatchInfo]:
|
||||
try:
|
||||
|
||||
if self.diff_files:
|
||||
return self.diff_files
|
||||
|
||||
base_sha = self.pr.last_merge_target_commit
|
||||
head_sha = self.pr.last_merge_source_commit
|
||||
|
||||
@ -304,27 +324,44 @@ class AzureDevopsProvider(GitProvider):
|
||||
edit_type=edit_type,
|
||||
)
|
||||
)
|
||||
|
||||
self.diff_files = diff_files
|
||||
return diff_files
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return []
|
||||
|
||||
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
|
||||
def publish_comment(self, pr_comment: str, is_temporary: bool = False, thread_context=None):
|
||||
comment = Comment(content=pr_comment)
|
||||
thread = CommentThread(comments=[comment])
|
||||
thread = CommentThread(comments=[comment], thread_context=thread_context, status=1)
|
||||
thread_response = self.azure_devops_client.create_thread(
|
||||
comment_thread=thread,
|
||||
project=self.workspace_slug,
|
||||
repository_id=self.repo_slug,
|
||||
pull_request_id=self.pr_num,
|
||||
)
|
||||
response = {"thread_id": thread_response.id, "comment_id": thread_response.comments[0].id}
|
||||
if is_temporary:
|
||||
self.temp_comments.append(
|
||||
{"thread_id": thread_response.id, "comment_id": thread_response.comments[0].id}
|
||||
)
|
||||
self.temp_comments.append(response)
|
||||
return response
|
||||
|
||||
def publish_description(self, pr_title: str, pr_body: str):
|
||||
if len(pr_body) > MAX_PR_DESCRIPTION_AZURE_LENGTH:
|
||||
|
||||
usage_guide_text='<details> <summary><strong>✨ Describe tool usage guide:</strong></summary><hr>'
|
||||
ind = pr_body.find(usage_guide_text)
|
||||
if ind != -1:
|
||||
pr_body = pr_body[:ind]
|
||||
|
||||
if len(pr_body) > MAX_PR_DESCRIPTION_AZURE_LENGTH:
|
||||
changes_walkthrough_text = '## **Changes walkthrough**'
|
||||
ind = pr_body.find(changes_walkthrough_text)
|
||||
if ind != -1:
|
||||
pr_body = pr_body[:ind]
|
||||
|
||||
if len(pr_body) > MAX_PR_DESCRIPTION_AZURE_LENGTH:
|
||||
trunction_message = " ... (description truncated due to length limit)"
|
||||
pr_body = pr_body[:MAX_PR_DESCRIPTION_AZURE_LENGTH - len(trunction_message)] + trunction_message
|
||||
get_logger().warning("PR description was truncated due to length limit")
|
||||
try:
|
||||
updated_pr = GitPullRequest()
|
||||
updated_pr.title = pr_title
|
||||
@ -347,17 +384,50 @@ class AzureDevopsProvider(GitProvider):
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to remove temp comments, error: {e}")
|
||||
|
||||
def publish_inline_comment(
|
||||
self, body: str, relevant_file: str, relevant_line_in_file: str
|
||||
):
|
||||
raise NotImplementedError(
|
||||
"Azure DevOps provider does not support publishing inline comment yet"
|
||||
)
|
||||
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||
self.publish_inline_comments([self.create_inline_comment(body, relevant_file, relevant_line_in_file)])
|
||||
|
||||
def publish_inline_comments(self, comments: list[dict]):
|
||||
raise NotImplementedError(
|
||||
"Azure DevOps provider does not support publishing inline comments yet"
|
||||
)
|
||||
|
||||
def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str,
|
||||
absolute_position: int = None):
|
||||
position, absolute_position = find_line_number_of_relevant_line_in_file(self.get_diff_files(),
|
||||
relevant_file.strip('`'),
|
||||
relevant_line_in_file,
|
||||
absolute_position)
|
||||
if position == -1:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}")
|
||||
subject_type = "FILE"
|
||||
else:
|
||||
subject_type = "LINE"
|
||||
path = relevant_file.strip()
|
||||
return dict(body=body, path=path, position=position, absolute_position=absolute_position) if subject_type == "LINE" else {}
|
||||
|
||||
def publish_inline_comments(self, comments: list[dict], disable_fallback: bool = False):
|
||||
overall_sucess = True
|
||||
for comment in comments:
|
||||
try:
|
||||
self.publish_comment(comment["body"],
|
||||
thread_context={
|
||||
"filePath": comment["path"],
|
||||
"rightFileStart": {
|
||||
"line": comment["absolute_position"],
|
||||
"offset": comment["position"],
|
||||
},
|
||||
"rightFileEnd": {
|
||||
"line": comment["absolute_position"],
|
||||
"offset": comment["position"],
|
||||
},
|
||||
})
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(
|
||||
f"Published code suggestion on {self.pr_num} at {comment['path']}"
|
||||
)
|
||||
except Exception as e:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().error(f"Failed to publish code suggestion, error: {e}")
|
||||
overall_sucess = False
|
||||
return overall_sucess
|
||||
|
||||
def get_title(self):
|
||||
return self.pr.title
|
||||
@ -412,7 +482,7 @@ class AzureDevopsProvider(GitProvider):
|
||||
"Azure DevOps provider does not support issue comments yet"
|
||||
)
|
||||
|
||||
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
|
||||
def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]:
|
||||
return True
|
||||
|
||||
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
||||
@ -440,13 +510,30 @@ class AzureDevopsProvider(GitProvider):
|
||||
|
||||
@staticmethod
|
||||
def _get_azure_devops_client():
|
||||
try:
|
||||
pat = get_settings().azure_devops.pat
|
||||
org = get_settings().azure_devops.org
|
||||
except AttributeError as e:
|
||||
raise ValueError("Azure DevOps PAT token is required ") from e
|
||||
org = get_settings().azure_devops.get("org", None)
|
||||
pat = get_settings().azure_devops.get("pat", None)
|
||||
|
||||
credentials = BasicAuthentication("", pat)
|
||||
if not org:
|
||||
raise ValueError("Azure DevOps organization is required")
|
||||
|
||||
if pat:
|
||||
auth_token = pat
|
||||
else:
|
||||
try:
|
||||
# try to use azure default credentials
|
||||
# see https://learn.microsoft.com/en-us/python/api/overview/azure/identity-readme?view=azure-python
|
||||
# for usage and env var configuration of user-assigned managed identity, local machine auth etc.
|
||||
get_logger().info("No PAT found in settings, trying to use Azure Default Credentials.")
|
||||
credentials = DefaultAzureCredential()
|
||||
accessToken = credentials.get_token(ADO_APP_CLIENT_DEFAULT_ID)
|
||||
auth_token = accessToken.token
|
||||
except Exception as e:
|
||||
get_logger().error(f"No PAT found in settings, and Azure Default Authentication failed, error: {e}")
|
||||
raise
|
||||
|
||||
credentials = BasicAuthentication("", auth_token)
|
||||
|
||||
credentials = BasicAuthentication("", auth_token)
|
||||
azure_devops_connection = Connection(base_url=org, creds=credentials)
|
||||
azure_devops_client = azure_devops_connection.clients.get_git_client()
|
||||
|
||||
@ -476,3 +563,4 @@ class AzureDevopsProvider(GitProvider):
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().error(f"Failed to get pr id, error: {e}")
|
||||
return ""
|
||||
|
||||
|
@ -6,10 +6,11 @@ import requests
|
||||
from atlassian.bitbucket import Cloud
|
||||
from starlette_context import context
|
||||
|
||||
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
|
||||
from pr_agent.algo.types import FilePatchInfo, EDIT_TYPE
|
||||
from ..algo.utils import find_line_number_of_relevant_line_in_file
|
||||
from ..config_loader import get_settings
|
||||
from ..log import get_logger
|
||||
from .git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
|
||||
from .git_provider import GitProvider
|
||||
|
||||
|
||||
class BitbucketProvider(GitProvider):
|
||||
@ -159,7 +160,7 @@ class BitbucketProvider(GitProvider):
|
||||
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):
|
||||
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True, name='review'):
|
||||
try:
|
||||
for comment in self.pr.comments():
|
||||
body = comment.raw
|
||||
@ -167,15 +168,15 @@ class BitbucketProvider(GitProvider):
|
||||
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"
|
||||
updated_header = f"{initial_header}\n\n### ({name.capitalize()} 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")
|
||||
get_logger().info(f"Persistent mode - updating comment {comment_url} to latest {name} 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}")
|
||||
f"**[Persistent {name}]({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}")
|
||||
@ -186,6 +187,13 @@ class BitbucketProvider(GitProvider):
|
||||
comment = self.pr.comment(pr_comment)
|
||||
if is_temporary:
|
||||
self.temp_comments.append(comment["id"])
|
||||
return comment
|
||||
|
||||
def edit_comment(self, comment, body: str):
|
||||
try:
|
||||
comment.update(body)
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to update comment, error: {e}")
|
||||
|
||||
def remove_initial_comment(self):
|
||||
try:
|
||||
@ -239,8 +247,8 @@ class BitbucketProvider(GitProvider):
|
||||
|
||||
def generate_link_to_relevant_line_number(self, suggestion) -> str:
|
||||
try:
|
||||
relevant_file = suggestion['relevant file'].strip('`').strip("'")
|
||||
relevant_line_str = suggestion['relevant line']
|
||||
relevant_file = suggestion['relevant_file'].strip('`').strip("'").rstrip()
|
||||
relevant_line_str = suggestion['relevant_line'].rstrip()
|
||||
if not relevant_line_str:
|
||||
return ""
|
||||
|
||||
@ -290,7 +298,7 @@ class BitbucketProvider(GitProvider):
|
||||
"Bitbucket provider does not support issue comments yet"
|
||||
)
|
||||
|
||||
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
|
||||
def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]:
|
||||
return True
|
||||
|
||||
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
||||
|
@ -6,9 +6,9 @@ import requests
|
||||
from atlassian.bitbucket import Bitbucket
|
||||
from starlette_context import context
|
||||
|
||||
from .git_provider import FilePatchInfo, GitProvider, EDIT_TYPE
|
||||
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
|
||||
from ..algo.utils import load_large_diff
|
||||
from .git_provider import GitProvider
|
||||
from pr_agent.algo.types import FilePatchInfo
|
||||
from ..algo.utils import load_large_diff, find_line_number_of_relevant_line_in_file
|
||||
from ..config_loader import get_settings
|
||||
from ..log import get_logger
|
||||
|
||||
@ -246,8 +246,8 @@ class BitbucketServerProvider(GitProvider):
|
||||
|
||||
def generate_link_to_relevant_line_number(self, suggestion) -> str:
|
||||
try:
|
||||
relevant_file = suggestion['relevant file'].strip('`').strip("'")
|
||||
relevant_line_str = suggestion['relevant line']
|
||||
relevant_file = suggestion['relevant_file'].strip('`').strip("'").rstrip()
|
||||
relevant_line_str = suggestion['relevant_line'].rstrip()
|
||||
if not relevant_line_str:
|
||||
return ""
|
||||
|
||||
@ -288,7 +288,7 @@ class BitbucketServerProvider(GitProvider):
|
||||
"Bitbucket provider does not support issue comments yet"
|
||||
)
|
||||
|
||||
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
|
||||
def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]:
|
||||
return True
|
||||
|
||||
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
||||
|
@ -5,9 +5,9 @@ from typing import List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pr_agent.git_providers.codecommit_client import CodeCommitClient
|
||||
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
from ..algo.utils import load_large_diff
|
||||
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
||||
from .git_provider import GitProvider
|
||||
from ..config_loader import get_settings
|
||||
from ..log import get_logger
|
||||
|
||||
@ -297,7 +297,7 @@ class CodeCommitProvider(GitProvider):
|
||||
settings_filename = ".pr_agent.toml"
|
||||
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, disable_eyes: bool = False) -> Optional[int]:
|
||||
get_logger().info("CodeCommit provider does not support eyes reaction yet")
|
||||
return True
|
||||
|
||||
|
@ -13,7 +13,8 @@ import urllib3.util
|
||||
from git import Repo
|
||||
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.git_providers.git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
||||
from pr_agent.git_providers.git_provider import GitProvider
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
from pr_agent.git_providers.local_git_provider import PullRequestMimic
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
@ -211,7 +212,7 @@ class GerritProvider(GitProvider):
|
||||
raise NotImplementedError(
|
||||
'Getting labels is not implemented for the gerrit provider')
|
||||
|
||||
def add_eyes_reaction(self, issue_comment_id: int):
|
||||
def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False):
|
||||
raise NotImplementedError(
|
||||
'Adding reactions is not implemented for the gerrit provider')
|
||||
|
||||
|
@ -1,35 +1,13 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
# enum EDIT_TYPE (ADDED, DELETED, MODIFIED, RENAMED)
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.algo.types import FilePatchInfo
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
|
||||
class EDIT_TYPE(Enum):
|
||||
ADDED = 1
|
||||
DELETED = 2
|
||||
MODIFIED = 3
|
||||
RENAMED = 4
|
||||
UNKNOWN = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilePatchInfo:
|
||||
base_file: str
|
||||
head_file: str
|
||||
patch: str
|
||||
filename: str
|
||||
tokens: int = -1
|
||||
edit_type: EDIT_TYPE = EDIT_TYPE.UNKNOWN
|
||||
old_filename: str = None
|
||||
num_plus_lines: int = -1
|
||||
num_minus_lines: int = -1
|
||||
|
||||
|
||||
class GitProvider(ABC):
|
||||
@abstractmethod
|
||||
def is_supported(self, capability: str) -> bool:
|
||||
@ -63,6 +41,12 @@ class GitProvider(ABC):
|
||||
def get_pr_description_full(self) -> str:
|
||||
pass
|
||||
|
||||
def edit_comment(self, comment, body: str):
|
||||
pass
|
||||
|
||||
def reply_to_comment_from_comment_id(self, comment_id: int, body: str):
|
||||
pass
|
||||
|
||||
def get_pr_description(self, *, full: bool = True) -> str:
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.algo.utils import clip_tokens
|
||||
@ -73,9 +57,12 @@ class GitProvider(ABC):
|
||||
return description
|
||||
|
||||
def get_user_description(self) -> str:
|
||||
if hasattr(self, 'user_description') and not (self.user_description is None):
|
||||
return self.user_description
|
||||
|
||||
description = (self.get_pr_description_full() or "").strip()
|
||||
description_lowercase = description.lower()
|
||||
get_logger().info(f"Existing description:\n{description_lowercase}")
|
||||
get_logger().debug(f"Existing description", description=description_lowercase)
|
||||
|
||||
# if the existing description wasn't generated by the pr-agent, just return it as-is
|
||||
if not self._is_generated_by_pr_agent(description_lowercase):
|
||||
@ -109,7 +96,9 @@ class GitProvider(ABC):
|
||||
if original_user_description.lower().startswith(user_description_header):
|
||||
original_user_description = original_user_description[len(user_description_header):].strip()
|
||||
|
||||
get_logger().info(f"Extracted user description from existing description:\n{original_user_description}")
|
||||
get_logger().info(f"Extracted user description from existing description",
|
||||
description=original_user_description)
|
||||
self.user_description = original_user_description
|
||||
return original_user_description
|
||||
|
||||
def _possible_headers(self):
|
||||
@ -135,7 +124,7 @@ class GitProvider(ABC):
|
||||
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):
|
||||
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool, name='review'):
|
||||
self.publish_comment(pr_comment)
|
||||
|
||||
@abstractmethod
|
||||
@ -178,7 +167,7 @@ class GitProvider(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
|
||||
def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -190,9 +179,21 @@ class GitProvider(ABC):
|
||||
def get_commit_messages(self):
|
||||
pass
|
||||
|
||||
def get_pr_url(self) -> str:
|
||||
if hasattr(self, 'pr_url'):
|
||||
return self.pr_url
|
||||
return ""
|
||||
|
||||
def get_latest_commit_url(self) -> str:
|
||||
return ""
|
||||
|
||||
def auto_approve(self) -> bool:
|
||||
return False
|
||||
|
||||
def calc_pr_statistics(self, pull_request_data: dict):
|
||||
return {}
|
||||
|
||||
|
||||
def get_main_pr_language(languages, files) -> str:
|
||||
"""
|
||||
Get the main language of the commit. Return an empty string if cannot determine.
|
||||
@ -261,7 +262,6 @@ def get_main_pr_language(languages, files) -> str:
|
||||
|
||||
return main_language_str
|
||||
|
||||
|
||||
class IncrementalPR:
|
||||
def __init__(self, is_incremental: bool = False):
|
||||
self.is_incremental = is_incremental
|
||||
|
@ -9,12 +9,12 @@ from retry import retry
|
||||
from starlette_context import context
|
||||
|
||||
from ..algo.language_handler import is_valid_file
|
||||
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
|
||||
from ..algo.utils import load_large_diff, clip_tokens
|
||||
from ..algo.utils import load_large_diff, clip_tokens, find_line_number_of_relevant_line_in_file
|
||||
from ..config_loader import get_settings
|
||||
from ..log import get_logger
|
||||
from ..servers.utils import RateLimitExceeded
|
||||
from .git_provider import FilePatchInfo, GitProvider, IncrementalPR, EDIT_TYPE
|
||||
from .git_provider import GitProvider, IncrementalPR
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
|
||||
|
||||
class GithubProvider(GitProvider):
|
||||
@ -24,6 +24,8 @@ class GithubProvider(GitProvider):
|
||||
self.installation_id = context.get("installation_id", None)
|
||||
except Exception:
|
||||
self.installation_id = None
|
||||
self.base_url = get_settings().get("GITHUB.BASE_URL", "https://api.github.com").rstrip("/")
|
||||
self.base_url_html = self.base_url.split("api/")[0].rstrip("/") if "api/" in self.base_url else "https://github.com"
|
||||
self.github_client = self._get_github_client()
|
||||
self.repo = None
|
||||
self.pr_num = None
|
||||
@ -34,23 +36,27 @@ class GithubProvider(GitProvider):
|
||||
self.incremental = incremental
|
||||
if pr_url and 'pull' in pr_url:
|
||||
self.set_pr(pr_url)
|
||||
self.last_commit_id = list(self.pr.get_commits())[-1]
|
||||
self.pr_commits = list(self.pr.get_commits())
|
||||
if self.incremental.is_incremental:
|
||||
self.get_incremental_commits()
|
||||
self.last_commit_id = self.pr_commits[-1]
|
||||
self.pr_url = self.get_pr_url() # pr_url for github actions can be as api.github.com, so we need to get the url from the pr object
|
||||
else:
|
||||
self.pr_commits = None
|
||||
|
||||
def is_supported(self, capability: str) -> bool:
|
||||
return True
|
||||
|
||||
def get_pr_url(self) -> str:
|
||||
return f"https://github.com/{self.repo}/pull/{self.pr_num}"
|
||||
return self.pr.html_url
|
||||
|
||||
def set_pr(self, pr_url: str):
|
||||
self.repo, self.pr_num = self._parse_pr_url(pr_url)
|
||||
self.pr = self._get_pr()
|
||||
if self.incremental.is_incremental:
|
||||
self.get_incremental_commits()
|
||||
|
||||
def get_incremental_commits(self):
|
||||
self.commits = list(self.pr.get_commits())
|
||||
if not self.pr_commits:
|
||||
self.pr_commits = list(self.pr.get_commits())
|
||||
|
||||
self.previous_review = self.get_previous_review(full=True, incremental=True)
|
||||
if self.previous_review:
|
||||
@ -68,14 +74,14 @@ class GithubProvider(GitProvider):
|
||||
def get_commit_range(self):
|
||||
last_review_time = self.previous_review.created_at
|
||||
first_new_commit_index = None
|
||||
for index in range(len(self.commits) - 1, -1, -1):
|
||||
if self.commits[index].commit.author.date > last_review_time:
|
||||
self.incremental.first_new_commit = self.commits[index]
|
||||
for index in range(len(self.pr_commits) - 1, -1, -1):
|
||||
if self.pr_commits[index].commit.author.date > last_review_time:
|
||||
self.incremental.first_new_commit = self.pr_commits[index]
|
||||
first_new_commit_index = index
|
||||
else:
|
||||
self.incremental.last_seen_commit = self.commits[index]
|
||||
self.incremental.last_seen_commit = self.pr_commits[index]
|
||||
break
|
||||
return self.commits[first_new_commit_index:] if first_new_commit_index is not None else []
|
||||
return self.pr_commits[first_new_commit_index:] if first_new_commit_index is not None else []
|
||||
|
||||
def get_previous_review(self, *, full: bool, incremental: bool):
|
||||
if not (full or incremental):
|
||||
@ -84,7 +90,7 @@ class GithubProvider(GitProvider):
|
||||
self.comments = list(self.pr.get_issue_comments())
|
||||
prefixes = []
|
||||
if full:
|
||||
prefixes.append("## PR Analysis")
|
||||
prefixes.append("## PR Review")
|
||||
if incremental:
|
||||
prefixes.append("## Incremental PR Review")
|
||||
for index in range(len(self.comments) - 1, -1, -1):
|
||||
@ -94,10 +100,18 @@ class GithubProvider(GitProvider):
|
||||
def get_files(self):
|
||||
if self.incremental.is_incremental and self.file_set:
|
||||
return self.file_set.values()
|
||||
if not self.git_files:
|
||||
# bring files from GitHub only once
|
||||
try:
|
||||
git_files = context.get("git_files", None)
|
||||
if git_files:
|
||||
return git_files
|
||||
self.git_files = self.pr.get_files()
|
||||
return self.git_files
|
||||
context["git_files"] = self.git_files
|
||||
return self.git_files
|
||||
except Exception:
|
||||
if not self.git_files:
|
||||
self.git_files = self.pr.get_files()
|
||||
return self.git_files
|
||||
|
||||
|
||||
@retry(exceptions=RateLimitExceeded,
|
||||
tries=get_settings().github.ratelimit_retries, delay=2, backoff=2, jitter=(1, 3))
|
||||
@ -111,6 +125,13 @@ class GithubProvider(GitProvider):
|
||||
or renamed files in the merge request.
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
diff_files = context.get("diff_files", None)
|
||||
if diff_files:
|
||||
return diff_files
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.diff_files:
|
||||
return self.diff_files
|
||||
|
||||
@ -156,6 +177,11 @@ class GithubProvider(GitProvider):
|
||||
diff_files.append(file_patch_canonical_structure)
|
||||
|
||||
self.diff_files = diff_files
|
||||
try:
|
||||
context["diff_files"] = diff_files
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return diff_files
|
||||
|
||||
except GithubException.RateLimitExceededException as e:
|
||||
@ -171,7 +197,7 @@ class GithubProvider(GitProvider):
|
||||
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):
|
||||
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True, name='review'):
|
||||
prev_comments = list(self.pr.get_issue_comments())
|
||||
for comment in prev_comments:
|
||||
body = comment.body
|
||||
@ -179,14 +205,14 @@ class GithubProvider(GitProvider):
|
||||
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"
|
||||
updated_header = f"{initial_header}\n\n### ({name.capitalize()} 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}")
|
||||
f"**[Persistent {name}]({comment_url})** updated to latest commit {latest_commit_url}")
|
||||
return
|
||||
self.publish_comment(pr_comment)
|
||||
|
||||
@ -202,6 +228,7 @@ class GithubProvider(GitProvider):
|
||||
if not hasattr(self.pr, 'comments_list'):
|
||||
self.pr.comments_list = []
|
||||
self.pr.comments_list.append(response)
|
||||
return response
|
||||
|
||||
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||
self.publish_inline_comments([self.create_inline_comment(body, relevant_file, relevant_line_in_file)])
|
||||
@ -380,6 +407,19 @@ class GithubProvider(GitProvider):
|
||||
get_logger().error(f"Failed to publish code suggestion, error: {e}")
|
||||
return False
|
||||
|
||||
def edit_comment(self, comment, body: str):
|
||||
comment.edit(body=body)
|
||||
|
||||
def reply_to_comment_from_comment_id(self, comment_id: int, body: str):
|
||||
try:
|
||||
# self.pr.get_issue_comment(comment_id).edit(body)
|
||||
headers, data_patch = self.pr._requester.requestJsonAndCheck(
|
||||
"POST", f"{self.base_url}/repos/{self.repo}/pulls/{self.pr_num}/comments/{comment_id}/replies",
|
||||
input={"body": body}
|
||||
)
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to reply comment, error: {e}")
|
||||
|
||||
def remove_initial_comment(self):
|
||||
try:
|
||||
for comment in getattr(self.pr, 'comments_list', []):
|
||||
@ -438,23 +478,31 @@ class GithubProvider(GitProvider):
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
|
||||
def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]:
|
||||
if disable_eyes:
|
||||
return None
|
||||
try:
|
||||
reaction = self.pr.get_issue_comment(issue_comment_id).create_reaction("eyes")
|
||||
return reaction.id
|
||||
headers, data_patch = self.pr._requester.requestJsonAndCheck(
|
||||
"POST", f"{self.base_url}/repos/{self.repo}/issues/comments/{issue_comment_id}/reactions",
|
||||
input={"content": "eyes"}
|
||||
)
|
||||
return data_patch.get("id", None)
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to add eyes reaction, error: {e}")
|
||||
return None
|
||||
|
||||
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
||||
def remove_reaction(self, issue_comment_id: int, reaction_id: str) -> bool:
|
||||
try:
|
||||
self.pr.get_issue_comment(issue_comment_id).delete_reaction(reaction_id)
|
||||
# self.pr.get_issue_comment(issue_comment_id).delete_reaction(reaction_id)
|
||||
headers, data_patch = self.pr._requester.requestJsonAndCheck(
|
||||
"DELETE",
|
||||
f"{self.base_url}/repos/{self.repo}/issues/comments/{issue_comment_id}/reactions/{reaction_id}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to remove eyes reaction, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _parse_pr_url(pr_url: str) -> Tuple[str, int]:
|
||||
parsed_url = urlparse(pr_url)
|
||||
@ -526,7 +574,7 @@ class GithubProvider(GitProvider):
|
||||
raise ValueError("GitHub app installation ID is required when using GitHub app deployment")
|
||||
auth = AppAuthentication(app_id=app_id, private_key=private_key,
|
||||
installation_id=self.installation_id)
|
||||
return Github(app_auth=auth, base_url=get_settings().github.base_url)
|
||||
return Github(app_auth=auth, base_url=self.base_url)
|
||||
|
||||
if deployment_type == 'user':
|
||||
try:
|
||||
@ -535,7 +583,7 @@ class GithubProvider(GitProvider):
|
||||
raise ValueError(
|
||||
"GitHub token is required when using user deployment. See: "
|
||||
"https://github.com/Codium-ai/pr-agent#method-2-run-from-source") from e
|
||||
return Github(auth=Auth.Token(token), base_url=get_settings().github.base_url)
|
||||
return Github(auth=Auth.Token(token), base_url=self.base_url)
|
||||
|
||||
def _get_repo(self):
|
||||
if hasattr(self, 'repo_obj') and \
|
||||
@ -603,8 +651,8 @@ class GithubProvider(GitProvider):
|
||||
|
||||
def generate_link_to_relevant_line_number(self, suggestion) -> str:
|
||||
try:
|
||||
relevant_file = suggestion['relevant file'].strip('`').strip("'")
|
||||
relevant_line_str = suggestion['relevant line']
|
||||
relevant_file = suggestion['relevant_file'].strip('`').strip("'").strip('\n')
|
||||
relevant_line_str = suggestion['relevant_line'].strip('\n')
|
||||
if not relevant_line_str:
|
||||
return ""
|
||||
|
||||
@ -618,7 +666,7 @@ class GithubProvider(GitProvider):
|
||||
|
||||
# link to diff
|
||||
sha_file = hashlib.sha256(relevant_file.encode('utf-8')).hexdigest()
|
||||
link = f"https://github.com/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{absolute_position}"
|
||||
link = f"{self.base_url_html}/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{absolute_position}"
|
||||
return link
|
||||
except Exception as e:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
@ -629,11 +677,11 @@ class GithubProvider(GitProvider):
|
||||
def get_line_link(self, relevant_file: str, relevant_line_start: int, relevant_line_end: int = None) -> str:
|
||||
sha_file = hashlib.sha256(relevant_file.encode('utf-8')).hexdigest()
|
||||
if relevant_line_start == -1:
|
||||
link = f"https://github.com/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}"
|
||||
link = f"{self.base_url_html}/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}"
|
||||
elif relevant_line_end:
|
||||
link = f"https://github.com/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{relevant_line_start}-R{relevant_line_end}"
|
||||
link = f"{self.base_url_html}/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{relevant_line_start}-R{relevant_line_end}"
|
||||
else:
|
||||
link = f"https://github.com/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{relevant_line_start}"
|
||||
link = f"{self.base_url_html}/{self.repo}/pull/{self.pr_num}/files#diff-{sha_file}R{relevant_line_start}"
|
||||
return link
|
||||
|
||||
|
||||
@ -643,3 +691,34 @@ class GithubProvider(GitProvider):
|
||||
return pr_id
|
||||
except:
|
||||
return ""
|
||||
|
||||
def auto_approve(self) -> bool:
|
||||
try:
|
||||
res = self.pr.create_review(event="APPROVE")
|
||||
if res.state == "APPROVED":
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to auto-approve, error: {e}")
|
||||
return False
|
||||
|
||||
def calc_pr_statistics(self, pull_request_data: dict):
|
||||
try:
|
||||
out = {}
|
||||
from datetime import datetime
|
||||
created_at = pull_request_data['created_at']
|
||||
closed_at = pull_request_data['closed_at']
|
||||
closed_at_datetime = datetime.strptime(closed_at, "%Y-%m-%dT%H:%M:%SZ")
|
||||
created_at_datetime = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ")
|
||||
difference = closed_at_datetime - created_at_datetime
|
||||
out['hours'] = difference.total_seconds() / 3600
|
||||
out['commits'] = pull_request_data['commits']
|
||||
out['comments'] = pull_request_data['comments']
|
||||
out['review_comments'] = pull_request_data['review_comments']
|
||||
out['changed_files'] = pull_request_data['changed_files']
|
||||
out['additions'] = pull_request_data['additions']
|
||||
out['deletions'] = pull_request_data['deletions']
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to calculate PR statistics, error: {e}")
|
||||
return {}
|
||||
return out
|
||||
|
@ -7,10 +7,10 @@ import gitlab
|
||||
from gitlab import GitlabGetError
|
||||
|
||||
from ..algo.language_handler import is_valid_file
|
||||
from ..algo.pr_processing import find_line_number_of_relevant_line_in_file
|
||||
from ..algo.utils import load_large_diff, clip_tokens
|
||||
from ..algo.utils import load_large_diff, clip_tokens, find_line_number_of_relevant_line_in_file
|
||||
from ..config_loader import get_settings
|
||||
from .git_provider import EDIT_TYPE, FilePatchInfo, GitProvider
|
||||
from .git_provider import GitProvider
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
from ..log import get_logger
|
||||
|
||||
|
||||
@ -151,21 +151,21 @@ class GitLabProvider(GitProvider):
|
||||
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):
|
||||
def publish_persistent_comment(self, pr_comment: str, initial_header: str, update_header: bool = True, name='review'):
|
||||
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"
|
||||
updated_header = f"{initial_header}\n\n### ({name.capitalize()} 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")
|
||||
get_logger().info(f"Persistent mode - updating comment {comment_url} to latest {name} 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}")
|
||||
f"**[Persistent {name}]({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}")
|
||||
@ -176,6 +176,14 @@ class GitLabProvider(GitProvider):
|
||||
comment = self.mr.notes.create({'body': mr_comment})
|
||||
if is_temporary:
|
||||
self.temp_comments.append(comment)
|
||||
return comment
|
||||
|
||||
def edit_comment(self, comment, body: str):
|
||||
self.mr.notes.update(comment.id,{'body': body} )
|
||||
|
||||
def reply_to_comment_from_comment_id(self, comment_id: int, body: str):
|
||||
discussion = self.mr.discussions.get(comment_id)
|
||||
discussion.notes.create({'body': body})
|
||||
|
||||
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str):
|
||||
edit_type, found, source_line_no, target_file, target_line_no = self.search_line(relevant_file,
|
||||
@ -360,7 +368,7 @@ class GitLabProvider(GitProvider):
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
|
||||
def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]:
|
||||
return True
|
||||
|
||||
def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool:
|
||||
@ -450,8 +458,8 @@ class GitLabProvider(GitProvider):
|
||||
|
||||
def generate_link_to_relevant_line_number(self, suggestion) -> str:
|
||||
try:
|
||||
relevant_file = suggestion['relevant file'].strip('`').strip("'")
|
||||
relevant_line_str = suggestion['relevant line']
|
||||
relevant_file = suggestion['relevant_file'].strip('`').strip("'").rstrip()
|
||||
relevant_line_str = suggestion['relevant_line'].rstrip()
|
||||
if not relevant_line_str:
|
||||
return ""
|
||||
|
||||
|
@ -5,7 +5,8 @@ from typing import List
|
||||
from git import Repo
|
||||
|
||||
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 GitProvider
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
|
||||
|
@ -7,14 +7,26 @@ from dynaconf import Dynaconf
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.git_providers import get_git_provider
|
||||
from pr_agent.log import get_logger
|
||||
from starlette_context import context
|
||||
|
||||
|
||||
def apply_repo_settings(pr_url):
|
||||
if get_settings().config.use_repo_settings_file:
|
||||
repo_settings_file = None
|
||||
try:
|
||||
git_provider = get_git_provider()(pr_url)
|
||||
repo_settings = git_provider.get_repo_settings()
|
||||
try:
|
||||
repo_settings = context.get("repo_settings", None)
|
||||
except Exception:
|
||||
repo_settings = None
|
||||
pass
|
||||
if repo_settings is None: # None is different from "", which is a valid value
|
||||
git_provider = get_git_provider()(pr_url)
|
||||
repo_settings = git_provider.get_repo_settings()
|
||||
try:
|
||||
context["repo_settings"] = repo_settings
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if repo_settings:
|
||||
repo_settings_file = None
|
||||
fd, repo_settings_file = tempfile.mkstemp(suffix='.toml')
|
||||
@ -26,7 +38,7 @@ def apply_repo_settings(pr_url):
|
||||
section_dict[key] = value
|
||||
get_settings().unset(section)
|
||||
get_settings().set(section, section_dict, merge=False)
|
||||
get_logger().info(f"Applying repo settings for section {section}, contents: {contents}")
|
||||
get_logger().info(f"Applying repo settings:\n{new_settings.as_dict()}")
|
||||
except Exception as e:
|
||||
get_logger().exception("Failed to apply repo settings", e)
|
||||
finally:
|
||||
|
13
pr_agent/identity_providers/__init__.py
Normal file
13
pr_agent/identity_providers/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.identity_providers.default_identity_provider import DefaultIdentityProvider
|
||||
|
||||
_IDENTITY_PROVIDERS = {
|
||||
'default': DefaultIdentityProvider
|
||||
}
|
||||
|
||||
|
||||
def get_identity_provider():
|
||||
identity_provider_id = get_settings().get("CONFIG.IDENTITY_PROVIDER", "default")
|
||||
if identity_provider_id not in _IDENTITY_PROVIDERS:
|
||||
raise ValueError(f"Unknown identity provider: {identity_provider_id}")
|
||||
return _IDENTITY_PROVIDERS[identity_provider_id]()
|
9
pr_agent/identity_providers/default_identity_provider.py
Normal file
9
pr_agent/identity_providers/default_identity_provider.py
Normal file
@ -0,0 +1,9 @@
|
||||
from pr_agent.identity_providers.identity_provider import Eligibility, IdentityProvider
|
||||
|
||||
|
||||
class DefaultIdentityProvider(IdentityProvider):
|
||||
def verify_eligibility(self, git_provider, git_provider_id, pr_url):
|
||||
return Eligibility.ELIGIBLE
|
||||
|
||||
def inc_invocation_count(self, git_provider, git_provider_id):
|
||||
pass
|
18
pr_agent/identity_providers/identity_provider.py
Normal file
18
pr_agent/identity_providers/identity_provider.py
Normal file
@ -0,0 +1,18 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Eligibility(Enum):
|
||||
NOT_ELIGIBLE = 0
|
||||
ELIGIBLE = 1
|
||||
TRIAL = 2
|
||||
|
||||
|
||||
class IdentityProvider(ABC):
|
||||
@abstractmethod
|
||||
def verify_eligibility(self, git_provider, git_provier_id, pr_url):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def inc_invocation_count(self, git_provider, git_provider_id):
|
||||
pass
|
@ -1,10 +1,13 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from enum import Enum
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pr_agent.config_loader import get_settings
|
||||
|
||||
|
||||
class LoggingFormat(str, Enum):
|
||||
CONSOLE = "CONSOLE"
|
||||
@ -15,23 +18,45 @@ def json_format(record: dict) -> str:
|
||||
return record["message"]
|
||||
|
||||
|
||||
def analytics_filter(record: dict) -> bool:
|
||||
return record.get("extra", {}).get("analytics", False)
|
||||
|
||||
|
||||
def inv_analytics_filter(record: dict) -> bool:
|
||||
return not record.get("extra", {}).get("analytics", False)
|
||||
|
||||
|
||||
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:
|
||||
if fmt == LoggingFormat.JSON and os.getenv("LOG_SANE", "0").lower() == "0": # better debugging github_app
|
||||
logger.remove(None)
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
filter=inv_analytics_filter,
|
||||
level=level,
|
||||
format="{message}",
|
||||
colorize=False,
|
||||
serialize=True,
|
||||
)
|
||||
elif fmt == LoggingFormat.CONSOLE:
|
||||
elif fmt == LoggingFormat.CONSOLE: # does not print the 'extra' fields
|
||||
logger.remove(None)
|
||||
logger.add(sys.stdout, level=level, colorize=True)
|
||||
logger.add(sys.stdout, level=level, colorize=True, filter=inv_analytics_filter)
|
||||
|
||||
log_folder = get_settings().get("CONFIG.ANALYTICS_FOLDER", "")
|
||||
if log_folder:
|
||||
pid = os.getpid()
|
||||
log_file = os.path.join(log_folder, f"pr-agent.{pid}.log")
|
||||
logger.add(
|
||||
log_file,
|
||||
filter=analytics_filter,
|
||||
level=level,
|
||||
format="{message}",
|
||||
colorize=False,
|
||||
serialize=True,
|
||||
)
|
||||
|
||||
return logger
|
||||
|
||||
|
@ -2,15 +2,18 @@ from pr_agent.config_loader import get_settings
|
||||
|
||||
|
||||
def get_secret_provider():
|
||||
try:
|
||||
provider_id = get_settings().config.secret_provider
|
||||
except AttributeError as e:
|
||||
raise ValueError("secret_provider is a required attribute in the configuration file") from e
|
||||
try:
|
||||
if provider_id == 'google_cloud_storage':
|
||||
if not get_settings().get("CONFIG.SECRET_PROVIDER"):
|
||||
return None
|
||||
|
||||
provider_id = get_settings().config.secret_provider
|
||||
if provider_id == 'google_cloud_storage':
|
||||
try:
|
||||
from pr_agent.secret_providers.google_cloud_storage_secret_provider import GoogleCloudStorageSecretProvider
|
||||
return GoogleCloudStorageSecretProvider()
|
||||
else:
|
||||
raise ValueError(f"Unknown secret provider: {provider_id}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to initialize secret provider {provider_id}") from e
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to initialize google_cloud_storage secret provider {provider_id}") from e
|
||||
else:
|
||||
raise ValueError("Unknown SECRET_PROVIDER")
|
||||
|
||||
|
||||
|
||||
|
137
pr_agent/servers/azuredevops_server_webhook.py
Normal file
137
pr_agent/servers/azuredevops_server_webhook.py
Normal file
@ -0,0 +1,137 @@
|
||||
# This file contains the code for the Azure DevOps Server webhook server.
|
||||
# The server listens for incoming webhooks from Azure DevOps Server and forwards them to the PR Agent.
|
||||
# ADO webhook documentation: https://learn.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import uvicorn
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from starlette import status
|
||||
from starlette.background import BackgroundTasks
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette_context.middleware import RawContextMiddleware
|
||||
|
||||
from pr_agent.agent.pr_agent import PRAgent, command2class
|
||||
from pr_agent.algo.utils import update_settings_from_args
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.git_providers.utils import apply_repo_settings
|
||||
from pr_agent.log import get_logger
|
||||
from fastapi import Request, Depends
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
security = HTTPBasic()
|
||||
router = APIRouter()
|
||||
available_commands_rgx = re.compile(r"^\/(" + "|".join(command2class.keys()) + r")\s*")
|
||||
azure_devops_server = get_settings().get("azure_devops_server")
|
||||
WEBHOOK_USERNAME = azure_devops_server.get("webhook_username")
|
||||
WEBHOOK_PASSWORD = azure_devops_server.get("webhook_password")
|
||||
|
||||
def handle_request(
|
||||
background_tasks: BackgroundTasks, url: str, body: str, log_context: dict
|
||||
):
|
||||
log_context["action"] = body
|
||||
log_context["api_url"] = url
|
||||
with get_logger().contextualize(**log_context):
|
||||
background_tasks.add_task(PRAgent().handle_request, url, body)
|
||||
|
||||
|
||||
# currently only basic auth is supported with azure webhooks
|
||||
# for this reason, https must be enabled to ensure the credentials are not sent in clear text
|
||||
def authorize(credentials: HTTPBasicCredentials = Depends(security)):
|
||||
is_user_ok = secrets.compare_digest(credentials.username, WEBHOOK_USERNAME)
|
||||
is_pass_ok = secrets.compare_digest(credentials.password, WEBHOOK_PASSWORD)
|
||||
if not (is_user_ok and is_pass_ok):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Incorrect username or password.',
|
||||
headers={'WWW-Authenticate': 'Basic'},
|
||||
)
|
||||
|
||||
|
||||
async def _perform_commands_azure(commands_conf: str, agent: PRAgent, api_url: str, log_context: dict):
|
||||
apply_repo_settings(api_url)
|
||||
commands = get_settings().get(f"azure_devops_server.{commands_conf}")
|
||||
for command in commands:
|
||||
try:
|
||||
split_command = command.split(" ")
|
||||
command = split_command[0]
|
||||
args = split_command[1:]
|
||||
other_args = update_settings_from_args(args)
|
||||
new_command = ' '.join([command] + other_args)
|
||||
get_logger().info(f"Performing command: {new_command}")
|
||||
with get_logger().contextualize(**log_context):
|
||||
await agent.handle_request(api_url, new_command)
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to perform command {command}: {e}")
|
||||
|
||||
|
||||
@router.post("/", dependencies=[Depends(authorize)])
|
||||
async def handle_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
log_context = {"server_type": "azure_devops_server"}
|
||||
data = await request.json()
|
||||
get_logger().info(json.dumps(data))
|
||||
|
||||
actions = []
|
||||
if data["eventType"] == "git.pullrequest.created":
|
||||
# API V1 (latest)
|
||||
pr_url = data["resource"]["_links"]["web"]["href"].replace("_apis/git/repositories", "_git")
|
||||
log_context["event"] = data["eventType"]
|
||||
log_context["api_url"] = pr_url
|
||||
await _perform_commands_azure("pr_commands", PRAgent(), pr_url, log_context)
|
||||
return
|
||||
elif data["eventType"] == "ms.vss-code.git-pullrequest-comment-event" and "content" in data["resource"]["comment"]:
|
||||
if available_commands_rgx.match(data["resource"]["comment"]["content"]):
|
||||
if(data["resourceVersion"] == "2.0"):
|
||||
repo = data["resource"]["pullRequest"]["repository"]["webUrl"]
|
||||
pr_url = f'{repo}/pullrequest/{data["resource"]["pullRequest"]["pullRequestId"]}'
|
||||
actions = [data["resource"]["comment"]["content"]]
|
||||
else:
|
||||
# API V1 not supported as it does not contain the PR URL
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=json.dumps({"message": "version 1.0 webhook for Azure Devops PR comment is not supported. please upgrade to version 2.0"})),
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=json.dumps({"message": "Unsupported command"}),
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
content=json.dumps({"message": "Unsupported event"}),
|
||||
)
|
||||
|
||||
log_context["event"] = data["eventType"]
|
||||
log_context["api_url"] = pr_url
|
||||
|
||||
for action in actions:
|
||||
try:
|
||||
handle_request(background_tasks, pr_url, action, log_context)
|
||||
except Exception as e:
|
||||
get_logger().error("Azure DevOps Trigger failed. Error:" + str(e))
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=json.dumps({"message": "Internal server error"}),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_202_ACCEPTED, content=jsonable_encoder({"message": "webhook triggerd successfully"})
|
||||
)
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
return {"status": "ok"}
|
||||
|
||||
def start():
|
||||
app = FastAPI(middleware=[Middleware(RawContextMiddleware)])
|
||||
app.include_router(router)
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "3000")))
|
||||
|
||||
if __name__ == "__main__":
|
||||
start()
|
@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
@ -17,6 +18,8 @@ from starlette_context.middleware import RawContextMiddleware
|
||||
from pr_agent.agent.pr_agent import PRAgent
|
||||
from pr_agent.config_loader import get_settings, global_settings
|
||||
from pr_agent.git_providers.utils import apply_repo_settings
|
||||
from pr_agent.identity_providers import get_identity_provider
|
||||
from pr_agent.identity_providers.identity_provider import Eligibility
|
||||
from pr_agent.log import LoggingFormat, get_logger, setup_logger
|
||||
from pr_agent.secret_providers import get_secret_provider
|
||||
from pr_agent.servers.github_action_runner import get_setting_or_env, is_true
|
||||
@ -26,7 +29,8 @@ from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
|
||||
setup_logger(fmt=LoggingFormat.JSON)
|
||||
router = APIRouter()
|
||||
secret_provider = get_secret_provider()
|
||||
secret_provider = get_secret_provider() if get_settings().get("CONFIG.SECRET_PROVIDER") else None
|
||||
|
||||
|
||||
async def get_bearer_token(shared_secret: str, client_key: str):
|
||||
try:
|
||||
@ -79,11 +83,27 @@ async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Req
|
||||
get_logger().debug(data)
|
||||
async def inner():
|
||||
try:
|
||||
owner = data["data"]["repository"]["owner"]["username"]
|
||||
try:
|
||||
if data["data"]["actor"]["type"] != "user":
|
||||
return "OK"
|
||||
except KeyError:
|
||||
get_logger().error("Failed to get actor type, check previous logs, this shouldn't happen.")
|
||||
try:
|
||||
owner = data["data"]["repository"]["owner"]["username"]
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to get owner, will continue: {e}")
|
||||
owner = "unknown"
|
||||
sender_id = data["data"]["actor"]["account_id"]
|
||||
log_context["sender"] = owner
|
||||
secrets = json.loads(secret_provider.get_secret(owner))
|
||||
log_context["sender_id"] = sender_id
|
||||
jwt_parts = input_jwt.split(".")
|
||||
claim_part = jwt_parts[1]
|
||||
claim_part += "=" * (-len(claim_part) % 4)
|
||||
decoded_claims = base64.urlsafe_b64decode(claim_part)
|
||||
claims = json.loads(decoded_claims)
|
||||
client_key = claims["iss"]
|
||||
secrets = json.loads(secret_provider.get_secret(client_key))
|
||||
shared_secret = secrets["shared_secret"]
|
||||
client_key = secrets["client_key"]
|
||||
jwt.decode(input_jwt, shared_secret, audience=client_key, algorithms=["HS256"])
|
||||
bearer_token = await get_bearer_token(shared_secret, client_key)
|
||||
context['bitbucket_bearer_token'] = bearer_token
|
||||
@ -97,15 +117,17 @@ async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Req
|
||||
if pr_url:
|
||||
with get_logger().contextualize(**log_context):
|
||||
apply_repo_settings(pr_url)
|
||||
auto_review = get_setting_or_env("BITBUCKET_APP.AUTO_REVIEW", None)
|
||||
if auto_review is None or is_true(auto_review): # by default, auto review is enabled
|
||||
await PRReviewer(pr_url).run()
|
||||
auto_improve = get_setting_or_env("BITBUCKET_APP.AUTO_IMPROVE", None)
|
||||
if is_true(auto_improve): # by default, auto improve is disabled
|
||||
await PRCodeSuggestions(pr_url).run()
|
||||
auto_describe = get_setting_or_env("BITBUCKET_APP.AUTO_DESCRIBE", None)
|
||||
if is_true(auto_describe): # by default, auto describe is disabled
|
||||
await PRDescription(pr_url).run()
|
||||
if get_identity_provider().verify_eligibility("bitbucket",
|
||||
sender_id, pr_url) is not Eligibility.NOT_ELIGIBLE:
|
||||
auto_review = get_setting_or_env("BITBUCKET_APP.AUTO_REVIEW", None)
|
||||
if auto_review is None or is_true(auto_review): # by default, auto review is enabled
|
||||
await PRReviewer(pr_url).run()
|
||||
auto_improve = get_setting_or_env("BITBUCKET_APP.AUTO_IMPROVE", None)
|
||||
if is_true(auto_improve): # by default, auto improve is disabled
|
||||
await PRCodeSuggestions(pr_url).run()
|
||||
auto_describe = get_setting_or_env("BITBUCKET_APP.AUTO_DESCRIBE", None)
|
||||
if is_true(auto_describe): # by default, auto describe is disabled
|
||||
await PRDescription(pr_url).run()
|
||||
# with get_logger().contextualize(**log_context):
|
||||
# await agent.handle_request(pr_url, "review")
|
||||
elif event == "pullrequest:comment_created":
|
||||
@ -114,7 +136,9 @@ async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Req
|
||||
log_context["event"] = "comment"
|
||||
comment_body = data["data"]["comment"]["content"]["raw"]
|
||||
with get_logger().contextualize(**log_context):
|
||||
await agent.handle_request(pr_url, comment_body)
|
||||
if get_identity_provider().verify_eligibility("bitbucket",
|
||||
sender_id, pr_url) is not Eligibility.NOT_ELIGIBLE:
|
||||
await agent.handle_request(pr_url, comment_body)
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to handle webhook: {e}")
|
||||
background_tasks.add_task(inner)
|
||||
|
@ -8,6 +8,7 @@ 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.log import get_logger
|
||||
from pr_agent.servers.github_app import handle_line_comments
|
||||
from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions
|
||||
from pr_agent.tools.pr_description import PRDescription
|
||||
from pr_agent.tools.pr_reviewer import PRReviewer
|
||||
@ -79,7 +80,7 @@ async def run_action():
|
||||
# Handle pull request event
|
||||
if GITHUB_EVENT_NAME == "pull_request":
|
||||
action = event_payload.get("action")
|
||||
if action in ["opened", "reopened"]:
|
||||
if action in ["opened", "reopened", "ready_for_review", "review_requested"]:
|
||||
pr_url = event_payload.get("pull_request", {}).get("url")
|
||||
if pr_url:
|
||||
# legacy - supporting both GITHUB_ACTION and GITHUB_ACTION_CONFIG
|
||||
@ -102,24 +103,38 @@ async def run_action():
|
||||
await PRCodeSuggestions(pr_url).run()
|
||||
|
||||
# Handle issue comment event
|
||||
elif GITHUB_EVENT_NAME == "issue_comment":
|
||||
elif GITHUB_EVENT_NAME == "issue_comment" or GITHUB_EVENT_NAME == "pull_request_review_comment":
|
||||
action = event_payload.get("action")
|
||||
if action in ["created", "edited"]:
|
||||
comment_body = event_payload.get("comment", {}).get("body")
|
||||
try:
|
||||
if GITHUB_EVENT_NAME == "pull_request_review_comment":
|
||||
if '/ask' in comment_body:
|
||||
comment_body = handle_line_comments(event_payload, comment_body)
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to handle line comments: {e}")
|
||||
return
|
||||
if comment_body:
|
||||
is_pr = False
|
||||
disable_eyes = False
|
||||
# check if issue is pull request
|
||||
if event_payload.get("issue", {}).get("pull_request"):
|
||||
url = event_payload.get("issue", {}).get("pull_request", {}).get("url")
|
||||
is_pr = True
|
||||
elif event_payload.get("comment", {}).get("pull_request_url"): # for 'pull_request_review_comment
|
||||
url = event_payload.get("comment", {}).get("pull_request_url")
|
||||
is_pr = True
|
||||
disable_eyes = True
|
||||
else:
|
||||
url = event_payload.get("issue", {}).get("url")
|
||||
|
||||
if url:
|
||||
body = comment_body.strip().lower()
|
||||
comment_id = event_payload.get("comment", {}).get("id")
|
||||
provider = get_git_provider()(pr_url=url)
|
||||
if is_pr:
|
||||
await PRAgent().handle_request(url, body, notify=lambda: provider.add_eyes_reaction(comment_id))
|
||||
await PRAgent().handle_request(url, body,
|
||||
notify=lambda: provider.add_eyes_reaction(comment_id, disable_eyes=disable_eyes))
|
||||
else:
|
||||
await PRAgent().handle_request(url, body)
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
import asyncio.locks
|
||||
import copy
|
||||
import os
|
||||
import asyncio.locks
|
||||
from typing import Any, Dict, List, Tuple
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
import uvicorn
|
||||
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
||||
@ -13,13 +15,21 @@ from pr_agent.agent.pr_agent import PRAgent
|
||||
from pr_agent.algo.utils import update_settings_from_args
|
||||
from pr_agent.config_loader import get_settings, global_settings
|
||||
from pr_agent.git_providers import get_git_provider
|
||||
from pr_agent.git_providers.utils import apply_repo_settings
|
||||
from pr_agent.git_providers.git_provider import IncrementalPR
|
||||
from pr_agent.git_providers.utils import apply_repo_settings
|
||||
from pr_agent.identity_providers import get_identity_provider
|
||||
from pr_agent.identity_providers.identity_provider import Eligibility
|
||||
from pr_agent.log import LoggingFormat, get_logger, setup_logger
|
||||
from pr_agent.servers.utils import verify_signature, DefaultDictWithTimeout
|
||||
|
||||
setup_logger(fmt=LoggingFormat.JSON)
|
||||
from pr_agent.servers.utils import DefaultDictWithTimeout, verify_signature
|
||||
|
||||
setup_logger(fmt=LoggingFormat.JSON, level="DEBUG")
|
||||
base_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
||||
build_number_path = os.path.join(base_path, "build_number.txt")
|
||||
if os.path.exists(build_number_path):
|
||||
with open(build_number_path) as f:
|
||||
build_number = f.read().strip()
|
||||
else:
|
||||
build_number = "unknown"
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@ -34,7 +44,6 @@ async def handle_github_webhooks(request: Request, response: Response):
|
||||
|
||||
body = await get_body(request)
|
||||
|
||||
get_logger().debug(f'Request body:\n{body}')
|
||||
installation_id = body.get("installation", {}).get("id")
|
||||
context["installation_id"] = installation_id
|
||||
context["settings"] = copy.deepcopy(global_settings)
|
||||
@ -63,10 +72,172 @@ async def get_body(request):
|
||||
return body
|
||||
|
||||
|
||||
_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_comments_on_pr(body: Dict[str, Any],
|
||||
event: str,
|
||||
sender: str,
|
||||
sender_id: str,
|
||||
action: str,
|
||||
log_context: Dict[str, Any],
|
||||
agent: PRAgent):
|
||||
if "comment" not in body:
|
||||
return {}
|
||||
comment_body = body.get("comment", {}).get("body")
|
||||
if comment_body and isinstance(comment_body, str) and not comment_body.lstrip().startswith("/"):
|
||||
get_logger().info("Ignoring comment not starting with /")
|
||||
return {}
|
||||
disable_eyes = False
|
||||
if "issue" in body and "pull_request" in body["issue"] and "url" in body["issue"]["pull_request"]:
|
||||
api_url = body["issue"]["pull_request"]["url"]
|
||||
elif "comment" in body and "pull_request_url" in body["comment"]:
|
||||
api_url = body["comment"]["pull_request_url"]
|
||||
try:
|
||||
if ('/ask' in comment_body and
|
||||
'subject_type' in body["comment"] and body["comment"]["subject_type"] == "line"):
|
||||
# comment on a code line in the "files changed" tab
|
||||
comment_body = handle_line_comments(body, comment_body)
|
||||
disable_eyes = True
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to handle line comments: {e}")
|
||||
else:
|
||||
return {}
|
||||
log_context["api_url"] = api_url
|
||||
comment_id = body.get("comment", {}).get("id")
|
||||
provider = get_git_provider()(pr_url=api_url)
|
||||
with get_logger().contextualize(**log_context):
|
||||
if get_identity_provider().verify_eligibility("github", sender_id, api_url) is not Eligibility.NOT_ELIGIBLE:
|
||||
get_logger().info(f"Processing comment on PR {api_url=}, comment_body={comment_body}")
|
||||
await agent.handle_request(api_url, comment_body,
|
||||
notify=lambda: provider.add_eyes_reaction(comment_id, disable_eyes=disable_eyes))
|
||||
else:
|
||||
get_logger().info(f"User {sender=} is not eligible to process comment on PR {api_url=}")
|
||||
|
||||
async def handle_new_pr_opened(body: Dict[str, Any],
|
||||
event: str,
|
||||
sender: str,
|
||||
sender_id: str,
|
||||
action: str,
|
||||
log_context: Dict[str, Any],
|
||||
agent: PRAgent):
|
||||
title = body.get("pull_request", {}).get("title", "")
|
||||
|
||||
# logic to ignore PRs with specific titles (e.g. "[Auto] ...")
|
||||
ignore_pr_title_re = get_settings().get("GITHUB_APP.IGNORE_PR_TITLE", [])
|
||||
if not isinstance(ignore_pr_title_re, list):
|
||||
ignore_pr_title_re = [ignore_pr_title_re]
|
||||
if ignore_pr_title_re and any(re.search(regex, title) for regex in ignore_pr_title_re):
|
||||
get_logger().info(f"Ignoring PR with title '{title}' due to github_app.ignore_pr_title setting")
|
||||
return {}
|
||||
|
||||
pull_request, api_url = _check_pull_request_event(action, body, log_context)
|
||||
if not (pull_request and api_url):
|
||||
get_logger().info(f"Invalid PR event: {action=} {api_url=}")
|
||||
return {}
|
||||
if action in get_settings().github_app.handle_pr_actions: # ['opened', 'reopened', 'ready_for_review', 'review_requested']
|
||||
if get_identity_provider().verify_eligibility("github", sender_id, api_url) is not Eligibility.NOT_ELIGIBLE:
|
||||
await _perform_auto_commands_github("pr_commands", agent, body, api_url, log_context)
|
||||
else:
|
||||
get_logger().info(f"User {sender=} is not eligible to process PR {api_url=}")
|
||||
|
||||
async def handle_push_trigger_for_new_commits(body: Dict[str, Any],
|
||||
event: str,
|
||||
sender: str,
|
||||
sender_id: str,
|
||||
action: str,
|
||||
log_context: Dict[str, Any],
|
||||
agent: PRAgent):
|
||||
pull_request, api_url = _check_pull_request_event(action, body, log_context)
|
||||
if not (pull_request and api_url):
|
||||
return {}
|
||||
|
||||
apply_repo_settings(api_url) # we need to apply the repo settings to get the correct settings for the PR. This is quite expensive - a call to the git provider is made for each PR event.
|
||||
if not get_settings().github_app.handle_push_trigger:
|
||||
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 {}
|
||||
|
||||
# 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 {}
|
||||
if get_identity_provider().verify_eligibility("github", sender_id, api_url) is not Eligibility.NOT_ELIGIBLE:
|
||||
get_logger().info(f"Performing incremental review for {api_url=} because of {event=} and {action=}")
|
||||
await _perform_auto_commands_github("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
|
||||
|
||||
|
||||
def handle_closed_pr(body, event, action, log_context):
|
||||
pull_request = body.get("pull_request", {})
|
||||
is_merged = pull_request.get("merged", False)
|
||||
if not is_merged:
|
||||
return
|
||||
api_url = pull_request.get("url", "")
|
||||
pr_statistics = get_git_provider()(pr_url=api_url).calc_pr_statistics(pull_request)
|
||||
log_context["api_url"] = api_url
|
||||
get_logger().info("PR-Agent statistics for closed PR", analytics=True, pr_statistics=pr_statistics, **log_context)
|
||||
|
||||
|
||||
def get_log_context(body, event, action, build_number):
|
||||
sender = ""
|
||||
sender_id = ""
|
||||
try:
|
||||
sender = body.get("sender", {}).get("login")
|
||||
sender_id = body.get("sender", {}).get("id")
|
||||
repo = body.get("repository", {}).get("full_name", "")
|
||||
git_org = body.get("organization", {}).get("login", "")
|
||||
app_name = get_settings().get("CONFIG.APP_NAME", "Unknown")
|
||||
log_context = {"action": action, "event": event, "sender": sender, "server_type": "github_app",
|
||||
"request_id": uuid.uuid4().hex, "build_number": build_number, "app_name": app_name,
|
||||
"repo": repo, "git_org": git_org}
|
||||
except Exception as e:
|
||||
get_logger().error("Failed to get log context", e)
|
||||
log_context = {}
|
||||
return log_context, sender, sender_id
|
||||
|
||||
|
||||
async def handle_request(body: Dict[str, Any], event: str):
|
||||
"""
|
||||
@ -74,123 +245,52 @@ async def handle_request(body: Dict[str, Any], event: str):
|
||||
|
||||
Args:
|
||||
body: The request body.
|
||||
event: The GitHub event type.
|
||||
event: The GitHub event type (e.g. "pull_request", "issue_comment", etc.).
|
||||
"""
|
||||
action = body.get("action")
|
||||
action = body.get("action") # "created", "opened", "reopened", "ready_for_review", "review_requested", "synchronize"
|
||||
if not action:
|
||||
return {}
|
||||
agent = PRAgent()
|
||||
bot_user = get_settings().github_app.bot_user
|
||||
sender = body.get("sender", {}).get("login")
|
||||
log_context = {"action": action, "event": event, "sender": sender, "server_type": "github_app"}
|
||||
log_context, sender, sender_id = get_log_context(body, event, action, build_number)
|
||||
|
||||
if get_settings().github_app.duplicate_requests_cache and _is_duplicate_request(body):
|
||||
return {}
|
||||
|
||||
# handle all sorts of comment events (e.g. issue_comment)
|
||||
# handle comments on PRs
|
||||
if action == 'created':
|
||||
if "comment" not in body:
|
||||
return {}
|
||||
comment_body = body.get("comment", {}).get("body")
|
||||
if sender and bot_user in sender:
|
||||
get_logger().info(f"Ignoring comment from {bot_user} user")
|
||||
return {}
|
||||
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"]:
|
||||
api_url = body["issue"]["pull_request"]["url"]
|
||||
elif "comment" in body and "pull_request_url" in body["comment"]:
|
||||
api_url = body["comment"]["pull_request_url"]
|
||||
else:
|
||||
return {}
|
||||
log_context["api_url"] = api_url
|
||||
get_logger().info(body)
|
||||
get_logger().info(f"Handling comment because of event={event} and action={action}")
|
||||
comment_id = body.get("comment", {}).get("id")
|
||||
provider = get_git_provider()(pr_url=api_url)
|
||||
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:
|
||||
# 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
|
||||
elif event == 'pull_request' and action != 'synchronize':
|
||||
pull_request, api_url = _check_pull_request_event(action, body, log_context, bot_user)
|
||||
if not (pull_request and api_url):
|
||||
return {}
|
||||
if action in get_settings().github_app.handle_pr_actions:
|
||||
if action == "review_requested":
|
||||
if body.get("requested_reviewer", {}).get("login", "") != bot_user:
|
||||
return {}
|
||||
get_logger().info(f"Performing review for {api_url=} because of {event=} and {action=}")
|
||||
await _perform_commands("pr_commands", agent, body, api_url, log_context)
|
||||
|
||||
get_logger().debug(f'Request body', artifact=body, event=event)
|
||||
await handle_comments_on_pr(body, event, sender, sender_id, action, log_context, agent)
|
||||
# handle new PRs
|
||||
elif event == 'pull_request' and action != 'synchronize' and action != 'closed':
|
||||
get_logger().debug(f'Request body', artifact=body, event=event)
|
||||
await handle_new_pr_opened(body, event, sender, sender_id, action, log_context, agent)
|
||||
# handle pull_request event with synchronize action - "push trigger" for new commits
|
||||
elif event == 'pull_request' and action == 'synchronize':
|
||||
pull_request, api_url = _check_pull_request_event(action, body, log_context, bot_user)
|
||||
if not (pull_request and api_url):
|
||||
return {}
|
||||
|
||||
apply_repo_settings(api_url)
|
||||
if not get_settings().github_app.handle_push_trigger:
|
||||
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")
|
||||
get_logger().debug(f'Request body', artifact=body, event=event)
|
||||
await handle_push_trigger_for_new_commits(body, event, sender, sender_id, action, log_context, agent)
|
||||
elif event == 'pull_request' and action == 'closed':
|
||||
if get_settings().get("CONFIG.ANALYTICS_FOLDER", ""):
|
||||
handle_closed_pr(body, event, action, log_context)
|
||||
else:
|
||||
get_logger().info(f"event {event=} action {action=} does not require any handling")
|
||||
return {}
|
||||
|
||||
|
||||
def _check_pull_request_event(action: str, body: dict, log_context: dict, bot_user: str) -> Tuple[Dict[str, Any], str]:
|
||||
def handle_line_comments(body: Dict, comment_body: [str, Any]) -> str:
|
||||
if not comment_body:
|
||||
return ""
|
||||
start_line = body["comment"]["start_line"]
|
||||
end_line = body["comment"]["line"]
|
||||
start_line = end_line if not start_line else start_line
|
||||
question = comment_body.replace('/ask', '').strip()
|
||||
diff_hunk = body["comment"]["diff_hunk"]
|
||||
get_settings().set("ask_diff_hunk", diff_hunk)
|
||||
path = body["comment"]["path"]
|
||||
side = body["comment"]["side"]
|
||||
comment_id = body["comment"]["id"]
|
||||
if '/ask' in comment_body:
|
||||
comment_body = f"/ask_line --line_start={start_line} --line_end={end_line} --side={side} --file_name={path} --comment_id={comment_id} {question}"
|
||||
return comment_body
|
||||
|
||||
|
||||
def _check_pull_request_event(action: str, body: dict, log_context: dict) -> Tuple[Dict[str, Any], str]:
|
||||
invalid_result = {}, ""
|
||||
pull_request = body.get("pull_request")
|
||||
if not pull_request:
|
||||
@ -199,7 +299,7 @@ def _check_pull_request_event(action: str, body: dict, log_context: dict, bot_us
|
||||
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:
|
||||
if pull_request.get("draft", True) or pull_request.get("state") != "open":
|
||||
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
|
||||
@ -207,51 +307,40 @@ def _check_pull_request_event(action: str, body: dict, log_context: dict, bot_us
|
||||
return pull_request, api_url
|
||||
|
||||
|
||||
async def _perform_commands(commands_conf: str, agent: PRAgent, body: dict, api_url: str, log_context: dict):
|
||||
async def _perform_auto_commands_github(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}")
|
||||
if not commands:
|
||||
with get_logger().contextualize(**log_context):
|
||||
get_logger().info(f"New PR, but no auto commands configured")
|
||||
return
|
||||
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):
|
||||
get_logger().info(f"{commands_conf}. Performing auto command '{new_command}', for {api_url=}")
|
||||
await agent.handle_request(api_url, new_command)
|
||||
|
||||
|
||||
def _is_duplicate_request(body: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
request_hash = hash(str(body))
|
||||
get_logger().info(f"request_hash: {request_hash}")
|
||||
is_duplicate = _duplicate_requests_cache.get(request_hash, False)
|
||||
_duplicate_requests_cache[request_hash] = True
|
||||
if is_duplicate:
|
||||
get_logger().info(f"Ignoring duplicate request {request_hash}")
|
||||
return is_duplicate
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if get_settings().github_app.override_deployment_type:
|
||||
# Override the deployment type to app
|
||||
get_settings().set("GITHUB.DEPLOYMENT_TYPE", "app")
|
||||
get_settings().set("CONFIG.PUBLISH_OUTPUT_PROGRESS", False)
|
||||
middleware = [Middleware(RawContextMiddleware)]
|
||||
app = FastAPI(middleware=middleware)
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
def start():
|
||||
if get_settings().github_app.override_deployment_type:
|
||||
# Override the deployment type to app
|
||||
get_settings().set("GITHUB.DEPLOYMENT_TYPE", "app")
|
||||
get_settings().set("CONFIG.PUBLISH_OUTPUT_PROGRESS", False)
|
||||
middleware = [Middleware(RawContextMiddleware)]
|
||||
app = FastAPI(middleware=middleware)
|
||||
app.include_router(router)
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "3000")))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
start()
|
||||
|
@ -11,7 +11,9 @@ from starlette_context import context
|
||||
from starlette_context.middleware import RawContextMiddleware
|
||||
|
||||
from pr_agent.agent.pr_agent import PRAgent
|
||||
from pr_agent.algo.utils import update_settings_from_args
|
||||
from pr_agent.config_loader import get_settings, global_settings
|
||||
from pr_agent.git_providers.utils import apply_repo_settings
|
||||
from pr_agent.log import LoggingFormat, get_logger, setup_logger
|
||||
from pr_agent.secret_providers import get_secret_provider
|
||||
|
||||
@ -29,6 +31,23 @@ def handle_request(background_tasks: BackgroundTasks, url: str, body: str, log_c
|
||||
background_tasks.add_task(PRAgent().handle_request, url, body)
|
||||
|
||||
|
||||
async def _perform_commands_gitlab(commands_conf: str, agent: PRAgent, api_url: str, log_context: dict):
|
||||
apply_repo_settings(api_url)
|
||||
commands = get_settings().get(f"gitlab.{commands_conf}", {})
|
||||
for command in commands:
|
||||
try:
|
||||
split_command = command.split(" ")
|
||||
command = split_command[0]
|
||||
args = split_command[1:]
|
||||
other_args = update_settings_from_args(args)
|
||||
new_command = ' '.join([command] + other_args)
|
||||
get_logger().info(f"Performing command: {new_command}")
|
||||
with get_logger().contextualize(**log_context):
|
||||
await agent.handle_request(api_url, new_command)
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to perform command {command}: {e}")
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
log_context = {"server_type": "gitlab_app"}
|
||||
@ -58,13 +77,32 @@ async def gitlab_webhook(background_tasks: BackgroundTasks, request: Request):
|
||||
if data.get('object_kind') == 'merge_request' and data['object_attributes'].get('action') in ['open', 'reopen']:
|
||||
get_logger().info(f"A merge request has been opened: {data['object_attributes'].get('title')}")
|
||||
url = data['object_attributes'].get('url')
|
||||
handle_request(background_tasks, url, "/review", log_context)
|
||||
await _perform_commands_gitlab("pr_commands", PRAgent(), url, log_context)
|
||||
# handle_request(background_tasks, url, "/review", log_context)
|
||||
elif data.get('object_kind') == 'note' and data['event_type'] == 'note':
|
||||
if 'merge_request' in data:
|
||||
mr = data['merge_request']
|
||||
url = mr.get('url')
|
||||
body = data.get('object_attributes', {}).get('note')
|
||||
if data.get('object_attributes', {}).get('type') == 'DiffNote' and '/ask' in body:
|
||||
line_range_ = data['object_attributes']['position']['line_range']
|
||||
|
||||
# if line_range_['start']['type'] == 'new':
|
||||
start_line = line_range_['start']['new_line']
|
||||
end_line = line_range_['end']['new_line']
|
||||
# else:
|
||||
# start_line = line_range_['start']['old_line']
|
||||
# end_line = line_range_['end']['old_line']
|
||||
|
||||
question = body.replace('/ask', '').strip()
|
||||
path = data['object_attributes']['position']['new_path']
|
||||
side = 'RIGHT'# if line_range_['start']['type'] == 'new' else 'LEFT'
|
||||
comment_id = data['object_attributes']["discussion_id"]
|
||||
get_logger().info(f"Handling line comment")
|
||||
body = f"/ask_line --line_start={start_line} --line_end={end_line} --side={side} --file_name={path} --comment_id={comment_id} {question}"
|
||||
|
||||
handle_request(background_tasks, url, body, log_context)
|
||||
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}))
|
||||
|
||||
|
||||
|
@ -48,7 +48,7 @@ Examples for extra instructions:
|
||||
```
|
||||
[pr_reviewer] # /review #
|
||||
extra_instructions="""
|
||||
In the 'general suggestions' section, emphasize the following:
|
||||
In the 'possible issues' section, emphasize the following:
|
||||
- Does the code logic cover relevant edge cases?
|
||||
- Is the code logic clear and easy to understand?
|
||||
- Is the code logic efficient?
|
||||
@ -99,6 +99,31 @@ Some of the feature that are disabled by default are quite useful, and should be
|
||||
"""
|
||||
output += "\n\n</details></td></tr>\n\n"
|
||||
|
||||
output += "<tr><td><details> <summary><strong> Auto-approve PRs</strong></summary><hr>\n\n"
|
||||
output += '''\
|
||||
By invoking:
|
||||
```
|
||||
/review auto_approve
|
||||
```
|
||||
The tool will automatically approve the PR, and add a comment with the approval.
|
||||
|
||||
|
||||
To ensure safety, the auto-approval feature is disabled by default. To enable auto-approval, you need to actively set in a pre-defined configuration file the following:
|
||||
```
|
||||
[pr_reviewer]
|
||||
enable_auto_approval = true
|
||||
```
|
||||
(this specific flag cannot be set with a command line argument, only in the configuration file, committed to the repository)
|
||||
|
||||
|
||||
You can also enable auto-approval only if the PR meets certain requirements, such as that the `estimated_review_effort` is equal or below a certain threshold, by adjusting the flag:
|
||||
```
|
||||
[pr_reviewer]
|
||||
maximal_review_effort = 5
|
||||
```
|
||||
'''
|
||||
output += "\n\n</details></td></tr>\n\n"
|
||||
|
||||
# general
|
||||
output += "\n\n<tr><td><details> <summary><strong> More PR-Agent commands</strong></summary><hr> \n\n"
|
||||
output += HelpMessage.get_general_bot_help_text()
|
||||
@ -186,6 +211,7 @@ To enable inline file summary, set `pr_description.inline_file_summary` in the c
|
||||
- `true`: A collapsable file comment with changes title and a changes summary for each file in the PR.
|
||||
- `false` (default): File changes walkthrough will be added only to the "Conversation" tab.
|
||||
"""
|
||||
|
||||
# extra instructions
|
||||
output += "<tr><td><details> <summary><strong> Utilizing extra instructions</strong></summary><hr>\n\n"
|
||||
output += '''\
|
||||
|
@ -76,3 +76,14 @@ base_url = ""
|
||||
|
||||
[litellm]
|
||||
LITELLM_TOKEN = "" # see https://docs.litellm.ai/docs/debugging/hosted_debugging for details and instructions on how to get a token
|
||||
|
||||
[azure_devops]
|
||||
# For Azure devops personal access token
|
||||
org = ""
|
||||
pat = ""
|
||||
|
||||
[azure_devops_server]
|
||||
# For Azure devops Server basic auth - configured in the webhook creation
|
||||
# Optional, uncomment if you want to use Azure devops webhooks. Value assinged when you create the webhook
|
||||
# webhook_username = "<basic auth user>"
|
||||
# webhook_password = "<basic auth password>"
|
||||
|
@ -1,27 +1,30 @@
|
||||
[config]
|
||||
model="gpt-4" # "gpt-4-0125-preview"
|
||||
model_turbo="gpt-4-0125-preview"
|
||||
fallback_models=["gpt-3.5-turbo-16k"]
|
||||
git_provider="github"
|
||||
publish_output=true
|
||||
publish_output_progress=true
|
||||
verbosity_level=0 # 0,1,2
|
||||
use_extra_bad_extensions=false
|
||||
use_wiki_settings_file=true
|
||||
use_repo_settings_file=true
|
||||
use_global_settings_file=true
|
||||
ai_timeout=180
|
||||
ai_timeout=120 # 2minutes
|
||||
max_description_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 = 1
|
||||
secret_provider="google_cloud_storage"
|
||||
cli_mode=false
|
||||
ai_disclaimer_title="" # Pro feature, title for a collapsible disclaimer to AI outputs
|
||||
ai_disclaimer="" # Pro feature, full text for the AI disclaimer
|
||||
|
||||
[pr_reviewer] # /review #
|
||||
# enable/disable features
|
||||
require_focused_review=false
|
||||
require_score_review=false
|
||||
require_tests_review=true
|
||||
require_security_review=true
|
||||
require_estimate_effort_to_review=true
|
||||
# soc2
|
||||
require_soc2_ticket=false
|
||||
@ -31,7 +34,6 @@ num_code_suggestions=4
|
||||
inline_code_comments = false
|
||||
ask_and_reflect=false
|
||||
#automatic_review=true
|
||||
remove_previous_review_comment=false
|
||||
persistent_comment=true
|
||||
extra_instructions = ""
|
||||
# review labels
|
||||
@ -42,6 +44,10 @@ require_all_thresholds_for_incremental_review=false
|
||||
minimal_commits_for_incremental_review=0
|
||||
minimal_minutes_for_incremental_review=0
|
||||
enable_help_text=true # Determines whether to include help text in the PR review. Enabled by default.
|
||||
# auto approval
|
||||
enable_auto_approval=false
|
||||
maximal_review_effort=5
|
||||
|
||||
|
||||
[pr_description] # /describe #
|
||||
publish_labels=true
|
||||
@ -52,7 +58,8 @@ use_bullet_points=true
|
||||
extra_instructions = ""
|
||||
enable_pr_type=true
|
||||
final_update_message = true
|
||||
enable_help_text=true
|
||||
enable_help_text=false
|
||||
enable_help_comment=true
|
||||
## changes walkthrough section
|
||||
enable_semantic_files_types=true
|
||||
collapsible_file_list='adaptive' # true, false, 'adaptive'
|
||||
@ -68,17 +75,19 @@ enable_help_text=true
|
||||
|
||||
|
||||
[pr_code_suggestions] # /improve #
|
||||
max_context_tokens=8000
|
||||
num_code_suggestions=4
|
||||
summarize = true
|
||||
extra_instructions = ""
|
||||
rank_suggestions = false
|
||||
enable_help_text=true
|
||||
# params for '/improve --extended' mode
|
||||
auto_extended_mode=false
|
||||
num_code_suggestions_per_chunk=8
|
||||
rank_extended_suggestions = true
|
||||
max_number_of_calls = 5
|
||||
final_clip_factor = 0.9
|
||||
auto_extended_mode=true
|
||||
num_code_suggestions_per_chunk=5
|
||||
max_number_of_calls = 3
|
||||
parallel_calls = true
|
||||
rank_extended_suggestions = false
|
||||
final_clip_factor = 0.8
|
||||
|
||||
[pr_add_docs] # /add_docs #
|
||||
extra_instructions = ""
|
||||
@ -99,6 +108,15 @@ file = "" # in case there are several components with the same name
|
||||
class_name = "" # in case there are several methods with the same name in the same file, you can specify the relevant class name
|
||||
enable_help_text=true
|
||||
|
||||
|
||||
[checks] # /checks (pro feature) #
|
||||
enable_auto_checks_feedback=true
|
||||
excluded_checks_list=["lint"] # list of checks to exclude, for example: ["check1", "check2"]
|
||||
persistent_comment=true
|
||||
enable_help_text=true
|
||||
|
||||
[pr_help] # /help #
|
||||
|
||||
[pr_config] # /config #
|
||||
|
||||
[github]
|
||||
@ -116,14 +134,9 @@ try_fix_invalid_inline_comments = true
|
||||
|
||||
[github_app]
|
||||
# these toggles allows running the github app from custom deployments
|
||||
bot_user = "github-actions[bot]"
|
||||
override_deployment_type = true
|
||||
# in some deployments it's possible to get duplicate requests if the handling is long,
|
||||
# these settings are used to avoid handling duplicate requests.
|
||||
duplicate_requests_cache = false
|
||||
duplicate_requests_cache_ttl = 60 # in seconds
|
||||
# settings for "pull_request" event
|
||||
handle_pr_actions = ['opened', 'reopened', 'ready_for_review', 'review_requested']
|
||||
handle_pr_actions = ['opened', 'reopened', 'ready_for_review']
|
||||
pr_commands = [
|
||||
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||
"/review --pr_reviewer.num_code_suggestions=0",
|
||||
@ -138,34 +151,17 @@ 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='' \
|
||||
"""
|
||||
"/review --pr_reviewer.num_code_suggestions=0",
|
||||
]
|
||||
ignore_pr_title = []
|
||||
|
||||
[gitlab]
|
||||
# URL to the gitlab service
|
||||
url = "https://gitlab.com"
|
||||
|
||||
# Polling (either project id or namespace/project_name) syntax can be used
|
||||
projects_to_monitor = ['org_name/repo_name']
|
||||
|
||||
# Polling trigger
|
||||
magic_word = "AutoReview"
|
||||
|
||||
# Polling interval
|
||||
polling_interval_seconds = 30
|
||||
url = "https://gitlab.com" # URL to the gitlab service
|
||||
pr_commands = [
|
||||
"/describe --pr_description.add_original_user_description=true --pr_description.keep_original_user_title=true",
|
||||
"/review --pr_reviewer.num_code_suggestions=0",
|
||||
"/improve --pr_code_suggestions.summarize=true",
|
||||
]
|
||||
|
||||
[bitbucket_app]
|
||||
#auto_review = true # set as config var in .pr_agent.toml
|
||||
|
@ -9,11 +9,11 @@ Example for the PR Diff format:
|
||||
@@ ... @@ def func1():
|
||||
__new hunk__
|
||||
12 code line1 that remained unchanged in the PR
|
||||
13 +new code line2 added in the PR
|
||||
13 +new hunk code line2 added in the PR
|
||||
14 code line3 that remained unchanged in the PR
|
||||
__old hunk__
|
||||
code line1 that remained unchanged in the PR
|
||||
-old code line2 that was removed in the PR
|
||||
-old hunk code line2 that was removed in the PR
|
||||
code line3 that remained unchanged in the PR
|
||||
|
||||
@@ ... @@ def func2():
|
||||
@ -33,19 +33,21 @@ Specific instructions:
|
||||
- The suggestions should refer only to code from the '__new hunk__' sections, and focus on new lines of code (lines starting with '+').
|
||||
- Prioritize suggestions that address major problems, issues and bugs in the PR code. As a second priority, suggestions should focus on enhancement, best practice, performance, maintainability, and other aspects.
|
||||
- Don't suggest to add docstring, type hints, or comments, or to remove unused imports.
|
||||
- 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 '__new hunk__' code.
|
||||
- Suggestions should not repeat code already present in the '__new hunk__' sections.
|
||||
- Provide the exact line numbers range (inclusive) for each suggestion.
|
||||
- When quoting variables or names from the code, use backticks (`) instead of single quote (').
|
||||
|
||||
|
||||
{%- if extra_instructions %}
|
||||
|
||||
|
||||
Extra instructions from the user:
|
||||
======
|
||||
{{ extra_instructions }}
|
||||
======
|
||||
{%- endif %}
|
||||
|
||||
|
||||
The output must be a YAML object equivalent to type $PRCodeSuggestions, according to the following Pydantic definitions:
|
||||
=====
|
||||
class CodeSuggestion(BaseModel):
|
||||
@ -72,35 +74,35 @@ class PRCodeSuggestions(BaseModel):
|
||||
Example output:
|
||||
```yaml
|
||||
code_suggestions:
|
||||
- relevant_file: |-
|
||||
- relevant_file: |
|
||||
src/file1.py
|
||||
language: |-
|
||||
language: |
|
||||
python
|
||||
suggestion_content: |-
|
||||
Add a docstring to func1()
|
||||
suggestion_content: |
|
||||
...
|
||||
{%- if summarize_mode %}
|
||||
existing_code: |-
|
||||
def func1():
|
||||
improved_code: |-
|
||||
existing_code: |
|
||||
...
|
||||
one_sentence_summary: |-
|
||||
improved_code: |
|
||||
...
|
||||
one_sentence_summary: |
|
||||
...
|
||||
relevant_lines_start: 12
|
||||
relevant_lines_end: 12
|
||||
relevant_lines_end: 13
|
||||
{%- else %}
|
||||
existing_code: |-
|
||||
def func1():
|
||||
existing_code: |
|
||||
...
|
||||
relevant_lines_start: 12
|
||||
relevant_lines_end: 12
|
||||
improved_code: |-
|
||||
relevant_lines_end: 13
|
||||
improved_code: |
|
||||
...
|
||||
{%- endif %}
|
||||
label: |-
|
||||
label: |
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
Each YAML output MUST be after a newline, indented, with block scalar indicator ('|-').
|
||||
Each YAML output MUST be after a newline, indented, with block scalar indicator ('|').
|
||||
"""
|
||||
|
||||
user="""PR Info:
|
||||
|
@ -91,7 +91,7 @@ labels:
|
||||
{%- endif %}
|
||||
```
|
||||
|
||||
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|-')
|
||||
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|')
|
||||
"""
|
||||
|
||||
user="""PR Info:
|
||||
|
53
pr_agent/settings/pr_line_questions_prompts.toml
Normal file
53
pr_agent/settings/pr_line_questions_prompts.toml
Normal file
@ -0,0 +1,53 @@
|
||||
[pr_line_questions_prompt]
|
||||
system="""You are PR-Reviewer, a language model designed to answer questions about a Git Pull Request (PR).
|
||||
|
||||
Your goal is to answer questions\\tasks about specific lines of code in the PR, and provide feedback.
|
||||
Be informative, constructive, and give examples. Try to be as specific as possible.
|
||||
Don't avoid answering the questions. You must answer the questions, as best as you can, without adding any unrelated content.
|
||||
|
||||
Additional guidelines:
|
||||
- When quoting variables or names from the code, use backticks (`) instead of single quote (').
|
||||
- If relevant, use bullet points.
|
||||
- Be short and to the point.
|
||||
|
||||
Example Hunk Structure:
|
||||
======
|
||||
## file: 'src/file1.py'
|
||||
|
||||
@@ -12,5 +12,5 @@ def func1():
|
||||
code line 1 that remained unchanged in the PR
|
||||
code line 2 that remained unchanged in the PR
|
||||
-code line that was removed in the PR
|
||||
+code line added in the PR
|
||||
code line 3 that remained unchanged in the PR
|
||||
======
|
||||
|
||||
"""
|
||||
|
||||
user="""PR Info:
|
||||
|
||||
Title: '{{title}}'
|
||||
|
||||
Branch: '{{branch}}'
|
||||
|
||||
|
||||
Here is a context hunk from the PR diff:
|
||||
======
|
||||
{{ full_hunk|trim }}
|
||||
======
|
||||
|
||||
|
||||
Now focus on the selected lines from the hunk:
|
||||
======
|
||||
{{ selected_lines|trim }}
|
||||
======
|
||||
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
|
||||
|
||||
|
||||
A question about the selected lines:
|
||||
======
|
||||
{{ question|trim }}
|
||||
======
|
||||
|
||||
Response to the question:
|
||||
"""
|
@ -1,6 +1,10 @@
|
||||
[pr_review_prompt]
|
||||
system="""You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
Your task is to provide constructive and concise feedback for the PR, and also provide meaningful code suggestions.
|
||||
{%- else %}
|
||||
Your task is to provide constructive and concise feedback for the PR.
|
||||
{%- endif %}
|
||||
The review should focus on new code added in the PR diff (lines starting with '+')
|
||||
|
||||
Example PR Diff:
|
||||
@ -27,7 +31,7 @@ code line 3 that remained unchanged in the PR
|
||||
|
||||
Code suggestions guidelines:
|
||||
- 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.
|
||||
- Don't suggest to add docstring, type hints, or comments.
|
||||
- Suggestions should focus on the new code added in the PR diff (lines starting with '+')
|
||||
@ -43,146 +47,81 @@ Extra instructions from the user:
|
||||
{% endif %}
|
||||
|
||||
|
||||
You must use the following YAML schema to format your answer:
|
||||
```yaml
|
||||
PR Analysis:
|
||||
Main theme:
|
||||
type: string
|
||||
description: a short explanation of the PR
|
||||
PR summary:
|
||||
type: string
|
||||
description: summary of the PR in 2-3 sentences.
|
||||
Type of PR:
|
||||
type: string
|
||||
enum:
|
||||
- Bug fix
|
||||
- Tests
|
||||
- Enhancement
|
||||
- Documentation
|
||||
- Other
|
||||
The output must be a YAML object equivalent to type $PRReview, according to the following Pydantic definitions:
|
||||
=====
|
||||
class Review(BaseModel)
|
||||
{%- if require_estimate_effort_to_review %}
|
||||
estimated_effort_to_review_[1-5]: str = Field(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. Take into account the size, complexity, quality, and the needed changes of the PR code diff. Explain your answer in a short and concise manner.")
|
||||
{%- endif %}
|
||||
{%- if require_score %}
|
||||
Score:
|
||||
type: int
|
||||
description: |-
|
||||
Rate this PR on a scale of 0-100 (inclusive), where 0 means the worst
|
||||
possible PR code, and 100 means PR code of the highest quality, without
|
||||
any bugs or performance issues, that is ready to be merged immediately and
|
||||
run in production at scale.
|
||||
score: str = Field(description="Rate this PR on a scale of 0-100 (inclusive), where 0 means the worst possible PR code, and 100 means PR code of the highest quality, without any bugs or performance issues, that is ready to be merged immediately and run in production at scale.")
|
||||
{%- endif %}
|
||||
{%- if require_tests %}
|
||||
Relevant tests added:
|
||||
type: string
|
||||
description: yes\\no question: does this PR have relevant tests ?
|
||||
relevant_tests: str = Field(description="yes\\no question: does this PR have relevant tests added or updated ?")
|
||||
{%- endif %}
|
||||
{%- if question_str %}
|
||||
Insights from user's answer:
|
||||
type: string
|
||||
description: |-
|
||||
shortly summarize the insights you gained from the user's answers to the questions
|
||||
insights_from_user_answers: str = Field(description="shortly summarize the insights you gained from the user's answers to the questions")
|
||||
{%- endif %}
|
||||
{%- if require_focused %}
|
||||
Focused PR:
|
||||
type: string
|
||||
description: |-
|
||||
Is this a focused PR, in the sense that all the PR code diff changes are
|
||||
united under a single focused theme ? If the theme is too broad, or the PR
|
||||
code diff changes are too scattered, then the PR is not focused. Explain
|
||||
your answer shortly.
|
||||
focused_pr: str = Field(description="Is this a focused PR, in the sense that all the PR code diff changes are united under a single focused theme ? If the theme is too broad, or the PR code diff changes are too scattered, then the PR is not focused. Explain your answer shortly.")
|
||||
{%- endif %}
|
||||
{%- if require_estimate_effort_to_review %}
|
||||
Estimated effort to review [1-5]:
|
||||
type: string
|
||||
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.
|
||||
Take into account the size, complexity, quality, and the needed changes of the PR code diff.
|
||||
Explain your answer shortly (1-2 sentences). Use the format: '1, because ...'
|
||||
{%- endif %}
|
||||
PR Feedback:
|
||||
General suggestions:
|
||||
type: string
|
||||
description: |-
|
||||
General suggestions and feedback for the contributors and maintainers of this PR.
|
||||
May include important suggestions for the overall structure,
|
||||
primary purpose, best practices, critical bugs, and other aspects of the PR.
|
||||
Don't address PR title and description, or lack of tests. Explain your suggestions.
|
||||
possible_issues: str = Field(description="Does this PR code introduce clear issues, bugs, or major performance concerns? If there are no apparent issues, respond with 'No'. If there are any issues, describe them briefly. Use bullet points if more than one issue. Be specific, and provide examples if possible. Start each bullet point with a short specific header, such as: "- Possible Bug: ...", etc.")
|
||||
security_concerns: str = Field(description="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. If there are security concerns or issues, start your answer with a short header, such as: 'Sensitive information exposure: ...', 'SQL injection: ...' etc. Explain your answer. Be specific and give examples if possible")
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
Code feedback:
|
||||
type: array
|
||||
maxItems: {{ num_code_suggestions }}
|
||||
uniqueItems: true
|
||||
items:
|
||||
relevant file:
|
||||
type: string
|
||||
description: the relevant file full path
|
||||
language:
|
||||
type: string
|
||||
description: the language of the relevant file
|
||||
suggestion:
|
||||
type: string
|
||||
description: |-
|
||||
a concrete suggestion for meaningfully improving the new PR code.
|
||||
Also describe how, specifically, the suggestion can be applied to new PR code.
|
||||
Add tags with importance measure that matches each suggestion ('important' or 'medium').
|
||||
Do not make suggestions for updating or adding docstrings, renaming PR title and description, or linter like.
|
||||
relevant line:
|
||||
type: string
|
||||
description: |-
|
||||
a single code line taken from the relevant file, to which the suggestion applies.
|
||||
The code line should start with a '+'.
|
||||
Make sure to output the line exactly as it appears in the relevant file
|
||||
|
||||
class CodeSuggestion(BaseModel)
|
||||
relevant_file: str = Field(description="the relevant file full path")
|
||||
language: str = Field(description="the language of the relevant file")
|
||||
suggestion: str = Field(description="a concrete suggestion for meaningfully improving the new PR code. Also describe how, specifically, the suggestion can be applied to new PR code. Add tags with importance measure that matches each suggestion ('important' or 'medium'). Do not make suggestions for updating or adding docstrings, renaming PR title and description, or linter like.")
|
||||
relevant_line: str = Field(description="a single code line taken from the relevant file, to which the suggestion applies. The code line should start with a '+'. Make sure to output the line exactly as it appears in the relevant file")
|
||||
{%- endif %}
|
||||
{%- if require_security %}
|
||||
Security concerns:
|
||||
type: string
|
||||
description: >-
|
||||
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.
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
|
||||
class PRReview(BaseModel)
|
||||
review: Review
|
||||
code_feedback: List[CodeSuggestion]
|
||||
{%- else %}
|
||||
|
||||
class PRReview(BaseModel)
|
||||
review: Review
|
||||
{%- endif %}
|
||||
```
|
||||
=====
|
||||
|
||||
|
||||
Example output:
|
||||
```yaml
|
||||
PR Analysis:
|
||||
Main theme: |-
|
||||
xxx
|
||||
PR summary: |-
|
||||
xxx
|
||||
Type of PR: |-
|
||||
...
|
||||
{%- if require_score %}
|
||||
Score: 89
|
||||
{%- endif %}
|
||||
Relevant tests added: |-
|
||||
No
|
||||
{%- if require_focused %}
|
||||
Focused PR: no, because ...
|
||||
{%- endif %}
|
||||
review:
|
||||
{%- if require_estimate_effort_to_review %}
|
||||
Estimated effort to review [1-5]: |-
|
||||
estimated_effort_to_review_[1-5]: |
|
||||
3, because ...
|
||||
{%- endif %}
|
||||
PR Feedback:
|
||||
General PR suggestions: |-
|
||||
...
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
Code feedback:
|
||||
- relevant file: |-
|
||||
directory/xxx.py
|
||||
language: |-
|
||||
python
|
||||
suggestion: |-
|
||||
xxx [important]
|
||||
relevant line: |-
|
||||
xxx
|
||||
...
|
||||
{%- if require_score %}
|
||||
score: 89
|
||||
{%- endif %}
|
||||
{%- if require_security %}
|
||||
Security concerns: No
|
||||
relevant_tests: |
|
||||
No
|
||||
{%- if require_focused %}
|
||||
focused_pr: |
|
||||
no, because ...
|
||||
{%- endif %}
|
||||
possible_issues: |
|
||||
No
|
||||
security_concerns: |
|
||||
No
|
||||
{%- if num_code_suggestions > 0 %}
|
||||
code_feedback
|
||||
- relevant_file: |
|
||||
directory/xxx.py
|
||||
language: |
|
||||
python
|
||||
suggestion: |
|
||||
xxx [important]
|
||||
relevant_line: |
|
||||
xxx
|
||||
{%- endif %}
|
||||
```
|
||||
|
||||
Each YAML output MUST be after a newline, indented, with block scalar indicator ('|-').
|
||||
Don't repeat the prompt in the answer, and avoid outputting the 'type' and 'description' fields.
|
||||
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|')
|
||||
"""
|
||||
|
||||
user="""PR Info:
|
||||
|
@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import textwrap
|
||||
from functools import partial
|
||||
@ -8,7 +9,7 @@ from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler
|
||||
from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler
|
||||
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.utils import load_yaml, replace_code_tags
|
||||
from pr_agent.algo.utils import load_yaml, replace_code_tags, ModelType
|
||||
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
|
||||
@ -26,6 +27,14 @@ class PRCodeSuggestions:
|
||||
self.git_provider.get_languages(), self.git_provider.get_files()
|
||||
)
|
||||
|
||||
# limit context specifically for the improve command, which has hard input to parse:
|
||||
if get_settings().pr_code_suggestions.max_context_tokens:
|
||||
MAX_CONTEXT_TOKENS_IMPROVE = get_settings().pr_code_suggestions.max_context_tokens
|
||||
if get_settings().config.max_model_tokens > MAX_CONTEXT_TOKENS_IMPROVE:
|
||||
get_logger().info(f"Setting max_model_tokens to {MAX_CONTEXT_TOKENS_IMPROVE} for PR improve")
|
||||
get_settings().config.max_model_tokens = MAX_CONTEXT_TOKENS_IMPROVE
|
||||
|
||||
|
||||
# extended mode
|
||||
try:
|
||||
self.is_extended = self._get_is_extended(args or [])
|
||||
@ -56,18 +65,27 @@ class PRCodeSuggestions:
|
||||
get_settings().pr_code_suggestions_prompt.system,
|
||||
get_settings().pr_code_suggestions_prompt.user)
|
||||
|
||||
self.progress = f"## Generating PR code suggestions\n\n"
|
||||
self.progress += f"""\nWork in progress ...<br>\n<img src="https://codium.ai/images/pr_agent/dual_ball_loading-crop.gif" width=48>"""
|
||||
self.progress_response = None
|
||||
|
||||
async def run(self):
|
||||
try:
|
||||
get_logger().info('Generating code suggestions for PR...')
|
||||
relevant_configs = {'pr_code_suggestions': dict(get_settings().pr_code_suggestions),
|
||||
'config': dict(get_settings().config)}
|
||||
get_logger().debug("Relevant configs", artifacts=relevant_configs)
|
||||
if get_settings().config.publish_output:
|
||||
self.git_provider.publish_comment("Preparing suggestions...", is_temporary=True)
|
||||
if self.git_provider.is_supported("gfm_markdown"):
|
||||
self.progress_response = self.git_provider.publish_comment(self.progress)
|
||||
else:
|
||||
self.git_provider.publish_comment("Preparing suggestions...", is_temporary=True)
|
||||
|
||||
get_logger().info('Preparing PR code suggestions...')
|
||||
if not self.is_extended:
|
||||
await retry_with_fallback_models(self._prepare_prediction)
|
||||
await retry_with_fallback_models(self._prepare_prediction, ModelType.TURBO)
|
||||
data = self._prepare_pr_code_suggestions()
|
||||
else:
|
||||
data = await retry_with_fallback_models(self._prepare_prediction_extended)
|
||||
data = await retry_with_fallback_models(self._prepare_prediction_extended, ModelType.TURBO)
|
||||
|
||||
|
||||
if (not data) or (not 'code_suggestions' in data):
|
||||
@ -80,55 +98,76 @@ class PRCodeSuggestions:
|
||||
data['code_suggestions'] = await self.rank_suggestions(data['code_suggestions'])
|
||||
|
||||
if get_settings().config.publish_output:
|
||||
get_logger().info('Pushing PR code suggestions...')
|
||||
self.git_provider.remove_initial_comment()
|
||||
if get_settings().pr_code_suggestions.summarize and self.git_provider.is_supported("gfm_markdown"):
|
||||
get_logger().info('Pushing summarize code suggestions...')
|
||||
|
||||
# generate summarized suggestions
|
||||
pr_body = self.generate_summarized_suggestions(data)
|
||||
get_logger().debug(f"PR output", artifact=pr_body)
|
||||
|
||||
# add usage guide
|
||||
if get_settings().pr_code_suggestions.enable_help_text:
|
||||
pr_body += "<hr>\n\n<details> <summary><strong>✨ Usage guide:</strong></summary><hr> \n\n"
|
||||
pr_body += "<hr>\n\n<details> <summary><strong>✨ Improve tool usage guide:</strong></summary><hr> \n\n"
|
||||
pr_body += HelpMessage.get_improve_usage_guide()
|
||||
pr_body += "\n</details>\n"
|
||||
|
||||
self.git_provider.publish_comment(pr_body)
|
||||
if self.progress_response:
|
||||
self.git_provider.edit_comment(self.progress_response, body=pr_body)
|
||||
else:
|
||||
self.git_provider.publish_comment(pr_body)
|
||||
|
||||
else:
|
||||
get_logger().info('Pushing inline code suggestions...')
|
||||
self.push_inline_code_suggestions(data)
|
||||
if self.progress_response:
|
||||
self.progress_response.delete()
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to generate code suggestions for PR, error: {e}")
|
||||
if self.progress_response:
|
||||
self.progress_response.delete()
|
||||
else:
|
||||
try:
|
||||
self.git_provider.remove_initial_comment()
|
||||
self.git_provider.publish_comment(f"Failed to generate code suggestions for PR")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
async def _prepare_prediction(self, model: str):
|
||||
get_logger().info('Getting PR diff...')
|
||||
self.patches_diff = get_pr_diff(self.git_provider,
|
||||
self.token_handler,
|
||||
model,
|
||||
add_line_numbers_to_hunks=True,
|
||||
disable_extra_lines=True)
|
||||
|
||||
get_logger().info('Getting AI prediction...')
|
||||
self.prediction = await self._get_prediction(model)
|
||||
if self.patches_diff:
|
||||
get_logger().debug(f"PR diff", artifact=self.patches_diff)
|
||||
self.prediction = await self._get_prediction(model, self.patches_diff)
|
||||
else:
|
||||
get_logger().error(f"Error getting PR diff")
|
||||
self.prediction = None
|
||||
|
||||
async def _get_prediction(self, model: str):
|
||||
async def _get_prediction(self, model: str, patches_diff: str):
|
||||
variables = copy.deepcopy(self.vars)
|
||||
variables["diff"] = self.patches_diff # update diff
|
||||
variables["diff"] = patches_diff # update diff
|
||||
environment = Environment(undefined=StrictUndefined)
|
||||
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)
|
||||
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)
|
||||
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"\nAI response:\n{response}")
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _truncate_if_needed(suggestion):
|
||||
max_code_suggestion_length = get_settings().get("PR_CODE_SUGGESTIONS.MAX_CODE_SUGGESTION_LENGTH", 0)
|
||||
suggestion_truncation_message = get_settings().get("PR_CODE_SUGGESTIONS.SUGGESTION_TRUNCATION_MESSAGE", "")
|
||||
if max_code_suggestion_length > 0:
|
||||
if len(suggestion['improved_code']) > max_code_suggestion_length:
|
||||
suggestion['improved_code'] = suggestion['improved_code'][:max_code_suggestion_length]
|
||||
suggestion['improved_code'] += f"\n{suggestion_truncation_message}"
|
||||
get_logger().info(f"Truncated suggestion from {len(suggestion['improved_code'])} "
|
||||
f"characters to {max_code_suggestion_length} characters")
|
||||
return suggestion
|
||||
|
||||
def _prepare_pr_code_suggestions(self) -> Dict:
|
||||
review = self.prediction.strip()
|
||||
data = load_yaml(review,
|
||||
@ -138,8 +177,22 @@ class PRCodeSuggestions:
|
||||
|
||||
# remove invalid suggestions
|
||||
suggestion_list = []
|
||||
one_sentence_summary_list = []
|
||||
for i, suggestion in enumerate(data['code_suggestions']):
|
||||
if suggestion['existing_code'] != suggestion['improved_code']:
|
||||
if get_settings().pr_code_suggestions.summarize:
|
||||
if not suggestion or 'one_sentence_summary' not in suggestion or 'label' not in suggestion or 'relevant_file' not in suggestion:
|
||||
get_logger().debug(f"Skipping suggestion {i + 1}, because it is invalid: {suggestion}")
|
||||
continue
|
||||
|
||||
if suggestion['one_sentence_summary'] in one_sentence_summary_list:
|
||||
get_logger().debug(f"Skipping suggestion {i + 1}, because it is a duplicate: {suggestion}")
|
||||
continue
|
||||
|
||||
if ('existing_code' in suggestion) and ('improved_code' in suggestion) and (
|
||||
suggestion['existing_code'] != suggestion['improved_code']):
|
||||
suggestion = self._truncate_if_needed(suggestion)
|
||||
if get_settings().pr_code_suggestions.summarize:
|
||||
one_sentence_summary_list.append(suggestion['one_sentence_summary'])
|
||||
suggestion_list.append(suggestion)
|
||||
else:
|
||||
get_logger().debug(
|
||||
@ -153,12 +206,13 @@ class PRCodeSuggestions:
|
||||
|
||||
if not data['code_suggestions']:
|
||||
get_logger().info('No suggestions found to improve this PR.')
|
||||
return self.git_provider.publish_comment('No suggestions found to improve this PR.')
|
||||
if self.progress_response:
|
||||
return self.git_provider.edit_comment(self.progress_response, body='No suggestions found to improve this PR.')
|
||||
else:
|
||||
return self.git_provider.publish_comment('No suggestions found to improve this PR.')
|
||||
|
||||
for d in data['code_suggestions']:
|
||||
try:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"suggestion: {d}")
|
||||
relevant_file = d['relevant_file'].strip()
|
||||
relevant_lines_start = int(d['relevant_lines_start']) # absolute position
|
||||
relevant_lines_end = int(d['relevant_lines_end'])
|
||||
@ -174,8 +228,7 @@ class PRCodeSuggestions:
|
||||
'relevant_lines_start': relevant_lines_start,
|
||||
'relevant_lines_end': relevant_lines_end})
|
||||
except Exception:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().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)
|
||||
if not is_successful:
|
||||
@ -201,8 +254,7 @@ class PRCodeSuggestions:
|
||||
if delta_spaces > 0:
|
||||
new_code_snippet = textwrap.indent(new_code_snippet, delta_spaces * " ").rstrip('\n')
|
||||
except Exception as e:
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
|
||||
get_logger().error(f"Could not dedent code snippet for file {relevant_file}, error: {e}")
|
||||
|
||||
return new_code_snippet
|
||||
|
||||
@ -217,28 +269,34 @@ class PRCodeSuggestions:
|
||||
return False
|
||||
|
||||
async def _prepare_prediction_extended(self, model: str) -> dict:
|
||||
get_logger().info('Getting PR diff...')
|
||||
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)
|
||||
self.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)
|
||||
if self.patches_diff_list:
|
||||
get_logger().debug(f"PR diff", artifact=self.patches_diff_list)
|
||||
|
||||
get_logger().info('Getting multi AI predictions...')
|
||||
prediction_list = []
|
||||
for i, patches_diff in enumerate(patches_diff_list):
|
||||
get_logger().info(f"Processing chunk {i + 1} of {len(patches_diff_list)}")
|
||||
self.patches_diff = patches_diff
|
||||
prediction = await self._get_prediction(model) # toDo: parallelize
|
||||
prediction_list.append(prediction)
|
||||
self.prediction_list = prediction_list
|
||||
|
||||
data = {}
|
||||
for prediction in prediction_list:
|
||||
self.prediction = prediction
|
||||
data_per_chunk = self._prepare_pr_code_suggestions()
|
||||
if "code_suggestions" in data:
|
||||
data["code_suggestions"].extend(data_per_chunk["code_suggestions"])
|
||||
# parallelize calls to AI:
|
||||
if get_settings().pr_code_suggestions.parallel_calls:
|
||||
prediction_list = await asyncio.gather(
|
||||
*[self._get_prediction(model, patches_diff) for patches_diff in self.patches_diff_list])
|
||||
self.prediction_list = prediction_list
|
||||
else:
|
||||
data.update(data_per_chunk)
|
||||
self.data = data
|
||||
prediction_list = []
|
||||
for i, patches_diff in enumerate(self.patches_diff_list):
|
||||
prediction = await self._get_prediction(model, patches_diff)
|
||||
prediction_list.append(prediction)
|
||||
|
||||
data = {}
|
||||
for prediction in prediction_list:
|
||||
self.prediction = prediction
|
||||
data_per_chunk = self._prepare_pr_code_suggestions()
|
||||
if "code_suggestions" in data:
|
||||
data["code_suggestions"].extend(data_per_chunk["code_suggestions"])
|
||||
else:
|
||||
data.update(data_per_chunk)
|
||||
self.data = data
|
||||
else:
|
||||
get_logger().error(f"Error getting PR diff")
|
||||
self.data = data = None
|
||||
return data
|
||||
|
||||
async def rank_suggestions(self, data: List) -> List:
|
||||
@ -273,9 +331,6 @@ class PRCodeSuggestions:
|
||||
system_prompt = environment.from_string(get_settings().pr_sort_code_suggestions_prompt.system).render(
|
||||
variables)
|
||||
user_prompt = environment.from_string(get_settings().pr_sort_code_suggestions_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, system=system_prompt,
|
||||
user=user_prompt)
|
||||
|
||||
@ -315,11 +370,13 @@ class PRCodeSuggestions:
|
||||
for ext in extensions:
|
||||
extension_to_language[ext] = language
|
||||
|
||||
pr_body = "## PR Code Suggestions\n\n"
|
||||
|
||||
pr_body += "<table>"
|
||||
header = f"Suggestions"
|
||||
delta = 77
|
||||
delta = 76
|
||||
header += " " * delta
|
||||
pr_body += f"""<thead><tr><th></th><th>{header}</th></tr></thead>"""
|
||||
pr_body += f"""<thead><tr><td>Category</td><td align=left>{header}</td></tr></thead>"""
|
||||
pr_body += """<tbody>"""
|
||||
suggestions_labels = dict()
|
||||
# add all suggestions related to each label
|
||||
@ -330,11 +387,13 @@ class PRCodeSuggestions:
|
||||
suggestions_labels[label].append(suggestion)
|
||||
|
||||
for label, suggestions in suggestions_labels.items():
|
||||
pr_body += f"""<tr><td><strong>{label}</strong></td>"""
|
||||
pr_body += f"""<td>"""
|
||||
num_suggestions=len(suggestions)
|
||||
# pr_body += f"""<tr><td><strong>{label}</strong></td>"""
|
||||
pr_body += f"""<tr><td rowspan={num_suggestions}><strong>{label.capitalize()}</strong></td>\n"""
|
||||
# pr_body += f"""<td>"""
|
||||
# pr_body += f"""<details><summary>{len(suggestions)} suggestions</summary>"""
|
||||
pr_body += f"""<table>"""
|
||||
for suggestion in suggestions:
|
||||
# pr_body += f"""<table>"""
|
||||
for i, suggestion in enumerate(suggestions):
|
||||
|
||||
relevant_file = suggestion['relevant_file'].strip()
|
||||
relevant_lines_start = int(suggestion['relevant_lines_start'])
|
||||
@ -362,17 +421,17 @@ class PRCodeSuggestions:
|
||||
|
||||
example_code = ""
|
||||
example_code += f"```diff\n{patch}\n```\n"
|
||||
|
||||
pr_body += f"""<tr><td>"""
|
||||
if i==0:
|
||||
pr_body += f"""<td>\n\n"""
|
||||
else:
|
||||
pr_body += f"""<tr><td>\n\n"""
|
||||
suggestion_summary = suggestion['one_sentence_summary'].strip()
|
||||
if '`' in suggestion_summary:
|
||||
suggestion_summary = replace_code_tags(suggestion_summary)
|
||||
suggestion_summary = suggestion_summary + max((77-len(suggestion_summary)), 0)*" "
|
||||
# suggestion_summary = suggestion_summary + max((77-len(suggestion_summary)), 0)*" "
|
||||
pr_body += f"""\n\n<details><summary>{suggestion_summary}</summary>\n\n___\n\n"""
|
||||
|
||||
pr_body += f"""
|
||||
|
||||
|
||||
**{suggestion_content}**
|
||||
|
||||
[{relevant_file} {range_str}]({code_snippet_link})
|
||||
@ -380,9 +439,9 @@ class PRCodeSuggestions:
|
||||
{example_code}
|
||||
"""
|
||||
pr_body += f"</details>"
|
||||
|
||||
pr_body += f"</td></tr>"
|
||||
|
||||
pr_body += """</table>"""
|
||||
# pr_body += "</details>"
|
||||
pr_body += """</td></tr>"""
|
||||
pr_body += """</tr></tbody></table>"""
|
||||
|
@ -9,7 +9,7 @@ from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler
|
||||
from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler
|
||||
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.algo.utils import load_yaml, set_custom_labels, get_user_labels, ModelType
|
||||
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
|
||||
@ -36,11 +36,12 @@ class PRDescription:
|
||||
|
||||
if get_settings().pr_description.enable_semantic_files_types and not self.git_provider.is_supported(
|
||||
"gfm_markdown"):
|
||||
get_logger().debug(f"Disabling semantic files types for {self.pr_id}")
|
||||
get_logger().debug(f"Disabling semantic files types for {self.pr_id}, gfm_markdown not supported.")
|
||||
get_settings().pr_description.enable_semantic_files_types = False
|
||||
|
||||
# Initialize the AI handler
|
||||
self.ai_handler = ai_handler()
|
||||
|
||||
|
||||
# Initialize the variables dictionary
|
||||
self.vars = {
|
||||
@ -55,9 +56,8 @@ class PRDescription:
|
||||
"custom_labels_class": "", # will be filled if necessary in 'set_custom_labels' function
|
||||
"enable_semantic_files_types": get_settings().pr_description.enable_semantic_files_types,
|
||||
}
|
||||
|
||||
self.user_description = self.git_provider.get_user_description()
|
||||
|
||||
|
||||
# Initialize the token handler
|
||||
self.token_handler = TokenHandler(
|
||||
self.git_provider.pr,
|
||||
@ -65,75 +65,79 @@ class PRDescription:
|
||||
get_settings().pr_description_prompt.system,
|
||||
get_settings().pr_description_prompt.user,
|
||||
)
|
||||
|
||||
|
||||
# Initialize patches_diff and prediction attributes
|
||||
self.patches_diff = None
|
||||
self.prediction = None
|
||||
self.file_label_dict = None
|
||||
self.COLLAPSIBLE_FILE_LIST_THRESHOLD = 8
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
Generates a PR description using an AI model and publishes it to the PR.
|
||||
"""
|
||||
|
||||
try:
|
||||
get_logger().info(f"Generating a PR description {self.pr_id}")
|
||||
get_logger().info(f"Generating a PR description for pr_id: {self.pr_id}")
|
||||
relevant_configs = {'pr_description': dict(get_settings().pr_description),
|
||||
'config': dict(get_settings().config)}
|
||||
get_logger().debug("Relevant configs", artifacts=relevant_configs)
|
||||
if get_settings().config.publish_output:
|
||||
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, ModelType.TURBO) # turbo model because larger context
|
||||
|
||||
get_logger().info(f"Preparing answer {self.pr_id}")
|
||||
if self.prediction:
|
||||
self._prepare_data()
|
||||
else:
|
||||
get_logger().error(f"Error getting AI prediction {self.pr_id}")
|
||||
self.git_provider.remove_initial_comment()
|
||||
return None
|
||||
|
||||
if get_settings().pr_description.enable_semantic_files_types:
|
||||
self._prepare_file_labels()
|
||||
self.file_label_dict = self._prepare_file_labels()
|
||||
|
||||
pr_labels = []
|
||||
pr_labels, pr_file_changes = [], []
|
||||
if get_settings().pr_description.publish_labels:
|
||||
pr_labels = self._prepare_labels()
|
||||
|
||||
if get_settings().pr_description.use_description_markers:
|
||||
pr_title, pr_body = self._prepare_pr_answer_with_markers()
|
||||
pr_title, pr_body, changes_walkthrough, pr_file_changes = self._prepare_pr_answer_with_markers()
|
||||
else:
|
||||
pr_title, pr_body, = self._prepare_pr_answer()
|
||||
pr_title, pr_body, changes_walkthrough, pr_file_changes = self._prepare_pr_answer()
|
||||
if not self.git_provider.is_supported(
|
||||
"publish_file_comments") or not get_settings().pr_description.inline_file_summary:
|
||||
pr_body += "\n\n" + changes_walkthrough
|
||||
get_logger().debug("PR output", artifact={"title": pr_title, "body": pr_body})
|
||||
|
||||
# Add help text if gfm_markdown is supported
|
||||
if self.git_provider.is_supported("gfm_markdown") and get_settings().pr_description.enable_help_text:
|
||||
pr_body += "<hr>\n\n<details> <summary><strong>✨ Usage guide:</strong></summary><hr> \n\n"
|
||||
pr_body += "<hr>\n\n<details> <summary><strong>✨ Describe tool usage guide:</strong></summary><hr> \n\n"
|
||||
pr_body += HelpMessage.get_describe_usage_guide()
|
||||
pr_body += "\n</details>\n"
|
||||
|
||||
# final markdown description
|
||||
full_markdown_description = f"## Title\n\n{pr_title}\n\n___\n{pr_body}"
|
||||
get_logger().debug(f"full_markdown_description:\n{full_markdown_description}")
|
||||
elif get_settings().pr_description.enable_help_comment:
|
||||
pr_body += "\n\n___\n\n> ✨ **PR-Agent usage**:"
|
||||
pr_body += "\n>Comment `/help` on the PR to get a list of all available PR-Agent tools and their descriptions\n\n"
|
||||
|
||||
if get_settings().config.publish_output:
|
||||
get_logger().info(f"Pushing answer {self.pr_id}")
|
||||
|
||||
# publish labels
|
||||
if get_settings().pr_description.publish_labels and self.git_provider.is_supported("get_labels"):
|
||||
current_labels = self.git_provider.get_pr_labels()
|
||||
user_labels = get_user_labels(current_labels)
|
||||
original_labels = self.git_provider.get_pr_labels()
|
||||
get_logger().debug(f"original labels", artifact=original_labels)
|
||||
user_labels = get_user_labels(original_labels)
|
||||
get_logger().debug(f"published labels:\n{pr_labels + user_labels}")
|
||||
self.git_provider.publish_labels(pr_labels + user_labels)
|
||||
|
||||
# publish description
|
||||
if get_settings().pr_description.publish_description_as_comment:
|
||||
get_logger().info(f"Publishing answer as comment")
|
||||
full_markdown_description = f"## Title\n\n{pr_title}\n\n___\n{pr_body}"
|
||||
self.git_provider.publish_comment(full_markdown_description)
|
||||
else:
|
||||
self.git_provider.publish_description(pr_title, pr_body)
|
||||
|
||||
# publish final update message
|
||||
if (get_settings().pr_description.final_update_message and
|
||||
hasattr(self.git_provider, 'pr_url') and self.git_provider.pr_url):
|
||||
if (get_settings().pr_description.final_update_message):
|
||||
latest_commit_url = self.git_provider.get_latest_commit_url()
|
||||
if latest_commit_url:
|
||||
self.git_provider.publish_comment(
|
||||
f"**[PR Description]({self.git_provider.pr_url})** updated to latest commit ({latest_commit_url})")
|
||||
pr_url = self.git_provider.get_pr_url()
|
||||
update_comment = f"**[PR Description]({pr_url})** updated to latest commit ({latest_commit_url})"
|
||||
self.git_provider.publish_comment(update_comment)
|
||||
self.git_provider.remove_initial_comment()
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error generating PR description {self.pr_id}: {e}")
|
||||
@ -144,10 +148,9 @@ class PRDescription:
|
||||
if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description:
|
||||
return None
|
||||
|
||||
get_logger().info(f"Getting PR diff {self.pr_id}")
|
||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||
if self.patches_diff:
|
||||
get_logger().info(f"Getting AI prediction {self.pr_id}")
|
||||
get_logger().debug(f"PR diff", artifact=self.patches_diff)
|
||||
self.prediction = await self._get_prediction(model)
|
||||
else:
|
||||
get_logger().error(f"Error getting PR diff {self.pr_id}")
|
||||
@ -172,10 +175,6 @@ class PRDescription:
|
||||
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)
|
||||
|
||||
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,
|
||||
@ -183,9 +182,6 @@ class PRDescription:
|
||||
user=user_prompt
|
||||
)
|
||||
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"\nAI response:\n{response}")
|
||||
|
||||
return response
|
||||
|
||||
def _prepare_data(self):
|
||||
@ -238,7 +234,7 @@ class PRDescription:
|
||||
get_logger().error(f"Error converting labels to original case {self.pr_id}: {e}")
|
||||
return pr_types
|
||||
|
||||
def _prepare_pr_answer_with_markers(self) -> Tuple[str, str]:
|
||||
def _prepare_pr_answer_with_markers(self) -> Tuple[str, str, str, List[dict]]:
|
||||
get_logger().info(f"Using description marker replacements {self.pr_id}")
|
||||
title = self.vars["title"]
|
||||
body = self.user_description
|
||||
@ -258,18 +254,20 @@ class PRDescription:
|
||||
body = body.replace('pr_agent:summary', summary)
|
||||
|
||||
ai_walkthrough = self.data.get('pr_files')
|
||||
walkthrough_gfm = ""
|
||||
pr_file_changes = []
|
||||
if ai_walkthrough and not re.search(r'<!--\s*pr_agent:walkthrough\s*-->', body):
|
||||
try:
|
||||
walkthrough_gfm = ""
|
||||
walkthrough_gfm = self.process_pr_files_prediction(walkthrough_gfm, self.file_label_dict)
|
||||
walkthrough_gfm, pr_file_changes = self.process_pr_files_prediction(walkthrough_gfm,
|
||||
self.file_label_dict)
|
||||
body = body.replace('pr_agent:walkthrough', walkthrough_gfm)
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failing to process walkthrough {self.pr_id}: {e}")
|
||||
body = body.replace('pr_agent:walkthrough', "")
|
||||
|
||||
return title, body
|
||||
return title, body, walkthrough_gfm, pr_file_changes
|
||||
|
||||
def _prepare_pr_answer(self) -> Tuple[str, str]:
|
||||
def _prepare_pr_answer(self) -> Tuple[str, str, str, List[dict]]:
|
||||
"""
|
||||
Prepare the PR description based on the AI prediction data.
|
||||
|
||||
@ -300,14 +298,14 @@ class PRDescription:
|
||||
|
||||
# Iterate over the remaining dictionary items and append the key and value to 'pr_body' in a markdown format,
|
||||
# except for the items containing the word 'walkthrough'
|
||||
pr_body = ""
|
||||
pr_body, changes_walkthrough = "", ""
|
||||
pr_file_changes = []
|
||||
for idx, (key, value) in enumerate(self.data.items()):
|
||||
if key == 'pr_files':
|
||||
value = self.file_label_dict
|
||||
key_publish = "Changes walkthrough"
|
||||
else:
|
||||
key_publish = key.rstrip(':').replace("_", " ").capitalize()
|
||||
pr_body += f"## **{key_publish}**\n"
|
||||
pr_body += f"## **{key_publish}**\n"
|
||||
if 'walkthrough' in key.lower():
|
||||
if self.git_provider.is_supported("gfm_markdown"):
|
||||
pr_body += "<details> <summary>files:</summary>\n\n"
|
||||
@ -318,7 +316,8 @@ class PRDescription:
|
||||
if self.git_provider.is_supported("gfm_markdown"):
|
||||
pr_body += "</details>\n"
|
||||
elif 'pr_files' in key.lower():
|
||||
pr_body = self.process_pr_files_prediction(pr_body, value)
|
||||
changes_walkthrough, pr_file_changes = self.process_pr_files_prediction(changes_walkthrough, value)
|
||||
changes_walkthrough = f"## **Changes walkthrough**\n{changes_walkthrough}"
|
||||
else:
|
||||
# if the value is a list, join its items by comma
|
||||
if isinstance(value, list):
|
||||
@ -327,27 +326,26 @@ class PRDescription:
|
||||
if idx < len(self.data) - 1:
|
||||
pr_body += "\n\n___\n\n"
|
||||
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"title:\n{title}\n{pr_body}")
|
||||
|
||||
return title, pr_body
|
||||
return title, pr_body, changes_walkthrough, pr_file_changes,
|
||||
|
||||
def _prepare_file_labels(self):
|
||||
self.file_label_dict = {}
|
||||
file_label_dict = {}
|
||||
for file in self.data['pr_files']:
|
||||
try:
|
||||
filename = file['filename'].replace("'", "`").replace('"', '`')
|
||||
changes_summary = file['changes_summary']
|
||||
changes_title = file['changes_title'].strip()
|
||||
label = file.get('label')
|
||||
if label not in self.file_label_dict:
|
||||
self.file_label_dict[label] = []
|
||||
self.file_label_dict[label].append((filename, changes_title, changes_summary))
|
||||
if label not in file_label_dict:
|
||||
file_label_dict[label] = []
|
||||
file_label_dict[label].append((filename, changes_title, changes_summary))
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error preparing file label dict {self.pr_id}: {e}")
|
||||
pass
|
||||
return file_label_dict
|
||||
|
||||
def process_pr_files_prediction(self, pr_body, value):
|
||||
pr_comments = []
|
||||
# logic for using collapsible file list
|
||||
use_collapsible_file_list = get_settings().pr_description.collapsible_file_list
|
||||
num_files = 0
|
||||
@ -355,15 +353,14 @@ class PRDescription:
|
||||
for semantic_label in value.keys():
|
||||
num_files += len(value[semantic_label])
|
||||
if use_collapsible_file_list == "adaptive":
|
||||
use_collapsible_file_list = num_files > 8
|
||||
use_collapsible_file_list = num_files > self.COLLAPSIBLE_FILE_LIST_THRESHOLD
|
||||
|
||||
if not self.git_provider.is_supported("gfm_markdown"):
|
||||
get_logger().info(f"Disabling semantic files types for {self.pr_id} since gfm_markdown is not supported")
|
||||
return pr_body
|
||||
try:
|
||||
pr_body += "<table>"
|
||||
header = f"Relevant files"
|
||||
delta = 77
|
||||
delta = 75
|
||||
# header += " " * delta
|
||||
pr_body += f"""<thead><tr><th></th><th align="left">{header}</th></tr></thead>"""
|
||||
pr_body += """<tbody>"""
|
||||
@ -377,14 +374,13 @@ class PRDescription:
|
||||
else:
|
||||
pr_body += f"""<td><table>"""
|
||||
for filename, file_changes_title, file_change_description in list_tuples:
|
||||
filename = filename.replace("'", "`")
|
||||
filename = filename.replace("'", "`").rstrip()
|
||||
filename_publish = filename.split("/")[-1]
|
||||
file_changes_title_br = insert_br_after_x_chars(file_changes_title, x=(delta - 5),
|
||||
new_line_char="\n\n")
|
||||
file_changes_title_extended = file_changes_title_br.strip() + "</code>"
|
||||
if len(file_changes_title_extended) < (delta - 5):
|
||||
file_changes_title_extended += " " * ((delta - 5) - len(file_changes_title_extended))
|
||||
filename_publish = f"<strong>{filename_publish}</strong><dd><code>{file_changes_title_extended}</dd>"
|
||||
file_changes_title_code = f"<code>{file_changes_title}</code>"
|
||||
file_changes_title_code_br = insert_br_after_x_chars(file_changes_title_code, x=(delta - 5)).strip()
|
||||
if len(file_changes_title_code_br) < (delta - 5):
|
||||
file_changes_title_code_br += " " * ((delta - 5) - len(file_changes_title_code_br))
|
||||
filename_publish = f"<strong>{filename_publish}</strong><dd>{file_changes_title_code_br}</dd>"
|
||||
diff_plus_minus = ""
|
||||
delta_nbsp = ""
|
||||
diff_files = self.git_provider.diff_files
|
||||
@ -412,7 +408,11 @@ class PRDescription:
|
||||
|
||||
{filename}
|
||||
{file_change_description_br}
|
||||
</details>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
</td>
|
||||
<td><a href="{link}">{diff_plus_minus}</a>{delta_nbsp}</td>
|
||||
</tr>
|
||||
@ -426,50 +426,77 @@ class PRDescription:
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error processing pr files to markdown {self.pr_id}: {e}")
|
||||
pass
|
||||
return pr_body
|
||||
return pr_body, pr_comments
|
||||
|
||||
def insert_br_after_x_chars(text, x=70, new_line_char="<br> "):
|
||||
|
||||
def count_chars_without_html(string):
|
||||
if '<' not in string:
|
||||
return len(string)
|
||||
no_html_string = re.sub('<[^>]+>', '', string)
|
||||
return len(no_html_string)
|
||||
|
||||
|
||||
def insert_br_after_x_chars(text, x=70):
|
||||
"""
|
||||
Insert <br> into a string after a word that increases its length above x characters.
|
||||
Use proper HTML tags for code and new lines.
|
||||
"""
|
||||
if len(text) < x:
|
||||
if count_chars_without_html(text) < x:
|
||||
return text
|
||||
|
||||
lines = text.splitlines()
|
||||
# replace odd instances of ` with <code> and even instances of ` with </code>
|
||||
text = replace_code_tags(text)
|
||||
|
||||
# convert list items to <li>
|
||||
if text.startswith("- "):
|
||||
text = "<li>" + text[2:]
|
||||
text = text.replace("\n- ", '<br><li> ').replace("\n - ", '<br><li> ')
|
||||
|
||||
# convert new lines to <br>
|
||||
text = text.replace("\n", '<br>')
|
||||
|
||||
# split text into lines
|
||||
lines = text.split('<br>')
|
||||
words = []
|
||||
for i,line in enumerate(lines):
|
||||
for i, line in enumerate(lines):
|
||||
words += line.split(' ')
|
||||
if i<len(lines)-1:
|
||||
words[-1] += "\n"
|
||||
if i < len(lines) - 1:
|
||||
words[-1] += "<br>"
|
||||
|
||||
|
||||
# words = text.split(' ')
|
||||
|
||||
new_text = ""
|
||||
current_length = 0
|
||||
new_text = []
|
||||
is_inside_code = False
|
||||
current_length = 0
|
||||
for word in words:
|
||||
# Check if adding this word exceeds x characters
|
||||
if current_length + len(word) > x:
|
||||
if not is_inside_code:
|
||||
new_text += f"{new_line_char} " # Insert line break
|
||||
current_length = 0 # Reset counter
|
||||
is_saved_word = False
|
||||
if word == "<code>" or word == "</code>" or word == "<li>" or word == "<br>":
|
||||
is_saved_word = True
|
||||
|
||||
len_word = count_chars_without_html(word)
|
||||
if not is_saved_word and (current_length + len_word > x):
|
||||
if is_inside_code:
|
||||
new_text.append("</code><br><code>")
|
||||
else:
|
||||
new_text += f"`{new_line_char} `"
|
||||
# check if inside <code> tag
|
||||
if word.startswith("`") and not is_inside_code and not word.endswith("`"):
|
||||
is_inside_code = True
|
||||
if word.endswith("`"):
|
||||
is_inside_code = False
|
||||
new_text.append("<br>")
|
||||
current_length = 0 # Reset counter
|
||||
new_text.append(word + " ")
|
||||
|
||||
# Add the word to the new text
|
||||
if word.endswith("\n"):
|
||||
new_text += word
|
||||
else:
|
||||
new_text += word + " "
|
||||
current_length += len(word) + 1 # Add 1 for the space
|
||||
if not is_saved_word:
|
||||
current_length += len_word + 1 # Add 1 for the space
|
||||
|
||||
|
||||
if word.endswith("\n"):
|
||||
if word == "<li>" or word == "<br>":
|
||||
current_length = 0
|
||||
return new_text.strip() # Remove trailing space
|
||||
|
||||
if "<code>" in word:
|
||||
is_inside_code = True
|
||||
if "</code>" in word:
|
||||
is_inside_code = False
|
||||
return ''.join(new_text).strip()
|
||||
|
||||
def replace_code_tags(text):
|
||||
"""
|
||||
Replace odd instances of ` with <code> and even instances of ` with </code>
|
||||
"""
|
||||
parts = text.split('`')
|
||||
for i in range(1, len(parts), 2):
|
||||
parts[i] = '<code>' + parts[i] + '</code>'
|
||||
return ''.join(parts)
|
@ -139,10 +139,6 @@ class PRGenerateLabels:
|
||||
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,
|
||||
@ -150,9 +146,6 @@ class PRGenerateLabels:
|
||||
user=user_prompt
|
||||
)
|
||||
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"\nAI response:\n{response}")
|
||||
|
||||
return response
|
||||
|
||||
def _prepare_data(self):
|
||||
|
99
pr_agent/tools/pr_help_message.py
Normal file
99
pr_agent/tools/pr_help_message.py
Normal file
@ -0,0 +1,99 @@
|
||||
from pr_agent.config_loader import get_settings
|
||||
from pr_agent.git_providers import get_git_provider, GithubProvider
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
|
||||
class PRHelpMessage:
|
||||
def __init__(self, pr_url: str, args=None, ai_handler=None):
|
||||
self.git_provider = get_git_provider()(pr_url)
|
||||
|
||||
async def run(self):
|
||||
try:
|
||||
if not self.git_provider.is_supported("gfm_markdown"):
|
||||
self.git_provider.publish_comment(
|
||||
"The `Help` tool requires gfm markdown, which is not supported by your code platform.")
|
||||
return
|
||||
|
||||
get_logger().info('Getting PR Help Message...')
|
||||
relevant_configs = {'pr_help': dict(get_settings().pr_help),
|
||||
'config': dict(get_settings().config)}
|
||||
get_logger().debug("Relevant configs", artifacts=relevant_configs)
|
||||
pr_comment = "## PR Agent Walkthrough\n\n"
|
||||
pr_comment += "🤖 Welcome to the PR Agent, an AI-powered tool for automated pull request analysis, feedback, suggestions and more."""
|
||||
pr_comment += "\n\nHere is a list of tools you can use to interact with the PR Agent:\n"
|
||||
base_path = "https://github.com/Codium-ai/pr-agent/tree/main/docs"
|
||||
|
||||
tool_names = []
|
||||
tool_names.append(f"[DESCRIBE]({base_path}/DESCRIBE.md)")
|
||||
tool_names.append(f"[REVIEW]({base_path}/REVIEW.md)")
|
||||
tool_names.append(f"[IMPROVE]({base_path}/IMPROVE.md)")
|
||||
tool_names.append(f"[ANALYZE]({base_path}/Analyze.md) 💎")
|
||||
tool_names.append(f"[UPDATE CHANGELOG]({base_path}/UPDATE_CHANGELOG.md)")
|
||||
tool_names.append(f"[ADD DOCUMENTATION]({base_path}/ADD_DOCUMENTATION.md) 💎")
|
||||
tool_names.append(f"[ASK]({base_path}/ASK.md)")
|
||||
tool_names.append(f"[GENERATE CUSTOM LABELS]({base_path}/GENERATE_CUSTOM_LABELS.md)")
|
||||
tool_names.append(f"[TEST]({base_path}/TEST.md) 💎")
|
||||
tool_names.append(f"[CI FEEDBACK]({base_path}/CI_FEEDBACK.md) 💎")
|
||||
tool_names.append(f"[CUSTOM SUGGESTIONS]({base_path}/CUSTOM_SUGGESTIONS.md) 💎")
|
||||
tool_names.append(f"[SIMILAR ISSUE]({base_path}/SIMILAR_ISSUE.md)")
|
||||
|
||||
descriptions = []
|
||||
descriptions.append("Generates PR description - title, type, summary, code walkthrough and labels")
|
||||
descriptions.append("Adjustable feedback about the PR, possible issues, security concerns, review effort and more")
|
||||
descriptions.append("Code suggestions for improving the PR.")
|
||||
descriptions.append("Identifies code components that changed in the PR, and enables to interactively generate tests, docs, and code suggestions for each component.")
|
||||
descriptions.append("Automatically updates the changelog.")
|
||||
descriptions.append("Generates documentation to methods/functions/classes that changed in the PR.")
|
||||
descriptions.append("Answering free-text questions about the PR.")
|
||||
descriptions.append("Generates custom labels for the PR, based on specific guidelines defined by the user")
|
||||
descriptions.append("Generates unit tests for a specific component, based on the PR code change.")
|
||||
descriptions.append("Generates feedback and analysis for a failed CI job.")
|
||||
descriptions.append("Generates custom suggestions for improving the PR code, based on specific guidelines defined by the user.")
|
||||
descriptions.append("Automatically retrieves and presents similar issues.")
|
||||
|
||||
commands =[]
|
||||
commands.append("`/describe`")
|
||||
commands.append("`/review`")
|
||||
commands.append("`/improve`")
|
||||
commands.append("`/analyze`")
|
||||
commands.append("`/update_changelog`")
|
||||
commands.append("`/add_docs`")
|
||||
commands.append("`/ask`")
|
||||
commands.append("`/generate_labels`")
|
||||
commands.append("`/test`")
|
||||
commands.append("`/checks`")
|
||||
commands.append("`/custom_suggestions`")
|
||||
commands.append("`/similar_issue`")
|
||||
|
||||
checkbox_list = []
|
||||
checkbox_list.append(" - [ ] Run <!-- /describe -->")
|
||||
checkbox_list.append(" - [ ] Run <!-- /review -->")
|
||||
checkbox_list.append(" - [ ] Run <!-- /improve -->")
|
||||
checkbox_list.append(" - [ ] Run <!-- /analyze -->")
|
||||
checkbox_list.append(" - [ ] Run <!-- /update_changelog -->")
|
||||
checkbox_list.append(" - [ ] Run <!-- /add_docs -->")
|
||||
checkbox_list.append("[*]")
|
||||
checkbox_list.append("[*]")
|
||||
checkbox_list.append("[*]")
|
||||
checkbox_list.append("[*]")
|
||||
checkbox_list.append("[*]")
|
||||
checkbox_list.append("[*]")
|
||||
|
||||
if isinstance(self.git_provider, GithubProvider):
|
||||
pr_comment += f"<table><tr align='center'><th align='center'>Tool</th><th align='center'>Description</th><th align='center'>Invoke Interactively :gem:</th></tr>"
|
||||
for i in range(len(tool_names)):
|
||||
pr_comment += f"\n<tr><td align='center'>\n\n<strong>{tool_names[i]}</strong></td>\n<td>{descriptions[i]}</td>\n<td>\n\n{checkbox_list[i]}\n</td></tr>"
|
||||
pr_comment += "</table>\n\n"
|
||||
pr_comment += f"""\n\n(1) Note that each tool be [triggered automatically](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#github-app-automatic-tools-for-pr-actions) when a new PR is opened, or called manually by [commenting on a PR](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#online-usage)."""
|
||||
pr_comment += f"""\n\n(2) Tools marked with [*] require additional parameters to be passed. For example, to invoke the `/ask` tool, you need to comment on a PR: `/ask "<question content>"`. See the relevant documentation for each tool for more details."""
|
||||
else:
|
||||
pr_comment += f"<table><tr align='center'><th align='center'>Tool</th><th align='left'>Command</th><th align='left'>Description</th></tr>"
|
||||
for i in range(len(tool_names)):
|
||||
pr_comment += f"\n<tr><td align='center'>\n\n<strong>{tool_names[i]}</strong></td><td>{commands[i]}</td><td>{descriptions[i]}</td></tr>"
|
||||
pr_comment += "</table>\n\n"
|
||||
pr_comment += f"""\n\nNote that each tool be [invoked automatically](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#github-app-automatic-tools-for-pr-actions) when a new PR is opened, or called manually by [commenting on a PR](https://github.com/Codium-ai/pr-agent/blob/main/Usage.md#online-usage)."""
|
||||
if get_settings().config.publish_output:
|
||||
self.git_provider.publish_comment(pr_comment)
|
||||
except Exception as e:
|
||||
get_logger().error(f"Error while running PRHelpMessage: {e}")
|
||||
return ""
|
104
pr_agent/tools/pr_line_questions.py
Normal file
104
pr_agent/tools/pr_line_questions.py
Normal file
@ -0,0 +1,104 @@
|
||||
import argparse
|
||||
import copy
|
||||
from functools import partial
|
||||
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
|
||||
from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler
|
||||
from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler
|
||||
from pr_agent.algo.git_patch_processing import convert_to_hunks_with_lines_numbers, \
|
||||
extract_hunk_lines_from_patch
|
||||
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 ModelType
|
||||
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
|
||||
from pr_agent.servers.help import HelpMessage
|
||||
|
||||
|
||||
class PR_LineQuestions:
|
||||
def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler):
|
||||
self.question_str = self.parse_args(args)
|
||||
self.git_provider = get_git_provider()(pr_url)
|
||||
|
||||
self.ai_handler = ai_handler()
|
||||
|
||||
self.vars = {
|
||||
"title": self.git_provider.pr.title,
|
||||
"branch": self.git_provider.get_pr_branch(),
|
||||
"diff": "", # empty diff for initial calculation
|
||||
"question": self.question_str,
|
||||
"full_hunk": "",
|
||||
"selected_lines": "",
|
||||
}
|
||||
self.token_handler = TokenHandler(self.git_provider.pr,
|
||||
self.vars,
|
||||
get_settings().pr_line_questions_prompt.system,
|
||||
get_settings().pr_line_questions_prompt.user)
|
||||
self.patches_diff = None
|
||||
self.prediction = None
|
||||
|
||||
def parse_args(self, args):
|
||||
if args and len(args) > 0:
|
||||
question_str = " ".join(args)
|
||||
else:
|
||||
question_str = ""
|
||||
return question_str
|
||||
|
||||
|
||||
async def run(self):
|
||||
get_logger().info('Answering a PR lines question...')
|
||||
# if get_settings().config.publish_output:
|
||||
# self.git_provider.publish_comment("Preparing answer...", is_temporary=True)
|
||||
|
||||
self.patch_with_lines = ""
|
||||
ask_diff = get_settings().get('ask_diff_hunk', "")
|
||||
line_start = get_settings().get('line_start', '')
|
||||
line_end = get_settings().get('line_end', '')
|
||||
side = get_settings().get('side', 'RIGHT')
|
||||
file_name = get_settings().get('file_name', '')
|
||||
comment_id = get_settings().get('comment_id', '')
|
||||
if ask_diff:
|
||||
self.patch_with_lines, self.selected_lines = extract_hunk_lines_from_patch(ask_diff,
|
||||
file_name,
|
||||
line_start=line_start,
|
||||
line_end=line_end,
|
||||
side=side
|
||||
)
|
||||
else:
|
||||
diff_files = self.git_provider.get_diff_files()
|
||||
for file in diff_files:
|
||||
if file.filename == file_name:
|
||||
self.patch_with_lines, self.selected_lines = extract_hunk_lines_from_patch(file.patch, file.filename,
|
||||
line_start=line_start,
|
||||
line_end=line_end,
|
||||
side=side)
|
||||
if self.patch_with_lines:
|
||||
response = await retry_with_fallback_models(self._get_prediction, model_type=ModelType.TURBO)
|
||||
|
||||
get_logger().info('Preparing answer...')
|
||||
if comment_id:
|
||||
self.git_provider.reply_to_comment_from_comment_id(comment_id, response)
|
||||
else:
|
||||
self.git_provider.publish_comment(response)
|
||||
|
||||
return ""
|
||||
|
||||
async def _get_prediction(self, model: str):
|
||||
variables = copy.deepcopy(self.vars)
|
||||
variables["full_hunk"] = self.patch_with_lines # update diff
|
||||
variables["selected_lines"] = self.selected_lines
|
||||
environment = Environment(undefined=StrictUndefined)
|
||||
system_prompt = environment.from_string(get_settings().pr_line_questions_prompt.system).render(variables)
|
||||
user_prompt = environment.from_string(get_settings().pr_line_questions_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}")
|
||||
print(f"\nSystem prompt:\n{system_prompt}")
|
||||
print(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
|
@ -48,27 +48,34 @@ class PRQuestions:
|
||||
|
||||
async def run(self):
|
||||
get_logger().info('Answering a PR question...')
|
||||
relevant_configs = {'pr_questions': dict(get_settings().pr_questions),
|
||||
'config': dict(get_settings().config)}
|
||||
get_logger().debug("Relevant configs", artifacts=relevant_configs)
|
||||
if get_settings().config.publish_output:
|
||||
self.git_provider.publish_comment("Preparing answer...", is_temporary=True)
|
||||
await retry_with_fallback_models(self._prepare_prediction)
|
||||
get_logger().info('Preparing answer...')
|
||||
|
||||
pr_comment = self._prepare_pr_answer()
|
||||
get_logger().debug(f"PR output", artifact=pr_comment)
|
||||
|
||||
if self.git_provider.is_supported("gfm_markdown") and get_settings().pr_questions.enable_help_text:
|
||||
pr_comment += "<hr>\n\n<details> <summary><strong>✨ Usage guide:</strong></summary><hr> \n\n"
|
||||
pr_comment += "<hr>\n\n<details> <summary><strong>✨ Ask tool usage guide:</strong></summary><hr> \n\n"
|
||||
pr_comment += HelpMessage.get_ask_usage_guide()
|
||||
pr_comment += "\n</details>\n"
|
||||
|
||||
if get_settings().config.publish_output:
|
||||
get_logger().info('Pushing answer...')
|
||||
self.git_provider.publish_comment(pr_comment)
|
||||
self.git_provider.remove_initial_comment()
|
||||
return ""
|
||||
|
||||
async def _prepare_prediction(self, model: str):
|
||||
get_logger().info('Getting PR diff...')
|
||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||
get_logger().info('Getting AI prediction...')
|
||||
self.prediction = await self._get_prediction(model)
|
||||
if self.patches_diff:
|
||||
get_logger().debug(f"PR diff", artifact=self.patches_diff)
|
||||
self.prediction = await self._get_prediction(model)
|
||||
else:
|
||||
get_logger().error(f"Error getting PR diff")
|
||||
self.prediction = ""
|
||||
|
||||
async def _get_prediction(self, model: str):
|
||||
variables = copy.deepcopy(self.vars)
|
||||
@ -76,9 +83,6 @@ class PRQuestions:
|
||||
environment = Environment(undefined=StrictUndefined)
|
||||
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)
|
||||
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
|
||||
@ -86,6 +90,4 @@ class PRQuestions:
|
||||
def _prepare_pr_answer(self) -> str:
|
||||
answer_str = f"Question: {self.question_str}\n\n"
|
||||
answer_str += f"Answer:\n{self.prediction.strip()}\n\n"
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"answer_str:\n{answer_str}")
|
||||
return answer_str
|
||||
|
@ -3,16 +3,12 @@ import datetime
|
||||
from collections import OrderedDict
|
||||
from functools import partial
|
||||
from typing import List, Tuple
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
from yaml import SafeLoader
|
||||
|
||||
from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler
|
||||
from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler
|
||||
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 convert_to_markdown, load_yaml, try_fix_yaml, set_custom_labels, get_user_labels
|
||||
from pr_agent.algo.utils import convert_to_markdown, load_yaml, ModelType
|
||||
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 IncrementalPR, get_main_pr_language
|
||||
@ -36,6 +32,7 @@ class PRReviewer:
|
||||
ai_handler (BaseAiHandler): The AI handler to be used for the review. Defaults to None.
|
||||
args (list, optional): List of arguments passed to the PRReviewer class. Defaults to None.
|
||||
"""
|
||||
self.args = args
|
||||
self.parse_args(args) # -i command
|
||||
|
||||
self.git_provider = get_git_provider()(pr_url, incremental=self.incremental)
|
||||
@ -61,7 +58,6 @@ class PRReviewer:
|
||||
"diff": "", # empty diff for initial calculation
|
||||
"require_score": get_settings().pr_reviewer.require_score_review,
|
||||
"require_tests": get_settings().pr_reviewer.require_tests_review,
|
||||
"require_security": get_settings().pr_reviewer.require_security_review,
|
||||
"require_focused": get_settings().pr_reviewer.require_focused_review,
|
||||
"require_estimate_effort_to_review": get_settings().pr_reviewer.require_estimate_effort_to_review,
|
||||
'num_code_suggestions': get_settings().pr_reviewer.num_code_suggestions,
|
||||
@ -102,45 +98,45 @@ class PRReviewer:
|
||||
if self.incremental.is_incremental and not self._can_run_incremental_review():
|
||||
return None
|
||||
|
||||
get_logger().info(f'Reviewing PR: {self.pr_url} ...')
|
||||
if isinstance(self.args, list) and self.args and self.args[0] == 'auto_approve':
|
||||
get_logger().info(f'Auto approve flow PR: {self.pr_url} ...')
|
||||
self.auto_approve_logic()
|
||||
return None
|
||||
|
||||
get_logger().info(f'Reviewing PR: {self.pr_url} ...')
|
||||
relevant_configs = {'pr_reviewer': dict(get_settings().pr_reviewer),
|
||||
'config': dict(get_settings().config)}
|
||||
get_logger().debug("Relevant configs", artifacts=relevant_configs)
|
||||
if get_settings().config.publish_output:
|
||||
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, model_type=ModelType.TURBO)
|
||||
if not self.prediction:
|
||||
self.git_provider.remove_initial_comment()
|
||||
return None
|
||||
|
||||
get_logger().info('Preparing PR review...')
|
||||
pr_comment = self._prepare_pr_review()
|
||||
pr_review = self._prepare_pr_review()
|
||||
get_logger().debug(f"PR output", artifact=pr_review)
|
||||
|
||||
if get_settings().config.publish_output:
|
||||
get_logger().info('Pushing PR review...')
|
||||
previous_review_comment = self._get_previous_review_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",
|
||||
self.git_provider.publish_persistent_comment(pr_review,
|
||||
initial_header="## PR Review",
|
||||
update_header=True)
|
||||
else:
|
||||
self.git_provider.publish_comment(pr_comment)
|
||||
self.git_provider.publish_comment(pr_review)
|
||||
|
||||
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:
|
||||
get_logger().info('Pushing inline code comments...')
|
||||
self._publish_inline_code_comments()
|
||||
except Exception as e:
|
||||
get_logger().error(f"Failed to review PR: {e}")
|
||||
|
||||
async def _prepare_prediction(self, model: str) -> None:
|
||||
get_logger().info('Getting PR diff...')
|
||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||
if self.patches_diff:
|
||||
get_logger().info('Getting AI prediction...')
|
||||
get_logger().debug(f"PR diff", diff=self.patches_diff)
|
||||
self.prediction = await self._get_prediction(model)
|
||||
else:
|
||||
get_logger().error(f"Error getting PR diff")
|
||||
@ -163,10 +159,6 @@ class PRReviewer:
|
||||
system_prompt = environment.from_string(get_settings().pr_review_prompt.system).render(variables)
|
||||
user_prompt = environment.from_string(get_settings().pr_review_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,
|
||||
@ -174,9 +166,6 @@ class PRReviewer:
|
||||
user=user_prompt
|
||||
)
|
||||
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"\nAI response:\n{response}")
|
||||
|
||||
return response
|
||||
|
||||
def _prepare_pr_review(self) -> str:
|
||||
@ -186,41 +175,30 @@ class PRReviewer:
|
||||
"""
|
||||
data = load_yaml(self.prediction.strip())
|
||||
|
||||
# Move 'Security concerns' key to 'PR Analysis' section for better display
|
||||
pr_feedback = data.get('PR Feedback', {})
|
||||
security_concerns = pr_feedback.get('Security concerns')
|
||||
if security_concerns is not None:
|
||||
del pr_feedback['Security concerns']
|
||||
if type(security_concerns) == bool and security_concerns == False:
|
||||
data.setdefault('PR Analysis', {})['Security concerns'] = 'No security concerns found'
|
||||
else:
|
||||
data.setdefault('PR Analysis', {})['Security concerns'] = security_concerns
|
||||
|
||||
#
|
||||
if 'Code feedback' in pr_feedback:
|
||||
code_feedback = pr_feedback['Code feedback']
|
||||
if 'code_feedback' in data:
|
||||
code_feedback = data['code_feedback']
|
||||
|
||||
# Filter out code suggestions that can be submitted as inline comments
|
||||
if get_settings().pr_reviewer.inline_code_comments:
|
||||
del pr_feedback['Code feedback']
|
||||
del data['code_feedback']
|
||||
else:
|
||||
for suggestion in code_feedback:
|
||||
if ('relevant file' in suggestion) and (not suggestion['relevant file'].startswith('``')):
|
||||
suggestion['relevant file'] = f"``{suggestion['relevant file']}``"
|
||||
if ('relevant_file' in suggestion) and (not suggestion['relevant_file'].startswith('``')):
|
||||
suggestion['relevant_file'] = f"``{suggestion['relevant_file']}``"
|
||||
|
||||
if 'relevant line' not in suggestion:
|
||||
suggestion['relevant line'] = ''
|
||||
if 'relevant_line' not in suggestion:
|
||||
suggestion['relevant_line'] = ''
|
||||
|
||||
relevant_line_str = suggestion['relevant line'].split('\n')[0]
|
||||
relevant_line_str = suggestion['relevant_line'].split('\n')[0]
|
||||
|
||||
# removing '+'
|
||||
suggestion['relevant line'] = relevant_line_str.lstrip('+').strip()
|
||||
suggestion['relevant_line'] = relevant_line_str.lstrip('+').strip()
|
||||
|
||||
# try to add line numbers link to code suggestions
|
||||
if hasattr(self.git_provider, 'generate_link_to_relevant_line_number'):
|
||||
link = self.git_provider.generate_link_to_relevant_line_number(suggestion)
|
||||
if link:
|
||||
suggestion['relevant line'] = f"[{suggestion['relevant line']}]({link})"
|
||||
suggestion['relevant_line'] = f"[{suggestion['relevant_line']}]({link})"
|
||||
else:
|
||||
pass
|
||||
|
||||
@ -243,17 +221,13 @@ class PRReviewer:
|
||||
|
||||
# Add help text if gfm_markdown is supported
|
||||
if self.git_provider.is_supported("gfm_markdown") and get_settings().pr_reviewer.enable_help_text:
|
||||
markdown_text += "<hr>\n\n<details> <summary><strong>✨ Usage guide:</strong></summary><hr> \n\n"
|
||||
markdown_text += "<hr>\n\n<details> <summary><strong>✨ Review tool usage guide:</strong></summary><hr> \n\n"
|
||||
markdown_text += HelpMessage.get_review_usage_guide()
|
||||
markdown_text += "\n</details>\n"
|
||||
|
||||
# Add custom labels from the review prediction (effort, security)
|
||||
self.set_review_labels(data)
|
||||
|
||||
# Log markdown response if verbosity level is high
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"Markdown response:\n{markdown_text}")
|
||||
|
||||
if markdown_text == None or len(markdown_text) == 0:
|
||||
markdown_text = ""
|
||||
|
||||
@ -266,11 +240,13 @@ class PRReviewer:
|
||||
if get_settings().pr_reviewer.num_code_suggestions == 0:
|
||||
return
|
||||
|
||||
data = load_yaml(self.prediction.strip())
|
||||
data = load_yaml(self.prediction.strip(),
|
||||
keys_fix_yaml=["estimated_effort_to_review_[1-5]:", "security_concerns:", "possible_issues:",
|
||||
"relevant_file:", "relevant_line:", "suggestion:"])
|
||||
comments: List[str] = []
|
||||
for suggestion in data.get('PR Feedback', {}).get('Code feedback', []):
|
||||
relevant_file = suggestion.get('relevant file', '').strip()
|
||||
relevant_line_in_file = suggestion.get('relevant line', '').strip()
|
||||
relevant_file = suggestion.get('relevant_file', '').strip()
|
||||
relevant_line_in_file = suggestion.get('relevant_line', '').strip()
|
||||
content = suggestion.get('suggestion', '')
|
||||
if not relevant_file or not relevant_line_in_file or not content:
|
||||
get_logger().info("Skipping inline comment with missing file/line/content")
|
||||
@ -315,7 +291,7 @@ class PRReviewer:
|
||||
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"):
|
||||
if 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,
|
||||
@ -328,7 +304,7 @@ class PRReviewer:
|
||||
Remove the previous review comment if it exists.
|
||||
"""
|
||||
try:
|
||||
if get_settings().pr_reviewer.remove_previous_review_comment and comment:
|
||||
if comment:
|
||||
self.git_provider.remove_comment(comment)
|
||||
except Exception as e:
|
||||
get_logger().exception(f"Failed to remove previous review comment, error: {e}")
|
||||
@ -370,12 +346,12 @@ class PRReviewer:
|
||||
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 = data['review']['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 = data['review']['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')
|
||||
@ -388,7 +364,36 @@ class PRReviewer:
|
||||
else:
|
||||
current_labels_filtered = []
|
||||
if current_labels or review_labels:
|
||||
get_logger().info(f"Setting review labels: {review_labels + current_labels_filtered}")
|
||||
get_logger().debug(f"Current labels:\n{current_labels}")
|
||||
get_logger().info(f"Setting review labels:\n{review_labels + current_labels_filtered}")
|
||||
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}")
|
||||
|
||||
def auto_approve_logic(self):
|
||||
"""
|
||||
Auto-approve a pull request if it meets the conditions for auto-approval.
|
||||
"""
|
||||
if get_settings().pr_reviewer.enable_auto_approval:
|
||||
maximal_review_effort = get_settings().pr_reviewer.maximal_review_effort
|
||||
if maximal_review_effort < 5:
|
||||
current_labels = self.git_provider.get_pr_labels()
|
||||
for label in current_labels:
|
||||
if label.lower().startswith('review effort [1-5]:'):
|
||||
effort = int(label.split(':')[1].strip())
|
||||
if effort > maximal_review_effort:
|
||||
get_logger().info(
|
||||
f"Auto-approve error: PR review effort ({effort}) is higher than the maximal review effort "
|
||||
f"({maximal_review_effort}) allowed")
|
||||
self.git_provider.publish_comment(
|
||||
f"Auto-approve error: PR review effort ({effort}) is higher than the maximal review effort "
|
||||
f"({maximal_review_effort}) allowed")
|
||||
return
|
||||
is_auto_approved = self.git_provider.auto_approve()
|
||||
if is_auto_approved:
|
||||
get_logger().info("Auto-approved PR")
|
||||
self.git_provider.publish_comment("Auto-approved PR")
|
||||
else:
|
||||
get_logger().info("Auto-approval option is disabled")
|
||||
self.git_provider.publish_comment("Auto-approval option for PR-Agent is disabled. "
|
||||
"You can enable it via a [configuration file](https://github.com/Codium-ai/pr-agent/blob/main/docs/REVIEW.md#auto-approval-1)")
|
@ -3,15 +3,14 @@ from datetime import date
|
||||
from functools import partial
|
||||
from time import sleep
|
||||
from typing import Tuple
|
||||
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
|
||||
from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler
|
||||
from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler
|
||||
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 ModelType
|
||||
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, GithubProvider
|
||||
from pr_agent.git_providers.git_provider import get_main_pr_language
|
||||
from pr_agent.log import get_logger
|
||||
|
||||
@ -48,29 +47,42 @@ class PRUpdateChangelog:
|
||||
get_settings().pr_update_changelog_prompt.user)
|
||||
|
||||
async def run(self):
|
||||
# assert type(self.git_provider) == GithubProvider, "Currently only Github is supported"
|
||||
|
||||
get_logger().info('Updating the changelog...')
|
||||
relevant_configs = {'pr_update_changelog': dict(get_settings().pr_update_changelog),
|
||||
'config': dict(get_settings().config)}
|
||||
get_logger().debug("Relevant configs", artifacts=relevant_configs)
|
||||
|
||||
# currently only GitHub is supported for pushing changelog changes
|
||||
if get_settings().pr_update_changelog.push_changelog_changes and type(self.git_provider) != GithubProvider:
|
||||
get_logger().error("Pushing changelog changes is not currently supported for this code platform")
|
||||
if get_settings().config.publish_output:
|
||||
self.git_provider.publish_comment(
|
||||
"Pushing changelog changes is not currently supported for this code platform")
|
||||
return
|
||||
|
||||
if get_settings().config.publish_output:
|
||||
self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True)
|
||||
await retry_with_fallback_models(self._prepare_prediction)
|
||||
get_logger().info('Preparing PR changelog updates...')
|
||||
|
||||
await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.TURBO)
|
||||
|
||||
new_file_content, answer = self._prepare_changelog_update()
|
||||
get_logger().debug(f"PR output", artifact=answer)
|
||||
|
||||
if get_settings().config.publish_output:
|
||||
self.git_provider.remove_initial_comment()
|
||||
get_logger().info('Publishing changelog updates...')
|
||||
if self.commit_changelog:
|
||||
get_logger().info('Pushing PR changelog updates to repo...')
|
||||
self._push_changelog_update(new_file_content, answer)
|
||||
else:
|
||||
get_logger().info('Publishing PR changelog as comment...')
|
||||
self.git_provider.publish_comment(f"**Changelog updates:**\n\n{answer}")
|
||||
|
||||
async def _prepare_prediction(self, model: str):
|
||||
get_logger().info('Getting PR diff...')
|
||||
self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model)
|
||||
get_logger().info('Getting AI prediction...')
|
||||
self.prediction = await self._get_prediction(model)
|
||||
if self.patches_diff:
|
||||
get_logger().debug(f"PR diff", artifact=self.patches_diff)
|
||||
self.prediction = await self._get_prediction(model)
|
||||
else:
|
||||
get_logger().error(f"Error getting PR diff")
|
||||
self.prediction = ""
|
||||
|
||||
async def _get_prediction(self, model: str):
|
||||
variables = copy.deepcopy(self.vars)
|
||||
@ -78,9 +90,6 @@ class PRUpdateChangelog:
|
||||
environment = Environment(undefined=StrictUndefined)
|
||||
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)
|
||||
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)
|
||||
|
||||
@ -101,9 +110,6 @@ class PRUpdateChangelog:
|
||||
answer += "\n\n\n>to commit the new content to the CHANGELOG.md file, please type:" \
|
||||
"\n>'/update_changelog --pr_update_changelog.push_changelog_changes=true'\n"
|
||||
|
||||
if get_settings().config.verbosity_level >= 2:
|
||||
get_logger().info(f"answer:\n{answer}")
|
||||
|
||||
return new_file_content, answer
|
||||
|
||||
def _push_changelog_update(self, new_file_content, answer):
|
||||
|
@ -1,6 +1,7 @@
|
||||
aiohttp==3.9.1
|
||||
atlassian-python-api==3.41.4
|
||||
azure-devops==7.1.0b3
|
||||
azure-identity==1.15.0
|
||||
boto3==1.33.6
|
||||
dynaconf==3.2.4
|
||||
fastapi==0.99.0
|
||||
@ -14,7 +15,7 @@ msrest==0.7.1
|
||||
openai==0.27.8
|
||||
pinecone-client
|
||||
pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main
|
||||
lancedb==0.3.4
|
||||
lancedb==0.5.1
|
||||
pytest==7.4.0
|
||||
PyGithub==1.59.*
|
||||
PyYAML==6.0.1
|
||||
|
@ -3,7 +3,7 @@ from unittest.mock import patch
|
||||
from pr_agent.git_providers.codecommit_provider import CodeCommitFile
|
||||
from pr_agent.git_providers.codecommit_provider import CodeCommitProvider
|
||||
from pr_agent.git_providers.codecommit_provider import PullRequestCCMimic
|
||||
from pr_agent.git_providers.git_provider import EDIT_TYPE
|
||||
from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo
|
||||
|
||||
|
||||
class TestCodeCommitFile:
|
||||
|
@ -1,5 +1,6 @@
|
||||
# Generated by CodiumAI
|
||||
from pr_agent.algo.utils import convert_to_markdown
|
||||
from pr_agent.tools.pr_description import insert_br_after_x_chars
|
||||
|
||||
"""
|
||||
Code Analysis
|
||||
@ -44,52 +45,62 @@ Additional aspects:
|
||||
class TestConvertToMarkdown:
|
||||
# Tests that the function works correctly with a simple dictionary input
|
||||
def test_simple_dictionary_input(self):
|
||||
input_data = {
|
||||
'Main theme': 'Test',
|
||||
'Type of PR': 'Test type',
|
||||
'Relevant tests added': 'no',
|
||||
'Unrelated changes': 'n/a', # won't be included in the output
|
||||
'Focused PR': 'Yes',
|
||||
'General PR suggestions': 'general suggestion...',
|
||||
'Code feedback': [
|
||||
{
|
||||
'Code example': {
|
||||
'Before': 'Code before',
|
||||
'After': 'Code after'
|
||||
}
|
||||
},
|
||||
{
|
||||
'Code example': {
|
||||
'Before': 'Code before 2',
|
||||
'After': 'Code after 2'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
expected_output = """\
|
||||
- 🎯 **Main theme:** Test\n\
|
||||
- 📌 **Type of PR:** Test type\n\
|
||||
- 🧪 **Relevant tests added:** no\n\
|
||||
- ✨ **Focused PR:** Yes\n\
|
||||
- **General PR suggestions:** general suggestion...\n\n\n<details><summary> <strong>🤖 Code feedback:</strong></summary> - **Code example:**\n - **Before:**\n ```\n Code before\n ```\n - **After:**\n ```\n Code after\n ```\n\n - **Code example:**\n - **Before:**\n ```\n Code before 2\n ```\n - **After:**\n ```\n Code after 2\n ```\n\n</details>\
|
||||
"""
|
||||
input_data = {'review': {
|
||||
'estimated_effort_to_review_[1-5]': '1, because the changes are minimal and straightforward, focusing on a single functionality addition.\n',
|
||||
'relevant_tests': 'No\n', 'possible_issues': 'No\n', 'security_concerns': 'No\n'}, 'code_feedback': [
|
||||
{'relevant_file': '``pr_agent/git_providers/git_provider.py\n``', 'language': 'python\n',
|
||||
'suggestion': "Consider raising an exception or logging a warning when 'pr_url' attribute is not found. This can help in debugging issues related to the absence of 'pr_url' in instances where it's expected. [important]\n",
|
||||
'relevant_line': '[return ""](https://github.com/Codium-ai/pr-agent-pro/pull/102/files#diff-52d45f12b836f77ed1aef86e972e65404634ea4e2a6083fb71a9b0f9bb9e062fR199)'}]}
|
||||
|
||||
expected_output = '## PR Review\n\n<table>\n<tr>\n<tr><td> ⏱️ <strong>Estimated effort to review [1-5]</strong></td><td>\n\n1, because the changes are minimal and straightforward, focusing on a single functionality addition.\n\n\n</td></tr>\n<tr><td> 🧪 <strong>Relevant tests</strong></td><td>\n\nNo\n\n\n</td></tr>\n<tr><td> 🔍 <strong>Possible issues</strong></td><td>\n\nNo\n\n</td></tr>\n<tr><td> 🔒 <strong>Security concerns</strong></td><td>\n\nNo\n\n</td></tr>\n</table>\n\n\n<details><summary> <strong>Code feedback:</strong></summary>\n\n<hr><table><tr><td>relevant file</td><td>pr_agent/git_providers/git_provider.py\n</td></tr><tr><td>suggestion </td><td>\n\n<strong>\n\nConsider raising an exception or logging a warning when \'pr_url\' attribute is not found. This can help in debugging issues related to the absence of \'pr_url\' in instances where it\'s expected. [important]\n\n</strong>\n</td></tr><tr><td>relevant line</td><td><a href=\'https://github.com/Codium-ai/pr-agent-pro/pull/102/files#diff-52d45f12b836f77ed1aef86e972e65404634ea4e2a6083fb71a9b0f9bb9e062fR199\'>return ""</a></td></tr></table><hr>\n\n</details>'
|
||||
|
||||
assert convert_to_markdown(input_data).strip() == expected_output.strip()
|
||||
|
||||
# Tests that the function works correctly with an empty dictionary input
|
||||
def test_empty_dictionary_input(self):
|
||||
input_data = {}
|
||||
expected_output = ""
|
||||
|
||||
expected_output = ''
|
||||
|
||||
|
||||
assert convert_to_markdown(input_data).strip() == expected_output.strip()
|
||||
|
||||
def test_dictionary_input_containing_only_empty_dictionaries(self):
|
||||
input_data = {
|
||||
'Main theme': {},
|
||||
'Type of PR': {},
|
||||
'Relevant tests added': {},
|
||||
'Unrelated changes': {},
|
||||
'Focused PR': {},
|
||||
'General PR suggestions': {},
|
||||
'Code suggestions': {}
|
||||
}
|
||||
def test_dictionary_with_empty_dictionaries(self):
|
||||
input_data = {'review': {}, 'code_feedback': [{}]}
|
||||
|
||||
expected_output = ''
|
||||
|
||||
|
||||
assert convert_to_markdown(input_data).strip() == expected_output.strip()
|
||||
|
||||
class TestBR:
|
||||
def test_br1(self):
|
||||
file_change_description = '- Imported `FilePatchInfo` and `EDIT_TYPE` from `pr_agent.algo.types` instead of `pr_agent.git_providers.git_provider`.'
|
||||
file_change_description_br = insert_br_after_x_chars(file_change_description)
|
||||
expected_output = ('<li>Imported <code>FilePatchInfo</code> and <code>EDIT_TYPE</code> from '
|
||||
'<code>pr_agent.algo.types</code> instead <br>of '
|
||||
'<code>pr_agent.git_providers.git_provider</code>.')
|
||||
assert file_change_description_br == expected_output
|
||||
# print("-----")
|
||||
# print(file_change_description_br)
|
||||
|
||||
def test_br2(self):
|
||||
file_change_description = (
|
||||
'- Created a - new -class `ColorPaletteResourcesCollection ColorPaletteResourcesCollection '
|
||||
'ColorPaletteResourcesCollection ColorPaletteResourcesCollection`')
|
||||
file_change_description_br = insert_br_after_x_chars(file_change_description)
|
||||
expected_output = ('<li>Created a - new -class <code>ColorPaletteResourcesCollection </code><br><code>'
|
||||
'ColorPaletteResourcesCollection ColorPaletteResourcesCollection '
|
||||
'</code><br><code>ColorPaletteResourcesCollection</code>')
|
||||
assert file_change_description_br == expected_output
|
||||
# print("-----")
|
||||
# print(file_change_description_br)
|
||||
|
||||
def test_br3(self):
|
||||
file_change_description = 'Created a new class `ColorPaletteResourcesCollection` which extends `AvaloniaDictionary<ThemeVariant, ColorPaletteResources>` and implements aaa'
|
||||
file_change_description_br = insert_br_after_x_chars(file_change_description)
|
||||
assert file_change_description_br == ('Created a new class <code>ColorPaletteResourcesCollection</code> which '
|
||||
'extends <br><code>AvaloniaDictionary<ThemeVariant, ColorPaletteResources>'
|
||||
'</code> and implements <br>aaa')
|
||||
# print("-----")
|
||||
# print(file_change_description_br)
|
||||
|
@ -1,8 +1,7 @@
|
||||
|
||||
# Generated by CodiumAI
|
||||
from pr_agent.git_providers.git_provider import FilePatchInfo
|
||||
from pr_agent.algo.pr_processing import find_line_number_of_relevant_line_in_file
|
||||
|
||||
from pr_agent.algo.types import FilePatchInfo
|
||||
from pr_agent.algo.utils import find_line_number_of_relevant_line_in_file
|
||||
|
||||
import pytest
|
||||
|
||||
|
@ -21,7 +21,7 @@ class TestLoadYaml:
|
||||
PR Analysis:
|
||||
Main theme: Enhancing the `/describe` command prompt by adding title and description
|
||||
Type of PR: Enhancement
|
||||
Relevant tests added: No
|
||||
Relevant tests: No
|
||||
Focused PR: Yes, the PR is focused on enhancing the `/describe` command prompt.
|
||||
|
||||
PR Feedback:
|
||||
@ -34,18 +34,20 @@ PR Feedback:
|
||||
with pytest.raises(ScannerError):
|
||||
yaml.safe_load(yaml_str)
|
||||
|
||||
expected_output = {'PR Analysis': {'Main theme': 'Enhancing the `/describe` command prompt by adding title and description', 'Type of PR': 'Enhancement', 'Relevant tests added': False, 'Focused PR': 'Yes, the PR is focused on enhancing the `/describe` command prompt.'}, 'PR Feedback': {'General suggestions': 'The PR seems to be well-structured and focused on a specific enhancement. However, it would be beneficial to add tests to ensure the new feature works as expected.', 'Code feedback': [{'relevant file': 'pr_agent/settings/pr_description_prompts.toml', 'suggestion': "Consider using a more descriptive variable name than 'user' for the command prompt. A more descriptive name would make the code more readable and maintainable. [medium]", 'relevant line': 'user="""PR Info: aaa'}], 'Security concerns': False}}
|
||||
expected_output = {'PR Analysis': {'Main theme': 'Enhancing the `/describe` command prompt by adding title and description', 'Type of PR': 'Enhancement', 'Relevant tests': False, 'Focused PR': 'Yes, the PR is focused on enhancing the `/describe` command prompt.'}, 'PR Feedback': {'General suggestions': 'The PR seems to be well-structured and focused on a specific enhancement. However, it would be beneficial to add tests to ensure the new feature works as expected.', 'Code feedback': [{'relevant file': 'pr_agent/settings/pr_description_prompts.toml', 'suggestion': "Consider using a more descriptive variable name than 'user' for the command prompt. A more descriptive name would make the code more readable and maintainable. [medium]", 'relevant line': 'user="""PR Info: aaa'}], 'Security concerns': False}}
|
||||
assert load_yaml(yaml_str) == expected_output
|
||||
|
||||
def test_load_invalid_yaml2(self):
|
||||
yaml_str = '''\
|
||||
- relevant file: src/app.py:
|
||||
suggestion content: The print statement is outside inside the if __name__ ==: \
|
||||
'''
|
||||
'''
|
||||
with pytest.raises(ScannerError):
|
||||
yaml.safe_load(yaml_str)
|
||||
|
||||
expected_output =[{'relevant file': 'src/app.py:',
|
||||
'suggestion content': 'The print statement is outside inside the if __name__ ==: '}]
|
||||
expected_output = [{'relevant file': 'src/app.py:', 'suggestion content': 'The print statement is outside inside the if __name__ ==: '}]
|
||||
assert load_yaml(yaml_str) == expected_output
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -61,7 +61,7 @@ class TestParseCodeSuggestion:
|
||||
'before': 'Before 1',
|
||||
'after': 'After 1'
|
||||
}
|
||||
expected_output = ' **suggestion:** Suggestion 1 \n **description:** Description 1 \n **before:** Before 1 \n **after:** After 1 \n\n' # noqa: E501
|
||||
expected_output = ' **suggestion:** Suggestion 1 \n **description:** Description 1 \n **before:** Before 1 \n **after:** After 1 \n\n' # noqa: E501
|
||||
assert parse_code_suggestion(code_suggestions) == expected_output
|
||||
|
||||
# Tests that function returns correct output when input dictionary has 'code example' key
|
||||
@ -74,5 +74,5 @@ class TestParseCodeSuggestion:
|
||||
'after': 'After 2'
|
||||
}
|
||||
}
|
||||
expected_output = ' **suggestion:** Suggestion 2 \n **description:** Description 2 \n - **code example:**\n - **before:**\n ```\n Before 2\n ```\n - **after:**\n ```\n After 2\n ```\n\n' # noqa: E501
|
||||
expected_output = ' **suggestion:** Suggestion 2 \n **description:** Description 2 \n - **code example:**\n - **before:**\n ```\n Before 2\n ```\n - **after:**\n ```\n After 2\n ```\n\n' # noqa: E501
|
||||
assert parse_code_suggestion(code_suggestions) == expected_output
|
||||
|
Reference in New Issue
Block a user