Skip to content

Commit 7bb0004

Browse files
committed
feat(scopes): upload scopes to Mergify API
Change-Id: I913ec1dfc5a9b9fd50253e67f453e8c3328eb2e2
1 parent b0b6f99 commit 7bb0004

File tree

9 files changed

+660
-28
lines changed

9 files changed

+660
-28
lines changed

mergify_cli/ci/cli.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,8 @@ async def junit_process( # noqa: PLR0913
191191

192192

193193
@ci.command(
194-
help="""Give the list scope impacted by changed files""",
195-
short_help="""Give the list scope impacted by changed files""",
194+
help="""Give the list scope impacted by changed files and send them to Mergify""",
195+
short_help="""Give the list scope impacted by changed files and send them to Mergify""",
196196
)
197197
@click.option(
198198
"--config",
@@ -202,10 +202,57 @@ async def junit_process( # noqa: PLR0913
202202
default=".mergify-ci.yml",
203203
help="Path to YAML config file.",
204204
)
205-
def scopes(
205+
@click.option(
206+
"--api-url",
207+
"-u",
208+
help="URL of the Mergify API",
209+
required=True,
210+
envvar="MERGIFY_API_URL",
211+
default="https://api.mergify.com",
212+
show_default=True,
213+
)
214+
@click.option(
215+
"--token",
216+
"-t",
217+
help="Mergify Key",
218+
envvar="MERGIFY_TOKEN",
219+
)
220+
@click.option(
221+
"--repository",
222+
"-r",
223+
help="Repository full name (owner/repo)",
224+
default=detector.get_github_repository,
225+
)
226+
@click.option(
227+
"--pull-request",
228+
"-p",
229+
help="pull_request number",
230+
type=int,
231+
default=detector.get_github_pull_request_number,
232+
)
233+
@utils.run_with_asyncio
234+
async def scopes(
206235
config_path: str,
236+
api_url: str,
237+
token: str | None = None,
238+
repository: str | None = None,
239+
pull_request: int | None = None,
207240
) -> None:
208241
try:
209-
scopes_cli.detect(config_path=config_path)
242+
scopes = scopes_cli.detect(config_path=config_path)
210243
except scopes_cli.ScopesError as e:
211244
raise click.ClickException(str(e)) from e
245+
246+
running_in_ci = utils.get_boolean_env("CI")
247+
if running_in_ci:
248+
if not token:
249+
click.echo("MERGIFY_TOKEN not set, skipping scopes upload")
250+
if not repository:
251+
click.echo("GitHub repository not found, skipping scopes upload")
252+
if not pull_request:
253+
click.echo("GitHub pull request number not found, skipping scopes upload")
254+
255+
if not token or not repository or not pull_request:
256+
return
257+
258+
await scopes_cli.upload_scopes(api_url, token, repository, pull_request, scopes)

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: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
import os
45
import pathlib
56
import typing
@@ -8,6 +9,7 @@
89
import pydantic
910
import yaml
1011

12+
from mergify_cli import utils
1113
from mergify_cli.ci.scopes import base_detector
1214
from mergify_cli.ci.scopes import changed_files
1315

@@ -110,7 +112,13 @@ def maybe_write_github_outputs(
110112
fh.write(f"{key}={val}\n")
111113

112114

113-
def detect(config_path: str) -> None:
115+
@dataclasses.dataclass
116+
class DetectedScope:
117+
ref: str
118+
scopes: set[str]
119+
120+
121+
def detect(config_path: str) -> DetectedScope:
114122
cfg = Config.from_yaml(config_path)
115123
base = base_detector.detect()
116124
try:
@@ -137,3 +145,18 @@ def detect(config_path: str) -> None:
137145
click.echo("No scopes matched.")
138146

139147
maybe_write_github_outputs(all_scopes, scopes_hit)
148+
return DetectedScope(base.ref, scopes_hit)
149+
150+
151+
async def upload_scopes(
152+
api_url: str,
153+
token: str,
154+
repository: str,
155+
pull_request: int,
156+
detected: DetectedScope,
157+
) -> None:
158+
client = utils.get_mergify_http_client(api_url, token)
159+
await client.post(
160+
f"/v1/repos/{repository}/pulls/{pull_request}/scopes",
161+
json={"scopes": sorted(detected.scopes)},
162+
)

mergify_cli/tests/ci/scopes/test_cli.py

Lines changed: 71 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.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.ref == "main"
274281

275282

276283
@mock.patch("mergify_cli.ci.scopes.cli.base_detector.detect")
@@ -295,9 +302,70 @@ 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.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+
detected = cli.DetectedScope(ref="main", scopes={"backend", "frontend"})
323+
324+
# Mock the HTTP request
325+
route = respx_mock.post(
326+
f"{api_url}/v1/repos/{repository}/pulls/{pull_request}/scopes",
327+
).mock(
328+
return_value=respx.MockResponse(200, json={"status": "ok"}),
329+
)
330+
331+
# Call the upload function
332+
await cli.upload_scopes(api_url, token, repository, pull_request, detected)
333+
334+
# Verify the request was made
335+
assert route.called
336+
assert route.call_count == 1
337+
338+
# Verify the request body
339+
request = route.calls[0].request
340+
assert request.headers["Authorization"] == "token test-token"
341+
assert request.headers["Accept"] == "application/json"
342+
343+
# Verify the JSON payload
344+
payload = json.loads(request.content)
345+
assert payload == {"scopes": ["backend", "frontend"]}
346+
347+
348+
async def test_upload_scopes_with_merge_queue(respx_mock: respx.MockRouter) -> None:
349+
api_url = "https://api.mergify.com"
350+
token = "secret-token" # noqa: S105
351+
repository = "test-org/test-repo"
352+
pull_request = 456
353+
354+
detected = cli.DetectedScope(ref="abc123", scopes={"merge-queue"})
355+
356+
# Mock the HTTP request
357+
route = respx_mock.post(
358+
f"{api_url}/v1/repos/{repository}/pulls/{pull_request}/scopes",
359+
).mock(
360+
return_value=respx.MockResponse(201),
361+
)
362+
363+
# Call the upload function
364+
await cli.upload_scopes(api_url, token, repository, pull_request, detected)
365+
366+
# Verify the request was made with correct data
367+
assert route.called
368+
request = route.calls[0].request
369+
370+
payload = json.loads(request.content)
371+
assert "merge-queue" in payload["scopes"]

0 commit comments

Comments
 (0)