|
| 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 |
0 commit comments