Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create review subtasks for requested reviewers #110

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/asana/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,31 @@ 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, reviewer: str, task_name: str, task_description,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd just call this assignee since this is a wrapper around the Asana API and doesn't necessarily need to know the context of "reviewer" - just to format and forward the request to the API

Suggested change
self, parent_task_id: str, reviewer: str, task_name: str, task_description,
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": reviewer,
"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 update_task(self, task_id: str, fields: dict):
"""
Updates the specified Asana task, setting the provided fields
Expand Down Expand Up @@ -146,13 +171,29 @@ 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, reviewer_id: 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, reviewer_id, task_name, task_description, due_date_str
)


def update_task(task_id: str, fields: dict):
"""
Updates the specified Asana task, setting the provided fields
"""
return AsanaClient.singleton().update_task(task_id, fields)


def is_task_completed(task_id) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the pattern established here is to keep the naming consistent

Suggested change
def is_task_completed(task_id) -> bool:
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})

Expand Down
29 changes: 29 additions & 0 deletions src/asana/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ def update_task(pull_request: PullRequest, task_id: str):
maybe_complete_tasks_on_merge(pull_request)


def create_review_subtask(pull_request: PullRequest, parent_task_id: str, reviewer_handle: str):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def create_review_subtask(pull_request: PullRequest, parent_task_id: str, reviewer_handle: str):
def create_review_subtask(pull_request: PullRequest, parent_task_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_task_id, asana_id, task_name, task_description, due_date_str=due_date_str
)


def update_subtask(pull_request: PullRequest, subtask_id):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming here is a bit confusing, perhaps just reopen_subtask_if_completed would be more accurate?

is_task_completed = asana_client.is_task_completed(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"<body>{pull_request.author_handle()} has asked you to re-review the PR.</body>"
)


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)
Expand Down Expand Up @@ -96,6 +119,12 @@ 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(pull_request, review):
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
Expand Down
37 changes: 30 additions & 7 deletions src/asana/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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'<a data-asana-gid="{user_id}"/>'


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)
Expand Down Expand Up @@ -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
]


Expand Down
10 changes: 9 additions & 1 deletion src/asana/logic.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,3 +18,11 @@ def should_autocomplete_tasks_on_merge(pull_request: PullRequest) -> bool:
pull_request, AutocompleteLabel.COMPLETE_ON_MERGE.value
)
)


def should_complete_subtask(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()
30 changes: 30 additions & 0 deletions src/dynamodb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
print(f"Inserting key {dynamo_db_key} to DynamoDb.")
logger.info(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()

Expand All @@ -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}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this function should sort the two node ids, so we don't have to remember which order to pass them in and reduce bugs around unexpected orders

78 changes: 78 additions & 0 deletions src/github/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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}")
Expand All @@ -25,6 +26,8 @@ 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_subtasks(pull_request, task_id)
update_subtasks(pull_request, task_id)


def _add_asana_task_to_pull_request(pull_request: PullRequest, task_id: str):
Expand All @@ -42,6 +45,63 @@ def _add_asana_task_to_pull_request(pull_request: PullRequest, task_id: str):
pull_request.set_body(new_body)


def upsert_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.
"""
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
)


def update_subtasks(pull_request: PullRequest, task_id: str) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be more efficient to do this and upsert_subtasks in one loop rather than two separate functions. Mostly because it would cut the calls to dynamodb in half and updating wouldn't be necessary for newly created subtasks

"""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.update_subtask(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)
Expand Down Expand Up @@ -71,6 +131,24 @@ def upsert_review(pull_request: PullRequest, review: Review):
if review.is_approval_or_changes_requested():
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):
Expand Down
4 changes: 4 additions & 0 deletions src/github/graphql/fragments/FullPullRequest.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,18 @@
nodes {
requestedReviewer {
... on User {
name
login
id
}
... on Team {
name
members(last:20) {
nodes {
... on User {
name
login
id
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/github/graphql/fragments/FullReview.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
login
... on User {
name
id
}
}
body
Expand Down
2 changes: 1 addition & 1 deletion src/github/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading