diff --git a/validate/check_repo_health.php b/validate/check_repo_health.php index 134e2e1..c2e301b 100755 --- a/validate/check_repo_health.php +++ b/validate/check_repo_health.php @@ -3,841 +3,348 @@ /** * Copyright (C) 2026 Moko Consulting * - * This file is part of a Moko Consulting project. - * * SPDX-License-Identifier: GPL-3.0-or-later * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.Validate - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API + * DEFGROUP: moko-platform.Validate + * INGROUP: moko-platform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /validate/check_repo_health.php - * VERSION: 04.06.00 - * BRIEF: Repository health checker - PHP implementation; includes deployment, secrets, and variables checks + * VERSION: 05.00.00 + * BRIEF: Repository health checker — validates against current Moko standards (wiki-first, no docs/) + * + * Categories (135 total points): + * Required Files (40) — README, LICENSE, CHANGELOG, CONTRIBUTING, SECURITY, profile.ps1, .mcp.json, .gitignore + * Manifest & Config (15) — .moko-platform, workflows dir, README content, CODE_OF_CONDUCT + * Documentation (10) — wiki-first: docs/ must NOT exist + * Disallowed (10) — TODO.md, vendor/, node_modules/ + * Workflows (15) — repo-health, sync-roadmap-wiki, CI/deploy + * Security (20) — SECURITY.md, scanning, dependency mgmt, no secrets + * Rulesets (15) — main protected, dev branch, rulesets + * Deployment (10) — deploy workflow, build system + * + * Note: This file uses curl_init for HTTP requests to the Gitea API. + * No shell commands or child processes are invoked. */ declare(strict_types=1); -require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/../vendor/autoload.php'; -use MokoEnterprise\{ - AuditLogger, - CliFramework, - MetricsCollector, - PluginFactory, - ProjectTypeDetector -}; +use MokoEnterprise\{AuditLogger, CliFramework, MetricsCollector, PluginFactory}; -/** - * Repository Health Checker - * - * Performs comprehensive repository health checks - */ class RepoHealthChecker extends CliFramework { private const DEFAULT_THRESHOLD = 70.0; - - /** Repos that are not Dolibarr modules — CUSTOM_FOLDER check is skipped for these. */ - private const CUSTOM_FOLDER_EXEMPT = ['MokoStandards', '.github-private']; - private AuditLogger $logger; private MetricsCollector $metrics; private PluginFactory $pluginFactory; - private ?object $projectPlugin = null; private string $apiBaseUrl = 'https://git.mokoconsulting.tech/api/v1'; - + private array $results = [ - 'categories' => [], - 'checks' => [], - 'score' => 0, - 'max_score' => 100, - 'percentage' => 0.0, - 'level' => 'unknown', + 'categories' => [], 'checks' => [], + 'score' => 0, 'max_score' => 0, 'percentage' => 0.0, 'level' => 'unknown', ]; - + protected function configure(): void { - $this->setDescription('Check repository health and compliance'); - $this->addArgument('--path', 'Repository path to check', '.'); - $this->addArgument('--threshold', 'Minimum health threshold (%)', '70'); - $this->addArgument('--json', 'Output results as JSON', false); - $this->addArgument('--create-issue', 'Create GitHub issue with results', false); - $this->addArgument('--repo', 'Repository name (owner/repo)', ''); + $this->setDescription('Check repository health against Moko standards'); + $this->addArgument('--path', 'Repository path', '.'); + $this->addArgument('--threshold', 'Minimum health %', '70'); + $this->addArgument('--json', 'JSON output', false); + $this->addArgument('--create-issue', 'Create Gitea issue', false); + $this->addArgument('--repo', 'owner/repo', ''); } - + protected function initialize(): void { parent::initialize(); - $this->logger = new AuditLogger('repo_health_checker'); $this->metrics = new MetricsCollector(); $this->pluginFactory = new PluginFactory($this->logger, $this->metrics); - - // Resolve API base URL from platform config $config = \MokoEnterprise\Config::load(); - $platform = $config->getString('platform', 'gitea'); - $this->apiBaseUrl = $platform === 'github' - ? 'https://api.github.com' - : rtrim($config->getString('gitea.url', 'https://git.mokoconsulting.tech'), '/') . '/api/v1'; - - $this->log('Repository health checker initialized with plugin system'); + $this->apiBaseUrl = rtrim($config->getString('gitea.url', 'https://git.mokoconsulting.tech'), '/') . '/api/v1'; } - + protected function run(): int { $path = $this->getArgument('--path'); $threshold = (float)$this->getArgument('--threshold'); - $jsonOutput = $this->getArgument('--json'); - $createIssue = $this->getArgument('--create-issue'); $repo = $this->getArgument('--repo'); - - $this->log("Checking repository health: {$path}"); - - // Try to load the project plugin - $this->projectPlugin = $this->pluginFactory->createForProject($path); - - if ($this->projectPlugin) { - $pluginName = $this->projectPlugin->getPluginName(); - $projectType = $this->projectPlugin->getProjectType(); - $this->log("Using plugin: {$pluginName} for type: {$projectType}"); - - // Use plugin's health check if available - $pluginHealth = $this->projectPlugin->healthCheck($path, []); - - // Merge plugin health check results - if (!empty($pluginHealth)) { - $this->results['plugin_health'] = $pluginHealth; - $this->log("Plugin health check completed: {$pluginHealth['score']}/100"); - } - } else { - $this->log("No plugin found, using generic health checks"); - } - - // Run standard checks (backwards compatible) - $this->section('Structure'); - $this->runStructureChecks($path); - $this->section('Documentation'); - $this->runDocumentationChecks($path); - $this->section('Workflows'); - $this->runWorkflowChecks($path, $repo); - $this->section('Security'); - $this->runSecurityChecks($path, $repo); - $this->section('Rulesets'); - $this->runRulesetChecks($repo); - $this->section('Deployment'); - $this->runDeploymentChecks($path, $repo); - // Calculate scores + $this->section('Required Files'); $this->checkRequiredFiles($path); + $this->section('Manifest & Config'); $this->checkManifest($path); + $this->section('Documentation'); $this->checkDocumentation($path); + $this->section('Disallowed Items'); $this->checkDisallowed($path); + $this->section('Workflows'); $this->checkWorkflows($path); + $this->section('Security'); $this->checkSecurity($path); + $this->section('Rulesets'); $this->checkRulesets($repo); + $this->section('Deployment'); $this->checkDeployment($path); + $this->calculateScore(); - // Output results - if ($jsonOutput) { + if ($this->getArgument('--json')) { echo json_encode($this->results, JSON_PRETTY_PRINT) . PHP_EOL; } else { $this->displayResults(); } - - // Create GitHub issue if requested - if ($createIssue && !empty($repo)) { + + if ($this->getArgument('--create-issue') && $repo) { $this->createHealthIssue($repo); - } elseif ($createIssue && empty($repo)) { - $this->warn("--create-issue requires --repo parameter (format: owner/repo)"); } - - // Record metrics - $this->metrics->setGauge('repo_health_score', $this->results['percentage']); - $this->metrics->setGauge('repo_health_checks_passed', - count(array_filter($this->results['checks'], fn($c) => $c['passed']))); - - // Check threshold - if ($this->results['percentage'] < $threshold) { - $this->error(sprintf( - "Health check failed: %.1f%% < %.1f%% threshold", - $this->results['percentage'], - $threshold - )); - return 1; - } - - $this->log(sprintf( - "Health check passed: %.1f%% >= %.1f%% threshold", - $this->results['percentage'], - $threshold - )); - - return 0; + + return $this->results['percentage'] >= $threshold ? 0 : 1; } - - private function runStructureChecks(string $path): void + + // ── Required Files (40 pts) ────────────────────────────────────── + + private function checkRequiredFiles(string $p): void { - $category = 'structure'; - $this->results['categories'][$category] = [ - 'name' => 'Repository Structure', - 'max_points' => 30, - 'earned_points' => 0, - 'checks_passed' => 0, - 'checks_failed' => 0, - ]; - - // Check README exists - $this->addCheck($category, 'README.md exists', - file_exists("{$path}/README.md"), 10); - - // Check LICENSE exists - $this->addCheck($category, 'LICENSE file exists', - file_exists("{$path}/LICENSE"), 10); - - // Check .gitignore exists - $this->addCheck($category, '.gitignore exists', - file_exists("{$path}/.gitignore"), 5); - - // Check CHANGELOG exists - $this->addCheck($category, 'CHANGELOG.md exists', - file_exists("{$path}/CHANGELOG.md"), 5); - } - - private function runDocumentationChecks(string $path): void - { - $category = 'documentation'; - $this->results['categories'][$category] = [ - 'name' => 'Documentation', - 'max_points' => 25, - 'earned_points' => 0, - 'checks_passed' => 0, - 'checks_failed' => 0, - ]; - - // Check docs directory exists - $this->addCheck($category, 'docs/ directory exists', - is_dir("{$path}/docs"), 10); - - // Check README has content - if (file_exists("{$path}/README.md")) { - $content = file_get_contents("{$path}/README.md"); - $this->addCheck($category, 'README has substantial content', - strlen($content) > 500, 10); - } - - // Check for code of conduct - $this->addCheck($category, 'CODE_OF_CONDUCT.md exists', - file_exists("{$path}/CODE_OF_CONDUCT.md"), 5); - } - - private function runWorkflowChecks(string $path, string $repo = ''): void - { - $category = 'workflows'; - $this->results['categories'][$category] = [ - 'name' => 'GitHub Workflows', - 'max_points' => 20, - 'earned_points' => 0, - 'checks_passed' => 0, - 'checks_failed' => 0, - ]; + $cat = 'required_files'; + $this->initCategory($cat, 'Required Files', 40); - $githubWfDir = "{$path}/.github/workflows"; - $giteaWfDir = "{$path}/.gitea/workflows"; - $workflowDir = is_dir($giteaWfDir) ? $giteaWfDir : $githubWfDir; - - if (!empty($repo)) { - // --repo provided: use API (authoritative, avoids checking the wrong local dir) - $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN') ?: getenv('GITEA_TOKEN'); - // Try .github/workflows first, fall back to .gitea/workflows - $remoteFiles = empty($token) - ? [] - : $this->githubListNames("repos/{$repo}/contents/.github/workflows", '', $token); - if (empty($remoteFiles) && !empty($token)) { - $remoteFiles = $this->githubListNames("repos/{$repo}/contents/.gitea/workflows", '', $token); - } - - $hasWorkflowDir = !empty($remoteFiles); - $this->addCheck($category, 'Workflows directory exists', $hasWorkflowDir, 10); - - if ($hasWorkflowDir) { - $hasCI = false; - foreach ($remoteFiles as $name) { - if (preg_match('/^ci.*\.(yml|yaml)$/i', $name)) { - $hasCI = true; - break; - } - } - $this->addCheck($category, 'CI workflow exists', $hasCI, 10); - } - } elseif (is_dir($workflowDir)) { - // No --repo: check local filesystem - $this->addCheck($category, 'Workflows directory exists', true, 10); - $hasCI = glob("{$workflowDir}/ci*.yml") || glob("{$workflowDir}/ci*.yaml"); - $this->addCheck($category, 'CI workflow exists', !empty($hasCI), 10); - } else { - $this->addCheck($category, 'Workflows directory exists', false, 10); + foreach ([ + 'README.md' => 8, 'LICENSE' => 8, 'CHANGELOG.md' => 5, + 'CONTRIBUTING.md' => 5, 'SECURITY.md' => 5, + 'profile.ps1' => 4, '.mcp.json' => 3, '.gitignore' => 2, + ] as $file => $pts) { + $this->addCheck($cat, "{$file} exists", file_exists("{$p}/{$file}"), $pts); } } - - private function runSecurityChecks(string $path, string $repo = ''): void + + // ── Manifest & Config (15 pts) ─────────────────────────────────── + + private function checkManifest(string $p): void { - $category = 'security'; - $this->results['categories'][$category] = [ - 'name' => 'Security', - 'max_points' => 25, - 'earned_points' => 0, - 'checks_passed' => 0, - 'checks_failed' => 0, - ]; + $cat = 'manifest'; + $this->initCategory($cat, 'Manifest & Config', 15); - // Check for SECURITY.md - $this->addCheck($category, 'SECURITY.md exists', - file_exists("{$path}/SECURITY.md") || - file_exists("{$path}/.github/SECURITY.md"), 10); + $this->addCheck($cat, '.gitea/.moko-platform manifest', + file_exists("{$p}/.gitea/.moko-platform"), 5); + $this->addCheck($cat, 'Workflows directory', + is_dir("{$p}/.gitea/workflows") || is_dir("{$p}/.github/workflows"), 5); + $this->addCheck($cat, 'README >500 chars', + file_exists("{$p}/README.md") && strlen(file_get_contents("{$p}/README.md")) > 500, 3); + $this->addCheck($cat, 'CODE_OF_CONDUCT.md', + file_exists("{$p}/CODE_OF_CONDUCT.md"), 2); + } - // Check for security scanning workflow (CodeQL on GitHub, Trivy on Gitea) - $hasSecurityScan = false; - $ghWf = "{$path}/.github/workflows"; - $gtWf = "{$path}/.gitea/workflows"; - if (is_dir($ghWf)) { - $hasSecurityScan = !empty(glob("{$ghWf}/*codeql*.yml")) || !empty(glob("{$ghWf}/*codeql*.yaml")); + // ── Documentation: Wiki-First (15 pts) ─────────────────────────── + + private function checkDocumentation(string $p): void + { + $cat = 'documentation'; + $this->initCategory($cat, 'Documentation (Wiki-First)', 15); + + $this->addCheck($cat, 'No docs/ directory (wiki-first)', + !is_dir("{$p}/docs"), 10); + + // CHANGELOG must have [Unreleased] section for release workflow + $hasUnreleased = false; + if (file_exists("{$p}/CHANGELOG.md")) { + $cl = file_get_contents("{$p}/CHANGELOG.md"); + $hasUnreleased = (bool)preg_match('/##\s*\[?Unreleased/i', $cl); } - if (!$hasSecurityScan && is_dir($gtWf)) { - $hasSecurityScan = !empty(glob("{$gtWf}/*trivy*.yml")) || !empty(glob("{$gtWf}/*trivy*.yaml")); + $this->addCheck($cat, 'CHANGELOG has [Unreleased] section', + $hasUnreleased, 5); + } + + // ── Disallowed Items (10 pts) ──────────────────────────────────── + + private function checkDisallowed(string $p): void + { + $cat = 'disallowed'; + $this->initCategory($cat, 'Disallowed Items', 10); + + $this->addCheck($cat, 'No TODO.md (use issues)', + !file_exists("{$p}/TODO.md"), 5); + $this->addCheck($cat, 'No vendor/ committed', + !is_dir("{$p}/vendor") || file_exists("{$p}/vendor/.gitkeep"), 3); + $this->addCheck($cat, 'No node_modules/', + !is_dir("{$p}/node_modules"), 2); + } + + // ── Workflows (15 pts) ─────────────────────────────────────────── + + private function checkWorkflows(string $p): void + { + $cat = 'workflows'; + $this->initCategory($cat, 'Workflows', 15); + + $wf = is_dir("{$p}/.gitea/workflows") ? "{$p}/.gitea/workflows" : "{$p}/.github/workflows"; + $exists = is_dir($wf); + + $this->addCheck($cat, 'Workflows directory', $exists, 5); + $this->addCheck($cat, 'repo-health.yml', + $exists && file_exists("{$wf}/repo-health.yml"), 3); + $this->addCheck($cat, 'sync-roadmap-wiki.yml', + $exists && file_exists("{$wf}/sync-roadmap-wiki.yml"), 3); + $this->addCheck($cat, 'CI/deploy workflow', + $exists && (!empty(glob("{$wf}/ci*.yml")) || !empty(glob("{$wf}/deploy*.yml")) || !empty(glob("{$wf}/build*.yml"))), 4); + } + + // ── Security (20 pts) ──────────────────────────────────────────── + + private function checkSecurity(string $p): void + { + $cat = 'security'; + $this->initCategory($cat, 'Security', 20); + + $this->addCheck($cat, 'SECURITY.md', file_exists("{$p}/SECURITY.md"), 5); + + $wf = is_dir("{$p}/.gitea/workflows") ? "{$p}/.gitea/workflows" : "{$p}/.github/workflows"; + $hasScan = is_dir($wf) && (!empty(glob("{$wf}/*gitleaks*.yml")) || !empty(glob("{$wf}/*trivy*.yml")) + || !empty(glob("{$wf}/*security*.yml"))); + $this->addCheck($cat, 'Security scanning workflow', $hasScan, 5); + + $this->addCheck($cat, 'Dependency management', + file_exists("{$p}/renovate.json") || file_exists("{$p}/.renovaterc.json") + || file_exists("{$p}/.github/dependabot.yml"), 5); + + $secrets = false; + foreach (['.env', '.env.local', 'credentials.json'] as $s) { + if (file_exists("{$p}/{$s}")) { $secrets = true; break; } } - $this->addCheck($category, 'Security scanning workflow exists', - $hasSecurityScan, 10); + $this->addCheck($cat, 'No secret files committed', !$secrets, 5); + } - // Check for dependency management (Dependabot or Renovate) - $this->addCheck($category, 'Dependency management configured', - file_exists("{$path}/.github/dependabot.yml") || - file_exists("{$path}/.github/dependabot.yaml") || - file_exists("{$path}/renovate.json") || - file_exists("{$path}/.renovaterc.json"), 5); + // ── Rulesets (15 pts) ──────────────────────────────────────────── - // Branch protection — requires --repo and GitHub token - if (empty($repo)) { + private function checkRulesets(string $repo): void + { + $cat = 'rulesets'; + $this->initCategory($cat, 'Branch Rulesets', 15); + + if (!$repo) { + $this->addCheck($cat, 'Main branch protected', false, 5); + $this->addCheck($cat, 'Dev branch exists', false, 5); + $this->addCheck($cat, 'Branch rulesets configured', false, 5); return; } - $this->results['categories'][$category]['max_points'] += 10; - $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); - - if (empty($token)) { - $this->warn("Cannot check branch protection: GH_TOKEN not set"); - $this->addCheck($category, 'main branch is protected', false, 10); + $token = getenv('GH_TOKEN') ?: getenv('GITEA_TOKEN') ?: ''; + if (!$token) { + $this->addCheck($cat, 'Main branch protected', false, 5); + $this->addCheck($cat, 'Dev branch exists', false, 5); + $this->addCheck($cat, 'Branch rulesets configured', false, 5); return; } - $isProtected = $this->githubVarExists("repos/{$repo}/branches/main/protection", $token); - $this->addCheck($category, 'main branch is protected', $isProtected, 10); + // Gitea uses /branch_protections endpoint (not /branches/main/protection) + $protections = $this->apiFetch("repos/{$repo}/branch_protections", $token); + $mainProtected = false; + foreach ($protections as $bp) { + if (($bp['branch_name'] ?? '') === 'main') { $mainProtected = true; break; } + } + $this->addCheck($cat, 'Main branch protected', $mainProtected, 5); + + $this->addCheck($cat, 'Dev branch exists', + $this->apiCheck("repos/{$repo}/branches/dev", $token), 5); + + $this->addCheck($cat, 'Branch protections configured', count($protections) > 0, 5); } - - /** - * Check that the expected GitHub rulesets are applied to the repository. - * - * Expected rulesets (applied at org or repo level): - * - MAIN: protects main/master (deletion, non-fast-forward, requires PRs) - * - VERSION: immutable version/* branches - * - DEV: prevents deletion of dev/* branches - * - * @param string $repo Owner/repo format (e.g. "mokoconsulting-tech/MokoCRM") - */ - private function runRulesetChecks(string $repo): void + + // ── Deployment (10 pts) ────────────────────────────────────────── + + private function checkDeployment(string $p): void { - $category = 'rulesets'; - $this->results['categories'][$category] = [ - 'name' => 'Branch Rulesets', - 'max_points' => 20, - 'earned_points' => 0, - 'checks_passed' => 0, - 'checks_failed' => 0, + $cat = 'deployment'; + $this->initCategory($cat, 'Deployment', 10); + + $wf = is_dir("{$p}/.gitea/workflows") ? "{$p}/.gitea/workflows" : "{$p}/.github/workflows"; + $this->addCheck($cat, 'Deploy workflow', + is_dir($wf) && !empty(glob("{$wf}/deploy*.yml")), 5); + $this->addCheck($cat, 'Build system', + file_exists("{$p}/Makefile") || file_exists("{$p}/package.json") || file_exists("{$p}/composer.json"), 5); + } + + // ── Helpers ────────────────────────────────────────────────────── + + private function initCategory(string $key, string $name, int $maxPts): void + { + $this->results['categories'][$key] = [ + 'name' => $name, 'max_points' => $maxPts, + 'earned_points' => 0, 'checks_passed' => 0, 'checks_failed' => 0, ]; - - if (empty($repo)) { - $this->log("Skipping ruleset checks (no --repo provided)"); - $this->addCheck($category, 'Main branch ruleset', false, 5); - $this->addCheck($category, 'Version branch ruleset', false, 5); - $this->addCheck($category, 'Dev branch ruleset', false, 5); - $this->addCheck($category, 'RC branch ruleset', false, 5); - return; - } - - $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); - if (empty($token)) { - $this->warn("Cannot check rulesets: GH_TOKEN not set"); - $this->addCheck($category, 'Main branch ruleset', false, 5); - $this->addCheck($category, 'Version branch ruleset', false, 5); - $this->addCheck($category, 'Dev branch ruleset', false, 5); - $this->addCheck($category, 'RC branch ruleset', false, 5); - return; - } - - // Fetch all rulesets visible to this repository (includes org-level) - $rulesets = $this->fetchRulesets($repo, $token); - - $hasMain = false; - $hasVersion = false; - $hasDev = false; - $hasRc = false; - - foreach ($rulesets as $rs) { - $name = strtolower($rs['name'] ?? ''); - $refs = $this->extractRulesetRefs($rs); - - // MAIN: any ruleset targeting main or master - if (str_contains($name, 'main') || str_contains($name, 'protect main') - || $this->refsInclude($refs, ['refs/heads/main', 'refs/heads/master'])) { - $hasMain = true; - } - - // VERSION: any ruleset targeting version/* branches - if (str_contains($name, 'version') - || $this->refsInclude($refs, ['refs/heads/version/*', 'refs/heads/version/**'])) { - $hasVersion = true; - } - - // DEV: any ruleset targeting dev/* branches - if ((str_contains($name, 'dev') && !str_contains($name, 'develop')) - || $this->refsInclude($refs, ['refs/heads/dev/*', 'refs/heads/dev/**'])) { - $hasDev = true; - } - - // RC: any ruleset targeting rc/* branches - if (str_contains($name, 'rc') - || $this->refsInclude($refs, ['refs/heads/rc/*', 'refs/heads/rc/**'])) { - $hasRc = true; - } - } - - $this->addCheck($category, 'Main branch ruleset', $hasMain, 5); - $this->addCheck($category, 'Version branch ruleset', $hasVersion, 5); - $this->addCheck($category, 'Dev branch ruleset', $hasDev, 5); - $this->addCheck($category, 'RC branch ruleset', $hasRc, 5); } - /** - * Fetch rulesets for a repository (includes org-inherited rulesets). - * - * @return array> - */ - private function fetchRulesets(string $repo, string $token): array + private function addCheck(string $cat, string $name, bool $passed, int $pts): void { - $url = "{$this->apiBaseUrl}/repos/{$repo}/rulesets?per_page=100&includes_parents=true"; - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'Authorization: token ' . $token, - 'User-Agent: MokoStandards-HealthCheck', - 'Accept: application/vnd.github.v3+json', - ], - ]); - $body = (string) curl_exec($ch); - $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($status !== 200) { - $this->warn("Could not fetch rulesets for {$repo} (HTTP {$status})"); - return []; - } - - return json_decode($body, true) ?? []; - } - - /** - * Extract ref include patterns from a ruleset's conditions. - * - * @return string[] - */ - private function extractRulesetRefs(array $ruleset): array - { - return $ruleset['conditions']['ref_name']['include'] ?? []; - } - - /** - * Check if any of the expected ref patterns are present in the ruleset refs. - * - * @param string[] $rulesetRefs - * @param string[] $expected - */ - private function refsInclude(array $rulesetRefs, array $expected): bool - { - foreach ($expected as $pattern) { - foreach ($rulesetRefs as $ref) { - if ($ref === $pattern || fnmatch($pattern, $ref) || fnmatch($ref, $pattern)) { - return true; - } - } - } - return false; - } - - private function runDeploymentChecks(string $path, string $repo): void - { - $category = 'deployment'; - $this->results['categories'][$category] = [ - 'name' => 'Dev Deployment', - 'max_points' => 5, - 'earned_points' => 0, - 'checks_passed' => 0, - 'checks_failed' => 0, - ]; - - // 1. Workflow file exists — filesystem check, always runs - // Check both workflow directories for deploy-dev.yml - $workflowFile = file_exists("{$path}/.gitea/workflows/deploy-dev.yml") - ? "{$path}/.gitea/workflows/deploy-dev.yml" - : "{$path}/.github/workflows/deploy-dev.yml"; - $this->addCheck( - $category, - 'deploy-dev.yml workflow exists', - file_exists($workflowFile), - 5 - ); - - // 2. Secrets & variables — require --repo for GitHub API - if (empty($repo)) { - $this->log("Skipping deployment secrets/variables checks (no --repo provided)"); - return; - } - - // Expand max_points now that we can run API checks. - // CUSTOM_FOLDER (2 pts) is not applicable to MokoStandards or .github-private. - [, $repoName] = array_pad(explode('/', $repo, 2), 2, ''); - $checkCustomFolder = !in_array($repoName, self::CUSTOM_FOLDER_EXEMPT, true); - $this->results['categories'][$category]['max_points'] += $checkCustomFolder ? 12 : 10; - - $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); - if (empty($token)) { - $this->warn("Cannot check deployment secrets/variables: GH_TOKEN not set"); - $this->addCheck($category, 'DEV_FTP_HOST variable configured', false, 3); - $this->addCheck($category, 'DEV_FTP_PATH variable configured', false, 3); - $this->addCheck($category, 'DEV_FTP_USERNAME variable configured', false, 2); - $this->addCheck($category, 'SFTP credentials configured (DEV_FTP_KEY or DEV_FTP_PASSWORD)', false, 2); - if ($checkCustomFolder) { - $this->addCheck($category, 'CUSTOM_FOLDER variable configured', false, 2); - } - return; - } - - [$org] = explode('/', $repo, 2); - - // Use the repo variables list — returns all vars visible to the repo (org-granted - // included) with just repo scope, avoiding the admin:org requirement of direct lookups. - $repoVars = $this->githubListNames("repos/{$repo}/actions/variables", 'variables', $token); - - $this->addCheck($category, 'DEV_FTP_HOST variable configured', - in_array('DEV_FTP_HOST', $repoVars, true), 3); - - $this->addCheck($category, 'DEV_FTP_PATH variable configured', - in_array('DEV_FTP_PATH', $repoVars, true), 3); - - $this->addCheck($category, 'DEV_FTP_USERNAME variable configured', - in_array('DEV_FTP_USERNAME', $repoVars, true), 2); - - // SFTP credentials — at least DEV_FTP_KEY or DEV_FTP_PASSWORD must exist. - // Use the list endpoint (repo scope sufficient) which returns all secrets visible - // to the repo, including org-level secrets that have been granted to it. - $repoSecrets = $this->githubListNames("repos/{$repo}/actions/secrets", 'secrets', $token); - $hasKey = in_array('DEV_FTP_KEY', $repoSecrets, true); - $hasPassword = in_array('DEV_FTP_PASSWORD', $repoSecrets, true); - $this->addCheck( - $category, - 'SFTP credentials configured (DEV_FTP_KEY or DEV_FTP_PASSWORD)', - $hasKey || $hasPassword, - 2 - ); - - // CUSTOM_FOLDER — repo-level variable; required for publish-to-mokodolimods workflow. - // Not applicable to MokoStandards or .github-private (no Dolibarr module to publish). - if ($checkCustomFolder) { - $this->addCheck( - $category, - 'CUSTOM_FOLDER variable configured', - $this->githubVarExists("repos/{$repo}/actions/variables/CUSTOM_FOLDER", $token), - 2 - ); - } - } - - /** - * Returns true when the GitHub API responds 200 for the given resource path. - * Used to check for the existence of org/repo variables and secrets by name. - * - * @param string $resourcePath e.g. "orgs/myorg/actions/variables/MY_VAR" - * @param string $token GitHub personal access token - */ - private function githubVarExists(string $resourcePath, string $token): bool - { - $url = "{$this->apiBaseUrl}/{$resourcePath}"; - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'Authorization: token ' . $token, - 'User-Agent: MokoStandards-HealthCheck', - 'Accept: application/vnd.github.v3+json', - ], - ]); - curl_exec($ch); - $error = curl_error($ch); - $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if (!empty($error)) { - $this->warn("curl error checking {$resourcePath}: {$error}"); - } - return $status === 200; - } - - - /** - * Fetch all names from a paginated GitHub list endpoint. - * Works with repo scope — avoids admin:org requirement of direct-lookup endpoints. - * - * @param string $resourcePath e.g. "repos/org/repo/actions/secrets" - * @param string $itemsKey JSON key that holds the array, e.g. "secrets" or "variables" - * @param string $token - * @return string[] - */ - private function githubListNames(string $resourcePath, string $itemsKey, string $token): array - { - $names = []; - $page = 1; - $perPage = 100; - - do { - $url = "{$this->apiBaseUrl}/{$resourcePath}?per_page={$perPage}&page={$page}"; - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'Authorization: token ' . $token, - 'User-Agent: MokoStandards-HealthCheck', - 'Accept: application/vnd.github.v3+json', - ], - ]); - $body = (string) curl_exec($ch); - $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($status !== 200) { - break; - } - - $data = json_decode($body, true) ?? []; - // Contents API returns a top-level array; secrets/variables APIs use a keyed object. - $items = ($itemsKey === '') ? $data : ($data[$itemsKey] ?? []); - foreach ($items as $item) { - if (isset($item['name'])) { - $names[] = $item['name']; - } - } - - $page++; - } while (count($items) === $perPage); - - return $names; - } - - private function addCheck(string $category, string $name, bool $passed, int $points): void - { - $this->results['checks'][] = [ - 'category' => $category, - 'name' => $name, - 'passed' => $passed, - 'points' => $points, - ]; - + $this->results['checks'][] = ['category' => $cat, 'name' => $name, 'passed' => $passed, 'points' => $pts]; if ($passed) { - $this->results['categories'][$category]['earned_points'] += $points; - $this->results['categories'][$category]['checks_passed']++; + $this->results['categories'][$cat]['earned_points'] += $pts; + $this->results['categories'][$cat]['checks_passed']++; } else { - $this->results['categories'][$category]['checks_failed']++; + $this->results['categories'][$cat]['checks_failed']++; } } - + private function calculateScore(): void { - $totalEarned = 0; - $maxScore = 0; - - foreach ($this->results['categories'] as $category) { - $totalEarned += $category['earned_points']; - $maxScore += $category['max_points']; - } - - $this->results['score'] = $totalEarned; - $this->results['max_score'] = $maxScore; - $this->results['percentage'] = $maxScore > 0 ? ($totalEarned / $maxScore * 100) : 0; - - // Determine level + $earned = $max = 0; + foreach ($this->results['categories'] as $c) { $earned += $c['earned_points']; $max += $c['max_points']; } + $this->results['score'] = $earned; + $this->results['max_score'] = $max; + $this->results['percentage'] = $max > 0 ? ($earned / $max * 100) : 0; $pct = $this->results['percentage']; - if ($pct >= 90) { - $this->results['level'] = 'excellent'; - } elseif ($pct >= 80) { - $this->results['level'] = 'good'; - } elseif ($pct >= 70) { - $this->results['level'] = 'fair'; - } elseif ($pct >= 60) { - $this->results['level'] = 'poor'; - } else { - $this->results['level'] = 'critical'; - } + $this->results['level'] = match (true) { + $pct >= 90 => 'excellent', $pct >= 80 => 'good', + $pct >= 70 => 'fair', $pct >= 60 => 'poor', default => 'critical', + }; } - + private function displayResults(): void { $this->section('Results'); - - foreach ($this->results['checks'] as $check) { - $this->status($check['passed'], $check['name'], $check['passed'] ? '' : "{$check['points']} pts lost"); + foreach ($this->results['checks'] as $c) { + $this->status($c['passed'], $c['name'], $c['passed'] ? '' : "{$c['points']} pts"); } - - $passedChecks = count(array_filter($this->results['checks'], fn($c) => $c['passed'])); - $failedChecks = count(array_filter($this->results['checks'], fn($c) => !$c['passed'])); - - $this->printSummary( - $passedChecks, - $failedChecks, - $this->elapsed() - ); - - $this->log(sprintf( - "Overall Score: %d/%d (%.1f%%) — Level: %s", - $this->results['score'], - $this->results['max_score'], - $this->results['percentage'], - strtoupper($this->results['level']) - )); + $p = count(array_filter($this->results['checks'], fn($c) => $c['passed'])); + $f = count(array_filter($this->results['checks'], fn($c) => !$c['passed'])); + $this->printSummary($p, $f, $this->elapsed()); + $this->log(sprintf("Score: %d/%d (%.1f%%) — %s", + $this->results['score'], $this->results['max_score'], + $this->results['percentage'], strtoupper($this->results['level']))); } - + + private function apiCheck(string $path, string $token): bool + { + $ch = curl_init("{$this->apiBaseUrl}/{$path}"); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'User-Agent: moko-platform']]); + curl_exec($ch); $s = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + return $s === 200; + } + + private function apiFetch(string $path, string $token): array + { + $ch = curl_init("{$this->apiBaseUrl}/{$path}"); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'User-Agent: moko-platform']]); + $body = (string)curl_exec($ch); $s = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + return $s === 200 ? (json_decode($body, true) ?? []) : []; + } + private function createHealthIssue(string $repo): void { - $this->log("Creating or updating health check issue for {$repo}"); - - $body = $this->generateIssueBody(); - $pct = round($this->results['percentage'], 1); - $labels = ['health-check', 'type: chore', 'automation']; - - if ($pct >= 90) { - $title = "✅ Repository Health Check: Excellent ({$pct}%)"; - } elseif ($pct >= 70) { - $title = "⚠️ Repository Health Check: Good ({$pct}%)"; - } elseif ($pct >= 50) { - $title = "🟡 Repository Health Check: Fair ({$pct}%)"; - } else { - $title = "❌ Repository Health Check: Critical ({$pct}%)"; + $pct = round($this->results['percentage'], 1); + $title = "Repository Health: {$this->results['level']} ({$pct}%)"; + $body = "## Health Check\n\nScore: {$this->results['score']}/{$this->results['max_score']} ({$pct}%)\n\n"; + $body .= "| Category | Score |\n|---|---|\n"; + foreach ($this->results['categories'] as $c) { + $body .= "| {$c['name']} | {$c['earned_points']}/{$c['max_points']} |\n"; } - - try { - // Search for an existing health-check issue (any state) to avoid duplicates - $existing = $this->apiClient->get("/repos/{$repo}/issues", [ - 'labels' => 'health-check', - 'state' => 'all', - 'per_page' => 1, - 'sort' => 'updated', - 'direction' => 'desc', - ]); - - if (!empty($existing[0]['number'])) { - $num = (int) $existing[0]['number']; - $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; - if (($existing[0]['state'] ?? 'open') === 'closed') { - $patch['state'] = 'open'; - } - $this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch); - try { - $this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]); - } catch (\Exception $le) { /* non-fatal */ } - $this->log("✅ Updated health issue #{$num} in {$repo}"); - } else { - $issue = $this->apiClient->post("/repos/{$repo}/issues", [ - 'title' => $title, - 'body' => $body, - 'labels' => $labels, - 'assignees' => ['jmiller'], - ]); - $issueNumber = $issue['number'] ?? 'unknown'; - $this->log("✅ Created health issue #{$issueNumber} in {$repo}"); - } - } catch (\Exception $e) { - $this->error("Failed to create/update health issue: " . $e->getMessage()); + $failed = array_filter($this->results['checks'], fn($c) => !$c['passed']); + if ($failed) { + $body .= "\n### Failed\n"; + foreach ($failed as $c) $body .= "- {$c['name']} ({$c['points']}pts)\n"; } - } - - private function generateIssueBody(): string - { - $body = "## Repository Health Check Results\n\n"; - $body .= "**Generated**: " . date('Y-m-d H:i:s T') . "\n"; - $body .= "**Overall Score**: {$this->results['score']}/{$this->results['max_score']} points ({$this->results['percentage']}%)\n"; - $body .= "**Health Level**: " . strtoupper($this->results['level']) . "\n\n"; - - // Category breakdown - $body .= "### Category Breakdown\n\n"; - $body .= "| Category | Score | Percentage | Passed | Failed |\n"; - $body .= "|----------|-------|------------|--------|--------|\n"; - - foreach ($this->results['categories'] as $category) { - $pct = $category['max_points'] > 0 - ? ($category['earned_points'] / $category['max_points'] * 100) - : 0; - - $body .= sprintf( - "| %s | %d/%d | %.1f%% | %d | %d |\n", - $category['name'], - $category['earned_points'], - $category['max_points'], - $pct, - $category['checks_passed'], - $category['checks_failed'] - ); - } - - // Failed checks details - $failedChecks = array_filter($this->results['checks'], fn($c) => !$c['passed']); - if (!empty($failedChecks)) { - $body .= "\n### ❌ Failed Checks\n\n"; - - $byCategory = []; - foreach ($failedChecks as $check) { - $cat = $this->results['categories'][$check['category']]['name']; - if (!isset($byCategory[$cat])) { - $byCategory[$cat] = []; - } - $byCategory[$cat][] = $check; - } - - foreach ($byCategory as $catName => $checks) { - $body .= "**{$catName}**\n"; - foreach ($checks as $check) { - $body .= "- ❌ {$check['name']} ({$check['points']} points)\n"; - } - $body .= "\n"; - } - } else { - $body .= "\n### ✅ All Checks Passed!\n\n"; - $body .= "This repository has passed all health checks. Excellent work! 🎉\n\n"; - } - - // Recommendations - if (!empty($failedChecks)) { - $body .= "### 📋 Recommendations\n\n"; - $body .= "To improve repository health:\n\n"; - foreach ($failedChecks as $check) { - $body .= "1. **{$check['name']}**: Address this check to gain {$check['points']} points\n"; - } - $body .= "\n"; - } - - // Health thresholds - $body .= "### 📊 Health Thresholds\n\n"; - $body .= "- ✅ **Excellent**: ≥90%\n"; - $body .= "- ⚠️ **Good**: 70-89%\n"; - $body .= "- 🟡 **Fair**: 50-69%\n"; - $body .= "- ❌ **Critical**: <50%\n\n"; - - $body .= "---\n"; - $body .= "*This issue was automatically created by the MokoStandards repository health checker.*\n"; - $body .= "*To customize health checks, edit `.github/override.tf` in your repository.*\n"; - - return $body; + $token = getenv('GH_TOKEN') ?: getenv('GITEA_TOKEN') ?: ''; + if (!$token) return; + $ch = curl_init("{$this->apiBaseUrl}/repos/{$repo}/issues"); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['title' => $title, 'body' => $body]), + CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'Content-Type: application/json', 'User-Agent: moko-platform']]); + curl_exec($ch); curl_close($ch); } } -// Run the application $app = new RepoHealthChecker(); exit($app->execute($argv));