diff --git a/.github/PENDING_REVIEW_NOTIFICATION_TEMPLATE.md b/.github/PENDING_REVIEW_NOTIFICATION_TEMPLATE.md index 69060e6..27c656a 100644 --- a/.github/PENDING_REVIEW_NOTIFICATION_TEMPLATE.md +++ b/.github/PENDING_REVIEW_NOTIFICATION_TEMPLATE.md @@ -1,8 +1,8 @@ Hi {{ username }}, -It looks like you're assigned to this PR, but haven't taken any action for at least 2 days: +The following PRs are currently blocked on your review: {{ pr_list }} -Please review and unassign yourself from the pending PRs as soon as possible. +Please review and unassign yourself from the pending PRs as soon as possible, then mark this discussion thread as 'Done'. To avoid these messages in the future, please bookmark [this link](https://github.com/pulls/assigned) and check it daily. Thanks! \ No newline at end of file diff --git a/src/github_services.py b/src/github_services.py index 59f2c27..34adc6d 100644 --- a/src/github_services.py +++ b/src/github_services.py @@ -21,7 +21,7 @@ import datetime import logging -from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, DefaultDict, Dict, List, Optional, Union from dateutil import parser import requests from src import github_domain @@ -203,18 +203,51 @@ def get_pull_request_dict_with_timestamp( assignee['created_at'] = parser.parse(event['created_at']) return pr_dict - @check_token -def _get_discussion_data( +def _get_repository_id( org_name: str, repo_name: str, - discussion_category: str, - discussion_title: str, -) -> Tuple[str, int]: - """Fetch discussion data from api and return corresponding discussion id and - discussion number. +) -> str: + """Fetch repository id from given org and repo and return the id.""" + + query = """ + query ($org_name: String!, $repository: String!) { + repository(owner: $org_name, name: $repository) { + id + } + } """ + variables = { + 'org_name': org_name, + 'repository': repo_name + } + + response = requests.post( + GITHUB_GRAPHQL_URL, + json={'query': query, 'variables': variables}, + headers=_get_request_headers(), + timeout=TIMEOUT_SECS + ) + data = response.json() + + repository_id: str = ( + data['data']['repository']['id']) + + if repository_id is None: + raise builtins.BaseException( + f'{org_name}/{repo_name} doesn\'t exist.') + + return repository_id + +@check_token +def _get_category_id( + org_name: str, + repo_name: str, + discussion_category: str +) -> str: + """Fetch discussion category id from given category name and return the id.""" + # The following query is written in GraphQL and is being used to fetch the category # ids and titles from the GitHub discussions. To learn more, check this out # https://docs.github.com/en/graphql. @@ -244,7 +277,7 @@ def _get_discussion_data( ) data = response.json() - category_id = None + category_id: Optional[str] = None discussion_categories = ( data['data']['repository']['discussionCategories']['nodes']) @@ -257,6 +290,21 @@ def _get_discussion_data( raise builtins.BaseException( f'{discussion_category} category is missing in GitHub Discussion.') + assert category_id is not None + return category_id + +@check_token +def _get_discussion_ids( + org_name: str, + repo_name: str, + discussion_category: str, +) -> List[str]: + """Fetch discussion data from api and return corresponding discussion id and + discussion number. + """ + + category_id = _get_category_id(org_name, repo_name, discussion_category) + # The following query is written in GraphQL and is being used to fetch discussions # from a particular GitHub discussion category. This helps to find out the discussion # where we want to comment. To learn more, check this out @@ -289,62 +337,34 @@ def _get_discussion_data( timeout=TIMEOUT_SECS ) data = response.json() - discussion_id = None discussions = data['data']['repository']['discussions']['nodes'] + discussion_ids = [ + discussion['id'] for discussion in discussions if discussion['id'] is not None + ] - for discussion in discussions: - if discussion['title'] == discussion_title: - discussion_id = discussion['id'] - discussion_number = discussion['number'] - break - if discussion_id is None: - raise builtins.BaseException( - f'Discussion with title {discussion_title} not found, please create a ' - 'discussion with that title.') - - return discussion_id, discussion_number - - -def _get_past_time(days: int=60) -> str: - """Returns the subtraction of current time and the arg passed in days.""" - return ( - datetime.datetime.now( - datetime.timezone.utc) - datetime.timedelta(days=days)).strftime( - '%Y-%m-%dT%H:%M:%SZ') + if not discussion_ids: + logging.info('No existing discussions found') + return discussion_ids -def _get_old_comment_ids( - org_name: str, - repo_name: str, - discussion_number: int -) -> List[str]: - """Return the old comment ids.""" +def _delete_discussion(discussion_id: str) -> None: + """Delete the GitHub Discussion comment related to the comment id.""" - # The following query is written in GraphQL and is being used to fetch the oldest 50 - # comments in a existing GitHub discussions. Assuming that, this workflow will run - # twice every week, we will may not have more than 50 comments to delete. To learn - # more, check this out https://docs.github.com/en/graphql. query = """ - query ($org_name: String!, $repository: String!, $discussion_number: Int!) { - repository(owner: $org_name, name: $repository) { - discussion(number: $discussion_number) { - comments(first: 50) { - nodes { - id - createdAt - } - } + mutation deleteDiscussion($discussion_id: ID!) { + deleteDiscussion(input: {id: $discussion_id}) { + clientMutationId, + discussion { + title } } } """ variables = { - 'org_name': org_name, - 'repository': repo_name, - 'discussion_number': discussion_number + 'discussion_id': discussion_id } response = requests.post( @@ -353,67 +373,39 @@ def _get_old_comment_ids( headers=_get_request_headers(), timeout=TIMEOUT_SECS ) - response.raise_for_status() - data = response.json() - - comment_ids: List[str] = [] - - discussion_comments = ( - data['data']['repository']['discussion']['comments']['nodes'] - ) - - # Delete comments posted before this time. - delete_comments_before_in_days = _get_past_time(DELETE_COMMENTS_BEFORE_IN_DAYS) - - for comment in discussion_comments: - if comment['createdAt'] < delete_comments_before_in_days: - comment_ids.append(comment['id']) - else: - break - - return comment_ids -def _delete_comment(comment_id: str) -> None: - """Delete the GitHub Discussion comment related to the comment id.""" - - query = """ - mutation deleteComment($comment_id: ID!) { - deleteDiscussionComment(input: {id: $comment_id}) { - clientMutationId - comment { - bodyText - } - } - } - """ - - variables = { - 'comment_id': comment_id - } +@check_token +def delete_discussions( + org_name: str, + repo_name: str, + discussion_category: str, +) -> None: + """Delete all existing discussions in the given discussion category.""" - response = requests.post( - GITHUB_GRAPHQL_URL, - json={'query': query, 'variables': variables}, - headers=_get_request_headers(), - timeout=TIMEOUT_SECS - ) - response.raise_for_status() + discussion_ids = _get_discussion_ids( + org_name, repo_name, discussion_category) + for discussion_id in discussion_ids: + _delete_discussion(discussion_id) -def _post_comment(discussion_id: str, message: str) -> None: - """Post the given message in an existing discussion.""" +@check_token +def create_discussion( + org_name: str, + repo_name: str, + discussion_category: str, + discussion_title: str, + discussion_body: str +) -> None: + """Create a new discussion with the given title and body in the given discussion category.""" - # The following code is written in GraphQL and is being used to perform a mutation - # operation. More specifically, we are using it to comment in GitHub discussion to - # let reviewers know about some of their pending tasks. To learn more, check this out: - # https://docs.github.com/en/graphql. + category_id = _get_category_id(org_name, repo_name, discussion_category) + repo_id = _get_repository_id(org_name, repo_name) query = """ - mutation post_comment($discussion_id: ID!, $comment: String!) { - addDiscussionComment(input: {discussionId: $discussion_id, body: $comment}) { - clientMutationId - comment { + mutation createDiscussion($repo_id: ID!, $category_id: ID!, $title: String!, $body: String!) { + createDiscussion(input: {repositoryId: $repo_id, categoryId: $category_id, title: $title, body: $body}) { + discussion { id } } @@ -421,8 +413,10 @@ def _post_comment(discussion_id: str, message: str) -> None: """ variables = { - 'discussion_id': discussion_id, - 'comment': message + 'repo_id': repo_id, + 'category_id': category_id, + 'title': discussion_title, + 'body': discussion_body } response = requests.post( @@ -431,38 +425,5 @@ def _post_comment(discussion_id: str, message: str) -> None: headers=_get_request_headers(), timeout=TIMEOUT_SECS ) - response.raise_for_status() - -@check_token -def delete_discussion_comments( - org_name: str, - repo_name: str, - discussion_category: str, - discussion_title: str -) -> None: - """Delete old comments from GitHub Discussion.""" - - _, discussion_number = _get_discussion_data( - org_name, repo_name, discussion_category, discussion_title) - - comment_ids = _get_old_comment_ids(org_name, repo_name, discussion_number) - - for comment_id in comment_ids: - _delete_comment(comment_id) - - -@check_token -def add_discussion_comments( - org_name: str, - repo_name: str, - discussion_category: str, - discussion_title: str, - message: str -) -> None: - """Add comments in an existing GitHub discussion.""" - - discussion_id, _ = _get_discussion_data( - org_name, repo_name, discussion_category, discussion_title) - - _post_comment(discussion_id, message) + response.raise_for_status() diff --git a/src/main.py b/src/main.py index e1eada3..1b0bc56 100644 --- a/src/main.py +++ b/src/main.py @@ -22,7 +22,7 @@ import os import re -from typing import List, Optional +from typing import DefaultDict, List, Optional from src import github_domain from src import github_services @@ -58,13 +58,18 @@ TEMPLATE_PATH = '.github/PENDING_REVIEW_NOTIFICATION_TEMPLATE.md' -def generate_message(username: str, pr_list: str, template_path: str=TEMPLATE_PATH) -> str: +def generate_message( + username: str, + pull_requests: List[github_domain.PullRequest], + template_path: str=TEMPLATE_PATH +) -> str: """Generates message using the template provided in PENDING_REVIEW_NOTIFICATION_TEMPLATE.md. Args: username: str. Reviewer username. - pr_list: str. List of PRs not reviewed within the maximum waiting time. + pr_list: List[github_domain.PullRequest]. List of PullRequest objects not reviewed within + the maximum waiting time. template_path: str. The template file path. Returns: @@ -73,6 +78,16 @@ def generate_message(username: str, pr_list: str, template_path: str=TEMPLATE_PA Raises: Exception. Template file is missing in the given path. """ + pr_list_messages: List[str] = [] + for pull_request in pull_requests: + assignee: Optional[github_domain.Assignee] = pull_request.get_assignee(username) + + if assignee is not None: + pr_list_messages.append( + f'- [#{pull_request.pr_number}]({pull_request.url}) [Waiting for the ' + f'last {assignee.get_waiting_time()}]') + + if not os.path.exists(template_path): raise builtins.BaseException(f'Please add a template file at: {template_path}') message = '' @@ -80,43 +95,10 @@ def generate_message(username: str, pr_list: str, template_path: str=TEMPLATE_PA message = file.read() message = re.sub(r'\{\{ *username *\}\}', '@' + username, message) - message = re.sub(r'\{\{ *pr_list *\}\}', pr_list, message) + message = re.sub(r'\{\{ *pr_list *\}\}', '\n'.join(pr_list_messages), message) return message - -def send_notification( - username: str, - pull_requests: List[github_domain.PullRequest], - org_name: str, - repo_name: str, - discussion_category: str, - discussion_title: str -) -> None: - """Sends notification on github-discussion. - - Args: - username: str. GitHub username of the reviewer. - pull_requests: List. List of pending PRs. - org_name: str. The GitHub org name. - repo_name: str. The GitHub repo name. - discussion_category: str. Category name of the discussion. - discussion_title: str. Discussion title. - """ - pr_list_messages: List[str] = [] - for pull_request in pull_requests: - assignee = pull_request.get_assignee(username) - assert assignee is not None - pr_list_messages.append( - f'- [#{pull_request.pr_number}]({pull_request.url}) [Waiting for the ' - f'last {assignee.get_waiting_time()}]') - - message = generate_message(username, '\n'.join(pr_list_messages), TEMPLATE_PATH) - - github_services.add_discussion_comments( - org_name, repo_name, discussion_category, discussion_title, message) - - def main(args: Optional[List[str]]=None) -> None: """The main function to execute the workflow. @@ -130,11 +112,10 @@ def main(args: Optional[List[str]]=None) -> None: org_name, repo_name = parsed_args.repo.split('/') discussion_category = parsed_args.category - discussion_title = parsed_args.title max_wait_hours = parsed_args.max_wait_hours # Raise error if any of the required arguments are not provided. - required_args = ['max_wait_hours', 'discussion_category', 'discussion_title'] + required_args = ['max_wait_hours', 'discussion_category'] for arg in required_args: if arg is None: raise builtins.BaseException(f'Please provide {arg} argument.') @@ -145,15 +126,18 @@ def main(args: Optional[List[str]]=None) -> None: github_services.init_service(parsed_args.token) - reviewer_to_assigned_prs = github_services.get_prs_assigned_to_reviewers( - org_name, repo_name, max_wait_hours) + reviewer_to_assigned_prs: DefaultDict[str, List[github_domain.PullRequest]] = ( + github_services.get_prs_assigned_to_reviewers(org_name, repo_name, max_wait_hours) + ) - github_services.delete_discussion_comments( - org_name, repo_name, discussion_category, discussion_title) + github_services.delete_discussions( + org_name, repo_name, discussion_category) - for reviewer_name, prs in reviewer_to_assigned_prs.items(): - send_notification( - reviewer_name, prs, org_name, repo_name, discussion_category, discussion_title) + for reviewer_name, pr_list in reviewer_to_assigned_prs.items(): + discussion_title = f"Pending Reviews: @{reviewer_name}" + discussion_body = generate_message(reviewer_name, pr_list, TEMPLATE_PATH) + github_services.create_discussion( + org_name, repo_name, discussion_category, discussion_title, discussion_body) if __name__ == '__main__': diff --git a/tests/github_services_test.py b/tests/github_services_test.py index ec7a26d..660552f 100644 --- a/tests/github_services_test.py +++ b/tests/github_services_test.py @@ -62,71 +62,63 @@ def _get_past_time(self, hours: int=0) -> str: def setUp(self) -> None: self.org_name = 'orgName' self.repo_name = 'repo' - self.discussion_category = 'category' - self.discussion_title = 'title' - self.response_for_get_categories = { - "data": { - "repository": { - "discussionCategories": { - "nodes": [ - { - "id": "test_category_id_1", - "name": "test_category_name_1" - }, + self.discussion_category = 'test_category_name_1' + self.discussion_title = 'Pending Reviews: User-1' + self.discussion_body = 'body' + self.response_for_get_repository_id = { + 'data': { + 'repository': { + 'id': 'test_repository_id' + } + } + } + self.response_for_get_category_id = { + 'data': { + 'repository': { + 'discussionCategories': { + 'nodes': [ { - "id": "test_category_id_2", - "name": "test_category_name_2" + 'id': 'test_category_id_1', + 'name': 'test_category_name_1' + },{ + 'id': 'test_category_id_2', + 'name': 'test_category_name_2' } ] } } } } - self.response_for_get_discussion = { + self.response_for_get_discussion_ids = { 'data': { 'repository': { 'discussions': { 'nodes': [ { 'id': 'test_discussion_id_1', - 'title': 'test_discussion_title_1', - 'number': 12345 + 'title': 'Pending Reviews: User-1', + 'number': 65 } ] } } } } - self.response_for_get_old_comment_ids = { + self.response_for_delete_discussion = { 'data': { - 'repository': { + 'deleteDiscussion': { + 'clientMutationId': 'null', 'discussion': { - 'comments': { - 'nodes': [ - { - 'id': 'test_comment_id_2', - 'createdAt': '2022-05-05T11:44:00Z' - } - ] - } + 'title': 'Pending Reviews: User-1' } } } } - self.response_for_delete_comment = { - 'data': { - 'deleteDiscussionComment': { - 'clientMutationId': 'test_id' - } - } - } - # Here we use type Any because this response is hard to annotate in a typedDict. - self.response_for_post_comment: Dict[str, Any] = { + self.response_for_create_discussion = { 'data': { - 'addDiscussionComment': { - 'clientMutationId': 'test_id', - 'comment': { - 'id': 'test_discussion_id_1' + 'createDiscussion': { + 'discussion': { + 'id': 'D_kwDOJclmXc4AdCMs' } } } @@ -291,19 +283,16 @@ def test_get_prs_assigned_to_reviewers(self) -> None: self.assertEqual(mock_request.call_count, 6) - def test_get_discussion_data(self) -> None: - """Test _get_discussion_data.""" - mock_response_for_get_categories = self.mock_post_requests( - self.response_for_get_categories) - mock_response_for_get_discussion = self.mock_post_requests( - self.response_for_get_discussion) + def test_get_repository_id(self) -> None: + """Test _get_repository_id.""" + + mock_response_for_get_repository_id = self.mock_post_requests( + self.response_for_get_repository_id) - self.assertTrue(mock_response_for_get_categories.assert_not_called) - self.assertTrue(mock_response_for_get_discussion.assert_not_called) + self.assertTrue(mock_response_for_get_repository_id.assert_not_called) mock_response = [ - mock_response_for_get_categories, - mock_response_for_get_discussion + mock_response_for_get_repository_id ] with requests_mock.Mocker() as mock_requests: @@ -312,22 +301,19 @@ def test_get_discussion_data(self) -> None: with mock.patch('requests.post', side_effect=mock_response) as mock_post: - mocked_response = github_services._get_discussion_data( + mocked_response = github_services._get_repository_id( self.org_name, - self.repo_name, - 'test_category_name_1', - 'test_discussion_title_1' + self.repo_name ) - self.assertTrue(mock_response_for_get_categories.assert_called_once) - self.assertTrue(mock_response_for_get_discussion.assert_called_once) - self.assertEqual(mock_post.call_count, 2) - self.assertEqual(mocked_response, ('test_discussion_id_1', 12345)) + self.assertTrue(mock_response_for_get_repository_id.assert_called_once) + self.assertEqual(mock_post.call_count, 1) + self.assertEqual(mocked_response, 'test_repository_id') - def test_get_old_comment_ids(self) -> None: - """Test _get_old_comment_ids.""" + def test_get_category_id(self) -> None: + """Test _get_category_id.""" mock_response = mock.Mock() - mock_response.json.return_value = self.response_for_get_old_comment_ids + mock_response.json.return_value = self.response_for_get_category_id self.assertTrue(mock_response.assert_not_called) with requests_mock.Mocker() as mock_requests: @@ -336,40 +322,51 @@ def test_get_old_comment_ids(self) -> None: with mock.patch('requests.post', side_effect=[mock_response]) as mock_post: - mocked_response = github_services._get_old_comment_ids( + mocked_response = github_services._get_category_id( self.org_name, self.repo_name, - 12345 + 'test_category_name_1' ) self.assertTrue(mock_response.assert_called_once) self.assertEqual(mock_post.call_count, 1) - self.assertEqual(mocked_response, ['test_comment_id_2']) + self.assertEqual(mocked_response, 'test_category_id_1') - def test_delete_comment(self) -> None: - """Test delete_comment.""" + def test_get_discussion_ids(self) -> None: + """Test _get_discussion_ids.""" - token = 'my_github_token' - github_services.init_service(token) - - mock_response = mock.Mock() - mock_response.json.return_value = self.response_for_delete_comment - self.assertTrue(mock_response.assert_not_called) + mock_response_1 = mock.Mock() + mock_response_1.json.return_value = self.response_for_get_category_id + mock_response_2 = mock.Mock() + mock_response_2.json.return_value = self.response_for_get_discussion_ids + self.assertTrue(mock_response_1.assert_not_called) + self.assertTrue(mock_response_2.assert_not_called) with requests_mock.Mocker() as mock_requests: self.mock_all_get_requests(mock_requests) - with mock.patch('requests.post', side_effect=[mock_response]) as mock_post: + with mock.patch('requests.post', side_effect=[ + mock_response_1, mock_response_2]) as mock_post: - github_services._delete_comment('test_comment_id_2') - self.assertTrue(mock_response.assert_called) - self.assertEqual(mock_post.call_count, 1) + mocked_response = github_services._get_discussion_ids( + self.org_name, + self.repo_name, + 'test_category_name_1' + ) + self.assertTrue(mock_response_1.assert_called_once) + self.assertTrue(mock_response_2.assert_called_once) + self.assertEqual(mock_post.call_count, 2) + self.assertEqual( + mocked_response, [ + 'test_discussion_id_1' + ] + ) - def test_post_comment(self) -> None: - """Test post comment.""" + def test_delete_discussion(self) -> None: + """Test _delete_discussion.""" mock_response = mock.Mock() - mock_response.json.return_value = self.response_for_post_comment + mock_response.json.return_value = self.response_for_delete_discussion self.assertTrue(mock_response.assert_not_called) with requests_mock.Mocker() as mock_requests: @@ -378,70 +375,40 @@ def test_post_comment(self) -> None: with mock.patch('requests.post', side_effect=[mock_response]) as mock_post: - github_services._post_comment( - 'test_discussion_id_1', - 'test_message' - ) + github_services._delete_discussion('test_discussion_id_1') self.assertTrue(mock_response.assert_called_once) self.assertEqual(mock_post.call_count, 1) - def test_delete_discussion_comments(self) -> None: - """Test delete_discussion_comments function.""" + def test_delete_discussions(self) -> None: + """Test _delete_discussions.""" - token = 'my_github_token' - github_services.init_service(token) - - mock_response_1 = mock.Mock() - mock_response_1.json.return_value = self.response_for_get_categories - - mock_response_2 = mock.Mock() - mock_response_2.json.return_value = self.response_for_get_discussion - - mock_response_3 = mock.Mock() - mock_response_3.json.return_value = self.response_for_get_old_comment_ids - - mock_response_4 = mock.Mock() - mock_response_4.json.return_value = self.response_for_delete_comment - - self.assertTrue(mock_response_1.assert_not_called) - self.assertTrue(mock_response_2.assert_not_called) - self.assertTrue(mock_response_3.assert_not_called) - self.assertTrue(mock_response_4.assert_not_called) + mock_response = mock.Mock() + mock_response.json.return_value = self.response_for_delete_discussion + self.assertTrue(mock_response.assert_not_called) + self.assertTrue(mock_response.assert_not_called) with requests_mock.Mocker() as mock_requests: self.mock_all_get_requests(mock_requests) - with mock.patch('requests.post', side_effect=[ - mock_response_1, mock_response_2, mock_response_3, mock_response_4]) as mock_post: + with mock.patch('requests.post', side_effect=[mock_response]) as mock_post: - github_services.delete_discussion_comments( - self.org_name, - self.repo_name, - 'test_category_name_1', - 'test_discussion_title_1' - ) - self.assertTrue(mock_response_1.assert_called) - self.assertTrue(mock_response_2.assert_called) - self.assertTrue(mock_response_3.assert_called) - self.assertTrue(mock_response_4.assert_called) - self.assertEqual(mock_post.call_count, 4) + github_services._delete_discussion('test_discussion_id_1') + self.assertTrue(mock_response.assert_called_once) + self.assertEqual(mock_post.call_count, 1) - def test_add_discussion_comments(self) -> None: - """Test discussion comments.""" + def test_create_discussion(self) -> None: + """Test create discussion.""" token = 'my_github_token' github_services.init_service(token) mock_response_1 = mock.Mock() - mock_response_1.json.return_value = self.response_for_get_categories - + mock_response_1.json.return_value = self.response_for_get_category_id mock_response_2 = mock.Mock() - mock_response_2.json.return_value = self.response_for_get_discussion - + mock_response_2.json.return_value = self.response_for_get_repository_id mock_response_3 = mock.Mock() - mock_response_3.json.return_value = self.response_for_post_comment - + mock_response_3.json.return_value = self.response_for_create_discussion self.assertTrue(mock_response_1.assert_not_called) self.assertTrue(mock_response_2.assert_not_called) self.assertTrue(mock_response_3.assert_not_called) @@ -453,12 +420,12 @@ def test_add_discussion_comments(self) -> None: with mock.patch('requests.post', side_effect=[ mock_response_1, mock_response_2, mock_response_3]) as mock_post: - github_services.add_discussion_comments( + github_services.create_discussion( self.org_name, self.repo_name, - 'test_category_name_1', - 'test_discussion_title_1', - 'test_message' + self.discussion_category, + self.discussion_title, + self.discussion_body ) self.assertTrue(mock_response_1.assert_called) self.assertTrue(mock_response_2.assert_called) diff --git a/tests/main_test.py b/tests/main_test.py index fbdeb13..0e2c2d7 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -23,6 +23,7 @@ from unittest import mock from src import github_services +from src import github_domain from src import main import requests @@ -42,13 +43,16 @@ def test_generate_message(self) -> None: file_data = mock.mock_open(read_data=self.test_template) template_path = '.github/PENDING_REVIEW_NOTIFICATION_TEMPLATE.md' with mock.patch('builtins.open', file_data): - pr_list = ( - '- [#123](https://githuburl.pull/123) [Waiting for the last 2 days, 8' - ' hours]') - response = main.generate_message('reviewerName1', pr_list, template_path) - expected_response = ( - '@reviewerName1\n- [#123](https://githuburl.pull/123) [Waiting for the last 2' - ' days, 8 hours]') + pull_requests = github_domain.PullRequest( + 'https://githuburl.pull/123', + 123, + 'user-1', + 'test-title', + [github_domain.Assignee('user-2', datetime.datetime.now())] + ) + response = main.generate_message('reviewerName1', [pull_requests], template_path) + expected_response = '@reviewerName1\n' + self.assertEqual(expected_response, response) def test_generate_message_raises_template_not_found_error(self) -> None: @@ -57,12 +61,12 @@ def test_generate_message_raises_template_not_found_error(self) -> None: file_data = mock.mock_open(read_data=self.test_template) template_path = 'invalid_path' with mock.patch('builtins.open', file_data): - pr_list = ( - '- [#123](https://githuburl.pull/123) [Waiting for the last 2 days, 8 ' - 'hours]') + pull_requests = github_domain.PullRequest( + 'https://githuburl.pull/123',123, 'user-1', 'test-title', [github_domain.Assignee('user-2', (datetime.datetime.now()))] + ) with self.assertRaisesRegex( builtins.BaseException, f'Please add a template file at: {template_path}'): - main.generate_message('reviewerName1', pr_list, template_path) + main.generate_message('reviewerName1', [pull_requests], template_path) class ModuleIntegrationTest(unittest.TestCase): @@ -80,69 +84,58 @@ def setUp(self) -> None: self.repo_name = 'repo' self.discussion_category = 'category' self.discussion_title = 'title' - self.response_for_get_categories = { - "data": { - "repository": { - "discussionCategories": { - "nodes": [ - { - "id": "test_category_id_1", - "name": "test_category_name_1" - }, + self.discussion_body = 'body' + self.response_for_get_repository_id = { + 'data': { + 'repository': { + 'id': 'test_repository_id' + } + } + } + self.response_for_get_category_ids = { + 'data': { + 'repository': { + 'discussionCategories': { + 'nodes': [ { - "id": "test_category_id_2", - "name": "test_category_name_2" + 'id': 'test_category_id_1', + 'name': 'test_category_name_1' } ] } } } } - self.response_for_get_discussion = { + self.response_for_get_discussion_ids = { 'data': { 'repository': { 'discussions': { 'nodes': [ { 'id': 'test_discussion_id_1', - 'title': 'test_discussion_title_1', - 'number': 12345 + 'title': 'Pending Reviews: User-1', + 'number': 65 } ] } } } } - self.response_for_get_old_comment_ids = { + self.response_for_delete_discussion = { 'data': { - 'repository': { + 'deleteDiscussion': { + 'clientMutationId': 'null', 'discussion': { - 'comments': { - 'nodes': [ - { - 'id': 'test_comment_id_2', - 'createdAt': '2022-05-05T11:44:00Z' - } - ] - } + 'title': 'Pending Reviews: User-1' } } } } - self.response_for_delete_comment = { - 'data': { - 'deleteDiscussionComment': { - 'clientMutationId': 'test_id' - } - } - } - # Here we use type Any because this response is hard to annotate in a typedDict. - self.response_for_post_comment: Dict[str, Any] = { + self.response_for_create_discussion = { 'data': { - 'addDiscussionComment': { - 'clientMutationId': 'test_id', - 'comment': { - 'id': 'test_discussion_id_1' + 'createDiscussion': { + 'discussion': { + 'id': 'D_kwDOJclmXc4AdCMs' } } } @@ -246,22 +239,30 @@ def mock_post_requests(self, response: Dict[str, Any]) -> mock.Mock: mocked_response.json.return_value = response return mocked_response - def test_executing_main_function_sends_notification(self) -> None: + def test_main_function(self) -> None: """Test main function to send notification.""" # Here we are mocking the POST requests that we will use in the test below. # and they are listed in the particular order they will be called. - post_requests_side_effect: List[mock.Mock] = [ - self.mock_post_requests(self.response_for_get_categories), - self.mock_post_requests(self.response_for_get_discussion), - self.mock_post_requests(self.response_for_get_old_comment_ids), - self.mock_post_requests(self.response_for_delete_comment), - self.mock_post_requests(self.response_for_get_categories), - self.mock_post_requests(self.response_for_get_discussion), - self.mock_post_requests(self.response_for_post_comment), - self.mock_post_requests(self.response_for_get_categories), - self.mock_post_requests(self.response_for_get_discussion), - self.mock_post_requests(self.response_for_post_comment) + post_requests_side_effect_1: List[mock.Mock] = [ + self.mock_post_requests(self.response_for_get_category_ids), + self.mock_post_requests(self.response_for_get_discussion_ids), + self.mock_post_requests(self.response_for_delete_discussion), + self.mock_post_requests(self.response_for_get_category_ids), + self.mock_post_requests(self.response_for_get_repository_id), + self.mock_post_requests(self.response_for_create_discussion), + ] + + post_requests_side_effect_2: List[mock.Mock] = [ + self.mock_post_requests(self.response_for_get_category_ids), + self.mock_post_requests(self.response_for_get_repository_id), + self.mock_post_requests(self.response_for_delete_discussion), + self.mock_post_requests(self.response_for_get_category_ids), + self.mock_post_requests(self.response_for_get_discussion_ids), + self.mock_post_requests(self.response_for_delete_discussion), + self.mock_post_requests(self.response_for_get_category_ids), + self.mock_post_requests(self.response_for_get_repository_id), + self.mock_post_requests(self.response_for_create_discussion), ] with requests_mock.Mocker() as mock_request: @@ -275,8 +276,7 @@ def test_executing_main_function_sends_notification(self) -> None: # to assert the response. with mock.patch( 'requests.post', side_effect=( - post_requests_side_effect + post_requests_side_effect - )) as mock_post: + post_requests_side_effect_1 + post_requests_side_effect_2)) as mock_post: self.assertEqual(mock_request.call_count, 0) self.assertEqual(mock_post.call_count, 0) @@ -286,68 +286,54 @@ def test_executing_main_function_sends_notification(self) -> None: main.main([ '--repo', 'orgName/repo', '--category', 'test_category_name_1', - '--title', 'test_discussion_title_1', + '--title', 'title', '--max-wait-hours', '20', '--token', 'githubTokenForApiRequest' ]) - response_for_get_categories = requests.post( + response_for_get_category_ids = requests.post( github_services.GITHUB_GRAPHQL_URL, timeout=( github_services.TIMEOUT_SECS)) - response_for_get_discussion = requests.post( + response_for_get_discussion_ids = requests.post( github_services.GITHUB_GRAPHQL_URL, timeout=( github_services.TIMEOUT_SECS)) - response_for_get_old_comment_ids = requests.post( + response_for_delete_discussion = requests.post( github_services.GITHUB_GRAPHQL_URL, timeout=( github_services.TIMEOUT_SECS)) - response_for_delete_comment = requests.post( + response_for_get_category_ids = requests.post( github_services.GITHUB_GRAPHQL_URL, timeout=( github_services.TIMEOUT_SECS)) - response_for_get_categories = requests.post( + response_for_get_repository_id = requests.post( github_services.GITHUB_GRAPHQL_URL, timeout=( github_services.TIMEOUT_SECS)) - response_for_get_discussion = requests.post( + response_for_create_discussion = requests.post( github_services.GITHUB_GRAPHQL_URL, timeout=( github_services.TIMEOUT_SECS)) - response_for_post_comment = requests.post( - github_services.GITHUB_GRAPHQL_URL, timeout=( - github_services.TIMEOUT_SECS)) - - self.assertEqual(mock_post.call_count, 17) + self.assertEqual(mock_post.call_count, 15) self.assertEqual(mock_request.call_count, 6) # Here we use MyPy ignore because the response is of Mock type and # Mock does not contain return_value attribute, so because of this MyPy throws an # error. Thus to avoid the error, we used ignore here. self.assertEqual( - response_for_get_categories.json.return_value, self.response_for_get_categories) # type: ignore[attr-defined] - # Here we use MyPy ignore because the response is of Mock type and - # Mock does not contain return_value attribute, so because of this MyPy throws an - # error. Thus to avoid the error, we used ignore here. - self.assertEqual( - response_for_get_discussion.json.return_value, self.response_for_get_discussion) # type: ignore[attr-defined] - # Here we use MyPy ignore because the response is of Mock type and - # Mock does not contain return_value attribute, so because of this MyPy throws an - # error. Thus to avoid the error, we used ignore here. - self.assertEqual( - response_for_get_old_comment_ids.json.return_value, self.response_for_get_old_comment_ids) # type: ignore[attr-defined] + response_for_get_category_ids.json.return_value, self.response_for_get_category_ids) # type: ignore[attr-defined] # Here we use MyPy ignore because the response is of Mock type and # Mock does not contain return_value attribute, so because of this MyPy throws an # error. Thus to avoid the error, we used ignore here. self.assertEqual( - response_for_delete_comment.json.return_value, self.response_for_delete_comment) # type: ignore[attr-defined] + response_for_get_discussion_ids.json.return_value, self.response_for_get_discussion_ids) # type: ignore[attr-defined] # Here we use MyPy ignore because the response is of Mock type and # Mock does not contain return_value attribute, so because of this MyPy throws an # error. Thus to avoid the error, we used ignore here. self.assertEqual( - response_for_get_categories.json.return_value, self.response_for_get_categories) # type: ignore[attr-defined] + response_for_delete_discussion.json.return_value, self.response_for_delete_discussion) # type: ignore[attr-defined] # Here we use MyPy ignore because the response is of Mock type and # Mock does not contain return_value attribute, so because of this MyPy throws an # error. Thus to avoid the error, we used ignore here. self.assertEqual( - response_for_get_discussion.json.return_value, self.response_for_get_discussion) # type: ignore[attr-defined] + response_for_get_repository_id.json.return_value, self.response_for_get_repository_id) # type: ignore[attr-defined] # Here we use MyPy ignore because the response is of Mock type and # Mock does not contain return_value attribute, so because of this MyPy throws an # error. Thus to avoid the error, we used ignore here. self.assertEqual( - response_for_post_comment.json.return_value, self.response_for_post_comment) # type: ignore[attr-defined] + response_for_create_discussion.json.return_value, self.response_for_create_discussion) # type: ignore[attr-defined]