├── 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 | Actions Status 5 | Coverage Status 6 | License: MIT 7 | PyPI 8 | Downloads 9 | Code style: black 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 | --------------------------------------------------------------------------------