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
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 )
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
def pr() -> PR: return PR( number=123, owner="tester", repo="repo", installation_id="abc", client=Client(owner="tester", repo="repo", installation_id="abc"), )
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])
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})")
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")
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})")
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})")
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")