예제 #1
0
async def test_attempting_to_notify_pr_author_with_no_automerge_label(
    api_client: queries.Client,
    mocker: MockFixture,
    event_response: queries.EventInfoResponse,
) -> None:
    """
    ensure that when Kodiak encounters a merge conflict it doesn't notify
    the user if an automerge label isn't required.
    """

    pr = PR(
        number=123,
        owner="ghost",
        repo="ghost",
        installation_id="abc123",
        client=api_client,
    )
    assert isinstance(event_response.config, V1)
    event_response.config.merge.require_automerge_label = False
    pr.event = event_response

    create_comment = mocker.patch.object(
        PR, "create_comment", return_value=wrap_future(None)
    )
    # mock to ensure we have a chance of hitting the create_comment call
    mocker.patch.object(PR, "delete_label", return_value=wrap_future(True))

    assert await pr.notify_pr_creator() is False
    assert not create_comment.called
예제 #2
0
async def test_cross_repo_missing_head(
    event_response: queries.EventInfoResponse, mocker: MockFixture
) -> None:
    """
    if a repository is from a fork (isCrossRepository), we will not be able to
    see head information due to a problem with the v4 api failing to return head
    information for forks, unlike the v3 api.
    """

    event_response.head_exists = False
    event_response.pull_request.isCrossRepository = True
    assert event_response.pull_request.mergeStateStatus == MergeStateStatus.BEHIND
    event_response.pull_request.labels = ["automerge"]
    assert event_response.branch_protection is not None
    event_response.branch_protection.requiresApprovingReviews = False
    event_response.branch_protection.requiresStrictStatusChecks = True
    mocker.patch.object(PR, "get_event", return_value=wrap_future(event_response))
    set_status = mocker.patch.object(PR, "set_status", return_value=wrap_future(None))
    pr = PR(
        number=123,
        owner="tester",
        repo="repo",
        installation_id="abc",
        client=queries.Client(owner="tester", repo="repo", installation_id="abc"),
    )
    await pr.mergeability()

    assert set_status.call_count == 1
    set_status.assert_called_with(
        summary=mocker.ANY, markdown_content=messages.FORKS_CANNOT_BE_UPDATED
    )
예제 #3
0
async def test_deleting_branch_after_merge(
    labels: List[str],
    expected: bool,
    event_response: queries.EventInfoResponse,
    mocker: MockFixture,
) -> None:
    """
    ensure client.delete_branch is called when a PR that is already merged is
    evaluated.
    """

    event_response.pull_request.state = queries.PullRequestState.MERGED
    event_response.pull_request.labels = labels
    assert isinstance(event_response.config, V1)
    event_response.config.merge.delete_branch_on_merge = True

    mocker.patch.object(PR, "get_event", return_value=wrap_future(event_response))
    mocker.patch.object(PR, "set_status", return_value=wrap_future(None))

    delete_branch = mocker.patch.object(
        queries.Client, "delete_branch", return_value=wrap_future(True)
    )

    pr = PR(
        number=123,
        owner="tester",
        repo="repo",
        installation_id="abc",
        client=queries.Client(owner="tester", repo="repo", installation_id="abc"),
    )

    await pr.mergeability()

    assert delete_branch.called == expected
예제 #4
0
def pr() -> PR:
    return PR(
        number=123,
        owner="tester",
        repo="repo",
        installation_id="abc",
        client=Client(owner="tester", repo="repo", installation_id="abc"),
    )
예제 #5
0
def test_pr(api_client: queries.Client) -> None:
    a = PR(
        number=123,
        owner="ghost",
        repo="ghost",
        installation_id="abc123",
        client=api_client,
    )
    b = PR(
        number=123,
        owner="ghost",
        repo="ghost",
        installation_id="abc123",
        client=api_client,
    )
    assert a == b, "equality should work even though they have different clients"

    from collections import deque

    assert a in deque([b])
