From 7f4d9ba82b73a04481a4ee52bf150f9cc68a2c07 Mon Sep 17 00:00:00 2001 From: Heiner Lohaus Date: Sat, 20 Jan 2024 18:16:18 +0100 Subject: Add copilot github action --- .github/workflows/copilot.yml | 33 +++++++ etc/tool/copilot.py | 215 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 .github/workflows/copilot.yml create mode 100644 etc/tool/copilot.py diff --git a/.github/workflows/copilot.yml b/.github/workflows/copilot.yml new file mode 100644 index 00000000..38c24378 --- /dev/null +++ b/.github/workflows/copilot.yml @@ -0,0 +1,33 @@ +name: AI Code Reviewer + +on: + pull_request: + types: + - opened + - synchronize + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +permissions: + contents: read + pull-requests: write + +jobs: + review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + cache: 'pip' + - name: Install Requirements + run: pip install -r requirements.txt + - name: Install PyGithub + run: pip install PyGithub + - name: AI Code Review + run: python -m etc.tool.copilot \ No newline at end of file diff --git a/etc/tool/copilot.py b/etc/tool/copilot.py new file mode 100644 index 00000000..62698c70 --- /dev/null +++ b/etc/tool/copilot.py @@ -0,0 +1,215 @@ +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).parent.parent.parent)) + +import g4f +import json +import os +import re +import requests +from typing import Union +from github import Github +from github.PullRequest import PullRequest + +g4f.debug.logging = True +g4f.debug.version_check = False + +GITHUB_TOKEN = os.getenv('GITHUB_TOKEN') +G4F_PROVIDER = os.getenv('G4F_PROVIDER') or g4f.Provider.OpenaiChat +G4F_MODEL = os.getenv('G4F_MODEL') or g4f.models.gpt_4 + +def get_pr_details(github: Github) -> PullRequest: + """ + Rteurns the details of the pull request from GitHub. + + Returns: + PullRequest: A PullRequest instance. + """ + with open(os.getenv('GITHUB_EVENT_PATH', ''), 'r') as file: + data = json.load(file) + + repo = github.get_repo(f"{data['repository']['owner']['login']}/{data['repository']['name']}") + pull = repo.get_pull(data['number']) + + return pull + +def get_diff(diff_url: str) -> str: + """ + Fetches the diff of the pull request. + + Args: + pull (PullRequest): Pull request. + + Returns: + str or None: The diff of the pull request or None if not available. + """ + response = requests.get(diff_url) + response.raise_for_status() + return response.text + +def read_json(text: str) -> dict: + match = re.search(r"```(json|)\n(?P[\S\s]+?)\n```", text) + if match: + text = match.group("code") + try: + return json.loads(text.strip()) + except json.JSONDecodeError: + print("No valid json:", text) + return {} + +def read_text(text: str) -> dict: + match = re.search(r"```(markdown|)\n(?P[\S\s]+?)\n```", text) + if match: + return match.group("text") + return text + +def get_ai_response(prompt, as_json: bool = True) -> Union[dict, str]: + """ + Gets a response from g4f API based on the prompt. + + Args: + prompt (str): The prompt to send to g4f. + + Returns: + dict: The parsed response from g4f. + """ + response = g4f.ChatCompletion.create( + G4F_MODEL, + [{'role': 'user', 'content': prompt}], + G4F_PROVIDER, + ignore_stream_and_auth=True + ) + if as_json: + return read_json(response) + return read_text(response) + +def analyze_code(pull: PullRequest, diff: str)-> list: + """ + Analyzes the code changes in the pull request. + + Args: + diff (str): The diff of the pull request. + pr_details (dict): Details of the pull request. + + Returns: + list: List of comments generated by the analysis. + """ + comments = [] + changed_lines = [] + current_file_path = None + offset_line = 0 + + for line in diff.split('\n'): + if line.startswith('+++ b/'): + current_file_path = line[6:] + elif line.startswith('@@'): + match = re.search(r'\+([0-9]+?),', line) + if match: + offset_line = int(match.group(1)) + elif current_file_path: + if line.startswith('\\') or line.startswith('diff'): + prompt = create_prompt(changed_lines, pull, current_file_path, offset_line) + response = get_ai_response(prompt) + for review in response.get('reviews', []): + review['path'] = current_file_path + comments.append(review) + changed_lines = [] + current_file_path = None + elif not line.startswith('-'): + changed_lines.append(line) + + return comments + +def create_prompt(changed_lines: list, pull: PullRequest, file_path: str, offset_line: int): + """ + Creates a prompt for the g4f model. + + Args: + diff (str): The line of code to analyze. + pr_details (dict): Details of the pull request. + + Returns: + str: The generated prompt. + """ + code = "\n".join([f"{offset_line+idx}:{line}" for idx, line in enumerate(changed_lines)]) + print("Code:", code) + example = '{"reviews": [{"line": , "body": ""}]}' + return f"""Your task is to review pull requests. Instructions: +- Provide the response in following JSON format: {example} +- Do not give positive comments or compliments. +- Provide comments and suggestions ONLY if there is something to improve, otherwise "reviews" should be an empty array. +- Write the comment in GitHub Markdown format. +- Use the given description only for the overall context and only comment the code. +- IMPORTANT: NEVER suggest adding comments to the code. + +Review the following code diff in the file "{file_path}" and take the pull request title and description into account when writing the response. + +Pull request title: {pull.title} +Pull request description: +--- +{pull.body} +--- + +Each line is prefixed by its number. Code to review: +``` +{code} +``` +""" + +def create_review_prompt(pull: PullRequest, diff: str): + """ + Creates a prompt to create a review. + + Args: + diff (str): The line of code to analyze. + + Returns: + str: The generated prompt. + """ + return f"""Your task is to review a pull request. Instructions: +- Your name / you are copilot. +- Write the review in GitHub Markdown format. +- Thank the author for contributing to the project. +- Point out that you might leave a few comments on the files. + +Pull request author: {pull.user.name} +Pull request title: {pull.title} +Pull request description: +--- +{pull.body} +--- + +Diff: +```diff +{diff} +``` +""" + +def main(): + try: + github = Github(GITHUB_TOKEN) + pull = get_pr_details(github) + diff = get_diff(pull.diff_url) + except Exception as e: + print(f"Error get details: {e}") + exit(1) + try: + review = get_ai_response(create_review_prompt(pull, diff), False) + except Exception as e: + print(f"Error create review: {e}") + exit(1) + try: + comments = analyze_code(pull, diff) + except Exception as e: + print(f"Error analyze: {e}") + exit(1) + print("Comments:", comments) + try: + pull.create_review(body=review, comments=comments) + except Exception as e: + print(f"Error posting review: {e}") + exit(1) + +if __name__ == "__main__": + main() -- cgit v1.2.3 From 782edbdb6a0bea10b48deedd4a4d3f3afdf81a7a Mon Sep 17 00:00:00 2001 From: Heiner Lohaus Date: Sat, 20 Jan 2024 18:16:45 +0100 Subject: Improve asserts in unittests --- etc/unittest/main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/etc/unittest/main.py b/etc/unittest/main.py index 4b92a6a6..ad1fe02d 100644 --- a/etc/unittest/main.py +++ b/etc/unittest/main.py @@ -39,10 +39,11 @@ class TestBackendApi(unittest.TestCase): class TestChatCompletion(unittest.TestCase): - def test_create(self): + def test_create_default(self): messages = [{'role': 'user', 'content': 'Hello'}] result = ChatCompletion.create(g4f.models.default, messages) - self.assertTrue("Hello" in result or "Good" in result) + if "Good" not in result and "Hi" not in result: + self.assertIn("Hello", result) def test_get_last_provider(self): messages = [{'role': 'user', 'content': 'Hello'}] @@ -53,14 +54,14 @@ class TestChatCompletion(unittest.TestCase): messages = [{'role': 'user', 'content': 'Hello'}] provider = g4f.Provider.Bing result = ChatCompletion.create(g4f.models.default, messages, provider) - self.assertTrue("Bing" in result) + self.assertIn("Bing", result) class TestChatCompletionAsync(unittest.IsolatedAsyncioTestCase): async def test_async(self): messages = [{'role': 'user', 'content': 'Hello'}] result = await ChatCompletion.create_async(g4f.models.default, messages, MockProvider) - self.assertTrue("Mock" in result) + self.assertEqual("Mock", result) class TestUtilityFunctions(unittest.TestCase): -- cgit v1.2.3 From cb0e065581423460663c58d1d169c54f0faa3084 Mon Sep 17 00:00:00 2001 From: Heiner Lohaus Date: Sat, 20 Jan 2024 18:23:54 +0100 Subject: Remove usage of get_event_loop helper --- g4f/Provider/base_provider.py | 30 +++++++++++++++++++++--------- g4f/errors.py | 3 +++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/g4f/Provider/base_provider.py b/g4f/Provider/base_provider.py index fd92d17a..95f1b0b2 100644 --- a/g4f/Provider/base_provider.py +++ b/g4f/Provider/base_provider.py @@ -8,6 +8,7 @@ from inspect import signature, Parameter from .helper import get_event_loop, get_cookies, format_prompt from ..typing import CreateResult, AsyncResult, Messages from ..base_provider import BaseProvider +from ..errors import NestAsyncioError if sys.version_info < (3, 10): NoneType = type(None) @@ -48,7 +49,7 @@ class AbstractProvider(BaseProvider): Returns: str: The created result as a string. """ - loop = loop or get_event_loop() + loop = loop or asyncio.get_running_loop() def create_func() -> str: return "".join(cls.create_completion(model, messages, False, **kwargs)) @@ -101,8 +102,6 @@ class AsyncProvider(AbstractProvider): model: str, messages: Messages, stream: bool = False, - *, - loop: AbstractEventLoop = None, **kwargs ) -> CreateResult: """ @@ -119,9 +118,15 @@ class AsyncProvider(AbstractProvider): Returns: CreateResult: The result of the completion creation. """ - loop = loop or get_event_loop() - coro = cls.create_async(model, messages, **kwargs) - yield loop.run_until_complete(coro) + try: + loop = asyncio.get_running_loop() + if not hasattr(loop.__class__, "_nest_patched"): + raise NestAsyncioError( + 'Use "create_async" instead of "create" function in a running event loop. Or use "nest_asyncio" package.' + ) + except RuntimeError: + pass + yield asyncio.run(cls.create_async(model, messages, **kwargs)) @staticmethod @abstractmethod @@ -159,8 +164,6 @@ class AsyncGeneratorProvider(AsyncProvider): model: str, messages: Messages, stream: bool = True, - *, - loop: AbstractEventLoop = None, **kwargs ) -> CreateResult: """ @@ -177,7 +180,16 @@ class AsyncGeneratorProvider(AsyncProvider): Returns: CreateResult: The result of the streaming completion creation. """ - loop = loop or get_event_loop() + try: + loop = asyncio.get_running_loop() + if not hasattr(loop.__class__, "_nest_patched"): + raise NestAsyncioError( + 'Use "create_async" instead of "create" function in a running event loop. Or use "nest_asyncio" package.' + ) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + generator = cls.create_async_generator(model, messages, stream=stream, **kwargs) gen = generator.__aiter__() diff --git a/g4f/errors.py b/g4f/errors.py index b874435a..c0e6ddfc 100644 --- a/g4f/errors.py +++ b/g4f/errors.py @@ -23,4 +23,7 @@ class RetryNoProviderError(Exception): pass class VersionNotFoundError(Exception): + pass + +class NestAsyncioError(Exception): pass \ No newline at end of file -- cgit v1.2.3 From 8787db385eec227f2c101544363d02d2d285cc24 Mon Sep 17 00:00:00 2001 From: Heiner Lohaus Date: Sat, 20 Jan 2024 18:36:04 +0100 Subject: Check access from chromedriver --- etc/tool/copilot.py | 2 +- g4f/webdriver.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/etc/tool/copilot.py b/etc/tool/copilot.py index 62698c70..4de53ffc 100644 --- a/etc/tool/copilot.py +++ b/etc/tool/copilot.py @@ -16,7 +16,7 @@ g4f.debug.logging = True g4f.debug.version_check = False GITHUB_TOKEN = os.getenv('GITHUB_TOKEN') -G4F_PROVIDER = os.getenv('G4F_PROVIDER') or g4f.Provider.OpenaiChat +G4F_PROVIDER = os.getenv('G4F_PROVIDER') G4F_MODEL = os.getenv('G4F_MODEL') or g4f.models.gpt_4 def get_pr_details(github: Github) -> PullRequest: diff --git a/g4f/webdriver.py b/g4f/webdriver.py index 9a83215f..85d6d695 100644 --- a/g4f/webdriver.py +++ b/g4f/webdriver.py @@ -6,6 +6,7 @@ from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from os import path +from os import access, R_OK from . import debug try: @@ -40,8 +41,9 @@ def get_browser( options = ChromeOptions() if proxy: options.add_argument(f'--proxy-server={proxy}') + # Check for system driver in docker driver = '/usr/bin/chromedriver' - if not path.isfile(driver): + if not path.isfile(driver) or not access(driver, R_OK): driver = None return Chrome( options=options, -- cgit v1.2.3 From de993f539c4e244687bd01572637e9a17af3350c Mon Sep 17 00:00:00 2001 From: Heiner Lohaus Date: Sat, 20 Jan 2024 18:54:32 +0100 Subject: Fix offset_line in copilot --- etc/tool/copilot.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/etc/tool/copilot.py b/etc/tool/copilot.py index 4de53ffc..9be1291c 100644 --- a/etc/tool/copilot.py +++ b/etc/tool/copilot.py @@ -108,8 +108,8 @@ def analyze_code(pull: PullRequest, diff: str)-> list: if match: offset_line = int(match.group(1)) elif current_file_path: - if line.startswith('\\') or line.startswith('diff'): - prompt = create_prompt(changed_lines, pull, current_file_path, offset_line) + if line.startswith('\\') or line.startswith('diff') and changed_lines: + prompt = create_prompt(changed_lines, pull, current_file_path) response = get_ai_response(prompt) for review in response.get('reviews', []): review['path'] = current_file_path @@ -117,11 +117,12 @@ def analyze_code(pull: PullRequest, diff: str)-> list: changed_lines = [] current_file_path = None elif not line.startswith('-'): - changed_lines.append(line) + changed_lines.append(f"{offset_line}:{line}") + offset_line += 1 return comments -def create_prompt(changed_lines: list, pull: PullRequest, file_path: str, offset_line: int): +def create_prompt(changed_lines: list, pull: PullRequest, file_path: str): """ Creates a prompt for the g4f model. @@ -132,8 +133,7 @@ def create_prompt(changed_lines: list, pull: PullRequest, file_path: str, offset Returns: str: The generated prompt. """ - code = "\n".join([f"{offset_line+idx}:{line}" for idx, line in enumerate(changed_lines)]) - print("Code:", code) + code = "\n".join(changed_lines) example = '{"reviews": [{"line": , "body": ""}]}' return f"""Your task is to review pull requests. Instructions: - Provide the response in following JSON format: {example} -- cgit v1.2.3 From 12866514486dc2a3566bdfc9f26b751c78c88e1d Mon Sep 17 00:00:00 2001 From: Heiner Lohaus Date: Sat, 20 Jan 2024 18:55:11 +0100 Subject: On new pull requests --- .github/workflows/copilot.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/copilot.yml b/.github/workflows/copilot.yml index 38c24378..f7083c0c 100644 --- a/.github/workflows/copilot.yml +++ b/.github/workflows/copilot.yml @@ -4,7 +4,6 @@ on: pull_request: types: - opened - - synchronize env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -- cgit v1.2.3 From 9cf2ee0279e1f348a082a7ded7c163ed12a6359a Mon Sep 17 00:00:00 2001 From: Heiner Lohaus Date: Sat, 20 Jan 2024 19:10:08 +0100 Subject: Fix permissions --- .github/workflows/copilot.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/copilot.yml b/.github/workflows/copilot.yml index f7083c0c..c34dcb11 100644 --- a/.github/workflows/copilot.yml +++ b/.github/workflows/copilot.yml @@ -4,13 +4,12 @@ on: pull_request: types: - opened + - synchronize env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -permissions: - contents: read - pull-requests: write +permissions: write-all jobs: review: -- cgit v1.2.3