Skip to content

Commit 2f24b3d

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 2f24b3d

File tree

9 files changed

+300
-4
lines changed

9 files changed

+300
-4
lines changed

mergify_cli/ci_issues/__init__.py

Whitespace-only changes.

mergify_cli/ci_issues/cli.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
required=True,
19+
envvar="MERGIFY_API_SERVER",
20+
default="https://api.mergify.com/v1",
21+
show_default=True,
22+
)
23+
@click.option(
24+
"--token",
25+
"-t",
26+
help="CI Issues Application Key",
27+
required=True,
28+
envvar="MERGIFY_CI_ISSUES_TOKEN",
29+
)
30+
@click.option(
31+
"--repository",
32+
"-r",
33+
help="Repository full name (owner/repo)",
34+
required=True,
35+
envvar="REPOSITORY",
36+
)
37+
@click.option(
38+
"--head-sha",
39+
"-s",
40+
help="Head SHA of the triggered job",
41+
required=True,
42+
envvar="HEAD_SHA",
43+
)
44+
@click.option(
45+
"--job-name",
46+
"-j",
47+
help="Job's name",
48+
required=True,
49+
envvar="JOB_NAME",
50+
)
51+
@click.option(
52+
"--provider",
53+
"-p",
54+
help="CI provider",
55+
default=None,
56+
envvar="CI_PROVIDER",
57+
)
58+
@click.argument(
59+
"files",
60+
nargs=-1,
61+
required=True,
62+
type=click.Path(exists=True, dir_okay=False),
63+
)
64+
@utils.run_with_asyncio
65+
async def junit_upload( # noqa: PLR0913, PLR0917
66+
api_url: str,
67+
token: str,
68+
repository: str,
69+
head_sha: str,
70+
job_name: str,
71+
provider: str | None,
72+
files: tuple[str, ...],
73+
) -> None:
74+
await junit_upload_mod.upload(
75+
api_url=api_url,
76+
token=token,
77+
repository=repository,
78+
head_sha=head_sha,
79+
job_name=job_name,
80+
provider=provider,
81+
files=files,
82+
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from mergify_cli import console
2+
from mergify_cli.ci_issues import utils
3+
4+
5+
async def upload( # noqa: PLR0913, PLR0917
6+
api_url: str,
7+
token: str,
8+
repository: str,
9+
head_sha: str,
10+
job_name: str,
11+
provider: str | None,
12+
files: tuple[str, ...],
13+
) -> None:
14+
files_to_upload = utils.get_files_to_upload(files)
15+
16+
form_data = {
17+
"head_sha": head_sha,
18+
"name": job_name,
19+
}
20+
if provider is not None:
21+
form_data["provider"] = provider
22+
23+
async with utils.get_ci_issues_client(api_url, token) as client:
24+
response = await client.post(
25+
f"/repos/{repository}/ci_issues_upload",
26+
data=form_data,
27+
files=files_to_upload,
28+
)
29+
30+
console.log(
31+
f"[green]:tada: File(s) uploaded (gigid=${response.json()['gigid']})[/]",
32+
)

mergify_cli/ci_issues/utils.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import pathlib
2+
3+
import httpx
4+
5+
from mergify_cli import console
6+
from mergify_cli import utils
7+
8+
9+
async def raise_for_status(response: httpx.Response) -> None:
10+
if response.is_error:
11+
await response.aread()
12+
details = response.text or "<empty_response>"
13+
console.log(f"[red]Error details: {details}[/]")
14+
15+
response.raise_for_status()
16+
17+
18+
def get_ci_issues_client(
19+
api_url: str,
20+
token: str,
21+
) -> httpx.AsyncClient:
22+
return utils.get_http_client(
23+
api_url,
24+
headers={
25+
"Authorization": f"Bearer {token}",
26+
},
27+
event_hooks={
28+
"request": [],
29+
"response": [raise_for_status],
30+
},
31+
)
32+
33+
34+
def get_files_to_upload(
35+
files: tuple[str, ...],
36+
) -> list[tuple[str, tuple[str, bytes, str]]]:
37+
files_to_upload = []
38+
39+
for file in set(files):
40+
file_path = pathlib.Path(file)
41+
# TODO(leo): stream the files instead of loading them
42+
with file_path.open("rb") as f:
43+
files_to_upload.append(
44+
("files", (file_path.name, f.read(), "application/xml")),
45+
)
46+
47+
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>
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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_issues import cli as cli_junit_upload
10+
from mergify_cli.ci_issues import junit_upload as junit_upload_mod
11+
from mergify_cli.ci_issues import utils as ci_issues_utils
12+
13+
14+
REPORT_XML = pathlib.Path(__file__).parent / "reports" / "report.xml"
15+
16+
17+
def test_options_values_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
18+
monkeypatch.setenv("MERGIFY_API_SERVER", "https://api.mergify.com/v2")
19+
monkeypatch.setenv("MERGIFY_CI_ISSUES_TOKEN", "abc")
20+
monkeypatch.setenv("REPOSITORY", "user/repo")
21+
monkeypatch.setenv("HEAD_SHA", "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199")
22+
monkeypatch.setenv("JOB_NAME", "JOB")
23+
monkeypatch.setenv("CI_PROVIDER", "circleci")
24+
25+
runner = testing.CliRunner()
26+
27+
with mock.patch.object(
28+
junit_upload_mod,
29+
"upload",
30+
mock.AsyncMock(),
31+
) as mocked_upload:
32+
result = runner.invoke(
33+
cli_junit_upload.junit_upload,
34+
[str(REPORT_XML)],
35+
)
36+
assert result.exit_code == 0
37+
assert mocked_upload.call_count == 1
38+
assert mocked_upload.call_args.kwargs == {
39+
"api_url": "https://api.mergify.com/v2",
40+
"token": "abc",
41+
"repository": "user/repo",
42+
"files": (str(REPORT_XML),),
43+
"head_sha": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
44+
"job_name": "JOB",
45+
"provider": "circleci",
46+
}
47+
48+
49+
def test_get_files_to_upload() -> None:
50+
files_to_upload = ci_issues_utils.get_files_to_upload(
51+
(str(REPORT_XML),),
52+
)
53+
assert files_to_upload == [
54+
(
55+
"files",
56+
(
57+
"report.xml",
58+
REPORT_XML.read_bytes(),
59+
"application/xml",
60+
),
61+
),
62+
]
63+
64+
65+
async def test_junit_upload(respx_mock: respx.MockRouter) -> None:
66+
respx_mock.post(
67+
"/v1/repos/user/repo/ci_issues_upload",
68+
).respond(
69+
200,
70+
json={"gigid": "1234azertyuiop"},
71+
)
72+
73+
await junit_upload_mod.upload(
74+
"https://api.mergify.com/v1",
75+
"token",
76+
"user/repo",
77+
"3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
78+
"ci-test-job",
79+
"circleci",
80+
(str(REPORT_XML),),
81+
)
82+
83+
84+
async def test_junit_upload_http_error(respx_mock: respx.MockRouter) -> None:
85+
respx_mock.post("/v1/repos/user/repo/ci_issues_upload").respond(
86+
422,
87+
json={"detail": "CI Issues is not enabled on this repository"},
88+
)
89+
90+
with pytest.raises(httpx.HTTPStatusError):
91+
await junit_upload_mod.upload(
92+
"https://api.mergify.com/v1",
93+
"token",
94+
"user/repo",
95+
"head-sha",
96+
"ci-job",
97+
"circleci",
98+
(str(REPORT_XML),),
99+
)

mergify_cli/utils.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,27 @@ async def log_httpx_response(response: httpx.Response) -> None:
185185
)
186186

