def test_config_default() -> None: file_path = load_config_fixture("v1-default.toml") loaded = toml.load(file_path) actual = V1.parse_obj(cast(Dict[Any, Any], loaded)) expected = V1(version=1) assert actual == expected
def test_config_parsing_opposite() -> None: """ parse config with all opposite settings so we can ensure the config is correctly formatted. """ file_path = load_config_fixture("v1-opposite.toml") loaded = toml.load(file_path) actual = V1.parse_obj(cast(Dict[Any, Any], loaded)) expected = V1( version=1, app_id="12345", merge=Merge( automerge_label="mergeit!", require_automerge_label=False, blacklist_title_regex="", blacklist_labels=["wip", "block-merge"], method=MergeMethod.squash, delete_branch_on_merge=True, block_on_reviews_requested=True, notify_on_conflict=False, optimistic_updates=False, message=MergeMessage( title=MergeTitleStyle.pull_request_title, body=MergeBodyStyle.pull_request_body, include_pr_number=False, body_type=BodyText.plain_text, strip_html_comments=True, ), ), ) assert actual == expected
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_get_merge_body_include_pull_request_author_mannequin() -> None: """ Test case where actor is not a User and Bot to see how we handle weird cases. """ pull_request = create_pull_request() pull_request.body = "hello world" pull_request.author.name = None pull_request.author.type = "Mannequin" actual = get_merge_body( config=V1( version=1, merge=Merge(message=MergeMessage( body=MergeBodyStyle.pull_request_body, include_pull_request_author=True, )), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody( merge_method="squash", commit_message= "hello world\n\nCo-authored-by: barry <*****@*****.**>", ) assert actual == expected
def test_get_merge_body_includes_pull_request_url() -> None: """ Ensure that when the appropriate config option is set, we include the pull request url in the commit message. """ pull_request = create_pull_request() actual = get_merge_body( config=V1( version=1, merge=Merge( message=MergeMessage(body=MergeBodyStyle.pull_request_body, include_pull_request_url=True)), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody( merge_method="squash", commit_message="""\ # some description PR-URL: https://github.com/example_org/example_repo/pull/65""", ) assert actual == expected
def test_get_merge_body_includes_pull_request_url_with_coauthor() -> None: """ Coauthor should appear after the pull request url """ pull_request = create_pull_request() actual = get_merge_body( config=V1( version=1, merge=Merge(message=MergeMessage( body=MergeBodyStyle.pull_request_body, include_pull_request_url=True, include_pull_request_author=True, )), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody( merge_method="squash", commit_message="""\ # some description PR-URL: https://github.com/example_org/example_repo/pull/65 Co-authored-by: Barry Berkman <*****@*****.**>""", ) assert actual == expected
def test_get_markdown_for_config_toml_error() -> None: config = "[[[ version = 12" error = V1.parse_toml(config) assert not isinstance(error, V1) markdown = get_markdown_for_config(error, config_str=config, git_path="master:.kodiak.toml") assert markdown == load_config_fixture("toml-error.md").read_text()
def validate_config(config_path: str) -> None: """ parse and output the json representation of a Kodiak config """ cfg_text = Path(config_path).read_text() cfg_file = V1.parse_toml(cfg_text) assert isinstance(cfg_file, V1) click.echo(cfg_file.json(indent=2))
def test_bad_file() -> None: res = V1.parse_toml("something[invalid[") assert isinstance(res, toml.TomlDecodeError) res = V1.parse_toml("version = 20") assert isinstance(res, pydantic.ValidationError) # we should return an error when we try to parse a different version res = V1.parse_toml("version = 20") assert isinstance(res, pydantic.ValidationError) # we should always require that the version is specified, even if we provide defaults for everything else res = V1.parse_toml("") assert isinstance(res, pydantic.ValidationError) res = V1.parse_toml("merge.automerge_label = 123") assert isinstance(res, pydantic.ValidationError)
async def get_config_for_ref( self, *, ref: str, org_repo_default_branch: str | None) -> CfgInfo | None: repo_root_config_expression = create_root_config_file_expression( branch=ref) repo_github_config_expression = create_github_config_file_expression( branch=ref) org_root_config_expression: str | None = None org_github_config_file_expression: str | None = None if org_repo_default_branch is not None: org_root_config_expression = create_root_config_file_expression( branch=org_repo_default_branch) org_github_config_file_expression = create_github_config_file_expression( branch=org_repo_default_branch) res = await self.send_query( query=GET_CONFIG_QUERY, variables=dict( owner=self.owner, repo=self.repo, rootConfigFileExpression=repo_root_config_expression, githubConfigFileExpression=repo_github_config_expression, orgRootConfigFileExpression=org_root_config_expression, orgGithubConfigFileExpression=org_github_config_file_expression, ), installation_id=self.installation_id, ) if res is None: return None data = res.get("data") if data is None: self.log.error("could not fetch default branch name", res=res) return None parsed_config = parse_config(data) if parsed_config is None: return None def get_file_expression() -> str: assert parsed_config is not None if parsed_config.kind == "repo_root": return repo_root_config_expression if parsed_config.kind == "repo_github": return repo_github_config_expression if parsed_config.kind == "org_root": assert org_root_config_expression is not None return org_root_config_expression if parsed_config.kind == "org_github": assert org_github_config_file_expression is not None return org_github_config_file_expression raise Exception(f"unknown config kind {parsed_config.kind!r}") return CfgInfo( parsed=V1.parse_toml(parsed_config.text), text=parsed_config.text, file_expression=get_file_expression(), )
def test_pr_get_merge_body_empty() -> None: pull_request = create_pull_request() actual = get_merge_body( config=V1(version=1), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash") assert actual == expected
def test_config_parsing_opposite(config_fixture_name: str, expected_config: V1) -> None: """ parse config with all opposite settings so we can ensure the config is correctly formatted. """ file_path = load_config_fixture(config_fixture_name) loaded = toml.load(file_path) actual = V1.parse_obj(cast(Dict[Any, Any], loaded)) assert actual == expected_config
def test_config_schema() -> None: """ Although we don't use the config schema anywhere, it provides a form of documentation for the bot config. """ schema_path = load_config_fixture("config-schema.json") assert json.loads(V1.schema_json()) == json.loads(schema_path.read_text()), ( "schema shouldn't change unexpectedly.\n" "Did you remember to run `poetry run kodiak gen-conf-json-schema > kodiak/test/fixtures/config/config-schema.json` in the `bot` directory?" )
def test_bad_file() -> None: with pytest.raises(toml.TomlDecodeError): V1.parse_toml("something[invalid[") with pytest.raises(ValueError): # we should raise an error when we try to parse a different version V1.parse_toml("version = 20") with pytest.raises(ValueError): # we should always require that the version is specified, even if we provide defaults for everything else V1.parse_toml("") with pytest.raises(ValueError): V1.parse_toml("merge.automerge_label = 123")
def test_get_merge_body_empty() -> None: pull_request = create_pull_request() pull_request.body = "hello world" actual = get_merge_body( config=V1( version=1, merge=Merge(message=MergeMessage(body=MergeBodyStyle.empty))), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash", commit_message="") assert actual == expected
def test_get_merge_body_empty(pull_request: queries.PullRequest) -> None: pull_request.body = "hello world" actual = get_merge_body( V1( version=1, merge=Merge( method=MergeMethod.squash, message=MergeMessage(body=MergeBodyStyle.empty), ), ), pull_request, ) expected = dict(merge_method="squash", commit_message="") assert actual == expected
def test_get_merge_body_strip_html_comments() -> None: pull_request = create_pull_request() pull_request.body = "hello <!-- testing -->world" actual = get_merge_body( config=V1( version=1, merge=Merge( message=MergeMessage(body=MergeBodyStyle.pull_request_body, strip_html_comments=True)), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash", commit_message="hello world") assert actual == expected
def test_get_merge_body_strip_html_comments(pull_request: queries.PullRequest, original: str, stripped: str) -> None: pull_request.body = "hello <!-- testing -->world" actual = get_merge_body( V1( version=1, merge=Merge( method=MergeMethod.squash, message=MergeMessage(body=MergeBodyStyle.pull_request_body, strip_html_comments=True), ), ), pull_request, ) expected = dict(merge_method="squash", commit_message="hello world") assert actual == expected
async def get_config_for_ref(self, *, ref: str) -> CfgInfo | None: root_config_file_expression = create_root_config_file_expression( branch=ref) github_config_file_expression = create_github_config_file_expression( branch=ref) res = await self.send_query( query=GET_CONFIG_QUERY, variables=dict( owner=self.owner, repo=self.repo, rootConfigFileExpression=root_config_file_expression, githubConfigFileExpression=github_config_file_expression, ), installation_id=self.installation_id, ) if res is None: return None data = res.get("data") errors = res.get("errors") if errors is not None or data is None: self.log.error("could not fetch default branch name", res=res) return None try: parsed = ConfigQueryResponse.parse_obj(data) except pydantic.ValidationError: self.log.exception("problem parsing api response for config") return None if not parsed.repository: return None if (parsed.repository.rootConfigFile is not None and parsed.repository.rootConfigFile.text is not None): config_file_expression = root_config_file_expression config_text = parsed.repository.rootConfigFile.text elif (parsed.repository.githubConfigFile is not None and parsed.repository.githubConfigFile.text is not None): config_file_expression = github_config_file_expression config_text = parsed.repository.githubConfigFile.text else: return None return CfgInfo( parsed=V1.parse_toml(config_text), text=config_text, file_expression=config_file_expression, )
def test_get_merge_body_includes_pull_request_url_github_default() -> None: """ We should not set a commit message when merge.body = "github_default". """ pull_request = create_pull_request() actual = get_merge_body( config=V1( version=1, merge=Merge( message=MergeMessage(body=MergeBodyStyle.github_default, include_pull_request_url=True)), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash", commit_message=None) assert actual == expected
def test_pr_get_merge_body_full() -> None: pull_request = create_pull_request() actual = get_merge_body( config=V1( version=1, merge=Merge(message=MergeMessage( title=MergeTitleStyle.pull_request_title, body=MergeBodyStyle.pull_request_body, include_pr_number=True, )), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody( merge_method="squash", commit_title=pull_request.title + f" (#{pull_request.number})", commit_message=pull_request.body, ) assert expected == actual
def test_pr_get_merge_body_full(pull_request: queries.PullRequest) -> None: actual = get_merge_body( V1( version=1, merge=Merge( method=MergeMethod.squash, message=MergeMessage( title=MergeTitleStyle.pull_request_title, body=MergeBodyStyle.pull_request_body, include_pr_number=True, ), ), ), pull_request, ) expected = dict( merge_method="squash", commit_title=pull_request.title + f" (#{pull_request.number})", commit_message=pull_request.body, ) assert actual == expected
def test_get_merge_body_include_pull_request_author_user() -> None: pull_request = create_pull_request() pull_request.body = "hello world" actual = get_merge_body( config=V1( version=1, merge=Merge(message=MergeMessage( body=MergeBodyStyle.pull_request_body, include_pull_request_author=True, )), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody( merge_method="squash", commit_message= "hello world\n\nCo-authored-by: Barry Berkman <*****@*****.**>", ) assert actual == expected
def test_get_merge_body_cut_body_after() -> None: """ Basic check of cut_body_after removing content. """ pull_request = create_pull_request() pull_request.body = "hello <!-- testing -->world" actual = get_merge_body( config=V1( version=1, merge=Merge( message=MergeMessage( body=MergeBodyStyle.pull_request_body, cut_body_after="<!-- testing -->", ) ), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash", commit_message="hello <!-- testing -->") assert actual == expected
def test_get_merge_body_cut_body_and_text_before() -> None: """ Verify that the separator is also gone after removing content. """ pull_request = create_pull_request() pull_request.body = "hello <!-- testing -->world" actual = get_merge_body( config=V1( version=1, merge=Merge( message=MergeMessage( body=MergeBodyStyle.pull_request_body, cut_body_before="<!-- testing -->", cut_body_and_text=True, ) ), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash", commit_message="world") assert actual == expected
def test_get_merge_body_cut_body_after_strip_html() -> None: """ We should be able to use strip_html_comments with cut_body_after. """ pull_request = create_pull_request() pull_request.body = "hello <!-- testing -->world" actual = get_merge_body( config=V1( version=1, merge=Merge( message=MergeMessage( body=MergeBodyStyle.pull_request_body, cut_body_after="<!-- testing -->", strip_html_comments=True, ) ), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash", commit_message="hello ") assert actual == expected
def test_get_merge_body_cut_body_before_multiple_markers() -> None: """ We should choose the first substring matching cut_body_before. """ pull_request = create_pull_request() pull_request.body = "hello <!-- testing -->world<!-- testing --> 123" actual = get_merge_body( config=V1( version=1, merge=Merge( message=MergeMessage( body=MergeBodyStyle.pull_request_body, cut_body_before="<!-- testing -->", strip_html_comments=True, ) ), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash", commit_message="world 123") assert actual == expected
def test_get_merge_body_cut_body_before_no_match_found() -> None: """ Ensure we don't edit the message if there isn't any match found with cut_body_before. """ pull_request = create_pull_request() pr_body = "hello <!-- foo -->world<!-- bar --> 123" pull_request.body = pr_body actual = get_merge_body( config=V1( version=1, merge=Merge( message=MergeMessage( body=MergeBodyStyle.pull_request_body, cut_body_before="<!-- buzz -->", ) ), ), pull_request=pull_request, merge_method=MergeMethod.squash, commits=[], ) expected = MergeBody(merge_method="squash", commit_message=pr_body) assert actual == expected
async def get_event_info( self, branch_name: str, pr_number: int ) -> Optional[EventInfoResponse]: """ Retrieve all the information we need to evaluate a pull request This is basically the "do-all-the-things" query """ log = self.log.bind(pr=pr_number) root_config_file_expression = create_root_config_file_expression( branch=branch_name ) github_config_file_expression = create_github_config_file_expression( branch=branch_name ) res = await self.send_query( query=GET_EVENT_INFO_QUERY, variables=dict( owner=self.owner, repo=self.repo, rootConfigFileExpression=root_config_file_expression, githubConfigFileExpression=github_config_file_expression, PRNumber=pr_number, ), installation_id=self.installation_id, ) if res is None: return None data = res.get("data") errors = res.get("errors") if errors is not None or data is None: log.error("could not fetch event info", res=res) return None repository = get_repo(data=data) if not repository: log.warning("could not find repository") return None subscription = await self.get_subscription() root_config_str = get_root_config_str(repo=repository) github_config_str = get_github_config_str(repo=repository) if root_config_str is not None: config_str = root_config_str config_file_expression = root_config_file_expression elif github_config_str is not None: config_str = github_config_str config_file_expression = github_config_file_expression else: # NOTE(chdsbd): we don't want to show a message for this as the lack # of a config allows kodiak to be selectively installed log.info("could not find configuration file") return None pull_request = get_pull_request(repo=repository) if not pull_request: log.warning("Could not find PR") return None config = V1.parse_toml(config_str) # update the dictionary to match what we need for parsing pull_request["labels"] = get_labels(pr=pull_request) pull_request["latest_sha"] = get_sha(pr=pull_request) pull_request["number"] = pr_number try: pr = PullRequest.parse_obj(pull_request) except ValueError: log.warning("Could not parse pull request") return None branch_protection = get_branch_protection( repo=repository, ref_name=pr.baseRefName ) partial_reviews = get_reviews(pr=pull_request) reviews_with_permissions = await self.get_reviewers_and_permissions( reviews=partial_reviews ) return EventInfoResponse( config=config, config_str=config_str, config_file_expression=config_file_expression, pull_request=pr, repository=RepoInfo( merge_commit_allowed=repository.get("mergeCommitAllowed", False), rebase_merge_allowed=repository.get("rebaseMergeAllowed", False), squash_merge_allowed=repository.get("squashMergeAllowed", False), delete_branch_on_merge=repository.get("deleteBranchOnMerge") is True, is_private=repository.get("isPrivate") is True, ), subscription=subscription, branch_protection=branch_protection, review_requests=get_requested_reviews(pr=pull_request), reviews=reviews_with_permissions, status_contexts=get_status_contexts(pr=pull_request), check_runs=get_check_runs(pr=pull_request), head_exists=get_head_exists(pr=pull_request), valid_signature=get_valid_signature(pr=pull_request), valid_merge_methods=get_valid_merge_methods(repo=repository), )
def create_event() -> EventInfoResponse: config = V1(version=1, merge=Merge(automerge_label="automerge", method=MergeMethod.squash)) pr = PullRequest( id="e14ff7599399478fb9dbc2dacb87da72", number=100, author=PullRequestAuthor(login="******", databaseId=49118, type="Bot"), mergeStateStatus=MergeStateStatus.BEHIND, state=PullRequestState.OPEN, isDraft=False, mergeable=MergeableState.MERGEABLE, isCrossRepository=False, labels=["automerge"], latest_sha="8d728d017cac4f5ba37533debe65730abe65730a", baseRefName="master", headRefName="df825f90-9825-424c-a97e-733522027e4c", title="Update README.md", body="", bodyText="", bodyHTML="", url="https://github.com/delos-corp/hive-mind/pull/324", ) rep_info = RepoInfo( merge_commit_allowed=False, rebase_merge_allowed=False, squash_merge_allowed=True, is_private=True, delete_branch_on_merge=False, ) branch_protection = BranchProtectionRule( requiresApprovingReviews=True, requiredApprovingReviewCount=2, requiresStatusChecks=True, requiredStatusCheckContexts=[ "ci/circleci: frontend_lint", "ci/circleci: frontend_test", ], requiresStrictStatusChecks=True, requiresCodeOwnerReviews=False, requiresCommitSignatures=False, restrictsPushes=False, pushAllowances=NodeListPushAllowance(nodes=[]), ) return EventInfoResponse( config=config, config_str="""\ version = 1 [merge] method = "squash" """, config_file_expression="master:.kodiak.toml", head_exists=True, pull_request=pr, repository=rep_info, branch_protection=branch_protection, review_requests=[], reviews=[], status_contexts=[], check_runs=[], valid_signature=True, valid_merge_methods=[MergeMethod.squash], subscription=None, )