Skip to content

Commit 92742e1

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 92742e1

File tree

8 files changed

+376
-4
lines changed

8 files changed

+376
-4
lines changed

mergify_cli/ci/__init__.py

Whitespace-only changes.

mergify_cli/ci/cli.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import os
2+
import re
3+
import typing
4+
5+
import click
6+
7+
from mergify_cli import console
8+
from mergify_cli import utils
9+
from mergify_cli.ci import junit_upload as junit_upload_mod
10+
11+
12+
ci = click.Group(
13+
"ci",
14+
help="Mergify's CI related commands",
15+
)
16+
17+
18+
def get_ci_provider() -> typing.Literal["github_action", "circleci"] | None:
19+
if os.getenv("GITHUB_ACTIONS") == "true":
20+
return "github_action"
21+
if os.getenv("CIRCLECI") == "true":
22+
return "circleci"
23+
return None
24+
25+
26+
def get_job_name() -> str | None:
27+
if os.getenv("GITHUB_ACTIONS") == "true":
28+
return os.getenv("GITHUB_WORKFLOW")
29+
if os.getenv("CIRCLECI") == "true":
30+
return os.getenv("CIRCLE_JOB")
31+
32+
console.log("Error: failed to get the job's name from env", style="red")
33+
return None
34+
35+
36+
def get_head_sha() -> str | None:
37+
if os.getenv("GITHUB_ACTIONS") == "true":
38+
return os.getenv("GITHUB_SHA")
39+
if os.getenv("CIRCLECI") == "true":
40+
return os.getenv("CIRCLE_SHA1")
41+
42+
console.log("Error: failed to get the head SHA from env", style="red")
43+
return None
44+
45+
46+
def get_github_repository() -> str | None:
47+
if os.getenv("GITHUB_ACTIONS") == "true":
48+
return os.getenv("GITHUB_REPOSITORY")
49+
if os.getenv("CIRCLECI") == "true":
50+
repository_url = os.getenv("CIRCLE_REPOSITORY_URL")
51+
if repository_url and (
52+
match := re.match(
53+
r"(https?://[\w.-]+/)?(?P<full_name>[\w.-]+/[\w.-]+)/?$",
54+
repository_url,
55+
)
56+
):
57+
return match.group("full_name")
58+
59+
console.log("Error: failed to get the GitHub repository from env", style="red")
60+
return None
61+
62+
63+
@ci.command(help="Upload JUnit XML reports")
64+
@click.option(
65+
"--api-url",
66+
"-u",
67+
help="URL of the Mergify API",
68+
required=True,
69+
envvar="MERGIFY_API_SERVER",
70+
default="https://api.mergify.com/",
71+
show_default=True,
72+
)
73+
@click.option(
74+
"--token",
75+
"-t",
76+
help="CI Issues Application Key",
77+
required=True,
78+
envvar="MERGIFY_CI_ISSUES_TOKEN",
79+
)
80+
@click.option(
81+
"--repository",
82+
"-r",
83+
help="Repository full name (owner/repo)",
84+
required=True,
85+
default=get_github_repository,
86+
)
87+
@click.option(
88+
"--head-sha",
89+
"-s",
90+
help="Head SHA of the triggered job",
91+
required=True,
92+
default=get_head_sha,
93+
)
94+
@click.option(
95+
"--job-name",
96+
"-j",
97+
help="Job's name",
98+
required=True,
99+
default=get_job_name,
100+
)
101+
@click.option(
102+
"--provider",
103+
"-p",
104+
help="CI provider",
105+
default=get_ci_provider,
106+
)
107+
@click.argument(
108+
"files",
109+
nargs=-1,
110+
required=True,
111+
type=click.Path(exists=True, dir_okay=False),
112+
)
113+
@utils.run_with_asyncio
114+
async def junit_upload( # noqa: PLR0913, PLR0917
115+
api_url: str,
116+
token: str,
117+
repository: str,
118+
head_sha: str,
119+
job_name: str,
120+
provider: str | None,
121+
files: tuple[str, ...],
122+
) -> None:
123+
await junit_upload_mod.upload(
124+
api_url=api_url,
125+
token=token,
126+
repository=repository,
127+
head_sha=head_sha,
128+
job_name=job_name,
129+
provider=provider,
130+
files=files,
131+
)

