Skip to content

Commit e2234ef

Browse files
authored
feat(ci-insights): Expand glob patterns in junit-process FILES (#1294)
Accept raw, unexpanded glob patterns (e.g. 'reports/**/*.xml') on the `mergify ci junit-process` and `junit-upload` FILES argument, and expand them inside Python. Large sharded test suites that exceed the shell's ARG_MAX limit can now pass a single quoted pattern instead of relying on shell expansion. Literal paths keep the existing friendly "file does not exist" error; zero-match patterns surface an analogous diagnostic. Fixes: MRGFY-7036
1 parent ed2487c commit e2234ef

4 files changed

Lines changed: 237 additions & 31 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ metadata.
3737

3838
| Command | Description |
3939
|---------|-------------|
40-
| `mergify ci junit-process FILES...` | Upload JUnit XML reports and evaluate quarantine |
40+
| `mergify ci junit-process FILES...` | Upload JUnit XML reports (literals or quoted glob patterns) and evaluate quarantine |
4141
| `mergify ci git-refs` | Detect base/head git references for the current PR |
4242
| `mergify ci scopes` | Detect CI scopes impacted by changed files |
4343
| `mergify ci scopes-send` | Send scopes tied to a pull request to Mergify |
@@ -99,7 +99,11 @@ mergify stack sync # Sync with upstream
9999
mergify stack checkout my-feature # Checkout an existing stack from GitHub
100100

101101
# CI insights
102-
mergify ci junit-process results.xml # Upload test results + quarantine
102+
mergify ci junit-process results.xml # Upload test results + quarantine
103+
mergify ci junit-process 'reports/**/*.xml'
104+
# Quote globs so Mergify expands them
105+
# instead of the shell (recommended
106+
# for large test suites).
103107
mergify ci scopes # Detect impacted scopes
104108
mergify ci git-refs # Detect base/head refs
105109
mergify ci git-refs --format=shell # Emit MERGIFY_GIT_REFS_* vars for `eval`

mergify_cli/ci/cli.py

Lines changed: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import glob
34
import json
45
import os
56
import pathlib
@@ -19,34 +20,57 @@
1920
from mergify_cli.exit_codes import ExitCode
2021

2122

22-
class JUnitFile(click.Path):
23-
"""Custom Click parameter type for JUnit files with better error messages."""
24-
25-
def __init__(self) -> None:
26-
super().__init__(exists=True, dir_okay=False)
27-
28-
def convert( # type: ignore[override]
29-
self,
30-
value: str,
31-
param: click.Parameter | None,
32-
ctx: click.Context | None,
33-
) -> str:
34-
try:
35-
return super().convert(value, param, ctx)
36-
except click.BadParameter as e:
37-
if "does not exist" in str(e):
38-
# Provide a more helpful error message
39-
error_msg = (
40-
f"JUnit XML file '{value}' does not exist. \n\n"
41-
"This usually indicates that a previous CI step failed to generate the test results.\n"
42-
"Please check if your test execution step completed successfully and produced the expected output file."
43-
)
23+
def _expand_junit_patterns(
24+
ctx: click.Context,
25+
param: click.Parameter,
26+
value: tuple[str, ...],
27+
) -> tuple[str, ...]:
28+
# Accept raw glob patterns and expand them here so callers don't have to
29+
# rely on shell expansion — preferable for large test suites.
30+
results: dict[str, None] = {}
31+
for entry in value:
32+
literal = pathlib.Path(entry)
33+
# Existing literal paths take precedence so filenames that happen to
34+
# contain glob metacharacters (e.g. `report[1].xml`) keep working.
35+
if literal.is_file():
36+
results.setdefault(entry, None)
37+
continue
38+
39+
if glob.has_magic(entry):
40+
matches = [
41+
match
42+
for match in glob.iglob(entry, recursive=True) # noqa: PTH207
43+
if pathlib.Path(match).is_file()
44+
]
45+
if not matches:
4446
raise click.BadParameter(
45-
error_msg,
47+
f"Pattern '{entry}' did not match any file.\n\n"
48+
"This usually indicates that a previous CI step failed to generate the test results.\n"
49+
"Please check if your test execution step completed successfully and produced the expected output files.",
4650
ctx=ctx,
4751
param=param,
48-
) from e
49-
raise
52+
)
53+
54+
results.update(dict.fromkeys(matches))
55+
continue
56+
57+
if literal.is_dir():
58+
raise click.BadParameter(
59+
f"'{entry}' is a directory, not a JUnit XML file.\n\n"
60+
"Pass a file path or a quoted glob pattern (e.g. 'reports/**/*.xml') instead.",
61+
ctx=ctx,
62+
param=param,
63+
)
64+
65+
raise click.BadParameter(
66+
f"JUnit XML file '{entry}' does not exist.\n\n"
67+
"This usually indicates that a previous CI step failed to generate the test results.\n"
68+
"Please check if your test execution step completed successfully and produced the expected output file.",
69+
ctx=ctx,
70+
param=param,
71+
)
72+
73+
return tuple(results)
5074