예제 #6
0
async def process_webhook_event(
    connection: RedisConnection,
    webhook_queue: RedisWebhookQueue,
    queue_name: str,
    log: structlog.BoundLogger,
) -> None:
    log.info("block for new webhook event")
    webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
        [queue_name])
    webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
    async with Client(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            installation_id=webhook_event.installation_id,
    ) as api_client:
        pull_request = PR(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            number=webhook_event.pull_request_number,
            installation_id=webhook_event.installation_id,
            client=api_client,
        )
        is_merging = (await connection.get(
            webhook_event.get_merge_target_queue_name()
        ) == webhook_event.json())
        # trigger status updates
        m_res, event = await pull_request.mergeability()
        if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
            # remove ineligible events from the merge queue
            await connection.zrem(webhook_event.get_merge_queue_name(),
                                  [webhook_event.json()])
            return
        if m_res == MergeabilityResponse.SKIPPABLE_CHECKS:
            log.info("skippable checks")
            return
        await update_pr_immediately_if_configured(m_res, event, pull_request,
                                                  log)

        if m_res not in (
                MergeabilityResponse.NEEDS_UPDATE,
                MergeabilityResponse.NEED_REFRESH,
                MergeabilityResponse.WAIT,
                MergeabilityResponse.OK,
                MergeabilityResponse.SKIPPABLE_CHECKS,
        ):
            raise Exception("Unknown MergeabilityResponse")

        if isinstance(event.config, V1) and event.config.merge.do_not_merge:
            # we duplicate the status messages found in the mergeability
            # function here because status messages for WAIT and NEEDS_UPDATE
            # are only set when Kodiak hits the merging logic.
            if m_res == MergeabilityResponse.WAIT:
                await pull_request.set_status(summary="⌛️ waiting for checks")
            if m_res in {
                    MergeabilityResponse.OK,
                    MergeabilityResponse.SKIPPABLE_CHECKS,
            }:
                await pull_request.set_status(summary="✅ okay to merge")
            log.debug(
                "skipping merging for PR because `merge.do_not_merge` is configured."
            )
            return

        if (isinstance(event.config, V1)
                and event.config.merge.prioritize_ready_to_merge
                and m_res == MergeabilityResponse.OK):
            merge_success = await pull_request.merge(event)
            if merge_success:
                return
            log.error("problem merging PR")

        # don't clobber statuses set in the merge loop
        # The following responses are okay to add to merge queue:
        #   + NEEDS_UPDATE - okay for merging
        #   + NEED_REFRESH - assume okay
        #   + WAIT - assume checks pass
        #   + OK - we've got the green
        webhook_event_jsons = await webhook_queue.enqueue_for_repo(
            event=webhook_event)
        if is_merging:
            return

        position = find_position(webhook_event_jsons, webhook_event_json.value)
        if position is None:
            return
        # use 1-based indexing
        humanized_position = inflection.ordinalize(position + 1)
        await pull_request.set_status(
            f"📦 enqueued for merge (position={humanized_position})")
예제 #7
0
async def process_repo_queue(log: structlog.BoundLogger,
                             connection: RedisConnection,
                             queue_name: str) -> None:
    log.info("block for new repo event")
    webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
        [queue_name])
    webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
    # mark this PR as being merged currently. we check this elsewhere to set proper status codes
    await connection.set(webhook_event.get_merge_target_queue_name(),
                         webhook_event.json())
    async with Client(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            installation_id=webhook_event.installation_id,
    ) as api_client:
        pull_request = PR(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            number=webhook_event.pull_request_number,
            installation_id=webhook_event.installation_id,
            client=api_client,
        )

        # mark that we're working on this PR
        await pull_request.set_status(summary="⛴ attempting to merge PR")
        skippable_check_timeout = 4
        while True:
            # there are two exits to this loop:
            # - OK MergeabilityResponse
            # - NOT_MERGEABLE MergeabilityResponse
            #
            # otherwise we continue to poll the Github API for a status change
            # from the other states: NEEDS_UPDATE, NEED_REFRESH, WAIT

            # TODO(chdsbd): Replace enum response with exceptions
            m_res, event = await pull_request.mergeability(merging=True)
            log = log.bind(res=m_res)
            if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
                log.info("cannot merge")
                break
            if m_res == MergeabilityResponse.SKIPPABLE_CHECKS:
                if skippable_check_timeout:
                    skippable_check_timeout -= 1
                    await asyncio.sleep(RETRY_RATE_SECONDS)
                    continue
                await pull_request.set_status(
                    summary="⌛️ waiting a bit for skippable checks")
                break

            if m_res == MergeabilityResponse.NEEDS_UPDATE:
                log.info("update pull request and don't attempt to merge")

                if await update_pr_with_retry(pull_request):
                    continue
                log.error("failed to update branch")
                await pull_request.set_status(
                    summary="🛑 could not update branch")
                # break to find next PR to try and merge
                break
            elif m_res == MergeabilityResponse.NEED_REFRESH:
                # trigger a git mergeability check on Github's end and poll for result
                log.info("needs refresh")
                await pull_request.trigger_mergeability_check()
                continue
            elif m_res == MergeabilityResponse.WAIT:
                # continuously poll until we either get an OK or a failure for mergeability
                log.info("waiting for status checks")
                continue
            elif m_res == MergeabilityResponse.OK:
                # continue to try and merge
                pass
            else:
                raise Exception("Unknown MergeabilityResponse")

            retries = 5
            while retries:
                log.info("merge")
                if await pull_request.merge(event):
                    # success merging
                    break
                retries -= 1
                log.info("retry merge")
                await asyncio.sleep(RETRY_RATE_SECONDS)
            else:
                log.error("Exhausted attempts to merge pull request")
