Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(ci-issues): add junit upload CLI #550

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added mergify_cli/ci/__init__.py
Empty file.
131 changes: 131 additions & 0 deletions mergify_cli/ci/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import os
import re
import typing

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",
)


def get_ci_provider() -> typing.Literal["github_action", "circleci"] | 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 os.getenv("GITHUB_ACTIONS") == "true":
return os.getenv("GITHUB_WORKFLOW")
if os.getenv("CIRCLECI") == "true":
return os.getenv("CIRCLE_JOB")

console.log("Error: failed to get the job's name from env", style="red")
return None


def get_head_sha() -> str | None:
if os.getenv("GITHUB_ACTIONS") == "true":
return os.getenv("GITHUB_SHA")
if os.getenv("CIRCLECI") == "true":
return os.getenv("CIRCLE_SHA1")

console.log("Error: failed to get the head SHA from env", style="red")
return None


def get_github_repository() -> str | None:
if os.getenv("GITHUB_ACTIONS") == "true":
return os.getenv("GITHUB_REPOSITORY")
if os.getenv("CIRCLECI") == "true":
repository_url = os.getenv("CIRCLE_REPOSITORY_URL")
if repository_url and (
match := re.match(
r"(https?://[\w.-]+/)?(?P<full_name>[\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_SERVER",
default="https://api.mergify.com/",
show_default=True,
)
@click.option(
"--token",
"-t",
help="CI Issues Application Key",
required=True,
envvar="MERGIFY_CI_ISSUES_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=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,
)
76 changes: 76 additions & 0 deletions mergify_cli/ci/junit_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import pathlib
import typing

import httpx

from mergify_cli import console
from mergify_cli import utils


def get_files_to_upload(
files: tuple[str, ...],
) -> list[tuple[str, tuple[str, typing.BinaryIO, str]]]:
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")),
)

return files_to_upload


async def raise_for_status(response: httpx.Response) -> None:
if response.is_error:
await response.aread()
details = response.text or "<empty_response>"
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

files_to_upload = get_files_to_upload(files)

async with get_ci_issues_client(api_url, token) as client:
response = await client.post(
f"v1/repos/{repository}/ci_issues_upload",
data=form_data,
files=files_to_upload,
)

console.log(
f"[green]:tada: File(s) uploaded (gigid={response.json()['gigid']})[/]",
)
2 changes: 2 additions & 0 deletions mergify_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -91,6 +92,7 @@ def cli(


cli.add_command(stack_cli_mod.stack)
cli.add_command(ci_cli_mod.ci)


def main() -> None:
Expand Down
Empty file.
15 changes: 15 additions & 0 deletions mergify_cli/tests/ci_issues/reports/report.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<testsuites>
<testsuite name="pytest" errors="0" failures="1" skipped="0" tests="2" time="0.026"
timestamp="2024-08-14T12:25:18.210796+02:00" hostname="mergify-MBP">
<testcase classname="mergify.tests.test_junit" name="test_success" time="0.000"/>
<testcase classname="mergify.tests.test_junit" name="test_failed" time="0.000">
<failure message="assert 1 == 0">def test_failed() -&gt; None:
&gt; assert 1 == 0
E assert 1 == 0

mergify/tests/test_junit.py:6: AssertionError
</failure>
</testcase>
</testsuite>
</testsuites>
119 changes: 119 additions & 0 deletions mergify_cli/tests/ci_issues/test_junit_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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"


@pytest.mark.parametrize(
("env", "provider"),
[
(
{
"GITHUB_ACTIONS": "true",
"MERGIFY_API_SERVER": "https://api.mergify.com/",
"MERGIFY_CI_ISSUES_TOKEN": "abc",
"GITHUB_REPOSITORY": "user/repo",
"GITHUB_SHA": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
"GITHUB_WORKFLOW": "JOB",
},
"github_action",
),
(
{
"CIRCLECI": "true",
"MERGIFY_API_SERVER": "https://api.mergify.com/",
"MERGIFY_CI_ISSUES_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_files_to_upload() -> None:
files_to_upload = junit_upload_mod.get_files_to_upload(
(str(REPORT_XML),),
)
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"


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),),
)
Loading
Loading