mergify_cli/ci/junit_upload.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import pathlib
2+
import typing
3+
4+
import httpx
5+
6+
from mergify_cli import console
7+
from mergify_cli import utils
8+
9+
10+
def get_files_to_upload(
11+
files: tuple[str, ...],
12+
) -> list[tuple[str, tuple[str, typing.BinaryIO, str]]]:
13+
files_to_upload: list[tuple[str, tuple[str, typing.BinaryIO, str]]] = []
14+
15+
for file in set(files):
16+
file_path = pathlib.Path(file)
17+
files_to_upload.append(
18+
("files", (file_path.name, file_path.open("rb"), "application/xml")),
19+
)
20+
21+
return files_to_upload
22+
23+
24+
async def raise_for_status(response: httpx.Response) -> None:
25+
if response.is_error:
26+
await response.aread()
27+
details = response.text or "<empty_response>"
28+
console.log(f"[red]Error details: {details}[/]")
29+
30+
response.raise_for_status()
31+
32+
33+
def get_ci_issues_client(
34+
api_url: str,
35+
token: str,
36+
) -> httpx.AsyncClient:
37+
return utils.get_http_client(
38+
api_url,
39+
headers={
40+
"Authorization": f"Bearer {token}",
41+
},
42+
event_hooks={
43+
"request": [],
44+
"response": [raise_for_status],
45+
},
46+
)
47+
48+
49+
async def upload( # noqa: PLR0913, PLR0917
50+
api_url: str,
51+
token: str,
52+
repository: str,
53+
head_sha: str,
54+
job_name: str,
55+
provider: str | None,
56+
files: tuple[str, ...],
57+
) -> None:
58+
form_data = {
59+
"head_sha": head_sha,
60+
"name": job_name,
61+
}
62+
if provider is not None:
63+
form_data["provider"] = provider
64+
65+
files_to_upload = get_files_to_upload(files)
66+
67+
async with get_ci_issues_client(api_url, token) as client:
68+
response = await client.post(
69+
f"v1/repos/{repository}/ci_issues_upload",
70+
data=form_data,
71+
files=files_to_upload,
72+
)
73+
74+
console.log(
75+
f"[green]:tada: File(s) uploaded (gigid={response.json()['gigid']})[/]",
76+
)

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 import cli as ci_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_cli_mod.ci)
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>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import pathlib
2+
from unittest import mock
3+
4+
from click import testing
5+
import httpx
6+
import pytest
7+
import respx
8+
9+
from mergify_cli.ci import cli as cli_junit_upload
10+
from mergify_cli.ci import junit_upload as junit_upload_mod
11+
12+
13+
REPORT_XML = pathlib.Path(__file__).parent / "reports" / "report.xml"
14+
15+
16+
@pytest.mark.parametrize(
17+
("env", "provider"),
18+
[
19+
(
20+
{
21+
"GITHUB_ACTIONS": "true",
22+
"MERGIFY_API_SERVER": "https://api.mergify.com/",
23+
"MERGIFY_CI_ISSUES_TOKEN": "abc",
24+
"GITHUB_REPOSITORY": "user/repo",
25+
"GITHUB_SHA": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
26+
"GITHUB_WORKFLOW": "JOB",
27+
},
28+
"github_action",
29+
),
30+
(
31+
{
32+
"CIRCLECI": "true",
33+
"MERGIFY_API_SERVER": "https://api.mergify.com/",
34+
"MERGIFY_CI_ISSUES_TOKEN": "abc",
35+
"CIRCLE_REPOSITORY_URL": "https://github.com/user/repo",
36+
"CIRCLE_SHA1": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
37+
"CIRCLE_JOB": "JOB",
38+
},
39+
"circleci",
40+
),
41+
],
42+
)
43+
def test_options_values_from_env_new(
44+
env: dict[str, str],
45+
provider: str,
46+
monkeypatch: pytest.MonkeyPatch,
47+
) -> None:
48+
for key, value in env.items():
49+
monkeypatch.setenv(key, value)
50+
51+
runner = testing.CliRunner()
52+
53+
with mock.patch.object(
54+
junit_upload_mod,
55+
"upload",
56+
mock.AsyncMock(),
57+
) as mocked_upload:
58+
result = runner.invoke(
59+
cli_junit_upload.junit_upload,
60+
[str(REPORT_XML)],
61+
)
62+
assert result.exit_code == 0
63+
assert mocked_upload.call_count == 1
64+
assert mocked_upload.call_args.kwargs == {
65+
"provider": provider,
66+
"api_url": "https://api.mergify.com/",
67+
"token": "abc",
68+
"repository": "user/repo",
69+
"head_sha": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
70+
"job_name": "JOB",
71+
"files": (str(REPORT_XML),),
72+
}
73+
74+
75+
def test_get_files_to_upload() -> None:
76+
files_to_upload = junit_upload_mod.get_files_to_upload(
77+
(str(REPORT_XML),),
78+
)
79+
assert len(files_to_upload) == 1
80+
assert files_to_upload[0][1][0] == "report.xml"
81+
assert files_to_upload[0][1][1].read() == REPORT_XML.read_bytes()
82+
assert files_to_upload[0][1][2] == "application/xml"
83+
84+
85+
async def test_junit_upload(respx_mock: respx.MockRouter) -> None:
86+
respx_mock.post(
87+
"/v1/repos/user/repo/ci_issues_upload",
88+
).respond(
89+
200,
90+
json={"gigid": "1234azertyuiop"},
91+
)
92+
93+
await junit_upload_mod.upload(
94+
"https://api.mergify.com/",
95+
"token",
96+
"user/repo",
97+
"3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
98+
"ci-test-job",
99+
"circleci",
100+
(str(REPORT_XML),),
101+
)
102+
103+
104+
async def test_junit_upload_http_error(respx_mock: respx.MockRouter) -> None:
105+
respx_mock.post("/v1/repos/user/repo/ci_issues_upload").respond(
106+
422,
107+
json={"detail": "CI Issues is not enabled on this repository"},
108+
)
109+
110+
with pytest.raises(httpx.HTTPStatusError):
111+
await junit_upload_mod.upload(
112+
"https://api.mergify.com/",
113+
"token",
114+
"user/repo",
115+
"head-sha",
116+
"ci-job",
117+
"circleci",
118+
(str(REPORT_XML),),
119+
)

0 commit comments

Comments
 (0)