예제 #8
0
async def process_webhook_event(
    connection: RedisConnection,
    webhook_queue: RedisWebhookQueue,
    queue_name: str,
    log: structlog.BoundLogger,
) -> None:
    log.info("block for new webhook event")
    webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
        [queue_name])
    webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
    async with Client(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            installation_id=webhook_event.installation_id,
    ) as api_client:
        pull_request = PR(
            owner=webhook_event.repo_owner,
            repo=webhook_event.repo_name,
            number=webhook_event.pull_request_number,
            installation_id=webhook_event.installation_id,
            client=api_client,
        )
        is_merging = (await connection.get(
            webhook_event.get_merge_target_queue_name()
        ) == webhook_event.json())
        # trigger status updates
        m_res, event = await pull_request.mergeability()
        if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
            # remove ineligible events from the merge queue
            await connection.zrem(webhook_event.get_merge_queue_name(),
                                  [webhook_event.json()])
            return
        if m_res == MergeabilityResponse.SKIPPABLE_CHECKS:
            log.info("skippable checks")
            return
        await update_pr_immediately_if_configured(m_res, event, pull_request,
                                                  log)

        if m_res not in (
                MergeabilityResponse.NEEDS_UPDATE,
                MergeabilityResponse.NEED_REFRESH,
                MergeabilityResponse.WAIT,
                MergeabilityResponse.OK,
                MergeabilityResponse.SKIPPABLE_CHECKS,
        ):
            raise Exception("Unknown MergeabilityResponse")

        # don't clobber statuses set in the merge loop
        # The following responses are okay to add to merge queue:
        #   + NEEDS_UPDATE - okay for merging
        #   + NEED_REFRESH - assume okay
        #   + WAIT - assume checks pass
        #   + OK - we've got the green
        webhook_event_jsons = await webhook_queue.enqueue_for_repo(
            event=webhook_event)
        if is_merging:
            return

        position = find_position(webhook_event_jsons, webhook_event_json.value)
        if position is None:
            return
        # use 1-based indexing
        humanized_position = inflection.ordinalize(position + 1)
        await pull_request.set_status(
            f"📦 enqueued for merge (position={humanized_position})")
예제 #9
0
async def webhook_event_consumer(*, connection: RedisConnection,
                                 webhook_queue: RedisWebhookQueue,
                                 queue_name: str) -> typing.NoReturn:
    """
    Worker to process incoming webhook events from redis

    1. process mergeability information and update github check status for pr
    2. enqueue pr into repo queue for merging, if mergeability passed
    """
    log = logger.bind(queue=queue_name)
    log.info("start webhook event consumer")

    while True:
        log.info("block for new webhook event")
        webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
            [queue_name])
        webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
        async with Client(
                owner=webhook_event.repo_owner,
                repo=webhook_event.repo_name,
                installation_id=webhook_event.installation_id,
        ) as api_client:
            pull_request = PR(
                owner=webhook_event.repo_owner,
                repo=webhook_event.repo_name,
                number=webhook_event.pull_request_number,
                installation_id=webhook_event.installation_id,
                client=api_client,
            )
            is_merging = (await connection.get(
                webhook_event.get_merge_target_queue_name()
            ) == webhook_event.json())
            # trigger status updates
            m_res, event = await pull_request.mergeability()
            if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
                # remove ineligible events from the merge queue
                await connection.zrem(webhook_event.get_merge_queue_name(),
                                      [webhook_event.json()])
                continue
            if m_res not in (
                    MergeabilityResponse.NEEDS_UPDATE,
                    MergeabilityResponse.NEED_REFRESH,
                    MergeabilityResponse.WAIT,
                    MergeabilityResponse.OK,
            ):
                raise Exception("Unknown MergeabilityResponse")

            # don't clobber statuses set in the merge loop
            # The following responses are okay to add to merge queue:
            #   + NEEDS_UPDATE - okay for merging
            #   + NEED_REFRESH - assume okay
            #   + WAIT - assume checks pass
            #   + OK - we've got the green
            webhook_event_jsons = await webhook_queue.enqueue_for_repo(
                event=webhook_event)
            if is_merging:
                continue

            position = find_position(webhook_event_jsons,
                                     webhook_event_json.value)
            if position is None:
                continue
            # use 1-based indexing
            humanized_position = inflection.ordinalize(position + 1)
            await pull_request.set_status(
                f"📦 enqueued for merge (position={humanized_position})")
