Skip to content

Commit 0037f05

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 0037f05

File tree

9 files changed

+921
-4
lines changed

9 files changed

+921
-4
lines changed

mergify_cli/ci/__init__.py

Whitespace-only changes.

mergify_cli/ci/cli.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import asyncio
2+
import json
3+
import os
4+
import pathlib
5+
import re
6+
import typing
7+
from urllib import parse
8+
9+
import click
10+
11+
from mergify_cli import console
12+
from mergify_cli import utils
13+
from mergify_cli.ci import junit_upload as junit_upload_mod
14+
15+
16+
ci = click.Group(
17+
"ci",
18+
help="Mergify's CI related commands",
19+
)
20+
21+
22+
CIProviderT = typing.Literal["github_action", "circleci"]
23+
24+
25+
def get_ci_provider() -> CIProviderT | None:
26+
if os.getenv("GITHUB_ACTIONS") == "true":
27+
return "github_action"
28+
if os.getenv("CIRCLECI") == "true":
29+
return "circleci"
30+
return None
31+
32+
33+
def get_job_name() -> str | None:
34+
if get_ci_provider() == "github_action":
35+
return os.getenv("GITHUB_WORKFLOW")
36+
if get_ci_provider() == "circleci":
37+
return os.getenv("CIRCLE_JOB")
38+
39+
console.log("Error: failed to get the job's name from env", style="red")
40+
return None
41+
42+
43+
def get_github_actions_head_sha() -> str | None:
44+
if os.getenv("GITHUB_EVENT_NAME") == "pull_request":
45+
# NOTE(leo): we want the head sha of pull request
46+
event_raw_path = os.getenv("GITHUB_EVENT_PATH")
47+
if event_raw_path and ((event_path := pathlib.Path(event_raw_path)).is_file()):
48+
event = json.loads(event_path.read_bytes())
49+
return str(event["pull_request"]["head"]["sha"])
50+
return os.getenv("GITHUB_SHA")
51+
52+
53+
async def get_circle_ci_head_sha() -> str | None:
54+
if (pull_url := os.getenv("CIRCLE_PULL_REQUESTS")) and len(
55+
pull_url.split(","),
56+
) == 1:
57+
if not (token := os.getenv("GITHUB_TOKEN")):
58+
msg = (
59+
"Failed to detect the head sha of the pull request associated"
60+
" to this run. Please make sure to set a token in the env "
61+
"variable 'GITHUB_TOKEN' for this purpose."
62+
)
63+
raise RuntimeError(msg)
64+
65+
parsed_url = parse.urlparse(pull_url)
66+
if parsed_url.netloc == "github.com":
67+
github_server = "https://api.github.com"
68+
else:
69+
github_server = f"{parsed_url.scheme}://{parsed_url.netloc}/api/v3"
70+
71+
async with utils.get_github_http_client(github_server, token) as client:
72+
resp = await client.get(f"/repos{parsed_url.path}")
73+
74+
return str(resp.json()["head"]["sha"])
75+
76+
return os.getenv("CIRCLE_SHA1")
77+
78+
79+
async def get_head_sha() -> str | None:
80+
if get_ci_provider() == "github_action":
81+
return get_github_actions_head_sha()
82+
if get_ci_provider() == "circleci":
83+
return await get_circle_ci_head_sha()
84+
85+
console.log("Error: failed to get the head SHA from env", style="red")
86+
return None
87+
88+
89+
def get_github_repository() -> str | None:
90+
if get_ci_provider() == "github_action":
91+
return os.getenv("GITHUB_REPOSITORY")
92+
if get_ci_provider() == "circleci":
93+
repository_url = os.getenv("CIRCLE_REPOSITORY_URL")
94+
if repository_url and (
95+
match := re.match(
96+
r"(https?://[\w.-]+/)?(?P<full_name>[\w.-]+/[\w.-]+)/?$",
97+
repository_url,
98+
)
99+
):
100+
return match.group("full_name")
101+
102+
console.log("Error: failed to get the GitHub repository from env", style="red")
103+
return None
104+
105+
106+
@ci.command(help="Upload JUnit XML reports")
107+
@click.option(
108+
"--api-url",
109+
"-u",
110+
help="URL of the Mergify API",
111+
required=True,
112+
envvar="MERGIFY_API_URL",
113+
default="https://api.mergify.com",
114+
show_default=True,
115+
)
116+
@click.option(
117+
"--token",
118+
"-t",
119+
help="CI Issues Application Key",
120+
required=True,
121+
envvar="MERGIFY_TOKEN",
122+
)
123+
@click.option(
124+
"--repository",
125+
"-r",
126+
help="Repository full name (owner/repo)",
127+
required=True,
128+
default=get_github_repository,
129+
)
130+
@click.option(
131+
"--head-sha",
132+
"-s",
133+
help="Head SHA of the triggered job",
134+
required=True,
135+
default=lambda: asyncio.run(get_head_sha()),
136+
)
137+
@click.option(
138+
"--job-name",
139+
"-j",
140+
help="Job's name",
141+
required=True,
142+
default=get_job_name,
143+
)
144+
@click.option(
145+
"--provider",
146+
"-p",
147+
help="CI provider",
148+
default=get_ci_provider,
149+
)
150+
@click.argument(
151+
"files",
152+
nargs=-1,
153+
required=True,
154+
type=click.Path(exists=True, dir_okay=False),
155+
)
156+
@utils.run_with_asyncio
157+
async def junit_upload( # noqa: PLR0913, PLR0917
158+
api_url: str,
159+
token: str,
160+
repository: str,
161+
head_sha: str,
162+
job_name: str,
163+
provider: str | None,
164+
files: tuple[str, ...],
165+
) -> None:
166+
await junit_upload_mod.upload(
167+
api_url=api_url,
168+
token=token,
169+
repository=repository,
170+
head_sha=head_sha,
171+
job_name=job_name,
172+
provider=provider,
173+
files=files,
174+
)

mergify_cli/ci/junit_upload.py

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

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.

0 commit comments

Comments
 (0)