def test_failing_checks( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, check_run: CheckRun, ) -> None: pull_request.mergeStateStatus = MergeStateStatus.BLOCKED branch_protection.requiredStatusCheckContexts = ["ci/backend", "wip-app"] context.context = "ci/backend" context.state = StatusState.SUCCESS check_run.name = "wip-app" check_run.conclusion = CheckConclusionState.FAILURE with pytest.raises(NotQueueable, match="failing required status checks"): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[check_run], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_require_automerge_label_false( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: """ If the automerge_label is missing, but we have require_automerge_label set to false, enqueue the PR for merge """ pull_request.labels = [] config.merge.automerge_label = "automerge" config.merge.require_automerge_label = False mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.merge, MergeMethod.squash], )
def test_regression_error_before_update( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, check_run: CheckRun, ) -> None: branch_protection.requiresStatusChecks = True branch_protection.requiredStatusCheckContexts = ["ci/backend", "wip-app"] branch_protection.requiresStrictStatusChecks = True pull_request.mergeStateStatus = MergeStateStatus.BEHIND contexts = [StatusContext(context="ci/backend", state=StatusState.SUCCESS)] check_run.name = "wip-app" check_run.conclusion = CheckConclusionState.SUCCESS with pytest.raises(NeedsBranchUpdate): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=1, reviews=[review], check_runs=[check_run], contexts=contexts, valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_app_id( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule ) -> None: config.app_id = "123" with pytest.raises(MissingAppID): mergeable( app_id="1234", config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[], contexts=[], check_runs=[], valid_signature=False, valid_merge_methods=[], ) # try without passing an app_id with pytest.raises(MissingAppID): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[], contexts=[], check_runs=[], valid_signature=False, valid_merge_methods=[], )
def test_passing_checks( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, check_run: CheckRun, ) -> None: branch_protection.requiresStatusChecks = True branch_protection.requiredStatusCheckContexts = ["ci/backend", "wip-app"] context.context = "ci/backend" context.state = StatusState.SUCCESS check_run.name = "wip-app" check_run.conclusion = CheckConclusionState.SUCCESS mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[check_run], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_dont_update_before_block( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: """ Regression test for when Kodiak would update a PR that is not mergeable. We were raising the NeedsBranchUpdate exception too early. """ pull_request.mergeStateStatus = MergeStateStatus.BEHIND branch_protection.requiresStrictStatusChecks = True with pytest.raises(NeedsBranchUpdate): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
async def mergeability( self, merging: bool = False ) -> typing.Tuple[MergeabilityResponse, typing.Optional[EventInfoResponse]]: self.log.info("get_event") self.event = await self.get_event() if self.event is None: self.log.info("no event") return MergeabilityResponse.NOT_MERGEABLE, None if not self.event.head_exists: self.log.info("branch deleted") return MergeabilityResponse.NOT_MERGEABLE, None try: self.log.info("check mergeable") mergeable( config=self.event.config, app_id=conf.GITHUB_APP_ID, pull_request=self.event.pull_request, branch_protection=self.event.branch_protection, review_requests_count=self.event.review_requests_count, reviews=self.event.reviews, contexts=self.event.status_contexts, check_runs=self.event.check_runs, valid_signature=self.event.valid_signature, valid_merge_methods=self.event.valid_merge_methods, ) self.log.info("okay") return MergeabilityResponse.OK, self.event except (NotQueueable, MergeConflict, BranchMerged) as e: if (isinstance(e, MergeConflict) and self.event.config.merge.notify_on_conflict): await self.notify_pr_creator() if (isinstance(e, BranchMerged) and self.event.config.merge.delete_branch_on_merge): await self.client.delete_branch( branch=self.event.pull_request.headRefName) await self.set_status(summary="🛑 cannot merge", detail=str(e)) return MergeabilityResponse.NOT_MERGEABLE, self.event except MissingAppID: return MergeabilityResponse.NOT_MERGEABLE, self.event except MissingGithubMergeabilityState: self.log.info("missing mergeability state, need refresh") return MergeabilityResponse.NEED_REFRESH, self.event except WaitingForChecks: if merging: await self.set_status(summary="⛴ attempting to merge PR", detail="waiting for checks") return MergeabilityResponse.WAIT, self.event except NeedsBranchUpdate: if merging: await self.set_status(summary="⛴ attempting to merge PR", detail="updating branch") return MergeabilityResponse.NEEDS_UPDATE, self.event
def test_regression_mishandling_multiple_reviews_okay_reviews( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, check_run: CheckRun, context: StatusContext, ) -> None: pull_request.mergeStateStatus = MergeStateStatus.BEHIND branch_protection.requiresApprovingReviews = True branch_protection.requiredApprovingReviewCount = 1 first_review_date = datetime(2010, 5, 15) latest_review_date = first_review_date + timedelta(minutes=20) reviews = [ PRReview( state=PRReviewState.CHANGES_REQUESTED, createdAt=first_review_date, author=PRReviewAuthor(login="******"), authorAssociation=CommentAuthorAssociation.CONTRIBUTOR, ), PRReview( state=PRReviewState.COMMENTED, createdAt=latest_review_date, author=PRReviewAuthor(login="******"), authorAssociation=CommentAuthorAssociation.CONTRIBUTOR, ), PRReview( state=PRReviewState.APPROVED, createdAt=latest_review_date, author=PRReviewAuthor(login="******"), authorAssociation=CommentAuthorAssociation.CONTRIBUTOR, ), PRReview( state=PRReviewState.APPROVED, createdAt=latest_review_date, author=PRReviewAuthor(login="******"), authorAssociation=CommentAuthorAssociation.CONTRIBUTOR, ), ] with pytest.raises(NeedsBranchUpdate): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=1, reviews=reviews, check_runs=[check_run], contexts=[context], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_config_merge_optimistic_updates( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule ) -> None: """ If optimisitc_updates are enabled, branch updates should be prioritized over waiting for running status checks to complete. Otherwise, status checks should be checked before updating. """ branch_protection.requiredApprovingReviewCount = 0 branch_protection.requiresStrictStatusChecks = True pull_request.mergeStateStatus = MergeStateStatus.BEHIND branch_protection.requiresStatusChecks = True branch_protection.requiredStatusCheckContexts = ["ci/lint", "ci/test"] contexts: List[StatusContext] = [] config.merge.optimistic_updates = True with pytest.raises(NeedsBranchUpdate): mergeable( app_id="1234", config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[], contexts=contexts, check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], ) config.merge.optimistic_updates = False with pytest.raises(WaitingForChecks): mergeable( app_id="1234", config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[], contexts=contexts, check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_unknown_blockage( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule ) -> None: branch_protection.requiredApprovingReviewCount = 0 branch_protection.requiresStatusChecks = False pull_request.mergeStateStatus = MergeStateStatus.BLOCKED with pytest.raises(NotQueueable, match="determine why PR is blocked"): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[], contexts=[], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_missing_branch_protection(pull_request: PullRequest, config: V1) -> None: """ We don't want to do anything if branch protection is missing """ branch_protection = None with pytest.raises(NotQueueable, match="missing branch protection"): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[], contexts=[], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_passing( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_missing_mergeability_state( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: with pytest.raises(MissingGithubMergeabilityState): pull_request.mergeable = MergeableState.UNKNOWN mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_block_on_reviews_requested( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: config.merge.block_on_reviews_requested = True with pytest.raises(NotQueueable, match="reviews requested"): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=1, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_closed( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: with pytest.raises(NotQueueable, match="closed"): pull_request.state = PullRequestState.CLOSED mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_need_update( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: with pytest.raises(NeedsBranchUpdate): pull_request.mergeStateStatus = MergeStateStatus.BEHIND mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_requires_signature( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: pull_request.mergeStateStatus = MergeStateStatus.BLOCKED branch_protection.requiresCommitSignatures = True with pytest.raises(NotQueueable, match="missing required signature"): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_missing_automerge_label( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: pull_request.labels = ["bug"] config.merge.automerge_label = "automerge" with pytest.raises(NotQueueable, match="missing automerge_label"): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.merge, MergeMethod.squash], )
def test_blocking_review( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: pull_request.mergeStateStatus = MergeStateStatus.BLOCKED review.state = PRReviewState.CHANGES_REQUESTED with pytest.raises(NotQueueable, match="blocking review"): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_requires_commit_signatures( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule ) -> None: """ If requiresCommitSignatures is enabled in branch protections, kodiak cannot function because it cannot create a signed commit to merge the PR. """ branch_protection.requiresCommitSignatures = True with pytest.raises(NotQueueable, match='"Require signed commits" not supported.'): mergeable( app_id="1234", config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[], contexts=[], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_missing_required_context( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: pull_request.mergeStateStatus = MergeStateStatus.BLOCKED branch_protection.requiredStatusCheckContexts = ["ci/backend", "ci/frontend"] context.context = "ci/backend" with pytest.raises(WaitingForChecks, match="missing required status checks"): mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
def test_blacklist_title_match( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: # a PR with a blacklisted title should not be mergeable with pytest.raises(NotQueueable, match="blacklist_title") as e_info: config.merge.blacklist_title_regex = "^WIP:.*" pull_request.title = "WIP: add fleeb to plumbus" mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.merge, MergeMethod.squash], ) assert config.merge.blacklist_title_regex in str(e_info.value)
def test_blacklisted( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule, review: PRReview, context: StatusContext, ) -> None: # a PR with a blacklisted label should not be mergeable with pytest.raises(NotQueueable, match="blacklist"): pull_request.labels = ["automerge", "dont-merge"] config.merge.automerge_label = "automerge" config.merge.blacklist_labels = ["dont-merge"] mergeable( config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[review], contexts=[context], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.merge, MergeMethod.squash], )
def test_merge_state_status_draft( pull_request: PullRequest, config: V1, branch_protection: BranchProtectionRule ) -> None: """ If optimisitc_updates are enabled, branch updates should be prioritized over waiting for running status checks to complete. Otherwise, status checks should be checked before updating. """ pull_request.mergeStateStatus = MergeStateStatus.DRAFT with pytest.raises(NotQueueable, match="draft state"): mergeable( app_id="1234", config=config, pull_request=pull_request, branch_protection=branch_protection, review_requests_count=0, reviews=[], contexts=[], check_runs=[], valid_signature=False, valid_merge_methods=[MergeMethod.squash], )
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, ) -> None: skippable_check_timeout = 4 api_call_retries_remaining = 5 api_call_errors = [] # type: List[APICallError] log = logger.bind(install=install, owner=owner, repo=repo, number=number) 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=30, ) 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, reviews=pr.event.reviews, contexts=pr.event.status_contexts, check_runs=pr.event.check_runs, commits=pr.event.commits, valid_signature=pr.event.valid_signature, 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=30, ) 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.exception("api_call_retries_remaining") return except asyncio.TimeoutError: # On timeout we add the PR to the back of the queue to try again. log.exception("mergeable_timeout") await requeue_callback()
async def evaluate_pr( install: str, owner: str, repo: str, number: int, merging: bool, dequeue_callback: Callable[[], Awaitable], requeue_callback: Callable[[], Awaitable], queue_for_merge_callback: Callable[[], Awaitable[Optional[int]]], is_active_merging: bool, ) -> None: skippable_check_timeout = 4 api_call_retry_timeout = 5 api_call_retry_method_name: Optional[str] = None log = logger.bind(install=install, owner=owner, repo=repo, number=number) while True: log.info("get_pr") 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=10, ) 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, reviews=pr.event.reviews, contexts=pr.event.status_contexts, check_runs=pr.event.check_runs, commit_authors=pr.event.commit_authors, valid_signature=pr.event.valid_signature, valid_merge_methods=pr.event.valid_merge_methods, merging=merging, is_active_merge=is_active_merging, skippable_check_timeout=skippable_check_timeout, api_call_retry_timeout=api_call_retry_timeout, api_call_retry_method_name=api_call_retry_method_name, ), timeout=10, ) 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_retry_timeout: api_call_retry_method_name = e.method api_call_retry_timeout -= 1 log.info("problem contacting remote api. retrying") continue log.exception("api_call_retry_timeout") return
async def mergeability( self, merging: bool = False ) -> Tuple[MergeabilityResponse, Optional[EventInfoResponse]]: self.log.info("get_event") self.event = await self.get_event() if self.event is None: self.log.info("no event") return MergeabilityResponse.NOT_MERGEABLE, None # PRs from forks will always appear deleted because the v4 api doesn't # return head information for forks like the v3 api does. if not self.event.pull_request.isCrossRepository and not self.event.head_exists: self.log.info("branch deleted") return MergeabilityResponse.NOT_MERGEABLE, None if not isinstance(self.event.config, V1): await self.set_status( "🚨 Invalid configuration", detail='Click "Details" for more info.', markdown_content=get_markdown_for_config( self.event.config, self.event.config_str, self.event.config_file_expression, ), ) return MergeabilityResponse.NOT_MERGEABLE, None try: self.log.info("check mergeable") mergeable( config=self.event.config, app_id=conf.GITHUB_APP_ID, pull_request=self.event.pull_request, branch_protection=self.event.branch_protection, review_requests=self.event.review_requests, reviews=self.event.reviews, contexts=self.event.status_contexts, check_runs=self.event.check_runs, valid_signature=self.event.valid_signature, valid_merge_methods=self.event.valid_merge_methods, ) self.log.info("okay") return MergeabilityResponse.OK, self.event except MissingSkippableChecks as e: self.log.info("skippable checks", checks=e.checks) await self.set_status( summary="🛑 not waiting for dont_wait_on_status_checks", detail=repr(e.checks), ) return MergeabilityResponse.SKIPPABLE_CHECKS, self.event except (NotQueueable, MergeConflict, BranchMerged) as e: if (isinstance(e, MergeConflict) and self.event.config.merge.notify_on_conflict): await self.notify_pr_creator() if (isinstance(e, BranchMerged) and self.event.config.merge.delete_branch_on_merge): await self.client.delete_branch( branch=self.event.pull_request.headRefName) await self.set_status(summary="🛑 cannot merge", detail=str(e)) return MergeabilityResponse.NOT_MERGEABLE, self.event except MergeBlocked as e: await self.set_status(summary=f"🛑 {e}") return MergeabilityResponse.NOT_MERGEABLE, self.event except MissingAppID: return MergeabilityResponse.NOT_MERGEABLE, self.event except MissingGithubMergeabilityState: self.log.info("missing mergeability state, need refresh") return MergeabilityResponse.NEED_REFRESH, self.event except WaitingForChecks as e: if merging: await self.set_status( summary="⛴ attempting to merge PR", detail=f"waiting for checks: {e.checks!r}", ) return MergeabilityResponse.WAIT, self.event except NeedsBranchUpdate: if self.event.pull_request.isCrossRepository: await self.set_status( summary= '🚨 forks cannot be updated via the github api. Click "Details" for more info', markdown_content=messages.FORKS_CANNOT_BE_UPDATED, ) return MergeabilityResponse.NOT_MERGEABLE, self.event if merging: await self.set_status(summary="⛴ attempting to merge PR", detail="updating branch") return MergeabilityResponse.NEEDS_UPDATE, self.event