#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Scripts.Maintenance * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /maintenance/update_repo_inventory.php * BRIEF: Queries GitHub org repos and rewrites the auto-generated section of REPOSITORY_INVENTORY.md */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use MokoEnterprise\CliFramework; use MokoEnterprise\Config; use MokoEnterprise\GitPlatformAdapter; use MokoEnterprise\PlatformAdapterFactory; /** * Queries the git platform API for all repositories in the organisation and * rewrites the auto-generated inventory table inside * docs/reference/REPOSITORY_INVENTORY.md between the markers: * * * * * Everything outside those markers is left untouched. */ class UpdateRepoInventory extends CliFramework { private ?GitPlatformAdapter $adapter = null; /** Marker that begins the auto-generated block. */ private const MARKER_START = ''; /** Marker that ends the auto-generated block. */ private const MARKER_END = ''; /** Path to the Dolibarr module registry relative to repo root. */ private const REGISTRY_PATH = 'docs/development/crm/module-registry.md'; /** Path to the inventory file relative to repo root. */ private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md'; protected function configure(): void { $this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list'); $this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech'); $this->addArgument('--path', 'Repository root path', '.'); } protected function run(): int { $root = rtrim((string) $this->getArgument('--path'), '/\\'); $config = Config::load(); try { $this->adapter = PlatformAdapterFactory::create($config); } catch (\RuntimeException $e) { $this->status(false, 'auth', $e->getMessage()); return 2; } $orgArg = (string) $this->getArgument('--org'); $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); // ── 1. Fetch repositories ───────────────────────────────────────────── $this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})"); $repos = $this->fetchAllRepos($org); if ($repos === null) { return 1; } $this->status(true, 'API', sprintf('Fetched %d repositories', count($repos))); // ── 2. Load Dolibarr module registry ────────────────────────────────── $this->section('Loading Dolibarr module registry'); $moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH); $this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap))); // ── 3. Build the Markdown tables ────────────────────────────────────── $this->section('Building inventory tables'); $table = $this->buildTables($repos, $moduleMap, $org); // ── 4. Rewrite the inventory file ──────────────────────────────────── $this->section('Updating ' . self::INVENTORY_PATH); $inventoryPath = $root . '/' . self::INVENTORY_PATH; if (!is_file($inventoryPath)) { $this->status(false, self::INVENTORY_PATH, 'file not found'); return 2; } $original = (string) file_get_contents($inventoryPath); $updated = $this->replaceSection($original, $table); if ($original === $updated) { $this->status(true, self::INVENTORY_PATH, 'no changes needed'); } elseif (!$this->isDryRun()) { file_put_contents($inventoryPath, $updated); $this->status(true, self::INVENTORY_PATH, 'updated'); } else { $this->status(true, self::INVENTORY_PATH, '[dry-run] would update'); } $this->printSummary(1, 0, $this->elapsed()); return 0; } // ── Platform API ────────────────────────────────────────────────────────── /** * Fetch all repositories for the org via the platform adapter. * * @return list>|null Null on API error. */ private function fetchAllRepos(string $org): ?array { try { // Use the adapter's paginated listing — returns full repo objects $repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']); $this->progress(count($repos), count($repos), '', true); return $repos; } catch (\Exception $e) { $this->status(false, 'API', $e->getMessage()); return null; } } // ── Module registry ─────────────────────────────────────────────────────── /** * Parse the Dolibarr module registry Markdown table. * * @return array Map of lower-case repo name → module number. */ private function parseModuleRegistry(string $path): array { if (!is_file($path)) { $this->warning("Module registry not found: {$path}"); return []; } $map = []; $content = (string) file_get_contents($path); // Match table rows: | ModuleName | 185051 | Status | … | preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $id = (int) $match[2]; if ($id >= 100000) { $map[strtolower($match[1])] = $id; } } return $map; } // ── Table builder ───────────────────────────────────────────────────────── /** * Build the full Markdown replacement for the inventory tables. * * @param list> $repos * @param array $moduleMap */ private function buildTables(array $repos, array $moduleMap, string $org): string { // Sort: active first, then archived; within each group alphabetically. usort($repos, static function (array $a, array $b): int { $aArch = (bool) ($a['archived'] ?? false); $bArch = (bool) ($b['archived'] ?? false); if ($aArch !== $bArch) { return $aArch ? 1 : -1; } return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); }); /** @var array>> $groups */ $groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []]; foreach ($repos as $repo) { $name = (string) ($repo['name'] ?? ''); $topics = array_map('strtolower', (array) ($repo['topics'] ?? [])); $archived = (bool) ($repo['archived'] ?? false); if ($archived) { $groups['archived'][] = $repo; continue; } $lower = strtolower($name); if (in_array('mokostandards-core', $topics, true) || $name === 'MokoStandards' || $name === '.github-private') { $groups['core'][] = $repo; } elseif ( in_array('dolibarr-module', $topics, true) || str_starts_with($lower, 'mokodoli') || (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme') ) { $groups['extension'][] = $repo; } elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) { $groups['product'][] = $repo; } elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) { $groups['template'][] = $repo; } else { $groups['internal'][] = $repo; } } $updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T'); $lines = [ "> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.", '> Do not edit this section manually; it is overwritten on every bulk sync.', '', ]; $groupLabels = [ 'core' => 'Core Repositories', 'product' => 'Product Repositories', 'extension' => 'Extension Repositories (Dolibarr / CRM)', 'template' => 'Template Repositories', 'internal' => 'Internal and Testing', 'archived' => 'Archived Repositories', ]; foreach ($groupLabels as $key => $label) { if (empty($groups[$key])) { continue; } $lines[] = "### {$label}"; $lines[] = ''; $isExt = ($key === 'extension'); if ($isExt) { $lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |'; $lines[] = '|------------|--------|-------------|-----------|----------|------------|'; } else { $lines[] = '| Repository | Status | Description | Language | Visibility |'; $lines[] = '|------------|--------|-------------|----------|------------|'; } foreach ($groups[$key] as $repo) { $name = (string) ($repo['name'] ?? ''); $desc = str_replace('|', '\\|', (string) ($repo['description'] ?? '')); $url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}"); $lang = (string) ($repo['language'] ?? '—'); $private = (bool) ($repo['private'] ?? false); $archived = (bool) ($repo['archived'] ?? false); $status = $archived ? '🗄 Archived' : '✅ Active'; $vis = $private ? 'Private' : 'Public'; $modId = $moduleMap[strtolower($name)] ?? null; $modCell = $modId !== null ? (string) $modId : '—'; if ($isExt) { $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |"; } else { $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |"; } } $lines[] = ''; } return implode("\n", $lines); } // ── File rewriter ───────────────────────────────────────────────────────── /** * Replace content between the start/end markers in the inventory file. * If markers are absent, appends a new section at the end. */ private function replaceSection(string $original, string $newContent): string { $startPos = strpos($original, self::MARKER_START); $endPos = strpos($original, self::MARKER_END); if ($startPos === false || $endPos === false) { $this->warning('Inventory markers not found; appending section to end of file.'); return $original . "\n\n## Active Repositories\n\n" . self::MARKER_START . "\n" . $newContent . "\n" . self::MARKER_END . "\n"; } $before = substr($original, 0, $startPos + strlen(self::MARKER_START)); $after = substr($original, $endPos); return $before . "\n" . $newContent . "\n" . $after; } } $script = new UpdateRepoInventory('update_repo_inventory', 'Updates the repository inventory documentation after sync'); exit($script->execute());