def maybe_add_automerge_warning_comment(pull_request: PullRequest): """Adds comment warnings if automerge label is enabled""" if SGTM_FEATURE__AUTOMERGE_ENABLED: owner = pull_request.repository_owner_handle() repo_name = pull_request.repository_name() pr_number = pull_request.number() # if a PR has an automerge label and doesn't contain a comment warning, we want to maybe add a warning comment # only add warning comment if it's set to auto-merge after approval and hasn't yet been approved to limit noise if ( ( pull_request_has_label( pull_request, AutomergeLabel.AFTER_TESTS_AND_APPROVAL.value ) or pull_request_has_label( pull_request, AutomergeLabel.AFTER_APPROVAL.value ) ) and not _pull_request_has_automerge_comment(pull_request) and not pull_request.is_approved() ): github_client.add_pr_comment( owner, repo_name, pr_number, AUTOMERGE_COMMENT_WARNING )
def _is_pull_request_ready_for_automerge(pull_request: PullRequest) -> bool: # autofail if not enabled or pull request isn't open if ( not SGTM_FEATURE__AUTOMERGE_ENABLED or pull_request.closed() or pull_request.merged() ): return False # if there are multiple labels, we use the most permissive to define automerge behavior if pull_request_has_label(pull_request, AutomergeLabel.IMMEDIATELY.value): return pull_request.mergeable() in ( MergeableState.MERGEABLE, MergeableState.UNKNOWN, ) if pull_request_has_label(pull_request, AutomergeLabel.AFTER_TESTS.value): return pull_request.is_build_successful() and pull_request.is_mergeable() if pull_request_has_label( pull_request, AutomergeLabel.AFTER_TESTS_AND_APPROVAL.value ): return ( pull_request.is_build_successful() and pull_request.is_mergeable() and pull_request.is_approved() ) if pull_request_has_label(pull_request, AutomergeLabel.AFTER_APPROVAL.value): return pull_request.is_mergeable() and pull_request.is_approved() return False
def _task_status_from_pull_request(pull_request: PullRequest) -> str: if not pull_request.closed(): return "Open" elif pull_request.closed() and pull_request.merged(): return "Merged" elif pull_request.closed() and not pull_request.merged(): return "Closed" else: logger.error("Pull request is in an invalid state") return ""
def _pull_request_review_mentions(pull_request: PullRequest) -> List[str]: review_texts = [review.body() for review in pull_request.reviews()] + [ comment.body() for comments in [review.comments() for review in pull_request.reviews()] for comment in comments ] return [ mention for review_text in review_texts for mention in _extract_mentions(review_text) ]
def maybe_automerge_pull_request(pull_request: PullRequest) -> bool: if _is_pull_request_ready_for_automerge(pull_request): logger.info( f"Pull request {pull_request.id()} is able to be automerged, automerging now" ) github_client.merge_pull_request( pull_request.repository_owner_handle(), pull_request.repository_name(), pull_request.number(), pull_request.title(), pull_request.body(), ) return True else: return False
def get_linked_task_ids(pull_request: PullRequest) -> List[str]: """ Extracts linked task ids from the body of the PR. We expect linked tasks to be in the description in a line under the line containing "Asana tasks:". :return: Returns a list of task ids. """ body_lines = pull_request.body().splitlines() stripped_body_lines = (line.strip() for line in body_lines) task_url_line = None seen_asana_tasks_line = False for line in stripped_body_lines: if seen_asana_tasks_line: task_url_line = line break if line.startswith("Asana tasks:"): seen_asana_tasks_line = True if task_url_line: task_urls = task_url_line.split() task_ids = [] for url in task_urls: maybe_id = re.search("\d+(?!.*\d)", url) if maybe_id is not None: task_ids.append(maybe_id.group()) return task_ids else: return []
def _pull_request_comment_mentions(pull_request: PullRequest) -> List[str]: comment_texts = [comment.body() for comment in pull_request.comments()] return [ mention for comment_text in comment_texts for mention in _extract_mentions(comment_text) ]
def _custom_fields_from_pull_request(pull_request: PullRequest) -> Dict: """ We currently expect the project to have two custom fields with its corresponding enum options: • PR Status: "Open", "Closed", "Merged" • Build: "Success", "Failure" """ repository_id = pull_request.repository_id() project_id = dynamodb_client.get_asana_id_from_github_node_id( repository_id) if project_id is None: logger.info( f"Task not found for pull request {pull_request.id()}. Running a full sync!" ) # TODO: Full sync return {} else: custom_field_settings = list( asana_client.get_project_custom_fields(project_id)) data = {} for custom_field_name, action in _custom_fields_to_extract_map.items(): enum_option_name = action(pull_request) if enum_option_name: custom_field_id = _get_custom_field_id(custom_field_name, custom_field_settings) enum_option_id = _get_custom_field_enum_option_id( custom_field_name, enum_option_name, custom_field_settings) if custom_field_id and enum_option_id: data[custom_field_id] = enum_option_id return data
def pull_request_approved_before_merging(pull_request: PullRequest) -> bool: """ The pull request has been approved if the last review (approval/changes requested) before merging was an approval """ merged_at = pull_request.merged_at() if merged_at is not None: premerge_reviews = [ review for review in pull_request.reviews() if review.is_approval_or_changes_requested() and review.submitted_at() < merged_at ] if premerge_reviews: latest_review = sorted(premerge_reviews, key=lambda r: r.submitted_at())[-1] return latest_review.is_approval() return False
def _task_description_from_pull_request(pull_request: PullRequest) -> str: link_to_pr = _link(pull_request.url()) github_author = pull_request.author() author = _asana_user_url_from_github_user_handle(github_author.login()) if author is None: author = _asana_display_name_for_github_user(github_author) status_reason = _task_completion_from_pull_request(pull_request) status = "complete" if status_reason.is_complete else "incomplete" 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✍️ " + author + _generate_assignee_description(pull_request.assignee()) + f"\n❗️Task is {status} because {status_reason.reason}" + _wrap_in_tag("strong")("\n\nDescription:\n") + _format_github_text_for_asana(pull_request.body()))
def all_pull_request_participants(pull_request: PullRequest) -> List[str]: return list( set( gh_handle for gh_handle in ( [pull_request.author_handle()] + pull_request.assignees() + pull_request.reviewers() + pull_request.requested_reviewers() + _pull_request_commenters(pull_request) + _pull_request_comment_mentions(pull_request) + _pull_request_review_mentions(pull_request) + _pull_request_body_mentions(pull_request) ) if gh_handle ) )
def _task_completion_from_pull_request( pull_request: PullRequest) -> StatusReason: if not pull_request.closed(): return StatusReason(False, "the pull request is open.") elif not pull_request.merged(): return StatusReason( True, "the pull request was closed without merging code.") elif github_logic.pull_request_approved_before_merging(pull_request): return StatusReason(True, "the pull request was approved before merging.") elif github_logic.pull_request_approved_after_merging(pull_request): return StatusReason(True, "the pull request was approved after merging.") else: return StatusReason( False, "the pull request hasn't yet been approved by a reviewer after merging.", )
def pull_request_approved_after_merging(pull_request: PullRequest) -> bool: """ If changes were requested, addressed, and then the PR merge, the state of the pr will still be "changes requested" unless the original review is dismissed and the reviewer is re-requested to review the pr. This is described here: https://stackoverflow.com/questions/40893008/how-to-resume-review-process-after-updating-pull-request-at-github To improve the UX of this process, we will still consider the pr approved if we find a comment on the pr with a marker text, such as "LGTM" or "looks good to me". This method handles this part of the logic. """ merged_at = pull_request.merged_at() if merged_at is not None: # the marker text may occur in any comment in the pr that occurred post-merge # TODO: consider whether we should allow pre-merge comments to have the same effect? It seems likely that # this limitation is just intended to ensure that the asana task is not closed due to a marker text unless # the PR has been merged into next-master, otherwise it might be forgotten in an approved state postmerge_comments = [ comment for comment in pull_request.comments() if comment.published_at() >= merged_at # TODO: consider using the lastEditedAt timestamp. A reviewer might comment: "noice!" prior to the PR being # merged, then update their comment to "noice! LGTM!!!" after it had been merged. This would however not # suffice to cause the PR to be considered approved after merging. ] # or it may occur in the summary text of a review that was submitted after the pr was merged postmerge_reviews = [ review for review in pull_request.reviews() if review.submitted_at() >= merged_at ] body_texts = [c.body() for c in postmerge_comments] + [ r.body() for r in postmerge_reviews ] # TODO: consider whether we should disallow the pr author to approve their own pr via a LGTM comment return bool( [ body_text for body_text in body_texts if _is_approval_comment_body(body_text) ] ) return False
def get_pull_request_and_review(pull_request_id: str, review_id: str) -> Tuple[PullRequest, Review]: data = _execute_graphql_query( GetPullRequestAndReview, { "pullRequestId": pull_request_id, "reviewId": review_id }, ) return PullRequest(data["pullRequest"]), Review(data["review"])
def get_pull_request_and_comment( pull_request_id: str, comment_id: str) -> Tuple[PullRequest, Comment]: data = _execute_graphql_query( GetPullRequestAndComment, { "pullRequestId": pull_request_id, "commentId": comment_id }, ) return PullRequest(data["pullRequest"]), comment_factory(data["comment"])
def get_pull_request_for_commit(commit_id: str) -> Optional[PullRequest]: data = _execute_graphql_query(GetPullRequestForCommit, {"id": commit_id}) edges = data["commit"]["associatedPullRequests"]["edges"] if edges: pull_request = data["commit"]["associatedPullRequests"]["edges"][0][ "node"] return PullRequest(pull_request) else: return None
def upsert_pull_request(pull_request: PullRequest): pull_request_id = pull_request.id() task_id = dynamodb_client.get_asana_id_from_github_node_id(pull_request_id) if task_id is None: task_id = asana_controller.create_task(pull_request.repository_id()) if task_id is None: # TODO: Handle this case return logger.info( f"Task created for pull request {pull_request_id}: {task_id}") dynamodb_client.insert_github_node_to_asana_id_mapping( pull_request_id, task_id) asana_helpers.create_attachments(pull_request.body(), task_id) _add_asana_task_to_pull_request(pull_request, task_id) else: logger.info( f"Task found for pull request {pull_request_id}, updating task {task_id}" ) asana_controller.update_task(pull_request, task_id)
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) if task_id is None: logger.info( f"Task not found for pull request {pull_request_id}. Running a full sync!" ) # TODO: Full sync else: asana_controller.upsert_github_comment_to_task(comment, task_id) asana_controller.update_task(pull_request, task_id)
def assign_pull_request_to_author(pull_request: PullRequest): owner = pull_request.repository_owner_handle() new_assignee = pull_request.author_handle() github_client.set_pull_request_assignee(owner, pull_request.repository_name(), pull_request.number(), new_assignee) # so we don't have to re-query the PR pull_request.set_assignees([new_assignee])
def upsert_review(pull_request: PullRequest, review: Review): pull_request_id = pull_request.id() task_id = dynamodb_client.get_asana_id_from_github_node_id(pull_request_id) if task_id is None: logger.info( f"Task not found for pull request {pull_request_id}. Running a full sync!" ) # TODO: Full sync else: logger.info( 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(): assign_pull_request_to_author(pull_request) asana_controller.update_task(pull_request, task_id)
def _add_asana_task_to_pull_request(pull_request: PullRequest, task_id: str): owner = pull_request.repository_owner_handle() task_url = asana_helpers.task_url_from_task_id(task_id) new_body = github_logic.inject_asana_task_into_pull_request_body( pull_request.body(), task_url) github_client.edit_pr_description(owner, pull_request.repository_name(), pull_request.number(), new_body) # Update the PullRequest object to represent the new body, so we don't have # to query it again pull_request.set_body(new_body)
def update_task(pull_request: PullRequest, task_id: str): task_url = asana_helpers.task_url_from_task_id(task_id) pr_url = pull_request.url() logger.info(f"Updating task {task_url} for pull request {pr_url}") fields = asana_helpers.extract_task_fields_from_pull_request(pull_request) # TODO: Should extract_task_fields_from_pull_request be broken into two # methods, one for fields and one for followers? update_task_fields = { k: v for k, v in fields.items() if k in ("assignee", "name", "html_notes", "completed", "custom_fields") } asana_client.update_task(task_id, update_task_fields) asana_client.add_followers(task_id, fields["followers"]) maybe_complete_tasks_on_merge(pull_request)
def build(self) -> PullRequest: return PullRequest(self.raw_pr)
def _build_status_from_pull_request( pull_request: PullRequest) -> Optional[str]: build_status = pull_request.build_status() return build_status.capitalize() if build_status is not None else None
def pull_request_has_label(pull_request: PullRequest, label: str) -> bool: label_names = map(lambda label: label.name(), pull_request.labels()) return label in label_names
def _pull_request_commenters(pull_request: PullRequest) -> List[str]: return sorted(comment.author_handle() for comment in pull_request.comments())
def _pull_request_body_mentions(pull_request: PullRequest) -> List[str]: return _extract_mentions(pull_request.body())
def get_pull_request(pull_request_id: str) -> PullRequest: data = _execute_graphql_query(GetPullRequest, {"id": pull_request_id}) return PullRequest(data["pullRequest"])
def should_autocomplete_tasks_on_merge(pull_request: PullRequest) -> bool: return (SGTM_FEATURE__AUTOCOMPLETE_ENABLED and pull_request.merged() and pull_request_has_label( pull_request, AutocompleteLabel.COMPLETE_ON_MERGE.value))
def _pull_request_has_automerge_comment(pull_request: PullRequest) -> bool: return any( comment.body() == AUTOMERGE_COMMENT_WARNING for comment in pull_request.comments() )