Skip to content

Commit 85cfbf3

Browse files
author
leoecrepont
committed
chore(ci-issues): add junit upload CLI
Adds the cli `mergify ci-issues junit-upload` to upload JUnit xml reports to CI Issues. Fixes MRGFY-4339
1 parent 427ccc6 commit 85cfbf3

File tree

11 files changed

+534
-2
lines changed

11 files changed

+534
-2
lines changed

mergify_cli/ci_issues/__init__.py

Whitespace-only changes.

mergify_cli/ci_issues/cli.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import click
2+
3+
from mergify_cli import utils
4+
from mergify_cli.ci_issues import junit_upload as junit_upload_mod
5+
6+
7+
ci_issues = click.Group(
8+
"ci-issues",
9+
help="Interact with Mergify's CI Issues",
10+
)
11+
12+
13+
@ci_issues.command(help="Upload JUnit XML reports")
14+
@click.option(
15+
"--api-url",
16+
"-u",
17+
help="URL of the Mergify API",
18+
default="https://api.mergify.com/v1",
19+
show_default=True,
20+
)
21+
@click.option(
22+
"--token",
23+
"-t",
24+
help="CI Issues Application Key",
25+
)
26+
@click.option(
27+
"--repository",
28+
"-r",
29+
help="Repository URL or full name (owner/repo)",
30+
)
31+
@click.option(
32+
"--files",
33+
"-f",
34+
help="Paths to JUnit XML reports (comma separated)",
35+
)
36+
@click.option(
37+
"--head-sha",
38+
"-s",
39+
help="Head SHA of the triggered job",
40+
)
41+
@click.option(
42+
"--job-name",
43+
"-j",
44+
help="Job's name",
45+
)
46+
@click.option(
47+
"--provider",
48+
"-p",
49+
help="CI provider",
50+
default=None,
51+
)
52+
@utils.run_with_asyncio
53+
async def junit_upload( # noqa: PLR0913, PLR0917
54+
api_url: str,
55+
token: str,
56+
repository: str,
57+
files: str,
58+
head_sha: str,
59+
job_name: str,
60+
provider: str | None,
61+
) -> None:
62+
await junit_upload_mod.upload(
63+
api_url=api_url,
64+
token=token,
65+
repository=repository,
66+
files=files,
67+
head_sha=head_sha,
68+
job_name=job_name,
69+
provider=provider,
70+
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import re
2+
import typing
3+
4+
import pydantic
5+
6+
7+
def validate_repository_full_name(repository: str) -> str:
8+
match = re.match(
9+
r"(https?://[\w.-]+/)?(?P<full_name>[\w.-]+/[\w.-]+)/?$",
10+
repository,
11+
)
12+
if match is not None:
13+
return match.group("full_name")
14+
15+
message = "Provide repository URL or 'owner/repo' format"
16+
raise ValueError(message)
17+
18+
19+
RepositoryT = typing.Annotated[
20+
str,
21+
pydantic.Field(min_length=1),
22+
pydantic.AfterValidator(
23+
validate_repository_full_name,
24+
),
25+
]
26+
27+
28+
class CIIssuesJunitUploadParameters(pydantic.BaseModel):
29+
api_url: pydantic.HttpUrl
30+
token: str = pydantic.Field(min_length=1)
31+
repository: RepositoryT
32+
files: str = pydantic.Field(min_length=1)
33+
head_sha: str = pydantic.Field(min_length=1)
34+
job_name: str = pydantic.Field(min_length=1)
35+
provider: str | None
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import sys
2+
3+
import httpx
4+
import pydantic
5+
6+
from mergify_cli import console
7+
from mergify_cli.ci_issues import utils
8+
from mergify_cli.ci_issues.custom_types import CIIssuesJunitUploadParameters
9+
10+
11+
async def upload( # noqa: PLR0913, PLR0917
12+
api_url: str,
13+
token: str,
14+
repository: str,
15+
files: str,
16+
head_sha: str,
17+
job_name: str,
18+
provider: str | None,
19+
) -> None:
20+
try:
21+
# NOTE(leo): perform a first validation of the input parameters in CLI
22+
# before the back-end handles the rest
23+
valid_params = CIIssuesJunitUploadParameters.model_validate(
24+
{
25+
"api_url": api_url,
26+
"token": token,
27+
"repository": repository,
28+
"files": files,
29+
"head_sha": head_sha,
30+
"job_name": job_name,
31+
"provider": provider,
32+
},
33+
)
34+
except pydantic.ValidationError as e:
35+
console.log(f"[red]Invalid parameters: \nDetails: {e!s}[/]")
36+
sys.exit(1)
37+
38+
try:
39+
files_to_upload = utils.get_files_to_upload(files)
40+
except utils.InvalidFileError as e:
41+
console.log(f"[red]{e!s}[/]")
42+
sys.exit(1)
43+
44+
client = utils.CIIssuesClient(api_url, token)
45+
46+
form_data = {
47+
"head_sha": head_sha,
48+
"name": job_name,
49+
}
50+
if provider is not None:
51+
form_data["provider"] = provider
52+
53+
try:
54+
await client.post(
55+
f"/repos/{valid_params.repository}/ci_issues_upload",
56+
data=form_data,
57+
files=files_to_upload,
58+
)
59+
except httpx.HTTPStatusError as e:
60+
console.log(f"[red]{e!s}[/]")
61+
sys.exit(1)
62+
63+
console.log("[green]File(s) uploaded :tada:[/]")

mergify_cli/ci_issues/utils.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import pathlib
2+
import typing
3+
4+
import httpx
5+
from httpx import _types as httpx_types
6+
7+
8+
def raise_for_status(response: httpx.Response) -> None:
9+
if response.is_error:
10+
details = response.text or "<empty-response>"
11+
error_type = "Client error" if response.is_client_error else "Server error"
12+
message = (
13+
f"{response.status_code} {error_type}: {response.reason_phrase} for url `{response.url}`"
14+
f"\nDetails: {details}"
15+
)
16+
raise httpx.HTTPStatusError(
17+
message,
18+
request=response.request,
19+
response=response,
20+
)
21+
22+
23+
class CIIssuesClient(httpx.AsyncClient):
24+
def __init__(self, base_url: str, token: str) -> None:
25+
super().__init__(
26+
base_url=base_url,
27+
headers={
28+
"Authorization": f"Bearer {token}",
29+
},
30+
timeout=10.0,
31+
)
32+
33+
async def request(
34+
self,
35+
method: str,
36+
url: httpx_types.URLTypes,
37+
*args: typing.Any, # noqa: ANN401
38+
**kwargs: typing.Any, # noqa: ANN401
39+
) -> httpx.Response:
40+
r = await super().request(method, url, *args, **kwargs)
41+
raise_for_status(r)
42+
return r
43+
44+
45+
class InvalidFileError(Exception):
46+
pass
47+
48+
49+
def get_files_to_upload(
50+
files: str,
51+
) -> list[tuple[str, tuple[str, bytes, str]]]:
52+
files_to_upload = []
53+
54+
for file in set(files.split(",")):
55+
file_path = pathlib.Path(file)
56+
if not file_path.is_file():
57+
message = f"File invalid or not found: '{file_path!s}'"
58+
raise InvalidFileError(message)
59+
60+
with file_path.open("rb") as f:
61+
files_to_upload.append(
62+
("files", (file_path.name, f.read(), "application/xml")),
63+
)
64+
65+
return files_to_upload

mergify_cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from mergify_cli import VERSION
2727
from mergify_cli import console
2828
from mergify_cli import utils
29+
from mergify_cli.ci_issues import cli as ci_issues_cli_mod
2930
from mergify_cli.stack import cli as stack_cli_mod
3031

3132

@@ -91,6 +92,7 @@ def cli(
9192

9293

9394
cli.add_command(stack_cli_mod.stack)
95+
cli.add_command(ci_issues_cli_mod.ci_issues)
9496

9597

9698
def main() -> None:

mergify_cli/tests/ci_issues/__init__.py

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<testsuites>
3+
<testsuite name="pytest" errors="0" failures="1" skipped="0" tests="2" time="0.026"
4+
timestamp="2024-08-14T12:25:18.210796+02:00" hostname="mergify-MBP">
5+
<testcase classname="mergify.tests.test_junit" name="test_success" time="0.000"/>
6+
<testcase classname="mergify.tests.test_junit" name="test_failed" time="0.000">
7+
<failure message="assert 1 == 0">def test_failed() -&gt; None:
8+
&gt; assert 1 == 0
9+
E assert 1 == 0
10+
11+
mergify/tests/test_junit.py:6: AssertionError
12+
</failure>
13+
</testcase>
14+
</testsuite>
15+
</testsuites>

0 commit comments

Comments
 (0)