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 all commits
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
53 changes: 53 additions & 0 deletions src/asana/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -146,13 +179,33 @@ 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
"""
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})

Expand Down
53 changes: 53 additions & 0 deletions src/asana/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<body>{pull_request.author_handle()} has asked you to re-review the PR.</body>"
)


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"<body>{pull_request.author_handle()} no longer requires your review.</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 +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
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
15 changes: 14 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,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
1 change: 1 addition & 0 deletions src/asana/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .subtask import Subtask
19 changes: 19 additions & 0 deletions src/asana/models/subtask.py
Original file line number Diff line number Diff line change
@@ -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"]
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

Loading