187187

188+
def get_http_client(
189+
server: str,
190+
headers: dict[str, typing.Any] | None = None,
191+
event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]]
192+
| None = None,
193+
follow_redirects: bool = False,
194+
) -> httpx.AsyncClient:
195+
if headers is not None:
196+
headers |= {"User-Agent": f"mergify_cli/{VERSION}"}
197+
198+
return httpx.AsyncClient(
199+
base_url=server,
200+
headers={
201+
"User-Agent": f"mergify_cli/{VERSION}",
202+
},
203+
event_hooks=event_hooks,
204+
follow_redirects=follow_redirects,
205+
timeout=5.0,
206+
)
207+
208+
188209
def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient:
189210
event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]] = {
190211
"request": [],
@@ -194,16 +215,14 @@ def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient:
194215
event_hooks["request"].insert(0, log_httpx_request)
195216
event_hooks["response"].insert(0, log_httpx_response)
196217

197-
return httpx.AsyncClient(
198-
base_url=github_server,
218+
return get_http_client(
219+
github_server,
199220
headers={
200221
"Accept": "application/vnd.github.v3+json",
201-
"User-Agent": f"mergify_cli/{VERSION}",
202222
"Authorization": f"token {token}",
203223
},
204224
event_hooks=event_hooks,
205225
follow_redirects=True,
206-
timeout=5.0,
207226
)
208227

209228

0 commit comments

Comments
 (0)