Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/freebsd-ports
Path: blob/main/Tools/scripts/report-outdated-for-maintainer.py
46584 views
1
#!/usr/bin/env python3
2
"""Report outdated FreeBSD ports for a maintainer using Repology and GitHub."""
3
4
# report-outdated-for-maintainer.py - a script that find outdated ports for
5
# a given maintainer based on Repology API and GitHub releases, and prints
6
# a categorized report.
7
#
8
# MAINTAINER= [email protected]
9
#
10
11
12
import sys
13
import os
14
import re
15
import json
16
import time
17
import subprocess
18
import concurrent.futures
19
import urllib.request
20
import urllib.parse
21
import urllib.error
22
import ssl
23
24
# --- Dependency check ---
25
26
missing_deps = []
27
try:
28
from tabulate2 import tabulate
29
except ImportError:
30
missing_deps.append("tabulate2")
31
32
if missing_deps:
33
py_ver = f"py{sys.version_info.major}{sys.version_info.minor}"
34
pkg_names = " ".join(f"{py_ver}-{dep}" for dep in missing_deps)
35
print(
36
f"Missing dependencies: {', '.join(missing_deps)}. "
37
f"Please install them with 'pkg install {pkg_names}' and try again."
38
)
39
sys.exit(1)
40
41
# --- Configuration ---
42
43
PORTSDIR = os.environ.get("PORTSDIR", "/usr/ports")
44
REPOLOGY_API_BASE = "https://repology.org/api/v1"
45
GITHUB_BASE = "https://github.com"
46
GITHUB_API_BASE = "https://api.github.com"
47
USER_AGENT = "report-outdated-for-maintainer/1.0 (FreeBSD ports maintainer tool)"
48
GITHUB_TOKEN = None
49
50
# USES keyword → Port Type label (in priority order)
51
USES_TYPES = [
52
("gmake", "gmake"),
53
("cmake", "cmake"),
54
("meson", "meson"),
55
("waf", "waf"),
56
("python", "Python"),
57
("cargo", "Rust"),
58
("cabal", "Haskel"),
59
("go", "GoLang"),
60
("nodejs", "NodeJS"),
61
("fortran", "Fortran"),
62
("drupal", "Drupal"),
63
("cran", "R"),
64
("ocaml", "Ocaml"),
65
("php", "php"),
66
("vala", "Vala"),
67
("ruby", "Ruby"),
68
("perl5", "Perl"),
69
("mono", "Mono"),
70
("ada", "Ada"),
71
("lazarus", "Lazarus"),
72
("erlang", "Erlang"),
73
("electron", "Electron"),
74
("linux", "Linux"),
75
("kmod", "kmod"),
76
("java", "Java"),
77
]
78
79
80
# --- Validation ---
81
82
def check_portsdir():
83
required = [
84
PORTSDIR,
85
os.path.join(PORTSDIR, "Makefile"),
86
os.path.join(PORTSDIR, "Mk"),
87
os.path.join(PORTSDIR, "MOVED"),
88
os.path.join(PORTSDIR, "devel"),
89
os.path.join(PORTSDIR, "devel", "Makefile"),
90
]
91
for path in required:
92
if not os.path.exists(path):
93
print(f"Error: Required path not found: {path}", file=sys.stderr)
94
sys.exit(1)
95
96
97
def get_github_token():
98
"""Return a GitHub token from env or gh auth, if available."""
99
global GITHUB_TOKEN
100
if GITHUB_TOKEN is not None:
101
return GITHUB_TOKEN
102
103
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
104
if GITHUB_TOKEN:
105
return GITHUB_TOKEN
106
107
try:
108
result = subprocess.run(
109
["gh", "auth", "token"],
110
capture_output=True,
111
text=True,
112
timeout=10,
113
)
114
token = result.stdout.strip()
115
if result.returncode == 0 and token:
116
GITHUB_TOKEN = token
117
return GITHUB_TOKEN
118
except Exception:
119
pass
120
121
GITHUB_TOKEN = ""
122
return None
123
124
125
# --- Ports tree parsing ---
126
127
def get_subdir_entries(makefile_path):
128
"""Return list of SUBDIR += entries from a Makefile."""
129
entries = []
130
try:
131
with open(makefile_path, "r", errors="replace") as f:
132
content = f.read()
133
for m in re.finditer(r"^\s*SUBDIR\s*\+=\s*(\S+)", content, re.MULTILINE):
134
entries.append(m.group(1))
135
except OSError:
136
pass
137
return entries
138
139
140
def read_makefile_logical_lines(makefile_path):
141
"""Read a Makefile and return a list of logical lines (continuations joined)."""
142
try:
143
with open(makefile_path, "r", errors="replace") as f:
144
content = f.read()
145
except OSError:
146
return []
147
# Join continuation lines
148
content = re.sub(r"\\\n", " ", content)
149
return content.splitlines()
150
151
152
def get_makefile_var(makefile_path, var):
153
"""Extract a simple variable value from a Makefile (first assignment found)."""
154
for line in read_makefile_logical_lines(makefile_path):
155
m = re.match(rf"^\s*{re.escape(var)}\s*(?::|\?)?=\s*(.+)$", line)
156
if m:
157
return re.sub(r"\s+#.*$", "", m.group(1)).strip()
158
return None
159
160
161
def get_uses_types(makefile_path):
162
"""Return a comma-separated string of port types based on USES= in the Makefile."""
163
uses_tokens = set()
164
for line in read_makefile_logical_lines(makefile_path):
165
# Match both USES= and USES+=
166
m = re.match(r"^\s*USES\s*\+?=\s*(.+)$", line)
167
if m:
168
for token in m.group(1).split():
169
# Strip :options suffix for matching
170
uses_tokens.add(token.split(":")[0])
171
172
types = []
173
for keyword, label in USES_TYPES:
174
if keyword in uses_tokens:
175
types.append(label)
176
return ", ".join(types)
177
178
179
def resolve_make_value(value, portname):
180
"""Resolve simple ${PORTNAME} substitutions in Makefile values."""
181
if value is None:
182
return None
183
184
def replacer(match):
185
modifier = match.group(1)
186
if modifier == ":tl":
187
return portname.lower()
188
if modifier == ":tu":
189
return portname.upper()
190
return portname
191
192
return re.sub(r"\$\{PORTNAME(?:(:tl|:tu))?\}", replacer, value)
193
194
195
def get_github_repo(makefile_path):
196
"""Return (account, project) for USE_GITHUB=yes ports, else None."""
197
use_github = get_makefile_var(makefile_path, "USE_GITHUB")
198
if not use_github or use_github.lower() != "yes":
199
return None
200
201
portname = get_makefile_var(makefile_path, "PORTNAME")
202
if not portname:
203
return None
204
205
gh_account = get_makefile_var(makefile_path, "GH_ACCOUNT") or portname
206
gh_project = get_makefile_var(makefile_path, "GH_PROJECT") or portname
207
gh_account = resolve_make_value(gh_account.split()[0], portname)
208
gh_project = resolve_make_value(gh_project.split()[0], portname)
209
return gh_account, gh_project
210
211
212
# --- Version handling ---
213
214
VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
215
G_VERSION_RE = re.compile(r"^g[a-zA-Z0-9]+$")
216
YYYYMMDD_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})$")
217
218
219
def looks_like_version(s):
220
return bool(s and VERSION_RE.match(s) and any(ch.isdigit() for ch in s))
221
222
223
def compute_aux_version(real_version, latest_effective=None):
224
"""
225
Return the auxiliary version string if it differs from real_version, else None.
226
227
- gNNNNN → 0.0.0... (matching component count of latest_effective)
228
- YYYYMMDD → YYYY.MM.DD
229
"""
230
if G_VERSION_RE.match(real_version) and latest_effective is not None:
231
parts = parse_version(latest_effective)
232
n = max(len(parts), 1)
233
return ".".join(["0"] * n)
234
235
m = YYYYMMDD_RE.match(real_version)
236
if m:
237
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
238
if 1900 <= year <= 2099 and 1 <= month <= 12 and 1 <= day <= 31:
239
return f"{m.group(1)}.{m.group(2)}.{m.group(3)}"
240
241
return None
242
243
244
def display_version(real, aux):
245
"""Return 'real (aux)' if aux differs from real, else just 'real'."""
246
if aux and aux != real:
247
return f"{real} ({aux})"
248
return real
249
250
251
def parse_version(version_str):
252
"""Parse version string into a tuple of ints."""
253
parts = [int(part) for part in re.findall(r"\d+", version_str)]
254
return tuple(parts) if parts else (0,)
255
256
257
def compare_versions(left_version, right_version):
258
"""Return 1 if left>right, -1 if left<right, 0 if equal by numeric components."""
259
left = parse_version(left_version)
260
right = parse_version(right_version)
261
max_len = max(len(left), len(right), 3)
262
left = left + (0,) * (max_len - len(left))
263
right = right + (0,) * (max_len - len(right))
264
if left > right:
265
return 1
266
if left < right:
267
return -1
268
return 0
269
270
271
def categorize_outdatedness(current_effective, latest_effective):
272
"""
273
Compare effective (auxiliary) versions.
274
Returns (category, level) or None if not outdated.
275
"""
276
if compare_versions(latest_effective, current_effective) <= 0:
277
return None
278
279
cur = parse_version(current_effective)
280
lat = parse_version(latest_effective)
281
max_len = max(len(cur), len(lat), 3)
282
cur = cur + (0,) * (max_len - len(cur))
283
lat = lat + (0,) * (max_len - len(lat))
284
285
if lat[0] > cur[0]:
286
return ("major", lat[0] - cur[0])
287
288
if lat[1] > cur[1]:
289
return ("minor", lat[1] - cur[1])
290
291
for i in range(2, max_len):
292
if lat[i] > cur[i]:
293
return ("patch", lat[i] - cur[i])
294
if lat[i] < cur[i]:
295
break
296
297
return ("patch", 1)
298
299
300
# --- Ports scanning ---
301
302
def get_port_version_from_makefile(makefile_path):
303
"""Try PORTVERSION then DISTVERSION; return value if it looks like a version."""
304
for var in ("PORTVERSION", "DISTVERSION"):
305
val = get_makefile_var(makefile_path, var)
306
if val and looks_like_version(val):
307
return val
308
return None
309
310
311
def get_port_version_via_make(port_dir):
312
"""Run 'make -V PORTVERSION' in port_dir and return the result."""
313
try:
314
result = subprocess.run(
315
["make", "BATCH=yes", "-V", "PORTVERSION"],
316
cwd=port_dir,
317
capture_output=True,
318
text=True,
319
timeout=30,
320
)
321
v = result.stdout.strip()
322
if looks_like_version(v):
323
return v
324
except Exception:
325
pass
326
return None
327
328
329
def find_maintained_ports(maintainer_email):
330
"""
331
Return (versions, uses_types, github_repos) keyed by port origin.
332
versions[origin] = real version string
333
uses_types[origin] = comma-separated port type string
334
github_repos[origin] = (account, project) for USE_GITHUB ports
335
"""
336
versions = {}
337
uses_types = {}
338
github_repos = {}
339
top_makefile = os.path.join(PORTSDIR, "Makefile")
340
categories = get_subdir_entries(top_makefile)
341
342
for category in categories:
343
cat_makefile = os.path.join(PORTSDIR, category, "Makefile")
344
if not os.path.isfile(cat_makefile):
345
continue
346
port_dirs = get_subdir_entries(cat_makefile)
347
for port_dir in port_dirs:
348
port_path = os.path.join(PORTSDIR, category, port_dir)
349
port_makefile = os.path.join(port_path, "Makefile")
350
if not os.path.isfile(port_makefile):
351
continue
352
maintainer = get_makefile_var(port_makefile, "MAINTAINER")
353
if not maintainer or maintainer.lower() != maintainer_email.lower():
354
continue
355
origin = f"{category}/{port_dir}"
356
version = get_port_version_from_makefile(port_makefile)
357
if version is None:
358
version = get_port_version_via_make(port_path)
359
if version is None:
360
print(
361
f"Warning: Could not determine version for {origin}, skipping.",
362
file=sys.stderr,
363
)
364
continue
365
versions[origin] = version
366
uses_types[origin] = get_uses_types(port_makefile)
367
github_repo = get_github_repo(port_makefile)
368
if github_repo:
369
github_repos[origin] = github_repo
370
371
return versions, uses_types, github_repos
372
373
374
# --- Repology API ---
375
376
def repology_request(url):
377
"""Make a single GET request to the Repology API; return parsed JSON."""
378
ctx = ssl.create_default_context()
379
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
380
try:
381
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
382
return json.loads(resp.read())
383
except urllib.error.HTTPError as e:
384
print(f"Error: Repology API returned HTTP {e.code}: {e.reason}", file=sys.stderr)
385
sys.exit(1)
386
except urllib.error.URLError as e:
387
print(f"Error: Could not reach Repology API: {e.reason}", file=sys.stderr)
388
sys.exit(1)
389
except Exception as e:
390
print(f"Error querying Repology: {e}", file=sys.stderr)
391
sys.exit(1)
392
393
394
def query_repology_outdated(maintainer_email):
395
"""
396
Return dict {origin: latest_version} for FreeBSD ports that Repology
397
considers outdated for this maintainer.
398
"""
399
encoded_email = urllib.parse.quote(maintainer_email.lower(), safe="@")
400
base_params = f"maintainer={encoded_email}&inrepo=freebsd&outdated=true"
401
url = f"{REPOLOGY_API_BASE}/projects/?{base_params}"
402
outdated = {}
403
404
while url:
405
data = repology_request(url)
406
if not data:
407
break
408
409
for project_name, packages in data.items():
410
freebsd_outdated = None
411
newest_version = None
412
413
for pkg in packages:
414
if pkg.get("repo") == "freebsd" and pkg.get("status") == "outdated":
415
freebsd_outdated = pkg
416
if pkg.get("status") == "newest" and newest_version is None:
417
newest_version = pkg.get("version")
418
419
if freebsd_outdated and newest_version:
420
srcname = freebsd_outdated.get("srcname") or freebsd_outdated.get("visiblename")
421
if srcname:
422
outdated[srcname] = newest_version
423
424
if len(data) == 200:
425
last_project = list(data.keys())[-1]
426
encoded_last = urllib.parse.quote(last_project, safe="")
427
url = f"{REPOLOGY_API_BASE}/projects/{encoded_last}/?{base_params}"
428
time.sleep(0.5) # be polite to the API
429
else:
430
url = None
431
432
return outdated
433
434
435
def github_request(url, method="GET"):
436
"""Make a single GitHub request and return the response object."""
437
headers = {
438
"User-Agent": USER_AGENT,
439
"Accept": "application/vnd.github+json",
440
}
441
token = get_github_token()
442
if token:
443
headers["Authorization"] = f"Bearer {token}"
444
req = urllib.request.Request(url, headers=headers, method=method)
445
return urllib.request.urlopen(req, timeout=30)
446
447
448
def query_github_latest_release(account, project, cache):
449
"""Return the latest stable GitHub release tag for account/project, or None."""
450
key = (account, project)
451
if key in cache:
452
return cache[key]
453
454
quoted_account = urllib.parse.quote(account, safe="")
455
quoted_project = urllib.parse.quote(project, safe="")
456
url = f"{GITHUB_API_BASE}/repos/{quoted_account}/{quoted_project}/releases/latest"
457
458
try:
459
with github_request(url) as resp:
460
data = json.load(resp)
461
except urllib.error.HTTPError as e:
462
if e.code != 404:
463
print(
464
f"Warning: Could not query GitHub latest release for {account}/{project}: HTTP {e.code}",
465
file=sys.stderr,
466
)
467
cache[key] = None
468
return None
469
except urllib.error.URLError as e:
470
print(
471
f"Warning: Could not query GitHub latest release for {account}/{project}: {e.reason}",
472
file=sys.stderr,
473
)
474
cache[key] = None
475
return None
476
except Exception as e:
477
print(
478
f"Warning: Could not query GitHub latest release for {account}/{project}: {e}",
479
file=sys.stderr,
480
)
481
cache[key] = None
482
return None
483
484
tag = (data.get("tag_name") or "").strip()
485
cache[key] = tag.strip() or None
486
return cache[key]
487
488
489
def query_github_releases(github_repos):
490
"""Return dict {origin: latest_release_tag} for GitHub-based ports with releases."""
491
cache = {}
492
latest_releases = {}
493
494
unique_repos = sorted(set(github_repos.values()))
495
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
496
future_map = {
497
executor.submit(query_github_latest_release, account, project, cache): (account, project)
498
for account, project in unique_repos
499
}
500
for future in concurrent.futures.as_completed(future_map):
501
future.result()
502
503
for origin, repo in github_repos.items():
504
latest = cache.get(repo)
505
if latest:
506
latest_releases[origin] = latest
507
return latest_releases
508
509
510
# --- Report formatting ---
511
512
SEP = "============="
513
514
515
def format_table_with_blank_header_line(rows, headers):
516
"""Render tabulate 'simple' table with a blank line inserted after the header separator."""
517
raw = tabulate(rows, headers=headers, tablefmt="simple")
518
lines = raw.splitlines()
519
# simple format: line 0 = header names, line 1 = dashes, line 2+ = data
520
if len(lines) >= 2:
521
lines.insert(2, "")
522
return "\n".join(lines)
523
524
525
def choose_latest_version(repology_real, github_real):
526
"""Choose the highest latest version from Repology/GitHub and return display info."""
527
candidates = []
528
if repology_real:
529
repology_aux = compute_aux_version(repology_real)
530
repology_effective = repology_aux if repology_aux else repology_real
531
candidates.append(("Repology", repology_real, repology_aux, repology_effective))
532
if github_real:
533
github_aux = compute_aux_version(github_real)
534
github_effective = github_aux if github_aux else github_real
535
candidates.append(("GitHub", github_real, github_aux, github_effective))
536
537
if not candidates:
538
return None
539
540
best = candidates[0]
541
for candidate in candidates[1:]:
542
cmp = compare_versions(candidate[3], best[3])
543
if cmp > 0 or (cmp == 0 and candidate[0] == "Repology" and best[0] != "Repology"):
544
best = candidate
545
546
notes = [candidate[0] for candidate in candidates if compare_versions(candidate[3], best[3]) == 0]
547
return {
548
"real": best[1],
549
"aux": best[2],
550
"effective": best[3],
551
"notes": ", ".join(notes),
552
}
553
554
555
def print_report(maintainer_email, local_versions, local_uses, outdated_repology, github_latest):
556
total_ports = len(local_versions)
557
558
headers = [
559
"Port origin",
560
"Latest released version",
561
"Current port version",
562
"Outdatedness level",
563
"Port Type",
564
"Notes",
565
]
566
567
major_ports = []
568
minor_ports = []
569
patch_ports = []
570
571
for origin, local_real in local_versions.items():
572
latest = choose_latest_version(outdated_repology.get(origin), github_latest.get(origin))
573
if latest is None:
574
continue
575
576
# Compute auxiliary version for local (gNNNNN needs latest_effective for depth)
577
local_aux = compute_aux_version(local_real, latest["effective"])
578
local_effective = local_aux if local_aux else local_real
579
580
result = categorize_outdatedness(local_effective, latest["effective"])
581
if result is None:
582
continue
583
category, level = result
584
585
port_type = local_uses.get(origin, "")
586
row = (
587
origin,
588
display_version(latest["real"], latest["aux"]),
589
display_version(local_real, local_aux),
590
level,
591
port_type,
592
latest["notes"],
593
)
594
if category == "major":
595
major_ports.append(row)
596
elif category == "minor":
597
minor_ports.append(row)
598
else:
599
patch_ports.append(row)
600
601
def sort_key(row):
602
return (-row[3], row[0])
603
604
major_ports.sort(key=sort_key)
605
minor_ports.sort(key=sort_key)
606
patch_ports.sort(key=sort_key)
607
608
n_major = len(major_ports)
609
n_minor = len(minor_ports)
610
n_patch = len(patch_ports)
611
n_outdated = n_major + n_minor + n_patch
612
613
# Title
614
print(f"Outdated ports for maintainer {maintainer_email}")
615
print()
616
print()
617
618
# Summary line
619
pct = (lambda n: f"{n / total_ports * 100:.2f}%") if total_ports > 0 else (lambda n: "0.00%")
620
621
print(
622
f"The maintainer has {n_outdated} ({pct(n_outdated)}) outdated ports, categorized as follows: "
623
f"{n_major} ({pct(n_major)}) majorly outdated, "
624
f"{n_minor} ({pct(n_minor)}) moderately outdated, "
625
f"and {n_patch} ({pct(n_patch)}) slightly outdated."
626
)
627
print()
628
629
for title, rows in [
630
("Majorly outdated ports", major_ports),
631
("Moderately outdated ports", minor_ports),
632
("Slightly outdated ports", patch_ports),
633
]:
634
print(f"{SEP}{title}{SEP}")
635
print()
636
if rows:
637
print(format_table_with_blank_header_line(rows, headers))
638
else:
639
print("No ports in this category")
640
print()
641
642
643
# --- Main ---
644
645
def main():
646
if len(sys.argv) != 2:
647
print(f"Usage: {sys.argv[0]} <maintainer-email>", file=sys.stderr)
648
sys.exit(1)
649
650
maintainer_email = sys.argv[1]
651
check_portsdir()
652
653
print(f"Scanning {PORTSDIR} for ports maintained by {maintainer_email}...", file=sys.stderr)
654
local_versions, local_uses, github_repos = find_maintained_ports(maintainer_email)
655
656
if not local_versions:
657
print(f"No ports maintained by {maintainer_email}")
658
sys.exit(0)
659
660
print(
661
f"Found {len(local_versions)} local ports ({len(github_repos)} GitHub-based). Querying Repology and GitHub...",
662
file=sys.stderr,
663
)
664
outdated_repology = query_repology_outdated(maintainer_email)
665
github_latest = query_github_releases(github_repos)
666
print(f"Repology reports {len(outdated_repology)} outdated FreeBSD ports.", file=sys.stderr)
667
print(f"GitHub provides latest releases for {len(github_latest)} GitHub-based ports.", file=sys.stderr)
668
669
print_report(maintainer_email, local_versions, local_uses, outdated_repology, github_latest)
670
671
672
if __name__ == "__main__":
673
main()
674
675