예제 #10
0
async def repo_queue_consumer(*, queue_name: str,
                              connection: RedisConnection) -> typing.NoReturn:
    """
    Worker for a repo given by :queue_name:

    Pull webhook events off redis queue and process for mergeability.

    We only run one of these per repo as we can only merge one PR at a time
    to be efficient. This also alleviates the need of locks.
    """
    with sentry_sdk.configure_scope() as scope:
        scope.set_tag("queue", queue_name)
    log = logger.bind(queue=queue_name)
    log.info("start repo_consumer")
    while True:
        log.info("block for new repo event")
        webhook_event_json: BlockingZPopReply = await connection.bzpopmin(
            [queue_name])
        webhook_event = WebhookEvent.parse_raw(webhook_event_json.value)
        # mark this PR as being merged currently. we check this elsewhere to set proper status codes
        await connection.set(webhook_event.get_merge_target_queue_name(),
                             webhook_event.json())
        async with Client(
                owner=webhook_event.repo_owner,
                repo=webhook_event.repo_name,
                installation_id=webhook_event.installation_id,
        ) as api_client:
            pull_request = PR(
                owner=webhook_event.repo_owner,
                repo=webhook_event.repo_name,
                number=webhook_event.pull_request_number,
                installation_id=webhook_event.installation_id,
                client=api_client,
            )

            # mark that we're working on this PR
            await pull_request.set_status(summary="â›´ attempting to merge PR")
            while True:
                # there are two exits to this loop:
                # - OK MergeabilityResponse
                # - NOT_MERGEABLE MergeabilityResponse
                #
                # otherwise we continue to poll the Github API for a status change
                # from the other states: NEEDS_UPDATE, NEED_REFRESH, WAIT

                # TODO(chdsbd): Replace enum response with exceptions
                m_res, event = await pull_request.mergeability(merging=True)
                log = log.bind(res=m_res)
                if event is None or m_res == MergeabilityResponse.NOT_MERGEABLE:
                    log.info("cannot merge")
                    break
                if m_res == MergeabilityResponse.NEEDS_UPDATE:
                    # update pull request and poll for result
                    log.info("update pull request and don't attempt to merge")

                    # try multiple times in case of intermittent failure
                    retries = 5
                    while retries:
                        log.info("update branch")
                        res = await pull_request.update()
                        # if res is None:
                        if res is None:
                            break
                        retries -= 1
                        log.info("retry update branch")
                        await asyncio.sleep(RETRY_RATE_SECONDS)
                    log.error("failed to update branch")
                    await pull_request.set_status(
                        summary="🛑 could not update branch: {res}")
                    # break to find next PR to try and merge
                    break
                elif m_res == MergeabilityResponse.NEED_REFRESH:
                    # trigger a git mergeability check on Github's end and poll for result
                    log.info("needs refresh")
                    await pull_request.trigger_mergeability_check()
                    continue
                elif m_res == MergeabilityResponse.WAIT:
                    # continuously poll until we either get an OK or a failure for mergeability
                    log.info("waiting for status checks")
                    continue
                elif m_res == MergeabilityResponse.OK:
                    # continue to try and merge
                    pass
                else:
                    raise Exception("Unknown MergeabilityResponse")

                retries = 5
                while retries:
                    log.info("merge")
                    if await pull_request.merge(event):
                        # success merging
                        break
                    retries -= 1
                    log.info("retry merge")
                    await asyncio.sleep(RETRY_RATE_SECONDS)
                else:
                    log.error("Exhausted attempts to merge pull request")