diff --git a/src/asana/client.py b/src/asana/client.py index f3c3bc93..fdd3b3e5 100644 --- a/src/asana/client.py +++ b/src/asana/client.py @@ -2,6 +2,7 @@ from typing_extensions import Literal import asana # type: ignore from src.config import ASANA_API_KEY +from src.asana.models import Subtask # See: https://developers.asana.com/docs/input-output-options # As we use more opt_fields, add to this list @@ -59,6 +60,38 @@ def create_task(self, project_id: str, due_date_str: str = None) -> str: response = self.asana_api_client.tasks.create(create_task_params) return response["gid"] + def create_subtask( + self, parent_task_id: str, assignee: str, task_name: str, task_description, + due_date_str: str = None + ) -> str: + """ + Creates an Asana subtask for the given parent task, returning the task_id + """ + validate_object_id(parent_task_id, "AsanaClient.create_subtask requires a task_id") + + create_task_params = { + "name": task_name, + "html_notes": task_description, + "assignee": assignee, + "parent": parent_task_id + } + if due_date_str: + create_task_params["due_on"] = due_date_str + response = self.asana_api_client.tasks.create(create_task_params) + return response["gid"] + + def get_task_completed_status(self, task_id: str) -> bool: + """Returns bool value representing the task completed status.""" + response = self.asana_api_client.tasks.find_by_id(task_id, opt_fields=["completed"]) + return response["completed"] + + def get_subtasks(self, parent_task_id: str) -> List[Subtask]: + response = self.asana_api_client.tasks.subtasks( + parent_task_id, opt_fields=["completed", "assignee"] + ) + return [Subtask(raw_subtask) for raw_subtask in response] + + def update_task(self, task_id: str, fields: dict): """ Updates the specified Asana task, setting the provided fields @@ -146,6 +179,22 @@ def create_task(project_id: str, due_date_str: str = None) -> str: return AsanaClient.singleton().create_task(project_id, due_date_str=due_date_str) +def create_subtask( + parent_task_id: str, assignee: str, task_name: str, task_description, + due_date_str: str = None +) -> str: + """ + Creates an Asana task and makes is a subtask of the given task id + """ + return AsanaClient.singleton().create_subtask( + parent_task_id, assignee, task_name, task_description, due_date_str + ) + + +def get_subtasks(parent_task_id: str) -> List[Subtask]: + return AsanaClient.singleton().get_subtasks(parent_task_id) + + def update_task(task_id: str, fields: dict): """ Updates the specified Asana task, setting the provided fields @@ -153,6 +202,10 @@ def update_task(task_id: str, fields: dict): return AsanaClient.singleton().update_task(task_id, fields) +def get_task_completed_status(task_id) -> bool: + return AsanaClient.singleton().get_task_completed_status(task_id) + + def complete_task(task_id: str): return update_task(task_id, {"completed": True}) diff --git a/src/asana/controller.py b/src/asana/controller.py index f13b81a7..9f9caea5 100644 --- a/src/asana/controller.py +++ b/src/asana/controller.py @@ -38,6 +38,52 @@ def update_task(pull_request: PullRequest, task_id: str): maybe_complete_tasks_on_merge(pull_request) +def create_review_subtask(pull_request: PullRequest, parent_id: str, reviewer_handle: str) -> str: + asana_id = asana_helpers.asana_user_id_from_github_handle(reviewer_handle) + task_name = asana_helpers.subtask_name_from_pull_request(pull_request) + task_description = asana_helpers.subtask_description_from_pull_request( + pull_request, reviewer_handle + ) + due_date_str = asana_helpers.default_due_date_str() + return asana_client.create_subtask( + parent_id, asana_id, task_name, task_description, due_date_str=due_date_str + ) + + +def update_subtask(pull_request: PullRequest, subtask_id: str, reviewer_handle: str): + # TODO: Consider updating the completed field here when a PR has been merged. + # Maybe close out tasks for non-assigned reviewers that have not completed their review. + fields = { + "name": asana_helpers.subtask_name_from_pull_request(pull_request), + "html_notes": asana_helpers.subtask_description_from_pull_request( + pull_request, reviewer_handle + ) + } + asana_client.update_task(subtask_id, fields) + + +def reopen_subtask_if_completed(pull_request: PullRequest, subtask_id: str): + is_task_completed = asana_client.get_task_completed_status(subtask_id) + if is_task_completed: + # We must re-open the task + asana_client.update_task(subtask_id, {"completed": False}) + asana_client.add_comment( + subtask_id, + f"{pull_request.author_handle()} has asked you to re-review the PR." + ) + + +def complete_subtask_after_review_request_removal(pull_request: PullRequest, subtask_id: str): + asana_client.update_task(subtask_id, {"completed": True}) + # TODO: Should we delete the task if the removal of a reviewer happens very quickly + # after the task creation? Would that clear the item out of the inbox of the requested reviewer + # in case of accidental assigns. + asana_client.add_comment( + subtask_id, + f"{pull_request.author_handle()} no longer requires your review." + ) + + def maybe_complete_tasks_on_merge(pull_request: PullRequest): if asana_logic.should_autocomplete_tasks_on_merge(pull_request): task_ids_to_complete_on_merge = asana_helpers.get_linked_task_ids(pull_request) @@ -96,6 +142,13 @@ def upsert_github_review_to_task(review: Review, task_id: str): ) +def update_subtask_after_review(pull_request: PullRequest, review: Review, subtask_id: str) -> None: + logger.info(f"Updating subtask {subtask_id} for pull request {pull_request.url()}") + if asana_logic.should_complete_subtask_after_review(pull_request, review): + if asana_client.get_task_completed_status(subtask_id) is False: + asana_client.complete_task(subtask_id) + + def delete_comment(github_comment_id: str): asana_comment_id = dynamodb_client.get_asana_id_from_github_node_id( github_comment_id diff --git a/src/asana/helpers.py b/src/asana/helpers.py index b96ca91f..db059331 100644 --- a/src/asana/helpers.py +++ b/src/asana/helpers.py @@ -164,10 +164,10 @@ def _get_custom_field_enum_option_id( def _task_assignee_from_pull_request(pull_request: PullRequest) -> Optional[str]: assignee = pull_request.assignee() - return _asana_user_id_from_github_handle(assignee.login) + return asana_user_id_from_github_handle(assignee.login) -def _asana_user_id_from_github_handle(github_handle: str) -> Optional[str]: +def asana_user_id_from_github_handle(github_handle: str) -> Optional[str]: return dynamodb_client.get_asana_domain_user_id_from_github_handle(github_handle) @@ -188,20 +188,43 @@ def _asana_display_name_for_github_user(github_user: User) -> str: def _asana_user_url_from_github_user_handle(github_handle: str) -> Optional[str]: - user_id = _asana_user_id_from_github_handle(github_handle) + user_id = asana_user_id_from_github_handle(github_handle) if user_id is None: return None return f'' def _task_name_from_pull_request(pull_request: PullRequest) -> str: - return "#{} - {}".format(pull_request.number(), pull_request.title()) + return f"#{pull_request.number()} - {pull_request.title()}" + + +def subtask_name_from_pull_request(pull_request: PullRequest) -> str: + return f"Review #{pull_request.number()} - {pull_request.title()}" + + +def subtask_description_from_pull_request(pull_request: PullRequest, reviewer_handle: str) -> str: + link_to_pr = _link(pull_request.url()) + + assignee_text = "" + if reviewer_handle in pull_request.assignees(): + assignee_text = _wrap_in_tag("strong")("\n\nYou are assignee on this PR.\n") +\ + "As an assignee you are expected to complete a review that is " +\ + "either an approval or request changes." + + return _wrap_in_tag("body")( + _wrap_in_tag("em")( + "This is a one-way sync from GitHub to Asana. Do not edit this task or comment on it!" + ) + + f"\n\n\uD83D\uDD17 {link_to_pr}" + + "\n\nSee parent task for more details.\n" + + assignee_text + ) def _transform_github_mentions_to_asana_mentions(text: str) -> str: def _github_mention_to_asana_mention(match: Match[str]) -> str: github_handle = match.group(1) - asana_user_id = _asana_user_id_from_github_handle(github_handle) + asana_user_id = asana_user_id_from_github_handle(github_handle) if asana_user_id is None: # Return the full matched string, including the "@" return match.group(0) @@ -430,9 +453,9 @@ def _task_completion_from_pull_request(pull_request: PullRequest) -> StatusReaso def _task_followers_from_pull_request(pull_request: PullRequest): return [ - _asana_user_id_from_github_handle(gh_handle) + asana_user_id_from_github_handle(gh_handle) for gh_handle in github_logic.all_pull_request_participants(pull_request) - if _asana_user_id_from_github_handle(gh_handle) is not None + if asana_user_id_from_github_handle(gh_handle) is not None ] diff --git a/src/asana/logic.py b/src/asana/logic.py index 4f494942..98c9eea5 100644 --- a/src/asana/logic.py +++ b/src/asana/logic.py @@ -1,4 +1,4 @@ -from src.github.models import PullRequest +from src.github.models import Review, PullRequest from src.github.helpers import pull_request_has_label from enum import Enum, unique from src.config import SGTM_FEATURE__AUTOCOMPLETE_ENABLED @@ -18,3 +18,16 @@ def should_autocomplete_tasks_on_merge(pull_request: PullRequest) -> bool: pull_request, AutocompleteLabel.COMPLETE_ON_MERGE.value ) ) + + +def should_complete_subtask_after_review(pull_request: PullRequest, review: Review) -> bool: + """Determine if a subtask of PR task should be completed based on given review.""" + if review.author_handle() not in pull_request.assignees(): + return True + else: + return review.is_approval_or_changes_requested() + + +def should_complete_subtask_after_pr_merge(pull_request: PullRequest, task_assignee: str): + """Determine if a subtask of PR task should be completed on PR merge.""" + pass diff --git a/src/asana/models/__init__.py b/src/asana/models/__init__.py index e69de29b..ba4e22d7 100644 --- a/src/asana/models/__init__.py +++ b/src/asana/models/__init__.py @@ -0,0 +1 @@ +from .subtask import Subtask diff --git a/src/asana/models/subtask.py b/src/asana/models/subtask.py new file mode 100644 index 00000000..7881eaf8 --- /dev/null +++ b/src/asana/models/subtask.py @@ -0,0 +1,19 @@ +from typing import Optional, Dict, Any +import copy + + +class Subtask(object): + def __init__(self, raw_subtask: Dict[str, Any]): + self._raw = copy.deepcopy(raw_subtask) + + def id(self) -> str: + return self._raw["gid"] + + def completed(self) -> bool: + return self._raw["completed"] + + def assignee_id(self) -> Optional[str]: + if self._raw["assignee"] is None: + return None + + return self._raw["assignee"]["gid"] diff --git a/src/dynamodb/client.py b/src/dynamodb/client.py index f99c39c8..934d33dd 100644 --- a/src/dynamodb/client.py +++ b/src/dynamodb/client.py @@ -210,6 +210,33 @@ def get_asana_domain_user_id_from_github_handle(github_handle: str) -> Optional[ ) +def get_asana_id_from_two_github_node_ids(gh_node_id_a: str, gh_node_id_b: str) -> Optional[str]: + """ + Using the singleton instance of DynamoDbClient, creating it if necessary: + + Retrieves the Asana object-id associated with the specified GitHub node-ids, + or None, if no such association exists. Object-table associations are created + by SGTM via the insert_github_node_to_asana_id_mapping method, below. + """ + return DynamoDbClient.singleton().get_asana_id_from_github_node_id( + _get_dynamodb_key_from_two_github_nodes(gh_node_id_a, gh_node_id_b) + ) + + +def insert_two_github_node_to_asana_id_mapping(gh_node_id_a: str, gh_node_id_b: str, asana_id: str): + """ + Using the singleton instance of DynamoDbClient, creating it if necessary: + + Creates an association between two GitHub node-ids and an Asana object-id. The dynamoDb + key is formed by concatenating the two GitHub node ids using a "-" separator. + """ + dynamo_db_key = _get_dynamodb_key_from_two_github_nodes(gh_node_id_a, gh_node_id_b) + print(f"Inserting key {dynamo_db_key} to DynamoDb.") + return DynamoDbClient.singleton().insert_github_node_to_asana_id_mapping( + _get_dynamodb_key_from_two_github_nodes(gh_node_id_a, gh_node_id_b), asana_id + ) + + def get_all_user_items() -> List[dict]: return DynamoDbClient.singleton().get_all_user_items() @@ -236,3 +263,6 @@ def bulk_insert_github_handle_to_asana_user_id_mapping( DynamoDbClient.singleton().bulk_insert_github_handle_to_asana_user_id_mapping( gh_and_asana_ids ) + +def _get_dynamodb_key_from_two_github_nodes(gh_node_id_a: str, gh_node_id_b: str) -> str: + return f"{gh_node_id_a}-{gh_node_id_b}" diff --git a/src/github/controller.py b/src/github/controller.py index 8028bd16..48a01afe 100644 --- a/src/github/controller.py +++ b/src/github/controller.py @@ -1,3 +1,4 @@ +from typing import Optional, Set import src.dynamodb.client as dynamodb_client import src.asana.controller as asana_controller from . import logic as github_logic @@ -5,6 +6,7 @@ import src.asana.helpers as asana_helpers from src.github.models import Comment, PullRequest, Review from src.logger import logger +from src.asana.client import get_subtasks def upsert_pull_request(pull_request: PullRequest): @@ -14,6 +16,7 @@ def upsert_pull_request(pull_request: PullRequest): task_id = asana_controller.create_task(pull_request.repository_id()) if task_id is None: # TODO: Handle this case + logger.error(f"No task id returned from create task {pull_request_id}") return logger.info(f"Task created for pull request {pull_request_id}: {task_id}") @@ -25,6 +28,7 @@ def upsert_pull_request(pull_request: PullRequest): f"Task found for pull request {pull_request_id}, updating task {task_id}" ) asana_controller.update_task(pull_request, task_id) + upsert_and_update_subtasks(pull_request, task_id) def _add_asana_task_to_pull_request(pull_request: PullRequest, task_id: str): @@ -42,6 +46,119 @@ def _add_asana_task_to_pull_request(pull_request: PullRequest, task_id: str): pull_request.set_body(new_body) +def upsert_and_update_subtasks(pull_request: PullRequest, task_id: str) -> None: + """ + Create subtasks for the reviewers. + + This is synchronized with dynamodb so we only create a subtask once for each reviewer. + + We go through the list of requested reviewers and check if they have a subtask created already. + If not, we create the subtask. + + We also check if a subtask should be re-opened in case of re-requested reviews or if a subtask + should be closed out when a requested review is removed. + """ + # We use this for later when we want to determine if a task needs to be completed. + subtasks = get_subtasks(task_id) + + seen_subtasks = set() + for reviewer in pull_request.requested_reviewers(): + subtask_id = dynamodb_client.get_asana_id_from_two_github_node_ids( + pull_request.id(), reviewer.id() + ) + if subtask_id is None: + created_subtask_id = asana_controller.create_review_subtask( + pull_request, task_id, reviewer.login() + ) + if created_subtask_id is None: + # TODO: Handle this case + logger.error( + f"No subtask id returned from create subtask {pull_request.id()}: {task_id}" + ) + continue + + logger.info( + f"Subtask created for pull request {pull_request.id()}: {task_id}: {created_subtask_id}" + ) + dynamodb_client.insert_two_github_node_to_asana_id_mapping( + pull_request.id(), reviewer.id(), created_subtask_id + ) + else: + seen_subtasks.add(subtask_id) + # Update the description and name if necessary. + asana_controller.update_subtask(pull_request, subtask_id, reviewer.login()) + # Check if we need to re-open it. + asana_controller.reopen_subtask_if_completed(pull_request, subtask_id) + + # Now we have: + # * Created a subtask for new review requests. + # * Updated all subtasks that already existed with latest info from PR. + # * Re-opened any subtask necessary because of a re-requested review. + # + # What we have not done is completed subtasks for those reviewers that review request might + # have been removed. + + memoized_github_assignees_asana_ids: Optional[Set[str]] = None + print(seen_subtasks) + + for subtask in subtasks: + if subtask.id() in seen_subtasks: + # This subtask already came up as we went through the requested reviewers. Carry on. + continue + + # Now we need to get the github handle of the assignee. + if subtask.assignee_id() is None: + continue + + if subtask.completed() is False: + # We are only interested in those subtasks that are still open. + # If a task is still open there are two scenarios: + # 1. The subtask assignee is an assignee on the Github PR and left a comment review. + # In this scenario we do not want to do anything, as we still expect the assignee + # to come back to this task and complete the review. + # 2. The subtask assignee was removed as a requested reviewer. In this scenario we + # want to close out the task with a comment. By checking if the subtask assignee + # is not in the list of Github assignees we can complete the task. + if memoized_github_assignees_asana_ids is None: + # Let's generate a mapping from github handle to asana user id for all assignees + # on the github PR. + memoized_github_assignees_asana_ids = set() + for assignee in pull_request.assignees(): + asana_id = dynamodb_client.get_asana_domain_user_id_from_github_handle(assignee) + memoized_github_assignees_asana_ids.add(asana_id) + + if subtask.assignee_id() not in memoized_github_assignees_asana_ids: + # We have scenario two. Let's complete the task. + asana_controller.complete_subtask_after_review_request_removal( + pull_request, subtask.id() + ) + + +def update_subtasks(pull_request: PullRequest, task_id: str) -> None: + """Syncs the subtasks based on latest version of the PR. + + Here we want to do three things: + 1. Sync updates to the PR name and other content to the subtask. + 2. Re-open, with note, subtasks that were completed since a new review has been requested. + 3. Close, with note, subtasks where reviewer has been removed. + """ + for reviewer in pull_request.requested_reviewers(): + subtask_id = dynamodb_client.get_asana_id_from_two_github_node_ids( + pull_request.id(), reviewer.id() + ) + if subtask_id is None: + # This should not happen as we should already have created subtasks for all + # requested reviewers. + # TODO: Handle this case + logger.error( + f"No subtask id returned for requested reviewer. PR {pull_request.id()} Reviewer: {reviewer.id()}" + ) + asana_controller.reopen_subtask_if_completed(pull_request, subtask_id) + + # TODO: Find tasks to be closed with comment. + # TODO: Update the subtask title and task description if applicable. + + def upsert_comment(pull_request: PullRequest, comment: Comment): pull_request_id = pull_request.id() task_id = dynamodb_client.get_asana_id_from_github_node_id(pull_request_id) @@ -68,9 +185,27 @@ def upsert_review(pull_request: PullRequest, review: Review): f"Found task id {task_id} for pull_request {pull_request_id}. Adding review now." ) asana_controller.upsert_github_review_to_task(review, task_id) - if review.is_approval_or_changes_requested(): + if github_logic.should_reassign_to_author(pull_request, review): assign_pull_request_to_author(pull_request) asana_controller.update_task(pull_request, task_id) + update_subtask_after_review(pull_request, review) + + +def update_subtask_after_review(pull_request: PullRequest, review: Review) -> None: + subtask_id = dynamodb_client.get_asana_id_from_two_github_node_ids( + pull_request.id(), review.author().id() + ) + if subtask_id is None: + # This happens if the review was made by a person not requested to review + # TODO: Test this scenario and delete the print and logging statement. + print("Review made by a person that was not requested to review") + logger.info( + f"Could not find subtask for author {review.author().id()} on PR {pull_request.id()}" + ) + else: + # Here we want to update the subtask. + # TODO do we want to track this with dynamodb so this only gets executed once? + asana_controller.update_subtask_after_review(pull_request, review, subtask_id) def assign_pull_request_to_author(pull_request: PullRequest): diff --git a/src/github/graphql/fragments/FullPullRequest.py b/src/github/graphql/fragments/FullPullRequest.py index 5ac61877..976a4f99 100644 --- a/src/github/graphql/fragments/FullPullRequest.py +++ b/src/github/graphql/fragments/FullPullRequest.py @@ -28,14 +28,18 @@ nodes { requestedReviewer { ... on User { + name login + id } ... on Team { name members(last:20) { nodes { ... on User { + name login + id } } } diff --git a/src/github/graphql/fragments/FullReview.py b/src/github/graphql/fragments/FullReview.py index 17f0d4a4..75a68655 100644 --- a/src/github/graphql/fragments/FullReview.py +++ b/src/github/graphql/fragments/FullReview.py @@ -9,6 +9,7 @@ login ... on User { name + id } } body diff --git a/src/github/logic.py b/src/github/logic.py index 4d7b784f..1ff9e79d 100644 --- a/src/github/logic.py +++ b/src/github/logic.py @@ -2,7 +2,7 @@ from typing import List from src.logger import logger from . import client as github_client -from src.github.models import PullRequest, MergeableState +from src.github.models import PullRequest, MergeableState, Review from enum import Enum, unique from src.github.helpers import pull_request_has_label from src.config import SGTM_FEATURE__AUTOMERGE_ENABLED @@ -142,7 +142,7 @@ def all_pull_request_participants(pull_request: PullRequest) -> List[str]: [pull_request.author_handle()] + pull_request.assignees() + pull_request.reviewers() - + pull_request.requested_reviewers() + + pull_request.requested_reviewers_logins() + _pull_request_commenters(pull_request) + _pull_request_comment_mentions(pull_request) + _pull_request_review_mentions(pull_request) @@ -200,6 +200,35 @@ def maybe_automerge_pull_request(pull_request: PullRequest) -> bool: return False +def should_reassign_to_author(pull_request: PullRequest, review: Review) -> bool: + """Check if we should reassign PR to author after a given review. + + Return True if the PR was reassigned to the author. + """ + assignees = pull_request.assignees() + + if pull_request.author_handle() in assignees: + # The author handle is already in the assignees list. Nothing to do here. + return False + + if review.is_changes_requested(): + # This review has requested changes and the PR should be assigned to the author. + return True + + if len(assignees) == 0: + # This deals with the case where there is no assignee. Here we only want to assign to the + # author if the review is approval. + return review.is_approval() + else: + # We have at least 1 assignee and the review is not request changes. + if review.author_handle() not in assignees: + # This reviewer is not one of the assignees. The PR should not be re-assigned. + return False + + # We only reassign to the author if all of the assignees have approved the PR. + return all(_assignee_has_approved(assignee) for assignee in assignees) + + # ---------------------------------------------------------------------------------- # Automerge helpers # ---------------------------------------------------------------------------------- @@ -244,3 +273,15 @@ def _pull_request_has_automerge_comment(pull_request: PullRequest) -> bool: comment.body() == AUTOMERGE_COMMENT_WARNING for comment in pull_request.comments() ) + + +# ---------------------------------------------------------------------------------- +# Should reassign to author helpers +# ---------------------------------------------------------------------------------- + +def _assignee_has_approved(pull_request: PullRequest, assignee: str): + assignee_last_review = pull_request.get_last_review_of_user(assignee) + if assignee_last_review is None: + return False + else: + return assignee_last_review.is_approval() diff --git a/src/github/models/pull_request.py b/src/github/models/pull_request.py index 597ddb20..8dc7cff8 100644 --- a/src/github/models/pull_request.py +++ b/src/github/models/pull_request.py @@ -50,21 +50,26 @@ def set_assignees(self, assignees: List[str]): ] self._assignees = self._assignees_from_raw() - def requested_reviewers(self) -> List[str]: - reviewer_logins = set() + def requested_reviewers(self) -> List[User]: + users = set() for node in self._raw["reviewRequests"]["nodes"]: if ( node["requestedReviewer"] is not None and "login" in node["requestedReviewer"] ): - reviewer_logins.add(node["requestedReviewer"]["login"]) + users.add(User(node["requestedReviewer"])) elif ( node["requestedReviewer"] is not None and "members" in node["requestedReviewer"] ): for reviewer in node["requestedReviewer"]["members"].get("nodes", []): - reviewer_logins.add(reviewer["login"]) - return sorted(reviewer_logins) + users.add(User(reviewer)) + + return sorted(users, key=lambda x: x.login()) + + + def requested_reviewers_logins(self) -> List[str]: + return [user.login() for user in self.requested_reviewers()] def reviewers(self) -> List[str]: return [review.author_handle() for review in self.reviews()] @@ -179,6 +184,18 @@ def merged_at(self) -> Optional[datetime]: def reviews(self) -> List[Review]: return [Review(review) for review in self._raw["reviews"]["nodes"]] + def get_last_review_of_user(self, user_handle: str) -> Optional[Review]: + last_review: Optional[Review] = None + for review in self.reviews(): + if review.author_handle() == user_handle: + if last_review is None: + last_review = review + else: + if review.submitted_at() > last_review.submitted_at(): + last_review = review + + return last_review + def comments(self) -> List[IssueComment]: return [IssueComment(comment) for comment in self._raw["comments"]["nodes"]] diff --git a/test/github/models/test_pull_request.py b/test/github/models/test_pull_request.py index 11a24079..6931bf4f 100644 --- a/test/github/models/test_pull_request.py +++ b/test/github/models/test_pull_request.py @@ -12,7 +12,9 @@ def test_requested_reviewers_github_users_or_teams(self): .requested_reviewers([user]) .requested_reviewer_team("some_team", ["user1", "user2"]) ) - self.assertEqual(["user1", "user2"], pull_request.requested_reviewers()) + reviewers = pull_request.requested_reviewers() + reviewer_logins = [reviewer.login for reviewer in reviewers] + self.assertEqual(["user1", "user2"], reviewer_logins) if __name__ == "__main__":