Files
MokoCLI/cli/workflow_sync.php
T
git-actions[bot] 99d55b63af
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 24s
chore(version): auto-bump patch 09.45.01-dev [skip ci]
2026-07-17 00:08:48 +00:00

1055 lines
35 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/workflow_sync.php
* VERSION: 09.45.01
* BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class WorkflowSyncCli extends CliFramework
{
private const PLATFORM_TEMPLATES = [
'joomla' => 'Template-Joomla',
'dolibarr' => 'Template-Dolibarr',
'go' => 'Template-Go',
'npm' => 'Template-NPM',
'mcp' => 'Template-NPM', // legacy alias: mcp was consolidated into npm; repo renamed Template-MCP → Template-NPM
'platform' => 'Template-Generic',
'generic' => 'Template-Generic',
'dot' => 'Template-Dot', // dot/infra repos (.vault, .mokogit, .mokogit-private): lean CI, no build/release
];
private const DEFAULT_TEMPLATE = 'Template-Generic';
private const GENERIC_TEMPLATE = 'Template-Generic';
/**
* Workflows to exclude per platform during sync.
* Key = platform name (matching PLATFORM_TEMPLATES keys), Value = array of workflow filenames to skip.
*/
private const PLATFORM_EXCLUDES = [
'joomla' => ['deploy-manual.yml'],
// dot/infra repos are config, not buildable code: drop build/test, release-cascade,
// and the code-project standards/health checks. Leaves the lean set: gitleaks,
// pr-check, branch-cleanup, auto-bump, version-set, notify, cleanup, ci-issue-reporter.
'dot' => [
'ci-generic.yml',
'auto-release.yml',
'cascade-dev.yml',
'pre-release.yml',
'rc-revert.yml',
'repo-health.yml',
'standards-compliance.yml',
],
];
/** Prefix for custom workflows preserved during orphan cleanup. */
private const CUSTOM_PREFIX = 'custom-';
/** Default ntfy endpoint for critical drift alerts (matches notify.yml / gitleaks.yml). */
private const DEFAULT_NTFY_URL = 'https://ntfy.mokoconsulting.tech';
private const DEFAULT_NTFY_TOPIC = 'mokogit-ops';
/** Marker in auto-filed health issues, used for idempotency. */
private const HEALTH_ISSUE_MARKER = '[workflow-health]';
private int $updated = 0;
private int $created = 0;
private int $skipped = 0;
private int $deleted = 0;
private int $errors = 0;
protected function configure(): void
{
$this->setDescription('Sync workflows from Generic → platform templates → live repos based on manifest.platform');
$this->addArgument('--git-url', 'Git URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Git API token', '');
$this->addArgument('--org', 'Target organization', '');
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
$this->addArgument('--phase', 'Phase to run: all, templates, repos, health (default: all)', 'all');
$this->addArgument('--platform-filter', 'Only sync repos matching this platform', '');
$this->addArgument('--repo-filter', 'Only sync/check these repos (comma-separated names)', '');
$this->addArgument('--delete-orphans', 'Delete workflows not in template (preserves custom-* and custom/)', false);
// Health-check (--phase health) options — read-only unless an alert flag is set.
$this->addArgument('--alert-ntfy', 'On drift, send a critical ntfy push notification', false);
$this->addArgument('--ntfy-url', 'ntfy server URL', self::DEFAULT_NTFY_URL);
$this->addArgument('--ntfy-topic', 'ntfy topic', self::DEFAULT_NTFY_TOPIC);
$this->addArgument('--alert-issue', 'On drift, open/update a Git issue on each affected repo', false);
}
protected function run(): int
{
$gitUrl = rtrim($this->getArgument('--git-url'), '/');
$token = $this->getArgument('--token');
$org = $this->getArgument('--org');
$branch = $this->getArgument('--branch');
$phase = $this->getArgument('--phase');
$platformFilter = $this->getArgument('--platform-filter');
if ($token === '') {
$this->log('ERROR', '--token is required.');
return 1;
}
if ($org === '') {
$this->log('ERROR', '--org is required.');
return 1;
}
if (!in_array($phase, ['all', 'templates', 'repos', 'health'], true)) {
$this->log('ERROR', "--phase must be one of: all, templates, repos, health (got: {$phase})");
return 1;
}
// Health-check is a standalone, read-only phase — it never syncs.
if ($phase === 'health') {
return $this->runHealthCheck($gitUrl, $token, $org, $branch, $platformFilter);
}
$this->log('INFO', "Workflow Sync — org: {$org}, branch: {$branch}, phase: {$phase}");
if ($platformFilter !== '') {
$this->log('INFO', "Platform filter: {$platformFilter}");
}
if ($this->dryRun) {
$this->log('INFO', '[DRY RUN] No changes will be made.');
}
echo "\n";
// Phase 1: Sync Generic → Platform Templates
if ($phase === 'all' || $phase === 'templates') {
$result = $this->syncGenericToTemplates($gitUrl, $token, $org, $branch, $platformFilter);
if ($result !== 0) {
return $result;
}
}
// Phase 2: Sync Platform Templates → Live Repos
if ($phase === 'all' || $phase === 'repos') {
$result = $this->syncTemplatesToRepos($gitUrl, $token, $org, $branch, $platformFilter);
if ($result !== 0) {
return $result;
}
}
echo "\n";
$this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, "
. "{$this->deleted} deleted, {$this->skipped} skipped, {$this->errors} error(s).");
return $this->errors > 0 ? 1 : 0;
}
/**
* Phase 1: Push all Generic workflows to each platform template repo.
* Skips platform-specific overrides (files that exist in the platform template but NOT in Generic).
*/
private function syncGenericToTemplates(
string $gitUrl,
string $token,
string $org,
string $branch,
string $platformFilter
): int {
$this->log('INFO', '=== Phase 1: Sync Generic → Platform Templates ===');
echo "\n";
// Get all workflow files from Template-Generic
$genericWorkflows = $this->listWorkflows($gitUrl, $token, $org, self::GENERIC_TEMPLATE, $branch);
if ($genericWorkflows === null) {
$this->log('ERROR', 'Could not list workflows from ' . self::GENERIC_TEMPLATE);
return 1;
}
if (count($genericWorkflows) === 0) {
$this->log('WARN', 'No workflows found in ' . self::GENERIC_TEMPLATE);
return 0;
}
$this->log('INFO', 'Found ' . count($genericWorkflows) . ' workflow(s) in ' . self::GENERIC_TEMPLATE);
echo "\n";
// Get unique platform templates (exclude Generic itself)
$platformTemplates = array_unique(array_filter(
array_values(self::PLATFORM_TEMPLATES),
fn(string $t) => $t !== self::GENERIC_TEMPLATE
));
// If platform-filter is set, only sync to the matching template
if ($platformFilter !== '') {
$targetTemplate = self::PLATFORM_TEMPLATES[$platformFilter] ?? null;
if ($targetTemplate === null || $targetTemplate === self::GENERIC_TEMPLATE) {
$this->log('INFO', "Platform filter '{$platformFilter}' does not map to a non-generic template, skipping Phase 1.");
return 0;
}
$platformTemplates = [$targetTemplate];
}
fprintf(STDERR, "%-45s | %s\n", 'Template / File', 'Status');
fprintf(STDERR, "%s\n", str_repeat('-', 70));
foreach ($platformTemplates as $templateRepo) {
foreach ($genericWorkflows as $workflow) {
$filename = $workflow['name'];
// Skip platform-excluded workflows
$templatePlatform = array_search($templateRepo, self::PLATFORM_TEMPLATES, true);
if ($templatePlatform !== false && in_array($filename, self::PLATFORM_EXCLUDES[$templatePlatform] ?? [], true)) {
fprintf(STDERR, "%-45s | %s\n", "{$templateRepo}/{$filename}", 'EXCLUDED (platform)');
$this->skipped++;
continue;
}
$destPath = '.mokogit/workflows/' . $filename;
$label = "{$templateRepo}/{$filename}";
// Get file content from Generic
$sourceContent = $this->getFileContent(
$gitUrl, $token, $org,
self::GENERIC_TEMPLATE, $destPath, $branch
);
if ($sourceContent === null) {
fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (read source)');
$this->errors++;
continue;
}
$commitMsg = "chore: sync {$filename} from " . self::GENERIC_TEMPLATE . " [skip ci]";
$this->pushFile(
$gitUrl, $token, $org, $templateRepo,
$destPath, $sourceContent, $branch, $commitMsg, $label
);
}
}
echo "\n";
return 0;
}
/**
* Phase 2: Sync platform template workflows to live repos based on manifest.platform.
*/
private function syncTemplatesToRepos(
string $gitUrl,
string $token,
string $org,
string $branch,
string $platformFilter
): int {
$this->log('INFO', '=== Phase 2: Sync Platform Templates → Live Repos ===');
echo "\n";
$repoFilter = $this->parseRepoFilter();
if ($repoFilter !== []) {
$this->log('INFO', 'Repo filter: ' . implode(', ', $repoFilter));
}
$repos = $this->fetchOrgRepos($gitUrl, $token, $org);
if ($repos === null) {
return 1;
}
$this->log('INFO', 'Found ' . count($repos) . " repo(s) in \"{$org}\".");
echo "\n";
fprintf(STDERR, "%-45s | %s\n", 'Repo / File', 'Status');
fprintf(STDERR, "%s\n", str_repeat('-', 70));
// Cache template workflows to avoid repeated API calls
$templateWorkflowCache = [];
foreach ($repos as $repoFullName) {
[, $repoName] = explode('/', $repoFullName, 2);
// Skip template repos
if (str_starts_with($repoName, 'Template-')) {
continue;
}
// Apply repo filter
if ($repoFilter !== [] && !in_array($repoName, $repoFilter, true)) {
continue;
}
// Read manifest.platform
$platform = $this->getRepoPlatform($gitUrl, $token, $org, $repoName, $branch);
// Apply platform filter
if ($platformFilter !== '' && $platform !== $platformFilter) {
continue;
}
// Resolve template
$templateRepo = self::PLATFORM_TEMPLATES[$platform] ?? self::DEFAULT_TEMPLATE;
// Get workflows from the template (cached)
if (!isset($templateWorkflowCache[$templateRepo])) {
$workflows = $this->listWorkflows($gitUrl, $token, $org, $templateRepo, $branch);
if ($workflows === null) {
$this->log('WARN', "Could not list workflows from {$templateRepo}, falling back to " . self::GENERIC_TEMPLATE);
$workflows = $this->listWorkflows($gitUrl, $token, $org, self::GENERIC_TEMPLATE, $branch);
}
$templateWorkflowCache[$templateRepo] = $workflows ?? [];
}
$workflows = $templateWorkflowCache[$templateRepo];
if (count($workflows) === 0) {
continue;
}
foreach ($workflows as $workflow) {
$filename = $workflow['name'];
$destPath = '.mokogit/workflows/' . $filename;
$label = "{$repoFullName}/{$filename}";
// Skip platform-excluded workflows
if (in_array($filename, self::PLATFORM_EXCLUDES[$platform] ?? [], true)) {
fprintf(STDERR, "%-45s | %s\n", $label, 'EXCLUDED (platform)');
$this->skipped++;
continue;
}
// Get source content from template
$sourceContent = $this->getFileContent(
$gitUrl, $token, $org,
$templateRepo, $destPath, $branch
);
if ($sourceContent === null) {
fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (read source)');
$this->errors++;
continue;
}
$commitMsg = "chore: sync {$filename} from {$templateRepo} [skip ci]";
$this->pushFile(
$gitUrl, $token, $org, $repoName,
$destPath, $sourceContent, $branch, $commitMsg, $label
);
}
// Delete orphan workflows if enabled
if ($this->getArgument('--delete-orphans', false)) {
$templateNames = array_map(fn($w) => $w['name'], $workflows);
$this->deleteOrphanWorkflows(
$gitUrl, $token, $org, $repoName, $branch, $templateNames, $platform
);
}
}
echo "\n";
return 0;
}
/**
* Push a file to a repo — create or update, skip if identical.
*/
private function pushFile(
string $gitUrl,
string $token,
string $org,
string $repoName,
string $destPath,
string $localContent,
string $branch,
string $commitMsg,
string $label
): void {
$existing = $this->apiRequest(
$gitUrl,
$token,
'GET',
"/api/v1/repos/{$org}/{$repoName}/contents/"
. "{$destPath}?ref={$branch}"
);
$encodedContent = base64_encode($localContent);
if ($existing['code'] === 200) {
$data = json_decode($existing['body'], true);
$remoteSha = $data['sha'] ?? '';
$remoteContent = base64_decode($data['content'] ?? '');
if ($remoteContent === $localContent) {
fprintf(STDERR, "%-45s | %s\n", $label, 'IDENTICAL (skipped)');
$this->skipped++;
return;
}
if ($this->dryRun) {
fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD UPDATE');
$this->updated++;
return;
}
$payload = json_encode([
'content' => $encodedContent,
'sha' => $remoteSha,
'message' => $commitMsg,
'branch' => $branch,
]);
$response = $this->apiRequest(
$gitUrl,
$token,
'PUT',
"/api/v1/repos/{$org}/{$repoName}/contents/" . $destPath,
$payload
);
if ($response['code'] === 200) {
fprintf(STDERR, "%-45s | %s\n", $label, 'UPDATED');
$this->updated++;
} else {
fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$response['code']})");
$this->errors++;
}
} elseif ($existing['code'] === 404) {
if ($this->dryRun) {
fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD CREATE');
$this->created++;
return;
}
$payload = json_encode([
'content' => $encodedContent,
'message' => $commitMsg,
'branch' => $branch,
]);
$response = $this->apiRequest(
$gitUrl,
$token,
'POST',
"/api/v1/repos/{$org}/{$repoName}/contents/" . $destPath,
$payload
);
if ($response['code'] === 201) {
fprintf(STDERR, "%-45s | %s\n", $label, 'CREATED');
$this->created++;
} else {
fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$response['code']})");
$this->errors++;
}
} else {
fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$existing['code']})");
$this->errors++;
}
}
/**
* Delete workflows in a repo that are NOT in the template and NOT custom.
*
* Protected from deletion:
* - Files matching template workflow names
* - Files with `custom-` prefix (convention for repo-specific workflows)
* - Directories named `custom` (future: subfolder discovery)
* - Platform-excluded workflows
*/
private function deleteOrphanWorkflows(
string $gitUrl,
string $token,
string $org,
string $repoName,
string $branch,
array $templateNames,
string $platform
): void {
$repoWorkflows = $this->listWorkflows($gitUrl, $token, $org, $repoName, $branch);
if ($repoWorkflows === null) {
return;
}
$platformExcludes = self::PLATFORM_EXCLUDES[$platform] ?? [];
foreach ($repoWorkflows as $workflow) {
$name = $workflow['name'];
// Keep if it's in the template
if (in_array($name, $templateNames, true)) {
continue;
}
// Keep if it has the custom- prefix
if (str_starts_with($name, self::CUSTOM_PREFIX)) {
$label = "{$org}/{$repoName}/{$name}";
fprintf(STDERR, "%-45s | %s\n", $label, 'KEPT (custom)');
continue;
}
// Keep if it's platform-excluded (legitimately skipped during sync)
if (in_array($name, $platformExcludes, true)) {
$label = "{$org}/{$repoName}/{$name}";
fprintf(STDERR, "%-45s | %s\n", $label, 'KEPT (platform-excluded)');
continue;
}
// Delete orphan
$filePath = '.mokogit/workflows/' . $name;
$label = "{$org}/{$repoName}/{$name}";
if ($this->dryRun) {
fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD DELETE');
$this->deleted++;
continue;
}
$deleted = $this->deleteFile($gitUrl, $token, $org, $repoName, $filePath, $branch);
if ($deleted) {
fprintf(STDERR, "%-45s | %s\n", $label, 'DELETED');
$this->deleted++;
} else {
fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (delete)');
$this->errors++;
}
}
}
/**
* Delete a file from a repo via the Git Contents API.
*/
private function deleteFile(
string $gitUrl,
string $token,
string $org,
string $repoName,
string $filePath,
string $branch
): bool {
// Get SHA first
$existing = $this->apiRequest(
$gitUrl, $token, 'GET',
"/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}?ref={$branch}"
);
if ($existing['code'] !== 200) {
return false;
}
$data = json_decode($existing['body'], true);
$sha = $data['sha'] ?? '';
if ($sha === '') {
return false;
}
$payload = json_encode([
'sha' => $sha,
'message' => "chore: delete orphan workflow {$filePath} [skip ci]",
'branch' => $branch,
]);
$response = $this->apiRequest(
$gitUrl, $token, 'DELETE',
"/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}",
$payload
);
return $response['code'] === 200;
}
/**
* List workflow files in a repo's .mokogit/workflows/ directory.
*/
private function listWorkflows(
string $gitUrl,
string $token,
string $org,
string $repoName,
string $branch
): ?array {
$response = $this->apiRequest(
$gitUrl,
$token,
'GET',
"/api/v1/repos/{$org}/{$repoName}/contents/.mokogit/workflows?ref={$branch}"
);
if ($response['code'] !== 200) {
return null;
}
$data = json_decode($response['body'], true);
if (!is_array($data)) {
return null;
}
// Filter to only files (not directories)
return array_values(array_filter($data, fn($item) => ($item['type'] ?? '') === 'file'));
}
/**
* Get file content from a repo as a raw string.
*/
private function getFileContent(
string $gitUrl,
string $token,
string $org,
string $repoName,
string $filePath,
string $branch
): ?string {
$response = $this->apiRequest(
$gitUrl,
$token,
'GET',
"/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}?ref={$branch}"
);
if ($response['code'] !== 200) {
return null;
}
$data = json_decode($response['body'], true);
if (!is_array($data) || !isset($data['content'])) {
return null;
}
return base64_decode($data['content']);
}
/**
* Read a repo's platform from the MokoGIT metadata API.
* Returns 'generic' if metadata is missing or has no platform field.
* (Replaces the retired .mokogit/manifest.xml lookup — platform now lives
* in the repo Metadata endpoint: GET /api/v1/repos/{owner}/{repo}/metadata.)
*/
private function getRepoPlatform(
string $gitUrl,
string $token,
string $org,
string $repoName,
string $branch
): string {
$response = $this->apiRequest(
$gitUrl,
$token,
'GET',
"/api/v1/repos/{$org}/{$repoName}/metadata"
);
if ($response['code'] !== 200) {
return 'generic';
}
$data = json_decode($response['body'], true);
if (!is_array($data) || empty($data['platform'])) {
return 'generic';
}
return strtolower(trim((string) $data['platform']));
}
/**
* Fetch all non-archived repos in an org (paginated).
*/
private function fetchOrgRepos(string $gitUrl, string $token, string $org): ?array
{
$this->log('INFO', "Fetching repos from org: {$org}");
$page = 1;
$repos = [];
while (true) {
$response = $this->apiRequest(
$gitUrl,
$token,
'GET',
"/api/v1/orgs/{$org}/repos?"
. "limit=50&page={$page}"
);
if ($response['code'] < 200 || $response['code'] >= 300) {
if ($page === 1) {
$this->log('ERROR', "Could not fetch repos "
. "(HTTP {$response['code']}).");
return null;
}
break;
}
$data = json_decode($response['body'], true);
if (!is_array($data) || count($data) === 0) {
break;
}
foreach ($data as $repo) {
if (!empty($repo['archived'])) {
continue;
}
$fullName = $repo['full_name'] ?? '';
if ($fullName !== '') {
$repos[] = $fullName;
}
}
$page++;
}
return $repos;
}
// =========================================================================
// Phase: health — read-only drift detection + alerting
// =========================================================================
/**
* Parse --repo-filter into a list of repo names (empty ⇒ no filter).
*
* @return string[]
*/
private function parseRepoFilter(): array
{
$raw = (string) $this->getArgument('--repo-filter');
return array_values(array_filter(array_map('trim', explode(',', $raw)), fn($n) => $n !== ''));
}
/**
* Health-check phase: verify every live repo still has the synced workflows its
* platform template provides, and (optionally) alert on drift. Fails loudly
* (non-zero exit) when a repo is missing its canonical set, so a scheduled job,
* an ntfy push, and/or a Git issue surface silent loss instead of it rotting.
*/
private function runHealthCheck(
string $gitUrl,
string $token,
string $org,
string $branch,
string $platformFilter
): int {
$alertNtfy = (bool) $this->getArgument('--alert-ntfy');
$alertIssue = (bool) $this->getArgument('--alert-issue');
$ntfyUrl = rtrim((string) $this->getArgument('--ntfy-url'), '/');
$ntfyTopic = (string) $this->getArgument('--ntfy-topic');
$repoFilter = $this->parseRepoFilter();
$this->log('INFO', "Workflow Health-Check — org: {$org}, branch: {$branch}");
if ($platformFilter !== '') {
$this->log('INFO', "Platform filter: {$platformFilter}");
}
if ($repoFilter !== []) {
$this->log('INFO', 'Repo filter: ' . implode(', ', $repoFilter));
}
echo "\n";
$repos = $this->fetchOrgRepos($gitUrl, $token, $org);
if ($repos === null) {
return self::EXIT_FAILURE;
}
$this->log('INFO', 'Scanning ' . count($repos) . " repo(s) in \"{$org}\".");
echo "\n";
$templateCache = [];
$unhealthy = [];
$checked = 0;
foreach ($repos as $repoFullName) {
[, $repoName] = explode('/', $repoFullName, 2);
if (str_starts_with($repoName, 'Template-')) {
continue;
}
$platform = $this->getRepoPlatform($gitUrl, $token, $org, $repoName, $branch);
if ($platformFilter !== '' && $platform !== $platformFilter) {
continue;
}
if ($repoFilter !== [] && !in_array($repoName, $repoFilter, true)) {
continue;
}
// A repo that has no such branch is out of scope for this run, not unhealthy.
if (!$this->branchExists($gitUrl, $token, $org, $repoName, $branch)) {
continue;
}
$templateRepo = self::PLATFORM_TEMPLATES[$platform] ?? self::DEFAULT_TEMPLATE;
// Expected set = the template's workflows (cached), minus platform excludes.
// Do NOT fall back to Generic on an unreachable template — that assesses against
// the wrong baseline. Record null (missing) distinctly.
if (!array_key_exists($templateRepo, $templateCache)) {
$tpl = $this->listWorkflows($gitUrl, $token, $org, $templateRepo, $branch);
$templateCache[$templateRepo] = $tpl === null
? null
: array_map(fn($w) => $w['name'], $tpl);
}
if ($templateCache[$templateRepo] === null) {
$this->log('WARN', "Template '{$templateRepo}' for platform '{$platform}' is unreachable"
. " — cannot assess {$repoName}.");
continue;
}
$expected = array_values(array_diff(
$templateCache[$templateRepo],
self::PLATFORM_EXCLUDES[$platform] ?? []
));
if (count($expected) === 0) {
continue;
}
$repoList = $this->listWorkflows($gitUrl, $token, $org, $repoName, $branch);
$actual = array_map(fn($w) => $w['name'], $repoList ?? []);
$missing = $this->evaluateRepoHealth($expected, $actual);
$checked++;
if (count($missing) > 0) {
$unhealthy[$repoName] = [
'full' => $repoFullName,
'platform' => $platform,
'expected' => count($expected),
'present' => count(array_intersect($expected, $actual)),
'missing' => $missing,
];
}
}
echo "\n";
if (count($unhealthy) === 0) {
$this->success("All {$checked} in-scope repo(s) have their synced workflows on '{$branch}'.");
return self::EXIT_SUCCESS;
}
$rows = [];
foreach ($unhealthy as $repo => $info) {
$rows[] = [$repo, $info['platform'], "{$info['present']}/{$info['expected']}", (string) count($info['missing'])];
}
$this->log('ERROR', count($unhealthy) . " of {$checked} repo(s) are MISSING synced workflows on '{$branch}':");
$this->table(['Repo', 'Platform', 'Present', 'Missing'], $rows);
if ($alertNtfy) {
$this->sendNtfyAlert($ntfyUrl, $ntfyTopic, $org, $branch, $unhealthy);
}
if ($alertIssue) {
foreach ($unhealthy as $repo => $info) {
$this->openHealthIssue($gitUrl, $token, $org, $repo, $branch, $info);
}
}
return self::EXIT_FAILURE;
}
/**
* Health verdict policy — given the workflows a repo SHOULD have (its platform
* template's set, minus excludes) and what it ACTUALLY has, return the missing
* filenames. Non-empty ⇒ unhealthy. Extra/custom files are ignored.
*
* @param string[] $expected
* @param string[] $actual
* @return string[]
*/
private function evaluateRepoHealth(array $expected, array $actual): array
{
return array_values(array_diff($expected, $actual));
}
/**
* Return true when the given branch exists in the repo.
*/
private function branchExists(
string $gitUrl,
string $token,
string $org,
string $repoName,
string $branch
): bool {
$response = $this->apiRequest($gitUrl, $token, 'GET', "/api/v1/repos/{$org}/{$repoName}/branches/{$branch}");
return $response['code'] === 200;
}
/**
* Send a critical ntfy push notification about drift (matches notify.yml headers).
*/
private function sendNtfyAlert(
string $ntfyUrl,
string $topic,
string $org,
string $branch,
array $unhealthy
): void {
if ($ntfyUrl === '' || $topic === '') {
$this->warn('ntfy alert requested but --ntfy-url/--ntfy-topic is empty; skipping.');
return;
}
$count = count($unhealthy);
$names = implode(', ', array_keys($unhealthy));
$title = "Workflow drift: {$count} repo(s) missing workflows";
$body = "Org {$org} ({$branch}): {$count} repo(s) missing synced .mokogit/workflows/.\n{$names}";
if ($this->dryRun) {
$this->log('INFO', "[DRY RUN] Would send critical ntfy to {$ntfyUrl}/{$topic}: {$title}");
return;
}
$ch = curl_init("{$ntfyUrl}/{$topic}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Title: {$title}",
'Priority: urgent',
'Tags: rotating_light,warning',
]);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300) {
$this->success("Sent critical ntfy alert to {$ntfyUrl}/{$topic}.");
} else {
$this->warn("ntfy alert failed (HTTP {$code}).");
}
}
/**
* Open a Git issue on an affected repo describing the missing workflows.
* Idempotent: skips if an open issue carrying the marker already exists.
*/
private function openHealthIssue(
string $gitUrl,
string $token,
string $org,
string $repoName,
string $branch,
array $info
): void {
$existing = $this->apiRequest(
$gitUrl, $token, 'GET',
"/api/v1/repos/{$org}/{$repoName}/issues?state=open&type=issues&limit=50"
);
if ($existing['code'] === 200) {
$issues = json_decode($existing['body'], true) ?: [];
foreach ($issues as $iss) {
if (str_contains((string) ($iss['title'] ?? ''), self::HEALTH_ISSUE_MARKER)) {
$this->log('INFO', "{$repoName}: health issue already open (#{$iss['number']}), skipping.");
return;
}
}
}
$missingList = implode("\n", array_map(fn($f) => "- `{$f}`", $info['missing']));
$title = self::HEALTH_ISSUE_MARKER . " Missing synced workflows on {$branch}";
$body = 'Automated health-check found this repository is missing '
. count($info['missing']) . " workflow(s) that its platform template "
. "(`{$info['platform']}`) provides in `.mokogit/workflows/` on `{$branch}`:\n\n"
. $missingList
. "\n\nRestore with:\n\n```\nphp bin/moko workflow:sync --org {$org} --branch {$branch} --token \$TOKEN\n```"
. "\n\n<sub>Filed by workflow_sync.php --phase health.</sub>";
if ($this->dryRun) {
$this->log('INFO', "[DRY RUN] Would open health issue on {$org}/{$repoName}.");
return;
}
$payload = json_encode(['title' => $title, 'body' => $body]);
$resp = $this->apiRequest($gitUrl, $token, 'POST', "/api/v1/repos/{$org}/{$repoName}/issues", $payload);
if ($resp['code'] === 201) {
$data = json_decode($resp['body'], true);
$this->success("Opened health issue {$org}/{$repoName}#" . ($data['number'] ?? '?') . '.');
} else {
$this->warn("Failed to open issue on {$org}/{$repoName} (HTTP {$resp['code']}).");
}
}
/**
* Make an HTTP request to the Git API.
*/
private function apiRequest(
string $gitUrl,
string $token,
string $method,
string $endpoint,
?string $body = null
): array {
$url = $gitUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$token}",
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo(
$ch,
CURLINFO_HTTP_CODE
);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return [
'code' => 0,
'body' => "cURL error: {$error}",
];
}
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
}
$app = new WorkflowSyncCli();
exit($app->execute());