Skip to content

Commit

Permalink
chore(ci-issues): add junit upload CLI
Browse files Browse the repository at this point in the history
Adds the cli `mergify ci-issues junit-upload` to upload JUnit xml reports to CI Issues.

Fixes MRGFY-4339
  • Loading branch information
lecrepont01 committed Nov 22, 2024
1 parent 427ccc6 commit e9af926
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 4 deletions.
Empty file.
82 changes: 82 additions & 0 deletions mergify_cli/ci_issues/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import click

from mergify_cli import utils
from mergify_cli.ci_issues import junit_upload as junit_upload_mod


ci_issues = click.Group(
"ci-issues",
help="Interact with Mergify's CI Issues",
)


@ci_issues.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/v1",
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,
envvar="REPOSITORY",
)
@click.option(
"--head-sha",
"-s",
help="Head SHA of the triggered job",
required=True,
envvar="HEAD_SHA",
)
@click.option(
"--job-name",
"-j",
help="Job's name",
required=True,
envvar="JOB_NAME",
)
@click.option(
"--provider",
"-p",
help="CI provider",
default=None,
envvar="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,
)
32 changes: 32 additions & 0 deletions mergify_cli/ci_issues/junit_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from mergify_cli import console
from mergify_cli.ci_issues import utils


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:
files_to_upload = utils.get_files_to_upload(files)

form_data = {
"head_sha": head_sha,
"name": job_name,
}
if provider is not None:
form_data["provider"] = provider

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

console.log(
f"[green]:tada: File(s) uploaded (gigid={response.json()['gigid']})[/]",
)
47 changes: 47 additions & 0 deletions mergify_cli/ci_issues/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import pathlib

import httpx

from mergify_cli import console
from mergify_cli import utils


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],
},
)


def get_files_to_upload(
files: tuple[str, ...],
) -> list[tuple[str, tuple[str, bytes, str]]]:
files_to_upload = []

for file in set(files):
file_path = pathlib.Path(file)
# TODO(leo): stream the files instead of loading them
with file_path.open("rb") as f:
files_to_upload.append(
("files", (file_path.name, f.read(), "application/xml")),
)

return files_to_upload
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_issues import cli as ci_issues_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_issues_cli_mod.ci_issues)


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>
99 changes: 99 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,99 @@
import pathlib
from unittest import mock

from click import testing
import httpx
import pytest
import respx

from mergify_cli.ci_issues import cli as cli_junit_upload
from mergify_cli.ci_issues import junit_upload as junit_upload_mod
from mergify_cli.ci_issues import utils as ci_issues_utils


REPORT_XML = pathlib.Path(__file__).parent / "reports" / "report.xml"


def test_options_values_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MERGIFY_API_SERVER", "https://api.mergify.com/v2")
monkeypatch.setenv("MERGIFY_CI_ISSUES_TOKEN", "abc")
monkeypatch.setenv("REPOSITORY", "user/repo")
monkeypatch.setenv("HEAD_SHA", "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199")
monkeypatch.setenv("JOB_NAME", "JOB")
monkeypatch.setenv("CI_PROVIDER", "circleci")

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 == {
"api_url": "https://api.mergify.com/v2",
"token": "abc",
"repository": "user/repo",
"files": (str(REPORT_XML),),
"head_sha": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
"job_name": "JOB",
"provider": "circleci",
}


def test_get_files_to_upload() -> None:
files_to_upload = ci_issues_utils.get_files_to_upload(
(str(REPORT_XML),),
)
assert files_to_upload == [
(
"files",
(
"report.xml",
REPORT_XML.read_bytes(),
"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/v1",
"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/v1",
"token",
"user/repo",
"head-sha",
"ci-job",
"circleci",
(str(REPORT_XML),),
)
26 changes: 22 additions & 4 deletions mergify_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,26 @@ 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:
headers |= default_headers

return httpx.AsyncClient(
base_url=server,
headers=headers,
event_hooks=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": [],
Expand All @@ -194,16 +214,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,
)


Expand Down

0 comments on commit e9af926

Please sign in to comment.