Skip to content

Commit 8744c22

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 8744c22

File tree

9 files changed

+296
-4
lines changed

9 files changed

+296
-4
lines changed

mergify_cli/ci_issues/__init__.py

Whitespace-only changes.

mergify_cli/ci_issues/cli.py

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

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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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_CI_ISSUES_TOKEN", "abc")
19+
monkeypatch.setenv("REPOSITORY", "user/repo")
20+
monkeypatch.setenv("HEAD_SHA", "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199")
21+
monkeypatch.setenv("JOB_NAME", "JOB")
22+
monkeypatch.setenv("CI_PROVIDER", "circleci")
23+
24+
runner = testing.CliRunner()
25+
26+
with mock.patch.object(
27+
junit_upload_mod,
28+
"upload",
29+
mock.AsyncMock(),
30+
) as mocked_upload:
31+
result = runner.invoke(
32+
cli_junit_upload.junit_upload,
33+
[str(REPORT_XML)],
34+
)
35+
assert result.exit_code == 0
36+
assert mocked_upload.call_count == 1
37+
assert mocked_upload.call_args.kwargs == {
38+
"api_url": "https://api.mergify.com/v1",
39+
"token": "abc",
40+
"repository": "user/repo",
41+
"files": (str(REPORT_XML),),
42+
"head_sha": "3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
43+
"job_name": "JOB",
44+
"provider": "circleci",
45+
}
46+
47+
48+
def test_get_files_to_upload() -> None:
49+
files_to_upload = ci_issues_utils.get_files_to_upload(
50+
(str(REPORT_XML),),
51+
)
52+
assert files_to_upload == [
53+
(
54+
"files",
55+
(
56+
"report.xml",
57+
REPORT_XML.read_bytes(),
58+
"application/xml",
59+
),
60+
),
61+
]
62+
63+
64+
async def test_junit_upload(respx_mock: respx.MockRouter) -> None:
65+
respx_mock.post(
66+
"/v1/repos/user/repo/ci_issues_upload",
67+
).respond(
68+
200,
69+
json={"gigid": "1234azertyuiop"},
70+
)
71+
72+
await junit_upload_mod.upload(
73+
"https://api.mergify.com/v1",
74+
"token",
75+
"user/repo",
76+
"3af96aa24f1d32fcfbb7067793cacc6dc0c6b199",
77+
"ci-test-job",
78+
"circleci",
79+
(str(REPORT_XML),),
80+
)
81+
82+
83+
async def test_junit_upload_http_error(respx_mock: respx.MockRouter) -> None:
84+
respx_mock.post("/v1/repos/user/repo/ci_issues_upload").respond(
85+
422,
86+
json={"detail": "CI Issues is not enabled on this repository"},
87+
)
88+
89+
with pytest.raises(httpx.HTTPStatusError):
90+
await junit_upload_mod.upload(
91+
"https://api.mergify.com/v1",
92+
"token",
93+
"user/repo",
94+
"head-sha",
95+
"ci-job",
96+
"circleci",
97+
(str(REPORT_XML),),
98+
)

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)