Path: blob/main/Tools/scripts/report-outdated-for-maintainer.py
46584 views
#!/usr/bin/env python31"""Report outdated FreeBSD ports for a maintainer using Repology and GitHub."""23# report-outdated-for-maintainer.py - a script that find outdated ports for4# a given maintainer based on Repology API and GitHub releases, and prints5# a categorized report.6#7# MAINTAINER= [email protected]8#91011import sys12import os13import re14import json15import time16import subprocess17import concurrent.futures18import urllib.request19import urllib.parse20import urllib.error21import ssl2223# --- Dependency check ---2425missing_deps = []26try:27from tabulate2 import tabulate28except ImportError:29missing_deps.append("tabulate2")3031if missing_deps:32py_ver = f"py{sys.version_info.major}{sys.version_info.minor}"33pkg_names = " ".join(f"{py_ver}-{dep}" for dep in missing_deps)34print(35f"Missing dependencies: {', '.join(missing_deps)}. "36f"Please install them with 'pkg install {pkg_names}' and try again."37)38sys.exit(1)3940# --- Configuration ---4142PORTSDIR = os.environ.get("PORTSDIR", "/usr/ports")43REPOLOGY_API_BASE = "https://repology.org/api/v1"44GITHUB_BASE = "https://github.com"45GITHUB_API_BASE = "https://api.github.com"46USER_AGENT = "report-outdated-for-maintainer/1.0 (FreeBSD ports maintainer tool)"47GITHUB_TOKEN = None4849# USES keyword → Port Type label (in priority order)50USES_TYPES = [51("gmake", "gmake"),52("cmake", "cmake"),53("meson", "meson"),54("waf", "waf"),55("python", "Python"),56("cargo", "Rust"),57("cabal", "Haskel"),58("go", "GoLang"),59("nodejs", "NodeJS"),60("fortran", "Fortran"),61("drupal", "Drupal"),62("cran", "R"),63("ocaml", "Ocaml"),64("php", "php"),65("vala", "Vala"),66("ruby", "Ruby"),67("perl5", "Perl"),68("mono", "Mono"),69("ada", "Ada"),70("lazarus", "Lazarus"),71("erlang", "Erlang"),72("electron", "Electron"),73("linux", "Linux"),74("kmod", "kmod"),75("java", "Java"),76]777879# --- Validation ---8081def check_portsdir():82required = [83PORTSDIR,84os.path.join(PORTSDIR, "Makefile"),85os.path.join(PORTSDIR, "Mk"),86os.path.join(PORTSDIR, "MOVED"),87os.path.join(PORTSDIR, "devel"),88os.path.join(PORTSDIR, "devel", "Makefile"),89]90for path in required:91if not os.path.exists(path):92print(f"Error: Required path not found: {path}", file=sys.stderr)93sys.exit(1)949596def get_github_token():97"""Return a GitHub token from env or gh auth, if available."""98global GITHUB_TOKEN99if GITHUB_TOKEN is not None:100return GITHUB_TOKEN101102GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")103if GITHUB_TOKEN:104return GITHUB_TOKEN105106try:107result = subprocess.run(108["gh", "auth", "token"],109capture_output=True,110text=True,111timeout=10,112)113token = result.stdout.strip()114if result.returncode == 0 and token:115GITHUB_TOKEN = token116return GITHUB_TOKEN117except Exception:118pass119120GITHUB_TOKEN = ""121return None122123124# --- Ports tree parsing ---125126def get_subdir_entries(makefile_path):127"""Return list of SUBDIR += entries from a Makefile."""128entries = []129try:130with open(makefile_path, "r", errors="replace") as f:131content = f.read()132for m in re.finditer(r"^\s*SUBDIR\s*\+=\s*(\S+)", content, re.MULTILINE):133entries.append(m.group(1))134except OSError:135pass136return entries137138139def read_makefile_logical_lines(makefile_path):140"""Read a Makefile and return a list of logical lines (continuations joined)."""141try:142with open(makefile_path, "r", errors="replace") as f:143content = f.read()144except OSError:145return []146# Join continuation lines147content = re.sub(r"\\\n", " ", content)148return content.splitlines()149150151def get_makefile_var(makefile_path, var):152"""Extract a simple variable value from a Makefile (first assignment found)."""153for line in read_makefile_logical_lines(makefile_path):154m = re.match(rf"^\s*{re.escape(var)}\s*(?::|\?)?=\s*(.+)$", line)155if m:156return re.sub(r"\s+#.*$", "", m.group(1)).strip()157return None158159160def get_uses_types(makefile_path):161"""Return a comma-separated string of port types based on USES= in the Makefile."""162uses_tokens = set()163for line in read_makefile_logical_lines(makefile_path):164# Match both USES= and USES+=165m = re.match(r"^\s*USES\s*\+?=\s*(.+)$", line)166if m:167for token in m.group(1).split():168# Strip :options suffix for matching169uses_tokens.add(token.split(":")[0])170171types = []172for keyword, label in USES_TYPES:173if keyword in uses_tokens:174types.append(label)175return ", ".join(types)176177178def resolve_make_value(value, portname):179"""Resolve simple ${PORTNAME} substitutions in Makefile values."""180if value is None:181return None182183def replacer(match):184modifier = match.group(1)185if modifier == ":tl":186return portname.lower()187if modifier == ":tu":188return portname.upper()189return portname190191return re.sub(r"\$\{PORTNAME(?:(:tl|:tu))?\}", replacer, value)192193194def get_github_repo(makefile_path):195"""Return (account, project) for USE_GITHUB=yes ports, else None."""196use_github = get_makefile_var(makefile_path, "USE_GITHUB")197if not use_github or use_github.lower() != "yes":198return None199200portname = get_makefile_var(makefile_path, "PORTNAME")201if not portname:202return None203204gh_account = get_makefile_var(makefile_path, "GH_ACCOUNT") or portname205gh_project = get_makefile_var(makefile_path, "GH_PROJECT") or portname206gh_account = resolve_make_value(gh_account.split()[0], portname)207gh_project = resolve_make_value(gh_project.split()[0], portname)208return gh_account, gh_project209210211# --- Version handling ---212213VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")214G_VERSION_RE = re.compile(r"^g[a-zA-Z0-9]+$")215YYYYMMDD_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})$")216217218def looks_like_version(s):219return bool(s and VERSION_RE.match(s) and any(ch.isdigit() for ch in s))220221222def compute_aux_version(real_version, latest_effective=None):223"""224Return the auxiliary version string if it differs from real_version, else None.225226- gNNNNN → 0.0.0... (matching component count of latest_effective)227- YYYYMMDD → YYYY.MM.DD228"""229if G_VERSION_RE.match(real_version) and latest_effective is not None:230parts = parse_version(latest_effective)231n = max(len(parts), 1)232return ".".join(["0"] * n)233234m = YYYYMMDD_RE.match(real_version)235if m:236year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))237if 1900 <= year <= 2099 and 1 <= month <= 12 and 1 <= day <= 31:238return f"{m.group(1)}.{m.group(2)}.{m.group(3)}"239240return None241242243def display_version(real, aux):244"""Return 'real (aux)' if aux differs from real, else just 'real'."""245if aux and aux != real:246return f"{real} ({aux})"247return real248249250def parse_version(version_str):251"""Parse version string into a tuple of ints."""252parts = [int(part) for part in re.findall(r"\d+", version_str)]253return tuple(parts) if parts else (0,)254255256def compare_versions(left_version, right_version):257"""Return 1 if left>right, -1 if left<right, 0 if equal by numeric components."""258left = parse_version(left_version)259right = parse_version(right_version)260max_len = max(len(left), len(right), 3)261left = left + (0,) * (max_len - len(left))262right = right + (0,) * (max_len - len(right))263if left > right:264return 1265if left < right:266return -1267return 0268269270def categorize_outdatedness(current_effective, latest_effective):271"""272Compare effective (auxiliary) versions.273Returns (category, level) or None if not outdated.274"""275if compare_versions(latest_effective, current_effective) <= 0:276return None277278cur = parse_version(current_effective)279lat = parse_version(latest_effective)280max_len = max(len(cur), len(lat), 3)281cur = cur + (0,) * (max_len - len(cur))282lat = lat + (0,) * (max_len - len(lat))283284if lat[0] > cur[0]:285return ("major", lat[0] - cur[0])286287if lat[1] > cur[1]:288return ("minor", lat[1] - cur[1])289290for i in range(2, max_len):291if lat[i] > cur[i]:292return ("patch", lat[i] - cur[i])293if lat[i] < cur[i]:294break295296return ("patch", 1)297298299# --- Ports scanning ---300301def get_port_version_from_makefile(makefile_path):302"""Try PORTVERSION then DISTVERSION; return value if it looks like a version."""303for var in ("PORTVERSION", "DISTVERSION"):304val = get_makefile_var(makefile_path, var)305if val and looks_like_version(val):306return val307return None308309310def get_port_version_via_make(port_dir):311"""Run 'make -V PORTVERSION' in port_dir and return the result."""312try:313result = subprocess.run(314["make", "BATCH=yes", "-V", "PORTVERSION"],315cwd=port_dir,316capture_output=True,317text=True,318timeout=30,319)320v = result.stdout.strip()321if looks_like_version(v):322return v323except Exception:324pass325return None326327328def find_maintained_ports(maintainer_email):329"""330Return (versions, uses_types, github_repos) keyed by port origin.331versions[origin] = real version string332uses_types[origin] = comma-separated port type string333github_repos[origin] = (account, project) for USE_GITHUB ports334"""335versions = {}336uses_types = {}337github_repos = {}338top_makefile = os.path.join(PORTSDIR, "Makefile")339categories = get_subdir_entries(top_makefile)340341for category in categories:342cat_makefile = os.path.join(PORTSDIR, category, "Makefile")343if not os.path.isfile(cat_makefile):344continue345port_dirs = get_subdir_entries(cat_makefile)346for port_dir in port_dirs:347port_path = os.path.join(PORTSDIR, category, port_dir)348port_makefile = os.path.join(port_path, "Makefile")349if not os.path.isfile(port_makefile):350continue351maintainer = get_makefile_var(port_makefile, "MAINTAINER")352if not maintainer or maintainer.lower() != maintainer_email.lower():353continue354origin = f"{category}/{port_dir}"355version = get_port_version_from_makefile(port_makefile)356if version is None:357version = get_port_version_via_make(port_path)358if version is None:359print(360f"Warning: Could not determine version for {origin}, skipping.",361file=sys.stderr,362)363continue364versions[origin] = version365uses_types[origin] = get_uses_types(port_makefile)366github_repo = get_github_repo(port_makefile)367if github_repo:368github_repos[origin] = github_repo369370return versions, uses_types, github_repos371372373# --- Repology API ---374375def repology_request(url):376"""Make a single GET request to the Repology API; return parsed JSON."""377ctx = ssl.create_default_context()378req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})379try:380with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:381return json.loads(resp.read())382except urllib.error.HTTPError as e:383print(f"Error: Repology API returned HTTP {e.code}: {e.reason}", file=sys.stderr)384sys.exit(1)385except urllib.error.URLError as e:386print(f"Error: Could not reach Repology API: {e.reason}", file=sys.stderr)387sys.exit(1)388except Exception as e:389print(f"Error querying Repology: {e}", file=sys.stderr)390sys.exit(1)391392393def query_repology_outdated(maintainer_email):394"""395Return dict {origin: latest_version} for FreeBSD ports that Repology396considers outdated for this maintainer.397"""398encoded_email = urllib.parse.quote(maintainer_email.lower(), safe="@")399base_params = f"maintainer={encoded_email}&inrepo=freebsd&outdated=true"400url = f"{REPOLOGY_API_BASE}/projects/?{base_params}"401outdated = {}402403while url:404data = repology_request(url)405if not data:406break407408for project_name, packages in data.items():409freebsd_outdated = None410newest_version = None411412for pkg in packages:413if pkg.get("repo") == "freebsd" and pkg.get("status") == "outdated":414freebsd_outdated = pkg415if pkg.get("status") == "newest" and newest_version is None:416newest_version = pkg.get("version")417418if freebsd_outdated and newest_version:419srcname = freebsd_outdated.get("srcname") or freebsd_outdated.get("visiblename")420if srcname:421outdated[srcname] = newest_version422423if len(data) == 200:424last_project = list(data.keys())[-1]425encoded_last = urllib.parse.quote(last_project, safe="")426url = f"{REPOLOGY_API_BASE}/projects/{encoded_last}/?{base_params}"427time.sleep(0.5) # be polite to the API428else:429url = None430431return outdated432433434def github_request(url, method="GET"):435"""Make a single GitHub request and return the response object."""436headers = {437"User-Agent": USER_AGENT,438"Accept": "application/vnd.github+json",439}440token = get_github_token()441if token:442headers["Authorization"] = f"Bearer {token}"443req = urllib.request.Request(url, headers=headers, method=method)444return urllib.request.urlopen(req, timeout=30)445446447def query_github_latest_release(account, project, cache):448"""Return the latest stable GitHub release tag for account/project, or None."""449key = (account, project)450if key in cache:451return cache[key]452453quoted_account = urllib.parse.quote(account, safe="")454quoted_project = urllib.parse.quote(project, safe="")455url = f"{GITHUB_API_BASE}/repos/{quoted_account}/{quoted_project}/releases/latest"456457try:458with github_request(url) as resp:459data = json.load(resp)460except urllib.error.HTTPError as e:461if e.code != 404:462print(463f"Warning: Could not query GitHub latest release for {account}/{project}: HTTP {e.code}",464file=sys.stderr,465)466cache[key] = None467return None468except urllib.error.URLError as e:469print(470f"Warning: Could not query GitHub latest release for {account}/{project}: {e.reason}",471file=sys.stderr,472)473cache[key] = None474return None475except Exception as e:476print(477f"Warning: Could not query GitHub latest release for {account}/{project}: {e}",478file=sys.stderr,479)480cache[key] = None481return None482483tag = (data.get("tag_name") or "").strip()484cache[key] = tag.strip() or None485return cache[key]486487488def query_github_releases(github_repos):489"""Return dict {origin: latest_release_tag} for GitHub-based ports with releases."""490cache = {}491latest_releases = {}492493unique_repos = sorted(set(github_repos.values()))494with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:495future_map = {496executor.submit(query_github_latest_release, account, project, cache): (account, project)497for account, project in unique_repos498}499for future in concurrent.futures.as_completed(future_map):500future.result()501502for origin, repo in github_repos.items():503latest = cache.get(repo)504if latest:505latest_releases[origin] = latest506return latest_releases507508509# --- Report formatting ---510511SEP = "============="512513514def format_table_with_blank_header_line(rows, headers):515"""Render tabulate 'simple' table with a blank line inserted after the header separator."""516raw = tabulate(rows, headers=headers, tablefmt="simple")517lines = raw.splitlines()518# simple format: line 0 = header names, line 1 = dashes, line 2+ = data519if len(lines) >= 2:520lines.insert(2, "")521return "\n".join(lines)522523524def choose_latest_version(repology_real, github_real):525"""Choose the highest latest version from Repology/GitHub and return display info."""526candidates = []527if repology_real:528repology_aux = compute_aux_version(repology_real)529repology_effective = repology_aux if repology_aux else repology_real530candidates.append(("Repology", repology_real, repology_aux, repology_effective))531if github_real:532github_aux = compute_aux_version(github_real)533github_effective = github_aux if github_aux else github_real534candidates.append(("GitHub", github_real, github_aux, github_effective))535536if not candidates:537return None538539best = candidates[0]540for candidate in candidates[1:]:541cmp = compare_versions(candidate[3], best[3])542if cmp > 0 or (cmp == 0 and candidate[0] == "Repology" and best[0] != "Repology"):543best = candidate544545notes = [candidate[0] for candidate in candidates if compare_versions(candidate[3], best[3]) == 0]546return {547"real": best[1],548"aux": best[2],549"effective": best[3],550"notes": ", ".join(notes),551}552553554def print_report(maintainer_email, local_versions, local_uses, outdated_repology, github_latest):555total_ports = len(local_versions)556557headers = [558"Port origin",559"Latest released version",560"Current port version",561"Outdatedness level",562"Port Type",563"Notes",564]565566major_ports = []567minor_ports = []568patch_ports = []569570for origin, local_real in local_versions.items():571latest = choose_latest_version(outdated_repology.get(origin), github_latest.get(origin))572if latest is None:573continue574575# Compute auxiliary version for local (gNNNNN needs latest_effective for depth)576local_aux = compute_aux_version(local_real, latest["effective"])577local_effective = local_aux if local_aux else local_real578579result = categorize_outdatedness(local_effective, latest["effective"])580if result is None:581continue582category, level = result583584port_type = local_uses.get(origin, "")585row = (586origin,587display_version(latest["real"], latest["aux"]),588display_version(local_real, local_aux),589level,590port_type,591latest["notes"],592)593if category == "major":594major_ports.append(row)595elif category == "minor":596minor_ports.append(row)597else:598patch_ports.append(row)599600def sort_key(row):601return (-row[3], row[0])602603major_ports.sort(key=sort_key)604minor_ports.sort(key=sort_key)605patch_ports.sort(key=sort_key)606607n_major = len(major_ports)608n_minor = len(minor_ports)609n_patch = len(patch_ports)610n_outdated = n_major + n_minor + n_patch611612# Title613print(f"Outdated ports for maintainer {maintainer_email}")614print()615print()616617# Summary line618pct = (lambda n: f"{n / total_ports * 100:.2f}%") if total_ports > 0 else (lambda n: "0.00%")619620print(621f"The maintainer has {n_outdated} ({pct(n_outdated)}) outdated ports, categorized as follows: "622f"{n_major} ({pct(n_major)}) majorly outdated, "623f"{n_minor} ({pct(n_minor)}) moderately outdated, "624f"and {n_patch} ({pct(n_patch)}) slightly outdated."625)626print()627628for title, rows in [629("Majorly outdated ports", major_ports),630("Moderately outdated ports", minor_ports),631("Slightly outdated ports", patch_ports),632]:633print(f"{SEP}{title}{SEP}")634print()635if rows:636print(format_table_with_blank_header_line(rows, headers))637else:638print("No ports in this category")639print()640641642# --- Main ---643644def main():645if len(sys.argv) != 2:646print(f"Usage: {sys.argv[0]} <maintainer-email>", file=sys.stderr)647sys.exit(1)648649maintainer_email = sys.argv[1]650check_portsdir()651652print(f"Scanning {PORTSDIR} for ports maintained by {maintainer_email}...", file=sys.stderr)653local_versions, local_uses, github_repos = find_maintained_ports(maintainer_email)654655if not local_versions:656print(f"No ports maintained by {maintainer_email}")657sys.exit(0)658659print(660f"Found {len(local_versions)} local ports ({len(github_repos)} GitHub-based). Querying Repology and GitHub...",661file=sys.stderr,662)663outdated_repology = query_repology_outdated(maintainer_email)664github_latest = query_github_releases(github_repos)665print(f"Repology reports {len(outdated_repology)} outdated FreeBSD ports.", file=sys.stderr)666print(f"GitHub provides latest releases for {len(github_latest)} GitHub-based ports.", file=sys.stderr)667668print_report(maintainer_email, local_versions, local_uses, outdated_repology, github_latest)669670671if __name__ == "__main__":672main()673674675