|
| 1 | +#!/bin/env python3.11 |
| 2 | + |
| 3 | +import argparse |
| 4 | +import os |
| 5 | +import re |
| 6 | +import subprocess |
| 7 | +import sys |
| 8 | +from jira import JIRA |
| 9 | +from release_config import release_map, jira_field_map |
| 10 | + |
| 11 | +CVE_PATTERN = r'CVE-\d{4}-\d{4,7}' |
| 12 | + |
| 13 | +# Reverse lookup: field name -> custom field ID |
| 14 | +jira_field_reverse = {v: k for k, v in jira_field_map.items()} |
| 15 | + |
| 16 | +def restore_git_branch(original_branch, kernel_src_tree): |
| 17 | + """Restore the original git branch in case of errors.""" |
| 18 | + try: |
| 19 | + subprocess.run( |
| 20 | + ["git", "checkout", original_branch], |
| 21 | + cwd=kernel_src_tree, |
| 22 | + check=True, |
| 23 | + capture_output=True, |
| 24 | + text=True |
| 25 | + ) |
| 26 | + except subprocess.CalledProcessError as e: |
| 27 | + print(f"ERROR: Failed to restore original branch {original_branch}: {e.stderr}") |
| 28 | + sys.exit(1) |
| 29 | + |
| 30 | + |
| 31 | +def main(): |
| 32 | + parser = argparse.ArgumentParser( |
| 33 | + description="Validate PR Commits against JIRA VULN Tickets", |
| 34 | + formatter_class=argparse.ArgumentDefaultsHelpFormatter |
| 35 | + ) |
| 36 | + # This is a necessary requirement at the moment to allow multiple different JIRA creds from the same user |
| 37 | + parser.add_argument( |
| 38 | + "--jira-url", |
| 39 | + required=False, |
| 40 | + help="JIRA server URL.", |
| 41 | + ) |
| 42 | + parser.add_argument( |
| 43 | + "--jira-user", |
| 44 | + required=False, |
| 45 | + help="JIRA user email", |
| 46 | + ) |
| 47 | + parser.add_argument( |
| 48 | + "--jira-key", |
| 49 | + required=False, |
| 50 | + help="JIRA API Key (or set JIRA_API_TOKEN environment variable)", |
| 51 | + ) |
| 52 | + # End JIRA creds section |
| 53 | + parser.add_argument( |
| 54 | + "--kernel-src-tree", |
| 55 | + required=True, |
| 56 | + help="Path to kernel source tree repository", |
| 57 | + ) |
| 58 | + parser.add_argument( |
| 59 | + "--merge-target", |
| 60 | + required=True, |
| 61 | + help="Merge target branch", |
| 62 | + ) |
| 63 | + parser.add_argument( |
| 64 | + "--pr-branch", |
| 65 | + required=True, |
| 66 | + help="PR branch to checkout", |
| 67 | + ) |
| 68 | + |
| 69 | + args = parser.parse_args() |
| 70 | + |
| 71 | + # Verify kernel source tree path exists |
| 72 | + if not os.path.isdir(args.kernel_src_tree): |
| 73 | + print(f"ERROR: Kernel source tree path does not exist: {args.kernel_src_tree}") |
| 74 | + sys.exit(1) |
| 75 | + |
| 76 | + jira_url = args.jira_url or os.environ.get('JIRA_URL') |
| 77 | + jira_user = args.jira_user or os.environ.get('JIRA_API_USER') |
| 78 | + jira_key = args.jira_key or os.environ.get('JIRA_API_TOKEN') |
| 79 | + |
| 80 | + if not all([jira_url, jira_user, jira_key]): |
| 81 | + print("ERROR: JIRA credentials not provided. Set via --jira-* args or environment variables.") |
| 82 | + sys.exit(1) |
| 83 | + |
| 84 | + # Connect to JIRA |
| 85 | + try: |
| 86 | + jira = JIRA(server=jira_url, basic_auth=(jira_user, jira_key)) |
| 87 | + except Exception as e: |
| 88 | + print(f"ERROR: Failed to connect to JIRA: {e}") |
| 89 | + sys.exit(1) |
| 90 | + |
| 91 | + original_branch = None |
| 92 | + try: |
| 93 | + # Get current branch to restore later |
| 94 | + result = subprocess.run( |
| 95 | + ["git", "branch", "--show-current"], |
| 96 | + cwd=args.kernel_src_tree, |
| 97 | + check=True, capture_output=True, text=True |
| 98 | + ) |
| 99 | + original_branch = result.stdout.strip() |
| 100 | + except subprocess.CalledProcessError as e: |
| 101 | + print(f"ERROR: Failed to get current git branch: {e.stderr}") |
| 102 | + sys.exit(1) |
| 103 | + |
| 104 | + # Checkout the merge target branch first to ensure it exists |
| 105 | + try: |
| 106 | + subprocess.run( |
| 107 | + ["git", "checkout", args.merge_target], |
| 108 | + cwd=args.kernel_src_tree, |
| 109 | + check=True, |
| 110 | + capture_output=True, |
| 111 | + text=True |
| 112 | + ) |
| 113 | + except subprocess.CalledProcessError as e: |
| 114 | + print(f"ERROR: Failed to checkout merge target branch {args.merge_target}: {e.stderr}") |
| 115 | + # Restore original branch if needed |
| 116 | + restore_git_branch(original_branch, kernel_src_tree=args.kernel_src_tree) |
| 117 | + sys.exit(1) |
| 118 | + |
| 119 | + # Checkout the PR branch |
| 120 | + try: |
| 121 | + subprocess.run( |
| 122 | + ["git", "checkout", args.pr_branch], |
| 123 | + cwd=args.kernel_src_tree, |
| 124 | + check=True, |
| 125 | + capture_output=True, |
| 126 | + text=True |
| 127 | + ) |
| 128 | + except subprocess.CalledProcessError as e: |
| 129 | + print(f"ERROR: Failed to checkout PR branch {args.pr_branch}: {e.stderr}") |
| 130 | + restore_git_branch(original_branch, kernel_src_tree=args.kernel_src_tree) |
| 131 | + sys.exit(1) |
| 132 | + |
| 133 | + # Get commits from merge_target to PR branch |
| 134 | + try: |
| 135 | + result = subprocess.run( |
| 136 | + ["git", "log", "--format=%H", f"{args.merge_target}..{args.pr_branch}"], |
| 137 | + cwd=args.kernel_src_tree, |
| 138 | + check=True, |
| 139 | + capture_output=True, |
| 140 | + text=True |
| 141 | + ) |
| 142 | + except subprocess.CalledProcessError as e: |
| 143 | + print(f"ERROR: failed to get commits: {e.stderr}") |
| 144 | + sys.exit(1) |
| 145 | + |
| 146 | + commit_shas = result.stdout.strip().split('\n') if result.stdout.strip() else [] |
| 147 | + |
| 148 | + # Parse each commit and extract header |
| 149 | + commits_data = [] |
| 150 | + for sha in commit_shas: |
| 151 | + if not sha: |
| 152 | + continue |
| 153 | + |
| 154 | + # Get full commit message |
| 155 | + try: |
| 156 | + result = subprocess.run( |
| 157 | + ["git", "show", "--format=%B", "--no-patch", sha], |
| 158 | + cwd=args.kernel_src_tree, |
| 159 | + check=True, |
| 160 | + capture_output=True, |
| 161 | + text=True |
| 162 | + ) |
| 163 | + except subprocess.CalledProcessError as e: |
| 164 | + print(f"ERROR: Failed to get commits: {e.stderr}") |
| 165 | + sys.exit(1) |
| 166 | + |
| 167 | + commit_msg = result.stdout.strip() |
| 168 | + lines = commit_msg.split('\n') |
| 169 | + |
| 170 | + # Extract summary line (first line) |
| 171 | + summary = lines[0] if lines else "" |
| 172 | + |
| 173 | + # Extract header (start after first blank line, end at next blank line) |
| 174 | + header_lines = [] |
| 175 | + in_header = False |
| 176 | + vuln_tickets = [] |
| 177 | + commit_cves = [] |
| 178 | + |
| 179 | + for i, line in enumerate(lines): |
| 180 | + if i == 0: # Skip summary line |
| 181 | + continue |
| 182 | + if not in_header and line.strip() == "": # First blank line, start of header |
| 183 | + in_header = True |
| 184 | + continue |
| 185 | + if in_header and line.strip() == "": # Second blank line, end of header |
| 186 | + break |
| 187 | + if in_header: |
| 188 | + header_lines.append(line) |
| 189 | + stripped = line.strip() |
| 190 | + |
| 191 | + # Check for jira line with VULN |
| 192 | + if stripped.lower().startswith('jira ') and 'vuln-' in stripped.lower(): |
| 193 | + parts = stripped.split() |
| 194 | + for part in parts[1:]: # Skip 'jira' keyword |
| 195 | + if part.upper().startswith('VULN-'): |
| 196 | + vuln_tickets.append(part.upper()) |
| 197 | + |
| 198 | + # Check for CVE line |
| 199 | + # Assume format: "cve CVE-YYYY-NNNN" |
| 200 | + # There will only be one CVE per line, but possibly multiple CVEs listed |
| 201 | + if stripped.lower().startswith('cve '): |
| 202 | + parts = stripped.split() |
| 203 | + for part in parts[1:]: # Skip 'cve' keyword/tag |
| 204 | + # CVES always start with CVE- |
| 205 | + if part.upper().startswith('CVE-'): |
| 206 | + commit_cves.append(part.upper()) |
| 207 | + |
| 208 | + header = '\n'.join(header_lines) |
| 209 | + |
| 210 | + # Check VULN tickets against merge target |
| 211 | + lts_match = None |
| 212 | + issues_list = [] |
| 213 | + |
| 214 | + if vuln_tickets: |
| 215 | + for vuln_id in vuln_tickets: |
| 216 | + try: |
| 217 | + issue = jira.issue(vuln_id) |
| 218 | + |
| 219 | + # Get LTS product |
| 220 | + lts_product_field = issue.get_field(jira_field_reverse["LTS Product"]) |
| 221 | + if hasattr(lts_product_field, 'value'): |
| 222 | + lts_product = lts_product_field.value |
| 223 | + else: |
| 224 | + lts_product = str(lts_product_field) if lts_product_field else None |
| 225 | + |
| 226 | + # Get CVEs from JIRA ticket |
| 227 | + ticket_cve_field = issue.get_field(jira_field_reverse["CVE"]) |
| 228 | + ticket_cves = set() |
| 229 | + |
| 230 | + if ticket_cve_field: |
| 231 | + # Handle different field types (string, list, or object) |
| 232 | + if isinstance(ticket_cve_field, str): |
| 233 | + # Split by common delimiters and extract CVE IDs |
| 234 | + ticket_cves.update(re.findall(CVE_PATTERN, ticket_cve_field, re.IGNORECASE)) |
| 235 | + elif isinstance(ticket_cve_field, list): |
| 236 | + for item in ticket_cve_field: |
| 237 | + if isinstance(item, str): |
| 238 | + ticket_cves.update(re.findall(CVE_PATTERN, item, re.IGNORECASE)) |
| 239 | + else: |
| 240 | + # Try to convert to string |
| 241 | + ticket_cves.update(re.findall(CVE_PATTERN, str(ticket_cve_field), re.IGNORECASE)) |
| 242 | + |
| 243 | + # Normalize to uppercase |
| 244 | + ticket_cves = {cve.upper() for cve in ticket_cves} |
| 245 | + |
| 246 | + # Compare CVEs between commit and JIRA ticket |
| 247 | + if commit_cves and ticket_cves: |
| 248 | + commit_cves_set = set(commit_cves) |
| 249 | + if not commit_cves_set.issubset(ticket_cves): |
| 250 | + missing_in_ticket = commit_cves_set - ticket_cves |
| 251 | + issues_list.append({ |
| 252 | + 'type': 'error', |
| 253 | + 'vuln_id': vuln_id, |
| 254 | + 'message': f"CVE mismatch - Commit has {', '.join(sorted(missing_in_ticket))} but VULN ticket does not" |
| 255 | + }) |
| 256 | + if not ticket_cves.issubset(commit_cves_set): |
| 257 | + missing_in_commit = ticket_cves - commit_cves_set |
| 258 | + issues_list.append({ |
| 259 | + 'type': 'warning', |
| 260 | + 'vuln_id': vuln_id, |
| 261 | + 'message': f"VULN ticket has {', '.join(sorted(missing_in_commit))} but commit does not" |
| 262 | + }) |
| 263 | + elif commit_cves and not ticket_cves: |
| 264 | + issues_list.append({ |
| 265 | + 'type': 'warning', |
| 266 | + 'vuln_id': vuln_id, |
| 267 | + 'message': f"Commit has CVEs {', '.join(sorted(commit_cves))} but VULN ticket has no CVEs" |
| 268 | + }) |
| 269 | + elif ticket_cves and not commit_cves: |
| 270 | + issues_list.append({ |
| 271 | + 'type': 'warning', |
| 272 | + 'vuln_id': vuln_id, |
| 273 | + 'message': f"VULN ticket has CVEs {', '.join(sorted(ticket_cves))} but commit has no CVEs" |
| 274 | + }) |
| 275 | + |
| 276 | + # Check ticket status |
| 277 | + status = issue.fields.status.name |
| 278 | + if status != "In Progress": |
| 279 | + issues_list.append({ |
| 280 | + 'type': 'error', |
| 281 | + 'vuln_id': vuln_id, |
| 282 | + 'message': f"Status is '{status}', expected 'In Progress'" |
| 283 | + }) |
| 284 | + |
| 285 | + # Check if time is logged |
| 286 | + time_spent = issue.fields.timespent |
| 287 | + if not time_spent or time_spent == 0: |
| 288 | + issues_list.append({ |
| 289 | + 'type': 'warning', |
| 290 | + 'vuln_id': vuln_id, |
| 291 | + 'message': 'No time logged - please log time manually' |
| 292 | + }) |
| 293 | + |
| 294 | + # Check if LTS product matches merge target branch |
| 295 | + if lts_product and lts_product in release_map: |
| 296 | + expected_branch = release_map[lts_product]["src_git_branch"] |
| 297 | + if expected_branch == args.merge_target: |
| 298 | + lts_match = True |
| 299 | + else: |
| 300 | + lts_match = False |
| 301 | + issues_list.append({ |
| 302 | + 'type': 'error', |
| 303 | + 'vuln_id': vuln_id, |
| 304 | + 'message': f"LTS product '{lts_product}' expects branch '{expected_branch}', but merge target is '{args.merge_target}'" |
| 305 | + }) |
| 306 | + else: |
| 307 | + issues_list.append({ |
| 308 | + 'type': 'error', |
| 309 | + 'vuln_id': vuln_id, |
| 310 | + 'message': f"LTS product '{lts_product}' not found in release_map" |
| 311 | + }) |
| 312 | + |
| 313 | + except Exception as e: |
| 314 | + issues_list.append({ |
| 315 | + 'type': 'error', |
| 316 | + 'vuln_id': vuln_id, |
| 317 | + 'message': f"Failed to retrieve ticket: {e}" |
| 318 | + }) |
| 319 | + |
| 320 | + commits_data.append({ |
| 321 | + 'sha': sha, |
| 322 | + 'summary': summary, |
| 323 | + 'header': header, |
| 324 | + 'full_message': commit_msg, |
| 325 | + 'vuln_tickets': vuln_tickets, |
| 326 | + 'lts_match': lts_match, |
| 327 | + 'issues': issues_list |
| 328 | + }) |
| 329 | + |
| 330 | + # Print formatted results |
| 331 | + print("\n## JIRA PR Check Results\n") |
| 332 | + |
| 333 | + commits_with_issues = [c for c in commits_data if c['issues']] |
| 334 | + has_errors = False |
| 335 | + |
| 336 | + if commits_with_issues: |
| 337 | + print(f"**{len(commits_with_issues)} commit(s) with issues found:**\n") |
| 338 | + |
| 339 | + for commit in commits_with_issues: |
| 340 | + print(f"### Commit `{commit['sha'][:12]}`") |
| 341 | + print(f"**Summary:** {commit['summary']}\n") |
| 342 | + |
| 343 | + # Group issues by type |
| 344 | + errors = [i for i in commit['issues'] if i['type'] == 'error'] |
| 345 | + warnings = [i for i in commit['issues'] if i['type'] == 'warning'] |
| 346 | + |
| 347 | + if errors: |
| 348 | + has_errors = True |
| 349 | + print("**❌ Errors:**") |
| 350 | + for issue in errors: |
| 351 | + print(f"- **{issue['vuln_id']}**: {issue['message']}") |
| 352 | + print() |
| 353 | + |
| 354 | + if warnings: |
| 355 | + print("**⚠️ Warnings:**") |
| 356 | + for issue in warnings: |
| 357 | + print(f"- **{issue['vuln_id']}**: {issue['message']}") |
| 358 | + print() |
| 359 | + else: |
| 360 | + print("✅ **No issues found!**\n") |
| 361 | + |
| 362 | + print(f"\n---\n**Summary:** Checked {len(commits_data)} commit(s) total.") |
| 363 | + |
| 364 | + # Exit with error code if any errors were found |
| 365 | + if has_errors: |
| 366 | + restore_git_branch(original_branch, kernel_src_tree=args.kernel_src_tree) |
| 367 | + sys.exit(1) |
| 368 | + |
| 369 | + # Restore original branch |
| 370 | + restore_git_branch(original_branch, kernel_src_tree=args.kernel_src_tree) |
| 371 | + |
| 372 | + return jira, commits_data |
| 373 | + |
| 374 | + |
| 375 | + |
| 376 | +if __name__ == "__main__": |
| 377 | + main() |
0 commit comments