Skip to content

Commit 9336584

Browse files
authored
feat(scopes): upload scopes to Mergify API (#806)
This will be used like this: ``` mergify ci scopes --write $RUNNER_TEMP/mergify-scopes.json mergify ci scopes-send --file $RUNNER_TEMP/mergify-scopes.json ```
1 parent 5044805 commit 9336584

File tree

10 files changed

+468
-40
lines changed

10 files changed

+468
-40
lines changed

mergify_cli/ci/cli.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,88 @@ async def junit_process( # noqa: PLR0913
199199
"config_path",
200200
required=True,
201201
type=click.Path(exists=True),
202-
default=".mergify-ci.yml",
202+
default=".mergify.yml",
203203
help="Path to YAML config file.",
204204
)
205+
@click.option(
206+
"--write",
207+
"-w",
208+
type=click.Path(),
209+
help="Write the detected scopes to a file (json).",
210+
)
205211
def scopes(
206212
config_path: str,
213+
write: str | None = None,
207214
) -> None:
208215
try:
209-
scopes_cli.detect(config_path=config_path)
216+
scopes = scopes_cli.detect(config_path=config_path)
210217
except scopes_cli.ScopesError as e:
211218
raise click.ClickException(str(e)) from e
219+
220+
if write is not None:
221+
scopes.save_to_file(write)
222+
223+
224+
@ci.command(help="Send scopes tied to a pull request to Mergify")
225+
@click.option(
226+
"--api-url",
227+
"-u",
228+
help="URL of the Mergify API",
229+
required=True,
230+
envvar="MERGIFY_API_URL",
231+
default="https://api.mergify.com",
232+
show_default=True,
233+
)
234+
@click.option(
235+
"--token",
236+
"-t",
237+
help="Mergify Key",
238+
envvar="MERGIFY_TOKEN",
239+
required=True,
240+
)
241+
@click.option(
242+
"--repository",
243+
"-r",
244+
help="Repository full name (owner/repo)",
245+
default=detector.get_github_repository,
246+
required=True,
247+
)
248+
@click.option(
249+
"--pull-request",
250+
"-p",
251+
help="pull_request number",
252+
type=int,
253+
default=detector.get_github_pull_request_number,
254+
required=True,
255+
)
256+
@click.option("--scope", "-s", multiple=True, help="Scope to upload")
257+
@click.option(
258+
"--file",
259+
"-f",
260+
help="File containing scopes to upload",
261+
type=click.Path(exists=True),
262+
)
263+
@utils.run_with_asyncio
264+
async def scopes_send( # noqa: PLR0913, PLR0917
265+
api_url: str,
266+
token: str,
267+
repository: str,
268+
pull_request: int,
269+
scope: tuple[str, ...],
270+
file: str | None,
271+
) -> None:
272+
scopes = list(scope)
273+
if file is not None:
274+
try:
275+
dump = scopes_cli.DetectedScope.load_from_file(file)
276+
except scopes_cli.ScopesError as e:
277+
raise click.ClickException(str(e)) from e
278+
scopes.extend(dump.scopes)
279+
280+
await scopes_cli.send_scopes(
281+
api_url,
282+
token,
283+
repository,
284+
pull_request,
285+
scopes,
286+
)

mergify_cli/ci/detector.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,22 @@ def _get_github_repository_from_env(env: str) -> str | None:
174174
return None
175175

176176

177+
def get_github_pull_request_number() -> int | None:
178+
match get_ci_provider():
179+
case "github_actions":
180+
try:
181+
event = utils.get_github_event()
182+
except utils.GitHubEventNotFoundError:
183+
return None
184+
pr = event.get("pull_request")
185+
if not isinstance(pr, dict):
186+
return None
187+
return typing.cast("int", pr["number"])
188+
189+
case _:
190+
return None
191+
192+
177193
def get_github_repository() -> str | None:
178194
match get_ci_provider():
179195
case "github_actions":
@@ -187,11 +203,4 @@ def get_github_repository() -> str | None:
187203

188204

189205
def is_flaky_test_detection_enabled() -> bool:
190-
return os.getenv("MERGIFY_TEST_FLAKY_DETECTION", default="").lower() in {
191-
"y",
192-
"yes",
193-
"t",
194-
"true",
195-
"on",
196-
"1",
197-
}
206+
return utils.get_boolean_env("MERGIFY_TEST_FLAKY_DETECTION")

mergify_cli/ci/scopes/base_detector.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from __future__ import annotations
22

33
import dataclasses
4-
import json
54
import os
6-
import pathlib
75
import typing
86

97
import click
108
import yaml
119

10+
from mergify_cli import utils
11+
1212

1313
class MergeQueuePullRequest(typing.TypedDict):
1414
number: int
@@ -72,16 +72,11 @@ class Base:
7272

7373

7474
def detect() -> Base:
75-
event_path = os.environ.get("GITHUB_EVENT_PATH")
76-
event: dict[str, typing.Any] | None = None
77-
if event_path and pathlib.Path(event_path).is_file():
78-
try:
79-
with pathlib.Path(event_path).open("r", encoding="utf-8") as f:
80-
event = json.load(f)
81-
except FileNotFoundError:
82-
event = None
83-
84-
if event is not None:
75+
try:
76+
event = utils.get_github_event()
77+
except utils.GitHubEventNotFoundError:
78+
pass
79+
else:
8580
# 0) merge-queue PR override
8681
mq_sha = _detect_base_from_merge_queue_payload(event)
8782
if mq_sha:

mergify_cli/ci/scopes/cli.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pydantic
99
import yaml
1010

11+
from mergify_cli import utils
1112
from mergify_cli.ci.scopes import base_detector
1213
from mergify_cli.ci.scopes import changed_files
1314

@@ -110,7 +111,28 @@ def maybe_write_github_outputs(
110111
fh.write(f"{key}={val}\n")
111112

112113

113-
def detect(config_path: str) -> None:
114+
class InvalidDetectedScopeError(ScopesError):
115+
pass
116+
117+
118+
class DetectedScope(pydantic.BaseModel):
119+
base_ref: str
120+
scopes: set[str]
121+
122+
def save_to_file(self, file: str) -> None:
123+
with pathlib.Path(file).open("w", encoding="utf-8") as f:
124+
f.write(self.model_dump_json())
125+
126+
@classmethod
127+
def load_from_file(cls, filename: str) -> DetectedScope:
128+
with pathlib.Path(filename).open("r", encoding="utf-8") as f:
129+
try:
130+
return cls.model_validate_json(f.read())
131+
except pydantic.ValidationError as e:
132+
raise InvalidDetectedScopeError(str(e))
133+
134+
135+
def detect(config_path: str) -> DetectedScope:
114136
cfg = Config.from_yaml(config_path)
115137
base = base_detector.detect()
116138
try:
@@ -137,3 +159,18 @@ def detect(config_path: str) -> None:
137159
click.echo("No scopes matched.")
138160

139161
maybe_write_github_outputs(all_scopes, scopes_hit)
162+
return DetectedScope(base_ref=base.ref, scopes=scopes_hit)
163+
164+
165+
async def send_scopes(
166+
api_url: str,
167+
token: str,
168+
repository: str,
169+
pull_request: int,
170+
scopes: list[str],
171+
) -> None:
172+
client = utils.get_mergify_http_client(api_url, token)
173+
await client.post(
174+
f"/v1/repos/{repository}/pulls/{pull_request}/scopes",
175+
json={"scopes": scopes},
176+
)

mergify_cli/tests/ci/scopes/test_cli.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import json
12
import pathlib
23
from unittest import mock
34

45
import pytest
6+
import respx
57
import yaml
68

79
from mergify_cli.ci.scopes import base_detector
@@ -230,7 +232,7 @@ def test_detect_with_matches(
230232

231233
# Capture output
232234
with mock.patch("click.echo") as mock_echo:
233-
cli.detect(str(config_file))
235+
result = cli.detect(str(config_file))
234236

235237
# Verify calls
236238
mock_detect_base.assert_called_once()
@@ -244,6 +246,9 @@ def test_detect_with_matches(
244246
assert "- backend" in calls
245247
assert "- merge-queue" in calls
246248

249+
assert result.base_ref == "main"
250+
assert result.scopes == {"backend", "merge-queue"}
251+
247252

248253
@mock.patch("mergify_cli.ci.scopes.cli.base_detector.detect")
249254
@mock.patch("mergify_cli.ci.scopes.changed_files.git_changed_files")
@@ -265,12 +270,14 @@ def test_detect_no_matches(
265270

266271
# Capture output
267272
with mock.patch("click.echo") as mock_echo:
268-
cli.detect(str(config_file))
273+
result = cli.detect(str(config_file))
269274

270275
# Verify output
271276
calls = [call.args[0] for call in mock_echo.call_args_list]
272277
assert "Base: main" in calls
273278
assert "No scopes matched." in calls
279+
assert result.scopes == set()
280+
assert result.base_ref == "main"
274281

275282

276283
@mock.patch("mergify_cli.ci.scopes.cli.base_detector.detect")
@@ -295,9 +302,58 @@ def test_detect_debug_output(
295302

296303
# Capture output
297304
with mock.patch("click.echo") as mock_echo:
298-
cli.detect(str(config_file))
305+
result = cli.detect(str(config_file))
299306

300307
# Verify debug output includes file details
301308
calls = [call.args[0] for call in mock_echo.call_args_list]
302309
assert any(" api/models.py" in call for call in calls)
303310
assert any(" api/views.py" in call for call in calls)
311+
312+
assert result.base_ref == "main"
313+
assert result.scopes == {"backend"}
314+
315+
316+
async def test_upload_scopes(respx_mock: respx.MockRouter) -> None:
317+
api_url = "https://api.mergify.test"
318+
token = "test-token" # noqa: S105
319+
repository = "owner/repo"
320+
pull_request = 123
321+
322+
# Mock the HTTP request
323+
route = respx_mock.post(
324+
f"{api_url}/v1/repos/{repository}/pulls/{pull_request}/scopes",
325+
).mock(
326+
return_value=respx.MockResponse(200, json={"status": "ok"}),
327+
)
328+
329+
# Call the upload function
330+
await cli.send_scopes(
331+
api_url,
332+
token,
333+
repository,
334+
pull_request,
335+
["backend", "frontend"],
336+
)
337+
338+
# Verify the request was made
339+
assert route.called
340+
assert route.call_count == 1
341+
342+
# Verify the request body
343+
request = route.calls[0].request
344+
assert request.headers["Authorization"] == "Bearer test-token"
345+
assert request.headers["Accept"] == "application/json"
346+
347+
# Verify the JSON payload
348+
payload = json.loads(request.content)
349+
assert payload == {"scopes": ["backend", "frontend"]}
350+
351+
352+
def test_dump(tmp_path: pathlib.Path) -> None:
353+
config_file = tmp_path / "scopes.json"
354+
saved = cli.DetectedScope(base_ref="main", scopes={"backend", "merge-queue"})
355+
saved.save_to_file(str(config_file))
356+
357+
loaded = cli.DetectedScope.load_from_file(str(config_file))
358+
assert loaded.scopes == saved.scopes
359+
assert loaded.base_ref == saved.base_ref

0 commit comments

Comments
 (0)