#!/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.Maintenance * INGROUP: MokoStandards * 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 * * USAGE * php maintenance/repo_inventory.php # Generate and post dashboard * php maintenance/repo_inventory.php --dry-run # Preview only * php maintenance/repo_inventory.php --json # JSON output to stdout */ declare(strict_types=1); $dryRun = in_array('--dry-run', $argv); $jsonOut = in_array('--json', $argv); $org = 'mokoconsulting-tech'; foreach ($argv as $i => $arg) { if ($arg === '--org' && isset($argv[$i + 1])) { $org = $argv[$i + 1]; } } $config = \MokoEnterprise\Config::load(); try { $_adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); $_api = $_adapter->getApiClient(); } catch (\Exception $e) { fwrite(STDERR, "Platform init failed: " . $e->getMessage() . "\n"); exit(1); } $token = $config->getString('platform', 'gitea') === 'gitea' ? $config->getString('gitea.token', '') : $config->getString('github.token', ''); $ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; /** * @return array{int, array} */ function ghApi(string $method, string $path, ?array $body, string $token): array { global $_api; try { $result = match ($method) { 'GET' => $_api->get("/{$path}"), 'POST' => $_api->post("/{$path}", $body ?? []), 'PATCH' => $_api->patch("/{$path}", $body ?? []), 'PUT' => $_api->put("/{$path}", $body ?? []), 'DELETE' => $_api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"), }; return [200, $result]; } catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; } } /** * GitHub GraphQL helper. * * @return array */ function graphql(string $query, array $variables, string $token): array { global $config; $pf = isset($config) ? $config->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 ' . $token, 'Content-Type: application/json', 'User-Agent: MokoStandards-Inventory', ], ]); $body = (string) curl_exec($ch); curl_close($ch); return json_decode($body, true)['data'] ?? []; } // ── Fetch all repos ───────────────────────────────────────────────────── if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } $allRepos = []; $page = 1; do { [$_, $batch] = ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null, $token); $allRepos = array_merge($allRepos, $batch); $page++; } while (count($batch) === 100); if (!$jsonOut) { echo "Found " . count($allRepos) . " total repositories\n\n"; } // ── Build inventory ───────────────────────────────────────────────────── $inventory = []; foreach ($allRepos as $repo) { $name = $repo['name']; if (in_array($name, $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; } // Detect platform from .mokostandards foreach (['.github/.mokostandards', '.mokostandards'] as $path) { [$status, $data] = ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null, $token); 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; } } // Check rulesets count [$status, $rulesets] = ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null, $token); if ($status === 200 && is_array($rulesets)) { $entry['rulesets'] = count($rulesets); } // Check for GitHub Project $gql = graphql( 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){projectsV2(first:1){totalCount}}}', ['owner' => $org, 'name' => $name], $token ); $entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0; $inventory[] = $entry; if (!$jsonOut) { echo " {$name}: {$entry['platform']} | v{$entry['version']} | rulesets:{$entry['rulesets']} | project:" . ($entry['has_project'] ? 'yes' : 'no') . "\n"; } } // ── JSON output ───────────────────────────────────────────────────────── if ($jsonOut) { echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n"; exit(0); } // ── Build dashboard ───────────────────────────────────────────────────── $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"; $body .= "**Organisation:** `{$org}`\n"; $body .= "**Generated:** {$now}\n"; $body .= "**Active:** {$activeN} | **Archived:** {$archivedN} | **Rulesets 3/3:** {$withRules} | **Projects:** {$withProj}\n\n"; $body .= "| Repository | Visibility | Platform | Version | Rulesets | Project | Issues |\n"; $body .= "|---|---|---|---|---|---|---|\n"; $body .= $table . "\n\n"; $body .= "---\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"; // ── Post as issue ─────────────────────────────────────────────────────── if (!$dryRun) { $title = "dashboard: repository inventory ({$org})"; [$_, $existing] = ghApi('GET', "repos/{$org}/MokoStandards/issues?labels=inventory&state=all&per_page=1&sort=created&direction=desc", null, $token); if (!empty($existing[0]['number'])) { $num = $existing[0]['number']; ghApi('PATCH', "repos/{$org}/MokoStandards/issues/{$num}", [ 'title' => $title, 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller'], ], $token); echo "Updated inventory issue #{$num}\n"; } else { [$_, $issue] = ghApi('POST', "repos/{$org}/MokoStandards/issues", [ 'title' => $title, 'body' => $body, 'labels' => ['inventory', 'type: chore', 'automation'], 'assignees' => ['jmiller'], ], $token); echo "Created inventory issue #{$issue['number']}\n"; } } else { echo "(dry-run) would post inventory dashboard issue\n"; }