#!/usr/bin/env python3 """Read GitHub PR file metadata and summarize possible Flyway version collisions.""" import html import json import os from pathlib import Path import re import urllib.error import urllib.request MIGRATION = re.compile( r"server/skillhub-app/src/main/resources/db/migration/V([0-9]+)__[^/]+\.sql\Z" ) class ScanError(Exception): pass def version(path): match = MIGRATION.fullmatch(path) return int(match[1]) if match else None def introduced_version(file): number = version(file["filename"]) if file["status"] == "added": return number if file["status"] == "renamed" and version(file.get("previous_filename", "")) != number: return number return None class GitHub: def __init__(self, repository, token): if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): raise ScanError("Invalid repository name") self.base = f"https://api.github.com/repos/{repository}" self.token = token def get(self, path): request = urllib.request.Request(self.base + path, headers={ "Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }) try: with urllib.request.urlopen(request, timeout=30) as response: return json.load(response) except urllib.error.HTTPError as error: raise ScanError(f"GitHub API HTTP {error.code}; scan incomplete") from error except (urllib.error.URLError, TimeoutError, ValueError, OSError) as error: # Do not echo server payloads, tokens, request headers or arbitrary URLs. raise ScanError(f"GitHub API {type(error).__name__}; scan incomplete") from error def pages(self, path, limit=None): result = [] for page in range(1, 1001): separator = "&" if "?" in path else "?" batch = self.get(f"{path}{separator}per_page=100&page={page}") if not isinstance(batch, list): raise ScanError("Unexpected GitHub pagination response") result.extend(batch) if len(batch) < 100: return result if limit is not None and len(result) >= limit: raise ScanError("PR files reached GitHub's 3000-file limit") raise ScanError("Pagination safety limit reached") def scan(api): versions = {} errors = [] prs = api.pages("/pulls?state=open&sort=created&direction=asc") for pr in prs: number = pr["number"] try: files = api.pages(f"/pulls/{number}/files", limit=3000) current = api.get(f"/pulls/{number}") if (current["head"]["sha"] != pr["head"]["sha"] or current["base"]["sha"] != pr["base"]["sha"] or current["state"] != "open"): raise ScanError("PR changed during scan; rerun required") if len(files) != current["changed_files"]: raise ScanError("PR file list incomplete; rerun required") for file in files: value = introduced_version(file) if value is not None: versions.setdefault(value, {}).setdefault(number, []).append(file["filename"]) except ScanError as error: errors.append(f"PR #{number}: {error}") return {v: entries for v, entries in sorted(versions.items()) if len(entries) > 1}, errors, len(prs) def safe(value): # HTML text in a pre block avoids Markdown/link/table injection from filenames. return html.escape(str(value)).replace("\r", " ").replace("\n", " ") def summary(collisions, errors, count): lines = ["## Flyway migration preflight", "", "Advisory only: no SQL files are fetched or executed; no PR is changed.", "", f"Open PRs discovered: {count}.", ""] if errors: lines += ["**Warning: scan incomplete. Absence of a reported collision is not clearance.**", "", "
",
*(safe(error) for error in errors), "", ""]
if collisions:
lines += ["**Warning: migration versions introduced by multiple open PRs:**", "", ""]
for value, entries in collisions.items():
lines.append(f"V{value}")
for number, paths in sorted(entries.items()):
lines.extend(f" PR #{number}: {safe(path)}" for path in sorted(set(paths)))
lines += ["", "", "Authors should coordinate numbering and rerun migration tests before merge."]
elif not errors:
lines.append("No cross-PR version collisions found in this snapshot.")
lines += ["", "Scope: added migrations and renames introducing a different version in the application migration directory. "
"Same-version renames, modified/deleted files and conflicts with already merged migrations are not checked. "
"Concurrent PR changes can make this advisory snapshot stale.", ""]
return "\n".join(lines)
def main():
try:
api = GitHub(os.environ.get("GITHUB_REPOSITORY", ""), os.environ.get("GITHUB_TOKEN", ""))
collisions, errors, count = scan(api)
except ScanError as error:
collisions, errors, count = {}, [str(error)], 0
except (KeyError, TypeError):
collisions, errors, count = {}, ["Malformed GitHub API response; no clearance issued."], 0
report = summary(collisions, errors, count)
destination = os.environ.get("GITHUB_STEP_SUMMARY")
if destination:
with Path(destination).open("a", encoding="utf-8") as output:
output.write(report)
else:
print(report)
return 1 if errors else 0 # Workflow treats errors as advisory, never as scan clearance.
if __name__ == "__main__":
raise SystemExit(main())