Skip to content

Commit 0136bea

Browse files
authored
feat: query Mergify's Quarantine API to check for ignorable failed tests (#142)
Fixes MRGFY-5519
1 parent 25fae32 commit 0136bea

File tree

8 files changed

+250
-10
lines changed

8 files changed

+250
-10
lines changed

poetry.lock

Lines changed: 26 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ poethepoet = ">=0.30"
2222
codespell = "^2.3.0"
2323
yamllint = "^1.35.1"
2424
anys = "^0.3.1"
25+
responses = "^0.25.7"
2526

2627
[build-system]
2728
requires = ["poetry-core", "poetry-dynamic-versioning"]
@@ -48,6 +49,10 @@ module = [
4849
]
4950
ignore_missing_imports = true
5051

52+
[[tool.mypy.overrides]]
53+
module = "requests"
54+
ignore_missing_imports = true
55+
5156
[tool.poetry-dynamic-versioning]
5257
enable = true
5358
vcs = "git"

pytest_mergify/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ def pytest_terminal_summary(
5454
)
5555
return
5656

57+
# CI Insights Quarantine warning logs
58+
if not self.mergify_ci.branch_name:
59+
terminalreporter.write_line(
60+
"No valid branch name found, unable to setup CI Insights Quarantine",
61+
yellow=True,
62+
)
63+
64+
if (
65+
self.mergify_ci.quarantined_tests is not None
66+
and self.mergify_ci.quarantined_tests.init_error_msg
67+
):
68+
terminalreporter.write_line(
69+
self.mergify_ci.quarantined_tests.init_error_msg, yellow=True
70+
)
71+
72+
# CI Insights Traces upload logs
5773
if self.mergify_ci.tracer_provider is None:
5874
terminalreporter.write_line(
5975
"Mergify Tracer didn't start for unexpected reason (please contact Mergify support); test results will not be uploaded",
@@ -171,6 +187,8 @@ def pytest_runtest_protocol(
171187
else:
172188
skip_attributes = {}
173189

190+
self.mergify_ci.mark_test_as_quarantined_if_needed(item)
191+
174192
context = opentelemetry.trace.set_span_in_context(self.session_span)
175193
with self.tracer.start_as_current_span(
176194
item.nodeid,

pytest_mergify/ci_insights.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import dataclasses
2-
import os
2+
import _pytest.nodes
33
import random
4+
import os
45
import typing
56

6-
import requests # type: ignore[import-untyped]
7+
import requests
78
import opentelemetry.sdk.resources
89
from opentelemetry.sdk.trace import export
910
from opentelemetry.sdk.trace import TracerProvider, SpanProcessor, ReadableSpan
@@ -13,8 +14,10 @@
1314
)
1415

1516
from pytest_mergify import utils
17+
import pytest_mergify.quarantine
1618

1719
import pytest_mergify.resources.ci as resources_ci
20+
from opentelemetry.semconv._incubating.attributes import vcs_attributes
1821
import pytest_mergify.resources.github_actions as resources_gha
1922
import pytest_mergify.resources.pytest as resources_pytest
2023
import pytest_mergify.resources.mergify as resources_mergify
@@ -60,6 +63,10 @@ class MergifyCIInsights:
6063
"MERGIFY_API_URL", "https://api.mergify.com"
6164
)
6265
)
66+
branch_name: typing.Optional[str] = dataclasses.field(
67+
init=False,
68+
default=None,
69+
)
6370
exporter: typing.Optional[export.SpanExporter] = dataclasses.field(
6471
init=False, default=None
6572
)
@@ -70,7 +77,14 @@ class MergifyCIInsights:
7077
dataclasses.field(init=False, default=None)
7178
)
7279
test_run_id: str = dataclasses.field(
73-
default_factory=lambda: random.getrandbits(64).to_bytes(8, "big").hex()
80+
init=False,
81+
default_factory=lambda: random.getrandbits(64).to_bytes(8, "big").hex(),
82+
)
83+
quarantined_tests: typing.Optional[pytest_mergify.quarantine.Quarantine] = (
84+
dataclasses.field(
85+
init=False,
86+
default=None,
87+
)
7488
)
7589

7690
def __post_init__(self) -> None:
@@ -122,3 +136,24 @@ def __post_init__(self) -> None:
122136

123137
self.tracer_provider.add_span_processor(span_processor)
124138
self.tracer = self.tracer_provider.get_tracer("pytest-mergify")
139+
140+
# Retrieve the branch name based on the detected resources's attributes
141+
branch_name = resource.attributes.get(
142+
vcs_attributes.VCS_REF_BASE_NAME,
143+
resource.attributes.get(vcs_attributes.VCS_REF_HEAD_NAME),
144+
)
145+
if branch_name is not None:
146+
# `str` cast just for `mypy`
147+
self.branch_name = str(branch_name)
148+
149+
if self.token and self.repo_name and self.branch_name:
150+
self.quarantined_tests = pytest_mergify.quarantine.Quarantine(
151+
self.api_url,
152+
self.token,
153+
self.repo_name,
154+
self.branch_name,
155+
)
156+
157+
def mark_test_as_quarantined_if_needed(self, item: _pytest.nodes.Item) -> None:
158+
if self.quarantined_tests is not None and item in self.quarantined_tests:
159+
self.quarantined_tests.mark_test_as_quarantined(item)

