git: bbb6a26c221c - main - Tools/scripts/report-outdated-for-maintainer.py: Update

From: Yuri Victorovich <yuri_at_FreeBSD.org>
Date: Thu, 10 Sep 2026 20:07:01 UTC
The branch main has been updated by yuri:

URL: https://cgit.FreeBSD.org/ports/commit/?id=bbb6a26c221c00b7acfc9ec3d030d3d4a80b486f

commit bbb6a26c221c00b7acfc9ec3d030d3d4a80b486f
Author:     Yuri Victorovich <yuri@FreeBSD.org>
AuthorDate: 2026-09-10 19:14:01 +0000
Commit:     Yuri Victorovich <yuri@FreeBSD.org>
CommitDate: 2026-09-10 20:04:57 +0000

    Tools/scripts/report-outdated-for-maintainer.py: Update
    
    It collects outdatedness info and presents it in many different ways
    in different tables.
    
    'Outdatedness Analysis' is the table that shows how many
    ports are outdated for more than a week, month, etc.
    
    The number of ports outdated for more than 1 month should be taken
    as a measure of outdatedness for a maintainer.
    
    Use of the portscout data in addition to Repology and GitHub is
    coming up.
---
 Tools/scripts/report-outdated-for-maintainer.py | 390 +++++++++++++++++++++++-
 1 file changed, 381 insertions(+), 9 deletions(-)

diff --git a/Tools/scripts/report-outdated-for-maintainer.py b/Tools/scripts/report-outdated-for-maintainer.py
index a897a0dab147..9b4269b317bb 100755
--- a/Tools/scripts/report-outdated-for-maintainer.py
+++ b/Tools/scripts/report-outdated-for-maintainer.py
@@ -16,13 +16,16 @@ import json
 import time
 import subprocess
 import concurrent.futures
+import asyncio
 import urllib.request
 import urllib.parse
 import urllib.error
+import shutil
 import ssl
 
 # --- Dependency check ---
 
+# missing python dependencies
 missing_deps = []
 try:
     from tabulate2 import tabulate
@@ -38,6 +41,7 @@ if missing_deps:
     )
     sys.exit(1)
 
+
 # --- Configuration ---
 
 PORTSDIR = os.environ.get("PORTSDIR", "/usr/ports")
