Skip to content

Commit 300a416

Browse files
committed
feat: change configuration file format
This change changes the configuration format to: ``` scopes: merge_queue_scope: merge-queue source: files: js: include: - "**/*.js" python: include: - "**/*.py" other: include: - "**/*" exclude: - "**/*.py" - "**/*.js" ``` Sources will later have value like: `bazel`, `nx`, `turporepo` We will have something like: ``` scopes: merge_queue_scope: merge-queue source: nx: command: ["npx", "nx"] ``` To get the scopes, this will run: `npx nx show projects --affected --base={base} --head={head}"` Change-Id: I2e4195e7fc8ce38325b618573456d26b05a57b56
1 parent 156470a commit 300a416

File tree

3 files changed

+171
-53
lines changed

3 files changed

+171
-53
lines changed

mergify_cli/ci/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ async def junit_process( # noqa: PLR0913
200200
"config_path",
201201
required=True,
202202
type=click.Path(exists=True),
203-
default=".mergify-ci.yml",
203+
default=".mergify.yml",
204204
help="Path to YAML config file.",
205205
)
206206
@click.option(

mergify_cli/ci/scopes/cli.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,33 @@ class ConfigInvalidError(exceptions.ScopesError):
2727
pass
2828

2929

30-
class ScopeConfig(pydantic.BaseModel):
31-
include: tuple[str, ...] = pydantic.Field(default_factory=tuple)
32-
exclude: tuple[str, ...] = pydantic.Field(default_factory=tuple)
33-
34-
3530
ScopeName = typing.Annotated[
3631
str,
3732
pydantic.StringConstraints(pattern=SCOPE_NAME_RE, min_length=1),
3833
]
3934

4035

41-
class Config(pydantic.BaseModel):
42-
scopes: dict[ScopeName, ScopeConfig]
36+
class FileFilters(pydantic.BaseModel):
37+
include: tuple[str, ...] = pydantic.Field(default_factory=tuple)
38+
exclude: tuple[str, ...] = pydantic.Field(default_factory=tuple)
39+
40+
41+
class SourceFiles(pydantic.BaseModel):
42+
files: dict[ScopeName, FileFilters] = pydantic.Field(default_factory=dict)
43+
44+
45+
class ScopesConfig(pydantic.BaseModel):
46+
model_config = pydantic.ConfigDict(extra="forbid")
47+
48+
source: SourceFiles | None = None
4349
merge_queue_scope: str | None = "merge-queue"
4450

51+
52+
class Config(pydantic.BaseModel):
53+
model_config = pydantic.ConfigDict(extra="ignore")
54+
55+
scopes: ScopesConfig
56+
4557
@classmethod
4658
def from_dict(cls, data: dict[str, typing.Any] | typing.Any) -> Config: # noqa: ANN401
4759
try:
@@ -61,15 +73,15 @@ def from_yaml(cls, path: str) -> Config:
6173

6274

6375
def match_scopes(
64-
config: Config,
6576
files: abc.Iterable[str],
77+
filters: dict[ScopeName, FileFilters],
6678
) -> tuple[set[str], dict[str, list[str]]]:
6779
scopes_hit: set[str] = set()
68-
per_scope: dict[str, list[str]] = {s: [] for s in config.scopes}
80+
per_scope: dict[str, list[str]] = {s: [] for s in filters}
6981
for f in files:
7082
# NOTE(sileht): we use pathlib.full_match to support **, as fnmatch does not
7183
p = pathlib.PurePosixPath(f)
72-
for scope, scope_config in config.scopes.items():
84+
for scope, scope_config in filters.items():
7385
if not scope_config.include and not scope_config.exclude:
7486
continue
7587

@@ -114,14 +126,23 @@ class DetectedScope:
114126
def detect(config_path: str) -> DetectedScope:
115127
cfg = Config.from_yaml(config_path)
116128
base = base_detector.detect()
129+
130+
if cfg.scopes.source is None:
131+
return DetectedScope(base.ref, set())
132+
133+
source = next(iter(cfg.scopes.source.__dict__))
134+
if source != "files":
135+
msg = "Ununsupported source type"
136+
raise RuntimeError(msg)
137+
117138
changed = changed_files.git_changed_files(base.ref)
118-
scopes_hit, per_scope = match_scopes(cfg, changed)
139+
scopes_hit, per_scope = match_scopes(changed, cfg.scopes.source.files)
140+
all_scopes = set(cfg.scopes.source.files.keys())
119141

120-
all_scopes = set(cfg.scopes.keys())
121-
if cfg.merge_queue_scope is not None:
122-
all_scopes.add(cfg.merge_queue_scope)
142+
if cfg.scopes.merge_queue_scope is not None:
143+
all_scopes.add(cfg.scopes.merge_queue_scope)
123144
if base.is_merge_queue:
124-
scopes_hit.add(cfg.merge_queue_scope)
145+
scopes_hit.add(cfg.scopes.merge_queue_scope)
125146

126147
click.echo(f"Base: {base.ref}")
127148
if scopes_hit:

mergify_cli/tests/ci/scopes/test_cli.py

Lines changed: 134 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,93 @@
1010
from mergify_cli.ci.scopes import cli
1111

1212

13+
def test_from_yaml_with_extras_ignored(tmp_path: pathlib.Path) -> None:
14+
config_file = tmp_path / "config.yml"
15+
config_file.write_text(
16+
yaml.dump(
17+
{
18+
"defaults": {},
19+
"queue_rules": [],
20+
"pull_request_rules": [],
21+
"partitions_rules": [],
22+
"scopes": {
23+
"source": {
24+
"files": {
25+
"backend": {"include": ["api/**/*.py", "backend/**/*.py"]},
26+
"frontend": {"include": ["ui/**/*.js", "ui/**/*.tsx"]},
27+
"docs": {"include": ["*.md", "docs/**/*"]},
28+
},
29+
},
30+
},
31+
},
32+
),
33+
)
34+
35+
config = cli.Config.from_yaml(str(config_file))
36+
assert config.model_dump() == {
37+
"scopes": {
38+
"source": {
39+
"files": {
40+
"backend": {
41+
"include": ("api/**/*.py", "backend/**/*.py"),
42+
"exclude": (),
43+
},
44+
"frontend": {
45+
"include": ("ui/**/*.js", "ui/**/*.tsx"),
46+
"exclude": (),
47+
},
48+
"docs": {"include": ("*.md", "docs/**/*"), "exclude": ()},
49+
},
50+
},
51+
"merge_queue_scope": "merge-queue",
52+
},
53+
}
54+
55+
1356
def test_from_yaml_valid(tmp_path: pathlib.Path) -> None:
1457
config_file = tmp_path / "config.yml"
1558
config_file.write_text(
1659
yaml.dump(
1760
{
1861
"scopes": {
19-
"backend": {"include": ["api/**/*.py", "backend/**/*.py"]},
20-
"frontend": {"include": ["ui/**/*.js", "ui/**/*.tsx"]},
21-
"docs": {"include": ["*.md", "docs/**/*"]},
62+
"source": {
63+
"files": {
64+
"backend": {"include": ["api/**/*.py", "backend/**/*.py"]},
65+
"frontend": {"include": ["ui/**/*.js", "ui/**/*.tsx"]},
66+
"docs": {"include": ["*.md", "docs/**/*"]},
67+
},
68+
},
2269
},
2370
},
2471
),
2572
)
2673

2774
config = cli.Config.from_yaml(str(config_file))
2875
assert config.model_dump() == {
29-
"merge_queue_scope": "merge-queue",
3076
"scopes": {
31-
"backend": {"include": ("api/**/*.py", "backend/**/*.py"), "exclude": ()},
32-
"frontend": {"include": ("ui/**/*.js", "ui/**/*.tsx"), "exclude": ()},
33-
"docs": {"include": ("*.md", "docs/**/*"), "exclude": ()},
77+
"merge_queue_scope": "merge-queue",
78+
"source": {
79+
"files": {
80+
"backend": {
81+
"include": ("api/**/*.py", "backend/**/*.py"),
82+
"exclude": (),
83+
},
84+
"frontend": {
85+
"include": ("ui/**/*.js", "ui/**/*.tsx"),
86+
"exclude": (),
87+
},
88+
"docs": {"include": ("*.md", "docs/**/*"), "exclude": ()},
89+
},
90+
},
3491
},
3592
}
3693

3794

3895
def test_from_yaml_invalid_config(tmp_path: pathlib.Path) -> None:
3996
config_file = tmp_path / "config.yml"
40-
config_file.write_text(yaml.dump({"scopes": {"Back#end-API": ["api/**/*.py"]}}))
97+
config_file.write_text(
98+
yaml.dump({"scopes": {"source": {"files": {"Back#end-API": ["api/**/*.py"]}}}}),
99+
)
41100

42101
# Bad name and missing dict
43102
with pytest.raises(cli.ConfigInvalidError, match="2 validation errors"):
@@ -48,15 +107,20 @@ def test_match_scopes_basic() -> None:
48107
config = cli.Config.from_dict(
49108
{
50109
"scopes": {
51-
"backend": {"include": ("api/**/*.py", "backend/**/*.py")},
52-
"frontend": {"include": ("ui/**/*.js", "ui/**/*.tsx")},
53-
"docs": {"include": ("*.md", "docs/**/*")},
110+
"source": {
111+
"files": {
112+
"backend": {"include": ("api/**/*.py", "backend/**/*.py")},
113+
"frontend": {"include": ("ui/**/*.js", "ui/**/*.tsx")},
114+
"docs": {"include": ("*.md", "docs/**/*")},
115+
},
116+
},
54117
},
55118
},
56119
)
57120
files = ["api/models.py", "ui/components/Button.tsx", "README.md", "other.txt"]
58121

59-
scopes_hit, per_scope = cli.match_scopes(config, files)
122+
assert config.scopes.source is not None
123+
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
60124

61125
assert scopes_hit == {"backend", "frontend", "docs"}
62126
assert per_scope == {
@@ -70,14 +134,19 @@ def test_match_scopes_no_matches() -> None:
70134
config = cli.Config.from_dict(
71135
{
72136
"scopes": {
73-
"backend": {"include": ("api/**/*.py",)},
74-
"frontend": {"include": ("ui/**/*.js",)},
137+
"source": {
138+
"files": {
139+
"backend": {"include": ("api/**/*.py",)},
140+
"frontend": {"include": ("ui/**/*.js",)},
141+
},
142+
},
75143
},
76144
},
77145
)
78146
files = ["other.txt", "unrelated.cpp"]
79147

80-
scopes_hit, per_scope = cli.match_scopes(config, files)
148+
assert config.scopes.source is not None
149+
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
81150

82151
assert scopes_hit == set()
83152
assert per_scope == {}
@@ -87,13 +156,18 @@ def test_match_scopes_multiple_include_single_scope() -> None:
87156
config = cli.Config.from_dict(
88157
{
89158
"scopes": {
90-
"backend": {"include": ("api/**/*.py", "backend/**/*.py")},
159+
"source": {
160+
"files": {
161+
"backend": {"include": ("api/**/*.py", "backend/**/*.py")},
162+
},
163+
},
91164
},
92165
},
93166
)
94167
files = ["api/models.py", "backend/services.py"]
95168

96-
scopes_hit, per_scope = cli.match_scopes(config, files)
169+
assert config.scopes.source is not None
170+
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
97171

98172
assert scopes_hit == {"backend"}
99173
assert per_scope == {
@@ -105,13 +179,17 @@ def test_match_scopes_with_negation_include() -> None:
105179
config = cli.Config.from_dict(
106180
{
107181
"scopes": {
108-
"backend": {
109-
"include": ("api/**/*.py",),
110-
"exclude": ("api/**/test_*.py",),
111-
},
112-
"frontend": {
113-
"include": ("ui/**/*.js",),
114-
"exclude": ("ui/**/*.spec.js",),
182+
"source": {
183+
"files": {
184+
"backend": {
185+
"include": ("api/**/*.py",),
186+
"exclude": ("api/**/test_*.py",),
187+
},
188+
"frontend": {
189+
"include": ("ui/**/*.js",),
190+
"exclude": ("ui/**/*.spec.js",),
191+
},
192+
},
115193
},
116194
},
117195
},
@@ -123,7 +201,8 @@ def test_match_scopes_with_negation_include() -> None:
123201
"ui/components.spec.js",
124202
]
125203

126-
scopes_hit, per_scope = cli.match_scopes(config, files)
204+
assert config.scopes.source is not None
205+
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
127206

128207
assert scopes_hit == {"backend", "frontend"}
129208
assert per_scope == {
@@ -136,16 +215,21 @@ def test_match_scopes_negation_only() -> None:
136215
config = cli.Config.from_dict(
137216
{
138217
"scopes": {
139-
"exclude_images": {
140-
"include": ("**/*",),
141-
"exclude": ("**/*.jpeg", "**/*.png"),
218+
"source": {
219+
"files": {
220+
"exclude_images": {
221+
"include": ("**/*",),
222+
"exclude": ("**/*.jpeg", "**/*.png"),
223+
},
224+
},
142225
},
143226
},
144227
},
145228
)
146229
files = ["image.jpeg", "document.txt", "photo.png", "readme.md"]
147230

148-
scopes_hit, per_scope = cli.match_scopes(config, files)
231+
assert config.scopes.source is not None
232+
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
149233

150234
assert scopes_hit == {"exclude_images"}
151235
assert per_scope == {
@@ -157,9 +241,13 @@ def test_match_scopes_mixed_with_complex_negation() -> None:
157241
config = cli.Config.from_dict(
158242
{
159243
"scopes": {
160-
"backend": {
161-
"include": ("**/*.py",),
162-
"exclude": ("**/test_*.py", "**/*_test.py"),
244+
"source": {
245+
"files": {
246+
"backend": {
247+
"include": ("**/*.py",),
248+
"exclude": ("**/test_*.py", "**/*_test.py"),
249+
},
250+
},
163251
},
164252
},
165253
},
@@ -172,7 +260,8 @@ def test_match_scopes_mixed_with_complex_negation() -> None:
172260
"main.py",
173261
]
174262

175-
scopes_hit, per_scope = cli.match_scopes(config, files)
263+
assert config.scopes.source is not None
264+
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
176265

177266
assert scopes_hit == {"backend"}
178267
assert per_scope == {
@@ -219,8 +308,12 @@ def test_detect_with_matches(
219308
# Setup config file
220309
config_data = {
221310
"scopes": {
222-
"backend": {"include": ["api/**/*.py"]},
223-
"frontend": {"include": ["api/**/*.js"]},
311+
"source": {
312+
"files": {
313+
"backend": {"include": ["api/**/*.py"]},
314+
"frontend": {"include": ["api/**/*.js"]},
315+
},
316+
},
224317
},
225318
}
226319
config_file = tmp_path / "mergify-ci.yml"
@@ -260,7 +353,9 @@ def test_detect_no_matches(
260353
tmp_path: pathlib.Path,
261354
) -> None:
262355
# Setup config file
263-
config_data = {"scopes": {"backend": {"include": ["api/**/*.py"]}}}
356+
config_data = {
357+
"scopes": {"source": {"files": {"backend": {"include": ["api/**/*.py"]}}}},
358+
}
264359
config_file = tmp_path / ".mergify-ci.yml"
265360
config_file.write_text(yaml.dump(config_data))
266361

@@ -292,7 +387,9 @@ def test_detect_debug_output(
292387
monkeypatch.setenv("ACTIONS_STEP_DEBUG", "true")
293388

294389
# Setup config file
295-
config_data = {"scopes": {"backend": {"include": ["api/**/*.py"]}}}
390+
config_data = {
391+
"scopes": {"source": {"files": {"backend": {"include": ["api/**/*.py"]}}}},
392+
}
296393
config_file = tmp_path / ".mergify-ci.yml"
297394
config_file.write_text(yaml.dump(config_data))
298395

0 commit comments

Comments
 (0)