Exemple #1
0
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
            )
Exemple #2
0
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
Exemple #3
0
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 ""
Exemple #4
0
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)
    ]
Exemple #5
0
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
Exemple #6
0
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 []
Exemple #7
0
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)
    ]
Exemple #8
0
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
Exemple #9
0
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
Exemple #10
0
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()))
Exemple #11
0
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
        )
    )
Exemple #12
0
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.",
        )
Exemple #13
0
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
Exemple #14
0
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"])
Exemple #15
0
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"])
Exemple #16
0
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
Exemple #17
0
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)
Exemple #18
0
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)
Exemple #19
0
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])
Exemple #20
0
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)
Exemple #21
0
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)
Exemple #22
0
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)
Exemple #23
0
 def build(self) -> PullRequest:
     return PullRequest(self.raw_pr)
Exemple #24
0
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
Exemple #25
0
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
Exemple #26
0
def _pull_request_commenters(pull_request: PullRequest) -> List[str]:
    return sorted(comment.author_handle() for comment in pull_request.comments())
Exemple #27
0
def _pull_request_body_mentions(pull_request: PullRequest) -> List[str]:
    return _extract_mentions(pull_request.body())
Exemple #28
0
def get_pull_request(pull_request_id: str) -> PullRequest:
    data = _execute_graphql_query(GetPullRequest, {"id": pull_request_id})
    return PullRequest(data["pullRequest"])
Exemple #29
0
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))
Exemple #30
0
def _pull_request_has_automerge_comment(pull_request: PullRequest) -> bool:
    return any(
        comment.body() == AUTOMERGE_COMMENT_WARNING
        for comment in pull_request.comments()
    )