chore: cascade main → dev (f8c28f0) [skip ci] #166

Merged
jmiller merged 2 commits from main into dev 2026-05-26 19:28:01 +00:00
2 changed files with 407 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/wiki_sync.php
* VERSION: 01.00.00
* BRIEF: Sync select wiki pages from moko-platform to all template repos
*/
declare(strict_types=1);
final class WikiSync
{
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private string $org = 'MokoConsulting';
private string $sourceRepo = 'moko-platform';
private array $targetRepos = [];
private array $pages = [];
private bool $dryRun = false;
private bool $allTemplates = false;
private int $synced = 0;
private int $created = 0;
private int $skipped = 0;
private int $errors = 0;
public function run(): int
{
$this->parseArgs();
if ($this->token === '') {
$this->log('ERROR: --token is required.');
$this->printUsage();
return 1;
}
if (empty($this->pages) && !$this->allTemplates) {
$this->log('ERROR: --page or --all-standards is required.');
$this->printUsage();
return 1;
}
// Discover template repos if --all-templates
if ($this->allTemplates || empty($this->targetRepos)) {
$this->targetRepos = $this->discoverTemplateRepos();
}
if (empty($this->targetRepos)) {
$this->log('No target repos found.');
return 0;
}
// If --all-standards, get all pages that start with uppercase
if (empty($this->pages)) {
$this->pages = $this->getStandardsPages();
}
$this->log("Syncing " . count($this->pages) . " page(s) to " . count($this->targetRepos) . " repo(s)");
if ($this->dryRun) {
$this->log("[DRY RUN] No changes will be made.\n");
}
foreach ($this->pages as $pageName) {
$this->log("\n--- Page: {$pageName} ---");
$sourceContent = $this->getWikiPage($this->sourceRepo, $pageName);
if ($sourceContent === null) {
$this->log(" WARNING: page not found in {$this->sourceRepo}");
$this->errors++;
continue;
}
foreach ($this->targetRepos as $repo) {
$existing = $this->getWikiPage($repo, $pageName);
if ($existing !== null && $existing === $sourceContent) {
$this->log(" {$repo}: IDENTICAL (skipped)");
$this->skipped++;
continue;
}
if ($this->dryRun) {
$action = $existing !== null ? 'WOULD UPDATE' : 'WOULD CREATE';
$this->log(" {$repo}: {$action}");
continue;
}
if ($existing !== null) {
$ok = $this->updateWikiPage($repo, $pageName, $sourceContent);
$this->log(" {$repo}: " . ($ok ? 'UPDATED' : 'ERROR'));
$ok ? $this->synced++ : $this->errors++;
} else {
$ok = $this->createWikiPage($repo, $pageName, $sourceContent);
$this->log(" {$repo}: " . ($ok ? 'CREATED' : 'ERROR'));
$ok ? $this->created++ : $this->errors++;
}
}
}
$this->log("\nDone: {$this->synced} updated, {$this->created} created, {$this->skipped} skipped, {$this->errors} error(s)");
return $this->errors > 0 ? 1 : 0;
}
private function discoverTemplateRepos(): array
{
$repos = $this->apiGet("/orgs/{$this->org}/repos?limit=100");
$templates = [];
foreach ($repos as $repo) {
if (str_starts_with($repo['name'], 'Template-') && !($repo['archived'] ?? false)) {
$templates[] = $repo['name'];
}
}
sort($templates);
$this->log("Found template repos: " . implode(', ', $templates));
return $templates;
}
private function getStandardsPages(): array
{
$pages = $this->apiGet("/repos/{$this->org}/{$this->sourceRepo}/wiki/pages");
$standards = [];
foreach ($pages as $page) {
$title = $page['title'] ?? '';
// Sync pages that are all-caps with underscores (standards pages)
if (preg_match('/^[A-Z][A-Z0-9_-]+$/', $title)) {
$standards[] = $title;
}
}
sort($standards);
$this->log("Found " . count($standards) . " standards pages: " . implode(', ', $standards));
return $standards;
}
private function getWikiPage(string $repo, string $pageName): ?string
{
$data = $this->apiGet("/repos/{$this->org}/{$repo}/wiki/page/{$pageName}");
if ($data === null || !isset($data['content_base64'])) {
return null;
}
return base64_decode($data['content_base64']);
}
private function createWikiPage(string $repo, string $pageName, string $content): bool
{
$payload = json_encode([
'title' => $pageName,
'content_base64' => base64_encode($content),
]);
return $this->apiPost("/repos/{$this->org}/{$repo}/wiki/new", $payload) !== null;
}
private function updateWikiPage(string $repo, string $pageName, string $content): bool
{
$payload = json_encode([
'title' => $pageName,
'content_base64' => base64_encode($content),
]);
return $this->apiPatch("/repos/{$this->org}/{$repo}/wiki/page/{$pageName}", $payload) !== null;
}
private function apiGet(string $endpoint): ?array
{
$url = "{$this->giteaUrl}/api/v1{$endpoint}";
$opts = [
'http' => [
'method' => 'GET',
'header' => "Authorization: token {$this->token}\r\nAccept: application/json\r\n",
'ignore_errors' => true,
],
];
$ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx);
if ($result === false) return null;
$data = json_decode($result, true);
return is_array($data) ? $data : null;
}
private function apiPost(string $endpoint, string $payload): ?array
{
return $this->apiWrite('POST', $endpoint, $payload);
}
private function apiPatch(string $endpoint, string $payload): ?array
{
return $this->apiWrite('PATCH', $endpoint, $payload);
}
private function apiWrite(string $method, string $endpoint, string $payload): ?array
{
$url = "{$this->giteaUrl}/api/v1{$endpoint}";
$opts = [
'http' => [
'method' => $method,
'header' => "Authorization: token {$this->token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
'content' => $payload,
'ignore_errors' => true,
],
];
$ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx);
if ($result === false) return null;
$data = json_decode($result, true);
return is_array($data) ? $data : null;
}
private function parseArgs(): void
{
global $argv;
$args = $argv;
for ($i = 1; $i < count($args); $i++) {
switch ($args[$i]) {
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--source':
$this->sourceRepo = $args[++$i] ?? '';
break;
case '--target':
$this->targetRepos[] = $args[++$i] ?? '';
break;
case '--page':
$this->pages[] = $args[++$i] ?? '';
break;
case '--all-standards':
$this->pages = []; // will be populated from source wiki
$this->allTemplates = true;
break;
case '--all-templates':
$this->allTemplates = true;
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log('Usage: wiki_sync.php --token <token> [options]');
$this->log('');
$this->log('Sync wiki pages from moko-platform to template repos.');
$this->log('');
$this->log('Options:');
$this->log(' --token <token> Gitea API token (required)');
$this->log(' --org <org> Organization (default: MokoConsulting)');
$this->log(' --source <repo> Source repo (default: moko-platform)');
$this->log(' --target <repo> Target repo (can repeat; default: all Template-* repos)');
$this->log(' --page <name> Page to sync (can repeat)');
$this->log(' --all-standards Sync all UPPERCASE standards pages');
$this->log(' --all-templates Target all Template-* repos');
$this->log(' --dry-run Show what would be done');
$this->log(' --help, -h Show this help');
$this->log('');
$this->log('Examples:');
$this->log(' php wiki_sync.php --token xxx --page MANIFEST_STANDARD --all-templates');
$this->log(' php wiki_sync.php --token xxx --all-standards --all-templates --dry-run');
$this->log(' php wiki_sync.php --token xxx --page WORKFLOW_STANDARDS --target Template-Joomla');
}
private function log(string $msg): void
{
fwrite(STDERR, $msg . "\n");
}
}
(new WikiSync())->run();
+125
View File
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
MokoStandards Manifest Schema v1.0
Defines the structure of .mokogitea/manifest.xml
Validate: xmllint - -schema definitions/manifest-schema.xsd .mokogitea/manifest.xml
-->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:moko="https://standards.mokoconsulting.tech/moko-platform/1.0"
targetNamespace="https://standards.mokoconsulting.tech/moko-platform/1.0"
elementFormDefault="qualified">
<!-- Root element -->
<xs:element name="moko-platform">
<xs:complexType>
<xs:sequence>
<xs:element name="identity" type="moko:identityType"/>
<xs:element name="governance" type="moko:governanceType"/>
<xs:element name="build" type="moko:buildType"/>
<xs:element name="deploy" type="moko:deployType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="schema-version" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
<!-- Identity block -->
<xs:complexType name="identityType">
<xs:sequence>
<xs:element name="name" type="xs:string"/>
<xs:element name="org" type="xs:string"/>
<xs:element name="description" type="xs:string"/>
<xs:element name="version" type="moko:versionType"/>
<xs:element name="license" type="moko:licenseType"/>
</xs:sequence>
</xs:complexType>
<!-- Version format: XX.YY.ZZ -->
<xs:simpleType name="versionType">
<xs:restriction base="xs:string">
<xs:pattern value="\d{2}\.\d{2}\.\d{2}"/>
</xs:restriction>
</xs:simpleType>
<!-- License with SPDX attribute -->
<xs:complexType name="licenseType">
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="spdx" type="xs:string" use="required"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
<!-- Governance block -->
<xs:complexType name="governanceType">
<xs:sequence>
<xs:element name="platform" type="moko:platformType"/>
<xs:element name="standards-version" type="moko:versionType"/>
<xs:element name="standards-source" type="xs:anyURI"/>
<xs:element name="last-synced" type="xs:dateTime" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<!-- Allowed platform values -->
<xs:simpleType name="platformType">
<xs:restriction base="xs:string">
<xs:enumeration value="joomla"/>
<xs:enumeration value="dolibarr"/>
<xs:enumeration value="go"/>
<xs:enumeration value="node"/>
<xs:enumeration value="rust"/>
<xs:enumeration value="python"/>
<xs:enumeration value="generic"/>
</xs:restriction>
</xs:simpleType>
<!-- Build block -->
<xs:complexType name="buildType">
<xs:sequence>
<xs:element name="language" type="moko:languageType"/>
<xs:element name="package-type" type="moko:packageType"/>
<xs:element name="entry-point" type="xs:string"/>
</xs:sequence>
</xs:complexType>
<!-- Allowed languages -->
<xs:simpleType name="languageType">
<xs:restriction base="xs:string">
<xs:enumeration value="PHP"/>
<xs:enumeration value="Go"/>
<xs:enumeration value="JavaScript"/>
<xs:enumeration value="TypeScript"/>
<xs:enumeration value="Rust"/>
<xs:enumeration value="Python"/>
<xs:enumeration value="HCL"/>
<xs:enumeration value="Shell"/>
</xs:restriction>
</xs:simpleType>
<!-- Allowed package types -->
<xs:simpleType name="packageType">
<xs:restriction base="xs:string">
<xs:enumeration value="joomla-extension"/>
<xs:enumeration value="dolibarr"/>
<xs:enumeration value="application"/>
<xs:enumeration value="library"/>
<xs:enumeration value="mcp-server"/>
<xs:enumeration value="generic"/>
</xs:restriction>
</xs:simpleType>
<!-- Deploy block (optional) -->
<xs:complexType name="deployType">
<xs:sequence>
<xs:element name="source-dir" type="xs:string" minOccurs="0"/>
<xs:element name="remote-subdir" type="xs:string" minOccurs="0"/>
<xs:element name="excludes" type="xs:string" minOccurs="0"/>
<xs:element name="dev-host" type="xs:string" minOccurs="0"/>
<xs:element name="demo-host" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
</xs:schema>