├── src
├── gpt
│ ├── __init__.py
│ └── __main__.py
└── gpt_review
│ ├── repositories
│ ├── __init__.py
│ ├── _repository.py
│ └── github.py
│ ├── prompts
│ ├── __init__.py
│ ├── prompt_coverage.yaml
│ ├── prompt_summary.yaml
│ ├── prompt_bug.yaml
│ └── _prompt.py
│ ├── __main__.py
│ ├── __init__.py
│ ├── _command.py
│ ├── utils.py
│ ├── main.py
│ ├── constants.py
│ ├── _gpt_cli.py
│ ├── context.py
│ ├── _git.py
│ ├── _openai.py
│ ├── _llama_index.py
│ ├── _review.py
│ └── _ask.py
├── .vscode
├── extensions.json
├── launch.json
├── tasks.json
└── settings.json
├── .pypirc
├── config.summary.template.yml
├── tests
├── config.summary.test.yml
├── test_context.py
├── mock.diff
├── test_llama_index.py
├── test_git.py
├── test_review.py
├── test_report.py
├── test_github.py
├── test_openai.py
├── test_gpt_cli.py
└── conftest.py
├── azure.yaml.template
├── .devcontainer
├── Dockerfile
└── devcontainer.json
├── .github
├── workflows
│ ├── semantic-pr-check.yml
│ ├── dynamic-template.yml
│ ├── schedule-action-update.yml
│ ├── dependency-review.yml
│ ├── codeql.yml
│ ├── test-action.yml
│ ├── on-push-create-draft-release.yml
│ └── python.yml
├── checklistConfig.json
├── pull_request_template.md
├── dependabot.yml
└── ISSUE_TEMPLATE
│ └── bug_report.yml
├── prompt.template.yml
├── dirs.proj
├── NuGet.config
├── LICENSE
├── .gitignore
├── SECURITY.md
├── README.md
├── Directory.Packages.props
└── pyproject.toml
/src/gpt/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/gpt_review/repositories/__init__.py:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/gpt_review/prompts/__init__.py:
--------------------------------------------------------------------------------
1 | """Collection of GPT Prompts."""
2 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "ms-azuretools.vscode-azurefunctions",
4 | "ms-python.python"
5 | ]
6 | }
--------------------------------------------------------------------------------
/.pypirc:
--------------------------------------------------------------------------------
1 | [distutils]
2 | index-servers =
3 | pypi
4 | testpypi
5 |
6 | [pypi]
7 | repository = https://upload.pypi.org/legacy/
8 |
9 | [testpypi]
10 | repository = https://test.pypi.org/legacy/
11 |
--------------------------------------------------------------------------------
/src/gpt/__main__.py:
--------------------------------------------------------------------------------
1 | """The GPT CLI entry point for python -m gpt"""
2 | import sys
3 |
4 | from gpt_review._gpt_cli import cli
5 |
6 | if __name__ == "__main__":
7 | exit_code = cli()
8 | sys.exit(exit_code)
9 |
--------------------------------------------------------------------------------
/src/gpt_review/__main__.py:
--------------------------------------------------------------------------------
1 | """The GPT CLI entry point for python -m gpt"""
2 | import sys
3 |
4 | from gpt_review._gpt_cli import cli
5 |
6 | if __name__ == "__main__":
7 | exit_code = cli()
8 | sys.exit(exit_code)
9 |
--------------------------------------------------------------------------------
/config.summary.template.yml:
--------------------------------------------------------------------------------
1 | # This configuration file generates sample.report.md
2 | report:
3 | Summary by GPT-4:
4 | _:
5 | Overview:
6 | _: What is the main goal of this PR?
7 | Suggestions: Any suggestions for improving the changes in this PR?
8 |
--------------------------------------------------------------------------------
/tests/config.summary.test.yml:
--------------------------------------------------------------------------------
1 | # This configuration file generates sample.report.md
2 | report:
3 | Summary by GPT-4:
4 | _:
5 | Overview:
6 | _: What is the main goal of this PR?
7 | Suggestions: Any suggestions for improving the changes in this PR?
8 |
--------------------------------------------------------------------------------
/azure.yaml.template:
--------------------------------------------------------------------------------
1 | azure_api_type: azure
2 | azure_api_base: base-url-for-azure-api
3 | azure_api_version: 2023-03-15-preview
4 | azure_model_map:
5 | turbo_llm_model_deployment_id: gpt-35-turbo
6 | smart_llm_model_deployment_id: gpt-4
7 | large_llm_model_deployment_id: gpt-4-32k
8 | embedding_model_deployment_id: text-embedding-ada-002
9 |
--------------------------------------------------------------------------------
/src/gpt_review/__init__.py:
--------------------------------------------------------------------------------
1 | # -------------------------------------------------------------
2 | # Copyright (c) Microsoft Corporation. All rights reserved.
3 | # Licensed under the MIT License. See LICENSE in project root for information.
4 | # -------------------------------------------------------------
5 | """Easy GPT CLI"""
6 | from __future__ import annotations
7 |
8 | __version__ = "0.9.5"
9 |
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/devcontainers/python:3
2 |
3 | RUN python -m pip install --upgrade pip \
4 | && python -m pip install 'flit>=3.8.0'
5 |
6 | ENV FLIT_ROOT_INSTALL=1
7 |
8 | COPY pyproject.toml .
9 | RUN touch README.md \
10 | && mkdir -p src/gpt_review \
11 | && python -m flit install --only-deps --deps develop \
12 | && rm -r pyproject.toml README.md src
13 |
--------------------------------------------------------------------------------
/src/gpt_review/prompts/prompt_coverage.yaml:
--------------------------------------------------------------------------------
1 | _type: prompt
2 | input_variables:
3 | ["diff"]
4 | template: |
5 | You are an experienced software developer.
6 |
7 | Generate unit test cases for the code submitted
8 | in the pull request, ensuring comprehensive coverage of all functions, methods,
9 | and scenarios to validate the correctness and reliability of the implementation.
10 |
11 | {diff}
12 |
--------------------------------------------------------------------------------
/.github/workflows/semantic-pr-check.yml:
--------------------------------------------------------------------------------
1 | name: "Semantic PR Check"
2 |
3 | on:
4 | pull_request:
5 | types:
6 | - opened
7 | - edited
8 | - synchronize
9 |
10 | jobs:
11 | main:
12 | name: Validate PR title
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: amannn/action-semantic-pull-request@v5
16 | env:
17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18 |
--------------------------------------------------------------------------------
/src/gpt_review/prompts/prompt_summary.yaml:
--------------------------------------------------------------------------------
1 | _type: prompt
2 | input_variables:
3 | ["diff"]
4 | template: |
5 | Summarize the following file changed in a pull request submitted by a developer on GitHub,
6 | focusing on major modifications, additions, deletions, and any significant updates within the files.
7 | Do not include the file name in the summary and list the summary with bullet points.
8 |
9 | {diff}
10 |
--------------------------------------------------------------------------------
/src/gpt_review/prompts/prompt_bug.yaml:
--------------------------------------------------------------------------------
1 | _type: prompt
2 | input_variables:
3 | ["diff"]
4 | template: |
5 | Provide a concise summary of the bug found in the code, describing its characteristics,
6 | location, and potential effects on the overall functionality and performance of the application.
7 | Present the potential issues and errors first, following by the most important findings, in your summary
8 |
9 | {diff}
10 |
--------------------------------------------------------------------------------
/tests/test_context.py:
--------------------------------------------------------------------------------
1 | """Tests for the context grabber."""
2 | from gpt_review.context import _load_context_file
3 |
4 |
5 | def test_load_context_file(monkeypatch) -> None:
6 | monkeypatch.setenv("CONTEXT_FILE", "azure.yaml.template")
7 | yaml_context = _load_context_file()
8 | assert "azure_api_type" in yaml_context
9 | assert "azure_api_base" in yaml_context
10 | assert "azure_api_version" in yaml_context
11 |
--------------------------------------------------------------------------------
/prompt.template.yml:
--------------------------------------------------------------------------------
1 | _type: few_shot
2 | input_variables:
3 | ["adjective"]
4 | prefix:
5 | Write antonyms for the following words.
6 | example_prompt:
7 | _type: prompt
8 | input_variables:
9 | ["input", "output"]
10 | template:
11 | "Input: {input}\nOutput: {output}"
12 | examples:
13 | - input: happy
14 | output: sad
15 | - input: tall
16 | output: short
17 | suffix:
18 | "Input: {adjective}\nOutput:"
19 |
--------------------------------------------------------------------------------
/src/gpt_review/_command.py:
--------------------------------------------------------------------------------
1 | """Interface for GPT CLI command groups."""
2 | from knack import CLICommandsLoader
3 |
4 |
5 | class GPTCommandGroup:
6 | """Command Group Interface."""
7 |
8 | @staticmethod
9 | def load_command_table(loader: CLICommandsLoader) -> None:
10 | """Load the command table."""
11 |
12 | @staticmethod
13 | def load_arguments(loader: CLICommandsLoader) -> None:
14 | """Load the arguments for the command group."""
15 |
--------------------------------------------------------------------------------
/.github/workflows/dynamic-template.yml:
--------------------------------------------------------------------------------
1 | name: "Dynamic checklist"
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | jobs:
8 | checklist_job:
9 | runs-on: ubuntu-latest
10 | name: A job to create dynamic checklist
11 | steps:
12 | - name: Checkout repo
13 | uses: actions/checkout@v2
14 | - name: Dynamic checklist action
15 | uses: ethanresnick/dynamic-checklist@v1
16 | with:
17 | config: ".github/checklistConfig.json"
18 | env:
19 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
20 |
--------------------------------------------------------------------------------
/dirs.proj:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.9.34607.119
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 |
7 | Global
8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
9 | Debug|Any CPU = Debug|Any CPU
10 | Debug|x64 = Debug|x64
11 | Debug|x86 = Debug|x86
12 | Release|Any CPU = Release|Any CPU
13 | Release|x64 = Release|x64
14 | Release|x86 = Release|x86
15 | EndGlobalSection
16 | GlobalSection(ExtensibilityGlobals) = postSolution
17 | SolutionGuid = {ACEC1A04-8B05-4C29-9BB0-AAD65DFBD469}
18 | EndGlobalSection
19 | EndGlobal
20 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Debug Tests",
6 | "type": "python",
7 | "request": "launch",
8 | "program": "${file}",
9 | "purpose": [
10 | "debug-test"
11 | ],
12 | "console": "integratedTerminal",
13 | "justMyCode": false,
14 | "env": {
15 | "PYTEST_ADDOPTS": "--no-cov -n0 --dist no"
16 | }
17 | },
18 | {
19 | "name": "Attach to Python Functions",
20 | "type": "python",
21 | "request": "attach",
22 | "port": 9091,
23 | "preLaunchTask": "func: host start"
24 | }
25 | ]
26 | }
--------------------------------------------------------------------------------
/tests/mock.diff:
--------------------------------------------------------------------------------
1 | diff --git a/README.md b/README.md
2 | index 6d0d0a7..b2b0b0a 100644
3 | --- a/README.md
4 | +++ b/README.md
5 | @@ -1,4 +1,4 @@
6 | -# GPT Review
7 | +# GPT Review Test
8 | GPT Review is a tool to help with code reviews.
9 | It uses GPT-4 to summarize code changes and provide insights.
10 | It is currently in alpha.
11 | diff --git a/src/gpt_review/_ask.py b/src/gpt_review/_ask.py
12 | index 6d0d0a7..b2b0b0a 100644
13 | --- a/src/gpt_review/_ask.py
14 | +++ b/src/gpt_review/_ask.py
15 | @@ -1,4 +1,4 @@
16 | -# GPT Review
17 | +# GPT Review Test
18 | GPT Review is a tool to help with code reviews.
19 | It uses GPT-4 to summarize code changes and provide insights.
20 | It is currently in alpha.
--------------------------------------------------------------------------------
/.github/checklistConfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "mappings": [
3 | {
4 | "paths": ["src/**/*.py"],
5 | "items": ["Python code includes tests"]
6 | },
7 | {
8 | "triggers": [
9 | "connection",
10 | "session",
11 | "CloseableHttpClient",
12 | "HttpClient"
13 | ],
14 | "items": [
15 | "Resources have been closed in finally block or using try-with-resources"
16 | ]
17 | },
18 | {
19 | "triggers": ["RequestMapping", "GetMapping", "PostMapping", "PutMapping"],
20 | "items": ["Endpoint URLs exposed by application use only small case"]
21 | },
22 | {
23 | "triggers": ["keyword1", "keyword2"],
24 | "items": ["reminder about keywords", "another reminder about keywords"]
25 | }
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 |
4 |
5 |
6 |
7 |
8 | ## Testing
9 |
10 |
11 |
12 |
13 | ## Additional context
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.github/workflows/schedule-action-update.yml:
--------------------------------------------------------------------------------
1 | name: GitHub Actions Version Updater
2 |
3 | # Controls when the action will run.
4 | on:
5 | workflow_dispatch:
6 | schedule:
7 | # Automatically run on every Sunday
8 | - cron: '0 0 * * 0'
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - uses: actions/checkout@v3
16 | with:
17 | # [Required] Access token with `workflow` scope.
18 | token: ${{ secrets.PAT }}
19 |
20 | - name: Run GitHub Actions Version Updater
21 | uses: saadmk11/github-actions-version-updater@v0.7.4
22 | with:
23 | # [Required] Access token with `workflow` scope.
24 | token: ${{ secrets.PAT }}
25 | pull_request_title: "ci: Update GitHub Actions to Latest Version"
26 |
--------------------------------------------------------------------------------
/tests/test_llama_index.py:
--------------------------------------------------------------------------------
1 | """Tests for the Llame Index Package."""
2 | import pytest
3 |
4 | from gpt_review._ask import _load_azure_openai_context
5 | from gpt_review._llama_index import _query_index
6 |
7 |
8 | def ask_doc_test() -> None:
9 | _load_azure_openai_context()
10 | question = "What is the name of the package?"
11 | files = ["src/gpt_review/__init__.py"]
12 | _query_index(question, files, fast=True)
13 |
14 | # Try again to use the cached index
15 | _query_index(question, files, fast=True)
16 |
17 |
18 | def test_ask_doc(mock_openai) -> None:
19 | """Unit Test for the ask_doc function."""
20 | ask_doc_test()
21 |
22 |
23 | @pytest.mark.integration
24 | def test_int_ask_doc() -> None:
25 | """Integration Test for the ask_doc function."""
26 | ask_doc_test()
27 |
--------------------------------------------------------------------------------
/tests/test_git.py:
--------------------------------------------------------------------------------
1 | """Test git functions."""
2 | import pytest
3 |
4 | from gpt_review._git import _commit, _find_git_dir
5 |
6 |
7 | def commit_test() -> None:
8 | """Test Case for commit wrapper."""
9 | message = _commit()
10 | assert message
11 |
12 | message = _commit(push=True)
13 | assert message
14 |
15 |
16 | def test_commit(mock_openai: None, mock_git_commit: None) -> None:
17 | """Unit test for commit function."""
18 | commit_test()
19 |
20 |
21 | @pytest.mark.integration
22 | def test_int_commit(mock_git_commit: None) -> None:
23 | """Integration test for commit function."""
24 | commit_test()
25 |
26 |
27 | @pytest.mark.unit
28 | @pytest.mark.integration
29 | def test_find_git_dir() -> None:
30 | _find_git_dir(path="tests")
31 |
32 | with pytest.raises(FileNotFoundError):
33 | _find_git_dir(path="/")
34 |
--------------------------------------------------------------------------------
/src/gpt_review/repositories/_repository.py:
--------------------------------------------------------------------------------
1 | """Abstract class for a repository client."""
2 | from abc import abstractmethod
3 |
4 |
5 | class _RepositoryClient:
6 | """Abstract class for a repository client."""
7 |
8 | @staticmethod
9 | @abstractmethod
10 | def get_pr_diff(patch_repo=None, patch_pr=None, access_token=None) -> str:
11 | """
12 | Get the diff of a PR.
13 |
14 | Args:
15 | patch_repo (str): The repo.
16 | patch_pr (str): The PR.
17 | access_token (str): The GitHub access token.
18 |
19 | Returns:
20 | str: The diff of the PR.
21 | """
22 |
23 | @staticmethod
24 | @abstractmethod
25 | def post_pr_summary(diff) -> None:
26 | """
27 | Post a summary to a PR.
28 |
29 | Args:
30 | diff (str): The diff of the PR.
31 |
32 | Returns:
33 | str: The review of the PR.
34 | """
35 |
--------------------------------------------------------------------------------
/src/gpt_review/utils.py:
--------------------------------------------------------------------------------
1 | """Utility functions"""
2 | import logging
3 | import time
4 | from typing import Optional
5 |
6 | import gpt_review.constants as C
7 |
8 |
9 | def _retry_with_exponential_backoff(current_retry: int, retry_after: Optional[str]) -> None:
10 | """
11 | Use exponential backoff to retry a request after specific time while staying under the retry count
12 |
13 | Args:
14 | current_retry (int): The current retry count.
15 | retry_after (Optional[str]): The time to wait before retrying.
16 | """
17 | logging.warning("Call to GPT failed due to rate limit, retry attempt %s of %s", current_retry, C.MAX_RETRIES)
18 |
19 | multiplication_factor = 2 * (1 + current_retry / C.MAX_RETRIES)
20 | wait_time = int(retry_after) * multiplication_factor if retry_after else current_retry * multiplication_factor
21 |
22 | logging.warning("Waiting for %s seconds before retrying.", wait_time)
23 |
24 | time.sleep(wait_time)
25 |
--------------------------------------------------------------------------------
/tests/test_review.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from gpt_review.repositories.github import GitHubClient
4 |
5 |
6 | def test_get_review(mock_openai) -> None:
7 | get_review_test()
8 |
9 |
10 | @pytest.mark.integration
11 | def test_int_get_review() -> None:
12 | get_review_test()
13 |
14 |
15 | def get_review_test() -> None:
16 | """Test get_review."""
17 | # Load test data from moock.diff
18 | with open("tests/mock.diff", "r") as f:
19 | diff = f.read()
20 |
21 | GitHubClient.post_pr_summary(diff)
22 |
23 |
24 | def test_empty_summary(empty_summary, mock_openai) -> None:
25 | get_review_test()
26 |
27 |
28 | @pytest.mark.integration
29 | def test_int_empty_summary(empty_summary) -> None:
30 | get_review_test()
31 |
32 |
33 | def test_file_summary(mock_openai, file_summary) -> None:
34 | get_review_test()
35 |
36 |
37 | @pytest.mark.integration
38 | def test_int_file_summary(mock_openai, file_summary) -> None:
39 | get_review_test()
40 |
--------------------------------------------------------------------------------
/.github/workflows/dependency-review.yml:
--------------------------------------------------------------------------------
1 | # Dependency Review Action
2 | #
3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
4 | #
5 | # Source repository: https://github.com/actions/dependency-review-action
6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
7 | name: 'Dependency Review'
8 | on: [pull_request]
9 |
10 | permissions:
11 | contents: read
12 |
13 | jobs:
14 | dependency-review:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: 'Checkout Repository'
18 | uses: actions/checkout@v3
19 | - name: 'Dependency Review'
20 | uses: actions/dependency-review-action@v3
21 |
--------------------------------------------------------------------------------
/NuGet.config:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/gpt_review/main.py:
--------------------------------------------------------------------------------
1 | """The GPT CLI entry point."""
2 | import sys
3 |
4 | from knack.help_files import helps
5 |
6 | from gpt_review._gpt_cli import cli
7 |
8 |
9 | def _help_text(help_type, short_summary) -> str:
10 | return f"""
11 | type: {help_type}
12 | short-summary: {short_summary}
13 | """
14 |
15 |
16 | helps[""] = _help_text("group", "Easily interact with GPT APIs.")
17 | helps["ask"] = _help_text("group", "Use GPT to ask questions.")
18 | helps["git"] = _help_text("group", "Use GPT enchanced git commands.")
19 | helps["git commit"] = _help_text("command", "Run git commit with a commit message generated by GPT.")
20 | helps["github"] = _help_text("group", "Use GPT with GitHub Repositories.")
21 | helps["github review"] = _help_text("command", "Review GitHub PR with Open AI, and post response as a comment.")
22 | helps["review"] = _help_text("group", "Use GPT to perform customized reviews.")
23 | helps["review diff"] = _help_text("command", "Review a git diff from file.")
24 |
25 |
26 | exit_code = cli()
27 | sys.exit(exit_code)
28 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "func",
6 | "label": "func: host start",
7 | "command": "host start",
8 | "problemMatcher": "$func-python-watch",
9 | "isBackground": true,
10 | "dependsOn": "pip install (functions)",
11 | "options": {
12 | "cwd": "${workspaceFolder}/azure/api"
13 | }
14 | },
15 | {
16 | "label": "pip install (functions)",
17 | "type": "shell",
18 | "osx": {
19 | "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt"
20 | },
21 | "windows": {
22 | "command": "${config:azureFunctions.pythonVenv}/Scripts/python -m pip install -r requirements.txt"
23 | },
24 | "linux": {
25 | "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt"
26 | },
27 | "problemMatcher": [],
28 | "options": {
29 | "cwd": "${workspaceFolder}/azure/api"
30 | }
31 | }
32 | ]
33 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Maciej Kilian
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/gpt_review/constants.py:
--------------------------------------------------------------------------------
1 | """Contains constants for minimum and maximum values of various parameters used in GPT Review."""
2 | import os
3 | import sys
4 |
5 | MAX_TOKENS_DEFAULT = 100
6 | MAX_TOKENS_MIN = 1
7 | MAX_TOKENS_MAX = sys.maxsize
8 |
9 | TEMPERATURE_DEFAULT = 0.7
10 | TEMPERATURE_MIN = 0
11 | TEMPERATURE_MAX = 1
12 |
13 | TOP_P_DEFAULT = 0.5
14 | TOP_P_MIN = 0
15 | TOP_P_MAX = 1
16 |
17 | FREQUENCY_PENALTY_DEFAULT = 0.5
18 | FREQUENCY_PENALTY_MIN = 0
19 | FREQUENCY_PENALTY_MAX = 2
20 |
21 | PRESENCE_PENALTY_DEFAULT = 0
22 | PRESENCE_PENALTY_MIN = 0
23 | PRESENCE_PENALTY_MAX = 2
24 |
25 | MAX_RETRIES = int(os.getenv("MAX_RETRIES", 15))
26 | DEFAULT_RETRY_AFTER = 30
27 |
28 | AZURE_API_TYPE = "azure"
29 | AZURE_API_VERSION = "2023-03-15-preview"
30 | AZURE_CONFIG_FILE = "azure.yaml"
31 | AZURE_TURBO_MODEL = "gpt-35-turbo"
32 | AZURE_SMART_MODEL = "gpt-4"
33 | AZURE_LARGE_MODEL = "gpt-4-32k"
34 | AZURE_EMBEDDING_MODEL = "text-embedding-ada-002"
35 | AZURE_KEY_VAULT = "https://dciborow-openai.vault.azure.net/"
36 |
37 | BUG_PROMPT_YAML = "prompt_bug.yaml"
38 | COVERAGE_PROMPT_YAML = "prompt_coverage.yaml"
39 | SUMMARY_PROMPT_YAML = "prompt_summary.yaml"
40 |
--------------------------------------------------------------------------------
/tests/test_report.py:
--------------------------------------------------------------------------------
1 | """Tests for customized markdown report generation."""
2 | import pytest
3 |
4 | from gpt_review._review import _process_report, _process_yaml
5 |
6 |
7 | def test_report_generation(git_diff, report_config, mock_openai) -> None:
8 | """Test report generation with mocks."""
9 | report_generation_test(git_diff, report_config)
10 |
11 |
12 | @pytest.mark.integration
13 | def test_int_report_generation(git_diff, report_config) -> None:
14 | """Test report generation."""
15 | report_generation_test(git_diff, report_config)
16 |
17 |
18 | def test_process_yaml(git_diff, config_yaml, mock_openai) -> None:
19 | process_yaml_test(git_diff, config_yaml)
20 |
21 |
22 | @pytest.mark.integration
23 | def test_int_process_yaml(git_diff, config_yaml) -> None:
24 | process_yaml_test(git_diff, config_yaml)
25 |
26 |
27 | def report_generation_test(git_diff, report_config) -> None:
28 | report = _process_report(git_diff, report_config)
29 | assert report
30 |
31 |
32 | def process_yaml_test(git_diff, config_yaml) -> None:
33 | """Test process_yaml."""
34 | report = _process_yaml(git_diff, config_yaml)
35 | assert report
36 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.formatOnPaste": true,
4 | "files.trimTrailingWhitespace": true,
5 | "files.autoSave": "onFocusChange",
6 | "git.autofetch": true,
7 | "[jsonc]": {
8 | "editor.defaultFormatter": "vscode.json-language-features"
9 | },
10 | "[python]": {
11 | "editor.defaultFormatter": "ms-python.black-formatter"
12 | },
13 | "python.defaultInterpreterPath": "/usr/local/bin/python",
14 | "python.formatting.provider": "black",
15 | "python.testing.unittestEnabled": false,
16 | "python.testing.pytestEnabled": true,
17 | "pylint.args": [
18 | "--rcfile=pyproject.toml"
19 | ],
20 | "black-formatter.args": [
21 | "--config=pyproject.toml"
22 | ],
23 | "flake8.args": [
24 | "--toml-config=pyproject.toml"
25 | ],
26 | "isort.args": [
27 | "--settings-path=pyproject.toml"
28 | ],
29 | "python.linting.banditEnabled": true,
30 | "python.linting.banditArgs": [
31 | "-c",
32 | "pyproject.toml"
33 | ],
34 | "python.analysis.inlayHints.functionReturnTypes": true,
35 | "python.analysis.diagnosticSeverityOverrides": {
36 | "reportUndefinedVariable": "none" // Covered by Ruff F821
37 | }
38 | }
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | registries:
8 | my-nuget-feed:
9 | type: nuget-feed
10 | url: https://msazure.pkgs.visualstudio.com/One/_packaging/azure-core-quality-engineering/nuget/v3/index.json
11 | username: dciborow@microsoft.com
12 | password: ${{secrets.DEVOPS_PAT}}
13 |
14 | updates:
15 | - package-ecosystem: "nuget"
16 | directory: "/" # Location of package manifests
17 | schedule:
18 | interval: "daily"
19 | commit-message:
20 | prefix: "fix: "
21 | registries:
22 | - my-nuget-feed
23 |
24 | - package-ecosystem: pip
25 | directory: "/"
26 | schedule:
27 | interval: daily
28 | time: "13:00"
29 | open-pull-requests-limit: 10
30 | reviewers:
31 | - dciborow
32 | commit-message:
33 | prefix: "fix: "
34 | allow:
35 | - dependency-type: direct
36 | - dependency-type: indirect
37 |
38 | - package-ecosystem: "github-actions"
39 | directory: "/"
40 | schedule:
41 | interval: daily
42 | time: "13:00"
43 | commit-message:
44 | prefix: "fix: "
45 | reviewers:
46 | - dciborow
47 |
--------------------------------------------------------------------------------
/src/gpt_review/prompts/_prompt.py:
--------------------------------------------------------------------------------
1 | """Interface for a GPT Prompts."""
2 | import os
3 | import sys
4 | from dataclasses import dataclass
5 | from pathlib import Path
6 |
7 | from langchain.prompts import PromptTemplate, load_prompt
8 |
9 | import gpt_review.constants as C
10 |
11 | if sys.version_info[:2] <= (3, 10):
12 | from typing_extensions import Self
13 | else:
14 | from typing import Self
15 |
16 |
17 | @dataclass
18 | class LangChainPrompt(PromptTemplate):
19 | """A prompt for the GPT LangChain task."""
20 |
21 | prompt_yaml: str
22 |
23 | @classmethod
24 | def load(cls, prompt_yaml) -> Self:
25 | """Load the prompt."""
26 | return load_prompt(prompt_yaml)
27 |
28 |
29 | def load_bug_yaml() -> LangChainPrompt:
30 | """Load the bug yaml."""
31 | yaml_path = os.getenv("PROMPT_BUG", str(Path(__file__).parents[0].joinpath(C.BUG_PROMPT_YAML)))
32 | return LangChainPrompt.load(yaml_path)
33 |
34 |
35 | def load_coverage_yaml() -> LangChainPrompt:
36 | """Load the coverage yaml."""
37 | yaml_path = os.getenv("PROMPT_COVERAGE", str(Path(__file__).parents[0].joinpath(C.COVERAGE_PROMPT_YAML)))
38 | return LangChainPrompt.load(yaml_path)
39 |
40 |
41 | def load_summary_yaml() -> LangChainPrompt:
42 | """Load the summary yaml."""
43 | yaml_path = os.getenv("PROMPT_SUMMARY", str(Path(__file__).parents[0].joinpath(C.SUMMARY_PROMPT_YAML)))
44 | return LangChainPrompt.load(yaml_path)
45 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ "main" ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ "main" ]
20 | schedule:
21 | - cron: '36 1 * * 1'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'python' ]
36 |
37 | steps:
38 | - name: Checkout repository
39 | uses: actions/checkout@v3
40 |
41 | # Initializes the CodeQL tools for scanning.
42 | - name: Initialize CodeQL
43 | uses: github/codeql-action/init@v2
44 | with:
45 | languages: ${{ matrix.language }}
46 |
47 | - name: Perform CodeQL Analysis
48 | uses: github/codeql-action/analyze@v2
49 | with:
50 | category: "/language:${{matrix.language}}"
51 |
--------------------------------------------------------------------------------
/tests/test_github.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from gpt_review.repositories.github import GitHubClient
4 |
5 |
6 | def get_pr_diff_test(starts_with, patch_repo=None, patch_pr=None) -> None:
7 | """Test the GitHub API call."""
8 | diff = GitHubClient.get_pr_diff(patch_repo=patch_repo, patch_pr=patch_pr)
9 | assert diff.startswith(starts_with)
10 |
11 |
12 | def post_pr_comment_test() -> None:
13 | """Test the GitHub API call."""
14 | response = GitHubClient.post_pr_summary("test")
15 | assert response
16 |
17 |
18 | @pytest.mark.integration
19 | def test_int_pr_diff(mock_github) -> None:
20 | """Integration Test for GitHub API diff call."""
21 | get_pr_diff_test("diff --git a/README.md b/README.md", "microsoft/gpt-review", 1)
22 |
23 |
24 | def test_pr_diff(mock_openai, mock_github) -> None:
25 | """Unit Test for GitHub API diff call."""
26 | get_pr_diff_test("diff --git a/README.md b/README.md")
27 |
28 |
29 | @pytest.mark.integration
30 | def test_int_pr_comment(mock_github) -> None:
31 | """Integration Test for GitHub API comment call."""
32 | post_pr_comment_test()
33 |
34 |
35 | @pytest.mark.integration
36 | def test_int_pr_update(mock_github, mock_github_comment) -> None:
37 | """Integration Test for updating GitHub API comment call."""
38 | post_pr_comment_test()
39 |
40 |
41 | def test_pr_comment(mock_openai, mock_github) -> None:
42 | """Unit Test for GitHub API comment call."""
43 | post_pr_comment_test()
44 |
45 |
46 | def test_pr_update(mock_openai, mock_github, mock_github_comment) -> None:
47 | """Unit Test for updating GitHub API comment call."""
48 | post_pr_comment_test()
49 |
--------------------------------------------------------------------------------
/src/gpt_review/_gpt_cli.py:
--------------------------------------------------------------------------------
1 | """The GPT CLI configuration and utilities."""
2 | import os
3 | import sys
4 | from collections import OrderedDict
5 |
6 | from knack import CLI, CLICommandsLoader
7 |
8 | from gpt_review import __version__
9 | from gpt_review._ask import AskCommandGroup
10 | from gpt_review._git import GitCommandGroup
11 | from gpt_review._review import ReviewCommandGroup
12 | from gpt_review.repositories.github import GitHubCommandGroup
13 |
14 | CLI_NAME = "gpt"
15 |
16 |
17 | class GPTCLI(CLI):
18 | """Custom CLI implemntation to set version for the GPT CLI."""
19 |
20 | def get_cli_version(self) -> str:
21 | return __version__
22 |
23 |
24 | class GPTCommandsLoader(CLICommandsLoader):
25 | """The GPT CLI Commands Loader."""
26 |
27 | _CommandGroups = [AskCommandGroup, GitHubCommandGroup, GitCommandGroup, ReviewCommandGroup]
28 |
29 | def load_command_table(self, args) -> OrderedDict:
30 | for command_group in self._CommandGroups:
31 | command_group.load_command_table(self)
32 | return OrderedDict(self.command_table)
33 |
34 | def load_arguments(self, command) -> None:
35 | for argument_group in self._CommandGroups:
36 | argument_group.load_arguments(self)
37 | super(GPTCommandsLoader, self).load_arguments(command)
38 |
39 |
40 | def cli() -> int:
41 | """The GPT CLI entry point."""
42 | gpt = GPTCLI(
43 | cli_name=CLI_NAME,
44 | config_dir=os.path.expanduser(os.path.join("~", f".{CLI_NAME}")),
45 | config_env_var_prefix=CLI_NAME,
46 | commands_loader_cls=GPTCommandsLoader,
47 | )
48 | return gpt.invoke(sys.argv[1:])
49 |
--------------------------------------------------------------------------------
/tests/test_openai.py:
--------------------------------------------------------------------------------
1 | """Tests for the Open AI Wrapper."""
2 | import pytest
3 | from openai.error import RateLimitError
4 |
5 | import gpt_review.constants as C
6 | from gpt_review._openai import _call_gpt, _get_model
7 | from gpt_review.context import _load_azure_openai_context
8 |
9 |
10 | def get_model_test() -> None:
11 | prompt = "This is a test prompt"
12 |
13 | context = _load_azure_openai_context()
14 |
15 | model = _get_model(prompt=prompt, max_tokens=1000, fast=True)
16 | assert model == context.turbo_llm_model_deployment_id
17 |
18 | model = _get_model(prompt=prompt, max_tokens=5000)
19 | assert model == context.smart_llm_model_deployment_id
20 |
21 | model = _get_model(prompt=prompt, max_tokens=9000)
22 | assert model == context.large_llm_model_deployment_id
23 |
24 |
25 | def test_get_model() -> None:
26 | get_model_test()
27 |
28 |
29 | @pytest.mark.integration
30 | def test_int_get_model() -> None:
31 | get_model_test()
32 |
33 |
34 | def rate_limit_test(monkeypatch):
35 | def mock_get_model(prompt: str, max_tokens: int, fast: bool = False, large: bool = False):
36 | error = RateLimitError("Rate Limit Error")
37 | error.headers["Retry-After"] = 10
38 | raise error
39 |
40 | monkeypatch.setattr("gpt_review._openai._get_model", mock_get_model)
41 | with pytest.raises(RateLimitError):
42 | _call_gpt(prompt="This is a test prompt", retry=C.MAX_RETRIES)
43 |
44 |
45 | def test_rate_limit(monkeypatch) -> None:
46 | rate_limit_test(monkeypatch)
47 |
48 |
49 | @pytest.mark.integration
50 | def test_int_rate_limit(monkeypatch) -> None:
51 | rate_limit_test(monkeypatch)
52 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report
3 | title: "[Bug Report]: "
4 | labels: ["Bug Report"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out this bug report!
10 | - type: input
11 | id: module-path
12 | attributes:
13 | label: Module path
14 | description: The CLI command producing bug
15 | placeholder: gpt --help
16 | validations:
17 | required: true
18 | - type: input
19 | id: version
20 | attributes:
21 | label: review-gpt CLI version
22 | description: Please provide the version of the review-gpt CLI you were using when you encountered the bug.
23 | placeholder: "0.4.1008"
24 | validations:
25 | required: true
26 | - type: textarea
27 | id: bug-description
28 | attributes:
29 | label: Describe the bug
30 | description: A clear and concise description of what the bug is vs. what you expected to happen.
31 | validations:
32 | required: true
33 | - type: textarea
34 | id: steps-to-reproduce
35 | attributes:
36 | label: To reproduce
37 | description: Steps to reproduce the problem.
38 | validations:
39 | required: true
40 | - type: textarea
41 | id: code-snippet
42 | attributes:
43 | label: Code snippet
44 | description: Please copy and paste any code snippet that can help reproduce the problem.
45 | render: Bicep
46 | - type: textarea
47 | id: logs
48 | attributes:
49 | label: Relevant log output
50 | description: Please copy and paste any relevant log output.
51 | render: Shell
52 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/python-3-miniconda
3 | {
4 | "name": "Python Environment",
5 | "build": {
6 | "dockerfile": "Dockerfile",
7 | "context": ".."
8 | },
9 | "customizations": {
10 | "vscode": {
11 | "extensions": [
12 | "editorconfig.editorconfig",
13 | "github.vscode-pull-request-github",
14 | "ms-azuretools.vscode-docker",
15 | "ms-python.python",
16 | "ms-python.vscode-pylance",
17 | "ms-python.pylint",
18 | "ms-python.isort",
19 | "ms-python.flake8",
20 | "ms-python.black-formatter",
21 | "ms-vsliveshare.vsliveshare",
22 | "ryanluker.vscode-coverage-gutters",
23 | "bungcip.better-toml",
24 | "GitHub.copilot",
25 | "GitHub.vscode-github-actions",
26 | "ms-vscode.azurecli",
27 | "ms-vscode.azure-account",
28 | "zokugun.explicit-folding",
29 | "MaxKless.git-squash-all",
30 | "GitHub.copilot-labs",
31 | "GitHub.codespaces",
32 | "charliermarsh.ruff"
33 | ],
34 | "settings": {
35 | "python.defaultInterpreterPath": "/usr/local/bin/python",
36 | "black-formatter.path": [
37 | "/usr/local/py-utils/bin/black"
38 | ],
39 | "pylint.path": [
40 | "/usr/local/py-utils/bin/pylint"
41 | ],
42 | "flake8.path": [
43 | "/usr/local/py-utils/bin/flake8"
44 | ],
45 | "isort.path": [
46 | "/usr/local/py-utils/bin/isort"
47 | ]
48 | }
49 | }
50 | },
51 | "features": {
52 | "azure-cli": "latest"
53 | },
54 | "onCreateCommand": "pip install -e ."
55 | }
--------------------------------------------------------------------------------
/.github/workflows/test-action.yml:
--------------------------------------------------------------------------------
1 | # Disable
2 | # on: [pull_request_target]
3 |
4 | # jobs:
5 | # add_pr_comment:
6 | # permissions: write-all
7 | # runs-on: ubuntu-latest
8 | # name: OpenAI PR Comment
9 | # env:
10 | # GIT_COMMIT_HASH: ${{ github.event.pull_request.head.sha }}
11 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
12 | # PR_NUMBER: ${{ github.event.pull_request.number }}
13 | # PR_TITLE: ${{ github.event.pull_request.title }}
14 | # REPOSITORY_NAME: ${{ github.repository }}
15 | # AZURE_OPENAI_API: ${{ secrets.AZURE_OPENAI_API }}
16 | # AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
17 | # LINK: "https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
18 | # FILE_SUMMARY: false
19 | # TEST_SUMMARY: false
20 | # BUG_SUMMARY: false
21 | # RISK_SUMMARY: false
22 | # RISK_BREAKING: false
23 |
24 | # steps:
25 | # - uses: actions/checkout@v3
26 | # with:
27 | # ref: ${{ github.event.pull_request.head.sha }}
28 |
29 | # - name: Set up Python 3.11
30 | # uses: actions/setup-python@v4
31 | # with:
32 | # python-version: 3.11
33 |
34 | # - uses: Azure/login@v1.4.6
35 | # with:
36 | # creds: ${{ secrets.AZURE_CREDENTIALS }}
37 |
38 | # - run: |
39 | # sudo apt-get update
40 | # python3 -m venv .env
41 | # source .env/bin/activate
42 | # python -m pip install --upgrade pip
43 | # python -m pip install gpt-review\
44 |
45 | # - run: |
46 | # source .env/bin/activate
47 |
48 | # gpt github review \
49 | # --access-token $GITHUB_TOKEN \
50 | # --pull-request $PR_NUMBER \
51 | # --repository $REPOSITORY_NAME
52 | # continue-on-error: true
53 |
54 | # - run: |
55 | # source .env/bin/activate
56 |
57 | # pip install -e .
58 |
59 | # gpt github review \
60 | # --access-token $GITHUB_TOKEN \
61 | # --pull-request $PR_NUMBER \
62 | # --repository $REPOSITORY_NAME
63 |
--------------------------------------------------------------------------------
/.github/workflows/on-push-create-draft-release.yml:
--------------------------------------------------------------------------------
1 | # Copyright (c) Microsoft Corporation. All rights reserved.
2 | # Licensed under the MIT License.
3 | name: Create new Release Draft
4 | on:
5 | push:
6 | branches:
7 | - main
8 | paths:
9 | - 'src/**'
10 | - 'pyproject.toml'
11 | workflow_dispatch:
12 |
13 | jobs:
14 | CreateRelease:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout repository
18 | uses: actions/checkout@v3
19 | with:
20 | token: ${{ secrets.PAT }}
21 |
22 | - name: Bump version and push tag
23 | id: tag_version
24 | uses: mathieudutour/github-tag-action@v6.1
25 | with:
26 | github_token: ${{ secrets.GITHUB_TOKEN }}
27 | dry_run: true
28 | tag_prefix: 'v'
29 | default_bump: false
30 |
31 | - name: Set Configurations
32 | if: steps.tag_version.outputs.release_type
33 | shell: bash
34 | env:
35 | OLD_VERSION: ${{ steps.tag_version.outputs.previous_version }}
36 | VERSION: ${{ steps.tag_version.outputs.new_version }}
37 | run: sed -ri 's/(__version__ = ")([0-9]+\.[0-9]+\.[0-9]+?.*)(")/\1'"$VERSION"'\3/' "src/gpt_review/__init__.py" || exit 1
38 |
39 | - uses: EndBug/add-and-commit@v9
40 | if: steps.tag_version.outputs.release_type
41 | with:
42 | message: Update Version to ${{ steps.tag_version.outputs.new_version }} [no ci]
43 | committer_name: GitHub Actions
44 | committer_email: actions@github.com
45 | add: "src/gpt_review/__init__.py"
46 |
47 | - if: steps.tag_version.outputs.release_type
48 | run: |
49 | python -m pip install --upgrade pip
50 | python -m pip install flit
51 |
52 | python -m flit build
53 |
54 | - if: steps.tag_version.outputs.release_type
55 | run: |
56 | gh release delete $TAG || echo "no tag existed"
57 |
58 | gh release create \
59 | -d \
60 | --target main \
61 | --generate-notes \
62 | $TAG \
63 | LICENSE dist/*.tar.gz dist/*.whl
64 | env:
65 | TAG: ${{ steps.tag_version.outputs.new_tag }}
66 | GH_TOKEN: ${{ secrets.PAT }}
67 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 | azure.yaml
113 |
114 | # Spyder project settings
115 | .spyderproject
116 | .spyproject
117 |
118 | # Rope project settings
119 | .ropeproject
120 |
121 | # mkdocs documentation
122 | /site
123 |
124 | # mypy
125 | .mypy_cache/
126 | .dmypy.json
127 | dmypy.json
128 |
129 | # Pyre type checker
130 | .pyre/
131 |
132 | # Default Directory for llama index files
133 | storage/
134 |
135 | # Azure Functions artifacts
136 | bin
137 | obj
138 | appsettings.json
139 | local.settings.json
140 | *.deb
141 | .venvs
142 |
143 | # Azurite artifacts
144 | __blobstorage__
145 | __queuestorage__
146 | __azurite_db*__.json
147 | .python_packages
148 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Security
4 |
5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/).
6 |
7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below.
8 |
9 | ## Reporting Security Issues
10 |
11 | **Please do not report security vulnerabilities through public GitHub issues.**
12 |
13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report).
14 |
15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey).
16 |
17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc).
18 |
19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
20 |
21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
22 | * Full paths of source file(s) related to the manifestation of the issue
23 | * The location of the affected source code (tag/branch/commit or direct URL)
24 | * Any special configuration required to reproduce the issue
25 | * Step-by-step instructions to reproduce the issue
26 | * Proof-of-concept or exploit code (if possible)
27 | * Impact of the issue, including how an attacker might exploit the issue
28 |
29 | This information will help us triage your report more quickly.
30 |
31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs.
32 |
33 | ## Preferred Languages
34 |
35 | We prefer all communications to be in English.
36 |
37 | ## Policy
38 |
39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd).
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/gpt_review/context.py:
--------------------------------------------------------------------------------
1 | """Context for the Azure OpenAI API and the models."""
2 | import os
3 | from dataclasses import dataclass
4 |
5 | import openai
6 | import yaml
7 | from azure.identity import DefaultAzureCredential
8 | from azure.keyvault.secrets import SecretClient
9 |
10 | import gpt_review.constants as C
11 |
12 |
13 | @dataclass
14 | class Context:
15 | azure_api_base: str
16 | azure_api_type: str = C.AZURE_API_TYPE
17 | azure_api_version: str = C.AZURE_API_VERSION
18 | turbo_llm_model_deployment_id: str = C.AZURE_TURBO_MODEL
19 | smart_llm_model_deployment_id: str = C.AZURE_SMART_MODEL
20 | large_llm_model_deployment_id: str = C.AZURE_LARGE_MODEL
21 | embedding_model_deployment_id: str = C.AZURE_EMBEDDING_MODEL
22 |
23 |
24 | def _load_context_file():
25 | """Import from yaml file and return the context."""
26 | context_file = os.getenv("CONTEXT_FILE", C.AZURE_CONFIG_FILE)
27 | with open(context_file, "r", encoding="utf8") as file:
28 | return yaml.load(file, Loader=yaml.SafeLoader)
29 |
30 |
31 | def _load_azure_openai_context() -> Context:
32 | """Load the context from the environment variables or the context file.
33 |
34 | If a config file is available its values will take precedence. Otherwise
35 | it will first check for an AZURE_OPENAI_API key, next OPENAI_API_KEY, and
36 | lastly the Azure Key Vault.
37 |
38 | Returns:
39 | Context: The context for the Azure OpenAI API and the models.
40 | """
41 | azure_config = _load_context_file() if os.path.exists(os.getenv("CONTEXT_FILE", C.AZURE_CONFIG_FILE)) else {}
42 |
43 | if azure_config.get("azure_api_type"):
44 | openai.api_type = os.environ["OPENAI_API_TYPE"] = azure_config.get("azure_api_type")
45 | elif os.getenv("AZURE_OPENAI_API"):
46 | openai.api_type = os.environ["OPENAI_API_TYPE"] = C.AZURE_API_TYPE
47 | elif "OPENAI_API_TYPE" in os.environ:
48 | openai.api_type = os.environ["OPENAI_API_TYPE"]
49 |
50 | if azure_config.get("azure_api_version"):
51 | openai.api_version = os.environ["OPENAI_API_VERSION"] = azure_config.get("azure_api_version")
52 | elif os.getenv("AZURE_OPENAI_API"):
53 | openai.api_version = os.environ["OPENAI_API_VERSION"] = C.AZURE_API_VERSION
54 | elif "OPENAI_API_VERSION" in os.environ:
55 | openai.api_version = os.environ["OPENAI_API_VERSION"]
56 |
57 | if os.getenv("AZURE_OPENAI_API"):
58 | openai.api_type = os.environ["OPENAI_API_TYPE"] = C.AZURE_API_TYPE
59 | openai.api_base = os.environ["OPENAI_API_BASE"] = os.getenv("AZURE_OPENAI_API") or azure_config.get(
60 | "azure_api_base"
61 | )
62 | openai.api_key = os.environ["OPENAI_API_KEY"] = os.getenv("AZURE_OPENAI_API_KEY") # type: ignore
63 | elif os.getenv("OPENAI_API_KEY"):
64 | openai.api_key = os.environ["OPENAI_API_KEY"]
65 | else:
66 | kv_client = SecretClient(
67 | vault_url=os.getenv("AZURE_KEY_VAULT_URL", C.AZURE_KEY_VAULT),
68 | credential=DefaultAzureCredential(additionally_allowed_tenants=["*"]),
69 | )
70 | openai.api_type = os.environ["OPENAI_API_TYPE"] = C.AZURE_API_TYPE
71 | openai.api_base = os.environ["OPENAI_API_BASE"] = kv_client.get_secret("azure-open-ai").value # type: ignore
72 | openai.api_key = os.environ["OPENAI_API_KEY"] = kv_client.get_secret("azure-openai-key").value # type: ignore
73 |
74 | return Context(
75 | azure_api_base=openai.api_base,
76 | azure_api_type=openai.api_type,
77 | azure_api_version=openai.api_version,
78 | **azure_config.get("azure_model_map", {}),
79 | )
80 |
--------------------------------------------------------------------------------
/src/gpt_review/_git.py:
--------------------------------------------------------------------------------
1 | """Basic Shell Commands for Git."""
2 | import logging
3 | import os
4 | from typing import Dict
5 |
6 | from git.repo import Repo
7 | from knack import CLICommandsLoader
8 | from knack.arguments import ArgumentsContext
9 | from knack.commands import CommandGroup
10 |
11 | from gpt_review._command import GPTCommandGroup
12 | from gpt_review._review import _request_goal
13 |
14 |
15 | def _find_git_dir(path=".") -> str:
16 | """
17 | Find the .git directory.
18 |
19 | Args:
20 | path (str): The path to start searching from.
21 |
22 | Returns:
23 | path (str): The path to the .git directory.
24 | """
25 | while path != "/":
26 | if os.path.exists(os.path.join(path, ".git")):
27 | return path
28 | path = os.path.abspath(os.path.join(path, os.pardir))
29 | raise FileNotFoundError(".git directory not found")
30 |
31 |
32 | def _diff() -> str:
33 | """
34 | Get the diff of the PR
35 | - run git commands via python
36 |
37 | Returns:
38 | diff (str): The diff of the PR.
39 | """
40 | return Repo.init(_find_git_dir()).git.diff(None, cached=True)
41 |
42 |
43 | def _commit_message(gpt4: bool = False, large: bool = False) -> str:
44 | """
45 | Create a commit message with GPT.
46 |
47 | Args:
48 | gpt4 (bool, optional): Whether to use gpt-4. Defaults to False.
49 | large (bool, optional): Whether to use gpt-4-32k. Defaults to False.
50 |
51 | Returns:
52 | response (str): The response from GPT-4.
53 | """
54 |
55 | goal = """
56 | Create a short, single-line, git commit message for these changes
57 | """
58 | diff = _diff()
59 | logging.debug("Diff: %s", diff)
60 |
61 | return _request_goal(diff, goal, fast=not gpt4, large=large)
62 |
63 |
64 | def _push() -> str:
65 | """Run git push."""
66 | logging.debug("Pushing commit to remote.")
67 | repo = Repo.init(_find_git_dir())
68 | return repo.git.push()
69 |
70 |
71 | def _commit(gpt4: bool = False, large: bool = False, push: bool = False) -> Dict[str, str]:
72 | """Run git commit with a commit message generated by GPT.
73 |
74 | Args:
75 | gpt4 (bool, optional): Whether to use gpt-4. Defaults to False.
76 | large (bool, optional): Whether to use gpt-4-32k. Defaults to False.
77 | push (bool, optional): Whether to push the commit to the remote. Defaults to False.
78 |
79 | Returns:
80 | response (Dict[str, str]): The response from git commit.
81 | """
82 | message = _commit_message(gpt4=gpt4, large=large)
83 | logging.debug("Commit Message: %s", message)
84 | repo = Repo.init(_find_git_dir())
85 | commit = repo.git.commit(message=message)
86 | if push:
87 | commit += f"\n{_push()}"
88 | return {"response": commit}
89 |
90 |
91 | class GitCommandGroup(GPTCommandGroup):
92 | """Ask Command Group."""
93 |
94 | @staticmethod
95 | def load_command_table(loader: CLICommandsLoader) -> None:
96 | with CommandGroup(loader, "git", "gpt_review._git#{}", is_preview=True) as group:
97 | group.command("commit", "_commit", is_preview=True)
98 |
99 | @staticmethod
100 | def load_arguments(loader: CLICommandsLoader) -> None:
101 | with ArgumentsContext(loader, "git commit") as args:
102 | args.argument(
103 | "gpt4",
104 | help="Use gpt-4 for generating commit messages instead of gpt-35-turbo.",
105 | default=False,
106 | action="store_true",
107 | )
108 | args.argument(
109 | "large",
110 | help="Use gpt-4-32k model for generating commit messages.",
111 | default=False,
112 | action="store_true",
113 | )
114 | args.argument(
115 | "push",
116 | help="Push the commit to the remote.",
117 | default=False,
118 | action="store_true",
119 | )
120 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gpt-review
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | A Python based CLI and GitHub Action to use Open AI or Azure Open AI models to review contents of pull requests.
13 |
14 | ## How to install CLI
15 |
16 | First, install the package via `pip`:
17 |
18 | ```bash
19 | pip install gpt-review
20 | ```
21 |
22 | ### GPT API credentials
23 |
24 | You will need to provide an OpenAI API key to use this CLI tool. In order of precedence, it will check the following methods:
25 |
26 | 1. Presence of a context file at `azure.yaml` or wherever `CONTEXT_FILE` points to. See `azure.yaml.template` for an example.
27 |
28 | 2. `AZURE_OPENAI_API_URL` and `AZURE_OPENAI_API_KEY` to connect to an Azure OpenAI API:
29 |
30 | ```bash
31 | export AZURE_OPENAI_API=
32 | export AZURE_OPENAI_API_KEY=
33 | ```
34 |
35 | 3. `OPENAI_API_KEY` for direct use of the OpenAI API
36 |
37 | ```bash
38 | export OPENAI_API_KEY=
39 | ```
40 |
41 | 4. `AZURE_KEY_VAULT_URL` to use Azure Key Vault. Put secrets for the url at `azure-open-ai` and the API Key `azure-openai-key`, then run:
42 |
43 | ```bash
44 | export AZURE_KEY_VAULT_URL=https://.vault.azure.net/
45 | az login
46 | ```
47 |
48 | ## Main Commands
49 |
50 | To show help information about available commands and their usage, run:
51 |
52 | ```bash
53 | gpt --help
54 | ```
55 |
56 | To display the current version of this CLI tool, run:
57 |
58 | ```bash
59 | gpt --version
60 | ```
61 |
62 | Here are the main commands for using this CLI tool:
63 |
64 | ### 1. Ask a Question
65 |
66 | To submit a question to GPT and receive an answer, use the following format:
67 |
68 | ```bash
69 | gpt ask "What is the capital of France?"
70 | ```
71 |
72 | You can customize your request using various options like maximum tokens (`--max-tokens`), temperature (`--temperature`), top-p value (`--top-p`), frequency penalty (`--frequency-penalty`), presence penalty (`--presence-penalty`), etc.
73 |
74 | #### Ask a Question about a File
75 |
76 | To submit a question to GPT with a file and receive an answer, use the following format:
77 |
78 | ```bash
79 | gpt ask --files WordDocument.docx "Summarize the contents of this document."
80 | ```
81 |
82 | ### 2. Review a PR
83 |
84 | To review a PR, use the following format:
85 |
86 | ```bash
87 | gpt github review \
88 | --access-token $GITHUB_ACCESS_TOKEN \
89 | --pull-request $PULL_REQUEST_NUMBER \
90 | --repository $REPOSITORY_NAME
91 | ```
92 |
93 | ### 3. Generate a git commit message with GPT
94 |
95 | To generate a git commit message with GPT after having added the files, use the following format:
96 |
97 | ```bash
98 | git add .
99 |
100 | gpt git commit
101 | ```
102 |
103 | For more detailed information on each command and its options, run:
104 |
105 | ```bash
106 | gpt COMMAND --help
107 | ```
108 |
109 | Replace COMMAND with one of the main commands listed above (e.g., 'ask').
110 |
111 | ## Developer Setup
112 |
113 | To install the package in development mode, with additional packages for testing, run the following command:
114 |
115 | ```bash
116 | pip install -e .[test]
117 | ```
118 |
--------------------------------------------------------------------------------
/src/gpt_review/_openai.py:
--------------------------------------------------------------------------------
1 | """Open AI API Call Wrapper."""
2 | import logging
3 | import os
4 |
5 | import openai
6 | from openai.error import RateLimitError
7 |
8 | import gpt_review.constants as C
9 | from gpt_review.context import _load_azure_openai_context
10 | from gpt_review.utils import _retry_with_exponential_backoff
11 |
12 |
13 | def _count_tokens(prompt) -> int:
14 | """
15 | Determine number of tokens in prompt.
16 |
17 | Args:
18 | prompt (str): The prompt to send to GPT-4.
19 |
20 | Returns:
21 | int: The number of tokens in the prompt.
22 | """
23 | return int(len(prompt) / 4 * 3)
24 |
25 |
26 | def _get_model(prompt: str, max_tokens: int, fast: bool = False, large: bool = False) -> str:
27 | """
28 | Get the OpenAI model based on the prompt length.
29 | - when greater then 8k use gpt-4-32k
30 | - otherwise use gpt-4
31 | - enable fast to use gpt-35-turbo for small prompts
32 |
33 | Args:
34 | prompt (str): The prompt to send to GPT-4.
35 | max_tokens (int): The maximum number of tokens to generate.
36 | fast (bool, optional): Whether to use the fast model. Defaults to False.
37 | large (bool, optional): Whether to use the large model. Defaults to False.
38 |
39 | Returns:
40 | str: The model to use.
41 | """
42 | context = _load_azure_openai_context()
43 |
44 | tokens = _count_tokens(prompt)
45 | if large or tokens + max_tokens > 8000:
46 | return context.large_llm_model_deployment_id
47 | if tokens + max_tokens > 4000:
48 | return context.smart_llm_model_deployment_id
49 | return context.turbo_llm_model_deployment_id if fast else context.smart_llm_model_deployment_id
50 |
51 |
52 | def _call_gpt(
53 | prompt: str,
54 | temperature=0.10,
55 | max_tokens=500,
56 | top_p=1.0,
57 | frequency_penalty=0.5,
58 | presence_penalty=0.0,
59 | retry=0,
60 | messages=None,
61 | fast: bool = False,
62 | large: bool = False,
63 | ) -> str:
64 | """
65 | Call GPT with the given prompt.
66 |
67 | Args:
68 | prompt (str): The prompt to send to GPT-4.
69 | temperature (float, optional): The temperature to use. Defaults to 0.10.
70 | max_tokens (int, optional): The maximum number of tokens to generate. Defaults to 500.
71 | top_p (float, optional): The top_p to use. Defaults to 1.
72 | frequency_penalty (float, optional): The frequency penalty to use. Defaults to 0.5.
73 | presence_penalty (float, optional): The presence penalty to use. Defaults to 0.0.
74 | retry (int, optional): The number of times to retry the request. Defaults to 0.
75 | messages (List[Dict[str, str]], optional): The messages to send to GPT-4. Defaults to None.
76 | fast (bool, optional): Whether to use the fast model. Defaults to False.
77 | large (bool, optional): Whether to use the large model. Defaults to False.
78 |
79 | Returns:
80 | str: The response from GPT.
81 | """
82 | messages = messages or [{"role": "user", "content": prompt}]
83 | logging.debug("Prompt sent to GPT: %s\n", prompt)
84 |
85 | try:
86 | model = _get_model(prompt, max_tokens=max_tokens, fast=fast, large=large)
87 | logging.debug("Model Selected based on prompt size: %s", model)
88 |
89 | if os.environ.get("OPENAI_API_TYPE", "") == C.AZURE_API_TYPE:
90 | logging.debug("Using Azure Open AI.")
91 | completion = openai.ChatCompletion.create(
92 | deployment_id=model,
93 | messages=messages,
94 | max_tokens=max_tokens,
95 | temperature=temperature,
96 | top_p=top_p,
97 | frequency_penalty=frequency_penalty,
98 | presence_penalty=presence_penalty,
99 | )
100 | else:
101 | logging.debug("Using Open AI.")
102 | completion = openai.ChatCompletion.create(
103 | model=model,
104 | messages=messages,
105 | max_tokens=max_tokens,
106 | temperature=temperature,
107 | top_p=top_p,
108 | frequency_penalty=frequency_penalty,
109 | presence_penalty=presence_penalty,
110 | )
111 | return completion.choices[0].message.content # type: ignore
112 | except RateLimitError as error:
113 | if retry < C.MAX_RETRIES:
114 | retry_after = error.headers.get("Retry-After", C.DEFAULT_RETRY_AFTER)
115 | _retry_with_exponential_backoff(retry, retry_after)
116 |
117 | return _call_gpt(prompt, temperature, max_tokens, top_p, frequency_penalty, presence_penalty, retry + 1)
118 | raise RateLimitError("Retry limit exceeded") from error
119 |
--------------------------------------------------------------------------------
/.github/workflows/python.yml:
--------------------------------------------------------------------------------
1 | name: Python CI
2 | on:
3 | push:
4 | branches: [ main ]
5 | pull_request:
6 | branches: [ main ]
7 | release:
8 | types: [released]
9 | workflow_dispatch:
10 |
11 | env:
12 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
13 | AZURE_OPENAI_API: ${{ secrets.AZURE_OPENAI_API }}
14 | AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
15 |
16 | jobs:
17 | validation:
18 | runs-on: ubuntu-latest
19 | strategy:
20 | fail-fast: false
21 | matrix:
22 | tools: ['black', 'bandit', 'pylint', 'flake8']
23 | python: ['3.10'] # '3.11.3'
24 | steps:
25 | - uses: actions/checkout@v2
26 | with:
27 | ref: ${{ github.event.pull_request.head.sha }}
28 |
29 | - name: ${{ matrix.tools }}
30 | uses: microsoft/action-python@0.7.1
31 | with:
32 | ${{ matrix.tools }}: true
33 | workdir: '.'
34 | testdir: 'tests'
35 | python_version: ${{ matrix.python }}
36 |
37 | tests:
38 | runs-on: ubuntu-latest
39 | env:
40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41 | ADO_TOKEN: ${{ secrets.ADO_TOKEN }}
42 | strategy:
43 | fail-fast: true
44 | matrix:
45 | python: ['3.8', '3.9', '3.10', '3.11']
46 | args: ['-m unit']
47 | flags: ['unittests']
48 | include:
49 | - python: '3.10'
50 | args: -m integration
51 | flags: integration
52 | - python: '3.11'
53 | args: -m integration
54 | flags: integration
55 | steps:
56 | - uses: actions/checkout@v2
57 | with:
58 | ref: ${{ github.event.pull_request.head.sha }}
59 | - uses: Azure/login@v1.4.6
60 | if: ${{ matrix.flags == 'integration' }}
61 | with:
62 | creds: ${{ secrets.AZURE_CREDENTIALS }}
63 | - name: ${{ matrix.tools }}
64 | uses: microsoft/action-python@0.7.1
65 | with:
66 | pytest: true
67 | args: ${{ matrix.args }}
68 | workdir: '.'
69 | testdir: 'tests'
70 | python_version: ${{ matrix.python }}
71 | flags: ${{ matrix.flags }}-${{ matrix.python }}
72 |
73 | publish:
74 | runs-on: ubuntu-latest
75 | steps:
76 | - uses: actions/checkout@v2
77 | if: ${{ github.event_name == 'release' }}
78 | with:
79 | ref: ${{ github.event.pull_request.head.sha }}
80 | token: ${{ secrets.PAT }}
81 |
82 | - uses: actions/checkout@v2
83 | if: ${{ github.event_name != 'release' }}
84 | with:
85 | ref: ${{ github.event.pull_request.head.sha }}
86 |
87 | - uses: actions/setup-python@v4
88 | with:
89 | python-version: '3.11.3'
90 | cache: 'pip' # caching pip dependencies
91 |
92 | - name: Bump version and push tag
93 | id: tag_version
94 | uses: mathieudutour/github-tag-action@v6.1
95 | with:
96 | github_token: ${{ secrets.GITHUB_TOKEN }}
97 | dry_run: true
98 | tag_prefix: 'v'
99 | default_bump: false
100 |
101 | - name: Set Configurations
102 | if: steps.tag_version.outputs.release_type && github.event_name != 'release'
103 | shell: bash
104 | env:
105 | VERSION: ${{ steps.tag_version.outputs.new_version }}
106 | run: sed -ri 's/(__version__ = ")([0-9]+\.[0-9]+\.[0-9]+?.*)(")/\1'"$VERSION"'\3/' "src/gpt_review/__init__.py" || exit 1
107 |
108 | - name: Set Configurations
109 | if: steps.tag_version.outputs.release_type && github.event_name == 'release'
110 | shell: bash
111 | env:
112 | VERSION: ${{ steps.tag_version.outputs.previous_version }}
113 | run: sed -ri 's/(__version__ = ")([0-9]+\.[0-9]+\.[0-9]+?.*)(")/\1'"$VERSION"'\3/' "src/gpt_review/__init__.py" || exit 1
114 |
115 | - name: Publish Snapshot to TestPyPi
116 | uses: microsoft/action-python@0.7.1
117 | continue-on-error: true
118 | if: ${{ github.event_name == 'pull_request_target' }}
119 | with:
120 | pypi_publish: true
121 | pypi_password: ${{ secrets.TEST_PYPI_PASSWORD }}
122 | pypi_repo: testpypi
123 | version_suffix: -post${{ github.run_number }}-dev${{ github.run_attempt }}
124 | workdir: '.'
125 | python_version: '3.11.3'
126 |
127 | - name: Publish RC to PyPi
128 | uses: microsoft/action-python@0.7.1
129 | if: ${{ github.event_name == 'push' }}
130 | with:
131 | pypi_publish: true
132 | pypi_password: ${{ secrets.PYPI_PASSWORD }}
133 | version_suffix: -rc${{ github.run_number }}-post${{ github.run_attempt }}
134 | workdir: '.'
135 | python_version: '3.11.3'
136 |
137 | - name: Publish Release to PyPi
138 | uses: microsoft/action-python@0.7.1
139 | if: ${{ github.event_name == 'release' }}
140 | with:
141 | pypi_publish: true
142 | pypi_password: ${{ secrets.PYPI_PASSWORD }}
143 | workdir: '.'
144 | python_version: '3.11.3'
145 |
--------------------------------------------------------------------------------
/tests/test_gpt_cli.py:
--------------------------------------------------------------------------------
1 | """Pytest for gpt_review/main.py"""
2 | import os
3 | import subprocess
4 | import sys
5 | from dataclasses import dataclass
6 |
7 | import pytest
8 |
9 | import gpt_review.constants as C
10 | from gpt_review._gpt_cli import cli
11 |
12 |
13 | @dataclass
14 | class CLICase:
15 | command: str
16 | expected_error_message: str = ""
17 | expected_error_code: int = 0
18 |
19 |
20 | @dataclass
21 | class CLICase1(CLICase):
22 | expected_error_code: int = 1
23 |
24 |
25 | @dataclass
26 | class CLICase2(CLICase):
27 | expected_error_code: int = 2
28 |
29 |
30 | SAMPLE_FILE = "src/gpt_review/__init__.py"
31 | QUESTION = "how are you"
32 | WHAT_LANGUAGE = "'what programming language is this code written in?'"
33 | HELP_TEXT = """usage: gpt ask [-h] [--verbose] [--debug] [--only-show-errors]
34 | [--output {json,jsonc,yaml,yamlc,table,tsv,none}]
35 | [--query JMESPATH] [--max-tokens MAX_TOKENS]
36 | [--temperature TEMPERATURE] [--top-p TOP_P]
37 | [--frequency-penalty FREQUENCY_PENALTY]
38 | [--presence-penalty PRESENCE_PENALTY]
39 | [ ...]
40 | """
41 |
42 | ROOT_COMMANDS = [
43 | CLICase("--version"),
44 | CLICase("--help"),
45 | ]
46 |
47 | ASK_COMMANDS = [
48 | CLICase("ask --help"),
49 | CLICase(f"ask {QUESTION}"),
50 | CLICase(f"ask --fast {QUESTION}"),
51 | CLICase(
52 | f"ask {QUESTION} --fast --max-tokens {C.MAX_TOKENS_DEFAULT} --temperature {C.TEMPERATURE_DEFAULT} --top-p {C.TOP_P_DEFAULT} --frequency-penalty {C.FREQUENCY_PENALTY_DEFAULT} --presence-penalty {C.FREQUENCY_PENALTY_MAX}"
53 | ),
54 | CLICase1(
55 | f"ask {QUESTION} --fast --max-tokens {C.MAX_TOKENS_MIN-1}",
56 | f"ERROR: --max-tokens must be a(n) int between {C.MAX_TOKENS_MIN} and {C.MAX_TOKENS_MAX}\n",
57 | ),
58 | CLICase1(
59 | f"ask {QUESTION} --temperature {C.TEMPERATURE_MAX+8}",
60 | f"ERROR: --temperature must be a(n) float between {C.TEMPERATURE_MIN} and {C.TEMPERATURE_MAX}\n",
61 | ),
62 | CLICase1(
63 | f"ask {QUESTION} --top-p {C.TOP_P_MAX+3.5}",
64 | f"ERROR: --top-p must be a(n) float between {C.TOP_P_MIN} and {C.TOP_P_MAX}\n",
65 | ),
66 | CLICase1(
67 | f"ask {QUESTION} --frequency-penalty {C.FREQUENCY_PENALTY_MAX+2}",
68 | f"ERROR: --frequency-penalty must be a(n) float between {C.FREQUENCY_PENALTY_MIN} and {C.FREQUENCY_PENALTY_MAX}\n",
69 | ),
70 | CLICase1(
71 | f"ask {QUESTION} --presence-penalty {C.PRESENCE_PENALTY_MAX+7.7}",
72 | f"ERROR: --presence-penalty must be a(n) float between {C.PRESENCE_PENALTY_MIN} and {C.PRESENCE_PENALTY_MAX}\n",
73 | ),
74 | CLICase2(
75 | f"ask {QUESTION} --fast --max-tokens",
76 | f"""{HELP_TEXT}
77 | gpt ask: error: argument --max-tokens: expected one argument
78 | """,
79 | ),
80 | CLICase2(
81 | f"ask {QUESTION} --fast --max-tokens 'test'",
82 | f"""{HELP_TEXT}
83 | gpt ask: error: argument --max-tokens: invalid int value: \"'test'\"
84 | """,
85 | ),
86 | CLICase(f"ask --files {SAMPLE_FILE} --files {SAMPLE_FILE} {WHAT_LANGUAGE} --reset"),
87 | CLICase(f"ask --fast -f {SAMPLE_FILE} {WHAT_LANGUAGE}"),
88 | CLICase(f"ask --fast -d src/gpt_review --reset --recursive --hidden --required-exts .py {WHAT_LANGUAGE}"),
89 | CLICase(f"ask --fast -repo microsoft/gpt-review --branch main {WHAT_LANGUAGE}"),
90 | ]
91 |
92 | GITHUB_COMMANDS = [
93 | CLICase("github review --help"),
94 | CLICase("github review"),
95 | ]
96 |
97 | GIT_COMMANDS = [
98 | CLICase("git commit --help"),
99 | # CLICase("git commit"),
100 | # CLICase("git commit --large"),
101 | # CLICase("git commit --gpt4"),
102 | # CLICase("git commit --push"),
103 | ]
104 |
105 | REVIEW_COMMANDS = [
106 | CLICase("review --help"),
107 | CLICase("review diff --help"),
108 | CLICase("review diff --diff tests/mock.diff --config tests/config.summary.test.yml"),
109 | CLICase("review diff --diff tests/mock.diff --config tests/config.summary.extra.yml"),
110 | ]
111 |
112 | ARGS = ROOT_COMMANDS + ASK_COMMANDS + GIT_COMMANDS + GITHUB_COMMANDS + REVIEW_COMMANDS
113 | ARGS_DICT = {arg.command: arg for arg in ARGS}
114 |
115 | MODULE_COMMANDS = [
116 | CLICase("python -m gpt --version"),
117 | CLICase("python -m gpt_review --version"),
118 | ]
119 | MODULE_DICT = {arg.command: arg for arg in MODULE_COMMANDS}
120 |
121 |
122 | def gpt_cli_test(command: CLICase) -> None:
123 | os.environ["GPT_ASK_COMMANDS"] = "1"
124 |
125 | sys.argv[1:] = command.command.split(" ")
126 | exit_code = -1
127 | try:
128 | exit_code = cli()
129 | except SystemExit as e:
130 | exit_code = e.code
131 | finally:
132 | assert exit_code == command.expected_error_code
133 |
134 |
135 | def cli_test(command, command_array) -> None:
136 | result = subprocess.run(
137 | command_array,
138 | stdout=subprocess.PIPE,
139 | stderr=subprocess.PIPE,
140 | check=False,
141 | )
142 |
143 | assert result.returncode == command.expected_error_code
144 |
145 |
146 | @pytest.mark.parametrize("command", ARGS_DICT.keys())
147 | @pytest.mark.cli
148 | def test_cli_gpt_cli(command: str) -> None:
149 | """Test gpt commands from installed CLI"""
150 | command_array = f"gpt {ARGS_DICT[command].command}".split(" ")
151 |
152 | cli_test(ARGS_DICT[command], command_array)
153 |
154 |
155 | @pytest.mark.parametrize("command", MODULE_DICT.keys())
156 | @pytest.mark.cli
157 | def test_cli_gpt_module(command: str) -> None:
158 | """Test running cli as module"""
159 | command_array = MODULE_DICT[command].command.split(" ")
160 |
161 | cli_test(MODULE_DICT[command], command_array)
162 |
163 |
164 | @pytest.mark.parametrize("command", ARGS_DICT.keys())
165 | def test_gpt_cli(command: str, mock_openai: None) -> None:
166 | gpt_cli_test(ARGS_DICT[command])
167 |
168 |
169 | @pytest.mark.parametrize("command", ARGS_DICT.keys())
170 | @pytest.mark.integration
171 | def test_int_gpt_cli(command: str) -> None:
172 | """Test gpt commands from CLI file"""
173 | gpt_cli_test(ARGS_DICT[command])
174 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from collections import namedtuple
2 |
3 | import pytest
4 | import yaml
5 | from llama_index import SimpleDirectoryReader
6 |
7 |
8 | def pytest_collection_modifyitems(items):
9 | for item in items:
10 | if "_int_" in item.nodeid:
11 | item.add_marker(pytest.mark.integration)
12 | elif "_cli_" in item.nodeid:
13 | item.add_marker(pytest.mark.cli)
14 | else:
15 | item.add_marker(pytest.mark.unit)
16 |
17 |
18 | @pytest.fixture
19 | def mock_openai(monkeypatch) -> None:
20 | """
21 | Mock OpenAI Functions with monkeypatch
22 | - aopenai.ChatCompletion.create
23 | """
24 | monkeypatch.setenv("OPENAI_API_KEY", "MOCK")
25 | monkeypatch.setenv("AZURE_OPENAI_API", "MOCK")
26 | monkeypatch.setenv("AZURE_OPENAI_API_KEY", "MOCK")
27 | monkeypatch.setenv("FILE_SUMMARY_FULL", "true")
28 |
29 | class MockResponse:
30 | def __init__(self) -> None:
31 | self.choices = [namedtuple("mockMessage", "message")(*[namedtuple("mockContent", "content")(*[["test"]])])]
32 |
33 | class MockQueryResponse:
34 | def __init__(self) -> None:
35 | self.response = "test"
36 |
37 | class MockStorageContext:
38 | def persist(self, persist_dir) -> None:
39 | pass
40 |
41 | class MockIndex:
42 | def __init__(self) -> None:
43 | self.storage_context = MockStorageContext()
44 |
45 | def query(self, question: str) -> MockQueryResponse:
46 | assert isinstance(question, str)
47 | return MockQueryResponse()
48 |
49 | def as_query_engine(self):
50 | return self
51 |
52 | def mock_create(
53 | model=None,
54 | deployment_id=None,
55 | messages=None,
56 | temperature=0,
57 | max_tokens=500,
58 | top_p=1,
59 | frequency_penalty=0,
60 | presence_penalty=0,
61 | ) -> MockResponse:
62 | return MockResponse()
63 |
64 | def from_documents(documents, service_context=None) -> MockIndex:
65 | return MockIndex()
66 |
67 | def init_mock_reader(self, owner, repo, use_parser) -> None:
68 | pass
69 |
70 | def mock_load_data_from_branch(self, branch):
71 | return SimpleDirectoryReader(input_dir=".").load_data()
72 |
73 | monkeypatch.setattr("openai.ChatCompletion.create", mock_create)
74 | monkeypatch.setattr("llama_index.GPTVectorStoreIndex.from_documents", from_documents)
75 | monkeypatch.setattr("llama_index.GithubRepositoryReader.__init__", init_mock_reader)
76 | monkeypatch.setattr("llama_index.GithubRepositoryReader._load_data_from_branch", mock_load_data_from_branch)
77 |
78 | def mock_query(self, question) -> MockQueryResponse:
79 | return MockQueryResponse()
80 |
81 | monkeypatch.setattr("llama_index.indices.query.base.BaseQueryEngine.query", mock_query)
82 |
83 |
84 | @pytest.fixture
85 | def mock_github(monkeypatch) -> None:
86 | """
87 | Mock GitHub Functions with monkeypatch
88 | - requests.get
89 | """
90 | monkeypatch.setenv("LINK", "https://github.com/microsoft/gpt-review/pull/1")
91 | monkeypatch.setenv("GIT_COMMIT_HASH", "a9da0c1e65f1102bc2ae16abed7b6a66400a5bde")
92 |
93 | class MockResponse:
94 | def __init__(self) -> None:
95 | self.text = "diff --git a/README.md b/README.md"
96 |
97 | def json(self) -> dict:
98 | return {"test": "test"}
99 |
100 | def mock_get(url, headers, timeout) -> MockResponse:
101 | return MockResponse()
102 |
103 | def mock_put(url, headers, data, timeout) -> MockResponse:
104 | return MockResponse()
105 |
106 | def mock_post(url, headers, data, timeout) -> MockResponse:
107 | return MockResponse()
108 |
109 | monkeypatch.setattr("requests.get", mock_get)
110 | monkeypatch.setattr("requests.put", mock_put)
111 | monkeypatch.setattr("requests.post", mock_post)
112 |
113 |
114 | @pytest.fixture
115 | def mock_github_comment(monkeypatch) -> None:
116 | class MockCommentResponse:
117 | def json(self) -> list:
118 | return [
119 | {
120 | "user": {"login": "github-actions[bot]"},
121 | "body": "Summary by GPT-4",
122 | "id": 1,
123 | }
124 | ]
125 |
126 | def mock_get(url, headers, timeout) -> MockCommentResponse:
127 | return MockCommentResponse()
128 |
129 | monkeypatch.setattr("requests.get", mock_get)
130 |
131 |
132 | @pytest.fixture
133 | def mock_git_commit(monkeypatch) -> None:
134 | """Mock git.commit with pytest monkey patch"""
135 |
136 | class MockGit:
137 | def __init__(self) -> None:
138 | self.git = self
139 |
140 | def commit(self, message, push: bool = False) -> str:
141 | return "test commit response"
142 |
143 | def diff(self, message, cached) -> str:
144 | return "test diff response"
145 |
146 | def push(self) -> str:
147 | return "test push response"
148 |
149 | def mock_init(cls) -> MockGit:
150 | return MockGit()
151 |
152 | monkeypatch.setattr("git.repo.Repo.init", mock_init)
153 |
154 |
155 | @pytest.fixture
156 | def report_config():
157 | """Load sample.report.yaml file"""
158 | return load_report_config("config.summary.template.yml")
159 |
160 |
161 | def load_report_config(file_name):
162 | with open(file_name, "r") as yaml_file:
163 | config = yaml.safe_load(yaml_file)
164 | return config["report"]
165 |
166 |
167 | @pytest.fixture
168 | def config_yaml():
169 | return "tests/config.summary.test.yml"
170 |
171 |
172 | @pytest.fixture
173 | def git_diff() -> str:
174 | """Load test.diff file"""
175 | with open("tests/mock.diff", "r") as diff_file:
176 | diff = diff_file.read()
177 | return diff
178 |
179 |
180 | @pytest.fixture
181 | def empty_summary(monkeypatch) -> None:
182 | """Test empty summary."""
183 | monkeypatch.setenv("FILE_SUMMARY", "false")
184 | monkeypatch.setenv("TEST_SUMMARY", "false")
185 | monkeypatch.setenv("BUG_SUMMARY", "false")
186 | monkeypatch.setenv("RISK_SUMMARY", "false")
187 | monkeypatch.setenv("FULL_SUMMARY", "false")
188 |
189 |
190 | @pytest.fixture
191 | def file_summary(monkeypatch) -> None:
192 | """Test empty summary."""
193 | monkeypatch.setenv("FILE_SUMMARY_FULL", "false")
194 |
--------------------------------------------------------------------------------
/Directory.Packages.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | runtime; build; native; contentfiles; analyzers; buildtransitive
87 | all
88 |
89 |
90 | runtime; build; native; contentfiles; analyzers; buildtransitive
91 | all
92 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/src/gpt_review/repositories/github.py:
--------------------------------------------------------------------------------
1 | """GitHub API helpers."""
2 | import json
3 | import logging
4 | import os
5 | from typing import Dict
6 |
7 | import requests
8 | from knack import CLICommandsLoader
9 | from knack.arguments import ArgumentsContext
10 | from knack.commands import CommandGroup
11 |
12 | from gpt_review._command import GPTCommandGroup
13 | from gpt_review._review import _summarize_files
14 | from gpt_review.repositories._repository import _RepositoryClient
15 |
16 |
17 | class GitHubClient(_RepositoryClient):
18 | """GitHub client."""
19 |
20 | @staticmethod
21 | def get_pr_diff(patch_repo=None, patch_pr=None, access_token=None) -> str:
22 | """
23 | Get the diff of a PR.
24 |
25 | Args:
26 | patch_repo (str): The repo.
27 | patch_pr (str): The PR.
28 | access_token (str): The GitHub access token.
29 |
30 | Returns:
31 | str: The diff of the PR.
32 | """
33 | patch_repo = patch_repo or os.getenv("PATCH_REPO")
34 | patch_pr = patch_pr or os.getenv("PATCH_PR")
35 | access_token = access_token or os.getenv("GITHUB_TOKEN")
36 |
37 | headers = {
38 | "Accept": "application/vnd.github.v3.diff",
39 | "authorization": f"Bearer {access_token}",
40 | }
41 |
42 | response = requests.get(
43 | f"https://api.github.com/repos/{patch_repo}/pulls/{patch_pr}", headers=headers, timeout=10
44 | )
45 | return response.text
46 |
47 | @staticmethod
48 | def _post_pr_comment(review, git_commit_hash: str, link: str, access_token: str) -> requests.Response:
49 | """
50 | Post a comment to a PR.
51 |
52 | Args:
53 | review (str): The review.
54 | git_commit_hash (str): The git commit hash.
55 | link (str): The link to the PR.
56 | access_token (str): The GitHub access token.
57 |
58 | Returns:
59 | requests.Response: The response.
60 | """
61 | data = {"body": review, "commit_id": git_commit_hash, "event": "COMMENT"}
62 | data = json.dumps(data)
63 |
64 | owner = link.split("/")[-4]
65 | repo = link.split("/")[-3]
66 | pr_number = link.split("/")[-1]
67 |
68 | headers = {
69 | "Accept": "application/vnd.github+json",
70 | "authorization": f"Bearer {access_token}",
71 | }
72 | response = requests.get(
73 | f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/reviews", headers=headers, timeout=10
74 | )
75 | comments = response.json()
76 |
77 | for comment in comments:
78 | if (
79 | "user" in comment
80 | and comment["user"]["login"] == "github-actions[bot]"
81 | and "body" in comment
82 | and "Summary by GPT-4" in comment["body"]
83 | ):
84 | review_id = comment["id"]
85 | data = {"body": review}
86 | data = json.dumps(data)
87 |
88 | response = requests.put(
89 | f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}",
90 | headers=headers,
91 | data=data,
92 | timeout=10,
93 | )
94 | break
95 | else:
96 | # https://api.github.com/repos/OWNER/REPO/pulls/PULL_NUMBER/reviews
97 | response = requests.post(
98 | f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/reviews",
99 | headers=headers,
100 | data=data,
101 | timeout=10,
102 | )
103 | logging.debug(response.json())
104 | return response
105 |
106 | @staticmethod
107 | def post_pr_summary(diff) -> Dict[str, str]:
108 | """
109 | Get a review of a PR.
110 |
111 | Requires the following environment variables:
112 | - LINK: The link to the PR.
113 | - GIT_COMMIT_HASH: The git commit hash.
114 | - GITHUB_TOKEN: The GitHub access token.
115 |
116 | Args:
117 | diff (str): The patch of the PR.
118 |
119 | Returns:
120 | Dict[str, str]: The review.
121 | """
122 | review = _summarize_files(diff)
123 | logging.debug(review)
124 |
125 | link = os.getenv("LINK")
126 | git_commit_hash = os.getenv("GIT_COMMIT_HASH")
127 | access_token = os.getenv("GITHUB_TOKEN")
128 |
129 | if link and git_commit_hash and access_token:
130 | GitHubClient._post_pr_comment(
131 | review=review, git_commit_hash=git_commit_hash, link=link, access_token=access_token
132 | )
133 | return {"response": "PR posted"}
134 |
135 | logging.warning("No PR to post too")
136 | return {"response": "No PR to post too"}
137 |
138 |
139 | def _review(repository=None, pull_request=None, access_token=None) -> Dict[str, str]:
140 | """Review GitHub PR with Open AI, and post response as a comment.
141 |
142 | Args:
143 | repository (str): The repo of the PR.
144 | pull_request (str): The PR number.
145 | access_token (str): The GitHub access token.
146 |
147 | Returns:
148 | Dict[str, str]: The response.
149 | """
150 | diff = GitHubClient.get_pr_diff(repository, pull_request, access_token)
151 | GitHubClient.post_pr_summary(diff)
152 | return {"response": "Review posted as a comment."}
153 |
154 |
155 | def _comment(question: str, comment_id: int, diff: str = ".diff", link=None, access_token=None) -> Dict[str, str]:
156 | """"""
157 | raise NotImplementedError
158 |
159 |
160 | class GitHubCommandGroup(GPTCommandGroup):
161 | """Ask Command Group."""
162 |
163 | @staticmethod
164 | def load_command_table(loader: CLICommandsLoader) -> None:
165 | with CommandGroup(loader, "github", "gpt_review.repositories.github#{}", is_preview=True) as group:
166 | group.command("review", "_review", is_preview=True)
167 |
168 | @staticmethod
169 | def load_arguments(loader: CLICommandsLoader) -> None:
170 | """Add patch_repo, patch_pr, and access_token arguments."""
171 | with ArgumentsContext(loader, "github") as args:
172 | args.argument(
173 | "access_token",
174 | type=str,
175 | help="The GitHub access token. Set or use GITHUB_TOKEN environment variable.",
176 | default=None,
177 | )
178 | args.argument(
179 | "pull_request",
180 | type=str,
181 | help="The PR number. Set or use PATCH_PR environment variable.",
182 | default=None,
183 | options_list=("--pull-request", "-pr"),
184 | )
185 | args.argument(
186 | "repository",
187 | type=str,
188 | help="The repo of the PR. Set or use PATCH_REPO environment variable.",
189 | default=None,
190 | options_list=("--repository", "-r"),
191 | )
192 |
--------------------------------------------------------------------------------
/src/gpt_review/_llama_index.py:
--------------------------------------------------------------------------------
1 | """Wrapper for Llama Index."""
2 | import logging
3 | import os
4 | from typing import List, Optional
5 |
6 | import openai
7 | from langchain.chat_models import AzureChatOpenAI, ChatOpenAI
8 | from langchain.embeddings import OpenAIEmbeddings
9 | from langchain.llms import AzureOpenAI
10 | from llama_index import (
11 | Document,
12 | GithubRepositoryReader,
13 | GPTVectorStoreIndex,
14 | LangchainEmbedding,
15 | LLMPredictor,
16 | ServiceContext,
17 | SimpleDirectoryReader,
18 | StorageContext,
19 | load_index_from_storage,
20 | )
21 | from llama_index.indices.base import BaseGPTIndex
22 | from llama_index.storage.storage_context import DEFAULT_PERSIST_DIR
23 | from typing_extensions import override
24 |
25 | import gpt_review.constants as C
26 | from gpt_review.context import _load_azure_openai_context
27 |
28 | logger = logging.getLogger(__name__)
29 |
30 |
31 | def _query_index(
32 | question: str,
33 | files: Optional[List[str]] = None,
34 | input_dir: Optional[str] = None,
35 | exclude_hidden: bool = True,
36 | recursive: bool = True,
37 | required_exts: Optional[List[str]] = None,
38 | repository: Optional[str] = None,
39 | branch: str = "main",
40 | fast: bool = False,
41 | large: bool = False,
42 | reset: bool = False,
43 | ) -> str:
44 | """
45 | Query a Vector Index with GPT.
46 | Args:
47 | question (List[str]): The question to ask.
48 | files (List[str], optional): The files to search.
49 | (Optional; overrides input_dir, exclude)
50 | input_dir (str, optional): Path to the directory.
51 | exclude_hidden (bool): Whether to exclude hidden files.
52 | recursive (bool): Whether to search directory recursively.
53 | required_exts (List, optional): The required extensions for files in directory.
54 | repository (str): The repository to search. Format: owner/repo
55 | fast (bool, optional): Whether to use the fast model. Defaults to False.
56 | large (bool, optional): Whether to use the large model. Defaults to False.
57 | reset (bool, optional): Whether to reset the index. Defaults to False.
58 |
59 | Returns:
60 | Dict[str, str]: The response.
61 | """
62 | documents = []
63 | if files:
64 | documents += SimpleDirectoryReader(input_files=files).load_data()
65 | elif input_dir:
66 | documents += SimpleDirectoryReader(
67 | input_dir=input_dir, exclude_hidden=exclude_hidden, recursive=recursive, required_exts=required_exts
68 | ).load_data()
69 | if repository:
70 | owner, repo = repository.split("/")
71 | documents += GithubRepositoryReader(owner=owner, repo=repo, use_parser=False).load_data(branch=branch)
72 |
73 | index = _load_index(documents, fast=fast, large=large, reset=reset)
74 |
75 | return index.as_query_engine().query(question).response # type: ignore
76 |
77 |
78 | def _load_index(
79 | documents: List[Document],
80 | fast: bool = True,
81 | large: bool = True,
82 | reset: bool = False,
83 | persist_dir: str = DEFAULT_PERSIST_DIR,
84 | ) -> BaseGPTIndex:
85 | """
86 | Load or create a document indexer.
87 |
88 | Args:
89 | documents (List[Document]): The documents to index.
90 | fast (bool, optional): Whether to use the fast model. Defaults to False.
91 | large (bool, optional): Whether to use the large model. Defaults to False.
92 | reset (bool, optional): Whether to reset the index. Defaults to False.
93 | persist_dir (str, optional): The directory to persist the index to. Defaults to './storage'.
94 |
95 | Returns:
96 | BaseGPTIndex: The document indexer.
97 | """
98 | service_context = _load_service_context(fast, large)
99 |
100 | if os.path.isdir(f"{persist_dir}") and not reset:
101 | logger.info("Loading index from storage")
102 | storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
103 | return load_index_from_storage(service_context=service_context, storage_context=storage_context)
104 |
105 | logger.info("Creating index")
106 | index = GPTVectorStoreIndex.from_documents(documents, service_context=service_context)
107 |
108 | logger.info("Saving index to storage")
109 | index.storage_context.persist(persist_dir=persist_dir)
110 |
111 | return index
112 |
113 |
114 | def _load_service_context(fast: bool = False, large: bool = False) -> ServiceContext:
115 | """
116 | Load the service context.
117 |
118 | Args:
119 | fast (bool, optional): Whether to use the fast model. Defaults to False.
120 | large (bool, optional): Whether to use the large model. Defaults to False.
121 |
122 | Returns:
123 | ServiceContext: The service context.
124 | """
125 |
126 | context = _load_azure_openai_context()
127 | model_name = (
128 | context.turbo_llm_model_deployment_id
129 | if fast
130 | else context.large_llm_model_deployment_id
131 | if large
132 | else context.smart_llm_model_deployment_id
133 | )
134 |
135 | if openai.api_type == C.AZURE_API_TYPE:
136 | llm_type = AzureGPT35Turbo if fast else AzureChatOpenAI
137 | llm = llm_type( # type: ignore
138 | deployment_name=model_name,
139 | model_kwargs={
140 | "api_key": openai.api_key,
141 | "api_base": openai.api_base,
142 | "api_type": openai.api_type,
143 | "api_version": openai.api_version,
144 | },
145 | max_retries=C.MAX_RETRIES,
146 | )
147 | else:
148 | llm = ChatOpenAI(
149 | model_name=model_name,
150 | model_kwargs={
151 | "api_key": openai.api_key,
152 | "api_base": openai.api_base,
153 | "api_type": openai.api_type,
154 | "api_version": openai.api_version,
155 | },
156 | max_retries=C.MAX_RETRIES,
157 | )
158 |
159 | llm_predictor = LLMPredictor(llm=llm)
160 |
161 | embedding_llm = LangchainEmbedding(
162 | OpenAIEmbeddings(
163 | model="text-embedding-ada-002",
164 | ), # type: ignore
165 | embed_batch_size=1,
166 | )
167 |
168 | return ServiceContext.from_defaults(
169 | llm_predictor=llm_predictor,
170 | embed_model=embedding_llm,
171 | )
172 |
173 |
174 | class AzureGPT35Turbo(AzureOpenAI):
175 | """Azure OpenAI Chat API."""
176 |
177 | @property
178 | @override
179 | def _default_params(self):
180 | """
181 | Get the default parameters for calling OpenAI API.
182 | gpt-35-turbo does not support best_of, logprobs, or echo.
183 | """
184 | normal_params = {
185 | "temperature": self.temperature,
186 | "max_tokens": self.max_tokens,
187 | "top_p": self.top_p,
188 | "frequency_penalty": self.frequency_penalty,
189 | "presence_penalty": self.presence_penalty,
190 | "n": self.n,
191 | "request_timeout": self.request_timeout,
192 | "logit_bias": self.logit_bias,
193 | }
194 | return {**normal_params, **self.model_kwargs}
195 |
--------------------------------------------------------------------------------
/src/gpt_review/_review.py:
--------------------------------------------------------------------------------
1 | """Basic functions for requesting review based goals from GPT-4."""
2 | import os
3 | from dataclasses import dataclass
4 | from typing import Dict
5 |
6 | import yaml
7 | from knack import CLICommandsLoader
8 | from knack.arguments import ArgumentsContext
9 | from knack.commands import CommandGroup
10 |
11 | from gpt_review._ask import _ask
12 | from gpt_review._command import GPTCommandGroup
13 | from gpt_review.prompts._prompt import (
14 | load_bug_yaml,
15 | load_coverage_yaml,
16 | load_summary_yaml,
17 | )
18 |
19 | _CHECKS = {
20 | "SUMMARY_CHECKS": [
21 | {
22 | "flag": "SUMMARY_SUGGEST",
23 | "header": "Suggestions",
24 | "goal": """
25 | Provide suggestions for improving the changes in this PR.
26 | If the PR has no clear issues, mention that no suggestions are needed.
27 | """,
28 | },
29 | ],
30 | "RISK_CHECKS": [
31 | {
32 | "flag": "RISK_BREAKING",
33 | "header": "Breaking Changes",
34 | "goal": """Detect breaking changes in a git diff. Here are some things that can cause a breaking change.
35 | - new parameters to public functions which are required and have no default value.
36 | """,
37 | },
38 | ],
39 | }
40 |
41 |
42 | @dataclass
43 | class GitFile:
44 | """A git file with its diff contents."""
45 |
46 | file_name: str
47 | diff: str
48 |
49 |
50 | def _request_goal(git_diff, goal, fast: bool = False, large: bool = False, temperature: float = 0) -> str:
51 | """
52 | Request a goal from GPT-4.
53 |
54 | Args:
55 | git_diff (str): The git diff to split.
56 | goal (str): The goal to request from GPT-4.
57 | fast (bool, optional): Whether to use the fast model. Defaults to False.
58 | large (bool, optional): Whether to use the large model. Defaults to False.
59 | temperature (float, optional): The temperature to use. Defaults to 0.
60 |
61 | Returns:
62 | response (str): The response from GPT-4.
63 | """
64 | prompt = f"""
65 | {goal}
66 |
67 | {git_diff}
68 | """
69 |
70 | return _ask([prompt], max_tokens=1500, fast=fast, large=large, temperature=temperature)["response"]
71 |
72 |
73 | def _check_goals(git_diff, checks, indent="###") -> str:
74 | """
75 | Check goals.
76 |
77 | Args:
78 | git_diff (str): The git diff to check.
79 | checks (list): The checks to run.
80 |
81 | Returns:
82 | str: The output of the checks.
83 | """
84 | return "".join(
85 | f"""
86 | {indent} {check["header"]}
87 |
88 | {_request_goal(git_diff, goal=check["goal"])}
89 | """
90 | for check in checks
91 | if os.getenv(check["flag"], "true").lower() == "true"
92 | )
93 |
94 |
95 | def _summarize_pr(git_diff) -> str:
96 | """
97 | Summarize a PR.
98 |
99 | Args:
100 | git_diff (str): The git diff to summarize.
101 |
102 | Returns:
103 | str: The summary of the PR.
104 | """
105 | text = ""
106 | if os.getenv("FULL_SUMMARY", "true").lower() == "true":
107 | text += f"""
108 | {_request_goal(git_diff, goal="")}
109 | """
110 |
111 | text += _check_goals(git_diff, _CHECKS["SUMMARY_CHECKS"])
112 | return text
113 |
114 |
115 | def _summarize_file(diff) -> str:
116 | """Summarize a file in a git diff.
117 |
118 | Args:
119 | diff (str): The file to summarize.
120 |
121 | Returns:
122 | str: The summary of the file.
123 | """
124 | git_file = GitFile(diff.split(" b/")[0], diff)
125 | question = load_summary_yaml().format(diff=diff)
126 |
127 | response = _ask(question=[question], temperature=0.0)
128 | return f"""
129 | ### {git_file.file_name}
130 | {response}
131 | """
132 |
133 |
134 | def _split_diff(git_diff):
135 | """Split a git diff into a list of files and their diff contents.
136 |
137 | Args:
138 | git_diff (str): The git diff to split.
139 |
140 | Returns:
141 | list: A list of tuples containing the file name and diff contents.
142 | """
143 | diff = "diff"
144 | git = "--git a/"
145 | return git_diff.split(f"{diff} {git}")[1:] # Use formated string to prevent splitting
146 |
147 |
148 | def _summarize_test_coverage(git_diff) -> str:
149 | """Summarize the test coverage of a git diff.
150 |
151 | Args:
152 | git_diff (str): The git diff to summarize.
153 |
154 | Returns:
155 | str: The summary of the test coverage.
156 | """
157 | files = {}
158 | for diff in _split_diff(git_diff):
159 | path = diff.split(" b/")[0]
160 | git_file = GitFile(path.split("/")[len(path.split("/")) - 1], diff)
161 |
162 | files[git_file.file_name] = git_file
163 |
164 | question = load_coverage_yaml().format(diff=git_diff)
165 |
166 | return _ask([question], temperature=0.0, max_tokens=1500)["response"]
167 |
168 |
169 | def _summarize_risk(git_diff) -> str:
170 | """
171 | Summarize potential risks.
172 |
173 | Args:
174 | git_diff (str): The git diff to split.
175 |
176 | Returns:
177 | response (str): The response from GPT-4.
178 | """
179 | text = ""
180 | if os.getenv("RISK_SUMMARY", "true").lower() == "true":
181 | text += """
182 | ## Potential Risks
183 |
184 | """
185 | text += _check_goals(git_diff, _CHECKS["RISK_CHECKS"])
186 | return text
187 |
188 |
189 | def _summarize_files(git_diff) -> str:
190 | """Summarize git files."""
191 | summary = """
192 | # Summary by GPT-4
193 | """
194 |
195 | summary += _summarize_pr(git_diff)
196 |
197 | if os.getenv("FILE_SUMMARY", "true").lower() == "true":
198 | file_summary = """
199 | ## Changes
200 |
201 | """
202 | file_summary += "".join(_summarize_file(diff) for diff in _split_diff(git_diff))
203 | if os.getenv("FILE_SUMMARY_FULL", "true").lower() == "true":
204 | summary += file_summary
205 |
206 | summary += f"""
207 | ### Summary of File Changes
208 | {_request_goal(file_summary, goal="Summarize the changes to the files.")}
209 | """
210 |
211 | if os.getenv("TEST_SUMMARY", "true").lower() == "true":
212 | summary += f"""
213 | ## Test Coverage
214 | {_summarize_test_coverage(git_diff)}
215 | """
216 |
217 | if os.getenv("BUG_SUMMARY", "true").lower() == "true":
218 | question = load_bug_yaml().format(diff=git_diff)
219 | pr_bugs = _ask([question])["response"]
220 |
221 | summary += f"""
222 | ## Potential Bugs
223 | {pr_bugs}
224 | """
225 |
226 | summary += _summarize_risk(git_diff)
227 |
228 | return summary
229 |
230 |
231 | def _review(diff: str = ".diff", config: str = "config.summary.yml") -> Dict[str, str]:
232 | """Review a git diff from file
233 |
234 | Args:
235 | diff (str, optional): The diff to review. Defaults to ".diff".
236 | config (str, optional): The config to use. Defaults to "config.summary.yml".
237 |
238 | Returns:
239 | Dict[str, str]: The response from GPT-4.
240 | """
241 |
242 | # If config is a file, use it
243 |
244 | with open(diff, "r", encoding="utf8") as file:
245 | diff_contents = file.read()
246 |
247 | if os.path.isfile(config):
248 | summary = _process_yaml(git_diff=diff_contents, yaml_file=config)
249 | else:
250 | summary = _summarize_files(diff_contents)
251 | return {"response": summary}
252 |
253 |
254 | def _process_yaml(git_diff, yaml_file, headers=True) -> str:
255 | """
256 | Process a yaml file.
257 |
258 | Args:
259 | git_diff (str): The diff of the PR.
260 | yaml_file (str): The path to the yaml file.
261 | headers (bool, optional): Whether to include headers. Defaults to True.
262 |
263 | Returns:
264 | str: The report.
265 | """
266 | with open(yaml_file, "r", encoding="utf8") as file:
267 | yaml_contents = file.read()
268 | config = yaml.safe_load(yaml_contents)
269 | report = config["report"]
270 |
271 | return _process_report(git_diff, report, headers=headers)
272 |
273 |
274 | def _process_report(git_diff, report: dict, indent="#", headers=True) -> str:
275 | """
276 | for-each record in report
277 | - if record is a string, check_goals
278 | - else recursively call process_report
279 |
280 | Args:
281 | git_diff (str): The diff of the PR.
282 | report (dict): The report to process.
283 | indent (str, optional): The indent to use. Defaults to "#".
284 | headers (bool, optional): Whether to include headers. Defaults to True.
285 |
286 | Returns:
287 | str: The report.
288 | """
289 | text = ""
290 | for key, record in report.items():
291 | if isinstance(record, str) or record is None:
292 | if headers and key != "_":
293 | text += f"""
294 | {indent} {key}
295 | """
296 | text += f"{_request_goal(git_diff, goal=record)}"
297 |
298 | else:
299 | text += f"""
300 | {indent} {key}
301 | """
302 | text += _process_report(git_diff, record, indent=f"{indent}#", headers=headers)
303 |
304 | return text
305 |
306 |
307 | class ReviewCommandGroup(GPTCommandGroup):
308 | """Review Command Group."""
309 |
310 | @staticmethod
311 | def load_command_table(loader: CLICommandsLoader) -> None:
312 | with CommandGroup(loader, "review", "gpt_review._review#{}", is_preview=True) as group:
313 | group.command("diff", "_review", is_preview=True)
314 |
315 | @staticmethod
316 | def load_arguments(loader: CLICommandsLoader) -> None:
317 | """Add patch_repo, patch_pr, and access_token arguments."""
318 | with ArgumentsContext(loader, "github") as args:
319 | args.argument(
320 | "diff",
321 | type=str,
322 | help="Git diff to review.",
323 | default=".diff",
324 | )
325 | args.argument(
326 | "config",
327 | type=str,
328 | help="The config file to use to customize review summary.",
329 | default="config.template.yml",
330 | )
331 |
--------------------------------------------------------------------------------
/src/gpt_review/_ask.py:
--------------------------------------------------------------------------------
1 | """Ask GPT a question."""
2 | import logging
3 | from typing import Dict, List, Optional
4 |
5 | from knack import CLICommandsLoader
6 | from knack.arguments import ArgumentsContext
7 | from knack.commands import CommandGroup
8 | from knack.util import CLIError
9 |
10 | import gpt_review.constants as C
11 | from gpt_review._command import GPTCommandGroup
12 | from gpt_review._llama_index import _query_index
13 | from gpt_review._openai import _call_gpt
14 | from gpt_review.context import _load_azure_openai_context
15 |
16 |
17 | def validate_parameter_range(namespace) -> None:
18 | """
19 | Validate the following parameters:
20 | - max_tokens is in [1,4000]
21 | - temperature is in [0,1]
22 | - top_p is in [0,1]
23 | - frequency_penalty is in [0,2]
24 | - presence_penalty is in [0,2]
25 |
26 | Args:
27 | namespace (argparse.Namespace): The namespace to validate.
28 |
29 | Raises:
30 | CLIError: If the parameter is not within the allowed range.
31 | """
32 | _range_validation(namespace.max_tokens, "max-tokens", C.MAX_TOKENS_MIN, C.MAX_TOKENS_MAX)
33 | _range_validation(namespace.temperature, "temperature", C.TEMPERATURE_MIN, C.TEMPERATURE_MAX)
34 | _range_validation(namespace.top_p, "top-p", C.TOP_P_MIN, C.TOP_P_MAX)
35 | _range_validation(
36 | namespace.frequency_penalty, "frequency-penalty", C.FREQUENCY_PENALTY_MIN, C.FREQUENCY_PENALTY_MAX
37 | )
38 | _range_validation(namespace.presence_penalty, "presence-penalty", C.PRESENCE_PENALTY_MIN, C.PRESENCE_PENALTY_MAX)
39 |
40 |
41 | def _range_validation(param, name, min_value, max_value) -> None:
42 | """Validates that the given parameter is within the allowed range
43 |
44 | Args:
45 | param (int or float): The parameter value to validate.
46 | name (str): The name of the parameter.
47 | min_value (int or float): The minimum allowed value for the parameter.
48 | max_value (int or float): The maximum allowed value for the parameter.
49 |
50 | Raises:
51 | CLIError: If the parameter is not within the allowed range.
52 | """
53 | if param is not None and (param < min_value or param > max_value):
54 | raise CLIError(f"--{name} must be a(n) {type(param).__name__} between {min_value} and {max_value}")
55 |
56 |
57 | def _ask(
58 | question: List[str],
59 | max_tokens: int = C.MAX_TOKENS_DEFAULT,
60 | temperature: float = C.TEMPERATURE_DEFAULT,
61 | top_p: float = C.TOP_P_DEFAULT,
62 | frequency_penalty: float = C.FREQUENCY_PENALTY_DEFAULT,
63 | presence_penalty: float = C.PRESENCE_PENALTY_DEFAULT,
64 | files: Optional[List[str]] = None,
65 | messages=None,
66 | fast: bool = False,
67 | large: bool = False,
68 | directory: Optional[str] = None,
69 | reset: bool = False,
70 | required_exts: Optional[List[str]] = None,
71 | hidden: bool = False,
72 | recursive: bool = False,
73 | repository: Optional[str] = None,
74 | branch: str = "main",
75 | ) -> Dict[str, str]:
76 | """
77 | Ask GPT a question.
78 |
79 | Args:
80 | question (List[str]): The question to ask GPT.
81 | max_tokens (int, optional): The maximum number of tokens to generate. Defaults to C.MAX_TOKENS_DEFAULT.
82 | temperature (float, optional): Controls randomness. Defaults to C.TEMPERATURE_DEFAULT.
83 | top_p (float, optional): Controls diversity via nucleus sampling. Defaults to C.TOP_P_DEFAULT.
84 | frequency_penalty (float, optional): How much to penalize new tokens based on their existing frequency in the
85 | text so far. Defaults to C.FREQUENCY_PENALTY_DEFAULT.
86 | presence_penalty (float, optional): How much to penalize new tokens based on whether they appear in the text so
87 | far. Defaults to C.PRESENCE_PENALTY_DEFAULT.
88 | files (Optional[List[str]], optional): The files to search. Defaults to None.
89 | messages ([type], optional): [description]. Defaults to None.
90 | fast (bool, optional): Use the fast model. Defaults to False.
91 | large (bool, optional): Use the large model. Defaults to False.
92 | directory (Optional[str], optional): The directory to search. Defaults to None.
93 | reset (bool, optional): Whether to reset the index. Defaults to False.
94 | required_exts (Optional[List[str]], optional): The required file extensions. Defaults to None.
95 | hidden (bool, optional): Include hidden files. Defaults to False.
96 | recursive (bool, optional): Recursively search the directory. Defaults to False.
97 | repository (Optional[str], optional): The repository to search. Defaults to None.
98 |
99 | Returns:
100 | Dict[str, str]: The response from GPT.
101 | """
102 | _load_azure_openai_context()
103 |
104 | prompt = "".join(question)
105 |
106 | if files or directory or repository:
107 | response = _query_index(
108 | prompt,
109 | files,
110 | input_dir=directory,
111 | reset=reset,
112 | exclude_hidden=not hidden,
113 | recursive=recursive,
114 | required_exts=required_exts,
115 | repository=repository,
116 | branch=branch,
117 | fast=fast,
118 | large=large,
119 | )
120 | else:
121 | response = _call_gpt(
122 | prompt=prompt,
123 | max_tokens=max_tokens,
124 | temperature=temperature,
125 | top_p=top_p,
126 | frequency_penalty=frequency_penalty,
127 | presence_penalty=presence_penalty,
128 | fast=fast,
129 | large=large,
130 | messages=messages,
131 | )
132 | logging.info(response)
133 | return {"response": response}
134 |
135 |
136 | class AskCommandGroup(GPTCommandGroup):
137 | """Ask Command Group."""
138 |
139 | @staticmethod
140 | def load_command_table(loader: CLICommandsLoader) -> None:
141 | with CommandGroup(loader, "", "gpt_review._ask#{}") as group:
142 | group.command("ask", "_ask", is_preview=True)
143 |
144 | @staticmethod
145 | def load_arguments(loader: CLICommandsLoader) -> None:
146 | with ArgumentsContext(loader, "ask") as args:
147 | args.positional("question", type=str, nargs="+", help="Provide a question to ask GPT.")
148 | args.argument(
149 | "fast",
150 | help="Use gpt-35-turbo for prompts < 4000 tokens.",
151 | default=False,
152 | action="store_true",
153 | )
154 | args.argument(
155 | "large",
156 | help="Use gpt-4-32k for prompts.",
157 | default=False,
158 | action="store_true",
159 | )
160 | args.argument(
161 | "temperature",
162 | type=float,
163 | help="Sets the level of creativity/randomness.",
164 | validator=validate_parameter_range,
165 | )
166 | args.argument(
167 | "max_tokens",
168 | type=int,
169 | help="The maximum number of tokens to generate.",
170 | validator=validate_parameter_range,
171 | )
172 | args.argument(
173 | "top_p",
174 | type=float,
175 | help="Also sets the level of creativity/randomness. Adjust temperature or top p but not both.",
176 | validator=validate_parameter_range,
177 | )
178 | args.argument(
179 | "frequency_penalty",
180 | type=float,
181 | help="Reduce the chance of repeating a token based on current frequency in the text.",
182 | validator=validate_parameter_range,
183 | )
184 | args.argument(
185 | "presence_penalty",
186 | type=float,
187 | help="Reduce the chance of repeating any token that has appeared in the text so far.",
188 | validator=validate_parameter_range,
189 | )
190 | args.argument(
191 | "files",
192 | type=str,
193 | help="Ask question about a file. Can be used multiple times.",
194 | default=None,
195 | action="append",
196 | options_list=("--files", "-f"),
197 | )
198 | args.argument(
199 | "directory",
200 | type=str,
201 | help="Path of the directory to index. Use --recursive (or -r) to index subdirectories.",
202 | default=None,
203 | options_list=("--directory", "-d"),
204 | )
205 | args.argument(
206 | "required_exts",
207 | type=str,
208 | help="Required extensions when indexing a directory. Requires --directory. Can be used multiple times.",
209 | default=None,
210 | action="append",
211 | )
212 | args.argument(
213 | "hidden",
214 | help="Include hidden files when indexing a directory. Requires --directory.",
215 | default=False,
216 | action="store_true",
217 | )
218 | args.argument(
219 | "recursive",
220 | help="Recursively index a directory. Requires --directory.",
221 | default=False,
222 | action="store_true",
223 | options_list=("--recursive", "-r"),
224 | )
225 | args.argument(
226 | "repository",
227 | type=str,
228 | help="Repository to index. Default: None.",
229 | default=None,
230 | options_list=("--repository", "-repo"),
231 | )
232 | args.argument(
233 | "branch",
234 | type=str,
235 | help="Branch to index. Default: main.",
236 | default="main",
237 | options_list=("--branch", "-b"),
238 | )
239 | args.argument(
240 | "reset",
241 | help="Reset the index, overwriting the directory. Requires --directory, --files, or --repository.",
242 | default=False,
243 | action="store_true",
244 | )
245 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["flit"]
3 | build-backend = "flit.buildapi"
4 |
5 | [project]
6 | name = "gpt-review"
7 | authors = [
8 | {name = "Daniel Ciborowski", email = "dciborow@microsoft.com"},
9 | ]
10 | description = "Python Project for reviewing GitHub PRs with Open AI and Chat-GPT."
11 | readme = "README.md"
12 | classifiers = [
13 | "Development Status :: 3 - Alpha",
14 | "Intended Audience :: Developers",
15 | "License :: OSI Approved :: MIT License",
16 | "Programming Language :: Python :: 3 :: Only",
17 | "Programming Language :: Python :: 3.8",
18 | "Programming Language :: Python :: 3.9",
19 | "Programming Language :: Python :: 3.10",
20 | "Programming Language :: Python :: 3.11"
21 | ]
22 | requires-python = ">=3.8.1"
23 | dynamic = ["version"]
24 | dependencies = [
25 | 'azure-devops',
26 | 'azure-functions; python_version <= "3.10"',
27 | 'azure-identity',
28 | 'azure-keyvault-secrets',
29 | 'llama-index>=0.6.0,<0.9.0',
30 | 'httpx',
31 | 'GitPython',
32 | 'knack',
33 | 'openai',
34 | 'requests',
35 | 'pyyaml',
36 | 'typing_extensions; python_version <= "3.10"',
37 | 'transformers; python_version <= "3.8"'
38 | ]
39 |
40 | [project.optional-dependencies]
41 | test = [
42 | "bandit[toml]==1.7.5",
43 | "black==23.3.0",
44 | "cattrs",
45 | "docx2txt",
46 | "check-manifest==0.49",
47 | "flake8-bugbear==23.6.5",
48 | "flake8-docstrings",
49 | "flake8-formatter_junit_xml",
50 | "flake8",
51 | "flake8-pyproject",
52 | "pre-commit==3.3.3",
53 | "pylint==2.17.4",
54 | "pylint_junit",
55 | "pytest-cov>=3.0.0",
56 | "pytest-mock",
57 | "pytest-runner",
58 | "pytest-xdist",
59 | "pytest>=7.2.2",
60 | "pytest-github-actions-annotate-failures",
61 | "shellcheck-py==0.9.0.5",
62 | "requests_mock"
63 | ]
64 |
65 | [project.scripts]
66 | gpt = "gpt_review.main:__main__"
67 |
68 | [project.urls]
69 | Documentation = "https://github.com/dciborow/action-gpt/tree/main#readme"
70 | Source = "https://github.com/dciborow/action-gpt"
71 | Tracker = "https://github.com/dciborow/action-gpt/issues"
72 |
73 | [tool.flit.module]
74 | name = "gpt_review"
75 |
76 | [tool.bandit]
77 | exclude_dirs = ["build","dist","tests","scripts"]
78 | number = 4
79 | recursive = true
80 | targets = "src"
81 |
82 | [tool.black]
83 | line-length = 120
84 | fast = true
85 | experimental-string-processing = true
86 |
87 | [tool.coverage.run]
88 | branch = true
89 | omit = [
90 | # Omitting files that can not be covered by tests
91 | "src/gpt/__main__.py",
92 | "src/gpt_review/__main__.py",
93 | "src/gpt_review/main.py"
94 | ]
95 |
96 | [tool.coverage.report]
97 | fail_under = 100
98 |
99 | [tool.flake8]
100 | max-line-length = 120
101 | select = "F,E,W,B,B901,B902,B903"
102 | exclude = [
103 | ".eggs",
104 | ".git",
105 | ".tox",
106 | "nssm",
107 | "obj",
108 | "out",
109 | "packages",
110 | "pywin32",
111 | "tests",
112 | "swagger_client"
113 | ]
114 | ignore = [
115 | "E722",
116 | "B001",
117 | "W503",
118 | "E203",
119 | # Covered by Ruff
120 | "F401",
121 | "F501",
122 | "F821",
123 | "W391", # Covered by Pylint trailing-newlines
124 | ]
125 |
126 | [tool.isort]
127 | profile = "black"
128 | src_paths = ["src", "tests", "azure"]
129 |
130 | [tool.pyright]
131 | include = ["src"]
132 | exclude = [
133 | "**/node_modules",
134 | "**/__pycache__",
135 | ]
136 | venv = "env37"
137 |
138 | reportMissingImports = true
139 | reportMissingTypeStubs = false
140 |
141 | pythonVersion = "3.7"
142 | pythonPlatform = "Linux"
143 |
144 | executionEnvironments = [
145 | { root = "src" }
146 | ]
147 |
148 | [tool.pytest.ini_options]
149 | addopts = "--cov-report xml:coverage.xml --cov src --cov-fail-under 0 --cov-append -n auto"
150 | pythonpath = [
151 | "src"
152 | ]
153 | testpaths = "tests"
154 | # junit_family = "xunit2"
155 | markers = [
156 | "integration: marks as integration test",
157 | "notebooks: marks as notebook test",
158 | "gpu: marks as gpu test",
159 | "spark: marks tests which need Spark",
160 | "slow: marks tests as slow",
161 | "unit: fast offline tests",
162 | "cli: test installed CLI",
163 | ]
164 |
165 | [tool.tox]
166 | legacy_tox_ini = """
167 | [tox]
168 | envlist = py, integration, spark, all
169 |
170 | [testenv]
171 | commands =
172 | pytest -m "not integration and not spark" {posargs}
173 |
174 | [testenv:integration]
175 | commands =
176 | pytest -m "integration" {posargs}
177 |
178 | [testenv:spark]
179 | extras = spark
180 | setenv =
181 | PYSPARK_DRIVER_PYTHON = {envpython}
182 | PYSPARK_PYTHON = {envpython}
183 | commands =
184 | pytest -m "spark" {posargs}
185 |
186 | [testenv:all]
187 | extras = all
188 | setenv =
189 | PYSPARK_DRIVER_PYTHON = {envpython}
190 | PYSPARK_PYTHON = {envpython}
191 | commands =
192 | pytest {posargs}
193 | """
194 |
195 | [tool.pylint]
196 | extension-pkg-whitelist= [
197 | "numpy",
198 | "torch",
199 | "cv2",
200 | "pyodbc",
201 | "pydantic",
202 | "ciso8601",
203 | "netcdf4",
204 | "scipy"
205 | ]
206 | ignore="CVS"
207 | ignore-patterns="test.*?py,conftest.py"
208 | init-hook='import sys; sys.setrecursionlimit(8 * sys.getrecursionlimit())'
209 | jobs=0
210 | limit-inference-results=100
211 | persistent="yes"
212 | suggestion-mode="yes"
213 | unsafe-load-any-extension="no"
214 |
215 | [tool.pylint.'MESSAGES CONTROL']
216 | enable="c-extension-no-member"
217 | disable = [
218 | "unused-import", # Covered by Ruff F401
219 | "undefined-variable", # Covered by Ruff F821
220 | "line-too-long", # Covered by Ruff E501
221 | ]
222 | [tool.pylint.'REPORTS']
223 | evaluation="10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)"
224 | output-format="text"
225 | reports="no"
226 | score="yes"
227 |
228 | [tool.pylint.'REFACTORING']
229 | max-nested-blocks=5
230 | never-returning-functions="sys.exit"
231 |
232 | [tool.pylint.'BASIC']
233 | argument-naming-style="snake_case"
234 | attr-naming-style="snake_case"
235 | bad-names= [
236 | "foo",
237 | "bar"
238 | ]
239 | class-attribute-naming-style="any"
240 | class-naming-style="PascalCase"
241 | const-naming-style="UPPER_CASE"
242 | docstring-min-length=-1
243 | function-naming-style="snake_case"
244 | good-names= [
245 | "i",
246 | "j",
247 | "k",
248 | "ex",
249 | "Run",
250 | "_"
251 | ]
252 | include-naming-hint="yes"
253 | inlinevar-naming-style="any"
254 | method-naming-style="snake_case"
255 | module-naming-style="any"
256 | no-docstring-rgx="^_"
257 | property-classes="abc.abstractproperty"
258 | variable-naming-style="snake_case"
259 |
260 | [tool.pylint.'FORMAT']
261 | ignore-long-lines="^\\s*(# )?.*['\"]??"
262 | indent-after-paren=4
263 | indent-string=' '
264 | max-line-length=120
265 | max-module-lines=1000
266 | single-line-class-stmt="no"
267 | single-line-if-stmt="no"
268 |
269 | [tool.pylint.'LOGGING']
270 | logging-format-style="old"
271 | logging-modules="logging"
272 |
273 | [tool.pylint.'MISCELLANEOUS']
274 | notes= [
275 | "FIXME",
276 | "XXX",
277 | "TODO"
278 | ]
279 |
280 | [tool.pylint.'SIMILARITIES']
281 | ignore-comments="yes"
282 | ignore-docstrings="yes"
283 | ignore-imports="yes"
284 | min-similarity-lines=7
285 |
286 | [tool.pylint.'SPELLING']
287 | max-spelling-suggestions=4
288 | spelling-store-unknown-words="no"
289 |
290 | [tool.pylint.'STRING']
291 | check-str-concat-over-line-jumps="no"
292 |
293 | [tool.pylint.'TYPECHECK']
294 | contextmanager-decorators="contextlib.contextmanager"
295 | generated-members="numpy.*,np.*,pyspark.sql.functions,collect_list"
296 | ignore-mixin-members="yes"
297 | ignore-none="yes"
298 | ignore-on-opaque-inference="yes"
299 | ignored-classes="optparse.Values,thread._local,_thread._local,numpy,torch,swagger_client"
300 | ignored-modules="numpy,torch,swagger_client,netCDF4,scipy"
301 | missing-member-hint="yes"
302 | missing-member-hint-distance=1
303 | missing-member-max-choices=1
304 |
305 | [tool.pylint.'VARIABLES']
306 | additional-builtins="dbutils"
307 | allow-global-unused-variables="yes"
308 | callbacks= [
309 | "cb_",
310 | "_cb"
311 | ]
312 | dummy-variables-rgx="_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_"
313 | ignored-argument-names="_.*|^ignored_|^unused_"
314 | init-import="no"
315 | redefining-builtins-modules="six.moves,past.builtins,future.builtins,builtins,io"
316 |
317 | [tool.pylint.'CLASSES']
318 | defining-attr-methods= [
319 | "__init__",
320 | "__new__",
321 | "setUp",
322 | "__post_init__"
323 | ]
324 | exclude-protected= [
325 | "_asdict",
326 | "_fields",
327 | "_replace",
328 | "_source",
329 | "_make"
330 | ]
331 | valid-classmethod-first-arg="cls"
332 | valid-metaclass-classmethod-first-arg="cls"
333 |
334 | [tool.pylint.'DESIGN']
335 | max-args=5
336 | max-attributes=7
337 | max-bool-expr=5
338 | max-branches=12
339 | max-locals=15
340 | max-parents=7
341 | max-public-methods=20
342 | max-returns=6
343 | max-statements=50
344 | min-public-methods=2
345 |
346 | [tool.pylint.'IMPORTS']
347 | allow-wildcard-with-all="no"
348 | analyse-fallback-blocks="no"
349 | deprecated-modules="optparse,tkinter.tix"
350 |
351 | [tool.pylint.'EXCEPTIONS']
352 | overgeneral-exceptions= [
353 | "BaseException",
354 | "Exception"
355 | ]
356 |
357 | [tool.ruff]
358 | # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default.
359 | select = ["E", "F"]
360 | ignore = []
361 |
362 | # Allow autofix for all enabled rules (when `--fix`) is provided.
363 | fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"]
364 | unfixable = []
365 |
366 | # Exclude a variety of commonly ignored directories.
367 | exclude = [
368 | ".bzr",
369 | ".direnv",
370 | ".eggs",
371 | ".git",
372 | ".hg",
373 | ".mypy_cache",
374 | ".nox",
375 | ".pants.d",
376 | ".pytype",
377 | ".ruff_cache",
378 | ".svn",
379 | ".tox",
380 | ".venv",
381 | "__pypackages__",
382 | "_build",
383 | "buck-out",
384 | "build",
385 | "dist",
386 | "node_modules",
387 | "venv",
388 | ]
389 |
390 | # Same as Black.
391 | line-length = 120
392 |
393 | # Allow unused variables when underscore-prefixed.
394 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
395 |
396 | # Assume Python 3.10.
397 | target-version = "py311"
398 |
399 | [tool.ruff.mccabe]
400 | # Unlike Flake8, default to a complexity level of 10.
401 | max-complexity = 10
402 |
--------------------------------------------------------------------------------