|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +update-readme.py — regenerates dynamic sections in README.md. |
| 4 | +
|
| 5 | +Sections updated: |
| 6 | + <!-- TREE_START --> ... <!-- TREE_END --> project file tree |
| 7 | + <!-- STATS_START --> ... <!-- STATS_END --> package stats table |
| 8 | + <!-- PACKAGES_START --> ... <!-- PACKAGES_END --> full package list |
| 9 | +
|
| 10 | +Run from repo root: |
| 11 | + python3 .github/scripts/update-readme.py |
| 12 | +""" |
| 13 | + |
| 14 | +import re |
| 15 | +import sys |
| 16 | +from pathlib import Path |
| 17 | + |
| 18 | +try: |
| 19 | + import tomllib # Python 3.11+ |
| 20 | +except ImportError: |
| 21 | + try: |
| 22 | + import tomli as tomllib |
| 23 | + except ImportError: |
| 24 | + print("error: need Python 3.11+ or 'pip install tomli'", file=sys.stderr) |
| 25 | + sys.exit(1) |
| 26 | + |
| 27 | +REPODATA = Path("repodata") |
| 28 | +README = Path("README.md") |
| 29 | + |
| 30 | +REPO_PRIO = {"core": 0, "main": 1, "extra": 2} |
| 31 | +REPO_EMOJI = {"core": "🔵", "main": "🟢", "extra": "🟡"} |
| 32 | + |
| 33 | +# ── file-tree generator ─────────────────────────────────────────────────────── |
| 34 | + |
| 35 | +# Directories to skip entirely in the tree |
| 36 | +TREE_SKIP_DIRS = {".git", "__pycache__", "node_modules", ".github"} |
| 37 | + |
| 38 | +# Directories whose *contents* are collapsed to a summary line |
| 39 | +TREE_COLLAPSE_DIRS = {"repodata", "src/apgbuild", "src/apgbuild/libapg"} |
| 40 | + |
| 41 | +# Per-path annotations shown after the entry |
| 42 | +ANNOTATIONS: dict[str, str] = { |
| 43 | + "apger.conf": "# build config (single source of truth)", |
| 44 | + "go.mod": "# Go module", |
| 45 | + "go.sum": "# Go checksums", |
| 46 | + "repodata/": "# package recipes (.toml)", |
| 47 | + "repodata/switch.toml": "# arch-switch helper", |
| 48 | + "examples/": "# example recipe / metadata files", |
| 49 | + "src/Meson.build": "# build system", |
| 50 | + "src/meson_options.txt": "# meson options", |
| 51 | + "k8s-manifest.yml": "# Kubernetes PVC + ConfigMap + Job + Pod", |
| 52 | + "src/cmd/apger/main.go": "# binary entry point", |
| 53 | + "src/core/config.go": "# Config struct, LoadConfig, FindConfig", |
| 54 | + "src/core/main.go": "# Run(), CLI flags, apger.conf wiring", |
| 55 | + "src/core/march.go": "# MArch type: normalization, x86_64 level table", |
| 56 | + "src/core/validate.go": "# OOMKill + march/CPUID pre-flight validation", |
| 57 | + "src/builder/orchestrator.go": "# Kubernetes Job lifecycle, multistage pipeline", |
| 58 | + "src/builder/split.go": "# SplitAnalyzer: libs/bins/dev file grouping", |
| 59 | + "src/builder/templates.go": "# build system templates (meson/cmake/autotools/…)", |
| 60 | + "src/builder/downloader.go": "# (legacy) HTTP downloader", |
| 61 | + "src/downloader/downloader.go": "# aria2c (tarballs) + go-git (git repos) + progress", |
| 62 | + "src/logger/build_logger.go": "# kbuild-style filter: CC/CXX/LD/AS/CARGO/GO/…", |
| 63 | + "src/k8s/generator.go": "# GenerateBuildJob, oomResources, pullPolicy", |
| 64 | + "src/k8s/client.go": "# Kubernetes client wrapper", |
| 65 | + "src/k8s/gen-krnl.go": "# kernel module job generator", |
| 66 | + "src/metadata/types.go": "# Recipe, RecipeSource, RecipeSplit, PackageMeta", |
| 67 | + "src/metadata/recipe_loader.go": "# LoadRecipe (.toml/.json), FindRecipes, template", |
| 68 | + "src/metadata/generator.go": "# GenerateMetadata, checksums, HashRecipe", |
| 69 | + "src/storage/store.go": "# Store interface + DB wrapper", |
| 70 | + "src/storage/packages_db_bbolt.go": "# bbolt backend (build tag: bbolt)", |
| 71 | + "src/storage/packages_db_sqlite.go": "# SQLite3 backend (build tag: sqlite)", |
| 72 | + "src/tui/main.go": "# Model, screens: Dashboard/FM/Editor/Build", |
| 73 | + "src/tui/icons.go": "# Nerd Font icons per build template", |
| 74 | + "src/tui/screen_credentials.go": "# credentials screen", |
| 75 | + "src/tui/screen_settings.go": "# settings screen", |
| 76 | + "src/credentials/manager.go": "# credential store", |
| 77 | + "src/credentials/github_app.go": "# GitHub App token exchange", |
| 78 | + "src/pgp/signer.go": "# PGP package signing", |
| 79 | + "src/publisher/github.go": "# publish .apg to GitHub Releases", |
| 80 | + "src/reporter/build_report.go": "# build report generation", |
| 81 | + "src/apgbuild/": "# APG package archiver (git submodule)", |
| 82 | + "src/apgbuild/libapg/": "# C library for APG format (git submodule)", |
| 83 | +} |
| 84 | + |
| 85 | + |
| 86 | +def _annotation(rel: str) -> str: |
| 87 | + return (" " + ANNOTATIONS[rel]) if rel in ANNOTATIONS else "" |
| 88 | + |
| 89 | + |
| 90 | +def build_tree(root: Path, prefix: str = "", rel_base: str = "") -> list[str]: |
| 91 | + """Recursively build tree lines for *root*, skipping unwanted dirs.""" |
| 92 | + try: |
| 93 | + entries = sorted(root.iterdir(), key=lambda p: (p.is_file(), p.name.lower())) |
| 94 | + except PermissionError: |
| 95 | + return [] |
| 96 | + |
| 97 | + lines = [] |
| 98 | + visible = [ |
| 99 | + e for e in entries |
| 100 | + if not (e.is_dir() and e.name in TREE_SKIP_DIRS) |
| 101 | + ] |
| 102 | + |
| 103 | + for i, entry in enumerate(visible): |
| 104 | + is_last = (i == len(visible) - 1) |
| 105 | + connector = "└── " if is_last else "├── " |
| 106 | + extension = " " if is_last else "│ " |
| 107 | + |
| 108 | + rel = (rel_base + "/" + entry.name).lstrip("/") |
| 109 | + |
| 110 | + if entry.is_dir(): |
| 111 | + rel_dir = rel + "/" |
| 112 | + ann = _annotation(rel_dir) |
| 113 | + |
| 114 | + # collapse certain subtrees |
| 115 | + if any(rel == c or rel.startswith(c.rstrip("/")) for c in TREE_COLLAPSE_DIRS): |
| 116 | + lines.append(f"{prefix}{connector}{entry.name}/{ann}") |
| 117 | + lines.append(f"{prefix}{extension}└── ...") |
| 118 | + continue |
| 119 | + |
| 120 | + lines.append(f"{prefix}{connector}{entry.name}/{ann}") |
| 121 | + lines.extend(build_tree(entry, prefix + extension, rel)) |
| 122 | + else: |
| 123 | + ann = _annotation(rel) |
| 124 | + lines.append(f"{prefix}{connector}{entry.name}{ann}") |
| 125 | + |
| 126 | + return lines |
| 127 | + |
| 128 | + |
| 129 | +def render_tree() -> str: |
| 130 | + root = Path(".") |
| 131 | + lines = ["```", "apger/"] |
| 132 | + lines.extend(build_tree(root)) |
| 133 | + lines.append("```") |
| 134 | + return "\n".join(lines) + "\n" |
| 135 | + |
| 136 | + |
| 137 | +# ── package collector ───────────────────────────────────────────────────────── |
| 138 | + |
| 139 | +def collect() -> list[dict]: |
| 140 | + entries = [] |
| 141 | + seen: set[str] = set() |
| 142 | + |
| 143 | + for toml_path in sorted(REPODATA.rglob("*.toml")): |
| 144 | + parts = toml_path.parts # ('repodata', arch, repo, 'name.toml') |
| 145 | + if len(parts) < 4: |
| 146 | + continue |
| 147 | + |
| 148 | + arch = parts[1] |
| 149 | + repo = parts[2] |
| 150 | + |
| 151 | + try: |
| 152 | + data = tomllib.loads(toml_path.read_text(encoding="utf-8")) |
| 153 | + except Exception as e: |
| 154 | + print(f"warn: cannot parse {toml_path}: {e}", file=sys.stderr) |
| 155 | + continue |
| 156 | + |
| 157 | + pkg = data.get("package", {}) |
| 158 | + build = data.get("build", {}) |
| 159 | + |
| 160 | + name = pkg.get("name", "") |
| 161 | + version = pkg.get("version", "") |
| 162 | + if not name: |
| 163 | + continue |
| 164 | + |
| 165 | + key = f"{name}@{version}" |
| 166 | + if key in seen: |
| 167 | + continue |
| 168 | + seen.add(key) |
| 169 | + |
| 170 | + entries.append({ |
| 171 | + "name": name, |
| 172 | + "version": version, |
| 173 | + "description": pkg.get("description", ""), |
| 174 | + "license": pkg.get("license", ""), |
| 175 | + "homepage": pkg.get("homepage", ""), |
| 176 | + "template": build.get("template", ""), |
| 177 | + "arch": arch, |
| 178 | + "repo": repo, |
| 179 | + }) |
| 180 | + |
| 181 | + entries.sort(key=lambda e: (REPO_PRIO.get(e["repo"], 9), e["name"])) |
| 182 | + return entries |
| 183 | + |
| 184 | + |
| 185 | +# ── section renderers ───────────────────────────────────────────────────────── |
| 186 | + |
| 187 | +def render_stats(entries: list[dict]) -> str: |
| 188 | + total = len(entries) |
| 189 | + by_repo: dict[str, int] = {} |
| 190 | + by_arch: dict[str, int] = {} |
| 191 | + by_template: dict[str, int] = {} |
| 192 | + |
| 193 | + for e in entries: |
| 194 | + by_repo[e["repo"]] = by_repo.get(e["repo"], 0) + 1 |
| 195 | + by_arch[e["arch"]] = by_arch.get(e["arch"], 0) + 1 |
| 196 | + if e["template"]: |
| 197 | + by_template[e["template"]] = by_template.get(e["template"], 0) + 1 |
| 198 | + |
| 199 | + lines = [ |
| 200 | + "| Metric | Value |", |
| 201 | + "|--------|-------|", |
| 202 | + f"| Total packages | **{total}** |", |
| 203 | + f"| 🔵 core | {by_repo.get('core', 0)} |", |
| 204 | + f"| 🟢 main | {by_repo.get('main', 0)} |", |
| 205 | + f"| 🟡 extra | {by_repo.get('extra', 0)} |", |
| 206 | + f"| x86\\_64 | {by_arch.get('x86_64', 0)} |", |
| 207 | + f"| aarch64 | {by_arch.get('aarch64', 0)} |", |
| 208 | + ] |
| 209 | + for tmpl, count in sorted(by_template.items(), key=lambda x: -x[1]): |
| 210 | + lines.append(f"| build: {tmpl} | {count} |") |
| 211 | + |
| 212 | + return "\n".join(lines) + "\n" |
| 213 | + |
| 214 | + |
| 215 | +def render_packages(entries: list[dict]) -> str: |
| 216 | + lines = [ |
| 217 | + "| Package | Version | Repo | Description | License | Build |", |
| 218 | + "|---------|---------|------|-------------|---------|-------|", |
| 219 | + ] |
| 220 | + for e in entries: |
| 221 | + name = f"[{e['name']}]({e['homepage']})" if e["homepage"] else e["name"] |
| 222 | + desc = e["description"] |
| 223 | + if len(desc) > 60: |
| 224 | + desc = desc[:57] + "..." |
| 225 | + emoji = REPO_EMOJI.get(e["repo"], "⚪") |
| 226 | + lines.append( |
| 227 | + f"| {name} | `{e['version']}` | {emoji} {e['repo']} " |
| 228 | + f"| {desc} | {e['license']} | {e['template']} |" |
| 229 | + ) |
| 230 | + return "\n".join(lines) + "\n" |
| 231 | + |
| 232 | + |
| 233 | +# ── README section replacer ─────────────────────────────────────────────────── |
| 234 | + |
| 235 | +def replace_section(content: str, tag: str, body: str) -> str: |
| 236 | + start = f"<!-- {tag}_START -->" |
| 237 | + end = f"<!-- {tag}_END -->" |
| 238 | + pattern = re.compile(re.escape(start) + r"[\s\S]*?" + re.escape(end)) |
| 239 | + replacement = f"{start}\n{body}{end}" |
| 240 | + if pattern.search(content): |
| 241 | + return pattern.sub(replacement, content) |
| 242 | + return content.rstrip("\n") + f"\n\n{replacement}\n" |
| 243 | + |
| 244 | + |
| 245 | +# ── main ────────────────────────────────────────────────────────────────────── |
| 246 | + |
| 247 | +def main() -> None: |
| 248 | + if not README.exists(): |
| 249 | + print("error: README.md not found — run from repo root", file=sys.stderr) |
| 250 | + sys.exit(1) |
| 251 | + |
| 252 | + content = README.read_text(encoding="utf-8") |
| 253 | + |
| 254 | + # 1. file tree |
| 255 | + print("building file tree…") |
| 256 | + content = replace_section(content, "TREE", render_tree()) |
| 257 | + |
| 258 | + # 2. package stats + list |
| 259 | + if REPODATA.is_dir(): |
| 260 | + entries = collect() |
| 261 | + print(f"collected {len(entries)} unique packages") |
| 262 | + content = replace_section(content, "STATS", render_stats(entries)) |
| 263 | + content = replace_section(content, "PACKAGES", render_packages(entries)) |
| 264 | + else: |
| 265 | + print("warn: repodata/ not found, skipping package sections", file=sys.stderr) |
| 266 | + |
| 267 | + README.write_text(content, encoding="utf-8") |
| 268 | + print("✓ README.md updated") |
| 269 | + |
| 270 | + |
| 271 | +if __name__ == "__main__": |
| 272 | + main() |
0 commit comments