diff --git a/mergify_cli/ci/__init__.py b/mergify_cli/ci/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mergify_cli/ci/cli.py b/mergify_cli/ci/cli.py new file mode 100644 index 00000000..ba1d9199 --- /dev/null +++ b/mergify_cli/ci/cli.py @@ -0,0 +1,174 @@ +import asyncio +import json +import os +import pathlib +import re +import typing +from urllib import parse + +import click + +from mergify_cli import console +from mergify_cli import utils +from mergify_cli.ci import junit_upload as junit_upload_mod + + +ci = click.Group( + "ci", + help="Mergify's CI related commands", +) + + +CIProviderT = typing.Literal["github_action", "circleci"] + + +def get_ci_provider() -> CIProviderT | None: + if os.getenv("GITHUB_ACTIONS") == "true": + return "github_action" + if os.getenv("CIRCLECI") == "true": + return "circleci" + return None + + +def get_job_name() -> str | None: + if get_ci_provider() == "github_action": + return os.getenv("GITHUB_WORKFLOW") + if get_ci_provider() == "circleci": + return os.getenv("CIRCLE_JOB") + + console.log("Error: failed to get the job's name from env", style="red") + return None + + +def get_github_actions_head_sha() -> str | None: + if os.getenv("GITHUB_EVENT_NAME") == "pull_request": + # NOTE(leo): we want the head sha of pull request + event_raw_path = os.getenv("GITHUB_EVENT_PATH") + if event_raw_path and ((event_path := pathlib.Path(event_raw_path)).is_file()): + event = json.loads(event_path.read_bytes()) + return str(event["pull_request"]["head"]["sha"]) + return os.getenv("GITHUB_SHA") + + +async def get_circle_ci_head_sha() -> str | None: + if (pull_url := os.getenv("CIRCLE_PULL_REQUESTS")) and len( + pull_url.split(","), + ) == 1: + if not (token := os.getenv("GITHUB_TOKEN")): + msg = ( + "Failed to detect the head sha of the pull request associated" + " to this run. Please make sure to set a token in the env " + "variable 'GITHUB_TOKEN' for this purpose." + ) + raise RuntimeError(msg) + + parsed_url = parse.urlparse(pull_url) + if parsed_url.netloc == "github.com": + github_server = "https://api.github.com" + else: + github_server = f"{parsed_url.scheme}://{parsed_url.netloc}/api/v3" + + async with utils.get_github_http_client(github_server, token) as client: + resp = await client.get(f"/repos{parsed_url.path}") + + return str(resp.json()["head"]["sha"]) + + return os.getenv("CIRCLE_SHA1") + + +async def get_head_sha() -> str | None: + if get_ci_provider() == "github_action": + return get_github_actions_head_sha() + if get_ci_provider() == "circleci": + return await get_circle_ci_head_sha() + + console.log("Error: failed to get the head SHA from env", style="red") + return None + + +def get_github_repository() -> str | None: + if get_ci_provider() == "github_action": + return os.getenv("GITHUB_REPOSITORY") + if get_ci_provider() == "circleci": + repository_url = os.getenv("CIRCLE_REPOSITORY_URL") + if repository_url and ( + match := re.match( + r"(https?://[\w.-]+/)?(?P[\w.-]+/[\w.-]+)/?$", + repository_url, + ) + ): + return match.group("full_name") + + console.log("Error: failed to get the GitHub repository from env", style="red") + return None + + +@ci.command(help="Upload JUnit XML reports") +@click.option( + "--api-url", + "-u", + help="URL of the Mergify API", + required=True, + envvar="MERGIFY_API_URL", + default="https://api.mergify.com", + show_default=True, +) +@click.option( + "--token", + "-t", + help="CI Issues Application Key", + required=True, + envvar="MERGIFY_TOKEN", +) +@click.option( + "--repository", + "-r", + help="Repository full name (owner/repo)", + required=True, + default=get_github_repository, +) +@click.option( + "--head-sha", + "-s", + help="Head SHA of the triggered job", + required=True, + default=lambda: asyncio.run(get_head_sha()), +) +@click.option( + "--job-name", + "-j", + help="Job's name", + required=True, + default=get_job_name, +) +@click.option( + "--provider", + "-p", + help="CI provider", + default=get_ci_provider, +) +@click.argument( + "files", + nargs=-1, + required=True, + type=click.Path(exists=True, dir_okay=False), +) +@utils.run_with_asyncio +async def junit_upload( # noqa: PLR0913, PLR0917 + api_url: str, + token: str, + repository: str, + head_sha: str, + job_name: str, + provider: str | None, + files: tuple[str, ...], +) -> None: + await junit_upload_mod.upload( + api_url=api_url, + token=token, + repository=repository, + head_sha=head_sha, + job_name=job_name, + provider=provider, + files=files, + ) diff --git a/mergify_cli/ci/junit_upload.py b/mergify_cli/ci/junit_upload.py new file mode 100644 index 00000000..5ee16b69 --- /dev/null +++ b/mergify_cli/ci/junit_upload.py @@ -0,0 +1,82 @@ +from collections import abc +import contextlib +import pathlib +import typing + +import httpx + +from mergify_cli import console +from mergify_cli import utils + + +@contextlib.contextmanager +def get_files_to_upload( + files: tuple[str, ...], +) -> abc.Generator[list[tuple[str, tuple[str, typing.BinaryIO, str]]], None, None]: + files_to_upload: list[tuple[str, tuple[str, typing.BinaryIO, str]]] = [] + + for file in set(files): + file_path = pathlib.Path(file) + files_to_upload.append( + ("files", (file_path.name, file_path.open("rb"), "application/xml")), + ) + + try: + yield files_to_upload + finally: + for _, (_, opened_file, _) in files_to_upload: + opened_file.close() + + +async def raise_for_status(response: httpx.Response) -> None: + if response.is_error: + await response.aread() + details = response.text or "" + console.log(f"[red]Error details: {details}[/]") + + response.raise_for_status() + + +def get_ci_issues_client( + api_url: str, + token: str, +) -> httpx.AsyncClient: + return utils.get_http_client( + api_url, + headers={ + "Authorization": f"Bearer {token}", + }, + event_hooks={ + "request": [], + "response": [raise_for_status], + }, + ) + + +async def upload( # noqa: PLR0913, PLR0917 + api_url: str, + token: str, + repository: str, + head_sha: str, + job_name: str, + provider: str | None, + files: tuple[str, ...], +) -> None: + form_data = { + "head_sha": head_sha, + "name": job_name, + } + if provider is not None: + form_data["provider"] = provider + + async with get_ci_issues_client(api_url, token) as client: + with get_files_to_upload(files) as files_to_upload: + response = await client.post( + f"/v1/repos/{repository}/ci_issues_upload", + data=form_data, + files=files_to_upload, + ) + + gigid = response.json()["gigid"] + console.log(f"::notice title=CI Issues report::CI_ISSUE_GIGID={gigid}") + console.log("[green]:tada: File(s) uploaded[/]") diff --git a/mergify_cli/cli.py b/mergify_cli/cli.py index 5df9ffb2..663edd2d 100644 --- a/mergify_cli/cli.py +++ b/mergify_cli/cli.py @@ -26,6 +26,7 @@ from mergify_cli import VERSION from mergify_cli import console from mergify_cli import utils +from mergify_cli.ci import cli as ci_cli_mod from mergify_cli.stack import cli as stack_cli_mod @@ -91,6 +92,7 @@ def cli( cli.add_command(stack_cli_mod.stack) +cli.add_command(ci_cli_mod.ci) def main() -> None: diff --git a/mergify_cli/tests/ci_issues/__init__.py b/mergify_cli/tests/ci_issues/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mergify_cli/tests/ci_issues/events/pull_request.json b/mergify_cli/tests/ci_issues/events/pull_request.json new file mode 100644 index 00000000..b2404d54 --- /dev/null +++ b/mergify_cli/tests/ci_issues/events/pull_request.json @@ -0,0 +1,449 @@ +{ + "installation": { + "id": 123 + }, + "action": "opened", + "number": 2, + "pull_request": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2", + "id": 279147437, + "node_id": "MDExOlB1bGxSZXF1ZXN0Mjc5MTQ3NDM3", + "html_url": "https://github.com/Codertocat/Hello-World/pull/2", + "diff_url": "https://github.com/Codertocat/Hello-World/pull/2.diff", + "patch_url": "https://github.com/Codertocat/Hello-World/pull/2.patch", + "issue_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "number": 2, + "state": "open", + "locked": false, + "title": "Update the README with new information.", + "user": { + "login": "AnotherUser", + "id": 12345678, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "body": "This is a pretty simple change that we need to pull into master.\u0000", + "created_at": "2019-05-15T15:20:33Z", + "updated_at": "2019-05-15T15:20:33Z", + "closed_at": null, + "merged_at": null, + "merge_commit_sha": null, + "assignee": null, + "assignees": [], + "requested_reviewers": [], + "requested_teams": [], + "labels": [], + "milestone": null, + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/commits", + "review_comments_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/comments", + "review_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/ec26c3e57ca3a959ca5aad62de7213c562f8c821", + "head": { + "label": "Codertocat:changes", + "ref": "changes", + "sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", + "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", + "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", + "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", + "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", + "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", + "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", + "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", + "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", + "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", + "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", + "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", + "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", + "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", + "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", + "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", + "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", + "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", + "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "git_url": "git://github.com/Codertocat/Hello-World.git", + "ssh_url": "git@github.com:Codertocat/Hello-World.git", + "clone_url": "https://github.com/Codertocat/Hello-World.git", + "svn_url": "https://github.com/Codertocat/Hello-World", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + } + }, + "base": { + "label": "Codertocat:master", + "ref": "master", + "sha": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", + "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", + "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", + "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", + "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", + "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", + "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", + "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", + "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", + "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", + "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", + "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", + "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", + "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", + "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", + "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", + "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", + "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", + "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "git_url": "git://github.com/Codertocat/Hello-World.git", + "ssh_url": "git@github.com:Codertocat/Hello-World.git", + "clone_url": "https://github.com/Codertocat/Hello-World.git", + "svn_url": "https://github.com/Codertocat/Hello-World", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2" + }, + "html": { + "href": "https://github.com/Codertocat/Hello-World/pull/2" + }, + "issue": { + "href": "https://api.github.com/repos/Codertocat/Hello-World/issues/2" + }, + "comments": { + "href": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/comments{/number}" + }, + "commits": { + "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/Codertocat/Hello-World/statuses/ec26c3e57ca3a959ca5aad62de7213c562f8c821" + } + }, + "author_association": "OWNER", + "draft": false, + "merged": false, + "mergeable": null, + "rebaseable": null, + "mergeable_state": "unknown", + "merged_by": null, + "comments": 0, + "review_comments": 0, + "maintainer_can_modify": false, + "commits": 1, + "additions": 1, + "deletions": 1, + "changed_files": 1 + }, + "repository": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", + "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", + "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", + "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", + "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", + "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", + "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", + "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", + "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", + "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", + "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", + "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", + "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", + "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", + "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", + "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", + "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", + "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", + "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "git_url": "git://github.com/Codertocat/Hello-World.git", + "ssh_url": "git@github.com:Codertocat/Hello-World.git", + "clone_url": "https://github.com/Codertocat/Hello-World.git", + "svn_url": "https://github.com/Codertocat/Hello-World", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + }, + "sender": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +} diff --git a/mergify_cli/tests/ci_issues/reports/report.xml b/mergify_cli/tests/ci_issues/reports/report.xml new file mode 100644 index 00000000..2da49c32 --- /dev/null +++ b/mergify_cli/tests/ci_issues/reports/report.xml @@ -0,0 +1,15 @@ + + + + + + def test_failed() -> None: + > assert 1 == 0 + E assert 1 == 0 + + mergify/tests/test_junit.py:6: AssertionError + + + + \ No newline at end of file diff --git a/mergify_cli/tests/ci_issues/test_junit_upload.py b/mergify_cli/tests/ci_issues/test_junit_upload.py new file mode 100644 index 00000000..c12ff262 --- /dev/null +++ b/mergify_cli/tests/ci_issues/test_junit_upload.py @@ -0,0 +1,168 @@ +import pathlib +from unittest import mock + +from click import testing +import httpx +import pytest +import respx + +from mergify_cli.ci import cli as cli_junit_upload +from mergify_cli.ci import junit_upload as junit_upload_mod + + +REPORT_XML = pathlib.Path(__file__).parent / "reports" / "report.xml" +PULL_REQUEST_EVENT = pathlib.Path(__file__).parent / "events" / "pull_request.json" + + +@pytest.mark.parametrize( + ("env", "provider"), + [ + ( + { + "GITHUB_EVENT_NAME": "push", + "GITHUB_ACTIONS": "true", + "MERGIFY_API_URL": "https://api.mergify.com", + "MERGIFY_TOKEN": "abc", + "GITHUB_REPOSITORY": "user/repo", + "GITHUB_SHA": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199", + "GITHUB_WORKFLOW": "JOB", + }, + "github_action", + ), + ( + { + "GITHUB_ACTIONS": "", + "CIRCLECI": "true", + "MERGIFY_API_URL": "https://api.mergify.com", + "MERGIFY_TOKEN": "abc", + "CIRCLE_REPOSITORY_URL": "https://github.com/user/repo", + "CIRCLE_SHA1": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199", + "CIRCLE_JOB": "JOB", + }, + "circleci", + ), + ], +) +def test_options_values_from_env_new( + env: dict[str, str], + provider: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + for key, value in env.items(): + monkeypatch.setenv(key, value) + + runner = testing.CliRunner() + + with mock.patch.object( + junit_upload_mod, + "upload", + mock.AsyncMock(), + ) as mocked_upload: + result = runner.invoke( + cli_junit_upload.junit_upload, + [str(REPORT_XML)], + ) + assert result.exit_code == 0 + assert mocked_upload.call_count == 1 + assert mocked_upload.call_args.kwargs == { + "provider": provider, + "api_url": "https://api.mergify.com", + "token": "abc", + "repository": "user/repo", + "head_sha": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199", + "job_name": "JOB", + "files": (str(REPORT_XML),), + } + + +def test_get_head_sha_github_actions(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(PULL_REQUEST_EVENT)) + + assert ( + cli_junit_upload.get_github_actions_head_sha() + == "ec26c3e57ca3a959ca5aad62de7213c562f8c821" + ) + + +@pytest.mark.parametrize( + ("url", "api_url"), + [ + ("https://enterprise-ghes.com", "https://enterprise-ghes.com/api/v3"), + ( + "https://github.com", + "https://api.github.com", + ), + ], +) +async def test_get_head_sha_circle_ci( + url: str, + api_url: str, + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, +) -> None: + monkeypatch.setenv( + "CIRCLE_PULL_REQUESTS", + f"{url}/owner/repo/pulls/123", + ) + respx_mock.get( + f"{api_url}/repos/owner/repo/pulls/123", + ).respond( + 200, + json={"head": {"sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821"}}, + ) + + assert ( + await cli_junit_upload.get_circle_ci_head_sha() + == "ec26c3e57ca3a959ca5aad62de7213c562f8c821" + ) + + +def test_get_files_to_upload() -> None: + with junit_upload_mod.get_files_to_upload( + (str(REPORT_XML),), + ) as files_to_upload: + assert len(files_to_upload) == 1 + assert files_to_upload[0][1][0] == "report.xml" + assert files_to_upload[0][1][1].read() == REPORT_XML.read_bytes() + assert files_to_upload[0][1][2] == "application/xml" + assert not files_to_upload[0][1][1].closed + assert files_to_upload[0][1][1].closed + + +async def test_junit_upload(respx_mock: respx.MockRouter) -> None: + respx_mock.post( + "/v1/repos/user/repo/ci_issues_upload", + ).respond( + 200, + json={"gigid": "1234azertyuiop"}, + ) + + await junit_upload_mod.upload( + "https://api.mergify.com", + "token", + "user/repo", + "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199", + "ci-test-job", + "circleci", + (str(REPORT_XML),), + ) + + +async def test_junit_upload_http_error(respx_mock: respx.MockRouter) -> None: + respx_mock.post("/v1/repos/user/repo/ci_issues_upload").respond( + 422, + json={"detail": "CI Issues is not enabled on this repository"}, + ) + + with pytest.raises(httpx.HTTPStatusError): + await junit_upload_mod.upload( + "https://api.mergify.com", + "token", + "user/repo", + "head-sha", + "ci-job", + "circleci", + (str(REPORT_XML),), + ) diff --git a/mergify_cli/utils.py b/mergify_cli/utils.py index 1a03b106..b85a09b4 100644 --- a/mergify_cli/utils.py +++ b/mergify_cli/utils.py @@ -185,6 +185,37 @@ async def log_httpx_response(response: httpx.Response) -> None: ) +def get_http_client( + server: str, + headers: dict[str, typing.Any] | None = None, + event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]] + | None = None, + follow_redirects: bool = False, +) -> httpx.AsyncClient: + default_headers = {"User-Agent": f"mergify_cli/{VERSION}"} + if headers is not None: + default_headers |= headers + + default_event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]] = { + "request": [], + "response": [], + } + if event_hooks is not None: + default_event_hooks["request"] += event_hooks["request"] + default_event_hooks["response"] += event_hooks["response"] + if is_debug(): + default_event_hooks["request"].insert(0, log_httpx_request) + default_event_hooks["response"].insert(0, log_httpx_response) + + return httpx.AsyncClient( + base_url=server, + headers=default_headers, + event_hooks=default_event_hooks, + follow_redirects=follow_redirects, + timeout=5.0, + ) + + def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient: event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]] = { "request": [], @@ -194,16 +225,14 @@ def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient: event_hooks["request"].insert(0, log_httpx_request) event_hooks["response"].insert(0, log_httpx_response) - return httpx.AsyncClient( - base_url=github_server, + return get_http_client( + github_server, headers={ "Accept": "application/vnd.github.v3+json", - "User-Agent": f"mergify_cli/{VERSION}", "Authorization": f"token {token}", }, event_hooks=event_hooks, follow_redirects=True, - timeout=5.0, )