5175

5276
def _process_tests_target_branch(
@@ -122,7 +146,7 @@ def ci(ctx: click.Context) -> None:
122146
"files",
123147
nargs=-1,
124148
required=True,
125-
type=JUnitFile(),
149+
callback=_expand_junit_patterns,
126150
)
127151
@utils.run_with_asyncio
128152
async def junit_upload(
@@ -149,8 +173,17 @@ async def junit_upload(
149173

150174

151175
@ci.command(
152-
help="""Upload JUnit XML reports and ignore failed tests with Mergify's CI Insights Quarantine""",
153-
short_help="""Upload JUnit XML reports and ignore failed tests with Mergify's CI Insights Quarantine""",
176+
help=(
177+
"Upload JUnit XML reports and ignore failed tests with Mergify's CI"
178+
" Insights Quarantine.\n\nFILES can be literal paths or quoted glob"
179+
" patterns (e.g. 'reports/**/*.xml'); quoting lets Mergify expand the"
180+
" pattern rather than the shell, which is recommended for large test"
181+
" suites."
182+
),
183+
short_help=(
184+
"Upload JUnit XML reports and ignore failed tests with Mergify's CI"
185+
" Insights Quarantine"
186+
),
154187
)
155188
@click.option(
156189
"--api-url",
@@ -204,7 +237,7 @@ async def junit_upload(
204237
"files",
205238
nargs=-1,
206239
required=True,
207-
type=JUnitFile(),
240+
callback=_expand_junit_patterns,
208241
)
209242
@utils.run_with_asyncio
210243
async def junit_process(

mergify_cli/tests/ci/test_cli.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,173 @@ def test_junit_file_not_found_error_message() -> None:
314314
)
315315

316316

317+
def test_expand_junit_patterns_literal_path() -> None:
318+
result = ci_cli._expand_junit_patterns(
319+
mock.Mock(),
320+
mock.Mock(),
321+
(str(REPORT_XML),),
322+
)
323+
assert result == (str(REPORT_XML),)
324+
325+
326+
def test_expand_junit_patterns_glob_matches_multiple(
327+
tmp_path: pathlib.Path,
328+
) -> None:
329+
first = tmp_path / "report_a.xml"
330+
second = tmp_path / "report_b.xml"
331+
first.write_text("")
332+
second.write_text("")
333+
334+
result = ci_cli._expand_junit_patterns(
335+
mock.Mock(),
336+
mock.Mock(),
337+
(str(tmp_path / "report_*.xml"),),
338+
)
339+
340+
assert set(result) == {str(first), str(second)}
341+
342+
343+
def test_expand_junit_patterns_recursive_glob(tmp_path: pathlib.Path) -> None:
344+
top = tmp_path / "top.xml"
345+
nested = tmp_path / "nested" / "deep" / "inner.xml"
346+
nested.parent.mkdir(parents=True)
347+
top.write_text("")
348+
nested.write_text("")
349+
350+
result = ci_cli._expand_junit_patterns(
351+
mock.Mock(),
352+
mock.Mock(),
353+
(str(tmp_path / "**" / "*.xml"),),
354+
)
355+
356+
assert set(result) == {str(top), str(nested)}
357+
358+
359+
def test_expand_junit_patterns_literal_takes_precedence_over_magic(
360+
tmp_path: pathlib.Path,
361+
) -> None:
362+
literal = tmp_path / "report[1].xml"
363+
literal.write_text("")
364+
365+
result = ci_cli._expand_junit_patterns(
366+
mock.Mock(),
367+
mock.Mock(),
368+
(str(literal),),
369+
)
370+
371+
assert result == (str(literal),)
372+
373+
374+
def test_expand_junit_patterns_directory_error(tmp_path: pathlib.Path) -> None:
375+
directory = tmp_path / "reports"
376+
directory.mkdir()
377+
378+
with pytest.raises(click.BadParameter) as exc_info:
379+
ci_cli._expand_junit_patterns(
380+
mock.Mock(),
381+
mock.Mock(),
382+
(str(directory),),
383+
)
384+
385+
assert "is a directory" in exc_info.value.message
386+
assert str(directory) in exc_info.value.message
387+
388+
389+
def test_expand_junit_patterns_skips_directories(tmp_path: pathlib.Path) -> None:
390+
(tmp_path / "subdir").mkdir()
391+
only_file = tmp_path / "only.xml"
392+
only_file.write_text("")
393+
394+
result = ci_cli._expand_junit_patterns(
395+
mock.Mock(),
396+
mock.Mock(),
397+
(str(tmp_path / "*"),),
398+
)
399+
400+
assert result == (str(only_file),)
401+
402+
403+
def test_expand_junit_patterns_zero_match_error(tmp_path: pathlib.Path) -> None:
404+
with pytest.raises(click.BadParameter) as exc_info:
405+
ci_cli._expand_junit_patterns(
406+
mock.Mock(),
407+
mock.Mock(),
408+
(str(tmp_path / "nonexistent-*.xml"),),
409+
)
410+
411+
assert "did not match any file" in exc_info.value.message
412+
assert "nonexistent-*.xml" in exc_info.value.message
413+
414+
415+
def test_expand_junit_patterns_dedupes_literal_and_glob(
416+
tmp_path: pathlib.Path,
417+
) -> None:
418+
report = tmp_path / "report.xml"
419+
report.write_text("")
420+
421+
result = ci_cli._expand_junit_patterns(
422+
mock.Mock(),
423+
mock.Mock(),
424+
(str(report), str(tmp_path / "*.xml")),
425+
)
426+
427+
assert result == (str(report),)
428+
429+
430+
def test_junit_process_glob_end_to_end(tmp_path: pathlib.Path) -> None:
431+
"""Confirm the callback is wired on junit-process and expansion reaches the runner."""
432+
first = tmp_path / "report_one.xml"
433+
second = tmp_path / "report_two.xml"
434+
first.write_bytes(REPORT_XML.read_bytes())
435+
second.write_bytes(REPORT_XML.read_bytes())
436+
437+
env = {
438+
"MERGIFY_API_URL": "https://api.mergify.com",
439+
"MERGIFY_TOKEN": "abc",
440+
"GITHUB_REPOSITORY": "user/repo",
441+
"GITHUB_BASE_REF": "main",
442+
}
443+
444+
runner = testing.CliRunner()
445+
mocked_process = mock.AsyncMock()
446+
with mock.patch.object(
447+
junit_processing_cli,
448+
"process_junit_files",
449+
mocked_process,
450+
):
451+
result = runner.invoke(
452+
ci_cli.junit_process,
453+
[str(tmp_path / "report_*.xml")],
454+
env=env,
455+
)
456+
457+
assert result.exit_code == 0, result.output
458+
assert mocked_process.await_count == 1
459+
await_args = mocked_process.await_args
460+
assert await_args is not None
461+
assert set(await_args.kwargs["files"]) == {str(first), str(second)}
462+
463+
464+
def test_junit_process_glob_no_match_error(tmp_path: pathlib.Path) -> None:
465+
env = {
466+
"MERGIFY_API_URL": "https://api.mergify.com",
467+
"MERGIFY_TOKEN": "abc",
468+
"GITHUB_REPOSITORY": "user/repo",
469+
"GITHUB_BASE_REF": "main",
470+
}
471+
472+
runner = testing.CliRunner()
473+
result = runner.invoke(
474+
ci_cli.junit_process,
475+
[str(tmp_path / "missing-*.xml")],
476+
env=env,
477+
)
478+
479+
assert result.exit_code == 2
480+
assert "did not match any file" in result.output
481+
assert "missing-*.xml" in result.output
482+
483+
317484
@pytest.mark.respx(base_url="https://api.github.com/")
318485
def test_scopes_send(
319486
respx_mock: respx.MockRouter,

skills/mergify-ci/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ mergify ci junit-process \
3232
path/to/junit-results.xml
3333
```
3434

35+
`FILES` can be individual paths or quoted glob patterns (e.g. `'reports/**/*.xml'`). Always quote the pattern so Mergify expands it rather than the shell — this is the recommended approach for large, sharded test suites.
36+
3537
**Key options:**
3638
- `--token` / `-t` (env: `MERGIFY_TOKEN`) -- CI Insights application key
3739
- `--repository` / `-r` -- Repository full name (auto-detected in GitHub Actions)

0 commit comments

Comments
 (0)