pytest_mergify/quarantine.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import dataclasses
2+
import pytest
3+
import _pytest.nodes
4+
import requests
5+
import typing
6+
7+
8+
@dataclasses.dataclass
9+
class Quarantine:
10+
api_url: str
11+
token: str
12+
repo_name: str
13+
branch_name: str
14+
quarantined_tests: typing.List[str] = dataclasses.field(
15+
init=False, default_factory=list
16+
)
17+
init_error_msg: typing.Optional[str] = dataclasses.field(init=False, default=None)
18+
19+
def __post_init__(self) -> None:
20+
try:
21+
owner, repository = self.repo_name.split("/")
22+
except ValueError:
23+
self.init_error_msg = f"Repository name '{self.repo_name}' has an unexpected format (expected 'owner/repository'), skipping CI Insights Quarantine setup"
24+
return
25+
26+
url = f"{self.api_url}/v1/ci/{owner}/repositories/{repository}/quarantines"
27+
28+
try:
29+
quarantine_resp: requests.Response = requests.get(
30+
url,
31+
headers={"Authorization": f"Bearer {self.token}"},
32+
params={"branch": self.branch_name},
33+
timeout=10,
34+
)
35+
except requests.ConnectionError as exc:
36+
self.init_error_msg = f"Failed to connect to Mergify's API, tests won't be quarantined. Error: {str(exc)}"
37+
return
38+
39+
if quarantine_resp.status_code == 402:
40+
# No CI Insights Quarantine subscription, skip it.
41+
return
42+
43+
try:
44+
quarantine_resp.raise_for_status()
45+
except requests.HTTPError as exc:
46+
self.init_error_msg = f"Error when querying Mergify's API, tests won't be quarantined. Error: {str(exc)}"
47+
return
48+
49+
self.quarantined_tests = quarantine_resp.json()["quarantined_tests"]
50+
51+
def __contains__(self, item: _pytest.nodes.Item) -> bool:
52+
return item.nodeid in self.quarantined_tests
53+
54+
@staticmethod
55+
def mark_test_as_quarantined(test_item: _pytest.nodes.Item) -> None:
56+
test_item.add_marker(
57+
pytest.mark.xfail(
58+
reason="Test is quarantined from Mergify CI Insights",
59+
raises=None,
60+
run=True,
61+
strict=False,
62+
),
63+
append=True,
64+
)

tests/conftest.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import typing
2+
import re
3+
import os
4+
import responses
25
import http.server
36
import socketserver
47
import threading
58

69
import pytest
10+
from pytest_mergify import utils
711
import _pytest.pytester
812
from opentelemetry.sdk import trace
913

1014
import pytest_mergify
15+
import pytest_mergify.quarantine
1116

1217
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
1318
InMemorySpanExporter,
@@ -35,6 +40,7 @@ def __call__(
3540
self,
3641
code: str = ...,
3742
setenv: typing.Optional[typing.Dict[str, typing.Optional[str]]] = ...,
43+
quarantined_tests: typing.Optional[typing.List[str]] = None,
3844
) -> PytesterWithSpanReturnT: ...
3945

4046

@@ -43,20 +49,41 @@ def __call__(
4349

4450
@pytest.fixture
4551
def pytester_with_spans(
46-
pytester: _pytest.pytester.Pytester, monkeypatch: pytest.MonkeyPatch
52+
pytester: _pytest.pytester.Pytester,
53+
monkeypatch: pytest.MonkeyPatch,
4754
) -> PytesterWithSpanT:
55+
@responses.activate
4856
def _run(
4957
code: str = _DEFAULT_PYTESTER_CODE,
5058
setenv: typing.Optional[typing.Dict[str, typing.Optional[str]]] = None,
59+
quarantined_tests: typing.Optional[typing.List[str]] = None,
5160
) -> PytesterWithSpanReturnT:
5261
monkeypatch.delenv("PYTEST_MERGIFY_DEBUG", raising=False)
5362
monkeypatch.setenv("CI", "true")
5463
monkeypatch.setenv("_PYTEST_MERGIFY_TEST", "true")
64+
5565
for k, v in (setenv or {}).items():
5666
if v is None:
5767
monkeypatch.delenv(k, raising=False)
5868
else:
5969
monkeypatch.setenv(k, v)
70+
71+
api_url = os.getenv("MERGIFY_API_URL")
72+
responses.add(
73+
responses.GET,
74+
re.compile(rf"{api_url}/v1/ci/.*/repositories/.*/quarantines\?branch=.*"),
75+
status=200,
76+
json={"quarantined_tests": quarantined_tests or []},
77+
)
78+
79+
full_repository = utils.get_repository_name()
80+
passthrough = responses.Response(
81+
responses.POST,
82+
f"{api_url}/v1/repos/{full_repository}/ci/traces",
83+
passthrough=True,
84+
)
85+
responses.add(passthrough)
86+
6087
plugin = pytest_mergify.PytestMergify()
6188
pytester.makepyfile(code)
6289
result = pytester.runpytest_inprocess(plugins=[plugin])

tests/test_plugin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def test_errors_logs(
147147
monkeypatch.setenv("CI", "1")
148148
monkeypatch.setenv("GITHUB_ACTIONS", "true")
149149
monkeypatch.setenv("GITHUB_REPOSITORY", "foo/bar")
150+
monkeypatch.setenv("GITHUB_REF", "main")
150151
pytester.makepyfile(
151152
"""
152153
def test_pass():
@@ -180,12 +181,14 @@ def test_errors_logs_403(
180181
monkeypatch.setenv("GITHUB_ACTIONS", "true")
181182
monkeypatch.setenv("GITHUB_REPOSITORY", "foo/bar")
182183
monkeypatch.setenv("MERGIFY_API_URL", http_server)
184+
monkeypatch.setenv("GITHUB_BASE_REF", "main")
183185
pytester.makepyfile(
184186
"""
185187
def test_pass():
186188
pass
187189
"""
188190
)
191+
189192
result = pytester.runpytest_subprocess()
190193
result.assert_outcomes(passed=1)
191194
assert any(

0 commit comments

Comments
 (0)