706 lines
24 KiB
Python
Executable File
706 lines
24 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Report outdated FreeBSD ports for a maintainer using Repology and GitHub."""
|
|
|
|
# report-outdated-for-maintainer.py - a script that find outdated ports for
|
|
# a given maintainer based on Repology API and GitHub releases, and prints
|
|
# a categorized report.
|
|
#
|
|
# MAINTAINER= yuri@FreeBSD.org
|
|
#
|
|
|
|
|
|
import sys
|
|
import os
|
|
import re
|
|
import json
|
|
import time
|
|
import subprocess
|
|
import concurrent.futures
|
|
import urllib.request
|
|
import urllib.parse
|
|
import urllib.error
|
|
import ssl
|
|
|
|
# --- Dependency check ---
|
|
|
|
missing_deps = []
|
|
try:
|
|
from tabulate2 import tabulate
|
|
except ImportError:
|
|
missing_deps.append("tabulate2")
|
|
|
|
if missing_deps:
|
|
py_ver = f"py{sys.version_info.major}{sys.version_info.minor}"
|
|
pkg_names = " ".join(f"{py_ver}-{dep}" for dep in missing_deps)
|
|
print(
|
|
f"Missing dependencies: {', '.join(missing_deps)}. "
|
|
f"Please install them with 'pkg install {pkg_names}' and try again."
|
|
)
|
|
sys.exit(1)
|
|
|
|
# --- Configuration ---
|
|
|
|
PORTSDIR = os.environ.get("PORTSDIR", "/usr/ports")
|
|
REPOLOGY_API_BASE = "https://repology.org/api/v1"
|
|
GITHUB_BASE = "https://github.com"
|
|
GITHUB_API_BASE = "https://api.github.com"
|
|
USER_AGENT = "report-outdated-for-maintainer/1.0 (FreeBSD ports maintainer tool)"
|
|
GITHUB_TOKEN = None
|
|
|
|
# USES keyword → Port Type label (in priority order)
|
|
USES_TYPES = [
|
|
("gmake", "gmake"),
|
|
("cmake", "cmake"),
|
|
("meson", "meson"),
|
|
("waf", "waf"),
|
|
("python", "Python"),
|
|
("cargo", "Rust"),
|
|
("cabal", "Haskel"),
|
|
("go", "GoLang"),
|
|
("nodejs", "NodeJS"),
|
|
("fortran", "Fortran"),
|
|
("drupal", "Drupal"),
|
|
("cran", "R"),
|
|
("ocaml", "Ocaml"),
|
|
("php", "php"),
|
|
("vala", "Vala"),
|
|
("ruby", "Ruby"),
|
|
("perl5", "Perl"),
|
|
("mono", "Mono"),
|
|
("ada", "Ada"),
|
|
("lazarus", "Lazarus"),
|
|
("erlang", "Erlang"),
|
|
("electron", "Electron"),
|
|
("linux", "Linux"),
|
|
("kmod", "kmod"),
|
|
("java", "Java"),
|
|
]
|
|
|
|
|
|
# --- Validation ---
|
|
|
|
def check_portsdir():
|
|
required = [
|
|
PORTSDIR,
|
|
os.path.join(PORTSDIR, "Makefile"),
|
|
os.path.join(PORTSDIR, "Mk"),
|
|
os.path.join(PORTSDIR, "MOVED"),
|
|
os.path.join(PORTSDIR, "devel"),
|
|
os.path.join(PORTSDIR, "devel", "Makefile"),
|
|
]
|
|
for path in required:
|
|
if not os.path.exists(path):
|
|
print(f"Error: Required path not found: {path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def get_github_token():
|
|
"""Return a GitHub token from env or gh auth, if available."""
|
|
global GITHUB_TOKEN
|
|
if GITHUB_TOKEN is not None:
|
|
return GITHUB_TOKEN
|
|
|
|
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
if GITHUB_TOKEN:
|
|
return GITHUB_TOKEN
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["gh", "auth", "token"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
token = result.stdout.strip()
|
|
if result.returncode == 0 and token:
|
|
GITHUB_TOKEN = token
|
|
return GITHUB_TOKEN
|
|
except Exception:
|
|
pass
|
|
|
|
GITHUB_TOKEN = ""
|
|
return None
|
|
|
|
|
|
# --- Ports tree parsing ---
|
|
|
|
def get_subdir_entries(makefile_path):
|
|
"""Return list of SUBDIR += entries from a Makefile."""
|
|
entries = []
|
|
try:
|
|
with open(makefile_path, "r", errors="replace") as f:
|
|
content = f.read()
|
|
for m in re.finditer(r"^\s*SUBDIR\s*\+=\s*(\S+)", content, re.MULTILINE):
|
|
entries.append(m.group(1))
|
|
except OSError:
|
|
pass
|
|
return entries
|
|
|
|
|
|
def read_makefile_logical_lines(makefile_path):
|
|
"""Read a Makefile and return a list of logical lines (continuations joined)."""
|
|
try:
|
|
with open(makefile_path, "r", errors="replace") as f:
|
|
content = f.read()
|
|
except OSError:
|
|
return []
|
|
# Join continuation lines
|
|
content = re.sub(r"\\\n", " ", content)
|
|
return content.splitlines()
|
|
|
|
|
|
def get_makefile_var(makefile_path, var):
|
|
"""Extract a simple variable value from a Makefile (first assignment found)."""
|
|
for line in read_makefile_logical_lines(makefile_path):
|
|
m = re.match(rf"^\s*{re.escape(var)}\s*(?::|\?)?=\s*(.+)$", line)
|
|
if m:
|
|
return re.sub(r"\s+#.*$", "", m.group(1)).strip()
|
|
return None
|
|
|
|
|
|
def get_uses_types(makefile_path):
|
|
"""Return a comma-separated string of port types based on USES= in the Makefile."""
|
|
uses_tokens = set()
|
|
for line in read_makefile_logical_lines(makefile_path):
|
|
# Match both USES= and USES+=
|
|
m = re.match(r"^\s*USES\s*\+?=\s*(.+)$", line)
|
|
if m:
|
|
for token in m.group(1).split():
|
|
# Strip :options suffix for matching
|
|
uses_tokens.add(token.split(":")[0])
|
|
|
|
types = []
|
|
for keyword, label in USES_TYPES:
|
|
if keyword in uses_tokens:
|
|
types.append(label)
|
|
return ", ".join(types)
|
|
|
|
|
|
def resolve_make_value(value, portname):
|
|
"""Resolve simple ${PORTNAME} substitutions in Makefile values."""
|
|
if value is None:
|
|
return None
|
|
|
|
def replacer(match):
|
|
modifier = match.group(1)
|
|
if modifier == ":tl":
|
|
return portname.lower()
|
|
if modifier == ":tu":
|
|
return portname.upper()
|
|
return portname
|
|
|
|
return re.sub(r"\$\{PORTNAME(?:(:tl|:tu))?\}", replacer, value)
|
|
|
|
|
|
def get_github_repo(makefile_path):
|
|
"""Return (account, project) for USE_GITHUB=yes ports or from WWW field, else None."""
|
|
use_github = get_makefile_var(makefile_path, "USE_GITHUB")
|
|
if use_github and use_github.lower() == "yes":
|
|
portname = get_makefile_var(makefile_path, "PORTNAME")
|
|
if not portname:
|
|
return None
|
|
|
|
gh_account = get_makefile_var(makefile_path, "GH_ACCOUNT") or portname
|
|
gh_project = get_makefile_var(makefile_path, "GH_PROJECT") or portname
|
|
gh_account = resolve_make_value(gh_account.split()[0], portname)
|
|
gh_project = resolve_make_value(gh_project.split()[0], portname)
|
|
return gh_account, gh_project
|
|
|
|
# If USE_GITHUB is not set, try to extract from WWW field, but only for ports
|
|
# that appear to use GitHub releases and are explicitly marked with a comment
|
|
# or have DISTVERSION matching a GitHub-style tag pattern (YYYY.M.DD or similar)
|
|
version = get_makefile_var(makefile_path, "DISTVERSION") or get_makefile_var(makefile_path, "PORTVERSION")
|
|
if version and looks_like_version(version):
|
|
# Only query from WWW if version matches GitHub-style patterns:
|
|
# - YYYY.M.DD (date-based like 2026.4.26, 2026.4.29)
|
|
# - X.Y.Z.W (4+ numeric components)
|
|
# This filters out most non-GitHub ports while catching real GitHub releases
|
|
parts = re.findall(r"\d+", version)
|
|
if len(parts) >= 3:
|
|
# Check if it looks like YYYY.M.DD (year >= 2000)
|
|
if int(parts[0]) >= 2000 and int(parts[0]) <= 2099:
|
|
if int(parts[1]) >= 1 and int(parts[1]) <= 12:
|
|
if int(parts[2]) >= 1 and int(parts[2]) <= 31:
|
|
# Looks like a date-based version from GitHub
|
|
www = get_makefile_var(makefile_path, "WWW")
|
|
if www:
|
|
m = re.match(r"https?://(?:www\.)?github\.com/([^/]+)/([^/\s]+)", www)
|
|
if m:
|
|
return m.group(1), m.group(2)
|
|
# Also check for simple 4+ component versions (e.g., 1.2.3.4)
|
|
elif len(parts) >= 4:
|
|
www = get_makefile_var(makefile_path, "WWW")
|
|
if www:
|
|
m = re.match(r"https?://(?:www\.)?github\.com/([^/]+)/([^/\s]+)", www)
|
|
if m:
|
|
return m.group(1), m.group(2)
|
|
|
|
return None
|
|
|
|
|
|
# --- Version handling ---
|
|
|
|
VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
|
|
G_VERSION_RE = re.compile(r"^g[a-zA-Z0-9]+$")
|
|
YYYYMMDD_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})$")
|
|
|
|
|
|
def looks_like_version(s):
|
|
return bool(s and VERSION_RE.match(s) and any(ch.isdigit() for ch in s))
|
|
|
|
|
|
def compute_aux_version(real_version, latest_effective=None):
|
|
"""
|
|
Return the auxiliary version string if it differs from real_version, else None.
|
|
|
|
- gNNNNN → 0.0.0... (matching component count of latest_effective)
|
|
- YYYYMMDD → YYYY.MM.DD
|
|
"""
|
|
if G_VERSION_RE.match(real_version) and latest_effective is not None:
|
|
parts = parse_version(latest_effective)
|
|
n = max(len(parts), 1)
|
|
return ".".join(["0"] * n)
|
|
|
|
m = YYYYMMDD_RE.match(real_version)
|
|
if m:
|
|
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
if 1900 <= year <= 2099 and 1 <= month <= 12 and 1 <= day <= 31:
|
|
return f"{m.group(1)}.{m.group(2)}.{m.group(3)}"
|
|
|
|
return None
|
|
|
|
|
|
def display_version(real, aux):
|
|
"""Return 'real (aux)' if aux differs from real, else just 'real'."""
|
|
if aux and aux != real:
|
|
return f"{real} ({aux})"
|
|
return real
|
|
|
|
|
|
def parse_version(version_str):
|
|
"""Parse version string into a tuple of ints."""
|
|
parts = [int(part) for part in re.findall(r"\d+", version_str)]
|
|
return tuple(parts) if parts else (0,)
|
|
|
|
|
|
def compare_versions(left_version, right_version):
|
|
"""Return 1 if left>right, -1 if left<right, 0 if equal by numeric components."""
|
|
left = parse_version(left_version)
|
|
right = parse_version(right_version)
|
|
max_len = max(len(left), len(right), 3)
|
|
left = left + (0,) * (max_len - len(left))
|
|
right = right + (0,) * (max_len - len(right))
|
|
if left > right:
|
|
return 1
|
|
if left < right:
|
|
return -1
|
|
return 0
|
|
|
|
|
|
def categorize_outdatedness(current_effective, latest_effective):
|
|
"""
|
|
Compare effective (auxiliary) versions.
|
|
Returns (category, level) or None if not outdated.
|
|
"""
|
|
if compare_versions(latest_effective, current_effective) <= 0:
|
|
return None
|
|
|
|
cur = parse_version(current_effective)
|
|
lat = parse_version(latest_effective)
|
|
max_len = max(len(cur), len(lat), 3)
|
|
cur = cur + (0,) * (max_len - len(cur))
|
|
lat = lat + (0,) * (max_len - len(lat))
|
|
|
|
if lat[0] > cur[0]:
|
|
return ("major", lat[0] - cur[0])
|
|
|
|
if lat[1] > cur[1]:
|
|
return ("minor", lat[1] - cur[1])
|
|
|
|
for i in range(2, max_len):
|
|
if lat[i] > cur[i]:
|
|
return ("patch", lat[i] - cur[i])
|
|
if lat[i] < cur[i]:
|
|
break
|
|
|
|
return ("patch", 1)
|
|
|
|
|
|
# --- Ports scanning ---
|
|
|
|
def get_port_version_from_makefile(makefile_path):
|
|
"""Try PORTVERSION then DISTVERSION; return value if it looks like a version."""
|
|
for var in ("PORTVERSION", "DISTVERSION"):
|
|
val = get_makefile_var(makefile_path, var)
|
|
if val and looks_like_version(val):
|
|
return val
|
|
return None
|
|
|
|
|
|
def get_port_version_via_make(port_dir):
|
|
"""Run 'make -V PORTVERSION' in port_dir and return the result."""
|
|
try:
|
|
result = subprocess.run(
|
|
["make", "BATCH=yes", "-V", "PORTVERSION"],
|
|
cwd=port_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
v = result.stdout.strip()
|
|
if looks_like_version(v):
|
|
return v
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def find_maintained_ports(maintainer_email):
|
|
"""
|
|
Return (versions, uses_types, github_repos, github_version_prefixes) keyed by port origin.
|
|
versions[origin] = real version string
|
|
uses_types[origin] = comma-separated port type string
|
|
github_repos[origin] = (account, project) for USE_GITHUB ports
|
|
github_version_prefixes[origin] = (prefix, suffix) to strip from GitHub release tags
|
|
"""
|
|
versions = {}
|
|
uses_types = {}
|
|
github_repos = {}
|
|
github_version_prefixes = {}
|
|
top_makefile = os.path.join(PORTSDIR, "Makefile")
|
|
categories = get_subdir_entries(top_makefile)
|
|
|
|
for category in categories:
|
|
cat_makefile = os.path.join(PORTSDIR, category, "Makefile")
|
|
if not os.path.isfile(cat_makefile):
|
|
continue
|
|
port_dirs = get_subdir_entries(cat_makefile)
|
|
for port_dir in port_dirs:
|
|
port_path = os.path.join(PORTSDIR, category, port_dir)
|
|
port_makefile = os.path.join(port_path, "Makefile")
|
|
if not os.path.isfile(port_makefile):
|
|
continue
|
|
maintainer = get_makefile_var(port_makefile, "MAINTAINER")
|
|
if not maintainer or maintainer.lower() != maintainer_email.lower():
|
|
continue
|
|
origin = f"{category}/{port_dir}"
|
|
version = get_port_version_from_makefile(port_makefile)
|
|
if version is None:
|
|
version = get_port_version_via_make(port_path)
|
|
if version is None:
|
|
print(
|
|
f"Warning: Could not determine version for {origin}, skipping.",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
versions[origin] = version
|
|
uses_types[origin] = get_uses_types(port_makefile)
|
|
github_repo = get_github_repo(port_makefile)
|
|
if github_repo:
|
|
github_repos[origin] = github_repo
|
|
portname = get_makefile_var(port_makefile, "PORTNAME") or ""
|
|
prefix_raw = get_makefile_var(port_makefile, "DISTVERSIONPREFIX") or ""
|
|
suffix_raw = get_makefile_var(port_makefile, "DISTVERSIONSUFFIX") or ""
|
|
prefix = resolve_make_value(prefix_raw, portname)
|
|
suffix = resolve_make_value(suffix_raw, portname)
|
|
if prefix or suffix:
|
|
github_version_prefixes[origin] = (prefix, suffix)
|
|
|
|
return versions, uses_types, github_repos, github_version_prefixes
|
|
|
|
|
|
# --- Repology API ---
|
|
|
|
def repology_request(url):
|
|
"""Make a single GET request to the Repology API; return parsed JSON."""
|
|
ctx = ssl.create_default_context()
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
|
|
return json.loads(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
print(f"Error: Repology API returned HTTP {e.code}: {e.reason}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except urllib.error.URLError as e:
|
|
print(f"Error: Could not reach Repology API: {e.reason}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Error querying Repology: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def query_repology_outdated(maintainer_email):
|
|
"""
|
|
Return dict {origin: latest_version} for FreeBSD ports that Repology
|
|
considers outdated for this maintainer.
|
|
"""
|
|
encoded_email = urllib.parse.quote(maintainer_email.lower(), safe="@")
|
|
base_params = f"maintainer={encoded_email}&inrepo=freebsd&outdated=true"
|
|
url = f"{REPOLOGY_API_BASE}/projects/?{base_params}"
|
|
outdated = {}
|
|
|
|
while url:
|
|
data = repology_request(url)
|
|
if not data:
|
|
break
|
|
|
|
for project_name, packages in data.items():
|
|
freebsd_outdated = None
|
|
newest_version = None
|
|
|
|
for pkg in packages:
|
|
if pkg.get("repo") == "freebsd" and pkg.get("status") == "outdated":
|
|
freebsd_outdated = pkg
|
|
if pkg.get("status") == "newest" and newest_version is None:
|
|
newest_version = pkg.get("version")
|
|
|
|
if freebsd_outdated and newest_version:
|
|
srcname = freebsd_outdated.get("srcname") or freebsd_outdated.get("visiblename")
|
|
if srcname:
|
|
outdated[srcname] = newest_version
|
|
|
|
if len(data) == 200:
|
|
last_project = list(data.keys())[-1]
|
|
encoded_last = urllib.parse.quote(last_project, safe="")
|
|
url = f"{REPOLOGY_API_BASE}/projects/{encoded_last}/?{base_params}"
|
|
time.sleep(0.5) # be polite to the API
|
|
else:
|
|
url = None
|
|
|
|
return outdated
|
|
|
|
|
|
def github_request(url, method="GET"):
|
|
"""Make a single GitHub request and return the response object."""
|
|
headers = {
|
|
"User-Agent": USER_AGENT,
|
|
"Accept": "application/vnd.github+json",
|
|
}
|
|
token = get_github_token()
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
req = urllib.request.Request(url, headers=headers, method=method)
|
|
return urllib.request.urlopen(req, timeout=30)
|
|
|
|
|
|
def query_github_latest_release(account, project, cache):
|
|
"""Return the latest stable GitHub release tag for account/project, or None."""
|
|
key = (account, project)
|
|
if key in cache:
|
|
return cache[key]
|
|
|
|
# Use 'gh' CLI for better rate limit handling and authentication
|
|
try:
|
|
result = subprocess.run(
|
|
["gh", "release", "view", "--repo", f"{account}/{project}"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
if result.returncode == 0:
|
|
for line in result.stdout.split("\n"):
|
|
if line.startswith("tag:"):
|
|
tag = line.split(":", 1)[1].strip()
|
|
cache[key] = tag.strip() or None
|
|
return cache[key]
|
|
cache[key] = None
|
|
return None
|
|
except Exception:
|
|
cache[key] = None
|
|
return None
|
|
|
|
|
|
def query_github_releases(github_repos, version_prefixes=None):
|
|
"""Return dict {origin: latest_release_tag} for GitHub-based ports with releases."""
|
|
cache = {}
|
|
latest_releases = {}
|
|
|
|
unique_repos = sorted(set(github_repos.values()))
|
|
# gh CLI handles rate limiting better than direct API calls, so we can use more workers
|
|
max_workers = min(8, len(unique_repos)) if unique_repos else 1
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
future_map = {
|
|
executor.submit(query_github_latest_release, account, project, cache): (account, project)
|
|
for account, project in unique_repos
|
|
}
|
|
for future in concurrent.futures.as_completed(future_map):
|
|
future.result()
|
|
|
|
for origin, repo in github_repos.items():
|
|
latest = cache.get(repo)
|
|
if latest:
|
|
if version_prefixes and origin in version_prefixes:
|
|
prefix, suffix = version_prefixes[origin]
|
|
if prefix and latest.startswith(prefix):
|
|
latest = latest[len(prefix):]
|
|
if suffix and latest.endswith(suffix):
|
|
latest = latest[:-len(suffix)]
|
|
latest_releases[origin] = latest
|
|
return latest_releases
|
|
|
|
|
|
# --- Report formatting ---
|
|
|
|
SEP = "============="
|
|
|
|
|
|
def format_table_with_blank_header_line(rows, headers):
|
|
"""Render tabulate 'simple' table with a blank line inserted after the header separator."""
|
|
raw = tabulate(rows, headers=headers, tablefmt="simple")
|
|
lines = raw.splitlines()
|
|
# simple format: line 0 = header names, line 1 = dashes, line 2+ = data
|
|
if len(lines) >= 2:
|
|
lines.insert(2, "")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def choose_latest_version(repology_real, github_real):
|
|
"""Choose the highest latest version from Repology/GitHub and return display info."""
|
|
candidates = []
|
|
if repology_real:
|
|
repology_aux = compute_aux_version(repology_real)
|
|
repology_effective = repology_aux if repology_aux else repology_real
|
|
candidates.append(("Repology", repology_real, repology_aux, repology_effective))
|
|
if github_real:
|
|
github_aux = compute_aux_version(github_real)
|
|
github_effective = github_aux if github_aux else github_real
|
|
candidates.append(("GitHub", github_real, github_aux, github_effective))
|
|
|
|
if not candidates:
|
|
return None
|
|
|
|
best = candidates[0]
|
|
for candidate in candidates[1:]:
|
|
cmp = compare_versions(candidate[3], best[3])
|
|
if cmp > 0 or (cmp == 0 and candidate[0] == "Repology" and best[0] != "Repology"):
|
|
best = candidate
|
|
|
|
notes = [candidate[0] for candidate in candidates if compare_versions(candidate[3], best[3]) == 0]
|
|
return {
|
|
"real": best[1],
|
|
"aux": best[2],
|
|
"effective": best[3],
|
|
"notes": ", ".join(notes),
|
|
}
|
|
|
|
|
|
def print_report(maintainer_email, local_versions, local_uses, outdated_repology, github_latest):
|
|
total_ports = len(local_versions)
|
|
|
|
headers = [
|
|
"Port origin",
|
|
"Latest released version",
|
|
"Current port version",
|
|
"Outdatedness level",
|
|
"Port Type",
|
|
"Notes",
|
|
]
|
|
|
|
major_ports = []
|
|
minor_ports = []
|
|
patch_ports = []
|
|
|
|
for origin, local_real in local_versions.items():
|
|
latest = choose_latest_version(outdated_repology.get(origin), github_latest.get(origin))
|
|
if latest is None:
|
|
continue
|
|
|
|
# Compute auxiliary version for local (gNNNNN needs latest_effective for depth)
|
|
local_aux = compute_aux_version(local_real, latest["effective"])
|
|
local_effective = local_aux if local_aux else local_real
|
|
|
|
result = categorize_outdatedness(local_effective, latest["effective"])
|
|
if result is None:
|
|
continue
|
|
category, level = result
|
|
|
|
port_type = local_uses.get(origin, "")
|
|
row = (
|
|
origin,
|
|
display_version(latest["real"], latest["aux"]),
|
|
display_version(local_real, local_aux),
|
|
level,
|
|
port_type,
|
|
latest["notes"],
|
|
)
|
|
if category == "major":
|
|
major_ports.append(row)
|
|
elif category == "minor":
|
|
minor_ports.append(row)
|
|
else:
|
|
patch_ports.append(row)
|
|
|
|
def sort_key(row):
|
|
return (-row[3], row[0])
|
|
|
|
major_ports.sort(key=sort_key)
|
|
minor_ports.sort(key=sort_key)
|
|
patch_ports.sort(key=sort_key)
|
|
|
|
n_major = len(major_ports)
|
|
n_minor = len(minor_ports)
|
|
n_patch = len(patch_ports)
|
|
n_outdated = n_major + n_minor + n_patch
|
|
|
|
# Title
|
|
print(f"Outdated ports for maintainer {maintainer_email}")
|
|
print()
|
|
print()
|
|
|
|
# Summary line
|
|
pct = (lambda n: f"{n / total_ports * 100:.2f}%") if total_ports > 0 else (lambda n: "0.00%")
|
|
|
|
print(
|
|
f"The maintainer has {n_outdated} ({pct(n_outdated)}) outdated ports, categorized as follows: "
|
|
f"{n_major} ({pct(n_major)}) majorly outdated, "
|
|
f"{n_minor} ({pct(n_minor)}) moderately outdated, "
|
|
f"and {n_patch} ({pct(n_patch)}) slightly outdated."
|
|
)
|
|
print()
|
|
|
|
for title, rows in [
|
|
("Majorly outdated ports", major_ports),
|
|
("Moderately outdated ports", minor_ports),
|
|
("Slightly outdated ports", patch_ports),
|
|
]:
|
|
print(f"{SEP}{title}{SEP}")
|
|
print()
|
|
if rows:
|
|
print(format_table_with_blank_header_line(rows, headers))
|
|
else:
|
|
print("No ports in this category")
|
|
print()
|
|
|
|
|
|
# --- Main ---
|
|
|
|
def main():
|
|
if len(sys.argv) != 2:
|
|
print(f"Usage: {sys.argv[0]} <maintainer-email>", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
maintainer_email = sys.argv[1]
|
|
check_portsdir()
|
|
|
|
print(f"Scanning {PORTSDIR} for ports maintained by {maintainer_email}...", file=sys.stderr)
|
|
local_versions, local_uses, github_repos, github_version_prefixes = find_maintained_ports(maintainer_email)
|
|
|
|
if not local_versions:
|
|
print(f"No ports maintained by {maintainer_email}")
|
|
sys.exit(0)
|
|
|
|
print(
|
|
f"Found {len(local_versions)} local ports ({len(github_repos)} GitHub-based). Querying Repology and GitHub...",
|
|
file=sys.stderr,
|
|
)
|
|
outdated_repology = query_repology_outdated(maintainer_email)
|
|
github_latest = query_github_releases(github_repos, github_version_prefixes)
|
|
print(f"Repology reports {len(outdated_repology)} outdated FreeBSD ports.", file=sys.stderr)
|
|
print(f"GitHub provides latest releases for {len(github_latest)} GitHub-based ports.", file=sys.stderr)
|
|
|
|
print_report(maintainer_email, local_versions, local_uses, outdated_repology, github_latest)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|