@@ -46,6 +50,34 @@ 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
+GIT_PARALLELISM = int(os.environ.get("GIT_PARALLELISM", 32))
+GIT_SINGLE_THRESHOLD = int(os.environ.get("GIT_SINGLE_THRESHOLD", 500))
+
+USAGE = f"""Usage: {os.path.basename(sys.argv[0])} [--disable-last-updated] <maintainer-email>
+       {os.path.basename(sys.argv[0])} --help
+
+Report outdated FreeBSD ports for a maintainer using Repology and GitHub.
+
+Options:
+  --disable-last-updated  Skip git history queries and omit the 'Last Updated' column.
+  --help, -h              Show this help message and exit.
+
+Example:
+  {os.path.basename(sys.argv[0])} yuri@FreeBSD.org
+"""
+
+STALENESS_THRESHOLDS = [
+    ("1 week", 7 * 24 * 3600),
+    ("2 weeks", 14 * 24 * 3600),
+    ("1 month", 30 * 24 * 3600),
+    ("2 months", 60 * 24 * 3600),
+    ("3 months", 90 * 24 * 3600),
+    ("4 months", 120 * 24 * 3600),
+    ("5 months", 150 * 24 * 3600),
+    ("6 months", 180 * 24 * 3600),
+    ("12 months", 365 * 24 * 3600),
+    ("24 months", 730 * 24 * 3600),
+]
 
 # USES keyword → Port Type label (in priority order)
 USES_TYPES = [
@@ -94,6 +126,49 @@ def check_portsdir():
             sys.exit(1)
 
 
+def check_required_commands(need_git=True):
+    """Check that required external commands are available."""
+    missing = []
+    if not shutil.which("gh"):
+        missing.append("gh")
+    if need_git and not shutil.which("git"):
+        missing.append("git")
+    if missing:
+        print(
+            f"Missing required commands: {', '.join(missing)}. "
+            "Please install them and try again."
+        )
+        sys.exit(1)
+
+
+def parse_args():
+    """Parse command-line arguments and return (maintainer_email, disable_last_updated)."""
+    args = sys.argv[1:]
+    if "--help" in args or "-h" in args:
+        print(USAGE)
+        sys.exit(0)
+
+    disable_last_updated = False
+    maintainer_email = None
+    for arg in args:
+        if arg == "--disable-last-updated":
+            disable_last_updated = True
+        elif arg.startswith("-"):
+            print(f"Unknown option: {arg}\n{USAGE}", file=sys.stderr)
+            sys.exit(1)
+        elif maintainer_email is None:
+            maintainer_email = arg
+        else:
+            print(USAGE, file=sys.stderr)
+            sys.exit(1)
+
+    if not maintainer_email:
+        print(USAGE, file=sys.stderr)
+        sys.exit(1)
+
+    return maintainer_email, disable_last_updated
+
+
 def get_github_token():
     """Return a GitHub token from env or gh auth, if available."""
     global GITHUB_TOKEN
@@ -409,6 +484,205 @@ def find_maintained_ports(maintainer_email):
     return versions, uses_types, github_repos, github_version_prefixes
 
 
+# --- Git last-update queries ---
+
+async def _find_last_updates_per_origin(origins):
+    """Per-origin streaming git log, killing each process once a bump is found."""
+    if not origins:
+        return {}
+
+    bump_re = re.compile(r"^(\+PORTVERSION=|\+DISTVERSION=)")
+    semaphore = asyncio.Semaphore(GIT_PARALLELISM)
+
+    async def _run_for_origin(origin):
+        async with semaphore:
+            makefile_rel = f"{origin}/Makefile"
+            args = [
+                "-C",
+                PORTSDIR,
+                "--no-optional-locks",
+                "log",
+                "-p",
+                "-m",
+                "--pretty=format:COMMIT:%H %ct",
+                "--",
+                makefile_rel,
+            ]
+            try:
+                proc = await asyncio.create_subprocess_exec(
+                    "git",
+                    *args,
+                    stdout=asyncio.subprocess.PIPE,
+                    stderr=asyncio.subprocess.PIPE,
+                )
+            except Exception:
+                return origin, None
+
+            current_time = None
+            timestamp = None
+
+            while True:
+                try:
+                    line_b = await asyncio.wait_for(proc.stdout.readline(), timeout=60)
+                except asyncio.TimeoutError:
+                    break
+                if not line_b:
+                    break
+                line = line_b.decode(errors="replace").rstrip("\n")
+
+                if line.startswith("COMMIT:"):
+                    parts = line[len("COMMIT:"):].split()
+                    current_time = parts[1] if len(parts) >= 2 else None
+                elif current_time and bump_re.match(line):
+                    try:
+                        timestamp = int(current_time)
+                    except ValueError:
+                        pass
+                    break
+
+            try:
+                if proc.returncode is None:
+                    proc.kill()
+                    await proc.wait()
+            except Exception:
+                pass
+
+            return origin, timestamp
+
+    tasks = [_run_for_origin(origin) for origin in origins]
+    results = await asyncio.gather(*tasks)
+
+    last_updates = {}
+    for origin, timestamp in results:
+        if timestamp is not None:
+            last_updates[origin] = timestamp
+    return last_updates
+
+
+async def _find_last_updates_single_log(origins):
+    """One repository-wide git log -G parse, with per-origin fallback."""
+    if not origins:
+        return {}
+
+    target_files = {f"{origin}/Makefile" for origin in origins}
+    bump_re = re.compile(r"^(\+PORTVERSION=|\+DISTVERSION=)")
+
+    args = [
+        "-C",
+        PORTSDIR,
+        "--no-optional-locks",
+        "log",
+        "-p",
+        "-m",
+        "-G",
+        "^PORTVERSION=|^DISTVERSION=",
+        "--pretty=format:COMMIT:%H %ct",
+        "--",
+        "*/Makefile",
+    ]
+
+    last_updates = {}
+    current_commit = None
+    current_time = None
+    current_file = None
+    current_has_bump = False
+
+    try:
+        proc = await asyncio.create_subprocess_exec(
+            "git",
+            *args,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+    except Exception:
+        return {}
+
+    def _flush():
+        nonlocal current_has_bump
+        if (
+            current_file
+            and current_commit
+            and current_time
+            and current_has_bump
+            and current_file in target_files
+            and current_file not in last_updates
+        ):
+            try:
+                last_updates[current_file] = int(current_time)
+            except ValueError:
+                pass
+        current_has_bump = False
+
+    while True:
+        try:
+            line_b = await asyncio.wait_for(proc.stdout.readline(), timeout=600)
+        except asyncio.TimeoutError:
+            break
+        if not line_b:
+            break
+        line = line_b.decode(errors="replace").rstrip("\n")
+
+        if line.startswith("COMMIT:"):
+            _flush()
+            current_file = None
+            parts = line[len("COMMIT:"):].split()
+            current_commit, current_time = (
+                (parts[0], parts[1]) if len(parts) >= 2 else (None, None)
+            )
+        elif line.startswith("diff --git"):
+            _flush()
+            match = re.match(r"^diff --git a/(.+) b/(.+)$", line)
+            current_file = match.group(2) if match else None
+        elif current_commit and current_file and bump_re.match(line):
+            current_has_bump = True
+
+    _flush()
+
+    try:
+        if proc.returncode is None:
+            proc.kill()
+            await proc.wait()
+    except Exception:
+        pass
+
+    # Fallback for renamed/moved Makefiles that the single -G log does not catch.
+    missing = [origin for origin in origins if f"{origin}/Makefile" not in last_updates]
+    if missing:
+        fallback = await _find_last_updates_per_origin(missing)
+        last_updates.update({f"{origin}/Makefile": ts for origin, ts in fallback.items()})
+
+    return {path[:-len("/Makefile")]: ts for path, ts in last_updates.items()}
+
+
+async def _find_last_updates(origins):
+    """Return {origin: timestamp} for the last version-bumping commit of each origin.
+
+    For large maintainer lists, use a single repository-wide ``git log -G``
+    query; for small lists, use per-port streaming git logs.  A per-origin
+    fallback handles renamed/moved Makefiles that the single log may miss.
+    """
+    if len(origins) > GIT_SINGLE_THRESHOLD:
+        return await _find_last_updates_single_log(origins)
+    return await _find_last_updates_per_origin(origins)
+
+
+def format_last_updated(timestamp):
+    """Format a Unix timestamp as minutes, hours, or days since that time."""
+    if timestamp is None:
+        return ""
+    now = time.time()
+    elapsed_seconds = now - timestamp
+    if elapsed_seconds < 3600:
+        minutes = int(elapsed_seconds / 60)
+        return f"{minutes} minute{'s' if minutes != 1 else ''}"
+    elapsed_hours = elapsed_seconds / 3600
+    if elapsed_hours < 24:
+        hours = int(elapsed_hours)
+        return f"{hours} hour{'s' if hours != 1 else ''}"
+    days = int(elapsed_hours / 24)
+    return f"{days} day{'s' if days != 1 else ''}"
+
+
 # --- Repology API ---
 
 def repology_request(url):
@@ -584,7 +858,15 @@ def choose_latest_version(repology_real, github_real):
     }
 
 
-def print_report(maintainer_email, local_versions, local_uses, outdated_repology, github_latest):
+def print_report(
+    maintainer_email,
+    local_versions,
+    local_uses,
+    outdated_repology,
+    github_latest,
+    last_updates,
+    disable_last_updated=False,
+):
     total_ports = len(local_versions)
 
     headers = [
@@ -595,10 +877,14 @@ def print_report(maintainer_email, local_versions, local_uses, outdated_repology
         "Port Type",
         "Notes",
     ]
+    if not disable_last_updated:
+        headers.insert(5, "Last Updated")
 
     major_ports = []
     minor_ports = []
     patch_ports = []
+    outdated_origins = []
+    stale_port_data = []  # (origin, outdatedness_level, last_update_timestamp)
 
     for origin, local_real in local_versions.items():
         latest = choose_latest_version(outdated_repology.get(origin), github_latest.get(origin))
@@ -615,14 +901,22 @@ def print_report(maintainer_email, local_versions, local_uses, outdated_repology
         category, level = result
 
         port_type = local_uses.get(origin, "")
-        row = (
+        row = [
             origin,
             display_version(latest["real"], latest["aux"]),
             display_version(local_real, local_aux),
             level,
             port_type,
             latest["notes"],
-        )
+        ]
+        if not disable_last_updated:
+            row.insert(5, format_last_updated(last_updates.get(origin)))
+        row = tuple(row)
+
+        if origin in last_updates:
+            stale_port_data.append((origin, level, last_updates[origin]))
+
+        outdated_origins.append(origin)
         if category == "major":
             major_ports.append(row)
         elif category == "minor":
@@ -671,15 +965,78 @@ def print_report(maintainer_email, local_versions, local_uses, outdated_repology
             print("No ports in this category")
         print()
 
+    if not disable_last_updated and outdated_origins:
+        now = time.time()
+        stale_counts = []
+        raw_counts = []
+        stale_pct = (lambda n: f"{n / total_ports * 100:.1f}%") if total_ports > 0 else (lambda n: "0.0%")
+        for label, seconds in STALENESS_THRESHOLDS:
+            count = sum(
+                1
+                for origin in outdated_origins
+                if origin in last_updates and (now - last_updates[origin]) >= seconds
+            )
+            stale_counts.append((label, f"{count} ({stale_pct(count)})"))
+            raw_counts.append(count)
+
+        print(f"{SEP}Staleness Analysis{SEP}")
+        print()
+        if any(raw_counts):
+            print(format_table_with_blank_header_line(stale_counts, ["Stale for Longer Than", "Count"]))
+        else:
+            print("No stale ports with last update information available.")
+        print()
+
+    if not disable_last_updated and stale_port_data:
+        now = time.time()
+
+        # Non-overlapping staleness buckets in chronological order.
+        bucket_intervals = [
+            (f"< {STALENESS_THRESHOLDS[0][0]}", 0, STALENESS_THRESHOLDS[0][1]),
+        ]
+        for i in range(len(STALENESS_THRESHOLDS) - 1):
+            label = f"{STALENESS_THRESHOLDS[i][0]} .. {STALENESS_THRESHOLDS[i + 1][0]}"
+            bucket_intervals.append(
+                (label, STALENESS_THRESHOLDS[i][1], STALENESS_THRESHOLDS[i + 1][1])
+            )
+        bucket_intervals.append((f"> {STALENESS_THRESHOLDS[-1][0]}", STALENESS_THRESHOLDS[-1][1], sys.maxsize))
+
+        buckets_rows = []
+        for period, lower, upper in bucket_intervals:
+            bucket_ports = [
+                (origin, level, timestamp)
+                for origin, level, timestamp in stale_port_data
+                if (age := now - timestamp) >= lower and age < upper
+            ]
+            if not bucket_ports:
+                continue
+            bucket_ports.sort(key=lambda x: (-x[2], x[0]))
+            count = len(bucket_ports)
+            for idx, (origin, level, timestamp) in enumerate(bucket_ports):
+                last_updated_str = format_last_updated(timestamp)
+                if idx == 0:
+                    buckets_rows.append([period, count, origin, last_updated_str])
+                else:
+                    buckets_rows.append(["", "", origin, last_updated_str])
+
+        print(f"{SEP}Ports by Time of Staleness{SEP}")
+        print()
+        if buckets_rows:
+            print(format_table_with_blank_header_line(
+                buckets_rows,
+                ["Time Period", "Count", "Port origin", "Last Updated"],
+            ))
+        else:
+            print("No stale ports with last update information available.")
+        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]
+    maintainer_email, disable_last_updated = parse_args()
+    check_required_commands(need_git=not disable_last_updated)
     check_portsdir()
 
     print(f"Scanning {PORTSDIR} for ports maintained by {maintainer_email}...", file=sys.stderr)
@@ -698,7 +1055,22 @@ def main():
     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)
+    last_updates = {}
+    if not disable_last_updated:
+        print("Querying git history for last port updates...", file=sys.stderr)
+        if local_versions:
+            last_updates = asyncio.run(_find_last_updates(local_versions.keys()))
+        print(f"Found last update information for {len(last_updates)} ports.", file=sys.stderr)
+
+    print_report(
+        maintainer_email,
+        local_versions,
+        local_uses,
+        outdated_repology,
+        github_latest,
+        last_updates,
+        disable_last_updated=disable_last_updated,
+    )
 
 
 if __name__ == "__main__":