diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 025eb2d7..dd07b8bb 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,6 +3,12 @@ FROM mcr.microsoft.com/devcontainers/python:3 RUN python -m pip install --upgrade pip \ && python -m pip install 'flit>=3.8.0' +RUN curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg \ + && mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg \ + && sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/debian/$(lsb_release -rs | cut -d'.' -f 1)/prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list' \ + && apt-get update \ + && apt-get install azure-functions-core-tools-4 + ENV FLIT_ROOT_INSTALL=1 COPY pyproject.toml . diff --git a/.gitignore b/.gitignore index 61684247..66ea1661 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,15 @@ __blobstorage__ __queuestorage__ __azurite_db*__.json .python_packages + +#summarization development +scripts/summarizations/msazure.yaml +scripts/summarizations/msdata.yaml +scripts/summarizations/msazure_pull_request_list.csv +scripts/summarizations/msdata_pull_request_list.csv +scripts/summarizations/summaries/ +src/gpt_review/prompts/prompt_pr_batch_summary.yaml +src/gpt_review/prompts/prompt_nature.yaml +src/gpt_review/prompts/prompt_pr_summary.yaml + + diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b019787..4df9b6c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,5 +34,12 @@ "python.analysis.inlayHints.functionReturnTypes": true, "python.analysis.diagnosticSeverityOverrides": { "reportUndefinedVariable": "none" // Covered by Ruff F821 - } + }, + "azureFunctions.deploySubpath": "azure/api", + "azureFunctions.scmDoBuildDuringDeployment": true, + "azureFunctions.pythonVenv": ".venv", + "azureFunctions.projectLanguage": "Python", + "azureFunctions.projectRuntime": "~4", + "debug.internalConsoleOptions": "neverOpen", + "azureFunctions.projectSubpath": "azure/api" } \ No newline at end of file diff --git a/azure/README.md b/azure/README.md new file mode 100644 index 00000000..6b9772f2 --- /dev/null +++ b/azure/README.md @@ -0,0 +1,29 @@ +This requires < Python 3.11 to run. + +Install the [Azure Function Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v4%2Clinux%2Ccsharp%2Cportal%2Cbash#local-settings-file) + +```sh +curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg +sudo mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg + +# Debian/Codespace +sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/debian/$(lsb_release -rs | cut -d'.' -f 1)/prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list' + +sudo apt-get update +sudo apt-get install azure-functions-core-tools-4 +``` + +Create a new python env when testing the function. + +```sh +python3.9 -m venv .venv/py39 +source .venv/py39/bin/activate + +python3.9 -m pip install flit +python3.9 -m flit install + +cd azure/api +python 3.9 +func start + +``` diff --git a/azure/api/.funcignore b/azure/api/.funcignore new file mode 100644 index 00000000..f1110d33 --- /dev/null +++ b/azure/api/.funcignore @@ -0,0 +1,8 @@ +.git* +.vscode +__azurite_db*__.json +__blobstorage__ +__queuestorage__ +local.settings.json +test +.venv diff --git a/azure/api/__init__.py b/azure/api/__init__.py new file mode 100644 index 00000000..7f41b1de --- /dev/null +++ b/azure/api/__init__.py @@ -0,0 +1 @@ +from . import incoming_msg_handler diff --git a/azure/api/host.json b/azure/api/host.json new file mode 100644 index 00000000..aa694f24 --- /dev/null +++ b/azure/api/host.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[3.15.0, 4.0.0)" + }, + "extensions": { + "serviceBus": { + "messageHandlerOptions": { + "autoComplete": false + } + } + } +} diff --git a/azure/api/incoming_msg_handler/__init__.py b/azure/api/incoming_msg_handler/__init__.py new file mode 100644 index 00000000..e060888b --- /dev/null +++ b/azure/api/incoming_msg_handler/__init__.py @@ -0,0 +1,24 @@ +"""Azure DevOps API incoming message handler.""" +import os + +import azure.functions as func + +from gpt_review.repositories.devops import DevOpsFunction + +HANDLER = DevOpsFunction( + pat=os.environ["ADO_TOKEN"], + org=os.environ["ADO_ORG"], + project=os.environ["ADO_PROJECT"], + repository_id=os.environ["ADO_REPO"], +) + +os.putenv("RISK_SUMMARY", "false") +os.putenv("FILE_SUMMARY_FULL", "false") +os.putenv("TEST_SUMMARY", "false") +os.putenv("BUG_SUMMARY", "false") +os.putenv("SUMMARY_SUGGEST", "false") + + +def main(msg: func.ServiceBusMessage) -> None: + """Handle an incoming message.""" + HANDLER.handle(msg) diff --git a/azure/api/incoming_msg_handler/function.json b/azure/api/incoming_msg_handler/function.json new file mode 100644 index 00000000..9b312de1 --- /dev/null +++ b/azure/api/incoming_msg_handler/function.json @@ -0,0 +1,12 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "msg", + "type": "serviceBusTrigger", + "direction": "in", + "queueName": "ado-gpt-review", + "connection": "AzureServiceBusConnectionString" + } + ] +} diff --git a/azure/api/requirements.txt b/azure/api/requirements.txt new file mode 100644 index 00000000..918ea384 --- /dev/null +++ b/azure/api/requirements.txt @@ -0,0 +1,6 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +gpt-review>=0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 775d1d88..ae9d2c73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,9 @@ dependencies = [ 'azure-devops', 'azure-functions; python_version <= "3.10"', 'azure-identity', + 'azure-keyvault', 'azure-keyvault-secrets', - 'llama-index>=0.6.0,<=0.6.14', + 'llama-index>=0.6.0,<=0.6.9', 'httpx', 'GitPython', 'knack', @@ -146,7 +147,7 @@ executionEnvironments = [ ] [tool.pytest.ini_options] -addopts = "--cov-report xml:coverage.xml --cov src --cov-fail-under 0 --cov-append -n auto" +addopts = "--cov-report xml:coverage.xml --cov src --cov-fail-under 0 --cov-append" pythonpath = [ "src" ] @@ -398,4 +399,4 @@ target-version = "py311" [tool.ruff.mccabe] # Unlike Flake8, default to a complexity level of 10. -max-complexity = 10 +max-complexity = 10 \ No newline at end of file diff --git a/scripts/summarizations/_summarizations.py b/scripts/summarizations/_summarizations.py new file mode 100644 index 00000000..65c68323 --- /dev/null +++ b/scripts/summarizations/_summarizations.py @@ -0,0 +1,242 @@ +"""Summarize the changes in a release.""" +import csv +import time +import os +from dataclasses import dataclass + +import yaml + +from gpt_review.repositories.devops import DevOpsClient +from gpt_review.prompts._prompt_pr_summary import load_batch_pr_summary_yaml, load_nature_yaml +from gpt_review.prompts._prompt import load_summary_yaml +from gpt_review._review import _ask +import gpt_review.constants as C + +FILE_SUMMARY_NAME = ( + "/workspaces/gpt-review/scripts/summarizations/summaries/file_summary-" + + str(time.strftime("%b-%d-%Y %H:%M:%S")) + + ".txt" +) + + +@dataclass +class GitFile: + """A git file with its diff contents.""" + + file_name: str + diff: str + + +def _print_to_file(file_path: str, text: str) -> None: + """Print text to a file. + + Args: + file_path (str): The path to the file. + text (str): The text to print. + """ + with open(file_path, "a+", encoding="utf-8") as file: + file.write(text) + + +def _load_pull_request_ids(file_path: str) -> list: + """Load pull request ids from a csv file. + + Args: + file_path (str): The path to the csv file. + + Returns: + list: The list of pull request ids. + """ + pull_request_ids_list = [] + with open(file_path, "r", encoding="utf-8") as file: + csv_file = csv.reader(file) + for line in csv_file: + if line[0].isdigit(): + pull_request_ids_list.append(line[0]) + return pull_request_ids_list + + +def _summarize_file(diff) -> str: + """Summarize a file in a git diff. + + Args: + diff (str): The file to summarize. + + Returns: + str: The summary of the file. + """ + question = load_summary_yaml().format(diff=diff) + + response = _ask(question=[question], temperature=0.0) + return response["response"] + + +def _request_goal(git_diff, goal, fast: bool = False, large: bool = False, temperature: float = 0) -> str: + """ + Request a goal from GPT-4. + + Args: + git_diff (str): The git diff to split. + goal (str): The goal to request from GPT-4. + fast (bool, optional): Whether to use the fast model. Defaults to False. + large (bool, optional): Whether to use the large model. Defaults to False. + temperature (float, optional): The temperature to use. Defaults to 0. + + Returns: + response (str): The response from GPT-4. + """ + prompt = f""" +{goal} + +{git_diff} +""" + + return _ask([prompt], max_tokens=1500, fast=fast, large=large, temperature=temperature)["response"] + + +def _summarize_pr_diff(diff) -> str: + """Summarize a pull request diff. + + Args: + diff (str): The diff to summarize. + + Returns: + str: The summary. + """ + summary = "" + file_summary = "" + file_summary += "".join(_summarize_file(file_diff) for file_diff in diff) + summary += _request_goal(file_summary, goal="Summarize the changes to the files.") + + return summary + + +def get_pr_diff(patch_repo=None, patch_pr=None, pat=None) -> list: + """ + Get the diff of a PR. + + Args: + patch_repo (str): The pointer to ADO in the format, org/project/repo + patch_pr (str): The PR id. + pat (str): The ADO access token. + """ + + org = patch_repo.split("/")[0] + project = patch_repo.split("/")[1] + repo = patch_repo.split("/")[2] + diff = [] + + if patch_pr and pat: + client = DevOpsClient(pat=pat, org=org, project=project, repository_id=repo) + pull_request = client.client.get_pull_request_by_id(pull_request_id=patch_pr) + diff = client.get_patches(pull_request_event=pull_request) + + return diff + + return diff + + +def _summarize_pull_requests(pull_request_ids_list: list, patch_repo: str, pat: str) -> list: + """Summarize pull requests. + + Args: + pull_request_ids_list (list): The list of pull request ids. + patch_repo (str): The pointer to ADO in the format, org/project/repo. + + Returns: + list: The list of summaries. + """ + summaries_list = [] + for pr_id in pull_request_ids_list: + start = time.process_time() + diff = get_pr_diff(patch_repo, pr_id, pat) + if diff: + summary = _summarize_pr_diff(diff=diff) + print(time.process_time() - start) + summaries_list.append(summary) + summary_to_print = f"{pr_id}, {summary}\n" + _print_to_file(FILE_SUMMARY_NAME, summary_to_print) + return summaries_list + + +def _summarize_summary_batch(summary_batch: list) -> str: + """Summarize a list of summaries. + + Args: + summary (str): The summary to summarize. + + Returns: + str: The summary of the summary. + """ + + question = load_batch_pr_summary_yaml().format(summaries=summary_batch) + response = _ask(question=[question], temperature=0.0) + return response + + +def _summarize_summaries(summaries_list: list) -> list: + """Summarize summaries. + + Args: + summaries_list (list): The list of summaries. + + Returns: + str: The summary of summaries. + """ + + summarized_summaries_list = [] + for i in range(0, len(summaries_list), 10): + summary_batch = summaries_list[i : i + 10] + summarized_summaries_list.append(_summarize_summary_batch(summary_batch)) + return summarized_summaries_list + + +def _get_final_summary(summaries_list: list) -> str: + """Get the final summary. + + Args: + summarized_summaries_list (list): The list of the summaries of the PRs. + + Returns: + str: The final summary. + """ + + summarized_summaries = _summarize_summaries(summaries_list) + while len(summarized_summaries) > 1: + summarized_summaries = _summarize_summaries(summarized_summaries) + summaries_to_print = f"{len(summarized_summaries)}, {summarized_summaries}\n" + _print_to_file(FILE_SUMMARY_NAME, summaries_to_print) + if summarized_summaries: + return summarized_summaries[0]["response"] + return "No summaries were provided." + + +def _get_deployment_nature(summary) -> str: + """Get the nature of the deployment. + + Args: + summary (str): The summary of the PRs in a deployment. + + Returns: + str: The nature of the deployment. + """ + question = load_nature_yaml().format(summary=summary) + response = _ask(question=[question], temperature=0.0) + return response["response"] + + +def _load_config_file(): + """Import from yaml file and return the config.""" + config_file = "scripts/summarizations/" + os.getenv("CONFIG_FILE", C.ADO_CONFIG_FILE) + with open(config_file, "r", encoding="utf8") as file: + return yaml.load(file, Loader=yaml.SafeLoader) + + +config = _load_config_file() +access_token = os.getenv(config.get("ado_token")) +pull_request_ids = _load_pull_request_ids(config.get("pull_request_list")) +summaries = _summarize_pull_requests(pull_request_ids, config.get("patch_repo"), access_token) +final_summary = _get_final_summary(summaries) +_print_to_file(FILE_SUMMARY_NAME, "\nThe final summary is:\n" + final_summary) + +_print_to_file(FILE_SUMMARY_NAME, "\nThe nature of this deployment is: " + _get_deployment_nature(final_summary)) diff --git a/scripts/summarizations/ado.yaml.template b/scripts/summarizations/ado.yaml.template new file mode 100644 index 00000000..148a11e8 --- /dev/null +++ b/scripts/summarizations/ado.yaml.template @@ -0,0 +1,3 @@ +ado_token : ADO_TOKEN +pull_request_list : "path/to/pull/request/list/file" +patch_repo : "org/project/repo" \ No newline at end of file diff --git a/src/gpt_review/__init__.py b/src/gpt_review/__init__.py index 3a71f018..81b9dc64 100644 --- a/src/gpt_review/__init__.py +++ b/src/gpt_review/__init__.py @@ -5,4 +5,4 @@ """Easy GPT CLI""" from __future__ import annotations -__version__ = "0.9.5" +__version__ = "0.9.4" diff --git a/src/gpt_review/_gpt_cli.py b/src/gpt_review/_gpt_cli.py index 5d8e8a82..12bdabba 100644 --- a/src/gpt_review/_gpt_cli.py +++ b/src/gpt_review/_gpt_cli.py @@ -9,6 +9,7 @@ from gpt_review._ask import AskCommandGroup from gpt_review._git import GitCommandGroup from gpt_review._review import ReviewCommandGroup +from gpt_review.repositories.devops import DevOpsCommandGroup from gpt_review.repositories.github import GitHubCommandGroup CLI_NAME = "gpt" @@ -24,7 +25,7 @@ def get_cli_version(self) -> str: class GPTCommandsLoader(CLICommandsLoader): """The GPT CLI Commands Loader.""" - _CommandGroups = [AskCommandGroup, GitHubCommandGroup, GitCommandGroup, ReviewCommandGroup] + _CommandGroups = [AskCommandGroup, DevOpsCommandGroup, GitHubCommandGroup, GitCommandGroup, ReviewCommandGroup] def load_command_table(self, args) -> OrderedDict: for command_group in self._CommandGroups: diff --git a/src/gpt_review/constants.py b/src/gpt_review/constants.py index e1a87630..48cba889 100644 --- a/src/gpt_review/constants.py +++ b/src/gpt_review/constants.py @@ -34,6 +34,14 @@ AZURE_EMBEDDING_MODEL = "text-embedding-ada-002" AZURE_KEY_VAULT = "https://dciborow-openai.vault.azure.net/" +ASK_PROMPT_YAML = "prompt_ask.yaml" BUG_PROMPT_YAML = "prompt_bug.yaml" COVERAGE_PROMPT_YAML = "prompt_coverage.yaml" SUMMARY_PROMPT_YAML = "prompt_summary.yaml" + +PR_SUMMARY_PROMPT_YAML = "prompt_pr_summary.yaml" +PR_REVIEW_PROMPT_YAML = "prompt_pr_review.yaml" +PR_BATCH_SUMMARY_PROMPT_YAML = "prompt_pr_batch_summary.yaml" +PROMPT_NATURE_YAML = "prompt_nature.yaml" + +ADO_CONFIG_FILE = "ado.yaml" diff --git a/src/gpt_review/main.py b/src/gpt_review/main.py index e1fa07ba..ebec1182 100644 --- a/src/gpt_review/main.py +++ b/src/gpt_review/main.py @@ -14,6 +14,9 @@ def _help_text(help_type, short_summary) -> str: helps[""] = _help_text("group", "Easily interact with GPT APIs.") +helps["ado"] = _help_text("group", "Use GPT with Azure Devops Repositories.") +helps["ado review"] = _help_text("command", "Review Azure Devops PR with Open AI, and post response as a comment.") +helps["ado comment"] = _help_text("command", "Comment on Azure Devops PR with Open AI.") helps["ask"] = _help_text("group", "Use GPT to ask questions.") helps["git"] = _help_text("group", "Use GPT enchanced git commands.") helps["git commit"] = _help_text("command", "Run git commit with a commit message generated by GPT.") diff --git a/src/gpt_review/prompts/_prompt.py b/src/gpt_review/prompts/_prompt.py index 12fe4fae..c18f32d6 100644 --- a/src/gpt_review/prompts/_prompt.py +++ b/src/gpt_review/prompts/_prompt.py @@ -42,3 +42,9 @@ def load_summary_yaml() -> LangChainPrompt: """Load the summary yaml.""" yaml_path = os.getenv("PROMPT_SUMMARY", str(Path(__file__).parents[0].joinpath(C.SUMMARY_PROMPT_YAML))) return LangChainPrompt.load(yaml_path) + + +def load_ask_yaml() -> LangChainPrompt: + """Load the summary yaml.""" + yaml_path = os.getenv("PROMPT_ASK", str(Path(__file__).parents[0].joinpath(C.ASK_PROMPT_YAML))) + return LangChainPrompt.load(yaml_path) diff --git a/src/gpt_review/prompts/_prompt_pr_summary.py b/src/gpt_review/prompts/_prompt_pr_summary.py new file mode 100644 index 00000000..a93d78bb --- /dev/null +++ b/src/gpt_review/prompts/_prompt_pr_summary.py @@ -0,0 +1,31 @@ +"""Prompt for PR summarization.""" +import os +from pathlib import Path +from gpt_review.prompts._prompt import LangChainPrompt +import gpt_review.constants as C + + +def load_pr_summary_yaml() -> LangChainPrompt: + """Load the PR summary yaml.""" + yaml_path = os.getenv("PROMPT_PR_SUMMARY", str(Path(__file__).parents[0].joinpath(C.PR_SUMMARY_PROMPT_YAML))) + return LangChainPrompt.load(yaml_path) + + +def load_pr_review_yaml() -> LangChainPrompt: + """Load the PR review yaml.""" + yaml_path = os.getenv("PROMPT_PR_REVIEW", str(Path(__file__).parents[0].joinpath(C.PR_REVIEW_PROMPT_YAML))) + return LangChainPrompt.load(yaml_path) + + +def load_batch_pr_summary_yaml() -> LangChainPrompt: + """Load the PR summary yaml.""" + yaml_path = os.getenv( + "PROMPT_PR_BATCH_SUMMARY", str(Path(__file__).parents[0].joinpath(C.PR_BATCH_SUMMARY_PROMPT_YAML)) + ) + return LangChainPrompt.load(yaml_path) + + +def load_nature_yaml() -> LangChainPrompt: + """Load the nature yaml.""" + yaml_path = os.getenv("PROMPT_NATURE", str(Path(__file__).parents[0].joinpath(C.PROMPT_NATURE_YAML))) + return LangChainPrompt.load(yaml_path) diff --git a/src/gpt_review/prompts/prompt_ask.yaml b/src/gpt_review/prompts/prompt_ask.yaml new file mode 100644 index 00000000..7697e670 --- /dev/null +++ b/src/gpt_review/prompts/prompt_ask.yaml @@ -0,0 +1,7 @@ +_type: prompt +input_variables: + ["diff", "ask"] +template: | + {diff} + + {ask} diff --git a/src/gpt_review/prompts/prompt_pr_summary.yaml b/src/gpt_review/prompts/prompt_pr_summary.yaml new file mode 100644 index 00000000..8bc79754 --- /dev/null +++ b/src/gpt_review/prompts/prompt_pr_summary.yaml @@ -0,0 +1,9 @@ +_type: prompt +input_variables: + ["diff"] +template: | + Summarize the following list of file diffs, + focusing on major modifications, additions, deletions, and any significant updates within the files. + Do not include the file name in the summary and list the summary with bullet points. + + {diff} diff --git a/src/gpt_review/repositories/devops.py b/src/gpt_review/repositories/devops.py new file mode 100644 index 00000000..8fa1daf1 --- /dev/null +++ b/src/gpt_review/repositories/devops.py @@ -0,0 +1,688 @@ +"""Azure DevOps Package Wrappers to Simplify Usage.""" +import abc +import itertools +import json +import logging +import os +import urllib.parse +from typing import Dict, Iterable, List, Tuple +from urllib.parse import urlparse + +from knack import CLICommandsLoader +from knack.arguments import ArgumentsContext +from knack.commands import CommandGroup +from msrest.authentication import BasicAuthentication + +from azure.devops.connection import Connection +from azure.devops.exceptions import AzureDevOpsServiceError +from azure.devops.v7_1.git.git_client import GitClient +from azure.devops.v7_1.git.models import ( + Comment, + GitBaseVersionDescriptor, + GitPullRequest, + GitCommitRef, + GitTargetVersionDescriptor, + GitVersionDescriptor, + GitPullRequestCommentThread, +) + +from gpt_review._ask import _ask +from gpt_review._command import GPTCommandGroup +from gpt_review._review import _summarize_files +from gpt_review.prompts._prompt import load_ask_yaml +from gpt_review.repositories._repository import _RepositoryClient +import gpt_review.repositories.devops_constants as C + + +class _DevOpsClient(_RepositoryClient, abc.ABC): + """Azure DevOps API Client Wrapper.""" + + def __init__(self, pat, org, project, repository_id) -> None: + """ + Initialize the client. + + Args: + pat (str): The Azure DevOps personal access token. + org (str): The Azure DevOps organization. + project (str): The Azure DevOps project. + repository_id (str): The Azure DevOps repository ID. + """ + self.pat = pat + self.org = org + self.project = project + self.repository_id = repository_id + + personal_access_token = pat + organization_url = f"https://dev.azure.com/{org}" + + # Create a connection to the org + credentials = BasicAuthentication("", personal_access_token) + self.connection = Connection(base_url=organization_url, creds=credentials) + + # Get a client (the "core" client provides access to projects, teams, etc) + self.client: GitClient = self.connection.clients_v7_1.get_git_client() + self.project = project + self.repository_id = repository_id + + def create_comment(self, pull_request_id: int, comment_id: int, text: str, **kwargs) -> Comment: + """ + Create a comment on a pull request. + + Args: + token (str): The Azure DevOps token. + org (str): The Azure DevOps organization. + project (str): The Azure DevOps project. + repository_id (str): The Azure DevOps repository ID. + pull_request_id (int): The Azure DevOps pull request ID. + comment_id (int): The Azure DevOps comment ID. + text (str): The text of the comment. + **kwargs: Any additional keyword arguments. + + Returns: + Comment: The response from the API. + """ + return self.client.create_comment( + comment=Comment(content=text), + repository_id=self.repository_id, + pull_request_id=pull_request_id, + thread_id=comment_id, + project=self.project, + **kwargs, + ) + + def update_pr(self, pull_request_id, title=None, description=None, **kwargs) -> GitPullRequest: + """ + Update a pull request. + + Args: + pull_request_id (int): The Azure DevOps pull request ID. + title (str): The title of the pull request. + description (str): The description of the pull request. + **kwargs: Any additional keyword arguments. + + Returns: + GitPullRequest: The response from the API. + """ + return self.client.update_pull_request( + git_pull_request_to_update=GitPullRequest(title=title, description=description), + repository_id=self.repository_id, + project=self.project, + pull_request_id=pull_request_id, + **kwargs, + ) + + def read_all_text( + self, + path: str, + commit_id: str = None, + check_if_exists=True, + **kwargs, + ) -> str: + """ + Read all text from a file. + + Args: + path (str): The path to the file. + commit_id (str): The commit ID. + check_if_exists (bool): Whether to check if the file exists. + **kwargs: Any additional keyword arguments. + + Returns: + str: The text of the file. + """ + try: + byte_iterator = self.client.get_item_content( + repository_id=self.repository_id, + path=path, + project=self.project, + version_descriptor=GitVersionDescriptor(commit_id, version_type="commit") if commit_id else None, + check_if_exists=check_if_exists, + **kwargs, + ) + return "".join(byte.decode("utf-8") for byte in byte_iterator).splitlines() + except AzureDevOpsServiceError: + # File Not Found + return "" + + @staticmethod + def process_comment_payload(payload: str) -> str: + """ + Extract question from Service Bus payload. + + Args: + payload (str): The Service Bus payload. + + Returns: + str: The question from the Azure DevOps Comment. + """ + return json.loads(payload)["resource"]["comment"]["content"] + + def get_patch(self, pull_request_event, pull_request_id, comment_id) -> List[str]: + """ + Get the diff of a pull request. + + Args: + pull_request_event (dict): The pull request event. + pull_request_id (str): The Azure DevOps pull request ID. + comment_id (str): The Azure DevOps comment ID. + + Returns: + List[str]: The diff of the pull request. + """ + thread_context = self.client.get_pull_request_thread( + repository_id=self.repository_id, + pull_request_id=pull_request_id, + thread_id=comment_id, + project=self.project, + ).thread_context + + commit_id = pull_request_event["pullRequest"]["lastMergeSourceCommit"]["commitId"] + left, right = self._calculate_selection(thread_context, commit_id) + + return self._create_patch(left, right, thread_context.file_path) + + def _create_patch(self, left, right, file_path) -> List: + """ + Create a patch. + + Args: + left (List[str]): The left side of the diff. + right (List[str]): The right side of the diff. + file_path (str): The file path. + + Returns: + List: The patch. + """ + + changes = [[0] * (len(right) + 1) for _ in range(len(left) + 1)] + + for i, j in itertools.product(range(len(left)), range(len(right))): + changes[i + 1][j + 1] = ( + changes[i][j] if left[i] == right[j] else 1 + min(changes[i][j + 1], changes[i + 1][j], changes[i][j]) + ) + + patch = [] + line, row = len(left), len(right) + while line > 0 and row > 0: + if changes[line][row] <= changes[line - 1][row] and changes[line][row] <= changes[line][row - 1]: + if left[line - 1] != right[row - 1]: + patch.append(f"+ {right[row - 1]}") + patch.append(f"- {left[line - 1]}") + line -= 1 + row -= 1 + elif changes[line - 1][row] < changes[line][row - 1]: + patch.append(f"- {left[line - 1]}") + line -= 1 + else: + patch.append(f"+ {right[row - 1]}") + row -= 1 + + patch.extend(f"- {left[i - 1]}" for i in range(0, line)) + patch.extend(f"+ {right[j - 1]}" for j in range(0, row)) + patch.append(file_path) + patch.reverse() + + return patch + + def _calculate_selection(self, thread: GitPullRequestCommentThread, commit_id: str) -> Tuple[str, str]: + """ + Calculate the selection for a given thread context. + + Args: + thread (GitPullRequestCommentThread): The thread context. + commit_id (str): The commit ID. + + Returns: + Tuple[str, str]: The left and right selection. + """ + + original_content, changed_content = self._load_content(file_path=thread.file_path, commit_id_changed=commit_id) + + def get_selection(lines: str, line_start: int, line_end: int) -> str: + return lines[line_start - 1 : line_end] if line_end - line_start >= C.MIN_CONTEXT_LINES else lines + + left_selection = ( + get_selection(original_content, thread.left_file_start.line, thread.left_file_end.line) + if thread.left_file_start and thread.left_file_end + else [] + ) + + right_selection = ( + get_selection(changed_content, thread.right_file_start.line, thread.right_file_end.line) + if thread.right_file_start and thread.right_file_end + else [] + ) + + return left_selection, right_selection + + def create_git_commit_ref_from_dict(self, commit_dict: Dict) -> GitCommitRef: + """Create a GitCommitRef object from a dictionary. + + Args: + commit_dict (Dict): The dictionary to create the GitCommitRef object from. + + Returns: + GitCommitRef: The GitCommitRef object. + """ + return GitCommitRef(commit_id=commit_dict["commitId"], url=commit_dict["url"]) + + def create_git_pull_request_from_dict(self, pr_dict: Dict) -> GitPullRequest: + """Create a GitPullRequest object from a dictionary. + + Args: + pr_dict (Dict): The dictionary to create the GitPullRequest object from. + + Returns: + GitPullRequest: The GitPullRequest object. + """ + pull_request = GitPullRequest( + title=pr_dict["title"], + description=pr_dict["description"], + source_ref_name=pr_dict["sourceRefName"], + target_ref_name=pr_dict["targetRefName"], + is_draft=pr_dict["isDraft"], + reviewers=pr_dict["reviewers"], + supports_iterations=pr_dict["supportsIterations"], + artifact_id=pr_dict["artifactId"], + status=pr_dict["status"], + created_by=pr_dict["createdBy"], + creation_date=pr_dict["creationDate"], + last_merge_source_commit=pr_dict["lastMergeSourceCommit"], + last_merge_target_commit=pr_dict["lastMergeTargetCommit"], + last_merge_commit=pr_dict["lastMergeCommit"], + url=pr_dict["url"], + repository=pr_dict["repository"], + merge_id=pr_dict["mergeId"], + ) + pull_request.last_merge_source_commit = self.create_git_commit_ref_from_dict( + pull_request.last_merge_source_commit + ) + pull_request.last_merge_target_commit = self.create_git_commit_ref_from_dict( + pull_request.last_merge_target_commit + ) + pull_request.last_merge_commit = self.create_git_commit_ref_from_dict(pull_request.last_merge_commit) + return pull_request + + def get_patches(self, pull_request_event) -> Iterable[List[str]]: + """ + Get the patches for a given pull request event. + + Args: + pull_request_event (Any): The pull request event to retrieve patches for. + + Returns: + Iterable[List[str]]: An iterable of lists containing the patches for the pull request event. + """ + + if isinstance(pull_request_event, dict): + pull_request = self.create_git_pull_request_from_dict(pull_request_event["pullRequest"]) + else: + pull_request = pull_request_event + + git_changes = self._get_changed_blobs(pull_request) + return [self._get_change(git_change, pull_request.last_merge_commit.commit_id) for git_change in git_changes] + + def _get_changed_blobs(self, pull_request: GitPullRequest) -> List[str]: + """ + Get the changed blobs in a pull request. + + Args: + pull_request (GitPullRequest): The pull request. + + Returns: + List[Dict[str, str]]: The changed blobs. + """ + changed_paths = [] + pr_commits = None + + skip = 0 + while pull_request.status != C.PR_TYPE_ABANDONED: + pr_commits = self.client.get_commit_diffs( + repository_id=self.repository_id, + project=self.project, + diff_common_commit=False, + base_version_descriptor=GitBaseVersionDescriptor( + base_version=pull_request.last_merge_commit.commit_id, base_version_type="commit" + ), + target_version_descriptor=GitTargetVersionDescriptor( + target_version=pull_request.last_merge_target_commit.commit_id, target_version_type="commit" + ), + ) + changed_paths.extend([change for change in pr_commits.changes if "isFolder" not in change["item"]]) + skip += len(pr_commits.changes) + if pr_commits.all_changes_included: + break + + return changed_paths + + def _get_change(self, git_change, commit_id) -> List[str]: + file_path = git_change["item"]["path"] + + original_content, changed_content = self._load_content( + file_path, commit_id_original=git_change["item"]["commitId"], commit_id_changed=commit_id + ) + + patch = self._create_patch(original_content, changed_content, file_path) + return "\n".join(patch) + + def _load_content( + self, + file_path, + commit_id_original: str = None, + commit_id_changed: str = None, + ): + return self.read_all_text(file_path, commit_id=commit_id_original), self.read_all_text( + file_path, commit_id=commit_id_changed + ) + + +class DevOpsClient(_DevOpsClient): + """Azure DevOps client Wrapper for working with.""" + + @staticmethod + def post_pr_summary(diff, link=None, access_token=None) -> Dict[str, str]: + """ + Get a review of a PR. + + Requires the following environment variables: + - LINK: The link to the PR. + Example: https://.visualstudio.com//_git//pullrequest/ + or https://dev.azure.com///_git//pullrequest/ + - ADO_TOKEN: The GitHub access token. + + Args: + diff (str): The patch of the PR. + link (str, optional): The link to the PR. Defaults to None. + access_token (str, optional): The GitHub access token. Defaults to None. + + Returns: + Dict[str, str]: The review. + """ + link = os.getenv("LINK", link) + access_token = os.getenv("ADO_TOKEN", access_token) + + if link and access_token: + review = _summarize_files(diff) + + org, project, repo, pr_id = DevOpsClient._parse_url(link) + + DevOpsClient(pat=access_token, org=org, project=project, repository_id=repo).update_pr( + pull_request_id=pr_id, + description=review, + ) + return {"response": "PR posted"} + + logging.warning("No PR to post too") + return {"response": "No PR to post too"} + + @staticmethod + def _parse_url(link): + parsed_url = urlparse(link) + + if "dev.azure.com" in parsed_url.netloc: + org = link.split("/")[3] + project = link.split("/")[4] + repo = link.split("/")[6] + pr_id = link.split("/")[8] + else: + org = link.split("/")[2].split(".")[0] + project = link.split("/")[3] + repo = link.split("/")[5] + pr_id = link.split("/")[7] + return org, project, repo, pr_id + + @staticmethod + def get_pr_diff(patch_repo=None, patch_pr=None, access_token=None) -> str: + """ + Get the diff of a PR. + + Args: + patch_repo (str): The pointer to ADO in the format, org/project/repo + patch_pr (str): The PR id. + access_token (str): The ADO access token. + """ + + link = urllib.parse.unquote( + os.getenv( + "LINK", + f"https://{patch_repo.split('/')[0]}.visualstudio.com/{patch_repo.split('/')[1]}/_git/{patch_repo.split('/')[2]}/pullrequest/{patch_pr}", + ) + ) + + access_token = os.getenv("ADO_TOKEN", access_token) + + if link and access_token: + org, project, repo, pr_id = DevOpsClient._parse_url(link) + + client = DevOpsClient(pat=access_token, org=org, project=project, repository_id=repo) + pull_request = client.client.get_pull_request_by_id(pull_request_id=pr_id) + diff = client.get_patches(pull_request_event=pull_request) + diff = "\n".join(diff) + + return {"response": "PR posted"} + + logging.warning("No PR to post too") + return {"response": "No PR to post too"} + + +class DevOpsFunction(DevOpsClient): + """Azure Function for process Service Messages from Azure DevOps.""" + + def handle(self, msg) -> None: + """ + The main function for the Azure Function. + + Args: + msg (func.QueueMessage): The Service Bus message. + """ + body = msg.get_body().decode("utf-8") + logging.debug("Python ServiceBus queue trigger processed message: %s", body) + if "copilot:summary" in body: + self._process_summary(body) + elif "copilot:" in body: + self._process_comment(body) + + def _process_comment(self, body) -> None: + """ + Process a comment from Copilot. + + Args: + body (str): The Service Bus payload. + """ + logging.debug("Copilot Comment Alert Triggered") + payload = json.loads(body) + + pr_id = self._get_pr_id(payload) + comment_id = self._get_comment_id(payload) + + try: + diff = self.get_patch(pull_request_event=payload["resource"], pull_request_id=pr_id, comment_id=comment_id) + except Exception: + diff = self.get_patches(pull_request_event=payload["resource"]) + + logging.debug("Copilot diff: %s", diff) + diff = "\n".join(diff) + + question = load_ask_yaml().format(diff=diff, ask=_DevOpsClient.process_comment_payload(body)) + + response = _ask(question=question, max_tokens=1000) + + self.create_comment(pull_request_id=pr_id, comment_id=comment_id, text=response["response"]) + + def _get_comment_id(self, payload) -> int: + """ + Get the comment ID from the payload. + + Args: + payload (dict): The payload from the Service Bus. + + Returns: + int: The comment ID. + """ + comment_id = payload["resource"]["comment"]["_links"]["threads"]["href"].split("/")[-1] + logging.debug("Copilot Commet ID: %s", comment_id) + return comment_id + + def _process_summary(self, body) -> None: + """ + Process a summary from Copilot. + + Args: + body (str): The Service Bus payload. + """ + logging.debug("Copilot Summary Alert Triggered") + payload = json.loads(body) + + pr_id = self._get_pr_id(payload) + link = self._get_link(pr_id) + + if "comment" in payload["resource"]: + self._post_summary(payload, pr_id, link) + + def _get_link(self, pr_id) -> str: + link = f"https://{self.org}.visualstudio.com/{self.project}/_git/{self.repository_id}/pullrequest/{pr_id}" + logging.debug("Copilot Link: %s", link) + return link + + def _get_pr_id(self, payload) -> int: + """ + Get the pull request ID from the Service Bus payload. + + Args: + payload (dict): The Service Bus payload. + + Returns: + int: The pull request ID. + """ + if "pullRequestId" in payload: + pr_id = payload["resource"]["pullRequestId"] + else: + pr_id = payload["resource"]["pullRequest"]["pullRequestId"] + logging.debug("Copilot PR ID: %s", pr_id) + return pr_id + + def _post_summary(self, payload, pr_id, link) -> None: + """ + Process a summary from Copilot. + + Args: + payload (dict): The Service Bus payload. + pr_id (str): The Azure DevOps pull request ID. + link (str): The link to the PR. + """ + comment_id = payload["resource"]["comment"]["_links"]["threads"]["href"].split("/")[-1] + logging.debug("Copilot Commet ID: %s", comment_id) + + diff = self.get_patch(pull_request_event=payload["resource"], pull_request_id=pr_id, comment_id=comment_id) + diff = "\n".join(diff) + logging.debug("Copilot diff: %s", diff) + + self.post_pr_summary(diff, link=link) + + +def _review(repository=None, pull_request=None, diff: str = ".diff", link=None, access_token=None) -> Dict[str, str]: + """Review Azure DevOps PR with Open AI, and post response as a comment. + + Args: + link (str): The link to the PR. + access_token (str): The Azure DevOps access token. + + Returns: + Dict[str, str]: The response. + """ + if repository and pull_request: + diff_contents = DevOpsClient.get_pr_diff(repository, pull_request, access_token) + else: + with open(diff, "r", encoding="utf8") as file: + diff_contents = file.read() + + return DevOpsClient.post_pr_summary(diff_contents, link, access_token) + + +def _comment(question: str, comment_id: int, diff: str = ".diff", link=None, access_token=None) -> Dict[str, str]: + """Review Azure DevOps PR with Open AI, and post response as a comment. + + Args: + question (str): The question to ask. + comment_id (int): The comment ID. + diff(str): The diff file. + link (str): The link to the PR. + access_token (str): The Azure DevOps access token. + + Returns: + Dict[str, str]: The response. + """ + # diff = _DevOpsClient.get_pr_diff(repository, pull_request, access_token) + + if os.path.exists(diff): + with open(diff, "r", encoding="utf8") as file: + diff_contents = file.read() + question = f"{diff_contents}\n{question}" + + link = os.getenv("LINK", link) + access_token = os.getenv("ADO_TOKEN", access_token) + + if link and access_token: + response = _ask( + question=question, + ) + parsed_url = urlparse(link) + + if "dev.azure.com" in parsed_url.netloc: + org = link.split("/")[3] + project = link.split("/")[4] + repo = link.split("/")[6] + pr_id = link.split("/")[8] + else: + org = link.split("/")[2].split(".")[0] + project = link.split("/")[3] + repo = link.split("/")[5] + pr_id = link.split("/")[7] + + DevOpsClient(pat=access_token, org=org, project=project, repository_id=repo).create_comment( + pull_request_id=pr_id, comment_id=comment_id, text=response["response"] + ) + return {"response": "Review posted as a comment.", "text": response["response"]} + raise ValueError("LINK and ADO_TOKEN must be set.") + + +class DevOpsCommandGroup(GPTCommandGroup): + """Ask Command Group.""" + + @staticmethod + def load_command_table(loader: CLICommandsLoader) -> None: + with CommandGroup(loader, "ado", "gpt_review.repositories.devops#{}", is_preview=True) as group: + group.command("review", "_review", is_preview=True) + group.command("comment", "_comment", is_preview=True) + + @staticmethod + def load_arguments(loader: CLICommandsLoader) -> None: + """Add patch_repo, patch_pr, and access_token arguments.""" + with ArgumentsContext(loader, "ado") as args: + args.argument( + "diff", + type=str, + help="Git diff to review.", + default=".diff", + ) + args.argument( + "access_token", + type=str, + help="The Azure DevOps access token, or set ADO_TOKEN", + default=None, + ) + args.argument( + "link", + type=str, + help="The link to the PR.", + default=None, + ) + + with ArgumentsContext(loader, "ado comment") as args: + args.positional("question", type=str, nargs="+", help="Provide a question to ask GPT.") + args.argument( + "comment_id", + type=int, + help="The comment ID of Azure DevOps Pull Request Comment.", + default=None, + ) diff --git a/src/gpt_review/repositories/devops_constants.py b/src/gpt_review/repositories/devops_constants.py new file mode 100644 index 00000000..fd526993 --- /dev/null +++ b/src/gpt_review/repositories/devops_constants.py @@ -0,0 +1,3 @@ +""" Constants for the devops functionality. """ +MIN_CONTEXT_LINES = 5 +PR_TYPE_ABANDONED = "abandoned" diff --git a/tests/test_devops.py b/tests/test_devops.py new file mode 100644 index 00000000..a76edb3c --- /dev/null +++ b/tests/test_devops.py @@ -0,0 +1,438 @@ +import os +from dataclasses import dataclass + +import pytest +import requests_mock +from azure.devops.v7_1.git.models import ( + Comment, + CommentThreadContext, + GitBaseVersionDescriptor, + GitCommitDiffs, + GitPullRequest, + GitPullRequestCommentThread, + GitTargetVersionDescriptor, +) + +from gpt_review.repositories.devops import DevOpsClient, DevOpsFunction, _comment + +# Azure Devops PAT requires +# - Code: 'Read','Write' +# - Pull Request Threads: 'Read & Write' +TOKEN = os.getenv("ADO_TOKEN", "token1") + +ORG = os.getenv("ADO_ORG", "msazure") +PROJECT = os.getenv("ADO_PROJECT", "one") +REPO = os.getenv("ADO_REPO", "azure-gaming") +PR_ID = int(os.getenv("ADO_PR_ID", 8063875)) +COMMENT_ID = int(os.getenv("ADO_COMMENT_ID", 141344325)) + +SOURCE = os.getenv("ADO_COMMIT_SOURCE", "36f9a015ee220516f5f553faaa1898ab10972536") +TARGET = os.getenv("ADO_COMMIT_TARGET", "ecea1ea7db038317e94b45e090781410dc519b85") + +SAMPLE_PAYLOAD = """{ + "resource": { + "comment": { + "content": "copilot: summary of this changed code" + } + } +} +""" + +LONG_PAYLOAD = { + "id": "e89fa09c-f412-4167-a2cd-f6a5bb8aef56", + "eventType": "ms.vss-code.git-pullrequest-comment-event", + "publisherId": "tfs", + "message": {"text": "Daniel Ciborowski has replied to a pull request comment"}, + "detailedMessage": { + "text": 'Daniel Ciborowski has replied to a pull request comment\r\n```suggestion\n inlineScript: | \n echo "##[section] Summarize Pull Request with Open AI"\n\n echo "##[command]python3 -m pip install --upgrade pip"\n python3 -m pip install --upgrade pip --quiet\n```\nhow could i update this code?\r\n' + }, + "resource": { + "comment": { + "id": 2, + "parentCommentId": 1, + "author": { + "displayName": "Daniel Ciborowski", + "url": "https://spsprodwus23.vssps.visualstudio.com/A41b4f3ee-c651-4a14-9847-b7cbb5315b80/_apis/Identities/0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "_links": { + "avatar": { + "href": "https://msazure.visualstudio.com/_apis/GraphProfile/MemberAvatars/aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4" + } + }, + "id": "0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "uniqueName": "dciborow@microsoft.com", + "imageUrl": "https://msazure.visualstudio.com/_apis/GraphProfile/MemberAvatars/aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4", + "descriptor": "aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4", + }, + "content": '```suggestion\n inlineScript: | \n echo "##[section] Summarize Pull Request with Open AI"\n\n echo "##[command]python3 -m pip install --upgrade pip"\n python3 -m pip install --upgrade pip --quiet\n```\nhow could i update this code?', + "publishedDate": "2023-05-13T00:30:56.68Z", + "lastUpdatedDate": "2023-05-13T00:30:56.68Z", + "lastContentUpdatedDate": "2023-05-13T00:30:56.68Z", + "commentType": "text", + "usersLiked": [], + "_links": { + "self": { + "href": "https://msazure.visualstudio.com/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/pullRequests/8063875/threads/141415813/comments/2" + }, + "repository": { + "href": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3" + }, + "threads": { + "href": "https://msazure.visualstudio.com/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/pullRequests/8063875/threads/141415813" + }, + "pullRequests": {"href": "https://msazure.visualstudio.com/_apis/git/pullRequests/8063875"}, + }, + }, + "pullRequest": { + "repository": { + "id": "612d9367-8ab6-4929-abe6-b5b5ad7b5ad3", + "name": "Azure-Gaming", + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3", + "project": { + "id": "b32aa71e-8ed2-41b2-9d77-5bc261222004", + "name": "One", + "description": "MSAzure/One is the VSTS project containing all Azure team code bases and work items.\nPlease see https://aka.ms/azaccess for work item and source access policies.", + "url": "https://msazure.visualstudio.com/_apis/projects/b32aa71e-8ed2-41b2-9d77-5bc261222004", + "state": "wellFormed", + "revision": 307061, + "visibility": "organization", + "lastUpdateTime": "2023-05-12T17:40:59.963Z", + }, + "size": 508859977, + "remoteUrl": "https://msazure.visualstudio.com/DefaultCollection/One/_git/Azure-Gaming", + "sshUrl": "msazure@vs-ssh.visualstudio.com:v3/msazure/One/Azure-Gaming", + "webUrl": "https://msazure.visualstudio.com/DefaultCollection/One/_git/Azure-Gaming", + "isDisabled": False, + "isInMaintenance": False, + }, + "pullRequestId": 8063875, + "codeReviewId": 8836473, + "status": "active", + "createdBy": { + "displayName": "Daniel Ciborowski", + "url": "https://spsprodwus23.vssps.visualstudio.com/A41b4f3ee-c651-4a14-9847-b7cbb5315b80/_apis/Identities/0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "_links": { + "avatar": { + "href": "https://msazure.visualstudio.com/_apis/GraphProfile/MemberAvatars/aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4" + } + }, + "id": "0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "uniqueName": "dciborow@microsoft.com", + "imageUrl": "https://msazure.visualstudio.com/_api/_common/identityImage?id=0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "descriptor": "aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4", + }, + "creationDate": "2023-05-05T03:11:26.8599393Z", + "title": "Sample PR Title", + "description": "description1", + "sourceRefName": "refs/heads/dciborow/update-pr", + "targetRefName": "refs/heads/main", + "mergeStatus": "succeeded", + "isDraft": False, + "mergeId": "0e7397c6-5f11-402c-a5c6-c5a12b105350", + "lastMergeSourceCommit": { + "commitId": "ecea1ea7db038317e94b45e090781410dc519b85", + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/commits/ecea1ea7db038317e94b45e090781410dc519b85", + }, + "lastMergeTargetCommit": { + "commitId": "36f9a015ee220516f5f553faaa1898ab10972536", + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/commits/36f9a015ee220516f5f553faaa1898ab10972536", + }, + "lastMergeCommit": { + "commitId": "d5fc735b618647a78a0aff006445b67bfe4e8185", + "author": { + "name": "Daniel Ciborowski", + "email": "dciborow@microsoft.com", + "date": "2023-05-05T14:23:49Z", + }, + "committer": { + "name": "Daniel Ciborowski", + "email": "dciborow@microsoft.com", + "date": "2023-05-05T14:23:49Z", + }, + "comment": "Merge pull request 8063875 from dciborow/update-pr into main", + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/commits/d5fc735b618647a78a0aff006445b67bfe4e8185", + }, + "reviewers": [], + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/pullRequests/8063875", + "supportsIterations": True, + "artifactId": "vstfs:///Git/PullRequestId/b32aa71e-8ed2-41b2-9d77-5bc261222004%2f612d9367-8ab6-4929-abe6-b5b5ad7b5ad3%2f8063875", + }, + }, + "resourceVersion": "2.0", + "resourceContainers": { + "collection": {"id": "41bf5486-7392-4b7a-a7e3-a735c767e3b3", "baseUrl": "https://msazure.visualstudio.com/"}, + "account": {"id": "41b4f3ee-c651-4a14-9847-b7cbb5315b80", "baseUrl": "https://msazure.visualstudio.com/"}, + "project": {"id": "b32aa71e-8ed2-41b2-9d77-5bc261222004", "baseUrl": "https://msazure.visualstudio.com/"}, + }, + "createdDate": "2023-05-13T00:31:02.6421816Z", +} + +PR_COMMENT_PAYLOAD = { + "id": "851991af-ce4b-4463-83d4-eb4733559f14", + "eventType": "ms.vss-code.git-pullrequest-comment-event", + "publisherId": "tfs", + "message": {"text": "Daniel Ciborowski has replied to a pull request comment"}, + "detailedMessage": {"text": "Daniel Ciborowski has replied to a pull request comment\r\ncopilot: test\r\n"}, + "resource": { + "comment": { + "id": 5, + "parentCommentId": 1, + "author": { + "displayName": "Daniel Ciborowski", + "url": "https://spsprodwus23.vssps.visualstudio.com/A41b4f3ee-c651-4a14-9847-b7cbb5315b80/_apis/Identities/0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "_links": { + "avatar": { + "href": "https://msazure.visualstudio.com/_apis/GraphProfile/MemberAvatars/aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4" + } + }, + "id": "0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "uniqueName": "dciborow@microsoft.com", + "imageUrl": "https://msazure.visualstudio.com/_apis/GraphProfile/MemberAvatars/aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4", + "descriptor": "aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4", + }, + "content": "copilot: test", + "publishedDate": "2023-05-16T01:22:28.67Z", + "lastUpdatedDate": "2023-05-16T01:22:28.67Z", + "lastContentUpdatedDate": "2023-05-16T01:22:28.67Z", + "commentType": "text", + "usersLiked": [], + "_links": { + "self": { + "href": "https://msazure.visualstudio.com/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/pullRequests/8111242/threads/141607999/comments/5" + }, + "repository": { + "href": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3" + }, + "threads": { + "href": "https://msazure.visualstudio.com/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/pullRequests/8111242/threads/141607999" + }, + "pullRequests": {"href": "https://msazure.visualstudio.com/_apis/git/pullRequests/8111242"}, + }, + }, + "pullRequest": { + "repository": { + "id": "612d9367-8ab6-4929-abe6-b5b5ad7b5ad3", + "name": "Azure-Gaming", + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3", + "project": { + "id": "b32aa71e-8ed2-41b2-9d77-5bc261222004", + "name": "One", + "description": "MSAzure/One is the VSTS project containing all Azure team code bases and work items.\nPlease see https://aka.ms/azaccess for work item and source access policies.", + "url": "https://msazure.visualstudio.com/_apis/projects/b32aa71e-8ed2-41b2-9d77-5bc261222004", + "state": "wellFormed", + "revision": 307071, + "visibility": "organization", + "lastUpdateTime": "2023-05-15T17:47:30.807Z", + }, + "size": 508859977, + "remoteUrl": "https://msazure.visualstudio.com/DefaultCollection/One/_git/Azure-Gaming", + "sshUrl": "msazure@vs-ssh.visualstudio.com:v3/msazure/One/Azure-Gaming", + "webUrl": "https://msazure.visualstudio.com/DefaultCollection/One/_git/Azure-Gaming", + "isDisabled": False, + "isInMaintenance": False, + }, + "pullRequestId": 8111242, + "codeReviewId": 8886256, + "status": "active", + "createdBy": { + "displayName": "Daniel Ciborowski", + "url": "https://spsprodwus23.vssps.visualstudio.com/A41b4f3ee-c651-4a14-9847-b7cbb5315b80/_apis/Identities/0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "_links": { + "avatar": { + "href": "https://msazure.visualstudio.com/_apis/GraphProfile/MemberAvatars/aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4" + } + }, + "id": "0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "uniqueName": "dciborow@microsoft.com", + "imageUrl": "https://msazure.visualstudio.com/_api/_common/identityImage?id=0ef5b3af-3e01-48fd-9bd3-2f701c8fdebe", + "descriptor": "aad.OTgwYzcxNzEtMDI2Ni03YzVmLTk0YzEtMDNlYzU2YjViYjY4", + }, + "creationDate": "2023-05-15T03:32:53.2319611Z", + "title": "Added __init__.py", + "description": "Added __init__.py", + "sourceRefName": "refs/heads/dciborow/python-sample", + "targetRefName": "refs/heads/main", + "mergeStatus": "succeeded", + "isDraft": False, + "mergeId": "762c15e2-0877-45d3-bec1-4257f94438b1", + "lastMergeSourceCommit": { + "commitId": "b7017e51b312116557fa2769a4a8e5310c9d51f4", + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/commits/b7017e51b312116557fa2769a4a8e5310c9d51f4", + }, + "lastMergeTargetCommit": { + "commitId": "36f9a015ee220516f5f553faaa1898ab10972536", + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/commits/36f9a015ee220516f5f553faaa1898ab10972536", + }, + "lastMergeCommit": { + "commitId": "84a8d5cc827b85271dda7f865c8516ddcc2ba941", + "author": { + "name": "Daniel Ciborowski", + "email": "dciborow@microsoft.com", + "date": "2023-05-15T03:54:44Z", + }, + "committer": { + "name": "Daniel Ciborowski", + "email": "dciborow@microsoft.com", + "date": "2023-05-15T03:54:44Z", + }, + "comment": "Merge pull request 8111242 from dciborow/python-sample into main", + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/commits/84a8d5cc827b85271dda7f865c8516ddcc2ba941", + }, + "reviewers": [], + "url": "https://msazure.visualstudio.com/b32aa71e-8ed2-41b2-9d77-5bc261222004/_apis/git/repositories/612d9367-8ab6-4929-abe6-b5b5ad7b5ad3/pullRequests/8111242", + "supportsIterations": True, + "artifactId": "vstfs:///Git/PullRequestId/b32aa71e-8ed2-41b2-9d77-5bc261222004%2f612d9367-8ab6-4929-abe6-b5b5ad7b5ad3%2f8111242", + }, + }, + "resourceVersion": "2.0", + "resourceContainers": { + "collection": {"id": "41bf5486-7392-4b7a-a7e3-a735c767e3b3", "baseUrl": "https://msazure.visualstudio.com/"}, + "account": {"id": "41b4f3ee-c651-4a14-9847-b7cbb5315b80", "baseUrl": "https://msazure.visualstudio.com/"}, + "project": {"id": "b32aa71e-8ed2-41b2-9d77-5bc261222004", "baseUrl": "https://msazure.visualstudio.com/"}, + }, + "createdDate": "2023-05-16T01:22:34.9492237Z", +} + + +@pytest.fixture +def mock_req(): + with requests_mock.Mocker() as m: + yield m + + +@pytest.fixture +def mock_ado_client(monkeypatch) -> None: + monkeypatch.setenv("ADO_TOKEN", "MOCK_TOKEN") + + @dataclass + class MockResponse: + text: str + status_code: int = 203 + + def mock_update_thread(self, text, repository_id, pull_request_id, comment_id) -> MockResponse: + return MockResponse("mock response") + + monkeypatch.setattr("azure.devops.v7_1.git.git_client_base.GitClientBase.update_thread", mock_update_thread) + + class MockDevOpsClient: + def get_git_client(self) -> "MockDevOpsClient": + return MockDevOpsClient() + + def update_thread(self, text, repository_id, pull_request_id, comment_id) -> MockResponse: + return MockResponse("mock response") + + def create_comment(self, comment, repository_id, pull_request_id, thread_id, project=None) -> Comment: + return Comment() + + def get_pull_request_thread( + self, repository_id, pull_request_id, thread_id, project=None, iteration=None, base_iteration=None + ) -> GitPullRequestCommentThread: + return GitPullRequestCommentThread(thread_context=CommentThreadContext()) + + def update_pull_request( + self, git_pull_request_to_update, repository_id, pull_request_id, project=None + ) -> GitPullRequest: + return GitPullRequest() + + def get_item_content(self, repository_id="", path="", project="", version_descriptor=None, **kwargs): + return bytes("mock content", "utf-8").split() + + def get_commit_diffs( + self, + repository_id="", + project=None, + diff_common_commit=None, + top=None, + skip=None, + base_version_descriptor=None, + target_version_descriptor=None, + base_version=None, + target_version=None, + ) -> GitCommitDiffs: + return GitCommitDiffs(changes=[], all_changes_included=True) + + def mock_client(self) -> MockDevOpsClient: + return MockDevOpsClient() + + monkeypatch.setattr("azure.devops.released.client_factory.ClientFactory.get_core_client", mock_client) + monkeypatch.setattr("azure.devops.v7_1.client_factory.ClientFactoryV7_1.get_git_client", mock_client) + + +@pytest.fixture +def devops_client() -> DevOpsClient: + return DevOpsClient(TOKEN, ORG, PROJECT, REPO) + + +@pytest.fixture +def devops_function() -> DevOpsFunction: + return DevOpsFunction(TOKEN, ORG, PROJECT, REPO) + + +def test_create_comment(mock_ado_client: None, devops_client: DevOpsClient) -> None: + response = devops_client.create_comment(pull_request_id=PR_ID, comment_id=COMMENT_ID, text="text1") + assert isinstance(response, Comment) + + +def test_update_pr(mock_ado_client: None, devops_client: DevOpsClient) -> None: + response = devops_client.update_pr(pull_request_id=PR_ID, title="title1", description="description1") + assert isinstance(response, GitPullRequest) + + +@pytest.mark.integration +def test_int_create_comment(devops_client: DevOpsClient) -> None: + response = devops_client.create_comment(pull_request_id=PR_ID, comment_id=COMMENT_ID, text="text1") + assert isinstance(response, Comment) + + +@pytest.mark.integration +def test_int_update_pr(devops_client: DevOpsClient) -> None: + response = devops_client.update_pr(PR_ID, description="description1") + assert isinstance(response, GitPullRequest) + response = devops_client.update_pr(PR_ID, title="Sample PR Title") + assert isinstance(response, GitPullRequest) + + +def process_payload_test() -> None: + question = DevOpsClient.process_comment_payload(SAMPLE_PAYLOAD) + link = "https://msazure.visualstudio.com/One/_git/Azure-Gaming/pullrequest/8063875" + _comment(question, comment_id=COMMENT_ID, link=link) + + +def test_process_payload(mock_openai: None, mock_ado_client: None) -> None: + process_payload_test() + + +@pytest.mark.integration +def test_int_process_payload() -> None: + process_payload_test() + + +def get_patch_test(devops_client: DevOpsClient, expected_len: int) -> None: + comment_id = LONG_PAYLOAD["resource"]["comment"]["_links"]["threads"]["href"].split("/")[-1] + patch = devops_client.get_patch( + pull_request_event=LONG_PAYLOAD["resource"], pull_request_id=PR_ID, comment_id=comment_id + ) + assert len(patch) == expected_len + + +def test_get_patch(mock_openai: None, mock_ado_client: None, devops_client: DevOpsClient) -> None: + get_patch_test(devops_client, 1) + + +@pytest.mark.integration +def test_int_get_patch(devops_client: DevOpsClient) -> None: + get_patch_test(devops_client, 64) + + +def get_patch_pr_comment_test(devops_function: DevOpsFunction, expected_len: int) -> None: + patch = devops_function.get_patches(pull_request_event=PR_COMMENT_PAYLOAD["resource"]) + patch = "\n".join(patch) + assert len(patch) == expected_len + + +def test_get_patch_pr_comment(mock_openai: None, mock_ado_client: None, devops_function: DevOpsFunction) -> None: + get_patch_pr_comment_test(devops_function, 0) + + +@pytest.mark.integration +def test_int_get_patch_pr_comment(devops_function: DevOpsFunction) -> None: + get_patch_pr_comment_test(devops_function, 3348) diff --git a/tests/test_gpt_cli.py b/tests/test_gpt_cli.py index f41ad92a..7418a08d 100644 --- a/tests/test_gpt_cli.py +++ b/tests/test_gpt_cli.py @@ -94,6 +94,11 @@ class CLICase2(CLICase): CLICase("github review"), ] +DEVOPS_COMMANDS = [ + CLICase("ado review --help"), + CLICase("ado review --diff tests/mock.diff"), +] + GIT_COMMANDS = [ CLICase("git commit --help"), # CLICase("git commit"), @@ -109,7 +114,7 @@ class CLICase2(CLICase): CLICase("review diff --diff tests/mock.diff --config tests/config.summary.extra.yml"), ] -ARGS = ROOT_COMMANDS + ASK_COMMANDS + GIT_COMMANDS + GITHUB_COMMANDS + REVIEW_COMMANDS +ARGS = ROOT_COMMANDS + ASK_COMMANDS + GIT_COMMANDS + GITHUB_COMMANDS + DEVOPS_COMMANDS + REVIEW_COMMANDS ARGS_DICT = {arg.command: arg for arg in ARGS} MODULE_COMMANDS = [