#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoPlatform.Maintenance * INGROUP: MokoPlatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /maintenance/repo_inventory.php * BRIEF: Generate a live inventory dashboard of all governed repos as a GitHub issue */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class RepoInventoryCli extends CliFramework { private $api = null; private string $token = ''; private $platformConfig = null; private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; protected function configure(): void { $this->setDescription('Generate a live inventory dashboard of all governed repos'); $this->addArgument('--org', 'Organization', 'mokoconsulting-tech'); $this->addArgument('--json', 'JSON output to stdout', false); } protected function initialize(): void { $this->platformConfig = \MokoEnterprise\Config::load(); try { $adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig); $this->api = $adapter->getApiClient(); } catch (\Exception $e) { $this->log('ERROR', "Platform init failed: " . $e->getMessage()); exit(1); } $this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea' ? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', ''); } protected function run(): int { $org = $this->getArgument('--org'); $jsonOut = (bool) $this->getArgument('--json'); if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } $allRepos = []; $page = 1; do { [$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null); $allRepos = array_merge($allRepos, $batch); $page++; } while (count($batch) === 100); if (!$jsonOut) { echo "Found " . count($allRepos) . " total repositories\n\n"; } $inventory = []; foreach ($allRepos as $repo) { $name = $repo['name']; if (in_array($name, self::ALWAYS_EXCLUDE, true)) { continue; } $entry = [ 'name' => $name, 'visibility' => $repo['private'] ? 'private' : 'public', 'archived' => $repo['archived'] ?? false, 'platform' => '-', 'version' => '-', 'last_push' => $repo['pushed_at'] ?? '-', 'open_issues' => $repo['open_issues_count'] ?? 0, 'has_project' => false, 'rulesets' => 0, ]; if ($entry['archived']) { $inventory[] = $entry; continue; } foreach (['.github/.mokostandards', '.mokostandards'] as $path) { [$status, $data] = $this->ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null); if ($status === 200 && !empty($data['content'])) { $content = base64_decode($data['content']); if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { $entry['platform'] = trim($m[1], " \t\n\r\"'"); } if (preg_match('/^version:\s*(.+)/m', $content, $m)) { $entry['version'] = trim($m[1], " \t\n\r\"'"); } break; } } [$status, $rulesets] = $this->ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null); if ($status === 200 && is_array($rulesets)) { $entry['rulesets'] = count($rulesets); } $gql = $this->graphql( 'query($owner:String!,$name:String!)' . '{repository(owner:$owner,name:$name)' . '{projectsV2(first:1){totalCount}}}', ['owner' => $org, 'name' => $name] ); $entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0; $inventory[] = $entry; if (!$jsonOut) { $proj = $entry['has_project'] ? 'yes' : 'no'; echo " {$name}: {$entry['platform']}" . " | v{$entry['version']}" . " | rulesets:{$entry['rulesets']}" . " | project:{$proj}\n"; } } if ($jsonOut) { echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n"; return 0; } $now = gmdate('Y-m-d H:i:s') . ' UTC'; $active = array_filter($inventory, fn($r) => !$r['archived']); $archived = array_filter($inventory, fn($r) => $r['archived']); $withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3)); $withProj = count(array_filter($active, fn($r) => $r['has_project'])); $activeN = count($active); $archivedN = count($archived); $rows = []; foreach ($inventory as $r) { $vis = $r['visibility'] === 'private' ? 'prv' : 'pub'; $arch = $r['archived'] ? ' archived' : ''; $proj = $r['has_project'] ? 'yes' : '-'; $rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3"); $rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |"; } $table = implode("\n", $rows); $body = "## Repository Inventory Dashboard\n\n" . "**Organisation:** `{$org}`\n" . "**Generated:** {$now}\n" . "**Active:** {$activeN} | **Archived:** {$archivedN}" . " | **Rulesets 3/3:** {$withRules}" . " | **Projects:** {$withProj}\n\n" . "| Repository | Visibility | Platform" . " | Version | Rulesets | Project | Issues |\n" . "|---|---|---|---|---|---|---|\n" . "{$table}\n\n---\n" . "*Auto-generated by `repo_inventory.php`*\n"; echo "\n" . str_repeat('-', 50) . "\n"; echo "Active: {$activeN} | Archived: {$archivedN}" . " | Rulesets 3/3: {$withRules}" . " | Projects: {$withProj}\n"; if (!$this->dryRun) { $title = "dashboard: repository inventory ({$org})"; $issueQuery = "repos/{$org}/moko-platform/issues" . "?labels=inventory&state=all&per_page=1" . "&sort=created&direction=desc"; [$_, $existing] = $this->ghApi('GET', $issueQuery, null); if (!empty($existing[0]['number'])) { $num = $existing[0]['number']; $this->ghApi( 'PATCH', "repos/{$org}/moko-platform/issues/{$num}", [ 'title' => $title, 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller'], ] ); echo "Updated inventory issue #{$num}\n"; } else { [$_, $issue] = $this->ghApi( 'POST', "repos/{$org}/moko-platform/issues", [ 'title' => $title, 'body' => $body, 'labels' => ['inventory', 'type: chore', 'automation'], 'assignees' => ['jmiller'], ] ); echo "Created inventory issue #{$issue['number']}\n"; } } else { echo "(dry-run) would post inventory dashboard issue\n"; } return 0; } private function ghApi(string $method, string $path, ?array $body): array { try { $result = match ($method) { 'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []), 'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []), 'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported: {$method}"), }; return [200, $result]; } catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; } } private function graphql(string $query, array $variables): array { $pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea'; if ($pf !== 'github') { return []; } $payload = json_encode(['query' => $query, 'variables' => $variables]); $ch = curl_init('https://api.github.com/graphql'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => [ 'Authorization: bearer ' . $this->token, 'Content-Type: application/json', 'User-Agent: moko-platform-Inventory', ], ]); $body = (string) curl_exec($ch); curl_close($ch); return json_decode($body, true)['data'] ?? []; } } $app = new RepoInventoryCli(); exit($app->execute());