diff --git a/INSTALL.md b/INSTALL.md index 88ad92bb..ba2547b1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -309,7 +309,9 @@ Example IAM permissions to that user to allow access to CodeCommit: "codecommit:Get*", "codecommit:List*", "codecommit:PostComment*", - "codecommit:PutCommentReaction" + "codecommit:PutCommentReaction", + "codecommit:UpdatePullRequestDescription", + "codecommit:UpdatePullRequestTitle" ], "Resource": "*" } diff --git a/README.md b/README.md index 47dca106..faab0af1 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ CodiumAI `PR-Agent` is an open-source tool aiming to help developers review pull | TOOLS | Review | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | | ⮑ Inline review | :white_check_mark: | :white_check_mark: | | | | | Ask | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| | Auto-Description | :white_check_mark: | :white_check_mark: | | | +| | Auto-Description | :white_check_mark: | :white_check_mark: | | :white_check_mark: | | | Improve Code | :white_check_mark: | :white_check_mark: | | | | | ⮑ Extended | :white_check_mark: | :white_check_mark: | | | | | Reflect and Review | :white_check_mark: | | | | diff --git a/pr_agent/git_providers/codecommit_client.py b/pr_agent/git_providers/codecommit_client.py index c1cfa763..6200340d 100644 --- a/pr_agent/git_providers/codecommit_client.py +++ b/pr_agent/git_providers/codecommit_client.py @@ -64,7 +64,7 @@ class CodeCommitClient: """ Get the differences between two commits in CodeCommit. - Parameters: + Args: - repo_name: Name of the repository - destination_commit: Commit hash you want to merge into (the "before" hash) (usually on the main or master branch) - source_commit: Commit hash of the code you are adding (the "after" branch) @@ -73,8 +73,8 @@ class CodeCommitClient: - List of CodeCommitDifferencesResponse objects Boto3 Documentation: - aws codecommit get-differences - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/get_differences.html + - aws codecommit get-differences + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/get_differences.html """ if self.boto_client is None: self._connect_boto_client() @@ -101,7 +101,7 @@ class CodeCommitClient: """ Retrieve a file from CodeCommit. - Parameters: + Args: - repo_name: Name of the repository - file_path: Path to the file you are retrieving - sha_hash: Commit hash of the file you are retrieving @@ -110,8 +110,8 @@ class CodeCommitClient: - File contents Boto3 Documentation: - aws codecommit get_file - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/get_file.html + - aws codecommit get_file + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/get_file.html """ if not file_path: return "" @@ -137,15 +137,15 @@ class CodeCommitClient: """ Get a information about a CodeCommit PR. - Parameters: + Args: - pr_number: The PR number you are requesting Returns: - CodeCommitPullRequestResponse object Boto3 Documentation: - aws codecommit get_pull_request - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/get_pull_request.html + - aws codecommit get_pull_request + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/get_pull_request.html """ if self.boto_client is None: self._connect_boto_client() @@ -164,11 +164,48 @@ class CodeCommitClient: return CodeCommitPullRequestResponse(response.get("pullRequest", {})) + def publish_description(self, pr_number: int, pr_title: str, pr_body: str): + """ + Set the title and description on a pull request + + Args: + - pr_number: the AWS CodeCommit pull request number + - pr_title: title of the pull request + - pr_body: body of the pull request + + Returns: + - None + + Boto3 Documentation: + - aws codecommit update_pull_request_title + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/update_pull_request_title.html + - aws codecommit update_pull_request_description + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/update_pull_request_description.html + """ + if self.boto_client is None: + self._connect_boto_client() + + try: + self.boto_client.update_pull_request_title(pullRequestId=str(pr_number), title=pr_title) + self.boto_client.update_pull_request_description(pullRequestId=str(pr_number), description=pr_body) + except botocore.exceptions.ClientError as e: + if e.response["Error"]["Code"] == 'PullRequestDoesNotExistException': + raise ValueError(f"PR number does not exist: {pr_number}") from e + if e.response["Error"]["Code"] == 'InvalidTitleException': + raise ValueError(f"Invalid title for PR number: {pr_number}") from e + if e.response["Error"]["Code"] == 'InvalidDescriptionException': + raise ValueError(f"Invalid description for PR number: {pr_number}") from e + if e.response["Error"]["Code"] == 'PullRequestAlreadyClosedException': + raise ValueError(f"PR is already closed: PR number: {pr_number}") from e + raise ValueError(f"Boto3 client error calling publish_description") from e + except Exception as e: + raise ValueError(f"Error calling publish_description") from e + def publish_comment(self, repo_name: str, pr_number: int, destination_commit: str, source_commit: str, comment: str): """ Publish a comment to a pull request - Parameters: + Args: - repo_name: name of the repository - pr_number: number of the pull request - destination_commit: The commit hash you want to merge into (the "before" hash) (usually on the main or master branch) @@ -179,8 +216,8 @@ class CodeCommitClient: - None Boto3 Documentation: - aws codecommit post_comment_for_pull_request - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/post_comment_for_pull_request.html + - aws codecommit post_comment_for_pull_request + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit/client/post_comment_for_pull_request.html """ if self.boto_client is None: self._connect_boto_client() diff --git a/pr_agent/git_providers/codecommit_provider.py b/pr_agent/git_providers/codecommit_provider.py index a747e7f2..d43409c3 100644 --- a/pr_agent/git_providers/codecommit_provider.py +++ b/pr_agent/git_providers/codecommit_provider.py @@ -1,5 +1,6 @@ import logging import os +import re from collections import Counter from typing import List, Optional, Tuple from urllib.parse import urlparse @@ -153,17 +154,27 @@ class CodeCommitProvider(GitProvider): return self.diff_files def publish_description(self, pr_title: str, pr_body: str): - return "" # not implemented yet + try: + self.codecommit_client.publish_description( + pr_number=self.pr_num, + pr_title=pr_title, + pr_body=CodeCommitProvider._add_additional_newlines(pr_body), + ) + except Exception as e: + raise ValueError(f"CodeCommit Cannot publish description for PR: {self.pr_num}") from e def publish_comment(self, pr_comment: str, is_temporary: bool = False): if is_temporary: logging.info(pr_comment) return + pr_comment = CodeCommitProvider._remove_markdown_html(pr_comment) + pr_comment = CodeCommitProvider._add_additional_newlines(pr_comment) + try: self.codecommit_client.publish_comment( repo_name=self.repo_name, - pr_number=str(self.pr_num), + pr_number=self.pr_num, destination_commit=self.pr.destination_commit, source_commit=self.pr.source_commit, comment=pr_comment, @@ -200,7 +211,7 @@ class CodeCommitProvider(GitProvider): Returns a dictionary of languages, containing the percentage of each language used in the PR. Returns: - dict: A dictionary where each key is a language name and the corresponding value is the percentage of that language in the PR. + - dict: A dictionary where each key is a language name and the corresponding value is the percentage of that language in the PR. """ commit_files = self.get_files() filenames = [ item.filename for item in commit_files ] @@ -251,11 +262,20 @@ class CodeCommitProvider(GitProvider): @staticmethod def _parse_pr_url(pr_url: str) -> Tuple[str, int]: + """ + Parse the CodeCommit PR URL and return the repository name and PR number. + + Args: + - pr_url: the full AWS CodeCommit pull request URL + + Returns: + - Tuple[str, int]: A tuple containing the repository name and PR number. + """ # Example PR URL: # https://us-east-1.console.aws.amazon.com/codesuite/codecommit/repositories/__MY_REPO__/pull-requests/123456" parsed_url = urlparse(pr_url) - if "us-east-1.console.aws.amazon.com" not in parsed_url.netloc: + if not CodeCommitProvider._is_valid_codecommit_hostname(parsed_url.netloc): raise ValueError(f"The provided URL is not a valid CodeCommit URL: {pr_url}") path_parts = parsed_url.path.strip("/").split("/") @@ -278,6 +298,22 @@ class CodeCommitProvider(GitProvider): return repo_name, pr_number + @staticmethod + def _is_valid_codecommit_hostname(hostname: str) -> bool: + """ + Check if the provided hostname is a valid AWS CodeCommit hostname. + + This is not an exhaustive check of AWS region names, + but instead uses a regex to check for matching AWS region patterns. + + Args: + - hostname: the hostname to check + + Returns: + - bool: True if the hostname is valid, False otherwise. + """ + return re.match(r"^[a-z]{2}-(gov-)?[a-z]+-\d\.console\.aws\.amazon\.com$", hostname) is not None + def _get_pr(self): response = self.codecommit_client.get_pr(self.pr_num) @@ -306,13 +342,52 @@ class CodeCommitProvider(GitProvider): return "" # not implemented yet @staticmethod - def _get_edit_type(codecommit_change_type): + def _add_additional_newlines(body: str) -> str: + """ + Replace single newlines in a PR body with double newlines. + + CodeCommit Markdown does not seem to render as well as GitHub Markdown, + so we add additional newlines to the PR body to make it more readable in CodeCommit. + + Args: + - body: the PR body + + Returns: + - str: the PR body with the double newlines added + """ + return re.sub(r'(? str: + """ + Remove the HTML tags from a PR comment. + + CodeCommit Markdown does not seem to render as well as GitHub Markdown, + so we remove the HTML tags from the PR comment to make it more readable in CodeCommit. + + Args: + - comment: the PR comment + + Returns: + - str: the PR comment with the HTML tags removed + """ + comment = comment.replace("
", "") + comment = comment.replace("
", "") + comment = comment.replace("", "") + comment = comment.replace("", "") + return comment + + @staticmethod + def _get_edit_type(codecommit_change_type: str): """ Convert the CodeCommit change type string to the EDIT_TYPE enum. The CodeCommit change type string is returned from the get_differences SDK method. + Args: + - codecommit_change_type: the CodeCommit change type string + Returns: - An EDIT_TYPE enum representing the modified, added, deleted, or renamed file in the PR diff. + - An EDIT_TYPE enum representing the modified, added, deleted, or renamed file in the PR diff. """ t = codecommit_change_type.upper() edit_type = None @@ -333,6 +408,12 @@ class CodeCommitProvider(GitProvider): The returned extensions will include the dot "." prefix, to accommodate for the dots in the existing language_extension_map settings. Filenames with no extension will return an empty string for the extension. + + Args: + - filenames: a list of filenames + + Returns: + - list: A list of file extensions, including the dot "." prefix. """ extensions = [] for filename in filenames: @@ -349,6 +430,12 @@ class CodeCommitProvider(GitProvider): Return a dictionary containing the programming language name (as the key), and the percentage that language is used (as the value), given a list of file extensions. + + Args: + - extensions: a list of file extensions + + Returns: + - dict: A dictionary where each key is a language name and the corresponding value is the percentage of that language in the PR. """ total_files = len(extensions) if total_files == 0: diff --git a/tests/unittest/test_codecommit_provider.py b/tests/unittest/test_codecommit_provider.py index e35f7250..9de7c45c 100644 --- a/tests/unittest/test_codecommit_provider.py +++ b/tests/unittest/test_codecommit_provider.py @@ -26,11 +26,48 @@ class TestCodeCommitFile: class TestCodeCommitProvider: def test_parse_pr_url(self): + # Test that the _parse_pr_url() function can extract the repo name and PR number from a CodeCommit URL url = "https://us-east-1.console.aws.amazon.com/codesuite/codecommit/repositories/my_test_repo/pull-requests/321" repo_name, pr_number = CodeCommitProvider._parse_pr_url(url) assert repo_name == "my_test_repo" assert pr_number == 321 + def test_is_valid_codecommit_hostname(self): + # Test the various AWS regions + assert CodeCommitProvider._is_valid_codecommit_hostname("af-south-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-east-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-northeast-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-northeast-2.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-northeast-3.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-south-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-south-2.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-southeast-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-southeast-2.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-southeast-3.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ap-southeast-4.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("ca-central-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("eu-central-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("eu-central-2.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("eu-north-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("eu-south-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("eu-south-2.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("eu-west-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("eu-west-2.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("eu-west-3.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("il-central-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("me-central-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("me-south-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("sa-east-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("us-east-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("us-east-2.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("us-gov-east-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("us-gov-west-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("us-west-1.console.aws.amazon.com") + assert CodeCommitProvider._is_valid_codecommit_hostname("us-west-2.console.aws.amazon.com") + # Test non-AWS regions + assert not CodeCommitProvider._is_valid_codecommit_hostname("no-such-region.console.aws.amazon.com") + assert not CodeCommitProvider._is_valid_codecommit_hostname("console.aws.amazon.com") + # Test that an error is raised when an invalid CodeCommit URL is provided to the set_pr() method of the CodeCommitProvider class. # Generated by CodiumAI def test_invalid_codecommit_url(self): @@ -106,6 +143,7 @@ class TestCodeCommitProvider: assert percentages == {} def test_get_edit_type(self): + # Test that the _get_edit_type() function can convert a CodeCommit letter to an EDIT_TYPE enum assert CodeCommitProvider._get_edit_type("A") == EDIT_TYPE.ADDED assert CodeCommitProvider._get_edit_type("D") == EDIT_TYPE.DELETED assert CodeCommitProvider._get_edit_type("M") == EDIT_TYPE.MODIFIED @@ -117,3 +155,18 @@ class TestCodeCommitProvider: assert CodeCommitProvider._get_edit_type("r") == EDIT_TYPE.RENAMED assert CodeCommitProvider._get_edit_type("X") is None + + def test_add_additional_newlines(self): + # a short string to test adding double newlines + input = "abc\ndef\n\n___\nghi\njkl\nmno\n\npqr\n" + expect = "abc\n\ndef\n\n___\n\nghi\n\njkl\n\nmno\n\npqr\n\n" + assert CodeCommitProvider._add_additional_newlines(input) == expect + # a test example from a real PR + input = "## PR Type:\nEnhancement\n\n___\n## PR Description:\nThis PR introduces a new feature to the script, allowing users to filter servers by name.\n\n___\n## PR Main Files Walkthrough:\n`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n`bar`: The bar script has been updated to list stopped servers.\n" + expect = "## PR Type:\n\nEnhancement\n\n___\n\n## PR Description:\n\nThis PR introduces a new feature to the script, allowing users to filter servers by name.\n\n___\n\n## PR Main Files Walkthrough:\n\n`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n\n`bar`: The bar script has been updated to list stopped servers.\n\n" + assert CodeCommitProvider._add_additional_newlines(input) == expect + + def test_remove_markdown_html(self): + input = "## PR Feedback\n
Code feedback:\nfile foo\n\n" + expect = "## PR Feedback\nCode feedback:\nfile foo\n\n" + assert CodeCommitProvider._remove_markdown_html(input) == expect \ No newline at end of file