Skip to content

Commit 0dd4748

Browse files
authored
Filter test collection based on files changed in upstream PRs (#15674)
Filter test collection based on files changed in upstream PRs (#14931)
1 parent f2f633d commit 0dd4748

File tree

5 files changed

+261
-0
lines changed

5 files changed

+261
-0
lines changed

conf/github_repos.yaml.template

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Upstream GitHub repos and test collection rules
2+
# This configuration maps file paths in upstream PRs to test components
3+
GITHUB_REPOS:
4+
TOKEN:
5+
# Optional: Base marker that all tests must have (e.g., 'upstream_pr_test')
6+
# If set, only tests with this marker will be considered for component filtering
7+
BASE_MARKER:
8+
REPOS:
9+
FOREMAN:
10+
ORG: theforeman
11+
REPO: foreman
12+
RULES:
13+
# Format: PATH patterns are regex patterns matched against full filenames
14+
# - PATH: app/(controllers|models)/hosts
15+
# COMPONENT: Hosts
16+
KATELLO:
17+
ORG: Katello # case-sensitive
18+
REPO: katello
19+
RULES:
20+
# - PATH: app/(controllers|modules)/katello/
21+
# COMPONENT: ContentManagement

conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
'pytest_plugins.jira_comments',
2626
'pytest_plugins.select_random_tests',
2727
'pytest_plugins.capsule_n-minus',
28+
'pytest_plugins.upstream_pr',
2829
# Fixtures
2930
'pytest_fixtures.core.broker',
3031
'pytest_fixtures.core.sat_cap_factory',

pytest_plugins/upstream_pr.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""Pytest plugin for filtering tests based on upstream GitHub PR file changes.
2+
3+
This plugin allows filtering robottelo test collection by analyzing the files modified
4+
in upstream GitHub pull requests (PRs) and mapping them to test components via configured rules.
5+
6+
Usage:
7+
pytest tests/foreman --upstream-pr foreman/12345,katello/6789
8+
9+
Configuration:
10+
Requires settings.github_repos configuration with repository mappings and file-to-component rules.
11+
"""
12+
13+
import re
14+
15+
from github import Auth, Github
16+
from github.GithubException import GithubException
17+
18+
from robottelo.config import settings
19+
from robottelo.logging import collection_logger as logger
20+
21+
22+
def match_file_to_rule(filename, rule):
23+
"""File matching using regex patterns only.
24+
25+
Args:
26+
filename: The filename to match against
27+
rule: Rule object with 'path' attribute containing the regex pattern
28+
29+
Returns:
30+
bool: True if filename matches the rule pattern
31+
"""
32+
path_pattern = rule.path
33+
34+
try:
35+
return bool(re.search(path_pattern, filename))
36+
except re.error as e:
37+
logger.error(f"Invalid regex pattern '{path_pattern}': {e}")
38+
return False
39+
40+
41+
def component_match(item, components, base_marker):
42+
"""Return True if the test (`item`) has a marker matching one of the `components`.
43+
44+
Requirements for a match:
45+
1. If `base_marker` is set, then `item` must have that marker.
46+
2. `item` must have a component marker matching one of the given `components`.
47+
48+
Args:
49+
item: pytest test item to check
50+
components: set of component names to match against
51+
base_marker: optional base marker that must be present
52+
53+
Returns:
54+
bool: True if item matches component and base marker requirements
55+
"""
56+
# Check for base marker match, if one was specified
57+
if base_marker and not any(base_marker == marker.name for marker in item.iter_markers()):
58+
return False
59+
60+
# Check for component marker matching any of the specified components
61+
return any(
62+
marker.name == 'component' and any(component in marker.args for component in components)
63+
for marker in item.iter_markers()
64+
)
65+
66+
67+
def pytest_addoption(parser):
68+
"""Add CLI option to specify upstream GitHub PRs.
69+
70+
Adds --upstream-pr option for filtering tests based on files modified by upstream PRs.
71+
The option accepts comma-separated list of repository/PR_number pairs.
72+
73+
Args:
74+
parser: pytest argument parser
75+
76+
Example:
77+
pytest tests/foreman --upstream-pr foreman/12345,katello/6789
78+
"""
79+
parser.addoption(
80+
"--upstream-pr",
81+
action="store",
82+
help=(
83+
"Comma-separated list of upstream PRs to filter test collection.\n"
84+
"Format: repo_key/pr_number (e.g., foreman/12345,katello/6789)\n"
85+
"Repository keys must be configured in settings.github_repos"
86+
),
87+
)
88+
89+
90+
def pytest_collection_modifyitems(session, items, config):
91+
"""Filter tests based on upstream PRs.
92+
93+
Process:
94+
1. Parse upstream PR specifications from command line
95+
2. Fetch modified files from each specified GitHub PR
96+
3. Map modified files to test components using configured rules
97+
4. Filter collected tests to include only those with matching components
98+
99+
Args:
100+
session: pytest session object
101+
items: list of collected test items
102+
config: pytest configuration object
103+
104+
Raises:
105+
ValueError: If PR format is invalid or repository key not found
106+
GithubException: If GitHub API access fails
107+
"""
108+
# Parse upstream PR option
109+
if not (upstream_pr_option := config.getoption('upstream_pr')):
110+
return
111+
112+
upstream_prs = [pr_info.strip() for pr_info in upstream_pr_option.split(',') if pr_info.strip()]
113+
if not upstream_prs:
114+
return
115+
116+
components = set()
117+
gh_settings = settings.github_repos
118+
119+
auth = None
120+
if token := gh_settings.get('token'):
121+
auth = Auth.Token(token)
122+
github_client = Github(auth=auth)
123+
124+
for pr_info in upstream_prs:
125+
try:
126+
# Parse and validate the PR repo and id
127+
if '/' not in pr_info:
128+
raise ValueError(
129+
f"Invalid PR format: '{pr_info}'. Expected format: repo_key/pr_number"
130+
)
131+
132+
repo_key, pr_id_str = pr_info.split('/', 1)
133+
try:
134+
pr_id = int(pr_id_str)
135+
except ValueError as e:
136+
raise ValueError(f"Invalid PR number: '{pr_id_str}'. Must be an integer") from e
137+
138+
# Validate repository configuration
139+
repo_config = gh_settings.repos.get(repo_key)
140+
if not repo_config:
141+
available_repos = ', '.join(gh_settings.repos.keys()) if gh_settings else 'none'
142+
raise ValueError(
143+
f"Repository key '{repo_key}' not found in settings.github_repos. "
144+
f"Available repositories: {available_repos}"
145+
)
146+
147+
# Fetch PR data from GitHub
148+
logger.info(f"Fetching files modified in upstream PR {repo_key}/{pr_id}")
149+
repo_full_name = f"{repo_config.org}/{repo_config.repo}"
150+
try:
151+
github_repo = github_client.get_repo(repo_full_name)
152+
pr = github_repo.get_pull(pr_id)
153+
pr_filenames = {file.filename for file in pr.get_files()}
154+
155+
# Add validation for PR state
156+
if pr.state != 'open':
157+
logger.warning(f"PR {repo_key}/{pr_id} is {pr.state}, results may be outdated")
158+
159+
except GithubException as e:
160+
if e.status == 404:
161+
logger.error(
162+
f"PR {repo_key}/{pr_id} not found. Check PR number and repository access."
163+
)
164+
elif e.status == 403:
165+
logger.error(
166+
"GitHub API rate limit or permission issue. Consider setting TOKEN to a GitHub token."
167+
)
168+
else:
169+
logger.error(f"GitHub API error for {repo_key}/{pr_id}: {e}")
170+
# Raise after logging error, do not continue with any other PRs
171+
raise
172+
173+
# Map modified files to components using configured rules
174+
unprocessed_filenames = pr_filenames.copy()
175+
logger.debug(f'Upstream PR {repo_key}/{pr_id} modified files: {sorted(pr_filenames)}')
176+
if not repo_config.rules:
177+
logger.warning(
178+
f"No rules configured for repository '{repo_key}', skipping component mapping"
179+
)
180+
continue
181+
182+
for rule in repo_config.rules:
183+
if not hasattr(rule, 'path') or not hasattr(rule, 'component'):
184+
logger.warning(
185+
f"Invalid rule in {repo_key}: missing 'path' or 'component' attribute"
186+
)
187+
continue
188+
189+
matched_filenames = {
190+
filename
191+
for filename in unprocessed_filenames
192+
if match_file_to_rule(filename, rule)
193+
}
194+
if matched_filenames:
195+
components.add(rule.component)
196+
unprocessed_filenames.difference_update(matched_filenames)
197+
logger.debug(
198+
f"Rule '{rule.path}' matched {len(matched_filenames)} files, "
199+
f"mapped to component '{rule.component}'"
200+
)
201+
if unprocessed_filenames:
202+
logger.debug(
203+
f"Unmatched files in {repo_key}/{pr_id}: {sorted(unprocessed_filenames)}"
204+
)
205+
206+
except (ValueError, GithubException) as e:
207+
logger.error(f"Error processing PR {pr_info}: {e}")
208+
raise
209+
210+
# Filter tests based on matched components
211+
if not components:
212+
logger.warning("No components matched from upstream PRs, all tests will be deselected")
213+
214+
selected = []
215+
deselected = []
216+
base_marker = settings.github_repos.base_marker
217+
logger.info(f"Filtering tests based on components: {sorted(components)}")
218+
219+
for item in items:
220+
if components and component_match(item, components, base_marker):
221+
logger.debug(f'Selected test {item.nodeid} (matches components: {components})')
222+
selected.append(item)
223+
else:
224+
logger.debug(f'Deselected test {item.nodeid} (no component match)')
225+
deselected.append(item)
226+
227+
logger.info(f"Test filtering complete: {len(selected)} selected, {len(deselected)} deselected")
228+
229+
# Apply the filtering
230+
if deselected:
231+
config.hook.pytest_deselected(items=deselected)
232+
items[:] = selected

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ jinja2==3.1.6
1212
manifester==0.2.14
1313
navmazing==1.3.0
1414
productmd==1.49
15+
PyGithub==2.3.0
1516
pyotp==2.9.0
1617
python-box==7.3.2
1718
pytest==9.0.1

robottelo/config/validators.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@
209209
must_exist=True,
210210
),
211211
],
212+
github_repos=[
213+
Validator(
214+
'github_repos.base_marker', default='', is_type_of=str, apply_default_on_none=True
215+
),
216+
Validator('github_repos.token', default='', is_type_of=str, apply_default_on_none=True),
217+
],
212218
http_proxy=[
213219
Validator(
214220
'http_proxy.un_auth_proxy_url',

0 commit comments

Comments
 (0)