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 def dequeue() -> None: await connection.zrem(webhook_event.get_merge_queue_name(), [webhook_event.json()]) async def queue_for_merge() -> Optional[int]: raise NotImplementedError log.info("evaluate PR for merging") await evaluate_pr( install=webhook_event.installation_id, owner=webhook_event.repo_owner, repo=webhook_event.repo_name, number=webhook_event.pull_request_number, dequeue_callback=dequeue, merging=True, is_active_merging=False, queue_for_merge_callback=queue_for_merge, )
async def update_pr_immediately_if_configured( m_res: MergeabilityResponse, event: EventInfoResponse, pull_request: PR, log: structlog.BoundLogger, ) -> None: if (m_res == MergeabilityResponse.NEEDS_UPDATE and isinstance(event.config, V1) and event.config.merge.update_branch_immediately): log.info("updating pull request") if not await update_pr_with_retry(pull_request): log.error("failed to update branch") await pull_request.set_status(summary="🛑 could not update branch")
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]) log.info("parsing webhook event") webhook_event = WebhookEvent.parse_raw(webhook_event_json.value) is_active_merging = (await connection.get( webhook_event.get_merge_target_queue_name()) == webhook_event.json()) async def dequeue() -> None: await connection.zrem(webhook_event.get_merge_queue_name(), [webhook_event.json()]) async def queue_for_merge() -> Optional[int]: return await webhook_queue.enqueue_for_repo(event=webhook_event) log.info("evaluate pr for webhook event") await evaluate_pr( install=webhook_event.installation_id, owner=webhook_event.repo_owner, repo=webhook_event.repo_name, number=webhook_event.pull_request_number, merging=False, dequeue_callback=dequeue, queue_for_merge_callback=queue_for_merge, is_active_merging=is_active_merging, )
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) target_name = webhook_event.get_merge_target_queue_name() # mark this PR as being merged currently. we check this elsewhere to set proper status codes await connection.set(target_name, webhook_event.json()) await connection.set(target_name + ":time", str(webhook_event_json.score)) async def dequeue() -> None: await connection.zrem(webhook_event.get_merge_queue_name(), [webhook_event.json()]) async def requeue() -> None: await connection.zadd( webhook_event.get_webhook_queue_name(), {webhook_event.json(): time.time()}, only_if_not_exists=True, ) async def queue_for_merge(*, first: bool) -> Optional[int]: raise NotImplementedError log.info("evaluate PR for merging") await evaluate_pr( install=webhook_event.installation_id, owner=webhook_event.repo_owner, repo=webhook_event.repo_name, number=webhook_event.pull_request_number, dequeue_callback=dequeue, requeue_callback=requeue, merging=True, is_active_merging=False, queue_for_merge_callback=queue_for_merge, ) log.info("merge completed, remove target marker", target_name=target_name) await connection.delete([target_name]) await connection.delete([target_name + ":time"])
async def evaluate_pr( install: str, owner: str, repo: str, number: int, merging: bool, dequeue_callback: Callable[[], Awaitable[None]], requeue_callback: Callable[[], Awaitable[None]], queue_for_merge_callback: QueueForMergeCallback, is_active_merging: bool, log: structlog.BoundLogger, ) -> None: skippable_check_timeout = 4 api_call_retries_remaining = 5 api_call_errors = [] # type: List[APICallError] log = log.bind(owner=owner, repo=repo, number=number, merging=merging) while True: log.info("get_pr") try: pr = await asyncio.wait_for( get_pr( install=install, owner=owner, repo=repo, number=number, dequeue_callback=dequeue_callback, requeue_callback=requeue_callback, queue_for_merge_callback=queue_for_merge_callback, ), timeout=60, ) if pr is None: log.info("failed to get_pr") return try: await asyncio.wait_for( mergeable( api=pr, subscription=pr.event.subscription, config=pr.event.config, config_str=pr.event.config_str, config_path=pr.event.config_file_expression, app_id=conf.GITHUB_APP_ID, repository=pr.event.repository, pull_request=pr.event.pull_request, branch_protection=pr.event.branch_protection, review_requests=pr.event.review_requests, bot_reviews=pr.event.bot_reviews, contexts=pr.event.status_contexts, check_runs=pr.event.check_runs, commits=pr.event.commits, valid_merge_methods=pr.event.valid_merge_methods, merging=merging, is_active_merge=is_active_merging, skippable_check_timeout=skippable_check_timeout, api_call_errors=api_call_errors, api_call_retries_remaining=api_call_retries_remaining, ), timeout=60, ) log.info("evaluate_pr successful") except RetryForSkippableChecks: if skippable_check_timeout > 0: skippable_check_timeout -= 1 log.info("waiting for skippable checks to pass") await asyncio.sleep(RETRY_RATE_SECONDS) continue except PollForever: log.info("polling") await asyncio.sleep(POLL_RATE_SECONDS) continue except ApiCallException as e: # if we have some api exception, it's likely a temporary error that # can be resolved by calling GitHub again. if api_call_retries_remaining: api_call_errors.append( APICallError( api_name=e.method, http_status=str(e.status_code), response_body=str(e.response), ) ) api_call_retries_remaining -= 1 log.info("problem contacting remote api. retrying") continue log.warning("api_call_retries_remaining", exc_info=True) return except asyncio.TimeoutError: # On timeout we add the PR to the back of the queue to try again. log.warning("mergeable_timeout", exc_info=True) await requeue_callback()
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})")